From 1f778ab9753c500bec1d9ff0a2f9a1b8973d0ec5 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 17 Jun 2026 15:58:38 +0900 Subject: [PATCH 1/9] =?UTF-8?q?[feat]=20negodata/backend:=20=EC=83=81?= =?UTF-8?q?=ED=92=88=C2=B7=ED=98=91=EB=A0=A5=EC=82=AC=C2=B7=EA=B2=AC?= =?UTF-8?q?=EC=A0=81=20=EB=8F=84=EB=A9=94=EC=9D=B8=20CRUD=20+=20delivery?= =?UTF-8?q?=5Ftype=20=EC=BD=94=EB=93=9C=ED=99=94=20+=20=EA=B3=B5=EC=9A=A9?= =?UTF-8?q?=20/v1/enums?= 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 From 2470183e0f11124e9bc6a14c793be7c3608ee614 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 17 Jun 2026 15:58:57 +0900 Subject: [PATCH 2/9] =?UTF-8?q?[fix]=20negodata/front:=20=EC=83=81?= =?UTF-8?q?=ED=92=88=C2=B7=ED=98=91=EB=A0=A5=EC=82=AC=C2=B7=EA=B2=AC?= =?UTF-8?q?=EC=A0=81=20=EB=AA=A9=EB=A1=9D=20=EC=97=B0=EB=8F=99=20+=20?= =?UTF-8?q?=EB=B0=B0=EC=86=A1=ED=98=95=ED=83=9C=20=EC=84=9C=EB=B2=84=20enu?= =?UTF-8?q?m=20=EC=BD=94=EB=93=9C=20=EC=A0=84=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - orval 실제 타입(ItemData/SupplierData/QuotationData/QuotationSettingData) 사용, 응답 봉투 과중첩(.data.data) 제거 → 목록 정상 렌더 - 배송형태(delivery_type) 자유텍스트 → 서버 /v1/enums 코드 select 로 전환 - orval 클라이언트 재생성 (enums 훅, companyData 등) Co-Authored-By: Claude Opus 4.8 (1M context) --- negodata/front/src/api/generated/auth/auth.ts | 104 +++----- .../src/api/generated/default/default.ts | 21 +- .../front/src/api/generated/enums/enums.ts | 145 +++++++++++ negodata/front/src/api/generated/item/item.ts | 200 ++++++--------- .../model/{companyBrief.ts => companyData.ts} | 2 +- .../src/api/generated/model/enumOption.ts | 12 + .../front/src/api/generated/model/index.ts | 6 +- .../generated/model/itemDataDeliveryType.ts | 2 +- .../generated/model/itemDataQuantityUnit.ts | 2 +- .../model/reqCreateItemDeliveryType.ts | 2 +- .../model/reqCreateItemQuantityUnit.ts | 2 +- .../model/reqUpdateItemDeliveryType.ts | 2 +- .../model/reqUpdateItemQuantityUnit.ts | 2 +- .../front/src/api/generated/model/resEnums.ts | 15 ++ .../src/api/generated/model/resEnumsEnums.ts | 9 + .../src/api/generated/model/resEnumsMsg.ts | 8 + .../src/api/generated/model/resMeCompany.ts | 4 +- .../quotation-setting/quotation-setting.ts | 96 +++---- .../src/api/generated/quotation/quotation.ts | 236 ++++++------------ .../src/api/generated/supplier/supplier.ts | 143 ++++------- .../partners/components/ExcelUploadModal.tsx | 2 +- .../partners/components/PartnerFormSheet.tsx | 4 +- .../features/partners/hooks/usePartners.ts | 18 +- negodata/front/src/features/partners/types.ts | 6 +- .../products/components/ExcelUploadModal.tsx | 4 +- .../products/components/ProductFormSheet.tsx | 37 ++- .../features/products/hooks/useProducts.ts | 18 +- negodata/front/src/features/products/types.ts | 6 +- .../quotations/hooks/useQuotations.ts | 16 +- .../front/src/features/quotations/types.ts | 32 +-- 30 files changed, 564 insertions(+), 592 deletions(-) create mode 100644 negodata/front/src/api/generated/enums/enums.ts rename negodata/front/src/api/generated/model/{companyBrief.ts => companyData.ts} (83%) create mode 100644 negodata/front/src/api/generated/model/enumOption.ts create mode 100644 negodata/front/src/api/generated/model/resEnums.ts create mode 100644 negodata/front/src/api/generated/model/resEnumsEnums.ts create mode 100644 negodata/front/src/api/generated/model/resEnumsMsg.ts diff --git a/negodata/front/src/api/generated/auth/auth.ts b/negodata/front/src/api/generated/auth/auth.ts index 5422cfe..17948a4 100644 --- a/negodata/front/src/api/generated/auth/auth.ts +++ b/negodata/front/src/api/generated/auth/auth.ts @@ -32,27 +32,18 @@ import type { import { customFetch } from "../../mutator/custom-fetch"; -type SecondParameter unknown> = Parameters[1]; - /** * id/pw 로 로그인하고 JWT 토큰을 발급한다. * @summary 로그인 */ -export const login = ( - reqLogin: ReqLogin, - options?: SecondParameter, - signal?: AbortSignal, -) => { - return customFetch( - { - url: `/v1/auth/login`, - method: "POST", - headers: { "Content-Type": "application/json" }, - data: reqLogin, - signal, - }, - options, - ); +export const login = (reqLogin: ReqLogin, signal?: AbortSignal) => { + return customFetch({ + url: `/v1/auth/login`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqLogin, + signal, + }); }; export const getLoginMutationOptions = < @@ -65,7 +56,6 @@ export const getLoginMutationOptions = < { data: ReqLogin }, TContext >; - request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, @@ -73,13 +63,13 @@ export const getLoginMutationOptions = < TContext > => { const mutationKey = ["login"]; - const { mutation: mutationOptions, request: requestOptions } = options + const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; + : { mutation: { mutationKey } }; const mutationFn: MutationFunction< Awaited>, @@ -87,7 +77,7 @@ export const getLoginMutationOptions = < > = (props) => { const { data } = props ?? {}; - return login(data, requestOptions); + return login(data); }; return { mutationFn, ...mutationOptions }; @@ -113,7 +103,6 @@ export const useLogin = < { data: ReqLogin }, TContext >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseMutationResult< @@ -127,24 +116,20 @@ export const useLogin = < return useMutation(mutationOptions, queryClient); }; /** - * company_id 하위로 유저를 생성한다(시드/관리자용). + * 새 계정을 생성한다. * @summary 계정 생성 */ export const createAccount = ( reqCreateAccount: ReqCreateAccount, - options?: SecondParameter, signal?: AbortSignal, ) => { - return customFetch( - { - url: `/v1/auth/create`, - method: "POST", - headers: { "Content-Type": "application/json" }, - data: reqCreateAccount, - signal, - }, - options, - ); + return customFetch({ + url: `/v1/auth/create`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqCreateAccount, + signal, + }); }; export const getCreateAccountMutationOptions = < @@ -157,7 +142,6 @@ export const getCreateAccountMutationOptions = < { data: ReqCreateAccount }, TContext >; - request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, @@ -165,13 +149,13 @@ export const getCreateAccountMutationOptions = < TContext > => { const mutationKey = ["createAccount"]; - const { mutation: mutationOptions, request: requestOptions } = options + const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; + : { mutation: { mutationKey } }; const mutationFn: MutationFunction< Awaited>, @@ -179,7 +163,7 @@ export const getCreateAccountMutationOptions = < > = (props) => { const { data } = props ?? {}; - return createAccount(data, requestOptions); + return createAccount(data); }; return { mutationFn, ...mutationOptions }; @@ -205,7 +189,6 @@ export const useCreateAccount = < { data: ReqCreateAccount }, TContext >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseMutationResult< @@ -222,14 +205,12 @@ export const useCreateAccount = < * refresh 토큰으로 access 토큰을 재발급한다. * @summary 액세스 토큰 갱신 */ -export const refreshToken = ( - options?: SecondParameter, - signal?: AbortSignal, -) => { - return customFetch( - { url: `/v1/auth/refresh_token`, method: "POST", signal }, - options, - ); +export const refreshToken = (signal?: AbortSignal) => { + return customFetch({ + url: `/v1/auth/refresh_token`, + method: "POST", + signal, + }); }; export const getRefreshTokenMutationOptions = < @@ -242,7 +223,6 @@ export const getRefreshTokenMutationOptions = < void, TContext >; - request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, @@ -250,19 +230,19 @@ export const getRefreshTokenMutationOptions = < TContext > => { const mutationKey = ["refreshToken"]; - const { mutation: mutationOptions, request: requestOptions } = options + const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; + : { mutation: { mutationKey } }; const mutationFn: MutationFunction< Awaited>, void > = () => { - return refreshToken(requestOptions); + return refreshToken(); }; return { mutationFn, ...mutationOptions }; @@ -285,7 +265,6 @@ export const useRefreshToken = ( void, TContext >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseMutationResult< @@ -299,17 +278,11 @@ export const useRefreshToken = ( return useMutation(mutationOptions, queryClient); }; /** - * 유효한 access 토큰이 있어야 호출 가능. 토큰의 유저+소속사 정보를 반환한다. + * 유효한 access 토큰이 있어야 호출 가능. 토큰의 유저+회사 정보를 반환한다. * @summary 내 정보 */ -export const me = ( - options?: SecondParameter, - signal?: AbortSignal, -) => { - return customFetch( - { url: `/v1/auth/me`, method: "GET", signal }, - options, - ); +export const me = (signal?: AbortSignal) => { + return customFetch({ url: `/v1/auth/me`, method: "GET", signal }); }; export const getMeQueryKey = () => { @@ -323,14 +296,13 @@ export const getMeQueryOptions = < query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; + const { query: queryOptions } = options ?? {}; const queryKey = queryOptions?.queryKey ?? getMeQueryKey(); const queryFn: QueryFunction>> = ({ signal }) => - me(requestOptions, signal); + me(signal); return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< Awaited>, @@ -355,7 +327,6 @@ export function useMe>, TError = void>( >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): DefinedUseQueryResult & { @@ -374,7 +345,6 @@ export function useMe>, TError = void>( >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -385,7 +355,6 @@ export function useMe>, TError = void>( query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -400,7 +369,6 @@ export function useMe>, TError = void>( query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { diff --git a/negodata/front/src/api/generated/default/default.ts b/negodata/front/src/api/generated/default/default.ts index 35a01f7..349773f 100644 --- a/negodata/front/src/api/generated/default/default.ts +++ b/negodata/front/src/api/generated/default/default.ts @@ -19,19 +19,11 @@ import type { import { customFetch } from "../../mutator/custom-fetch"; -type SecondParameter unknown> = Parameters[1]; - /** * @summary Healthz */ -export const healthzHealthzGet = ( - options?: SecondParameter, - signal?: AbortSignal, -) => { - return customFetch( - { url: `/healthz`, method: "GET", signal }, - options, - ); +export const healthzHealthzGet = (signal?: AbortSignal) => { + return customFetch({ url: `/healthz`, method: "GET", signal }); }; export const getHealthzHealthzGetQueryKey = () => { @@ -49,15 +41,14 @@ export const getHealthzHealthzGetQueryOptions = < TData > >; - request?: SecondParameter; }) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; + const { query: queryOptions } = options ?? {}; const queryKey = queryOptions?.queryKey ?? getHealthzHealthzGetQueryKey(); const queryFn: QueryFunction< Awaited> - > = ({ signal }) => healthzHealthzGet(requestOptions, signal); + > = ({ signal }) => healthzHealthzGet(signal); return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< Awaited>, @@ -91,7 +82,6 @@ export function useHealthzHealthzGet< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): DefinedUseQueryResult & { @@ -117,7 +107,6 @@ export function useHealthzHealthzGet< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -135,7 +124,6 @@ export function useHealthzHealthzGet< TData > >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -157,7 +145,6 @@ export function useHealthzHealthzGet< TData > >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { diff --git a/negodata/front/src/api/generated/enums/enums.ts b/negodata/front/src/api/generated/enums/enums.ts new file mode 100644 index 0000000..41850d4 --- /dev/null +++ b/negodata/front/src/api/generated/enums/enums.ts @@ -0,0 +1,145 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import { useQuery } from "@tanstack/react-query"; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseQueryOptions, + UseQueryResult, +} from "@tanstack/react-query"; + +import type { ResEnums } from ".././model"; + +import { customFetch } from "../../mutator/custom-fetch"; + +/** + * @summary 도메인 코드 enum 전체 + */ +export const listEnums = (signal?: AbortSignal) => { + return customFetch({ url: `/v1/enums`, method: "GET", signal }); +}; + +export const getListEnumsQueryKey = () => { + return [`/v1/enums`] as const; +}; + +export const getListEnumsQueryOptions = < + TData = Awaited>, + TError = void, +>(options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; +}) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListEnumsQueryKey(); + + const queryFn: QueryFunction>> = ({ + signal, + }) => listEnums(signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ListEnumsQueryResult = NonNullable< + Awaited> +>; +export type ListEnumsQueryError = void; + +export function useListEnums< + TData = Awaited>, + TError = void, +>( + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useListEnums< + TData = Awaited>, + TError = void, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useListEnums< + TData = Awaited>, + TError = void, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary 도메인 코드 enum 전체 + */ + +export function useListEnums< + TData = Awaited>, + TError = void, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getListEnumsQueryOptions(options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} diff --git a/negodata/front/src/api/generated/item/item.ts b/negodata/front/src/api/generated/item/item.ts index 11c6c19..6f9a7a7 100644 --- a/negodata/front/src/api/generated/item/item.ts +++ b/negodata/front/src/api/generated/item/item.ts @@ -36,20 +36,16 @@ import type { import { customFetch } from "../../mutator/custom-fetch"; -type SecondParameter unknown> = Parameters[1]; - /** * @summary 상품 목록 */ -export const listItems = ( - params?: ListItemsParams, - options?: SecondParameter, - signal?: AbortSignal, -) => { - return customFetch( - { url: `/v1/item/list`, method: "GET", params, signal }, - options, - ); +export const listItems = (params?: ListItemsParams, signal?: AbortSignal) => { + return customFetch({ + url: `/v1/item/list`, + method: "GET", + params, + signal, + }); }; export const getListItemsQueryKey = (params?: ListItemsParams) => { @@ -65,16 +61,15 @@ export const getListItemsQueryOptions = < query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, ) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; + const { query: queryOptions } = options ?? {}; const queryKey = queryOptions?.queryKey ?? getListItemsQueryKey(params); const queryFn: QueryFunction>> = ({ signal, - }) => listItems(params, requestOptions, signal); + }) => listItems(params, signal); return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< Awaited>, @@ -105,7 +100,6 @@ export function useListItems< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): DefinedUseQueryResult & { @@ -128,7 +122,6 @@ export function useListItems< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -143,7 +136,6 @@ export function useListItems< query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -162,7 +154,6 @@ export function useListItems< query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -185,19 +176,15 @@ export function useListItems< */ export const createItem = ( reqCreateItem: ReqCreateItem, - options?: SecondParameter, signal?: AbortSignal, ) => { - return customFetch( - { - url: `/v1/item/create`, - method: "POST", - headers: { "Content-Type": "application/json" }, - data: reqCreateItem, - signal, - }, - options, - ); + return customFetch({ + url: `/v1/item/create`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqCreateItem, + signal, + }); }; export const getCreateItemMutationOptions = < @@ -210,7 +197,6 @@ export const getCreateItemMutationOptions = < { data: ReqCreateItem }, TContext >; - request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, @@ -218,13 +204,13 @@ export const getCreateItemMutationOptions = < TContext > => { const mutationKey = ["createItem"]; - const { mutation: mutationOptions, request: requestOptions } = options + const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; + : { mutation: { mutationKey } }; const mutationFn: MutationFunction< Awaited>, @@ -232,7 +218,7 @@ export const getCreateItemMutationOptions = < > = (props) => { const { data } = props ?? {}; - return createItem(data, requestOptions); + return createItem(data); }; return { mutationFn, ...mutationOptions }; @@ -258,7 +244,6 @@ export const useCreateItem = < { data: ReqCreateItem }, TContext >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseMutationResult< @@ -276,22 +261,18 @@ export const useCreateItem = < */ export const uploadItemsExcel = ( bodyUploadItemsExcelV1ItemUploadExcelPost: BodyUploadItemsExcelV1ItemUploadExcelPost, - options?: SecondParameter, signal?: AbortSignal, ) => { const formData = new FormData(); formData.append(`file`, bodyUploadItemsExcelV1ItemUploadExcelPost.file); - return customFetch( - { - url: `/v1/item/upload-excel`, - method: "POST", - headers: { "Content-Type": "multipart/form-data" }, - data: formData, - signal, - }, - options, - ); + return customFetch({ + url: `/v1/item/upload-excel`, + method: "POST", + headers: { "Content-Type": "multipart/form-data" }, + data: formData, + signal, + }); }; export const getUploadItemsExcelMutationOptions = < @@ -304,7 +285,6 @@ export const getUploadItemsExcelMutationOptions = < { data: BodyUploadItemsExcelV1ItemUploadExcelPost }, TContext >; - request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, @@ -312,13 +292,13 @@ export const getUploadItemsExcelMutationOptions = < TContext > => { const mutationKey = ["uploadItemsExcel"]; - const { mutation: mutationOptions, request: requestOptions } = options + const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; + : { mutation: { mutationKey } }; const mutationFn: MutationFunction< Awaited>, @@ -326,7 +306,7 @@ export const getUploadItemsExcelMutationOptions = < > = (props) => { const { data } = props ?? {}; - return uploadItemsExcel(data, requestOptions); + return uploadItemsExcel(data); }; return { mutationFn, ...mutationOptions }; @@ -353,7 +333,6 @@ export const useUploadItemsExcel = < { data: BodyUploadItemsExcelV1ItemUploadExcelPost }, TContext >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseMutationResult< @@ -369,15 +348,12 @@ export const useUploadItemsExcel = < /** * @summary 상품 조회 */ -export const getItem = ( - itemId: string, - options?: SecondParameter, - signal?: AbortSignal, -) => { - return customFetch( - { url: `/v1/item/${itemId}`, method: "GET", signal }, - options, - ); +export const getItem = (itemId: string, signal?: AbortSignal) => { + return customFetch({ + url: `/v1/item/${itemId}`, + method: "GET", + signal, + }); }; export const getGetItemQueryKey = (itemId?: string) => { @@ -393,16 +369,15 @@ export const getGetItemQueryOptions = < query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, ) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; + const { query: queryOptions } = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetItemQueryKey(itemId); const queryFn: QueryFunction>> = ({ signal, - }) => getItem(itemId, requestOptions, signal); + }) => getItem(itemId, signal); return { queryKey, @@ -436,7 +411,6 @@ export function useGetItem< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): DefinedUseQueryResult & { @@ -459,7 +433,6 @@ export function useGetItem< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -474,7 +447,6 @@ export function useGetItem< query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -493,7 +465,6 @@ export function useGetItem< query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -514,20 +485,13 @@ export function useGetItem< /** * @summary 상품 수정 */ -export const updateItem = ( - itemId: string, - reqUpdateItem: ReqUpdateItem, - options?: SecondParameter, -) => { - return customFetch( - { - url: `/v1/item/update/${itemId}`, - method: "PATCH", - headers: { "Content-Type": "application/json" }, - data: reqUpdateItem, - }, - options, - ); +export const updateItem = (itemId: string, reqUpdateItem: ReqUpdateItem) => { + return customFetch({ + url: `/v1/item/update/${itemId}`, + method: "PATCH", + headers: { "Content-Type": "application/json" }, + data: reqUpdateItem, + }); }; export const getUpdateItemMutationOptions = < @@ -540,7 +504,6 @@ export const getUpdateItemMutationOptions = < { itemId: string; data: ReqUpdateItem }, TContext >; - request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, @@ -548,13 +511,13 @@ export const getUpdateItemMutationOptions = < TContext > => { const mutationKey = ["updateItem"]; - const { mutation: mutationOptions, request: requestOptions } = options + const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; + : { mutation: { mutationKey } }; const mutationFn: MutationFunction< Awaited>, @@ -562,7 +525,7 @@ export const getUpdateItemMutationOptions = < > = (props) => { const { itemId, data } = props ?? {}; - return updateItem(itemId, data, requestOptions); + return updateItem(itemId, data); }; return { mutationFn, ...mutationOptions }; @@ -588,7 +551,6 @@ export const useUpdateItem = < { itemId: string; data: ReqUpdateItem }, TContext >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseMutationResult< @@ -604,14 +566,11 @@ export const useUpdateItem = < /** * @summary 상품 삭제 */ -export const deleteItem = ( - itemId: string, - options?: SecondParameter, -) => { - return customFetch( - { url: `/v1/item/delete/${itemId}`, method: "DELETE" }, - options, - ); +export const deleteItem = (itemId: string) => { + return customFetch({ + url: `/v1/item/delete/${itemId}`, + method: "DELETE", + }); }; export const getDeleteItemMutationOptions = < @@ -624,7 +583,6 @@ export const getDeleteItemMutationOptions = < { itemId: string }, TContext >; - request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, @@ -632,13 +590,13 @@ export const getDeleteItemMutationOptions = < TContext > => { const mutationKey = ["deleteItem"]; - const { mutation: mutationOptions, request: requestOptions } = options + const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; + : { mutation: { mutationKey } }; const mutationFn: MutationFunction< Awaited>, @@ -646,7 +604,7 @@ export const getDeleteItemMutationOptions = < > = (props) => { const { itemId } = props ?? {}; - return deleteItem(itemId, requestOptions); + return deleteItem(itemId); }; return { mutationFn, ...mutationOptions }; @@ -672,7 +630,6 @@ export const useDeleteItem = < { itemId: string }, TContext >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseMutationResult< @@ -688,15 +645,12 @@ export const useDeleteItem = < /** * @summary 최저가 수집 요청(스텁) */ -export const triggerLowestPrice = ( - itemId: string, - options?: SecondParameter, - signal?: AbortSignal, -) => { - return customFetch( - { url: `/v1/item/${itemId}/lowest-price`, method: "POST", signal }, - options, - ); +export const triggerLowestPrice = (itemId: string, signal?: AbortSignal) => { + return customFetch({ + url: `/v1/item/${itemId}/lowest-price`, + method: "POST", + signal, + }); }; export const getTriggerLowestPriceMutationOptions = < @@ -709,7 +663,6 @@ export const getTriggerLowestPriceMutationOptions = < { itemId: string }, TContext >; - request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, @@ -717,13 +670,13 @@ export const getTriggerLowestPriceMutationOptions = < TContext > => { const mutationKey = ["triggerLowestPrice"]; - const { mutation: mutationOptions, request: requestOptions } = options + const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; + : { mutation: { mutationKey } }; const mutationFn: MutationFunction< Awaited>, @@ -731,7 +684,7 @@ export const getTriggerLowestPriceMutationOptions = < > = (props) => { const { itemId } = props ?? {}; - return triggerLowestPrice(itemId, requestOptions); + return triggerLowestPrice(itemId); }; return { mutationFn, ...mutationOptions }; @@ -757,7 +710,6 @@ export const useTriggerLowestPrice = < { itemId: string }, TContext >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseMutationResult< @@ -773,15 +725,12 @@ export const useTriggerLowestPrice = < /** * @summary 최저가 수집 결과(스텁) */ -export const getLowestPrice = ( - itemId: string, - options?: SecondParameter, - signal?: AbortSignal, -) => { - return customFetch( - { url: `/v1/item/${itemId}/lowest-price`, method: "GET", signal }, - options, - ); +export const getLowestPrice = (itemId: string, signal?: AbortSignal) => { + return customFetch({ + url: `/v1/item/${itemId}/lowest-price`, + method: "GET", + signal, + }); }; export const getGetLowestPriceQueryKey = (itemId?: string) => { @@ -797,16 +746,15 @@ export const getGetLowestPriceQueryOptions = < query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, ) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; + const { query: queryOptions } = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetLowestPriceQueryKey(itemId); const queryFn: QueryFunction>> = ({ signal, - }) => getLowestPrice(itemId, requestOptions, signal); + }) => getLowestPrice(itemId, signal); return { queryKey, @@ -842,7 +790,6 @@ export function useGetLowestPrice< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): DefinedUseQueryResult & { @@ -865,7 +812,6 @@ export function useGetLowestPrice< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -880,7 +826,6 @@ export function useGetLowestPrice< query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -899,7 +844,6 @@ export function useGetLowestPrice< query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { diff --git a/negodata/front/src/api/generated/model/companyBrief.ts b/negodata/front/src/api/generated/model/companyData.ts similarity index 83% rename from negodata/front/src/api/generated/model/companyBrief.ts rename to negodata/front/src/api/generated/model/companyData.ts index 80e4960..1f6c575 100644 --- a/negodata/front/src/api/generated/model/companyBrief.ts +++ b/negodata/front/src/api/generated/model/companyData.ts @@ -5,7 +5,7 @@ * OpenAPI spec version: 0.1.0 */ -export interface CompanyBrief { +export interface CompanyData { company_id?: string; name?: string; } diff --git a/negodata/front/src/api/generated/model/enumOption.ts b/negodata/front/src/api/generated/model/enumOption.ts new file mode 100644 index 0000000..3b1b7e1 --- /dev/null +++ b/negodata/front/src/api/generated/model/enumOption.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export interface EnumOption { + value: number; + name: string; + label: string; +} diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 1241ae7..3eff534 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -13,7 +13,8 @@ export * from "./chatMessageDataCardId"; export * from "./chatMessageDataCardType"; export * from "./chatMessageDataCardUsedYn"; export * from "./chatMessageDataIndicatorValue"; -export * from "./companyBrief"; +export * from "./companyData"; +export * from "./enumOption"; export * from "./errorInfo"; export * from "./errorInfoCode"; export * from "./errorInfoDesc"; @@ -134,6 +135,9 @@ export * from "./resDeleteQuotationSetting"; export * from "./resDeleteQuotationSettingMsg"; export * from "./resDeleteSupplier"; export * from "./resDeleteSupplierMsg"; +export * from "./resEnums"; +export * from "./resEnumsEnums"; +export * from "./resEnumsMsg"; export * from "./resExcelUpload"; export * from "./resExcelUploadMsg"; export * from "./resExcelUploadReceivedFilename"; diff --git a/negodata/front/src/api/generated/model/itemDataDeliveryType.ts b/negodata/front/src/api/generated/model/itemDataDeliveryType.ts index 1c141fd..df68667 100644 --- a/negodata/front/src/api/generated/model/itemDataDeliveryType.ts +++ b/negodata/front/src/api/generated/model/itemDataDeliveryType.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ItemDataDeliveryType = string | null; +export type ItemDataDeliveryType = number | null; diff --git a/negodata/front/src/api/generated/model/itemDataQuantityUnit.ts b/negodata/front/src/api/generated/model/itemDataQuantityUnit.ts index 1122a9a..f4a4a0b 100644 --- a/negodata/front/src/api/generated/model/itemDataQuantityUnit.ts +++ b/negodata/front/src/api/generated/model/itemDataQuantityUnit.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ItemDataQuantityUnit = number | null; +export type ItemDataQuantityUnit = string | null; diff --git a/negodata/front/src/api/generated/model/reqCreateItemDeliveryType.ts b/negodata/front/src/api/generated/model/reqCreateItemDeliveryType.ts index 52e30af..d6b79b7 100644 --- a/negodata/front/src/api/generated/model/reqCreateItemDeliveryType.ts +++ b/negodata/front/src/api/generated/model/reqCreateItemDeliveryType.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ReqCreateItemDeliveryType = string | null; +export type ReqCreateItemDeliveryType = number | null; diff --git a/negodata/front/src/api/generated/model/reqCreateItemQuantityUnit.ts b/negodata/front/src/api/generated/model/reqCreateItemQuantityUnit.ts index 51ca367..8ff931f 100644 --- a/negodata/front/src/api/generated/model/reqCreateItemQuantityUnit.ts +++ b/negodata/front/src/api/generated/model/reqCreateItemQuantityUnit.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ReqCreateItemQuantityUnit = number | null; +export type ReqCreateItemQuantityUnit = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateItemDeliveryType.ts b/negodata/front/src/api/generated/model/reqUpdateItemDeliveryType.ts index 241a59e..079c6ca 100644 --- a/negodata/front/src/api/generated/model/reqUpdateItemDeliveryType.ts +++ b/negodata/front/src/api/generated/model/reqUpdateItemDeliveryType.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ReqUpdateItemDeliveryType = string | null; +export type ReqUpdateItemDeliveryType = number | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateItemQuantityUnit.ts b/negodata/front/src/api/generated/model/reqUpdateItemQuantityUnit.ts index 5c6bb78..9d36af1 100644 --- a/negodata/front/src/api/generated/model/reqUpdateItemQuantityUnit.ts +++ b/negodata/front/src/api/generated/model/reqUpdateItemQuantityUnit.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ReqUpdateItemQuantityUnit = number | null; +export type ReqUpdateItemQuantityUnit = string | null; diff --git a/negodata/front/src/api/generated/model/resEnums.ts b/negodata/front/src/api/generated/model/resEnums.ts new file mode 100644 index 0000000..47afe1c --- /dev/null +++ b/negodata/front/src/api/generated/model/resEnums.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from "./errorInfo"; +import type { ResEnumsMsg } from "./resEnumsMsg"; +import type { ResEnumsEnums } from "./resEnumsEnums"; + +export interface ResEnums { + result?: ErrorInfo; + msg?: ResEnumsMsg; + enums?: ResEnumsEnums; +} diff --git a/negodata/front/src/api/generated/model/resEnumsEnums.ts b/negodata/front/src/api/generated/model/resEnumsEnums.ts new file mode 100644 index 0000000..bfd4e6a --- /dev/null +++ b/negodata/front/src/api/generated/model/resEnumsEnums.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { EnumOption } from "./enumOption"; + +export type ResEnumsEnums = { [key: string]: EnumOption[] }; diff --git a/negodata/front/src/api/generated/model/resEnumsMsg.ts b/negodata/front/src/api/generated/model/resEnumsMsg.ts new file mode 100644 index 0000000..f2ddea5 --- /dev/null +++ b/negodata/front/src/api/generated/model/resEnumsMsg.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ResEnumsMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resMeCompany.ts b/negodata/front/src/api/generated/model/resMeCompany.ts index aa239b5..2270cb1 100644 --- a/negodata/front/src/api/generated/model/resMeCompany.ts +++ b/negodata/front/src/api/generated/model/resMeCompany.ts @@ -4,6 +4,6 @@ * Negodata Api Server * OpenAPI spec version: 0.1.0 */ -import type { CompanyBrief } from "./companyBrief"; +import type { CompanyData } from "./companyData"; -export type ResMeCompany = CompanyBrief | null; +export type ResMeCompany = CompanyData | null; diff --git a/negodata/front/src/api/generated/quotation-setting/quotation-setting.ts b/negodata/front/src/api/generated/quotation-setting/quotation-setting.ts index 81d75a6..7ae9557 100644 --- a/negodata/front/src/api/generated/quotation-setting/quotation-setting.ts +++ b/negodata/front/src/api/generated/quotation-setting/quotation-setting.ts @@ -31,19 +31,15 @@ import type { import { customFetch } from "../../mutator/custom-fetch"; -type SecondParameter unknown> = Parameters[1]; - /** * @summary 견적 설정 목록 */ -export const listSettings = ( - options?: SecondParameter, - signal?: AbortSignal, -) => { - return customFetch( - { url: `/v1/quotation-setting/list`, method: "GET", signal }, - options, - ); +export const listSettings = (signal?: AbortSignal) => { + return customFetch({ + url: `/v1/quotation-setting/list`, + method: "GET", + signal, + }); }; export const getListSettingsQueryKey = () => { @@ -57,15 +53,14 @@ export const getListSettingsQueryOptions = < query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; + const { query: queryOptions } = options ?? {}; const queryKey = queryOptions?.queryKey ?? getListSettingsQueryKey(); const queryFn: QueryFunction>> = ({ signal, - }) => listSettings(requestOptions, signal); + }) => listSettings(signal); return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< Awaited>, @@ -95,7 +90,6 @@ export function useListSettings< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): DefinedUseQueryResult & { @@ -117,7 +111,6 @@ export function useListSettings< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -131,7 +124,6 @@ export function useListSettings< query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -149,7 +141,6 @@ export function useListSettings< query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -172,19 +163,15 @@ export function useListSettings< */ export const createSetting = ( reqCreateQuotationSetting: ReqCreateQuotationSetting, - options?: SecondParameter, signal?: AbortSignal, ) => { - return customFetch( - { - url: `/v1/quotation-setting/create`, - method: "POST", - headers: { "Content-Type": "application/json" }, - data: reqCreateQuotationSetting, - signal, - }, - options, - ); + return customFetch({ + url: `/v1/quotation-setting/create`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqCreateQuotationSetting, + signal, + }); }; export const getCreateSettingMutationOptions = < @@ -197,7 +184,6 @@ export const getCreateSettingMutationOptions = < { data: ReqCreateQuotationSetting }, TContext >; - request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, @@ -205,13 +191,13 @@ export const getCreateSettingMutationOptions = < TContext > => { const mutationKey = ["createSetting"]; - const { mutation: mutationOptions, request: requestOptions } = options + const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; + : { mutation: { mutationKey } }; const mutationFn: MutationFunction< Awaited>, @@ -219,7 +205,7 @@ export const getCreateSettingMutationOptions = < > = (props) => { const { data } = props ?? {}; - return createSetting(data, requestOptions); + return createSetting(data); }; return { mutationFn, ...mutationOptions }; @@ -245,7 +231,6 @@ export const useCreateSetting = < { data: ReqCreateQuotationSetting }, TContext >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseMutationResult< @@ -264,17 +249,13 @@ export const useCreateSetting = < export const updateSetting = ( qtSettingId: string, reqUpdateQuotationSetting: ReqUpdateQuotationSetting, - options?: SecondParameter, ) => { - return customFetch( - { - url: `/v1/quotation-setting/update/${qtSettingId}`, - method: "PATCH", - headers: { "Content-Type": "application/json" }, - data: reqUpdateQuotationSetting, - }, - options, - ); + return customFetch({ + url: `/v1/quotation-setting/update/${qtSettingId}`, + method: "PATCH", + headers: { "Content-Type": "application/json" }, + data: reqUpdateQuotationSetting, + }); }; export const getUpdateSettingMutationOptions = < @@ -287,7 +268,6 @@ export const getUpdateSettingMutationOptions = < { qtSettingId: string; data: ReqUpdateQuotationSetting }, TContext >; - request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, @@ -295,13 +275,13 @@ export const getUpdateSettingMutationOptions = < TContext > => { const mutationKey = ["updateSetting"]; - const { mutation: mutationOptions, request: requestOptions } = options + const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; + : { mutation: { mutationKey } }; const mutationFn: MutationFunction< Awaited>, @@ -309,7 +289,7 @@ export const getUpdateSettingMutationOptions = < > = (props) => { const { qtSettingId, data } = props ?? {}; - return updateSetting(qtSettingId, data, requestOptions); + return updateSetting(qtSettingId, data); }; return { mutationFn, ...mutationOptions }; @@ -335,7 +315,6 @@ export const useUpdateSetting = < { qtSettingId: string; data: ReqUpdateQuotationSetting }, TContext >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseMutationResult< @@ -351,14 +330,11 @@ export const useUpdateSetting = < /** * @summary 견적 설정 삭제 */ -export const deleteSetting = ( - qtSettingId: string, - options?: SecondParameter, -) => { - return customFetch( - { url: `/v1/quotation-setting/delete/${qtSettingId}`, method: "DELETE" }, - options, - ); +export const deleteSetting = (qtSettingId: string) => { + return customFetch({ + url: `/v1/quotation-setting/delete/${qtSettingId}`, + method: "DELETE", + }); }; export const getDeleteSettingMutationOptions = < @@ -371,7 +347,6 @@ export const getDeleteSettingMutationOptions = < { qtSettingId: string }, TContext >; - request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, @@ -379,13 +354,13 @@ export const getDeleteSettingMutationOptions = < TContext > => { const mutationKey = ["deleteSetting"]; - const { mutation: mutationOptions, request: requestOptions } = options + const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; + : { mutation: { mutationKey } }; const mutationFn: MutationFunction< Awaited>, @@ -393,7 +368,7 @@ export const getDeleteSettingMutationOptions = < > = (props) => { const { qtSettingId } = props ?? {}; - return deleteSetting(qtSettingId, requestOptions); + return deleteSetting(qtSettingId); }; return { mutationFn, ...mutationOptions }; @@ -419,7 +394,6 @@ export const useDeleteSetting = < { qtSettingId: string }, TContext >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseMutationResult< diff --git a/negodata/front/src/api/generated/quotation/quotation.ts b/negodata/front/src/api/generated/quotation/quotation.ts index e325118..a3750ba 100644 --- a/negodata/front/src/api/generated/quotation/quotation.ts +++ b/negodata/front/src/api/generated/quotation/quotation.ts @@ -37,20 +37,19 @@ import type { import { customFetch } from "../../mutator/custom-fetch"; -type SecondParameter unknown> = Parameters[1]; - /** * @summary 견적 목록 */ export const listQuotations = ( params?: ListQuotationsParams, - options?: SecondParameter, signal?: AbortSignal, ) => { - return customFetch( - { url: `/v1/quotation/list`, method: "GET", params, signal }, - options, - ); + return customFetch({ + url: `/v1/quotation/list`, + method: "GET", + params, + signal, + }); }; export const getListQuotationsQueryKey = (params?: ListQuotationsParams) => { @@ -66,16 +65,15 @@ export const getListQuotationsQueryOptions = < query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, ) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; + const { query: queryOptions } = options ?? {}; const queryKey = queryOptions?.queryKey ?? getListQuotationsQueryKey(params); const queryFn: QueryFunction>> = ({ signal, - }) => listQuotations(params, requestOptions, signal); + }) => listQuotations(params, signal); return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< Awaited>, @@ -106,7 +104,6 @@ export function useListQuotations< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): DefinedUseQueryResult & { @@ -129,7 +126,6 @@ export function useListQuotations< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -144,7 +140,6 @@ export function useListQuotations< query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -163,7 +158,6 @@ export function useListQuotations< query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -186,19 +180,15 @@ export function useListQuotations< */ export const createQuotation = ( reqCreateQuotation: ReqCreateQuotation, - options?: SecondParameter, signal?: AbortSignal, ) => { - return customFetch( - { - url: `/v1/quotation/create`, - method: "POST", - headers: { "Content-Type": "application/json" }, - data: reqCreateQuotation, - signal, - }, - options, - ); + return customFetch({ + url: `/v1/quotation/create`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqCreateQuotation, + signal, + }); }; export const getCreateQuotationMutationOptions = < @@ -211,7 +201,6 @@ export const getCreateQuotationMutationOptions = < { data: ReqCreateQuotation }, TContext >; - request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, @@ -219,13 +208,13 @@ export const getCreateQuotationMutationOptions = < TContext > => { const mutationKey = ["createQuotation"]; - const { mutation: mutationOptions, request: requestOptions } = options + const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; + : { mutation: { mutationKey } }; const mutationFn: MutationFunction< Awaited>, @@ -233,7 +222,7 @@ export const getCreateQuotationMutationOptions = < > = (props) => { const { data } = props ?? {}; - return createQuotation(data, requestOptions); + return createQuotation(data); }; return { mutationFn, ...mutationOptions }; @@ -259,7 +248,6 @@ export const useCreateQuotation = < { data: ReqCreateQuotation }, TContext >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseMutationResult< @@ -275,15 +263,12 @@ export const useCreateQuotation = < /** * @summary 견적 마감 */ -export const stopQuotation = ( - qtId: string, - options?: SecondParameter, - signal?: AbortSignal, -) => { - return customFetch( - { url: `/v1/quotation/stop/${qtId}`, method: "POST", signal }, - options, - ); +export const stopQuotation = (qtId: string, signal?: AbortSignal) => { + return customFetch({ + url: `/v1/quotation/stop/${qtId}`, + method: "POST", + signal, + }); }; export const getStopQuotationMutationOptions = < @@ -296,7 +281,6 @@ export const getStopQuotationMutationOptions = < { qtId: string }, TContext >; - request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, @@ -304,13 +288,13 @@ export const getStopQuotationMutationOptions = < TContext > => { const mutationKey = ["stopQuotation"]; - const { mutation: mutationOptions, request: requestOptions } = options + const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; + : { mutation: { mutationKey } }; const mutationFn: MutationFunction< Awaited>, @@ -318,7 +302,7 @@ export const getStopQuotationMutationOptions = < > = (props) => { const { qtId } = props ?? {}; - return stopQuotation(qtId, requestOptions); + return stopQuotation(qtId); }; return { mutationFn, ...mutationOptions }; @@ -344,7 +328,6 @@ export const useStopQuotation = < { qtId: string }, TContext >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseMutationResult< @@ -360,15 +343,12 @@ export const useStopQuotation = < /** * @summary 견적 상태 조회 */ -export const getQuotationStatus = ( - qtId: string, - options?: SecondParameter, - signal?: AbortSignal, -) => { - return customFetch( - { url: `/v1/quotation/${qtId}/status`, method: "GET", signal }, - options, - ); +export const getQuotationStatus = (qtId: string, signal?: AbortSignal) => { + return customFetch({ + url: `/v1/quotation/${qtId}/status`, + method: "GET", + signal, + }); }; export const getGetQuotationStatusQueryKey = (qtId?: string) => { @@ -388,17 +368,16 @@ export const getGetQuotationStatusQueryOptions = < TData > >; - request?: SecondParameter; }, ) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; + const { query: queryOptions } = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetQuotationStatusQueryKey(qtId); const queryFn: QueryFunction< Awaited> - > = ({ signal }) => getQuotationStatus(qtId, requestOptions, signal); + > = ({ signal }) => getQuotationStatus(qtId, signal); return { queryKey, @@ -438,7 +417,6 @@ export function useGetQuotationStatus< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): DefinedUseQueryResult & { @@ -465,7 +443,6 @@ export function useGetQuotationStatus< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -484,7 +461,6 @@ export function useGetQuotationStatus< TData > >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -507,7 +483,6 @@ export function useGetQuotationStatus< TData > >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -528,15 +503,12 @@ export function useGetQuotationStatus< /** * @summary 참여현황 */ -export const getQuotationSessions = ( - qtId: string, - options?: SecondParameter, - signal?: AbortSignal, -) => { - return customFetch( - { url: `/v1/quotation/${qtId}/sessions`, method: "GET", signal }, - options, - ); +export const getQuotationSessions = (qtId: string, signal?: AbortSignal) => { + return customFetch({ + url: `/v1/quotation/${qtId}/sessions`, + method: "GET", + signal, + }); }; export const getGetQuotationSessionsQueryKey = (qtId?: string) => { @@ -556,17 +528,16 @@ export const getGetQuotationSessionsQueryOptions = < TData > >; - request?: SecondParameter; }, ) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; + const { query: queryOptions } = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetQuotationSessionsQueryKey(qtId); const queryFn: QueryFunction< Awaited> - > = ({ signal }) => getQuotationSessions(qtId, requestOptions, signal); + > = ({ signal }) => getQuotationSessions(qtId, signal); return { queryKey, @@ -606,7 +577,6 @@ export function useGetQuotationSessions< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): DefinedUseQueryResult & { @@ -633,7 +603,6 @@ export function useGetQuotationSessions< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -652,7 +621,6 @@ export function useGetQuotationSessions< TData > >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -675,7 +643,6 @@ export function useGetQuotationSessions< TData > >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -696,15 +663,12 @@ export function useGetQuotationSessions< /** * @summary 채팅 상세 */ -export const getSessionChat = ( - sessionId: string, - options?: SecondParameter, - signal?: AbortSignal, -) => { - return customFetch( - { url: `/v1/quotation/session/${sessionId}/chat`, method: "GET", signal }, - options, - ); +export const getSessionChat = (sessionId: string, signal?: AbortSignal) => { + return customFetch({ + url: `/v1/quotation/session/${sessionId}/chat`, + method: "GET", + signal, + }); }; export const getGetSessionChatQueryKey = (sessionId?: string) => { @@ -720,17 +684,16 @@ export const getGetSessionChatQueryOptions = < query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, ) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; + const { query: queryOptions } = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetSessionChatQueryKey(sessionId); const queryFn: QueryFunction>> = ({ signal, - }) => getSessionChat(sessionId, requestOptions, signal); + }) => getSessionChat(sessionId, signal); return { queryKey, @@ -766,7 +729,6 @@ export function useGetSessionChat< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): DefinedUseQueryResult & { @@ -789,7 +751,6 @@ export function useGetSessionChat< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -804,7 +765,6 @@ export function useGetSessionChat< query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -823,7 +783,6 @@ export function useGetSessionChat< query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -844,15 +803,12 @@ export function useGetSessionChat< /** * @summary 낙찰 결과 */ -export const getQuotationResult = ( - qtId: string, - options?: SecondParameter, - signal?: AbortSignal, -) => { - return customFetch( - { url: `/v1/quotation/${qtId}/result`, method: "GET", signal }, - options, - ); +export const getQuotationResult = (qtId: string, signal?: AbortSignal) => { + return customFetch({ + url: `/v1/quotation/${qtId}/result`, + method: "GET", + signal, + }); }; export const getGetQuotationResultQueryKey = (qtId?: string) => { @@ -872,17 +828,16 @@ export const getGetQuotationResultQueryOptions = < TData > >; - request?: SecondParameter; }, ) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; + const { query: queryOptions } = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetQuotationResultQueryKey(qtId); const queryFn: QueryFunction< Awaited> - > = ({ signal }) => getQuotationResult(qtId, requestOptions, signal); + > = ({ signal }) => getQuotationResult(qtId, signal); return { queryKey, @@ -922,7 +877,6 @@ export function useGetQuotationResult< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): DefinedUseQueryResult & { @@ -949,7 +903,6 @@ export function useGetQuotationResult< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -968,7 +921,6 @@ export function useGetQuotationResult< TData > >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -991,7 +943,6 @@ export function useGetQuotationResult< TData > >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -1012,15 +963,12 @@ export function useGetQuotationResult< /** * @summary 견적 사용 카드 */ -export const getQuotationCards = ( - qtId: string, - options?: SecondParameter, - signal?: AbortSignal, -) => { - return customFetch( - { url: `/v1/quotation/${qtId}/cards`, method: "GET", signal }, - options, - ); +export const getQuotationCards = (qtId: string, signal?: AbortSignal) => { + return customFetch({ + url: `/v1/quotation/${qtId}/cards`, + method: "GET", + signal, + }); }; export const getGetQuotationCardsQueryKey = (qtId?: string) => { @@ -1040,16 +988,15 @@ export const getGetQuotationCardsQueryOptions = < TData > >; - request?: SecondParameter; }, ) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; + const { query: queryOptions } = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetQuotationCardsQueryKey(qtId); const queryFn: QueryFunction< Awaited> - > = ({ signal }) => getQuotationCards(qtId, requestOptions, signal); + > = ({ signal }) => getQuotationCards(qtId, signal); return { queryKey, @@ -1089,7 +1036,6 @@ export function useGetQuotationCards< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): DefinedUseQueryResult & { @@ -1116,7 +1062,6 @@ export function useGetQuotationCards< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -1135,7 +1080,6 @@ export function useGetQuotationCards< TData > >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -1158,7 +1102,6 @@ export function useGetQuotationCards< TData > >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -1179,14 +1122,11 @@ export function useGetQuotationCards< /** * @summary 견적 삭제 */ -export const deleteQuotation = ( - qtId: string, - options?: SecondParameter, -) => { - return customFetch( - { url: `/v1/quotation/delete/${qtId}`, method: "DELETE" }, - options, - ); +export const deleteQuotation = (qtId: string) => { + return customFetch({ + url: `/v1/quotation/delete/${qtId}`, + method: "DELETE", + }); }; export const getDeleteQuotationMutationOptions = < @@ -1199,7 +1139,6 @@ export const getDeleteQuotationMutationOptions = < { qtId: string }, TContext >; - request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, @@ -1207,13 +1146,13 @@ export const getDeleteQuotationMutationOptions = < TContext > => { const mutationKey = ["deleteQuotation"]; - const { mutation: mutationOptions, request: requestOptions } = options + const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; + : { mutation: { mutationKey } }; const mutationFn: MutationFunction< Awaited>, @@ -1221,7 +1160,7 @@ export const getDeleteQuotationMutationOptions = < > = (props) => { const { qtId } = props ?? {}; - return deleteQuotation(qtId, requestOptions); + return deleteQuotation(qtId); }; return { mutationFn, ...mutationOptions }; @@ -1247,7 +1186,6 @@ export const useDeleteQuotation = < { qtId: string }, TContext >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseMutationResult< @@ -1263,15 +1201,12 @@ export const useDeleteQuotation = < /** * @summary 견적 조회 */ -export const getQuotation = ( - qtId: string, - options?: SecondParameter, - signal?: AbortSignal, -) => { - return customFetch( - { url: `/v1/quotation/${qtId}`, method: "GET", signal }, - options, - ); +export const getQuotation = (qtId: string, signal?: AbortSignal) => { + return customFetch({ + url: `/v1/quotation/${qtId}`, + method: "GET", + signal, + }); }; export const getGetQuotationQueryKey = (qtId?: string) => { @@ -1287,16 +1222,15 @@ export const getGetQuotationQueryOptions = < query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, ) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; + const { query: queryOptions } = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetQuotationQueryKey(qtId); const queryFn: QueryFunction>> = ({ signal, - }) => getQuotation(qtId, requestOptions, signal); + }) => getQuotation(qtId, signal); return { queryKey, @@ -1332,7 +1266,6 @@ export function useGetQuotation< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): DefinedUseQueryResult & { @@ -1355,7 +1288,6 @@ export function useGetQuotation< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -1370,7 +1302,6 @@ export function useGetQuotation< query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -1389,7 +1320,6 @@ export function useGetQuotation< query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { diff --git a/negodata/front/src/api/generated/supplier/supplier.ts b/negodata/front/src/api/generated/supplier/supplier.ts index 7f8d184..70c507a 100644 --- a/negodata/front/src/api/generated/supplier/supplier.ts +++ b/negodata/front/src/api/generated/supplier/supplier.ts @@ -34,20 +34,19 @@ import type { import { customFetch } from "../../mutator/custom-fetch"; -type SecondParameter unknown> = Parameters[1]; - /** * @summary 협력사 목록 */ export const listSuppliers = ( params?: ListSuppliersParams, - options?: SecondParameter, signal?: AbortSignal, ) => { - return customFetch( - { url: `/v1/supplier/list`, method: "GET", params, signal }, - options, - ); + return customFetch({ + url: `/v1/supplier/list`, + method: "GET", + params, + signal, + }); }; export const getListSuppliersQueryKey = (params?: ListSuppliersParams) => { @@ -63,16 +62,15 @@ export const getListSuppliersQueryOptions = < query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, ) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; + const { query: queryOptions } = options ?? {}; const queryKey = queryOptions?.queryKey ?? getListSuppliersQueryKey(params); const queryFn: QueryFunction>> = ({ signal, - }) => listSuppliers(params, requestOptions, signal); + }) => listSuppliers(params, signal); return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< Awaited>, @@ -103,7 +101,6 @@ export function useListSuppliers< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): DefinedUseQueryResult & { @@ -126,7 +123,6 @@ export function useListSuppliers< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -141,7 +137,6 @@ export function useListSuppliers< query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -160,7 +155,6 @@ export function useListSuppliers< query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -183,19 +177,15 @@ export function useListSuppliers< */ export const createSupplier = ( reqCreateSupplier: ReqCreateSupplier, - options?: SecondParameter, signal?: AbortSignal, ) => { - return customFetch( - { - url: `/v1/supplier/create`, - method: "POST", - headers: { "Content-Type": "application/json" }, - data: reqCreateSupplier, - signal, - }, - options, - ); + return customFetch({ + url: `/v1/supplier/create`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqCreateSupplier, + signal, + }); }; export const getCreateSupplierMutationOptions = < @@ -208,7 +198,6 @@ export const getCreateSupplierMutationOptions = < { data: ReqCreateSupplier }, TContext >; - request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, @@ -216,13 +205,13 @@ export const getCreateSupplierMutationOptions = < TContext > => { const mutationKey = ["createSupplier"]; - const { mutation: mutationOptions, request: requestOptions } = options + const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; + : { mutation: { mutationKey } }; const mutationFn: MutationFunction< Awaited>, @@ -230,7 +219,7 @@ export const getCreateSupplierMutationOptions = < > = (props) => { const { data } = props ?? {}; - return createSupplier(data, requestOptions); + return createSupplier(data); }; return { mutationFn, ...mutationOptions }; @@ -256,7 +245,6 @@ export const useCreateSupplier = < { data: ReqCreateSupplier }, TContext >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseMutationResult< @@ -274,7 +262,6 @@ export const useCreateSupplier = < */ export const uploadSuppliersExcel = ( bodyUploadSuppliersExcelV1SupplierUploadExcelPost: BodyUploadSuppliersExcelV1SupplierUploadExcelPost, - options?: SecondParameter, signal?: AbortSignal, ) => { const formData = new FormData(); @@ -283,16 +270,13 @@ export const uploadSuppliersExcel = ( bodyUploadSuppliersExcelV1SupplierUploadExcelPost.file, ); - return customFetch( - { - url: `/v1/supplier/upload-excel`, - method: "POST", - headers: { "Content-Type": "multipart/form-data" }, - data: formData, - signal, - }, - options, - ); + return customFetch({ + url: `/v1/supplier/upload-excel`, + method: "POST", + headers: { "Content-Type": "multipart/form-data" }, + data: formData, + signal, + }); }; export const getUploadSuppliersExcelMutationOptions = < @@ -305,7 +289,6 @@ export const getUploadSuppliersExcelMutationOptions = < { data: BodyUploadSuppliersExcelV1SupplierUploadExcelPost }, TContext >; - request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, @@ -313,13 +296,13 @@ export const getUploadSuppliersExcelMutationOptions = < TContext > => { const mutationKey = ["uploadSuppliersExcel"]; - const { mutation: mutationOptions, request: requestOptions } = options + const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; + : { mutation: { mutationKey } }; const mutationFn: MutationFunction< Awaited>, @@ -327,7 +310,7 @@ export const getUploadSuppliersExcelMutationOptions = < > = (props) => { const { data } = props ?? {}; - return uploadSuppliersExcel(data, requestOptions); + return uploadSuppliersExcel(data); }; return { mutationFn, ...mutationOptions }; @@ -354,7 +337,6 @@ export const useUploadSuppliersExcel = < { data: BodyUploadSuppliersExcelV1SupplierUploadExcelPost }, TContext >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseMutationResult< @@ -370,15 +352,12 @@ export const useUploadSuppliersExcel = < /** * @summary 협력사 조회 */ -export const getSupplier = ( - supplierId: string, - options?: SecondParameter, - signal?: AbortSignal, -) => { - return customFetch( - { url: `/v1/supplier/${supplierId}`, method: "GET", signal }, - options, - ); +export const getSupplier = (supplierId: string, signal?: AbortSignal) => { + return customFetch({ + url: `/v1/supplier/${supplierId}`, + method: "GET", + signal, + }); }; export const getGetSupplierQueryKey = (supplierId?: string) => { @@ -394,16 +373,15 @@ export const getGetSupplierQueryOptions = < query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, ) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; + const { query: queryOptions } = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetSupplierQueryKey(supplierId); const queryFn: QueryFunction>> = ({ signal, - }) => getSupplier(supplierId, requestOptions, signal); + }) => getSupplier(supplierId, signal); return { queryKey, @@ -439,7 +417,6 @@ export function useGetSupplier< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): DefinedUseQueryResult & { @@ -462,7 +439,6 @@ export function useGetSupplier< >, "initialData" >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -477,7 +453,6 @@ export function useGetSupplier< query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -496,7 +471,6 @@ export function useGetSupplier< query?: Partial< UseQueryOptions>, TError, TData> >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseQueryResult & { @@ -520,17 +494,13 @@ export function useGetSupplier< export const updateSupplier = ( supplierId: string, reqUpdateSupplier: ReqUpdateSupplier, - options?: SecondParameter, ) => { - return customFetch( - { - url: `/v1/supplier/update/${supplierId}`, - method: "PATCH", - headers: { "Content-Type": "application/json" }, - data: reqUpdateSupplier, - }, - options, - ); + return customFetch({ + url: `/v1/supplier/update/${supplierId}`, + method: "PATCH", + headers: { "Content-Type": "application/json" }, + data: reqUpdateSupplier, + }); }; export const getUpdateSupplierMutationOptions = < @@ -543,7 +513,6 @@ export const getUpdateSupplierMutationOptions = < { supplierId: string; data: ReqUpdateSupplier }, TContext >; - request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, @@ -551,13 +520,13 @@ export const getUpdateSupplierMutationOptions = < TContext > => { const mutationKey = ["updateSupplier"]; - const { mutation: mutationOptions, request: requestOptions } = options + const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; + : { mutation: { mutationKey } }; const mutationFn: MutationFunction< Awaited>, @@ -565,7 +534,7 @@ export const getUpdateSupplierMutationOptions = < > = (props) => { const { supplierId, data } = props ?? {}; - return updateSupplier(supplierId, data, requestOptions); + return updateSupplier(supplierId, data); }; return { mutationFn, ...mutationOptions }; @@ -591,7 +560,6 @@ export const useUpdateSupplier = < { supplierId: string; data: ReqUpdateSupplier }, TContext >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseMutationResult< @@ -607,14 +575,11 @@ export const useUpdateSupplier = < /** * @summary 협력사 삭제 */ -export const deleteSupplier = ( - supplierId: string, - options?: SecondParameter, -) => { - return customFetch( - { url: `/v1/supplier/delete/${supplierId}`, method: "DELETE" }, - options, - ); +export const deleteSupplier = (supplierId: string) => { + return customFetch({ + url: `/v1/supplier/delete/${supplierId}`, + method: "DELETE", + }); }; export const getDeleteSupplierMutationOptions = < @@ -627,7 +592,6 @@ export const getDeleteSupplierMutationOptions = < { supplierId: string }, TContext >; - request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, @@ -635,13 +599,13 @@ export const getDeleteSupplierMutationOptions = < TContext > => { const mutationKey = ["deleteSupplier"]; - const { mutation: mutationOptions, request: requestOptions } = options + const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; + : { mutation: { mutationKey } }; const mutationFn: MutationFunction< Awaited>, @@ -649,7 +613,7 @@ export const getDeleteSupplierMutationOptions = < > = (props) => { const { supplierId } = props ?? {}; - return deleteSupplier(supplierId, requestOptions); + return deleteSupplier(supplierId); }; return { mutationFn, ...mutationOptions }; @@ -675,7 +639,6 @@ export const useDeleteSupplier = < { supplierId: string }, TContext >; - request?: SecondParameter; }, queryClient?: QueryClient, ): UseMutationResult< diff --git a/negodata/front/src/features/partners/components/ExcelUploadModal.tsx b/negodata/front/src/features/partners/components/ExcelUploadModal.tsx index 645c06d..89d09bb 100644 --- a/negodata/front/src/features/partners/components/ExcelUploadModal.tsx +++ b/negodata/front/src/features/partners/components/ExcelUploadModal.tsx @@ -1,6 +1,6 @@ import { useMemo, useRef, useState } from 'react'; import { Upload, X, FileSpreadsheet, CheckCircle2, Download } from 'lucide-react'; -import type { SupplierCreate } from '@/api/generated/model/supplierCreate'; +import type { ReqCreateSupplier as SupplierCreate } from '@/api/generated/model/reqCreateSupplier'; import { showToast } from '@/lib/notify'; import { downloadExcel, parseCsv } from '@/lib/excel'; import { Typography } from '@/components/ui/typography'; diff --git a/negodata/front/src/features/partners/components/PartnerFormSheet.tsx b/negodata/front/src/features/partners/components/PartnerFormSheet.tsx index a19f9e6..c27d651 100644 --- a/negodata/front/src/features/partners/components/PartnerFormSheet.tsx +++ b/negodata/front/src/features/partners/components/PartnerFormSheet.tsx @@ -2,8 +2,8 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Trash2 } from 'lucide-react'; -import type { SupplierCreate } from '@/api/generated/model/supplierCreate'; -import type { SupplierUpdate } from '@/api/generated/model/supplierUpdate'; +import type { ReqCreateSupplier as SupplierCreate } from '@/api/generated/model/reqCreateSupplier'; +import type { ReqUpdateSupplier as SupplierUpdate } from '@/api/generated/model/reqUpdateSupplier'; import { showToast } from '@/lib/notify'; import { Typography } from '@/components/ui/typography'; import { Button } from '@/components/ui/button'; diff --git a/negodata/front/src/features/partners/hooks/usePartners.ts b/negodata/front/src/features/partners/hooks/usePartners.ts index ba298da..55600c7 100644 --- a/negodata/front/src/features/partners/hooks/usePartners.ts +++ b/negodata/front/src/features/partners/hooks/usePartners.ts @@ -6,9 +6,8 @@ import { deleteSupplier, getListSuppliersQueryKey, } from '@/api/generated/supplier/supplier'; -import type { SupplierResponse } from '@/api/generated/model/supplierResponse'; -import type { SupplierCreate } from '@/api/generated/model/supplierCreate'; -import type { SupplierUpdate } from '@/api/generated/model/supplierUpdate'; +import type { ReqCreateSupplier } from '@/api/generated/model/reqCreateSupplier'; +import type { ReqUpdateSupplier } from '@/api/generated/model/reqUpdateSupplier'; import type { Partner } from '../types'; const LIST_PARAMS = { size: 100 }; @@ -22,11 +21,11 @@ export function usePartners() { const refresh = () => queryClient.invalidateQueries({ queryKey: getListSuppliersQueryKey(LIST_PARAMS) }); - const createPartner = async (data: SupplierCreate) => { + const createPartner = async (data: ReqCreateSupplier) => { await createSupplier(data); await refresh(); }; - const updatePartner = async (supplierId: string, data: SupplierUpdate) => { + const updatePartner = async (supplierId: string, data: ReqUpdateSupplier) => { await updateSupplier(supplierId, data); await refresh(); }; @@ -35,17 +34,16 @@ export function usePartners() { await refresh(); }; // 엑셀 일괄 등록 — 검증된 행들을 순차 생성 후 한 번만 재조회. - const bulkCreate = async (rows: SupplierCreate[]) => { + const bulkCreate = async (rows: ReqCreateSupplier[]) => { for (const row of rows) { await createSupplier(row); } await refresh(); }; - // 서버 SupplierResponse → UI Partner (그대로 + id 별칭만) - const suppliers = - (suppliersQuery.data as { data?: { data?: { suppliers?: SupplierResponse[] } } } | undefined) - ?.data?.data?.suppliers ?? []; + // 서버 SupplierData → UI Partner (그대로 + id 별칭만). + // customFetch 가 본문을 그대로 주므로 suppliersQuery.data 가 곧 ResSupplierList → .suppliers. + const suppliers = suppliersQuery.data?.suppliers ?? []; const partners: Partner[] = suppliers.map((sp) => ({ ...sp, id: sp.supplier_id })); return { partners, createPartner, updatePartner, deletePartner, bulkCreate, refresh, suppliersQuery }; diff --git a/negodata/front/src/features/partners/types.ts b/negodata/front/src/features/partners/types.ts index 98e75d9..539e01f 100644 --- a/negodata/front/src/features/partners/types.ts +++ b/negodata/front/src/features/partners/types.ts @@ -1,8 +1,8 @@ -import type { SupplierResponse } from '@/api/generated/model/supplierResponse'; +import type { SupplierData } from '@/api/generated/model/supplierData'; -// UI에서 쓰는 협력사 타입. 서버 SupplierResponse에 화면 전용 파생 필드만 얹는다. +// UI에서 쓰는 협력사 타입. 서버 SupplierData에 화면 전용 파생 필드만 얹는다. // (level/rank/status 등 서버 미연동 가짜 필드는 두지 않는다 — 표시 가능한 건 priority뿐.) -export type Partner = SupplierResponse & { +export type Partner = SupplierData & { deleted?: boolean; id?: string; }; diff --git a/negodata/front/src/features/products/components/ExcelUploadModal.tsx b/negodata/front/src/features/products/components/ExcelUploadModal.tsx index fc51f4c..37a5e7d 100644 --- a/negodata/front/src/features/products/components/ExcelUploadModal.tsx +++ b/negodata/front/src/features/products/components/ExcelUploadModal.tsx @@ -1,6 +1,6 @@ import { useMemo, useRef, useState } from 'react'; import { Upload, X, FileSpreadsheet, CheckCircle2, Download } from 'lucide-react'; -import type { ItemCreate } from '@/api/generated/model/itemCreate'; +import type { ReqCreateItem as ItemCreate } from '@/api/generated/model/reqCreateItem'; import { showToast } from '@/lib/notify'; import { downloadExcel, parseCsv } from '@/lib/excel'; import { Typography } from '@/components/ui/typography'; @@ -62,7 +62,7 @@ function toItemCreate(row: RawRow): ItemCreate { manufacturer: '벌크 대리 수급처', made_in: '미지정', quantity_unit: 'EA', - delivery_type: '벌크 대중 물류', + delivery_type: 1, // 협력사배송 (delivery_type 코드) moq: '10 EA', lead_time: 14, vat_yn: true, diff --git a/negodata/front/src/features/products/components/ProductFormSheet.tsx b/negodata/front/src/features/products/components/ProductFormSheet.tsx index 7aa8eda..87f8f62 100644 --- a/negodata/front/src/features/products/components/ProductFormSheet.tsx +++ b/negodata/front/src/features/products/components/ProductFormSheet.tsx @@ -1,8 +1,9 @@ import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; -import type { ItemCreate } from '@/api/generated/model/itemCreate'; -import type { ItemUpdate } from '@/api/generated/model/itemUpdate'; +import type { ReqCreateItem as ItemCreate } from '@/api/generated/model/reqCreateItem'; +import type { ReqUpdateItem as ItemUpdate } from '@/api/generated/model/reqUpdateItem'; +import { useListEnums } from '@/api/generated/enums/enums'; import { showToast } from '@/lib/notify'; import ImageDropzone from '@/components/ImageDropzone'; import { Typography } from '@/components/ui/typography'; @@ -23,7 +24,7 @@ const schema = z.object({ manufacturer: z.string(), origin: z.string(), unit: z.string(), - shippingType: z.string(), + shippingType: z.number({ message: '배송 형태를 선택해 주십시오.' }), // delivery_type 코드 imageUrl: z.string(), moq: z.string(), leadTime: z.number({ message: '숫자를 입력해 주십시오.' }).min(0), @@ -58,7 +59,7 @@ function buildDefaults(mode: 'create' | 'edit', product: Product | null): FormVa manufacturer: product.manufacturer || '', origin: product.made_in || '', unit: product.quantity_unit || 'EA', - shippingType: product.delivery_type || '벌크 물류 배송', + shippingType: product.delivery_type ?? 1, imageUrl: product.image_url || '', moq: product.moq || '10 EA', leadTime: product.lead_time || 14, @@ -78,7 +79,7 @@ function buildDefaults(mode: 'create' | 'edit', product: Product | null): FormVa manufacturer: '', origin: '대한민국', unit: 'EA', - shippingType: '벌크 물류 배송', + shippingType: 1, imageUrl: '', moq: '10 EA', leadTime: 14, @@ -112,6 +113,10 @@ export function ProductFormSheet({ defaultValues: buildDefaults(mode, product), }); + // 배송 형태 코드(delivery_type) 선택지는 서버 enum 에서 가져온다. + const { data: enumsData } = useListEnums(); + const deliveryTypes = enumsData?.enums?.delivery_type ?? []; + // minPrice는 화면 전용(서버 미전송). 검증된 값만 payload로. const onValid = async (v: FormValues) => { const common = { @@ -307,14 +312,26 @@ export function ProductFormSheet({ - {/* Shipping Type */} + {/* Shipping Type — 서버 enum(delivery_type) 코드 전송 */}
배송 형태 - ( + + )} /> + {errors.shippingType &&

{errors.shippingType.message}

}
{/* DB Schema Booleans (vat_yn, delivery_fee_yn) using Switches */} diff --git a/negodata/front/src/features/products/hooks/useProducts.ts b/negodata/front/src/features/products/hooks/useProducts.ts index 29e6be7..a84f6ae 100644 --- a/negodata/front/src/features/products/hooks/useProducts.ts +++ b/negodata/front/src/features/products/hooks/useProducts.ts @@ -6,9 +6,8 @@ import { deleteItem, getListItemsQueryKey, } from '@/api/generated/item/item'; -import type { ItemResponse } from '@/api/generated/model/itemResponse'; -import type { ItemCreate } from '@/api/generated/model/itemCreate'; -import type { ItemUpdate } from '@/api/generated/model/itemUpdate'; +import type { ReqCreateItem } from '@/api/generated/model/reqCreateItem'; +import type { ReqUpdateItem } from '@/api/generated/model/reqUpdateItem'; import { type Product, toMinPrice } from '../types'; const LIST_PARAMS = { size: 100 }; @@ -22,11 +21,11 @@ export function useProducts() { const refresh = () => queryClient.invalidateQueries({ queryKey: getListItemsQueryKey(LIST_PARAMS) }); - const createProduct = async (data: ItemCreate) => { + const createProduct = async (data: ReqCreateItem) => { await createItem(data); await refresh(); }; - const updateProduct = async (itemId: string, data: ItemUpdate) => { + const updateProduct = async (itemId: string, data: ReqUpdateItem) => { await updateItem(itemId, data); await refresh(); }; @@ -35,17 +34,16 @@ export function useProducts() { await refresh(); }; // 엑셀 일괄 등록 — 검증된 행들을 순차 생성 후 한 번만 재조회. - const bulkCreate = async (rows: ItemCreate[]) => { + const bulkCreate = async (rows: ReqCreateItem[]) => { for (const row of rows) { await createItem(row); } await refresh(); }; - // 서버 ItemResponse → UI Product (화면 전용 파생 필드 부여) - const items = - (itemsQuery.data as { data?: { data?: { items?: ItemResponse[] } } } | undefined) - ?.data?.data?.items ?? []; + // 서버 ItemData → UI Product (화면 전용 파생 필드 부여). + // customFetch 가 본문을 그대로 주므로 itemsQuery.data 가 곧 ResItemList → .items. + const items = itemsQuery.data?.items ?? []; const products: Product[] = items.map((it) => ({ ...it, id: it.item_id, diff --git a/negodata/front/src/features/products/types.ts b/negodata/front/src/features/products/types.ts index 31c5eaf..9b5d9f3 100644 --- a/negodata/front/src/features/products/types.ts +++ b/negodata/front/src/features/products/types.ts @@ -1,7 +1,7 @@ -import type { ItemResponse } from '@/api/generated/model/itemResponse'; +import type { ItemData } from '@/api/generated/model/itemData'; -// UI에서 쓰는 상품 타입. 서버 ItemResponse에 화면 전용 파생 필드를 얹는다. -export type Product = ItemResponse & { +// UI에서 쓰는 상품 타입. 서버 ItemData에 화면 전용 파생 필드를 얹는다. +export type Product = ItemData & { deleted?: boolean; id?: string; minPrice?: number; diff --git a/negodata/front/src/features/quotations/hooks/useQuotations.ts b/negodata/front/src/features/quotations/hooks/useQuotations.ts index e156cf8..8d6a519 100644 --- a/negodata/front/src/features/quotations/hooks/useQuotations.ts +++ b/negodata/front/src/features/quotations/hooks/useQuotations.ts @@ -3,10 +3,10 @@ import { useListItems } from '@/api/generated/item/item'; import { useListSuppliers } from '@/api/generated/supplier/supplier'; import { useListSettings } from '@/api/generated/quotation-setting/quotation-setting'; import { useListQuotations } from '@/api/generated/quotation/quotation'; -import type { ItemResponse } from '@/api/generated/model/itemResponse'; -import type { SupplierResponse } from '@/api/generated/model/supplierResponse'; -import type { QuotationSettingResponse } from '@/api/generated/model/quotationSettingResponse'; -import type { QuotationResponse } from '@/api/generated/model/quotationResponse'; +import type { ItemData } from '@/api/generated/model/itemData'; +import type { SupplierData } from '@/api/generated/model/supplierData'; +import type { QuotationSettingData } from '@/api/generated/model/quotationSettingData'; +import type { QuotationData } from '@/api/generated/model/quotationData'; import { showToast } from '@/lib/notify'; import type { Estimate, QuotationSetting, ChatSession, NegotiationCard, Product } from '@/types'; import { unwrap, mapItem, mapSupplier, mapSetting, mapQuotation } from '../types'; @@ -36,18 +36,18 @@ export function useQuotations() { const settingsQuery = useListSettings(); const quotationsQuery = useListQuotations(undefined); - const products = (unwrap<{ items?: ItemResponse[] }>(itemsQuery.data)?.items ?? []).map(mapItem); - const partners = (unwrap<{ suppliers?: SupplierResponse[] }>(suppliersQuery.data)?.suppliers ?? []).map(mapSupplier); + const products = (unwrap<{ items?: ItemData[] }>(itemsQuery.data)?.items ?? []).map(mapItem); + const partners = (unwrap<{ suppliers?: SupplierData[] }>(suppliersQuery.data)?.suppliers ?? []).map(mapSupplier); const [quotationSettings, setQuotationSettings] = useState([]); useEffect(() => { - const s = unwrap<{ settings?: QuotationSettingResponse[] }>(settingsQuery.data)?.settings; + const s = unwrap<{ settings?: QuotationSettingData[] }>(settingsQuery.data)?.settings; if (s) setQuotationSettings(s.map(mapSetting)); }, [settingsQuery.data]); const [quotations, setQuotations] = useState([]); useEffect(() => { - const qs = unwrap<{ quotations?: QuotationResponse[] }>(quotationsQuery.data)?.quotations; + const qs = unwrap<{ quotations?: QuotationData[] }>(quotationsQuery.data)?.quotations; if (qs) setQuotations(qs.map(mapQuotation)); }, [quotationsQuery.data]); diff --git a/negodata/front/src/features/quotations/types.ts b/negodata/front/src/features/quotations/types.ts index daad291..0d5cc87 100644 --- a/negodata/front/src/features/quotations/types.ts +++ b/negodata/front/src/features/quotations/types.ts @@ -1,7 +1,7 @@ -import type { ItemResponse } from '@/api/generated/model/itemResponse'; -import type { SupplierResponse } from '@/api/generated/model/supplierResponse'; -import type { QuotationSettingResponse } from '@/api/generated/model/quotationSettingResponse'; -import type { QuotationResponse } from '@/api/generated/model/quotationResponse'; +import type { ItemData } from '@/api/generated/model/itemData'; +import type { SupplierData } from '@/api/generated/model/supplierData'; +import type { QuotationSettingData } from '@/api/generated/model/quotationSettingData'; +import type { QuotationData } from '@/api/generated/model/quotationData'; import type { Product, Partner, @@ -15,16 +15,16 @@ export type { Product, Partner, QuotationSetting, Estimate, ChatSession, Negotia // ── 서버 응답 → UI 모델 매퍼 ───────────────────────────────────────────── -// BaseRestApiResponse 봉투에서 내부 payload만 꺼낸다. +// customFetch 가 응답 본문을 그대로 반환하므로 query.data 가 곧 봉투(ResXxxList) — 추가 언랩 불필요. export function unwrap(env: unknown): T | undefined { - return (env as { data?: { data?: T | null } } | undefined)?.data?.data ?? undefined; + return (env as T | undefined) ?? undefined; } -export function mapItem(it: ItemResponse): Product { - return { ...it, id: it.item_id, minPrice: Math.round((it.price || 0) * 0.83), status: 'ACTIVE' }; +export function mapItem(it: ItemData): Product { + return { ...it, id: it.item_id, minPrice: Math.round((it.price || 0) * 0.83), status: 'ACTIVE' } as Product; } -export function mapSupplier(sp: SupplierResponse): Partner { +export function mapSupplier(sp: SupplierData): Partner { return { supplier_id: sp.supplier_id, company_id: sp.company_id, @@ -33,8 +33,8 @@ export function mapSupplier(sp: SupplierResponse): Partner { manager_name: sp.manager_name ?? null, manager_email: sp.manager_email ?? null, priority: sp.priority ?? null, - created_at: sp.created_at, - updated_at: sp.updated_at, + created_at: sp.created_at ?? undefined, + updated_at: sp.updated_at ?? undefined, id: sp.supplier_id, managerName: sp.manager_name || '', managerEmail: sp.manager_email || '', @@ -44,22 +44,22 @@ export function mapSupplier(sp: SupplierResponse): Partner { }; } -export function mapSetting(s: QuotationSettingResponse): QuotationSetting { +export function mapSetting(s: QuotationSettingData): QuotationSetting { return { qt_setting_id: s.qt_setting_id, user_id: s.user_id || '', target_margin: String(s.target_margin_rate ?? ''), anchoring_value: String(s.anchoring_value ?? ''), card_use_count: String(s.card_count ?? ''), - created_at: s.created_at, - updated_at: s.updated_at, + created_at: s.created_at ?? '', + updated_at: s.updated_at ?? '', deleted: false, }; } -export function mapQuotation(q: QuotationResponse): Estimate { +export function mapQuotation(q: QuotationData): Estimate { return { - ...(q as Partial), + ...(q as unknown as Partial), id: q.qt_id, title: q.name, dueDate: q.end_time, From ece24f40676d6f7b589b33934366c824ff42141a Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 17 Jun 2026 15:59:08 +0900 Subject: [PATCH 3/9] =?UTF-8?q?[chore]=20negodata:=20CORS=20client=5Furl?= =?UTF-8?q?=20=EC=98=88=EC=8B=9C=20+=20=EC=8B=9C=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EB=B9=84=20+=20README=20DB=20=EC=A0=88=EC=B0=A8=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config.local.toml.example: client_url 추가(CORS 허용 origin), DB명 negosium_db 정정 - postgres-init: 03-seed.sql → 03-seed-negodata.sql (negodata 전용 시드) - README: DB 준비 절차(스키마+learning+시드) 갱신 - docker-compose 정비 Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 7 ++-- docker-compose.yml | 14 ++++++- .../backend/config/config.local.toml.example | 3 +- postgres-init/03-seed-negodata.sql | 42 +++++++++++++++++++ 4 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 postgres-init/03-seed-negodata.sql diff --git a/README.md b/README.md index 2b2805d..effa92d 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,10 @@ PostgreSQL (외부, 5432) ## 빠른 시작 ```bash -# 1) DB 준비 (최초 1회) — 사용할 PostgreSQL 에 스키마 적용 -psql -h 127.0.0.1 -p 5432 -U postgres -f postgres-init/01-schema.sql -# (negosium_db / negodata_db + tbl_account 생성) +# 1) DB 준비 (최초 1회) — 사용할 PostgreSQL 에 스키마 + 시드 적용 +psql -h 127.0.0.1 -p 5432 -U postgres -f postgres-init/01-schema.sql # negosium_db + 도메인 schema +psql -h 127.0.0.1 -p 5432 -U postgres -f postgres-init/02-learning-schema.sql # agent learning 스키마 +psql -h 127.0.0.1 -p 5432 -U postgres -f postgres-init/03-seed-negodata.sql # negodata 전용 시드 # 2) 백엔드 기동 docker compose up -d # 두 backend (DB 는 config 대로 외부 연결) diff --git a/docker-compose.yml b/docker-compose.yml index ef24f8a..557841f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,8 +9,9 @@ # agent 서버: http://localhost:9500/docs # # DB 준비(최초 1회): postgres-init 의 SQL 을 대상 DB 에 적용한다. -# psql -h -p -U -f postgres-init/01-schema.sql (negosium_db/negodata_db + tbl_account) +# psql -h -p -U -f postgres-init/01-schema.sql (단일 negosium_db + 도메인별 schema) # psql -h -p -U -f postgres-init/02-learning-schema.sql (agent learning 스키마) +# psql -h -p -U -f postgres-init/03-seed-negodata.sql (negodata 전용 시드: admin / admin1234, company.users) services: negosium-backend: @@ -38,6 +39,17 @@ services: - "host.docker.internal:host-gateway" restart: unless-stopped + # negodata 프론트 (Vite dev 서버). + negodata-front: + build: ./negodata/front + container_name: negodata-front + ports: + - "3000:3000" + volumes: + - ./negodata/front:/app + - /app/node_modules + restart: unless-stopped + # 협상 에이전트 (negosium_db 공유, learning 스키마 사용). agent: build: ./agent diff --git a/negodata/backend/config/config.local.toml.example b/negodata/backend/config/config.local.toml.example index 3c8c6ae..bc968f5 100644 --- a/negodata/backend/config/config.local.toml.example +++ b/negodata/backend/config/config.local.toml.example @@ -7,6 +7,7 @@ port = 9400 process_count = 1 is_ssl = false is_test = true +client_url = "http://localhost:3000" # CORS 허용 [LogConfig] print_console = true @@ -16,7 +17,7 @@ log_level = "debug" # 관리형 DB(RDS/Aurora/Azure)는 host 에 엔드포인트, sslmode="require". [MainDBConfig] db_type = "postgresql" -name = "negodata_db" +name = "negosium_db" write_host = "127.0.0.1" write_port = 5432 write_id = "" diff --git a/postgres-init/03-seed-negodata.sql b/postgres-init/03-seed-negodata.sql new file mode 100644 index 0000000..87c194d --- /dev/null +++ b/postgres-init/03-seed-negodata.sql @@ -0,0 +1,42 @@ +-- negodata 전용 로컬/개발 시드 — company.companies + company.users 만 채운다. +-- (negodata 로그인은 company.users 를 쓴다. negosium 의 company.tbl_account 는 대상이 아님.) +-- 전체/공용 시드가 아니다. 재실행 안전(WHERE NOT EXISTS) — 운영 DB 에는 적용하지 않는다. +-- admin 계정은 negodata 프론트 로그인 폼 기본값(admin / admin1234)에 대응한다. +-- 적용(도커 postgres): docker exec -i negosium-db psql -U postgres -d negosium_db < postgres-init/03-seed-negodata.sql + +\connect negosium_db + +-- 회사 1개 (고정 UUID). status 1=active. +INSERT INTO company.companies (company_id, name, email, status) +SELECT '00000000-0000-0000-0000-000000000001', 'Negosium', 'admin@negosium.dev', 1 +WHERE NOT EXISTS ( + SELECT 1 FROM company.companies WHERE company_id = '00000000-0000-0000-0000-000000000001' +); + +-- admin 유저. password 는 'admin1234' 의 bcrypt 해시(백엔드 GetHashedPW 와 동일 알고리즘, checkpw 로 검증됨). +-- role 2=manager (UserRole.MANAGER; ADMIN 코드는 enum 에 없어 최상위인 MANAGER 사용). status 1=active. +INSERT INTO company.users (user_id, company_id, id, password, name, email, last_accessed_at, status, role) +SELECT '00000000-0000-0000-0000-000000000002', + '00000000-0000-0000-0000-000000000001', + 'admin', + '$2b$12$oq5qlcYQ4BNywh9iS7cF1OBP6kGBvB6SfsGgVknCiJ6lMSW5cjW.C', + 'admin', 'admin@negosium.dev', now(), 1, 2 +WHERE NOT EXISTS ( + SELECT 1 FROM company.users WHERE id = 'admin' AND deleted = FALSE +); + +-- 데모 상품 5개. company_id/user_id 는 'admin' 유저의 소속에서 가져온다 +-- (clean DB=Negosium, 테스트가 남긴 DB=그 회사 — 어느 쪽이든 admin 으로 로그인하면 보이도록). +-- category 는 프론트 categoriesList 와 맞춤. quantity_unit 은 ORM(String) 기준 varchar 라벨. +INSERT INTO partner.items (item_id, company_id, user_id, name, code, price, category, model_name, spec, moq, lead_time, manufacturer, made_in, quantity_unit, vat_yn, delivery_fee_yn, internet_lowest_price_yn, category_type) +SELECT v.item_id, u.company_id, u.user_id, v.name, v.code, v.price, v.category, v.model_name, v.spec, v.moq, v.lead_time, v.manufacturer, v.made_in, v.quantity_unit, v.vat_yn, v.delivery_fee_yn, FALSE, v.category_type +FROM company.users u +CROSS JOIN (VALUES + ('00000000-0000-0000-0000-000000000010'::uuid, '리튬인산철 배터리 모듈', 'BAT-LFP-100', 1250000::bigint, '에너지/배터리', 'LFP-100A', '3.2V 100Ah', '10', 14::smallint, '한성에너지', '대한민국', 'EA', TRUE, TRUE, 1), + ('00000000-0000-0000-0000-000000000011'::uuid, '산업용 6축 로봇암', 'ROB-6AX-22', 18900000::bigint, '자동화설비', 'RX-6A', '가반하중 12kg', '1', 30::smallint, '오토메카', '일본', 'EA', TRUE, FALSE, 2), + ('00000000-0000-0000-0000-000000000012'::uuid, 'MEMS 가속도 센서', 'SEN-MEMS-3A', 8500::bigint, '반도체소자/센서', 'MA-3X', '±16g 3축', '100', 7::smallint, '센서텍', '대만', 'EA', TRUE, TRUE, 3), + ('00000000-0000-0000-0000-000000000013'::uuid, '탄소섬유 시트', 'MAT-CF-12', 320000::bigint, '신소재', 'CF-T700', '0.5T 1000x1000', '5', 21::smallint, '카본소재', '대한민국', 'BOX', TRUE, FALSE, 4), + ('00000000-0000-0000-0000-000000000014'::uuid, '광트랜시버 SFP+ 10G', 'OPT-SFP-10G', 95000::bigint, '광학/통신', 'SFP-10G-LR', '10km LC', '20', 10::smallint, '옵틱링크', '중국', 'EA', TRUE, TRUE, 5) +) AS v(item_id, name, code, price, category, model_name, spec, moq, lead_time, manufacturer, made_in, quantity_unit, vat_yn, delivery_fee_yn, category_type) +WHERE u.id = 'admin' AND u.deleted = FALSE + AND NOT EXISTS (SELECT 1 FROM partner.items i WHERE i.item_id = v.item_id); From 4587be5844c2a8c7fab0830a31c2c07e1c9593d5 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Thu, 18 Jun 2026 11:00:15 +0900 Subject: [PATCH 4/9] =?UTF-8?q?[refactor]=20negodata/backend:=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=20PageParams(page/size/skip)=20+=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20enum=C2=B7=EB=AA=A8=EB=8D=B8=20=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../backend/common/database/model/models.py | 80 +++++++++++++++---- negodata/backend/common/enums.py | 51 ++++++++++++ negodata/backend/common/models/gmodel.py | 19 +++++ 3 files changed, 133 insertions(+), 17 deletions(-) diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index 98aee18..1ee8139 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -25,29 +25,14 @@ class _DBTypeMixin: return DBType.MAIN.value -# ERD 공통 컬럼(created/updated/deleted). negodata 도메인 테이블은 단일 MAIN DB 에서 soft-delete 를 쓴다. +# ERD 공통 컬럼 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) - pw = Column(String(255), nullable=False, default="") - nickname = Column(String(45), nullable=False, default="") - is_blocked = Column(Boolean, nullable=False, default=False) - 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) +# ERD 도메인 모델 class companies(MainTableMixin, MAIN_BASE): __tablename__ = "companies" @@ -122,6 +107,31 @@ class suppliers(MainTableMixin, MAIN_BASE): priority = Column(String(10), nullable=True) # True/False 가 아닌 string value 가능 +class nego_cards(MainTableMixin, MAIN_BASE): + __tablename__ = "nego_cards" + + nego_card_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 등록 유저(o2o 기본 카드는 NULL) + name = Column(String(20), nullable=True) # 카드명 + number = Column(String(10), nullable=True) # 식별번호(카드코드) + script = Column(String(255), nullable=True) # 협상 스크립트(평문 미리보기) + edit_script = Column(JSONB, nullable=True) # 편집된 스크립트(Slate JSON) + + +class wild_cards(MainTableMixin, MAIN_BASE): + __tablename__ = "wild_cards" + + wild_card_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 등록 유저(o2o 기본 카드는 NULL) + name = Column(String(20), nullable=True) # 카드명 + number = Column(String(10), nullable=True) # 식별번호(카드코드) + script = Column(String(255), nullable=True) # 협상 스크립트(평문 미리보기) + edit_script = Column(JSONB, nullable=True) # 편집된 스크립트(Slate JSON) + condition = Column(String(255), nullable=True) # 사용 조건(트리거) + available = Column(Boolean, nullable=False, default=False) # 수동 협상 적용 여부(ACTIVE/INACTIVE 매핑) + memo = Column(String(255), nullable=True) # 자유 메모 + + class quotation_settings(MainTableMixin, MAIN_BASE): __tablename__ = "quotation_settings" @@ -159,3 +169,39 @@ class quotations(MainTableMixin, MAIN_BASE): preferred_sp_name = Column(String(20), nullable=True) equal_bid_yn = Column(Boolean, nullable=True) equal_bid_data = Column(JSONB, nullable=True) + + +class sessions(MainTableMixin, MAIN_BASE): + __tablename__ = "sessions" + + session_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + quotation_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 소속 견적(quotations.qt_id) + item_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 대상 상품(items.item_id) + supplier_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 대상 공급사(suppliers.supplier_id) + + qt_number = Column(String(30), nullable=False) # 견적번호 스냅샷 + qt_round = Column(Integer, nullable=False) # 견적 라운드 스냅샷 + qt_type = Column(SmallInteger, nullable=False) # QuotationType 스냅샷 + target_price = Column(BigInteger, nullable=False) # 목표가(원) + status = Column(SmallInteger, nullable=False) # SessionStatus 코드 + bid_price = Column(BigInteger, nullable=True) # 입찰가(원) + bid_at = Column(DateTime, nullable=True) # 입찰 시각 + end_time = Column(DateTime, nullable=False) # 세션 종료 시각 + reject_reason = Column(String(255), nullable=True) + reject_price = Column(BigInteger, nullable=True) + reject_delivery_type = Column(SmallInteger, nullable=True) # DeliveryType 코드 + + +class chats(MainTableMixin, MAIN_BASE): + __tablename__ = "chats" + + chat_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + session_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 소속 세션(sessions.session_id) + card_id = Column(UUID(as_uuid=True), nullable=True) # 사용된 카드(nego_cards.nego_card_id) + + seq = Column(Integer, nullable=False, default=1) # 세션 내 메시지 순번(프로토콜 index) + sender = Column(SmallInteger, nullable=False) # ChatSender 코드(1=bot, 2=partner) + target_price = Column(BigInteger, nullable=False) # 제시 목표가(원) + card_used_yn = Column(Boolean, nullable=True) # 카드 사용 여부 + indicator_value = Column(Numeric(8, 6), nullable=True) + card_type = Column(SmallInteger, nullable=True) # 1=nego_card, 2=wild_card diff --git a/negodata/backend/common/enums.py b/negodata/backend/common/enums.py index 9a627ef..21d115a 100644 --- a/negodata/backend/common/enums.py +++ b/negodata/backend/common/enums.py @@ -38,9 +38,11 @@ class ErrorType(Enum): # 상품 관련 에러 ITEM_NOT_FOUND = 1300 + ITEM_CODE_DUPLICATE = auto() # 협력사 관련 에러 SUPPLIER_NOT_FOUND = 1400 + SUPPLIER_CODE_DUPLICATE = auto() # 견적 관련 에러 QUOTATION_NOT_FOUND = 1500 @@ -48,6 +50,9 @@ class ErrorType(Enum): # 견적 설정 관련 에러 QUOTATION_SETTING_NOT_FOUND = 1600 + # 협상카드 관련 에러 + CARD_NOT_FOUND = 1700 + # 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) @@ -102,6 +107,30 @@ class QuotationType(Enum): REQUOTE = 2 +class QuotationStatus(Enum): + """quotations.status 코드값(SMALLINT). 프론트 견적상태 뱃지와 매핑된다.""" + + CREATED = 1 # 견적생성 + ACTIVE = 2 # 견적진행중 + CLOSED = 3 # 견적마감 + ON_HOLD = 4 # 협상보류 + + +class SessionStatus(Enum): + """negotiation.sessions.status 코드값. 협력사별 협상 세션 진행 상태.""" + + NEGOTIATING = 1 # 협상중 + COMPLETED = 2 # 협상종료 + REJECTED = 3 # 협상거부 + + +class ChatSender(Enum): + """negotiation.chats.sender 코드값. 채팅 발신 주체.""" + + BOT = 1 # 구매대행 봇 + PARTNER = 2 # 협력사 + + class DeliveryType(Enum): """items.delivery_type 코드값. 협상 채팅의 배송형태 선택지와 동일 집합.""" @@ -110,6 +139,13 @@ class DeliveryType(Enum): PICKUP = 3 # 픽업배송 +class CardStatus(Enum): + """nego_cards.status 코드값. 와일드카드의 협상 적용 여부(수동 승인). 일반 협상카드는 상시 ACTIVE.""" + + ACTIVE = 1 + INACTIVE = 2 + + # 도메인 enum 한글 라벨. 프론트 드롭다운 표시는 이 라벨을 쓴다(값=코드). ENUM_LABELS = { UserStatus.ACTIVE: "활성", @@ -120,9 +156,20 @@ ENUM_LABELS = { CompanyStatus.INACTIVE: "비활성", QuotationType.RENEGO: "재협상", QuotationType.REQUOTE: "재견적", + QuotationStatus.CREATED: "견적생성", + QuotationStatus.ACTIVE: "견적진행중", + QuotationStatus.CLOSED: "견적마감", + QuotationStatus.ON_HOLD: "협상보류", + SessionStatus.NEGOTIATING: "협상중", + SessionStatus.COMPLETED: "협상종료", + SessionStatus.REJECTED: "협상거부", + ChatSender.BOT: "봇", + ChatSender.PARTNER: "협력사", DeliveryType.PARTNER: "협력사배송", DeliveryType.COURIER: "지정택배배송", DeliveryType.PICKUP: "픽업배송", + CardStatus.ACTIVE: "적용", + CardStatus.INACTIVE: "대기", } # 프론트로 내려주는 도메인 코드 enum 모음. 새 코드 enum 추가 시 여기에 등록한다. @@ -131,5 +178,9 @@ DOMAIN_ENUMS = { "user_role": UserRole, "company_status": CompanyStatus, "quotation_type": QuotationType, + "quotation_status": QuotationStatus, + "session_status": SessionStatus, + "chat_sender": ChatSender, "delivery_type": DeliveryType, + "card_status": CardStatus, } diff --git a/negodata/backend/common/models/gmodel.py b/negodata/backend/common/models/gmodel.py index 694ab6a..cfa6d5d 100644 --- a/negodata/backend/common/models/gmodel.py +++ b/negodata/backend/common/models/gmodel.py @@ -1,6 +1,7 @@ import json from typing import Optional +from fastapi import Query from pydantic import BaseModel, Field from common.enums import ErrorType @@ -45,6 +46,24 @@ class Res_WebPacketProtocol(WebPacketProtocol): msg: Optional[str] = None +class Res_PageProtocol(Res_WebPacketProtocol): + # 목록 응답 공용 페이지 메타. + total: int = 0 + page: int = 0 + size: int = 0 + + +class PageParams: + # 목록 엔드포인트 공용 쿼리 파라미터. 라우터에서 Depends() 로 주입한다. + def __init__(self, page: int = Query(1, ge=1), size: int = Query(20, ge=1, le=100)): + self.page = page + self.size = size + + @property + def skip(self) -> int: + return (self.page - 1) * self.size + + class UserInfo(StructModel): """JWT subject 로 인코딩되는 유저 식별 정보.""" From 1fef405e88347d128735984381d34e931a56e9a0 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Thu, 18 Jun 2026 11:00:15 +0900 Subject: [PATCH 5/9] =?UTF-8?q?[feat]=20negodata/backend:=20=EC=83=81?= =?UTF-8?q?=ED=92=88=C2=B7=ED=98=91=EB=A0=A5=EC=82=AC=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20+=20=EA=B2=80=EC=83=89/=ED=95=84=ED=84=B0(?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=C2=B7priority)=20+=20?= =?UTF-8?q?=EC=83=81=ED=92=88=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20dis?= =?UTF-8?q?tinct=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- negodata/backend/crud/item_crud.py | 67 +++++++++++++++++++ negodata/backend/crud/supplier_crud.py | 47 ++++++++++++- negodata/backend/router/v1/item/item.py | 20 ++++-- negodata/backend/router/v1/item/protocol.py | 24 +++++-- .../backend/router/v1/supplier/protocol.py | 15 +++-- .../backend/router/v1/supplier/supplier.py | 17 +++-- negodata/backend/services/item_service.py | 67 +++++++++++++++++-- negodata/backend/services/supplier_service.py | 44 ++++++++++-- 8 files changed, 269 insertions(+), 32 deletions(-) diff --git a/negodata/backend/crud/item_crud.py b/negodata/backend/crud/item_crud.py index 51ac97e..0995d08 100644 --- a/negodata/backend/crud/item_crud.py +++ b/negodata/backend/crud/item_crud.py @@ -17,6 +17,18 @@ class IItemCRUD(ABC): async def search(self, cdb: AsyncSession, company_id, search, category, skip, limit) -> Tuple[ErrorType, list, int]: pass + @abstractmethod + async def distinct_categories(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]: + pass + + @abstractmethod + async def code_exists(self, cdb: AsyncSession, company_id, code) -> Tuple[ErrorType, bool]: + pass + + @abstractmethod + async def existing_codes(self, cdb: AsyncSession, company_id, codes: list) -> Tuple[ErrorType, list]: + pass + @abstractmethod async def get_by_id(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, items]: pass @@ -62,6 +74,61 @@ class ItemCRUD(IItemCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, [], 0 + # 회사의 모든 상품에서 카테고리를 "이름 단위"로 중복 제거해 (이름, 대표 타입) 목록을 돌려준다. + async def distinct_categories(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]: + try: + query = ( + select(items.category, func.min(items.category_type)) + .where( + items.company_id == company_id, + items.deleted == False, # noqa: E712 + items.category.isnot(None), + items.category != "", + ) + .group_by(items.category) + .order_by(items.category) + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, [] + return ErrorType.SUCCESS, list(rows) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + + async def code_exists(self, cdb: AsyncSession, company_id, code) -> Tuple[ErrorType, bool]: + try: + query = select(func.count()).select_from(items).where( + items.company_id == company_id, + items.code == code, + items.deleted == False, # noqa: E712 + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, False + cnt = int(rows[0] or 0) if rows else 0 + return ErrorType.SUCCESS, cnt > 0 + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, False + + async def existing_codes(self, cdb: AsyncSession, company_id, codes: list) -> Tuple[ErrorType, list]: + try: + if not codes: + return ErrorType.SUCCESS, [] + query = select(items.code).where( + items.company_id == company_id, + items.code.in_(codes), + items.deleted == False, # noqa: E712 + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, [] + return ErrorType.SUCCESS, [c for c in rows if c is not None] + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + 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 diff --git a/negodata/backend/crud/supplier_crud.py b/negodata/backend/crud/supplier_crud.py index 9a8ccca..e0178a7 100644 --- a/negodata/backend/crud/supplier_crud.py +++ b/negodata/backend/crud/supplier_crud.py @@ -14,7 +14,15 @@ 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]: + async def search(self, cdb: AsyncSession, company_id, search, priority, skip, limit) -> Tuple[ErrorType, list, int]: + pass + + @abstractmethod + async def code_exists(self, cdb: AsyncSession, company_id, code) -> Tuple[ErrorType, bool]: + pass + + @abstractmethod + async def existing_codes(self, cdb: AsyncSession, company_id, codes: list) -> Tuple[ErrorType, list]: pass @abstractmethod @@ -36,7 +44,7 @@ class ISupplierCRUD(ABC): class SupplierCRUD(ISupplierCRUD): async def search( - self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int + self, cdb: AsyncSession, company_id, search: Optional[str], priority: Optional[str], skip: int, limit: int ) -> Tuple[ErrorType, list, int]: try: conditions = [suppliers.deleted == False, suppliers.company_id == company_id] # noqa: E712 @@ -48,6 +56,8 @@ class SupplierCRUD(ISupplierCRUD): suppliers.manager_name.ilike(f"%{search}%"), ) ) + if priority: + conditions.append(suppliers.priority == priority) where = and_(*conditions) cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(suppliers).where(where)) @@ -66,6 +76,39 @@ class SupplierCRUD(ISupplierCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, [], 0 + async def code_exists(self, cdb: AsyncSession, company_id, code) -> Tuple[ErrorType, bool]: + try: + query = select(func.count()).select_from(suppliers).where( + suppliers.company_id == company_id, + suppliers.code == code, + suppliers.deleted == False, # noqa: E712 + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, False + cnt = int(rows[0] or 0) if rows else 0 + return ErrorType.SUCCESS, cnt > 0 + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, False + + async def existing_codes(self, cdb: AsyncSession, company_id, codes: list) -> Tuple[ErrorType, list]: + try: + if not codes: + return ErrorType.SUCCESS, [] + query = select(suppliers.code).where( + suppliers.company_id == company_id, + suppliers.code.in_(codes), + suppliers.deleted == False, # noqa: E712 + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, [] + return ErrorType.SUCCESS, [c for c in rows if c is not None] + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + 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 diff --git a/negodata/backend/router/v1/item/item.py b/negodata/backend/router/v1/item/item.py index e1dd640..e82bb92 100644 --- a/negodata/backend/router/v1/item/item.py +++ b/negodata/backend/router/v1/item/item.py @@ -2,15 +2,18 @@ from uuid import UUID from fastapi import APIRouter, Depends, File, Query, UploadFile -from common.models.gmodel import UserInfo +from common.models.gmodel import PageParams, UserInfo from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse from services.item_service import ItemService from .protocol import ( + Req_CheckCodes, Req_CreateItem, Req_UpdateItem, + Res_CheckCodes, Res_DeleteItem, Res_ExcelUpload, Res_Item, + Res_ItemCategories, Res_ItemList, Res_LowestPriceResult, Res_LowestPriceTrigger, @@ -26,10 +29,14 @@ async def list_items( 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), + pg: PageParams = Depends(), ): - return RemoveNoneResponse(await service.list_items(user_info.company_id, search, category, page, size)) + return RemoveNoneResponse(await service.list_items(user_info.company_id, search, category, pg)) + + +@router.get(path="/categories", response_model=Res_ItemCategories, summary="상품 카테고리 목록(distinct)") +async def list_item_categories(service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.list_categories(user_info.company_id)) @router.post(path="/create", response_model=Res_Item, summary="상품 등록") @@ -39,6 +46,11 @@ async def create_item(req: Req_CreateItem, service: ItemService = Depends(), use ) +@router.post(path="/check-codes", response_model=Res_CheckCodes, summary="코드 중복 사전검사(업로드 즉시)") +async def check_item_codes(req: Req_CheckCodes, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.check_codes(user_info.company_id, req.codes)) + + @router.post(path="/upload-excel", response_model=Res_ExcelUpload, summary="엑셀 일괄 등록(스텁)") async def upload_items_excel( service: ItemService = Depends(), diff --git a/negodata/backend/router/v1/item/protocol.py b/negodata/backend/router/v1/item/protocol.py index cef45da..9ae9bcc 100644 --- a/negodata/backend/router/v1/item/protocol.py +++ b/negodata/backend/router/v1/item/protocol.py @@ -4,7 +4,7 @@ from typing import Optional from pydantic import ConfigDict -from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol +from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol class ItemProtocol(WebPacketProtocol): @@ -82,11 +82,25 @@ class Res_Item(Res_WebPacketProtocol): item: Optional[ItemData] = None -class Res_ItemList(Res_WebPacketProtocol): +class Res_ItemList(Res_PageProtocol): items: list[ItemData] = [] - total: int = 0 - page: int = 0 - size: int = 0 + + +class ItemCategory(WebPacketProtocol): + name: str + category_type: int = 1 + + +class Res_ItemCategories(Res_WebPacketProtocol): + categories: list[ItemCategory] = [] + + +class Req_CheckCodes(ItemProtocol): + codes: list[str] = [] + + +class Res_CheckCodes(Res_WebPacketProtocol): + existing: list[str] = [] # codes 중 이미 DB(같은 회사)에 존재하는 코드들 class Res_DeleteItem(Res_WebPacketProtocol): diff --git a/negodata/backend/router/v1/supplier/protocol.py b/negodata/backend/router/v1/supplier/protocol.py index 4b4f5a3..4aed82b 100644 --- a/negodata/backend/router/v1/supplier/protocol.py +++ b/negodata/backend/router/v1/supplier/protocol.py @@ -4,7 +4,7 @@ from typing import Optional from pydantic import ConfigDict -from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol +from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol class SupplierProtocol(WebPacketProtocol): @@ -49,11 +49,16 @@ class Res_Supplier(Res_WebPacketProtocol): supplier: Optional[SupplierData] = None -class Res_SupplierList(Res_WebPacketProtocol): +class Res_SupplierList(Res_PageProtocol): suppliers: list[SupplierData] = [] - total: int = 0 - page: int = 0 - size: int = 0 + + +class Req_CheckCodes(SupplierProtocol): + codes: list[str] = [] + + +class Res_CheckCodes(Res_WebPacketProtocol): + existing: list[str] = [] # codes 중 이미 DB(같은 회사)에 존재하는 코드들 class Res_DeleteSupplier(Res_WebPacketProtocol): diff --git a/negodata/backend/router/v1/supplier/supplier.py b/negodata/backend/router/v1/supplier/supplier.py index bfa34c6..d344d6b 100644 --- a/negodata/backend/router/v1/supplier/supplier.py +++ b/negodata/backend/router/v1/supplier/supplier.py @@ -2,12 +2,14 @@ from uuid import UUID from fastapi import APIRouter, Depends, File, Query, UploadFile -from common.models.gmodel import UserInfo +from common.models.gmodel import PageParams, UserInfo from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse from services.supplier_service import SupplierService from .protocol import ( + Req_CheckCodes, Req_CreateSupplier, Req_UpdateSupplier, + Res_CheckCodes, Res_DeleteSupplier, Res_ExcelUpload, Res_Supplier, @@ -23,10 +25,10 @@ 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), + priority: str | None = Query(None, description="우선순위 필터(HIGH/MEDIUM/LOW)"), + pg: PageParams = Depends(), ): - return RemoveNoneResponse(await service.list_suppliers(user_info.company_id, search, page, size)) + return RemoveNoneResponse(await service.list_suppliers(user_info.company_id, search, priority, pg)) @router.post(path="/create", response_model=Res_Supplier, summary="협력사 등록") @@ -38,6 +40,13 @@ async def create_supplier( ) +@router.post(path="/check-codes", response_model=Res_CheckCodes, summary="코드 중복 사전검사(업로드 즉시)") +async def check_supplier_codes( + req: Req_CheckCodes, service: SupplierService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) +): + return RemoveNoneResponse(await service.check_codes(user_info.company_id, req.codes)) + + @router.post(path="/upload-excel", response_model=Res_ExcelUpload, summary="협력사 엑셀 일괄 등록(스텁)") async def upload_suppliers_excel( service: SupplierService = Depends(), diff --git a/negodata/backend/services/item_service.py b/negodata/backend/services/item_service.py index 18ff4b9..b5409f0 100644 --- a/negodata/backend/services/item_service.py +++ b/negodata/backend/services/item_service.py @@ -6,8 +6,17 @@ 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 common.models.gmodel import PageParams from crud.item_crud import IItemCRUD, ItemCRUD -from router.v1.item.protocol import ItemData, Res_DeleteItem, Res_Item, Res_ItemList +from router.v1.item.protocol import ( + ItemCategory, + ItemData, + Res_CheckCodes, + Res_DeleteItem, + Res_Item, + Res_ItemCategories, + Res_ItemList, +) class ItemService: @@ -29,15 +38,14 @@ class ItemService: 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) + async def list_items(self, company_id: str, search, category, pg: PageParams) -> Res_ItemList: + res = Res_ItemList(page=pg.page, size=pg.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), + lambda s: self.item_crud.search(s, company_uuid, search, category, pg.skip, pg.size), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) @@ -46,6 +54,21 @@ class ItemService: res.total = total return res + async def list_categories(self, company_id: str) -> Res_ItemCategories: + """회사 전체 상품에서 distinct 카테고리(이름+타입)를 돌려준다. 카테고리 목록은 백엔드가 책임진다.""" + res = Res_ItemCategories() + company_uuid = uuid.UUID(company_id) + err_type, rows = await DB_SESSION_MNG.execute_lambda( + items.DBType(), + DBWRType.DB_READ.value, + lambda s: self.item_crud.distinct_categories(s, company_uuid), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.categories = [ItemCategory(name=r[0], category_type=r[1] if r[1] is not None else 1) for r in rows] + 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)) @@ -55,9 +78,41 @@ class ItemService: res.item = ItemData.model_validate(item) return res + async def check_codes(self, company_id: str, codes: list) -> Res_CheckCodes: + """업로드 즉시 호출: codes 중 같은 회사 DB 에 이미 있는 코드를 돌려준다(미리보기 사전검사).""" + res = Res_CheckCodes() + company_uuid = uuid.UUID(company_id) + err_type, existing = await DB_SESSION_MNG.execute_lambda( + items.DBType(), + DBWRType.DB_READ.value, + lambda s: self.item_crud.existing_codes(s, company_uuid, codes), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.existing = list(existing) + 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)) + company_uuid = uuid.UUID(company_id) + + # DB 중복코드 검증: 같은 회사에 동일 code 가 이미 있으면 거부(프론트는 받아온 목록만 보므로 여기서 최종 차단). + code = data.get("code") + if code: + dup_err, exists = await DB_SESSION_MNG.execute_lambda( + items.DBType(), + DBWRType.DB_READ.value, + lambda s: self.item_crud.code_exists(s, company_uuid, code), + ) + if dup_err != ErrorType.SUCCESS: + res.result.SetResult(dup_err) + return res + if exists: + res.result.SetResult(ErrorType.ITEM_CODE_DUPLICATE) + return res + + item = items(**data, company_id=company_uuid, 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)], diff --git a/negodata/backend/services/supplier_service.py b/negodata/backend/services/supplier_service.py index c000b07..bac747e 100644 --- a/negodata/backend/services/supplier_service.py +++ b/negodata/backend/services/supplier_service.py @@ -6,8 +6,9 @@ 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 common.models.gmodel import PageParams from crud.supplier_crud import ISupplierCRUD, SupplierCRUD -from router.v1.supplier.protocol import Res_DeleteSupplier, Res_Supplier, Res_SupplierList, SupplierData +from router.v1.supplier.protocol import Res_CheckCodes, Res_DeleteSupplier, Res_Supplier, Res_SupplierList, SupplierData class SupplierService: @@ -29,15 +30,14 @@ class SupplierService: 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) + async def list_suppliers(self, company_id: str, search, priority, pg: PageParams) -> Res_SupplierList: + res = Res_SupplierList(page=pg.page, size=pg.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), + lambda s: self.supplier_crud.search(s, company_uuid, search, priority, pg.skip, pg.size), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) @@ -55,9 +55,41 @@ class SupplierService: res.supplier = SupplierData.model_validate(supplier) return res + async def check_codes(self, company_id: str, codes: list) -> Res_CheckCodes: + """업로드 즉시 호출: codes 중 같은 회사 DB 에 이미 있는 코드를 돌려준다(미리보기 사전검사).""" + res = Res_CheckCodes() + company_uuid = uuid.UUID(company_id) + err_type, existing = await DB_SESSION_MNG.execute_lambda( + suppliers.DBType(), + DBWRType.DB_READ.value, + lambda s: self.supplier_crud.existing_codes(s, company_uuid, codes), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.existing = list(existing) + 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)) + company_uuid = uuid.UUID(company_id) + + # DB 중복코드 검증: 같은 회사에 동일 code 가 이미 있으면 거부(프론트는 받아온 목록만 보므로 여기서 최종 차단). + code = data.get("code") + if code: + dup_err, exists = await DB_SESSION_MNG.execute_lambda( + suppliers.DBType(), + DBWRType.DB_READ.value, + lambda s: self.supplier_crud.code_exists(s, company_uuid, code), + ) + if dup_err != ErrorType.SUCCESS: + res.result.SetResult(dup_err) + return res + if exists: + res.result.SetResult(ErrorType.SUPPLIER_CODE_DUPLICATE) + return res + + supplier = suppliers(**data, company_id=company_uuid, 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)], From e7660b10acecd6badfc2e47fd625f45cd0b26433 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Thu, 18 Jun 2026 11:00:15 +0900 Subject: [PATCH 6/9] =?UTF-8?q?[feat]=20negodata/backend:=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EB=8F=84=EB=A9=94=EC=9D=B8=20CRUD=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20+=20=EA=B2=AC=EC=A0=81=C2=B7auth=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- negodata/backend/crud/card_crud.py | 104 +++++++++ negodata/backend/crud/quotation_crud.py | 80 ++++++- negodata/backend/router/router.py | 2 + negodata/backend/router/v1/auth/protocol.py | 1 + negodata/backend/router/v1/card/card.py | 50 +++++ negodata/backend/router/v1/card/protocol.py | 63 ++++++ .../backend/router/v1/quotation/protocol.py | 7 +- .../backend/router/v1/quotation/quotation.py | 7 +- negodata/backend/services/auth_service.py | 3 +- negodata/backend/services/card_service.py | 204 ++++++++++++++++++ .../backend/services/quotation_service.py | 120 +++++++++-- 11 files changed, 609 insertions(+), 32 deletions(-) create mode 100644 negodata/backend/crud/card_crud.py create mode 100644 negodata/backend/router/v1/card/card.py create mode 100644 negodata/backend/router/v1/card/protocol.py create mode 100644 negodata/backend/services/card_service.py diff --git a/negodata/backend/crud/card_crud.py b/negodata/backend/crud/card_crud.py new file mode 100644 index 0000000..c05019f --- /dev/null +++ b/negodata/backend/crud/card_crud.py @@ -0,0 +1,104 @@ +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.enums import ErrorType +from common.logger import LOG +from common.utils.gtime import GTime + + +# 협상카드 CRUD. nego_cards/wild_cards 두 테이블에 공통으로 쓰는 제네릭 구현. +class ICardCRUD(ABC): + @abstractmethod + async def search(self, cdb: AsyncSession, model, user_id, search, skip, limit) -> Tuple[ErrorType, list, int]: + pass + + @abstractmethod + async def get_by_id(self, cdb: AsyncSession, model, pk_col, card_id) -> Tuple[ErrorType, object]: + pass + + @abstractmethod + async def add(self, cdb: AsyncSession, card) -> ErrorType: + pass + + @abstractmethod + async def update(self, cdb: AsyncSession, model, pk_col, card_id, data: dict) -> ErrorType: + pass + + @abstractmethod + async def soft_delete(self, cdb: AsyncSession, model, pk_col, card_id) -> ErrorType: + pass + + +class CardCRUD(ICardCRUD): + async def search( + self, cdb: AsyncSession, model, user_id, search: Optional[str], skip: int, limit: int + ) -> Tuple[ErrorType, list, int]: + try: + conditions = [model.deleted == False, model.user_id == user_id] # noqa: E712 + if search: + conditions.append( + or_( + model.name.ilike(f"%{search}%"), + model.number.ilike(f"%{search}%"), + model.script.ilike(f"%{search}%"), + ) + ) + where = and_(*conditions) + + cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(model).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(model).where(where).order_by(model.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, model, pk_col, card_id) -> Tuple[ErrorType, object]: + try: + query = select(model).where(pk_col == card_id, model.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(self, cdb: AsyncSession, card) -> ErrorType: + try: + return await DB_SESSION_MNG.insert(cdb, card) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + + async def update(self, cdb: AsyncSession, model, pk_col, card_id, data: dict) -> ErrorType: + try: + if not data: + return ErrorType.SUCCESS + query = update(model).where(pk_col == card_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, model, pk_col, card_id) -> ErrorType: + try: + query = update(model).where(pk_col == card_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 index 8527fbf..c213073 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -6,14 +6,13 @@ 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.database.model.models import quotations, sessions, chats, nego_cards, wild_cards from common.enums import ErrorType from common.logger import LOG from common.utils.gtime import GTime -# 견적 CRUD. quotations 테이블에는 company_id 가 없어 회사 스코프는 하지 않는다(토큰 검증만). -# user_id 는 생성 시 소유자로 기록만 한다. +# 견적 CRUD. class IQuotationCRUD(ABC): @abstractmethod async def search( @@ -37,6 +36,18 @@ class IQuotationCRUD(ABC): async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType: pass + @abstractmethod + async def list_sessions(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]: + pass + + @abstractmethod + async def list_chats(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]: + pass + + @abstractmethod + async def list_used_cards(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]: + pass + class QuotationCRUD(IQuotationCRUD): async def search( @@ -114,3 +125,66 @@ class QuotationCRUD(IQuotationCRUD): except Exception as ex: LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED + + # ----- 견적 상세: 세션 / 채팅 / 사용카드 (읽기 전용) ----- + async def list_sessions(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]: + try: + query = ( + select(sessions) + .where(sessions.quotation_id == qt_id, sessions.deleted == False) # noqa: E712 + .order_by(sessions.created_at.asc()) + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, [] + return ErrorType.SUCCESS, list(rows) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + + async def list_chats(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]: + try: + query = ( + select(chats) + .where(chats.session_id == session_id, chats.deleted == False) # noqa: E712 + .order_by(chats.seq.asc()) + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, [] + return ErrorType.SUCCESS, list(rows) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + + async def list_used_cards(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]: + """견적의 세션들에서 실제 사용된 카드(chats.card_used_yn)를 카드 카탈로그와 조인. + 반환: [(chat_row, card_id, name, script), ...]. + card_type 1=nego_cards / 2=wild_cards 양쪽을 LEFT JOIN 해서 어느 쪽이든 잡는다. + """ + try: + query = ( + select( + chats, + func.coalesce(nego_cards.nego_card_id, wild_cards.wild_card_id).label("card_pk"), + func.coalesce(nego_cards.name, wild_cards.name).label("card_name"), + func.coalesce(nego_cards.script, wild_cards.script).label("card_script"), + ) + .join(sessions, sessions.session_id == chats.session_id) + .outerjoin(nego_cards, and_(nego_cards.nego_card_id == chats.card_id, chats.card_type == 1)) + .outerjoin(wild_cards, and_(wild_cards.wild_card_id == chats.card_id, chats.card_type == 2)) + .where( + sessions.quotation_id == qt_id, + chats.card_used_yn == True, # noqa: E712 + chats.deleted == False, # noqa: E712 + sessions.deleted == False, # noqa: E712 + ) + .order_by(chats.created_at.asc()) + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, [] + return ErrorType.SUCCESS, list(rows) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] diff --git a/negodata/backend/router/router.py b/negodata/backend/router/router.py index 3d1c0b5..19ce6b0 100644 --- a/negodata/backend/router/router.py +++ b/negodata/backend/router/router.py @@ -12,6 +12,7 @@ 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.card.card import router.v1.quotation.quotation import router.v1.quotation_setting.quotation_setting import router.v1.enums.enums @@ -60,6 +61,7 @@ async def healthz(): 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.card.card.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/protocol.py b/negodata/backend/router/v1/auth/protocol.py index e8e332f..983ed8d 100644 --- a/negodata/backend/router/v1/auth/protocol.py +++ b/negodata/backend/router/v1/auth/protocol.py @@ -53,4 +53,5 @@ class Res_Me(Res_WebPacketProtocol): email: Optional[str] = None contact_number: Optional[str] = None role: int = UserRole.USER.value + role_label: str = "" company: Optional[CompanyData] = Field(default=None) diff --git a/negodata/backend/router/v1/card/card.py b/negodata/backend/router/v1/card/card.py new file mode 100644 index 0000000..8281109 --- /dev/null +++ b/negodata/backend/router/v1/card/card.py @@ -0,0 +1,50 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, Query + +from common.models.gmodel import PageParams, UserInfo +from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse +from services.card_service import CardService +from .protocol import ( + Req_CreateCard, + Req_UpdateCard, + Res_Card, + Res_CardList, + Res_DeleteCard, +) + +router = APIRouter(prefix="/v1/card", tags=["Card"], responses={404: {"description": "Not found"}}) + + +@router.get(path="/list", response_model=Res_CardList, summary="협상카드 목록") +async def list_cards( + service: CardService = Depends(), + user_info: UserInfo = Depends(IsValidAccessToken), + search: str | None = Query(None, description="카드명/카드번호/스크립트 검색"), + pg: PageParams = Depends(), +): + return RemoveNoneResponse(await service.list_cards(user_info.user_id, search, pg)) + + +@router.post(path="/create", response_model=Res_Card, summary="협상카드 등록") +async def create_card(req: Req_CreateCard, service: CardService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse( + await service.create_card(user_info.user_id, req.model_dump(exclude_unset=True)) + ) + + +@router.get(path="/{card_id}", response_model=Res_Card, summary="협상카드 조회") +async def get_card(card_id: UUID, service: CardService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.get_card(user_info.user_id, str(card_id))) + + +@router.patch(path="/update/{card_id}", response_model=Res_Card, summary="협상카드 수정") +async def update_card( + card_id: UUID, req: Req_UpdateCard, service: CardService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) +): + return RemoveNoneResponse(await service.update_card(user_info.user_id, str(card_id), req.model_dump(exclude_unset=True))) + + +@router.delete(path="/delete/{card_id}", response_model=Res_DeleteCard, summary="협상카드 삭제") +async def delete_card(card_id: UUID, service: CardService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.delete_card(user_info.user_id, str(card_id))) diff --git a/negodata/backend/router/v1/card/protocol.py b/negodata/backend/router/v1/card/protocol.py new file mode 100644 index 0000000..c6c1524 --- /dev/null +++ b/negodata/backend/router/v1/card/protocol.py @@ -0,0 +1,63 @@ +import uuid +from datetime import datetime +from typing import Any, Optional + +from pydantic import ConfigDict + +from common.enums import CardStatus +from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol + + +class CardProtocol(WebPacketProtocol): + pass + + +class Req_CreateCard(CardProtocol): + is_wildcard: bool = False + name: Optional[str] = None + number: Optional[str] = None + script: Optional[str] = None + edit_script: Optional[Any] = None + status: int = CardStatus.ACTIVE.value # 와일드카드 적용 여부(available 매핑). 일반카드는 무시. + condition: Optional[str] = None # 와일드카드 전용 + memo: Optional[str] = None # 와일드카드 전용 + + +class Req_UpdateCard(CardProtocol): + name: Optional[str] = None + number: Optional[str] = None + script: Optional[str] = None + edit_script: Optional[Any] = None + status: Optional[int] = None + condition: Optional[str] = None + memo: Optional[str] = None + + +# 통합 카드 표현(nego_cards + wild_cards 공통). nego_card_id 는 출처 테이블의 PK 를 그대로 담는다. +class CardData(WebPacketProtocol): + model_config = ConfigDict(from_attributes=True) + + nego_card_id: uuid.UUID # 통합 식별자(일반=nego_card_id / 와일드=wild_card_id) + user_id: Optional[uuid.UUID] = None + is_wildcard: bool = False + name: Optional[str] = None + number: Optional[str] = None + script: Optional[str] = None + edit_script: Optional[Any] = None + status: int = CardStatus.ACTIVE.value + condition: Optional[str] = None + memo: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class Res_Card(Res_WebPacketProtocol): + card: Optional[CardData] = None + + +class Res_CardList(Res_PageProtocol): + cards: list[CardData] = [] + + +class Res_DeleteCard(Res_WebPacketProtocol): + pass diff --git a/negodata/backend/router/v1/quotation/protocol.py b/negodata/backend/router/v1/quotation/protocol.py index fd2da1a..fe55a0c 100644 --- a/negodata/backend/router/v1/quotation/protocol.py +++ b/negodata/backend/router/v1/quotation/protocol.py @@ -4,7 +4,7 @@ from typing import Any, Optional from pydantic import ConfigDict -from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol +from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol class QuotationProtocol(WebPacketProtocol): @@ -59,11 +59,8 @@ class Res_Quotation(Res_WebPacketProtocol): quotation: Optional[QuotationData] = None -class Res_QuotationList(Res_WebPacketProtocol): +class Res_QuotationList(Res_PageProtocol): quotations: list[QuotationData] = [] - total: int = 0 - page: int = 0 - size: int = 0 class Res_DeleteQuotation(Res_WebPacketProtocol): diff --git a/negodata/backend/router/v1/quotation/quotation.py b/negodata/backend/router/v1/quotation/quotation.py index 41f78f1..5de3905 100644 --- a/negodata/backend/router/v1/quotation/quotation.py +++ b/negodata/backend/router/v1/quotation/quotation.py @@ -3,7 +3,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, Query -from common.models.gmodel import UserInfo +from common.models.gmodel import PageParams, UserInfo from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse from services.quotation_service import QuotationService from .protocol import ( @@ -35,10 +35,9 @@ async def list_quotations( 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), + pg: PageParams = Depends(), ): - return RemoveNoneResponse(await service.list_quotations(status, type, start_from, start_to, page, size)) + return RemoveNoneResponse(await service.list_quotations(status, type, start_from, start_to, pg)) @router.post(path="/create", response_model=Res_CreateQuotation, summary="견적 생성") diff --git a/negodata/backend/services/auth_service.py b/negodata/backend/services/auth_service.py index d3c39af..32536a1 100644 --- a/negodata/backend/services/auth_service.py +++ b/negodata/backend/services/auth_service.py @@ -4,7 +4,7 @@ from fastapi import Depends from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import users -from common.enums import DBWRType, ErrorType, UserStatus, UserRole +from common.enums import DBWRType, ErrorType, UserStatus, UserRole, ENUM_LABELS from common.logger import LOG from common.models.gmodel import UserInfo from crud.user_crud import IUserCRUD, UserCRUD @@ -156,6 +156,7 @@ class AuthService: res.email = user.email res.contact_number = user.contact_number res.role = user.role + res.role_label = ENUM_LABELS.get(UserRole(user.role), str(user.role)) res.company = company return res diff --git a/negodata/backend/services/card_service.py b/negodata/backend/services/card_service.py new file mode 100644 index 0000000..222f370 --- /dev/null +++ b/negodata/backend/services/card_service.py @@ -0,0 +1,204 @@ +import uuid + +from fastapi import Depends + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import nego_cards, wild_cards +from common.enums import CardStatus, DBWRType, ErrorType +from common.models.gmodel import PageParams +from crud.card_crud import ICardCRUD, CardCRUD +from router.v1.card.protocol import CardData, Res_Card, Res_CardList, Res_DeleteCard + + +class CardService: + """협상카드 비즈니스 로직. nego_cards/wild_cards 두 테이블을 user_id 로 스코프하고 + 프론트용 단일 모델(CardData, is_wildcard 플래그)로 합친다.""" + + def __init__(self, card_crud: ICardCRUD = Depends(CardCRUD)): + self.card_crud = card_crud + + # ---- 행 → 통합 CardData -------------------------------------------------- + @staticmethod + def _nego_to_data(row) -> CardData: + # 일반 협상카드는 상태 개념이 없다(상시 적용) → ACTIVE 고정. + return CardData( + nego_card_id=row.nego_card_id, + user_id=row.user_id, + is_wildcard=False, + name=row.name, + number=row.number, + script=row.script, + edit_script=row.edit_script, + status=CardStatus.ACTIVE.value, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + @staticmethod + def _wild_to_data(row) -> CardData: + # 와일드카드 적용 여부(available) → status(ACTIVE/INACTIVE) 매핑. + return CardData( + nego_card_id=row.wild_card_id, + user_id=row.user_id, + is_wildcard=True, + name=row.name, + number=row.number, + script=row.script, + edit_script=row.edit_script, + status=CardStatus.ACTIVE.value if row.available else CardStatus.INACTIVE.value, + condition=row.condition, + memo=row.memo, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + # ---- 소유 카드 탐색(어느 테이블인지 모를 때) ------------------------------ + async def _find_owned(self, user_uuid: uuid.UUID, card_id: uuid.UUID): + """card_id 를 nego_cards → wild_cards 순으로 찾고 소유권 확인. + (ErrorType, model, pk_col, row, is_wildcard) 반환.""" + err, row = await DB_SESSION_MNG.execute_lambda( + nego_cards.DBType(), + DBWRType.DB_READ.value, + lambda s: self.card_crud.get_by_id(s, nego_cards, nego_cards.nego_card_id, card_id), + ) + if err == ErrorType.SUCCESS and row is not None: + if row.user_id != user_uuid: + return ErrorType.CARD_NOT_FOUND, None, None, None, False + return ErrorType.SUCCESS, nego_cards, nego_cards.nego_card_id, row, False + + err, row = await DB_SESSION_MNG.execute_lambda( + wild_cards.DBType(), + DBWRType.DB_READ.value, + lambda s: self.card_crud.get_by_id(s, wild_cards, wild_cards.wild_card_id, card_id), + ) + if err == ErrorType.SUCCESS and row is not None: + if row.user_id != user_uuid: + return ErrorType.CARD_NOT_FOUND, None, None, None, True + return ErrorType.SUCCESS, wild_cards, wild_cards.wild_card_id, row, True + + return ErrorType.CARD_NOT_FOUND, None, None, None, False + + # ---- 목록 ---------------------------------------------------------------- + async def list_cards(self, user_id: str, search, pg: PageParams) -> Res_CardList: + res = Res_CardList(page=pg.page, size=pg.size) + if not user_id: + return res + user_uuid = uuid.UUID(user_id) + # 합쳐서 정렬/페이징하므로 각 테이블에서 skip+limit 까지 받아온다(카드 수가 적어 충분). + fetch = pg.skip + pg.size + + err_n, nego_rows, total_n = await DB_SESSION_MNG.execute_lambda( + nego_cards.DBType(), + DBWRType.DB_READ.value, + lambda s: self.card_crud.search(s, nego_cards, user_uuid, search, 0, fetch), + ) + if err_n != ErrorType.SUCCESS: + res.result.SetResult(err_n) + return res + + err_w, wild_rows, total_w = await DB_SESSION_MNG.execute_lambda( + wild_cards.DBType(), + DBWRType.DB_READ.value, + lambda s: self.card_crud.search(s, wild_cards, user_uuid, search, 0, fetch), + ) + if err_w != ErrorType.SUCCESS: + res.result.SetResult(err_w) + return res + + merged = [self._nego_to_data(r) for r in nego_rows] + [self._wild_to_data(r) for r in wild_rows] + merged.sort(key=lambda c: c.created_at or "", reverse=True) + res.cards = merged[pg.skip : pg.skip + pg.size] + res.total = total_n + total_w + return res + + # ---- 단건 조회 ----------------------------------------------------------- + async def get_card(self, user_id: str, card_id: str) -> Res_Card: + res = Res_Card() + err, _model, _pk, row, is_wild = await self._find_owned(uuid.UUID(user_id), uuid.UUID(card_id)) + if err != ErrorType.SUCCESS: + res.result.SetResult(err) + return res + res.card = self._wild_to_data(row) if is_wild else self._nego_to_data(row) + return res + + # ---- 등록 ---------------------------------------------------------------- + async def create_card(self, user_id: str, data: dict) -> Res_Card: + res = Res_Card() + user_uuid = uuid.UUID(user_id) + is_wildcard = bool(data.get("is_wildcard", False)) + + common = dict( + user_id=user_uuid, + name=data.get("name"), + number=data.get("number"), + script=data.get("script"), + edit_script=data.get("edit_script"), + ) + if is_wildcard: + card = wild_cards( + **common, + condition=data.get("condition"), + available=(data.get("status", CardStatus.ACTIVE.value) == CardStatus.ACTIVE.value), + memo=data.get("memo"), + ) + model, pk_attr = wild_cards, "wild_card_id" + else: + card = nego_cards(**common) + model, pk_attr = nego_cards, "nego_card_id" + + err = await DB_SESSION_MNG.execute_lambda_run( + [model.DBType()], + [lambda s: self.card_crud.add(s, card)], + ) + if err != ErrorType.SUCCESS: + res.result.SetResult(err) + return res + # 서버 기본값(created_at 등)은 insert 후 객체에 실리지 않으므로 재조회. + return await self.get_card(user_id, str(getattr(card, pk_attr))) + + # ---- 수정 ---------------------------------------------------------------- + async def update_card(self, user_id: str, card_id: str, data: dict) -> Res_Card: + res = Res_Card() + user_uuid = uuid.UUID(user_id) + card_uuid = uuid.UUID(card_id) + + err, model, pk_col, _row, is_wild = await self._find_owned(user_uuid, card_uuid) + if err != ErrorType.SUCCESS: + res.result.SetResult(err) + return res + + # 해당 테이블에 있는 컬럼만 추린다(없는 필드는 무시). status → available(와일드 전용). + allowed = {"name", "number", "script", "edit_script"} + if is_wild: + allowed |= {"condition", "memo"} + payload = {k: v for k, v in data.items() if k in allowed} + if is_wild and "status" in data and data["status"] is not None: + payload["available"] = data["status"] == CardStatus.ACTIVE.value + + err = await DB_SESSION_MNG.execute_lambda_run( + [model.DBType()], + [lambda s: self.card_crud.update(s, model, pk_col, card_uuid, payload)], + ) + if err != ErrorType.SUCCESS: + res.result.SetResult(err) + return res + return await self.get_card(user_id, card_id) + + # ---- 삭제(soft) ---------------------------------------------------------- + async def delete_card(self, user_id: str, card_id: str) -> Res_DeleteCard: + res = Res_DeleteCard() + user_uuid = uuid.UUID(user_id) + card_uuid = uuid.UUID(card_id) + + err, model, pk_col, _row, _is_wild = await self._find_owned(user_uuid, card_uuid) + if err != ErrorType.SUCCESS: + res.result.SetResult(err) + return res + + err = await DB_SESSION_MNG.execute_lambda_run( + [model.DBType()], + [lambda s: self.card_crud.soft_delete(s, model, pk_col, card_uuid)], + ) + if err != ErrorType.SUCCESS: + res.result.SetResult(err) + return res diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index 07a0567..dee18d4 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -3,12 +3,16 @@ 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.database.model.models import quotations, sessions, chats from common.enums import DBWRType, ErrorType +from common.models.gmodel import PageParams from crud.quotation_crud import IQuotationCRUD, QuotationCRUD from router.v1.quotation.protocol import ( AsyncJob, + ChatMessageData, + QuotationCardData, QuotationData, + SessionData, Res_CreateQuotation, Res_DeleteQuotation, Res_Quotation, @@ -42,14 +46,13 @@ class QuotationService: 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 + async def list_quotations(self, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList: + res = Res_QuotationList(page=pg.page, size=pg.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), + lambda s: self.quotation_crud.search(s, status, type_, start_from, start_to, pg.skip, pg.size), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) @@ -154,31 +157,110 @@ class QuotationService: 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)) + qt_uuid = uuid.UUID(qt_id) + err_type, quotation = await self._fetch(qt_uuid) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res + + err_type, rows = await DB_SESSION_MNG.execute_lambda( + sessions.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.list_sessions(s, qt_uuid), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.qt_id = quotation.qt_id - res.sessions = [] - res.total = 0 + # sessions.quotation_id → SessionData.qt_id 로 명시 매핑(컬럼명 불일치). + res.sessions = [ + SessionData( + session_id=r.session_id, + qt_id=r.quotation_id, + supplier_id=r.supplier_id, + item_id=r.item_id, + qt_number=r.qt_number, + qt_round=r.qt_round, + qt_type=r.qt_type, + target_price=r.target_price, + status=r.status, + bid_price=r.bid_price, + bid_at=r.bid_at, + end_time=r.end_time, + reject_reason=r.reject_reason, + reject_price=r.reject_price, + reject_delivery_type=r.reject_delivery_type, + ) + for r in rows + ] + res.total = len(res.sessions) 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 + sess_uuid = uuid.UUID(session_id) + res.session_id = sess_uuid - 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)) + err_type, rows = await DB_SESSION_MNG.execute_lambda( + chats.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.list_chats(s, sess_uuid), + ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res - res.qt_id = quotation.qt_id - res.cards = [] + + # chats.seq → ChatMessageData.index 로 매핑. indicator_value(Decimal) → float. + res.messages = [ + ChatMessageData( + chat_id=r.chat_id, + session_id=r.session_id, + card_id=r.card_id, + index=r.seq, + sender=r.sender, + target_price=r.target_price, + card_used_yn=r.card_used_yn, + indicator_value=float(r.indicator_value) if r.indicator_value is not None else None, + card_type=r.card_type, + ) + for r in rows + ] + return res + + async def list_cards(self, qt_id: str) -> Res_QuotationCards: + res = Res_QuotationCards() + qt_uuid = uuid.UUID(qt_id) + err_type, quotation = await self._fetch(qt_uuid) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + err_type, rows = await DB_SESSION_MNG.execute_lambda( + chats.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.list_used_cards(s, qt_uuid), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + res.qt_id = quotation.qt_id + # rows = [(chat_row, nego_card_id, name, script), ...]. nego/wild 구분은 chats.card_type. + cards = [] + for chat_row, nc_id, nc_name, nc_script in rows: + is_wild = chat_row.card_type == 2 + cards.append( + QuotationCardData( + session_card_id=chat_row.chat_id, + qt_id=quotation.qt_id, + nego_card_id=None if is_wild else nc_id, + wild_card_id=nc_id if is_wild else None, + type=chat_row.card_type if chat_row.card_type is not None else 1, + name=nc_name, + script=nc_script, + ) + ) + res.cards = cards return res From 01e80281d123adb0377b12b1d63153395cff258d Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Thu, 18 Jun 2026 11:00:15 +0900 Subject: [PATCH 7/9] =?UTF-8?q?[chore]=20negodata:=20=EB=8D=B0=EB=AA=A8=20?= =?UTF-8?q?=EC=8B=9C=EB=93=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=95?= =?UTF-8?q?=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- postgres-init/03-seed-negodata.sql | 129 +++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/postgres-init/03-seed-negodata.sql b/postgres-init/03-seed-negodata.sql index 87c194d..58014dd 100644 --- a/postgres-init/03-seed-negodata.sql +++ b/postgres-init/03-seed-negodata.sql @@ -40,3 +40,132 @@ CROSS JOIN (VALUES ) AS v(item_id, name, code, price, category, model_name, spec, moq, lead_time, manufacturer, made_in, quantity_unit, vat_yn, delivery_fee_yn, category_type) WHERE u.id = 'admin' AND u.deleted = FALSE AND NOT EXISTS (SELECT 1 FROM partner.items i WHERE i.item_id = v.item_id); + +-- 견적 세팅 1개 (admin 소유, 고정 UUID). target_margin_rate 는 비율(0.12 = 12%), anchoring_value 0.01, card_count 3. +-- 견적(quotations)이 qt_setting_id 로 참조하므로 선행 시드. +INSERT INTO quotation.quotation_settings (qt_setting_id, user_id, target_margin_rate, anchoring_value, card_count) +SELECT '00000000-0000-0000-0000-000000000020', u.user_id, 0.12, 0.01, 3 +FROM company.users u +WHERE u.id = 'admin' AND u.deleted = FALSE + AND NOT EXISTS (SELECT 1 FROM quotation.quotation_settings qs WHERE qs.qt_setting_id = '00000000-0000-0000-0000-000000000020'); + +-- 협상전략 버전 1개 (admin 소유, 고정 UUID). quotations.version_id 가 NOT NULL 이라 참조 대상으로 시드. +INSERT INTO card.versions (version_id, user_id, code, name) +SELECT '00000000-0000-0000-0000-000000000030', u.user_id, 1, '기본전략' +FROM company.users u +WHERE u.id = 'admin' AND u.deleted = FALSE + AND NOT EXISTS (SELECT 1 FROM card.versions vv WHERE vv.version_id = '00000000-0000-0000-0000-000000000030'); + +-- 데모 견적 4건. status 코드 = QuotationStatus(1=견적생성 2=견적진행중 3=견적마감 4=협상보류), +-- type 코드 = QuotationType(1=재협상 2=재견적). 마감기한은 시드 적용 시점 기준 상대시각. +INSERT INTO quotation.quotations ( + qt_id, user_id, qt_setting_id, version_id, name, number, type, round, status, + start_time, end_time, manager_name, manager_email, manager_contact_number, memo, + iteration, preferred_sp_yn, preferred_sp_name, equal_bid_yn, equal_bid_data +) +SELECT v.qt_id, u.user_id, '00000000-0000-0000-0000-000000000020', '00000000-0000-0000-0000-000000000030', + v.name, v.number, v.type, v.round, v.status, + v.start_time, v.end_time, v.manager_name, v.manager_email, v.manager_contact_number, v.memo, + v.iteration, v.preferred_sp_yn, v.preferred_sp_name, v.equal_bid_yn, v.equal_bid_data +FROM company.users u +CROSS JOIN (VALUES + ('00000000-0000-0000-0000-000000000040'::uuid, '사무용 복합기 대량구매 재견적', 'EST-202606-1001', 2::smallint, 1, 1::smallint, + now(), now() + interval '5 day', '김조달', 'buyer1@negosium.dev', '02-1000-2001', '연간 단가계약 목적 재견적', 0, NULL, NULL, NULL, NULL::jsonb), + ('00000000-0000-0000-0000-000000000041'::uuid, '리튬인산철 배터리 단가 재협상', 'EST-202606-1002', 1::smallint, 2, 2::smallint, + now() - interval '2 day', now() + interval '3 day', '이매입', 'buyer2@negosium.dev', '02-1000-2002', '2차 라운드 진행중', 1, NULL, NULL, NULL, NULL::jsonb), + ('00000000-0000-0000-0000-000000000042'::uuid, 'MEMS 가속도 센서 연간 단가견적', 'EST-202606-1003', 2::smallint, 1, 3::smallint, + now() - interval '10 day', now() - interval '1 day', '박구매', 'buyer3@negosium.dev', '02-1000-2003', '낙찰 완료, 동일가 발생', 1, TRUE, '센서텍', TRUE, '{"part-a":730000,"part-b":730000}'::jsonb), + ('00000000-0000-0000-0000-000000000043'::uuid, '산업용 6축 로봇암 도입 협상', 'EST-202606-1004', 1::smallint, 1, 4::smallint, + now() - interval '4 day', now() + interval '2 day', '최담당', 'buyer4@negosium.dev', '02-1000-2004', '관리자 사유로 협상보류', 0, NULL, NULL, NULL, NULL::jsonb) +) AS v(qt_id, name, number, type, round, status, start_time, end_time, manager_name, manager_email, manager_contact_number, memo, iteration, preferred_sp_yn, preferred_sp_name, equal_bid_yn, equal_bid_data) +WHERE u.id = 'admin' AND u.deleted = FALSE + AND NOT EXISTS (SELECT 1 FROM quotation.quotations q WHERE q.qt_id = v.qt_id); + +-- 협력사 3곳 (admin 소유). 협상 세션의 supplier_id 가 참조한다. +INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name, code, manager_name, manager_email, manager_contact_number, priority) +SELECT v.supplier_id, u.company_id, u.user_id, v.name, v.code, v.manager_name, v.manager_email, v.manager_contact_number, v.priority +FROM company.users u +CROSS JOIN (VALUES + ('00000000-0000-0000-0000-000000000050'::uuid, '(주)우성테크놀로지', 'SUP-01', '김우성', 'woosung@vendor.dev', '02-500-1001', 'HIGH'), + ('00000000-0000-0000-0000-000000000051'::uuid, '대현정밀공업(주)', 'SUP-02', '이대현', 'daehyun@vendor.dev', '02-500-1002', 'MEDIUM'), + ('00000000-0000-0000-0000-000000000052'::uuid, '센서텍', 'SUP-03', '박센서', 'sensortech@vendor.dev', '02-500-1003', 'HIGH') +) AS v(supplier_id, name, code, manager_name, manager_email, manager_contact_number, priority) +WHERE u.id = 'admin' AND u.deleted = FALSE + AND NOT EXISTS (SELECT 1 FROM partner.suppliers s WHERE s.supplier_id = v.supplier_id); + +-- 협상카드 2장 (admin 유저). 채팅에서 card_id 로 사용된다. +INSERT INTO card.nego_cards (nego_card_id, user_id, name, number, script) +SELECT v.nego_card_id, u.user_id, v.name, v.number, v.script +FROM company.users u +CROSS JOIN (VALUES + ('00000000-0000-0000-0000-000000000060'::uuid, '즉시수용 인센티브', 'NC-01', '지금 수용하시면 차기 견적 우선참여와 추가 물량을 보장합니다.'), + ('00000000-0000-0000-0000-000000000061'::uuid, '단가 매칭 보장', 'NC-02', '경쟁사 최저가에 맞춰주시면 즉시 낙찰 처리하겠습니다.') +) AS v(nego_card_id, name, number, script) +WHERE u.id = 'admin' AND u.deleted = FALSE + AND NOT EXISTS (SELECT 1 FROM card.nego_cards c WHERE c.nego_card_id = v.nego_card_id); + +-- 협상 세션. status=SessionStatus(1=협상중 2=협상종료 3=협상거부), qt_type=QuotationType(1=재협상 2=재견적). +-- (sessions 엔 user_id 가 없어 고정 UUID 로 직접 삽입. 견적/상품/협력사 시드에 의존.) +INSERT INTO negotiation.sessions ( + session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, + target_price, status, bid_price, bid_at, end_time, reject_reason, reject_price, reject_delivery_type +) +VALUES + -- 견적 0041(배터리 재협상, 진행중) — 1:1 세션 + ('00000000-0000-0000-0000-000000000070', '00000000-0000-0000-0000-000000000041', '00000000-0000-0000-0000-000000000010', '00000000-0000-0000-0000-000000000050', + 'EST-202606-1002', 2, 1, 1125000, 1, 1180000, now() - interval '1 day', now() + interval '3 day', NULL, NULL, NULL), + -- 견적 0042(MEMS 센서 재견적, 마감) — 1:N 세션 3개 (낙찰/동일가/거부) + ('00000000-0000-0000-0000-000000000071', '00000000-0000-0000-0000-000000000042', '00000000-0000-0000-0000-000000000012', '00000000-0000-0000-0000-000000000052', + 'EST-202606-1003', 1, 2, 7600, 2, 7300, now() - interval '2 day', now() - interval '1 day', NULL, NULL, NULL), + ('00000000-0000-0000-0000-000000000072', '00000000-0000-0000-0000-000000000042', '00000000-0000-0000-0000-000000000012', '00000000-0000-0000-0000-000000000051', + 'EST-202606-1003', 1, 2, 7600, 2, 7300, now() - interval '2 day', now() - interval '1 day', NULL, NULL, NULL), + ('00000000-0000-0000-0000-000000000073', '00000000-0000-0000-0000-000000000042', '00000000-0000-0000-0000-000000000012', '00000000-0000-0000-0000-000000000050', + 'EST-202606-1003', 1, 2, 7600, 3, NULL, NULL, now() - interval '1 day', '제조 원가 미달로 제시 단가 수용 불가', 8200, 2), + -- 견적 0043(로봇암 재협상, 보류) — 1:1 세션 + ('00000000-0000-0000-0000-000000000074', '00000000-0000-0000-0000-000000000043', '00000000-0000-0000-0000-000000000011', '00000000-0000-0000-0000-000000000050', + 'EST-202606-1004', 1, 1, 17010000, 1, 18200000, now() - interval '2 day', now() + interval '2 day', NULL, NULL, NULL) +ON CONFLICT (session_id) DO NOTHING; + +-- 협상 채팅. sender=ChatSender(1=봇 2=협력사), card_type=1=협상카드. card_used_yn=TRUE 인 행이 '사용 카드' 탭 소스. +INSERT INTO negotiation.chats (chat_id, session_id, card_id, seq, sender, target_price, card_used_yn, indicator_value, card_type) +VALUES + -- 세션 0070 (배터리) + ('00000000-0000-0000-0000-000000000080', '00000000-0000-0000-0000-000000000070', NULL, 1, 1, 1125000, FALSE, NULL, NULL), + ('00000000-0000-0000-0000-000000000081', '00000000-0000-0000-0000-000000000070', NULL, 2, 2, 1250000, FALSE, NULL, NULL), + ('00000000-0000-0000-0000-000000000082', '00000000-0000-0000-0000-000000000070', '00000000-0000-0000-0000-000000000060', 3, 1, 1150000, TRUE, 0.08, 1), + ('00000000-0000-0000-0000-000000000083', '00000000-0000-0000-0000-000000000070', NULL, 4, 2, 1180000, FALSE, NULL, NULL), + -- 세션 0071 (센서, 낙찰) + ('00000000-0000-0000-0000-000000000084', '00000000-0000-0000-0000-000000000071', NULL, 1, 1, 7600, FALSE, NULL, NULL), + ('00000000-0000-0000-0000-000000000085', '00000000-0000-0000-0000-000000000071', NULL, 2, 2, 7800, FALSE, NULL, NULL), + ('00000000-0000-0000-0000-000000000086', '00000000-0000-0000-0000-000000000071', '00000000-0000-0000-0000-000000000061', 3, 1, 7400, TRUE, 0.05, 1), + ('00000000-0000-0000-0000-000000000087', '00000000-0000-0000-0000-000000000071', NULL, 4, 2, 7300, FALSE, NULL, NULL), + -- 세션 0072 (센서, 동일가) + ('00000000-0000-0000-0000-000000000088', '00000000-0000-0000-0000-000000000072', NULL, 1, 1, 7600, FALSE, NULL, NULL), + ('00000000-0000-0000-0000-000000000089', '00000000-0000-0000-0000-000000000072', NULL, 2, 2, 7300, FALSE, NULL, NULL), + -- 세션 0073 (센서, 거부) + ('00000000-0000-0000-0000-00000000008a', '00000000-0000-0000-0000-000000000073', NULL, 1, 1, 7600, FALSE, NULL, NULL), + ('00000000-0000-0000-0000-00000000008b', '00000000-0000-0000-0000-000000000073', NULL, 2, 2, 8200, FALSE, NULL, NULL), + -- 세션 0074 (로봇암) + ('00000000-0000-0000-0000-00000000008c', '00000000-0000-0000-0000-000000000074', NULL, 1, 1, 17010000, FALSE, NULL, NULL), + ('00000000-0000-0000-0000-00000000008d', '00000000-0000-0000-0000-000000000074', '00000000-0000-0000-0000-000000000060', 2, 1, 17500000, TRUE, 0.06, 1), + ('00000000-0000-0000-0000-00000000008e', '00000000-0000-0000-0000-000000000074', NULL, 3, 2, 18200000, FALSE, NULL, NULL) +ON CONFLICT (chat_id) DO NOTHING; + +-- 와일드카드 2장 (admin 유저). 채팅에서 card_type=2 로 사용된다. +INSERT INTO card.wild_cards (wild_card_id, user_id, name, number, script, condition, available) +SELECT v.wild_card_id, u.user_id, v.name, v.number, v.script, v.condition, TRUE +FROM company.users u +CROSS JOIN (VALUES + ('00000000-0000-0000-0000-000000000062'::uuid, '긴급마감 압박', 'WC-01', '오늘 자정까지 확정 시 단가를 동결하고 우선 배정합니다.', '마감 24시간 이내'), + ('00000000-0000-0000-0000-000000000063'::uuid, '독점공급 보장', 'WC-02', '연간 독점 공급권을 보장하는 대신 추가 인하를 요청합니다.', '연간 계약 전환 시') +) AS v(wild_card_id, name, number, script, condition) +WHERE u.id = 'admin' AND u.deleted = FALSE + AND NOT EXISTS (SELECT 1 FROM card.wild_cards w WHERE w.wild_card_id = v.wild_card_id); + +-- 세션마다 카드 사용 기록이 남도록 보강(0072 와일드 / 0073 협상 / 0074 와일드). card_type 1=협상 2=와일드. +INSERT INTO negotiation.chats (chat_id, session_id, card_id, seq, sender, target_price, card_used_yn, indicator_value, card_type) +VALUES + ('00000000-0000-0000-0000-000000000090', '00000000-0000-0000-0000-000000000072', '00000000-0000-0000-0000-000000000062', 3, 1, 7500, TRUE, 0.04, 2), + ('00000000-0000-0000-0000-000000000091', '00000000-0000-0000-0000-000000000073', '00000000-0000-0000-0000-000000000061', 3, 1, 7500, TRUE, 0.03, 1), + ('00000000-0000-0000-0000-000000000092', '00000000-0000-0000-0000-000000000074', '00000000-0000-0000-0000-000000000063', 4, 1, 18000000, TRUE, 0.05, 2) +ON CONFLICT (chat_id) DO NOTHING; From 7c244bca2770cdabb6ceb20a354b5b1c33ac241f Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Thu, 18 Jun 2026 11:03:11 +0900 Subject: [PATCH 8/9] =?UTF-8?q?[chore]=20negodata/front:=20orval=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=AC=EC=83=9D=EC=84=B1=20(=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8=20+=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=C2=B7=ED=98=91=EB=A0=A5?= =?UTF-8?q?=EC=82=AC=20priority=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- negodata/front/src/api/generated/card/card.ts | 551 ++++++++++++++++++ negodata/front/src/api/generated/item/item.ts | 235 ++++++++ .../front/src/api/generated/model/cardData.ts | 30 + .../api/generated/model/cardDataCondition.ts | 8 + .../api/generated/model/cardDataCreatedAt.ts | 8 + .../api/generated/model/cardDataEditScript.ts | 8 + .../src/api/generated/model/cardDataMemo.ts | 8 + .../src/api/generated/model/cardDataName.ts | 8 + .../src/api/generated/model/cardDataNumber.ts | 8 + .../src/api/generated/model/cardDataScript.ts | 8 + .../api/generated/model/cardDataUpdatedAt.ts | 8 + .../src/api/generated/model/cardDataUserId.ts | 8 + .../front/src/api/generated/model/index.ts | 39 ++ .../src/api/generated/model/itemCategory.ts | 11 + .../api/generated/model/listCardsParams.ts | 22 + .../generated/model/listSuppliersParams.ts | 4 + .../src/api/generated/model/reqCheckCodes.ts | 10 + .../src/api/generated/model/reqCreateCard.ts | 23 + .../generated/model/reqCreateCardCondition.ts | 8 + .../model/reqCreateCardEditScript.ts | 8 + .../api/generated/model/reqCreateCardMemo.ts | 8 + .../api/generated/model/reqCreateCardName.ts | 8 + .../generated/model/reqCreateCardNumber.ts | 8 + .../generated/model/reqCreateCardScript.ts | 8 + .../src/api/generated/model/reqUpdateCard.ts | 23 + .../generated/model/reqUpdateCardCondition.ts | 8 + .../model/reqUpdateCardEditScript.ts | 8 + .../api/generated/model/reqUpdateCardMemo.ts | 8 + .../api/generated/model/reqUpdateCardName.ts | 8 + .../generated/model/reqUpdateCardNumber.ts | 8 + .../generated/model/reqUpdateCardScript.ts | 8 + .../generated/model/reqUpdateCardStatus.ts | 8 + .../front/src/api/generated/model/resCard.ts | 15 + .../src/api/generated/model/resCardCard.ts | 9 + .../src/api/generated/model/resCardList.ts | 18 + .../src/api/generated/model/resCardListMsg.ts | 8 + .../src/api/generated/model/resCardMsg.ts | 8 + .../src/api/generated/model/resCheckCodes.ts | 14 + .../api/generated/model/resCheckCodesMsg.ts | 8 + .../src/api/generated/model/resDeleteCard.ts | 13 + .../api/generated/model/resDeleteCardMsg.ts | 8 + .../api/generated/model/resItemCategories.ts | 15 + .../generated/model/resItemCategoriesMsg.ts | 8 + .../front/src/api/generated/model/resMe.ts | 1 + .../src/api/generated/supplier/supplier.ts | 87 +++ 45 files changed, 1336 insertions(+) create mode 100644 negodata/front/src/api/generated/card/card.ts create mode 100644 negodata/front/src/api/generated/model/cardData.ts create mode 100644 negodata/front/src/api/generated/model/cardDataCondition.ts create mode 100644 negodata/front/src/api/generated/model/cardDataCreatedAt.ts create mode 100644 negodata/front/src/api/generated/model/cardDataEditScript.ts create mode 100644 negodata/front/src/api/generated/model/cardDataMemo.ts create mode 100644 negodata/front/src/api/generated/model/cardDataName.ts create mode 100644 negodata/front/src/api/generated/model/cardDataNumber.ts create mode 100644 negodata/front/src/api/generated/model/cardDataScript.ts create mode 100644 negodata/front/src/api/generated/model/cardDataUpdatedAt.ts create mode 100644 negodata/front/src/api/generated/model/cardDataUserId.ts create mode 100644 negodata/front/src/api/generated/model/itemCategory.ts create mode 100644 negodata/front/src/api/generated/model/listCardsParams.ts create mode 100644 negodata/front/src/api/generated/model/reqCheckCodes.ts create mode 100644 negodata/front/src/api/generated/model/reqCreateCard.ts create mode 100644 negodata/front/src/api/generated/model/reqCreateCardCondition.ts create mode 100644 negodata/front/src/api/generated/model/reqCreateCardEditScript.ts create mode 100644 negodata/front/src/api/generated/model/reqCreateCardMemo.ts create mode 100644 negodata/front/src/api/generated/model/reqCreateCardName.ts create mode 100644 negodata/front/src/api/generated/model/reqCreateCardNumber.ts create mode 100644 negodata/front/src/api/generated/model/reqCreateCardScript.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateCard.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateCardCondition.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateCardEditScript.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateCardMemo.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateCardName.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateCardNumber.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateCardScript.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateCardStatus.ts create mode 100644 negodata/front/src/api/generated/model/resCard.ts create mode 100644 negodata/front/src/api/generated/model/resCardCard.ts create mode 100644 negodata/front/src/api/generated/model/resCardList.ts create mode 100644 negodata/front/src/api/generated/model/resCardListMsg.ts create mode 100644 negodata/front/src/api/generated/model/resCardMsg.ts create mode 100644 negodata/front/src/api/generated/model/resCheckCodes.ts create mode 100644 negodata/front/src/api/generated/model/resCheckCodesMsg.ts create mode 100644 negodata/front/src/api/generated/model/resDeleteCard.ts create mode 100644 negodata/front/src/api/generated/model/resDeleteCardMsg.ts create mode 100644 negodata/front/src/api/generated/model/resItemCategories.ts create mode 100644 negodata/front/src/api/generated/model/resItemCategoriesMsg.ts diff --git a/negodata/front/src/api/generated/card/card.ts b/negodata/front/src/api/generated/card/card.ts new file mode 100644 index 0000000..f8ffcd3 --- /dev/null +++ b/negodata/front/src/api/generated/card/card.ts @@ -0,0 +1,551 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + MutationFunction, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult, +} from "@tanstack/react-query"; + +import type { + HTTPValidationError, + ListCardsParams, + ReqCreateCard, + ReqUpdateCard, + ResCard, + ResCardList, + ResDeleteCard, +} from ".././model"; + +import { customFetch } from "../../mutator/custom-fetch"; + +/** + * @summary 협상카드 목록 + */ +export const listCards = (params?: ListCardsParams, signal?: AbortSignal) => { + return customFetch({ + url: `/v1/card/list`, + method: "GET", + params, + signal, + }); +}; + +export const getListCardsQueryKey = (params?: ListCardsParams) => { + return [`/v1/card/list`, ...(params ? [params] : [])] as const; +}; + +export const getListCardsQueryOptions = < + TData = Awaited>, + TError = void | HTTPValidationError, +>( + params?: ListCardsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListCardsQueryKey(params); + + const queryFn: QueryFunction>> = ({ + signal, + }) => listCards(params, signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ListCardsQueryResult = NonNullable< + Awaited> +>; +export type ListCardsQueryError = void | HTTPValidationError; + +export function useListCards< + TData = Awaited>, + TError = void | HTTPValidationError, +>( + params: undefined | ListCardsParams, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useListCards< + TData = Awaited>, + TError = void | HTTPValidationError, +>( + params?: ListCardsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useListCards< + TData = Awaited>, + TError = void | HTTPValidationError, +>( + params?: ListCardsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary 협상카드 목록 + */ + +export function useListCards< + TData = Awaited>, + TError = void | HTTPValidationError, +>( + params?: ListCardsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getListCardsQueryOptions(params, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + +/** + * @summary 협상카드 등록 + */ +export const createCard = ( + reqCreateCard: ReqCreateCard, + signal?: AbortSignal, +) => { + return customFetch({ + url: `/v1/card/create`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqCreateCard, + signal, + }); +}; + +export const getCreateCardMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: ReqCreateCard }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { data: ReqCreateCard }, + TContext +> => { + const mutationKey = ["createCard"]; + const { mutation: mutationOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { data: ReqCreateCard } + > = (props) => { + const { data } = props ?? {}; + + return createCard(data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type CreateCardMutationResult = NonNullable< + Awaited> +>; +export type CreateCardMutationBody = ReqCreateCard; +export type CreateCardMutationError = void | HTTPValidationError; + +/** + * @summary 협상카드 등록 + */ +export const useCreateCard = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: ReqCreateCard }, + TContext + >; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { data: ReqCreateCard }, + TContext +> => { + const mutationOptions = getCreateCardMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * @summary 협상카드 조회 + */ +export const getCard = (cardId: string, signal?: AbortSignal) => { + return customFetch({ + url: `/v1/card/${cardId}`, + method: "GET", + signal, + }); +}; + +export const getGetCardQueryKey = (cardId?: string) => { + return [`/v1/card/${cardId}`] as const; +}; + +export const getGetCardQueryOptions = < + TData = Awaited>, + TError = void | HTTPValidationError, +>( + cardId: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetCardQueryKey(cardId); + + const queryFn: QueryFunction>> = ({ + signal, + }) => getCard(cardId, signal); + + return { + queryKey, + queryFn, + enabled: !!cardId, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag; + }; +}; + +export type GetCardQueryResult = NonNullable< + Awaited> +>; +export type GetCardQueryError = void | HTTPValidationError; + +export function useGetCard< + TData = Awaited>, + TError = void | HTTPValidationError, +>( + cardId: string, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useGetCard< + TData = Awaited>, + TError = void | HTTPValidationError, +>( + cardId: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useGetCard< + TData = Awaited>, + TError = void | HTTPValidationError, +>( + cardId: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary 협상카드 조회 + */ + +export function useGetCard< + TData = Awaited>, + TError = void | HTTPValidationError, +>( + cardId: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getGetCardQueryOptions(cardId, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + +/** + * @summary 협상카드 수정 + */ +export const updateCard = (cardId: string, reqUpdateCard: ReqUpdateCard) => { + return customFetch({ + url: `/v1/card/update/${cardId}`, + method: "PATCH", + headers: { "Content-Type": "application/json" }, + data: reqUpdateCard, + }); +}; + +export const getUpdateCardMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { cardId: string; data: ReqUpdateCard }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { cardId: string; data: ReqUpdateCard }, + TContext +> => { + const mutationKey = ["updateCard"]; + const { mutation: mutationOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { cardId: string; data: ReqUpdateCard } + > = (props) => { + const { cardId, data } = props ?? {}; + + return updateCard(cardId, data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type UpdateCardMutationResult = NonNullable< + Awaited> +>; +export type UpdateCardMutationBody = ReqUpdateCard; +export type UpdateCardMutationError = void | HTTPValidationError; + +/** + * @summary 협상카드 수정 + */ +export const useUpdateCard = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { cardId: string; data: ReqUpdateCard }, + TContext + >; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { cardId: string; data: ReqUpdateCard }, + TContext +> => { + const mutationOptions = getUpdateCardMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * @summary 협상카드 삭제 + */ +export const deleteCard = (cardId: string) => { + return customFetch({ + url: `/v1/card/delete/${cardId}`, + method: "DELETE", + }); +}; + +export const getDeleteCardMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { cardId: string }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { cardId: string }, + TContext +> => { + const mutationKey = ["deleteCard"]; + const { mutation: mutationOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { cardId: string } + > = (props) => { + const { cardId } = props ?? {}; + + return deleteCard(cardId); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type DeleteCardMutationResult = NonNullable< + Awaited> +>; + +export type DeleteCardMutationError = void | HTTPValidationError; + +/** + * @summary 협상카드 삭제 + */ +export const useDeleteCard = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { cardId: string }, + TContext + >; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { cardId: string }, + TContext +> => { + const mutationOptions = getDeleteCardMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; diff --git a/negodata/front/src/api/generated/item/item.ts b/negodata/front/src/api/generated/item/item.ts index 6f9a7a7..e732c89 100644 --- a/negodata/front/src/api/generated/item/item.ts +++ b/negodata/front/src/api/generated/item/item.ts @@ -24,11 +24,14 @@ import type { BodyUploadItemsExcelV1ItemUploadExcelPost, HTTPValidationError, ListItemsParams, + ReqCheckCodes, ReqCreateItem, ReqUpdateItem, + ResCheckCodes, ResDeleteItem, ResExcelUpload, ResItem, + ResItemCategories, ResItemList, ResLowestPriceResult, ResLowestPriceTrigger, @@ -171,6 +174,153 @@ export function useListItems< return query; } +/** + * @summary 상품 카테고리 목록(distinct) + */ +export const listItemCategories = (signal?: AbortSignal) => { + return customFetch({ + url: `/v1/item/categories`, + method: "GET", + signal, + }); +}; + +export const getListItemCategoriesQueryKey = () => { + return [`/v1/item/categories`] as const; +}; + +export const getListItemCategoriesQueryOptions = < + TData = Awaited>, + TError = void, +>(options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; +}) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListItemCategoriesQueryKey(); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => listItemCategories(signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ListItemCategoriesQueryResult = NonNullable< + Awaited> +>; +export type ListItemCategoriesQueryError = void; + +export function useListItemCategories< + TData = Awaited>, + TError = void, +>( + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useListItemCategories< + TData = Awaited>, + TError = void, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useListItemCategories< + TData = Awaited>, + TError = void, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary 상품 카테고리 목록(distinct) + */ + +export function useListItemCategories< + TData = Awaited>, + TError = void, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getListItemCategoriesQueryOptions(options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + /** * @summary 상품 등록 */ @@ -256,6 +406,91 @@ export const useCreateItem = < return useMutation(mutationOptions, queryClient); }; +/** + * @summary 코드 중복 사전검사(업로드 즉시) + */ +export const checkItemCodes = ( + reqCheckCodes: ReqCheckCodes, + signal?: AbortSignal, +) => { + return customFetch({ + url: `/v1/item/check-codes`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqCheckCodes, + signal, + }); +}; + +export const getCheckItemCodesMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: ReqCheckCodes }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { data: ReqCheckCodes }, + TContext +> => { + const mutationKey = ["checkItemCodes"]; + const { mutation: mutationOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { data: ReqCheckCodes } + > = (props) => { + const { data } = props ?? {}; + + return checkItemCodes(data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type CheckItemCodesMutationResult = NonNullable< + Awaited> +>; +export type CheckItemCodesMutationBody = ReqCheckCodes; +export type CheckItemCodesMutationError = void | HTTPValidationError; + +/** + * @summary 코드 중복 사전검사(업로드 즉시) + */ +export const useCheckItemCodes = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: ReqCheckCodes }, + TContext + >; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { data: ReqCheckCodes }, + TContext +> => { + const mutationOptions = getCheckItemCodesMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; /** * @summary 엑셀 일괄 등록(스텁) */ diff --git a/negodata/front/src/api/generated/model/cardData.ts b/negodata/front/src/api/generated/model/cardData.ts new file mode 100644 index 0000000..0d2562c --- /dev/null +++ b/negodata/front/src/api/generated/model/cardData.ts @@ -0,0 +1,30 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { CardDataUserId } from "./cardDataUserId"; +import type { CardDataName } from "./cardDataName"; +import type { CardDataNumber } from "./cardDataNumber"; +import type { CardDataScript } from "./cardDataScript"; +import type { CardDataEditScript } from "./cardDataEditScript"; +import type { CardDataCondition } from "./cardDataCondition"; +import type { CardDataMemo } from "./cardDataMemo"; +import type { CardDataCreatedAt } from "./cardDataCreatedAt"; +import type { CardDataUpdatedAt } from "./cardDataUpdatedAt"; + +export interface CardData { + nego_card_id: string; + user_id?: CardDataUserId; + is_wildcard?: boolean; + name?: CardDataName; + number?: CardDataNumber; + script?: CardDataScript; + edit_script?: CardDataEditScript; + status?: number; + condition?: CardDataCondition; + memo?: CardDataMemo; + created_at?: CardDataCreatedAt; + updated_at?: CardDataUpdatedAt; +} diff --git a/negodata/front/src/api/generated/model/cardDataCondition.ts b/negodata/front/src/api/generated/model/cardDataCondition.ts new file mode 100644 index 0000000..e9cdbbb --- /dev/null +++ b/negodata/front/src/api/generated/model/cardDataCondition.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type CardDataCondition = string | null; diff --git a/negodata/front/src/api/generated/model/cardDataCreatedAt.ts b/negodata/front/src/api/generated/model/cardDataCreatedAt.ts new file mode 100644 index 0000000..05c880e --- /dev/null +++ b/negodata/front/src/api/generated/model/cardDataCreatedAt.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type CardDataCreatedAt = string | null; diff --git a/negodata/front/src/api/generated/model/cardDataEditScript.ts b/negodata/front/src/api/generated/model/cardDataEditScript.ts new file mode 100644 index 0000000..5db88eb --- /dev/null +++ b/negodata/front/src/api/generated/model/cardDataEditScript.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type CardDataEditScript = unknown | null; diff --git a/negodata/front/src/api/generated/model/cardDataMemo.ts b/negodata/front/src/api/generated/model/cardDataMemo.ts new file mode 100644 index 0000000..c4204a3 --- /dev/null +++ b/negodata/front/src/api/generated/model/cardDataMemo.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type CardDataMemo = string | null; diff --git a/negodata/front/src/api/generated/model/cardDataName.ts b/negodata/front/src/api/generated/model/cardDataName.ts new file mode 100644 index 0000000..b9afb49 --- /dev/null +++ b/negodata/front/src/api/generated/model/cardDataName.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type CardDataName = string | null; diff --git a/negodata/front/src/api/generated/model/cardDataNumber.ts b/negodata/front/src/api/generated/model/cardDataNumber.ts new file mode 100644 index 0000000..1ffca42 --- /dev/null +++ b/negodata/front/src/api/generated/model/cardDataNumber.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type CardDataNumber = string | null; diff --git a/negodata/front/src/api/generated/model/cardDataScript.ts b/negodata/front/src/api/generated/model/cardDataScript.ts new file mode 100644 index 0000000..04d40d3 --- /dev/null +++ b/negodata/front/src/api/generated/model/cardDataScript.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type CardDataScript = string | null; diff --git a/negodata/front/src/api/generated/model/cardDataUpdatedAt.ts b/negodata/front/src/api/generated/model/cardDataUpdatedAt.ts new file mode 100644 index 0000000..d20573a --- /dev/null +++ b/negodata/front/src/api/generated/model/cardDataUpdatedAt.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type CardDataUpdatedAt = string | null; diff --git a/negodata/front/src/api/generated/model/cardDataUserId.ts b/negodata/front/src/api/generated/model/cardDataUserId.ts new file mode 100644 index 0000000..6246deb --- /dev/null +++ b/negodata/front/src/api/generated/model/cardDataUserId.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type CardDataUserId = string | null; diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 3eff534..01ae6f3 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -8,6 +8,16 @@ export * from "./asyncJob"; export * from "./bodyUploadItemsExcelV1ItemUploadExcelPost"; export * from "./bodyUploadSuppliersExcelV1SupplierUploadExcelPost"; +export * from "./cardData"; +export * from "./cardDataCondition"; +export * from "./cardDataCreatedAt"; +export * from "./cardDataEditScript"; +export * from "./cardDataMemo"; +export * from "./cardDataName"; +export * from "./cardDataNumber"; +export * from "./cardDataScript"; +export * from "./cardDataUpdatedAt"; +export * from "./cardDataUserId"; export * from "./chatMessageData"; export * from "./chatMessageDataCardId"; export * from "./chatMessageDataCardType"; @@ -20,6 +30,7 @@ export * from "./errorInfoCode"; export * from "./errorInfoDesc"; export * from "./errorInfoSuccess"; export * from "./hTTPValidationError"; +export * from "./itemCategory"; export * from "./itemData"; export * from "./itemDataCategory"; export * from "./itemDataCode"; @@ -37,6 +48,7 @@ export * from "./itemDataQuantityUnit"; export * from "./itemDataSpec"; export * from "./itemDataUpdatedAt"; export * from "./itemDataVatYn"; +export * from "./listCardsParams"; export * from "./listItemsParams"; export * from "./listQuotationsParams"; export * from "./listSuppliersParams"; @@ -63,7 +75,15 @@ export * from "./quotationSettingData"; export * from "./quotationSettingDataCreatedAt"; export * from "./quotationSettingDataUpdatedAt"; export * from "./quotationSettingDataUserId"; +export * from "./reqCheckCodes"; export * from "./reqCreateAccount"; +export * from "./reqCreateCard"; +export * from "./reqCreateCardCondition"; +export * from "./reqCreateCardEditScript"; +export * from "./reqCreateCardMemo"; +export * from "./reqCreateCardName"; +export * from "./reqCreateCardNumber"; +export * from "./reqCreateCardScript"; export * from "./reqCreateItem"; export * from "./reqCreateItemCategory"; export * from "./reqCreateItemCode"; @@ -92,6 +112,14 @@ export * from "./reqCreateSupplierManagerEmail"; export * from "./reqCreateSupplierManagerName"; export * from "./reqCreateSupplierPriority"; export * from "./reqLogin"; +export * from "./reqUpdateCard"; +export * from "./reqUpdateCardCondition"; +export * from "./reqUpdateCardEditScript"; +export * from "./reqUpdateCardMemo"; +export * from "./reqUpdateCardName"; +export * from "./reqUpdateCardNumber"; +export * from "./reqUpdateCardScript"; +export * from "./reqUpdateCardStatus"; export * from "./reqUpdateItem"; export * from "./reqUpdateItemCategory"; export * from "./reqUpdateItemCategoryType"; @@ -121,12 +149,21 @@ export * from "./reqUpdateSupplierManagerEmail"; export * from "./reqUpdateSupplierManagerName"; export * from "./reqUpdateSupplierName"; export * from "./reqUpdateSupplierPriority"; +export * from "./resCard"; +export * from "./resCardCard"; +export * from "./resCardList"; +export * from "./resCardListMsg"; +export * from "./resCardMsg"; +export * from "./resCheckCodes"; +export * from "./resCheckCodesMsg"; export * from "./resCreateAccount"; export * from "./resCreateAccountMsg"; export * from "./resCreateQuotation"; export * from "./resCreateQuotationAsyncJob"; export * from "./resCreateQuotationMsg"; export * from "./resCreateQuotationQuotation"; +export * from "./resDeleteCard"; +export * from "./resDeleteCardMsg"; export * from "./resDeleteItem"; export * from "./resDeleteItemMsg"; export * from "./resDeleteQuotation"; @@ -142,6 +179,8 @@ export * from "./resExcelUpload"; export * from "./resExcelUploadMsg"; export * from "./resExcelUploadReceivedFilename"; export * from "./resItem"; +export * from "./resItemCategories"; +export * from "./resItemCategoriesMsg"; export * from "./resItemItem"; export * from "./resItemList"; export * from "./resItemListMsg"; diff --git a/negodata/front/src/api/generated/model/itemCategory.ts b/negodata/front/src/api/generated/model/itemCategory.ts new file mode 100644 index 0000000..a7c2357 --- /dev/null +++ b/negodata/front/src/api/generated/model/itemCategory.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export interface ItemCategory { + name: string; + category_type?: number; +} diff --git a/negodata/front/src/api/generated/model/listCardsParams.ts b/negodata/front/src/api/generated/model/listCardsParams.ts new file mode 100644 index 0000000..9bf1168 --- /dev/null +++ b/negodata/front/src/api/generated/model/listCardsParams.ts @@ -0,0 +1,22 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ListCardsParams = { + /** + * 카드명/카드번호/스크립트 검색 + */ + search?: string | null; + /** + * @minimum 1 + */ + page?: number; + /** + * @minimum 1 + * @maximum 100 + */ + size?: number; +}; diff --git a/negodata/front/src/api/generated/model/listSuppliersParams.ts b/negodata/front/src/api/generated/model/listSuppliersParams.ts index c577f77..c6afa66 100644 --- a/negodata/front/src/api/generated/model/listSuppliersParams.ts +++ b/negodata/front/src/api/generated/model/listSuppliersParams.ts @@ -10,6 +10,10 @@ export type ListSuppliersParams = { * 협력사명/코드/담당자명 검색 */ search?: string | null; + /** + * 우선순위 필터(HIGH/MEDIUM/LOW) + */ + priority?: string | null; /** * @minimum 1 */ diff --git a/negodata/front/src/api/generated/model/reqCheckCodes.ts b/negodata/front/src/api/generated/model/reqCheckCodes.ts new file mode 100644 index 0000000..bf39a7c --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCheckCodes.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export interface ReqCheckCodes { + codes?: string[]; +} diff --git a/negodata/front/src/api/generated/model/reqCreateCard.ts b/negodata/front/src/api/generated/model/reqCreateCard.ts new file mode 100644 index 0000000..b67c791 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateCard.ts @@ -0,0 +1,23 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ReqCreateCardName } from "./reqCreateCardName"; +import type { ReqCreateCardNumber } from "./reqCreateCardNumber"; +import type { ReqCreateCardScript } from "./reqCreateCardScript"; +import type { ReqCreateCardEditScript } from "./reqCreateCardEditScript"; +import type { ReqCreateCardCondition } from "./reqCreateCardCondition"; +import type { ReqCreateCardMemo } from "./reqCreateCardMemo"; + +export interface ReqCreateCard { + is_wildcard?: boolean; + name?: ReqCreateCardName; + number?: ReqCreateCardNumber; + script?: ReqCreateCardScript; + edit_script?: ReqCreateCardEditScript; + status?: number; + condition?: ReqCreateCardCondition; + memo?: ReqCreateCardMemo; +} diff --git a/negodata/front/src/api/generated/model/reqCreateCardCondition.ts b/negodata/front/src/api/generated/model/reqCreateCardCondition.ts new file mode 100644 index 0000000..13e6f09 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateCardCondition.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqCreateCardCondition = string | null; diff --git a/negodata/front/src/api/generated/model/reqCreateCardEditScript.ts b/negodata/front/src/api/generated/model/reqCreateCardEditScript.ts new file mode 100644 index 0000000..336697b --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateCardEditScript.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqCreateCardEditScript = unknown | null; diff --git a/negodata/front/src/api/generated/model/reqCreateCardMemo.ts b/negodata/front/src/api/generated/model/reqCreateCardMemo.ts new file mode 100644 index 0000000..d29f826 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateCardMemo.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqCreateCardMemo = string | null; diff --git a/negodata/front/src/api/generated/model/reqCreateCardName.ts b/negodata/front/src/api/generated/model/reqCreateCardName.ts new file mode 100644 index 0000000..45fd31b --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateCardName.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqCreateCardName = string | null; diff --git a/negodata/front/src/api/generated/model/reqCreateCardNumber.ts b/negodata/front/src/api/generated/model/reqCreateCardNumber.ts new file mode 100644 index 0000000..b256396 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateCardNumber.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqCreateCardNumber = string | null; diff --git a/negodata/front/src/api/generated/model/reqCreateCardScript.ts b/negodata/front/src/api/generated/model/reqCreateCardScript.ts new file mode 100644 index 0000000..ea9872a --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateCardScript.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqCreateCardScript = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateCard.ts b/negodata/front/src/api/generated/model/reqUpdateCard.ts new file mode 100644 index 0000000..18b397e --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCard.ts @@ -0,0 +1,23 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ReqUpdateCardName } from "./reqUpdateCardName"; +import type { ReqUpdateCardNumber } from "./reqUpdateCardNumber"; +import type { ReqUpdateCardScript } from "./reqUpdateCardScript"; +import type { ReqUpdateCardEditScript } from "./reqUpdateCardEditScript"; +import type { ReqUpdateCardStatus } from "./reqUpdateCardStatus"; +import type { ReqUpdateCardCondition } from "./reqUpdateCardCondition"; +import type { ReqUpdateCardMemo } from "./reqUpdateCardMemo"; + +export interface ReqUpdateCard { + name?: ReqUpdateCardName; + number?: ReqUpdateCardNumber; + script?: ReqUpdateCardScript; + edit_script?: ReqUpdateCardEditScript; + status?: ReqUpdateCardStatus; + condition?: ReqUpdateCardCondition; + memo?: ReqUpdateCardMemo; +} diff --git a/negodata/front/src/api/generated/model/reqUpdateCardCondition.ts b/negodata/front/src/api/generated/model/reqUpdateCardCondition.ts new file mode 100644 index 0000000..b5d34bb --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCardCondition.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqUpdateCardCondition = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateCardEditScript.ts b/negodata/front/src/api/generated/model/reqUpdateCardEditScript.ts new file mode 100644 index 0000000..d2c55df --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCardEditScript.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqUpdateCardEditScript = unknown | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateCardMemo.ts b/negodata/front/src/api/generated/model/reqUpdateCardMemo.ts new file mode 100644 index 0000000..a5b995e --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCardMemo.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqUpdateCardMemo = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateCardName.ts b/negodata/front/src/api/generated/model/reqUpdateCardName.ts new file mode 100644 index 0000000..2cff992 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCardName.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqUpdateCardName = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateCardNumber.ts b/negodata/front/src/api/generated/model/reqUpdateCardNumber.ts new file mode 100644 index 0000000..7b30f29 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCardNumber.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqUpdateCardNumber = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateCardScript.ts b/negodata/front/src/api/generated/model/reqUpdateCardScript.ts new file mode 100644 index 0000000..e8ca692 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCardScript.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqUpdateCardScript = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateCardStatus.ts b/negodata/front/src/api/generated/model/reqUpdateCardStatus.ts new file mode 100644 index 0000000..4b1d001 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCardStatus.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqUpdateCardStatus = number | null; diff --git a/negodata/front/src/api/generated/model/resCard.ts b/negodata/front/src/api/generated/model/resCard.ts new file mode 100644 index 0000000..8ed8f0e --- /dev/null +++ b/negodata/front/src/api/generated/model/resCard.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from "./errorInfo"; +import type { ResCardMsg } from "./resCardMsg"; +import type { ResCardCard } from "./resCardCard"; + +export interface ResCard { + result?: ErrorInfo; + msg?: ResCardMsg; + card?: ResCardCard; +} diff --git a/negodata/front/src/api/generated/model/resCardCard.ts b/negodata/front/src/api/generated/model/resCardCard.ts new file mode 100644 index 0000000..40bd3c8 --- /dev/null +++ b/negodata/front/src/api/generated/model/resCardCard.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { CardData } from "./cardData"; + +export type ResCardCard = CardData | null; diff --git a/negodata/front/src/api/generated/model/resCardList.ts b/negodata/front/src/api/generated/model/resCardList.ts new file mode 100644 index 0000000..cff383b --- /dev/null +++ b/negodata/front/src/api/generated/model/resCardList.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from "./errorInfo"; +import type { ResCardListMsg } from "./resCardListMsg"; +import type { CardData } from "./cardData"; + +export interface ResCardList { + result?: ErrorInfo; + msg?: ResCardListMsg; + cards?: CardData[]; + total?: number; + page?: number; + size?: number; +} diff --git a/negodata/front/src/api/generated/model/resCardListMsg.ts b/negodata/front/src/api/generated/model/resCardListMsg.ts new file mode 100644 index 0000000..41e5de2 --- /dev/null +++ b/negodata/front/src/api/generated/model/resCardListMsg.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ResCardListMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resCardMsg.ts b/negodata/front/src/api/generated/model/resCardMsg.ts new file mode 100644 index 0000000..e651e2c --- /dev/null +++ b/negodata/front/src/api/generated/model/resCardMsg.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ResCardMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resCheckCodes.ts b/negodata/front/src/api/generated/model/resCheckCodes.ts new file mode 100644 index 0000000..0d79010 --- /dev/null +++ b/negodata/front/src/api/generated/model/resCheckCodes.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from "./errorInfo"; +import type { ResCheckCodesMsg } from "./resCheckCodesMsg"; + +export interface ResCheckCodes { + result?: ErrorInfo; + msg?: ResCheckCodesMsg; + existing?: string[]; +} diff --git a/negodata/front/src/api/generated/model/resCheckCodesMsg.ts b/negodata/front/src/api/generated/model/resCheckCodesMsg.ts new file mode 100644 index 0000000..759258e --- /dev/null +++ b/negodata/front/src/api/generated/model/resCheckCodesMsg.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ResCheckCodesMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resDeleteCard.ts b/negodata/front/src/api/generated/model/resDeleteCard.ts new file mode 100644 index 0000000..c95d877 --- /dev/null +++ b/negodata/front/src/api/generated/model/resDeleteCard.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from "./errorInfo"; +import type { ResDeleteCardMsg } from "./resDeleteCardMsg"; + +export interface ResDeleteCard { + result?: ErrorInfo; + msg?: ResDeleteCardMsg; +} diff --git a/negodata/front/src/api/generated/model/resDeleteCardMsg.ts b/negodata/front/src/api/generated/model/resDeleteCardMsg.ts new file mode 100644 index 0000000..b1bcc6c --- /dev/null +++ b/negodata/front/src/api/generated/model/resDeleteCardMsg.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ResDeleteCardMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resItemCategories.ts b/negodata/front/src/api/generated/model/resItemCategories.ts new file mode 100644 index 0000000..367dca0 --- /dev/null +++ b/negodata/front/src/api/generated/model/resItemCategories.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from "./errorInfo"; +import type { ResItemCategoriesMsg } from "./resItemCategoriesMsg"; +import type { ItemCategory } from "./itemCategory"; + +export interface ResItemCategories { + result?: ErrorInfo; + msg?: ResItemCategoriesMsg; + categories?: ItemCategory[]; +} diff --git a/negodata/front/src/api/generated/model/resItemCategoriesMsg.ts b/negodata/front/src/api/generated/model/resItemCategoriesMsg.ts new file mode 100644 index 0000000..d522568 --- /dev/null +++ b/negodata/front/src/api/generated/model/resItemCategoriesMsg.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ResItemCategoriesMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resMe.ts b/negodata/front/src/api/generated/model/resMe.ts index c5e900f..aef9374 100644 --- a/negodata/front/src/api/generated/model/resMe.ts +++ b/negodata/front/src/api/generated/model/resMe.ts @@ -20,5 +20,6 @@ export interface ResMe { email?: ResMeEmail; contact_number?: ResMeContactNumber; role?: number; + role_label?: string; company?: ResMeCompany; } diff --git a/negodata/front/src/api/generated/supplier/supplier.ts b/negodata/front/src/api/generated/supplier/supplier.ts index 70c507a..98c87eb 100644 --- a/negodata/front/src/api/generated/supplier/supplier.ts +++ b/negodata/front/src/api/generated/supplier/supplier.ts @@ -24,8 +24,10 @@ import type { BodyUploadSuppliersExcelV1SupplierUploadExcelPost, HTTPValidationError, ListSuppliersParams, + ReqCheckCodes, ReqCreateSupplier, ReqUpdateSupplier, + ResCheckCodes, ResDeleteSupplier, ResExcelUpload, ResSupplier, @@ -257,6 +259,91 @@ export const useCreateSupplier = < return useMutation(mutationOptions, queryClient); }; +/** + * @summary 코드 중복 사전검사(업로드 즉시) + */ +export const checkSupplierCodes = ( + reqCheckCodes: ReqCheckCodes, + signal?: AbortSignal, +) => { + return customFetch({ + url: `/v1/supplier/check-codes`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: reqCheckCodes, + signal, + }); +}; + +export const getCheckSupplierCodesMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: ReqCheckCodes }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { data: ReqCheckCodes }, + TContext +> => { + const mutationKey = ["checkSupplierCodes"]; + const { mutation: mutationOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { data: ReqCheckCodes } + > = (props) => { + const { data } = props ?? {}; + + return checkSupplierCodes(data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type CheckSupplierCodesMutationResult = NonNullable< + Awaited> +>; +export type CheckSupplierCodesMutationBody = ReqCheckCodes; +export type CheckSupplierCodesMutationError = void | HTTPValidationError; + +/** + * @summary 코드 중복 사전검사(업로드 즉시) + */ +export const useCheckSupplierCodes = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: ReqCheckCodes }, + TContext + >; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { data: ReqCheckCodes }, + TContext +> => { + const mutationOptions = getCheckSupplierCodesMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; /** * @summary 협력사 엑셀 일괄 등록(스텁) */ From dd03c2aecdfa3f85481dc6ba8b48181fc67afa1c Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Thu, 18 Jun 2026 11:03:11 +0900 Subject: [PATCH 9/9] =?UTF-8?q?[feat]=20negodata/front:=20=EC=83=81?= =?UTF-8?q?=ED=92=88=C2=B7=ED=98=91=EB=A0=A5=EC=82=AC=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EC=85=98=C2=B7=EA=B2=80=EC=83=89=20=EC=97=B0=EB=8F=99?= =?UTF-8?q?(=EC=9E=AC=EC=82=AC=EC=9A=A9=20useServerList)=20+=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=C2=B7=EA=B2=AC=EC=A0=81=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../front/src/api/mutator/custom-fetch.ts | 94 ++++-- .../front/src/components/layout/Layout.tsx | 1 - negodata/front/src/features/auth/service.ts | 2 +- .../cards/components/CardFormModal.tsx | 53 ++-- .../src/features/cards/hooks/useCards.ts | 101 +++++-- negodata/front/src/features/cards/types.ts | 24 ++ .../partners/components/ExcelUploadModal.tsx | 106 +++++-- .../partners/components/PartnerFormSheet.tsx | 31 +- .../partners/hooks/usePartnerFilters.ts | 48 --- .../features/partners/hooks/usePartners.ts | 77 +++-- .../products/components/ExcelUploadModal.tsx | 285 +++++++++++++----- .../products/components/ProductFormSheet.tsx | 61 ++-- .../products/hooks/useProductFilters.ts | 47 --- .../features/products/hooks/useProducts.ts | 108 +++++-- negodata/front/src/features/products/types.ts | 3 +- .../components/CreateQuotationWizard.tsx | 83 +++-- .../components/QuotationDetailDrawer.tsx | 157 +++++----- .../components/QuotationSettingsModal.tsx | 17 +- .../quotations/components/QuotationTable.tsx | 2 +- .../quotations/hooks/useQuotations.ts | 101 ++++--- .../front/src/features/quotations/types.ts | 87 +++++- negodata/front/src/lib/excel.ts | 3 + negodata/front/src/lib/useServerList.ts | 55 ++++ negodata/front/src/pages/cards.tsx | 10 +- negodata/front/src/pages/partners.tsx | 47 +-- negodata/front/src/pages/products.tsx | 64 ++-- negodata/front/src/pages/quotation.tsx | 3 - negodata/front/src/stores/auth.ts | 2 +- 28 files changed, 1111 insertions(+), 561 deletions(-) delete mode 100644 negodata/front/src/features/partners/hooks/usePartnerFilters.ts delete mode 100644 negodata/front/src/features/products/hooks/useProductFilters.ts create mode 100644 negodata/front/src/lib/useServerList.ts diff --git a/negodata/front/src/api/mutator/custom-fetch.ts b/negodata/front/src/api/mutator/custom-fetch.ts index 6d2ac59..5921c43 100644 --- a/negodata/front/src/api/mutator/custom-fetch.ts +++ b/negodata/front/src/api/mutator/custom-fetch.ts @@ -9,12 +9,52 @@ // baseURL — compose 의 VITE_API_BASE_URL 로 주입, 없으면 9400 폴백. const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9400'; -// 토큰 보관소 — 인증 연동 시 로그인 후 setAccessToken()으로 주입한다(현재 목업 단계에선 미사용). +// 토큰 보관소 — 로그인 후 setAccessToken()으로 주입. localStorage(auth/service 와 동일 키)와 동기화. +const ACCESS_KEY = 'negodata.accessToken'; +const REFRESH_KEY = 'negodata.refreshToken'; + let accessToken: string | null = null; export const setAccessToken = (token: string | null) => { accessToken = token; }; +// 동시 다발 434(액세스 토큰 만료)를 단 한 번의 refresh 로 합치기 위한 in-flight 프라미스. +let refreshInFlight: Promise | null = null; + +// refresh 토큰으로 access 재발급. 성공 시 새 access 토큰, 실패 시 null. +async function refreshAccessToken(): Promise { + if (refreshInFlight) return refreshInFlight; + refreshInFlight = (async () => { + try { + const refresh = localStorage.getItem(REFRESH_KEY); + if (!refresh) return null; + // refresh 토큰은 Authorization 헤더(Bearer)로 보낸다(auth/service restore() 와 동일 규약). + const res = await fetch(`${BASE_URL}/v1/auth/refresh_token`, { + method: 'POST', + headers: { Authorization: `Bearer ${refresh}` }, + }); + if (!res.ok) return null; + const refreshed = await res.json().catch(() => null); + const newToken: string | undefined = refreshed?.access_token; + if (!newToken || refreshed?.result?.success === false) return null; + localStorage.setItem(ACCESS_KEY, newToken); + accessToken = newToken; + return newToken; + } catch { + return null; + } finally { + refreshInFlight = null; + } + })(); + return refreshInFlight; +} + +function clearTokens() { + localStorage.removeItem(ACCESS_KEY); + localStorage.removeItem(REFRESH_KEY); + accessToken = null; +} + export class ApiError extends Error { constructor( public status: number, @@ -42,19 +82,6 @@ export const customFetch = async ( ): Promise => { const { url, method, params, data, signal } = config; - // [요청 인터셉터] 헤더 병합: config → options 순으로 덮어쓴다. - const headers = new Headers(config.headers); - if (options?.headers) { - new Headers(options.headers).forEach((v, k) => headers.set(k, v)); - } - if (data != null && !headers.has('Content-Type')) { - headers.set('Content-Type', 'application/json'); - } - // 토큰이 있으면 Authorization 자동 주입 - if (accessToken) { - headers.set('Authorization', `Bearer ${accessToken}`); - } - // 쿼리스트링 직렬화 (null/undefined 값은 제외) const queryString = params ? new URLSearchParams( @@ -67,13 +94,37 @@ export const customFetch = async ( (url.startsWith('http') ? url : `${BASE_URL}${url}`) + (queryString ? `?${queryString}` : ''); - const response = await fetch(requestUrl, { - ...options, - method, - headers, - body: data != null ? JSON.stringify(data) : options?.body, - signal, - }); + // [요청 인터셉터] 토큰을 받아 헤더를 구성하고 한 번 호출(만료 재시도 시 새 토큰으로 재구성). + const doFetch = (token: string | null) => { + const headers = new Headers(config.headers); + if (options?.headers) { + new Headers(options.headers).forEach((v, k) => headers.set(k, v)); + } + if (data != null && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + if (token) headers.set('Authorization', `Bearer ${token}`); + return fetch(requestUrl, { + ...options, + method, + headers, + body: data != null ? JSON.stringify(data) : options?.body, + signal, + }); + }; + + let response = await doFetch(accessToken); + + // 434(액세스 토큰 만료) → refresh 로 재발급 후 1회 재시도. auth 엔드포인트 자신은 제외(무한루프 방지). + const isAuthEndpoint = url.includes('/v1/auth/refresh_token') || url.includes('/v1/auth/login'); + if ((response.status === 434 || response.status === 401) && !isAuthEndpoint) { + const newToken = await refreshAccessToken(); + if (newToken) { + response = await doFetch(newToken); + } else { + clearTokens(); // refresh 실패 → 토큰 폐기(다음 로드/가드에서 로그인으로 유도) + } + } // [응답 인터셉터] 본문 파싱 (204/빈 응답 대응) const isJson = response.headers.get('content-type')?.includes('application/json'); @@ -82,7 +133,6 @@ export const customFetch = async ( // fetch는 4xx/5xx도 resolve하므로 직접 throw → React Query가 error로 처리 if (!response.ok) { - // TODO: 401이면 refresh 토큰으로 재발급 후 재시도 로직 추가 위치 throw new ApiError(response.status, response.statusText, body); } diff --git a/negodata/front/src/components/layout/Layout.tsx b/negodata/front/src/components/layout/Layout.tsx index 930fc1a..9216071 100644 --- a/negodata/front/src/components/layout/Layout.tsx +++ b/negodata/front/src/components/layout/Layout.tsx @@ -150,7 +150,6 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay {pageLabelMap[currentPage]} - B2B 시스템 diff --git a/negodata/front/src/features/auth/service.ts b/negodata/front/src/features/auth/service.ts index 2051c67..005f408 100644 --- a/negodata/front/src/features/auth/service.ts +++ b/negodata/front/src/features/auth/service.ts @@ -26,7 +26,7 @@ function toAuthUser(me: ResMe): AuthUser { loginId: me.id ?? '', email: me.email ?? '', contact: me.contact_number ?? '', - role: (me.role as UserRole) ?? '일반', + role: (me.role_label as UserRole) || '일반', }; } diff --git a/negodata/front/src/features/cards/components/CardFormModal.tsx b/negodata/front/src/features/cards/components/CardFormModal.tsx index 7dcd241..8203e38 100644 --- a/negodata/front/src/features/cards/components/CardFormModal.tsx +++ b/negodata/front/src/features/cards/components/CardFormModal.tsx @@ -5,6 +5,7 @@ import { X, Layers } from 'lucide-react'; import { showToast } from '@/lib/notify'; import { Typography } from '@/components/ui/typography'; import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { type NegotiationCard, type CardTab, generateCardCode } from '../types'; import type { CardInput } from '../hooks/useCards'; @@ -25,8 +26,8 @@ type CardFormModalProps = { mode: 'create' | 'edit'; card: NegotiationCard | null; // edit 모드 초기값 출처 activeTab: CardTab; // create 시 기본 카드 종류 결정 - onCreate: (input: CardInput) => void; - onUpdate: (id: string, input: CardInput) => void; + onCreate: (input: CardInput) => Promise; + onUpdate: (id: string, input: CardInput) => Promise; onClose: () => void; }; @@ -85,7 +86,7 @@ export function CardFormModal({ if (!open) return null; - const onValid = (v: FormValues) => { + const onValid = async (v: FormValues) => { const input: CardInput = { title: v.title, code: v.code, @@ -95,14 +96,19 @@ export function CardFormModal({ triggerCondition: v.triggerCondition, memo: v.memo, }; - if (mode === 'create') { - onCreate(input); - showToast(`${v.isWildcard ? '와일드카드' : '협상카드'}가 추가되었습니다.`, 'success'); - } else if (card) { - onUpdate(card.id, input); - showToast('정보가 수정되었습니다.', 'success'); + const kind = v.isWildcard ? '와일드카드' : '협상카드'; + try { + if (mode === 'create') { + await onCreate(input); + showToast(`${kind}가 추가되었습니다.`, 'success'); + } else if (card) { + await onUpdate(card.id, input); + showToast('정보가 수정되었습니다.', 'success'); + } + onClose(); + } catch (err) { + showToast(err instanceof Error ? err.message : `${kind} 저장 실패`, 'error'); } - onClose(); }; return ( @@ -139,21 +145,26 @@ export function CardFormModal({ control={control} name="isWildcard" render={({ field }) => ( - + + + {(value) => (value === 'WILD' ? '와일드카드' : '일반 협상카드')} + + + + 일반 협상카드 + 와일드카드 + + )} /> diff --git a/negodata/front/src/features/cards/hooks/useCards.ts b/negodata/front/src/features/cards/hooks/useCards.ts index 586324e..07c9a0b 100644 --- a/negodata/front/src/features/cards/hooks/useCards.ts +++ b/negodata/front/src/features/cards/hooks/useCards.ts @@ -1,6 +1,17 @@ -import { useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { + useListCards, + createCard, + updateCard, + deleteCard, + getListCardsQueryKey, +} from '@/api/generated/card/card'; +import type { ReqCreateCard } from '@/api/generated/model/reqCreateCard'; +import type { ResCard } from '@/api/generated/model/resCard'; import type { NegotiationCard } from '@/types'; -import { generateSlateJSON } from '../types'; +import { generateSlateJSON, mapCardData, toCardStatusCode } from '../types'; + +const LIST_PARAMS = { size: 100 }; // 카드 폼이 넘기는 입력값(편집/생성 공통). export type CardInput = { @@ -13,34 +24,60 @@ export type CardInput = { memo?: string; }; -// 협상카드 카탈로그 — 백엔드 미연동이라 로컬 state로만 CRUD(목업 제거됨, 빈 상태로 시작). -// 와일드카드 전용 필드(triggerCondition/memo)는 isWildcard일 때만 보존한다. -export function useCards() { - const [cards, setCards] = useState([]); - - const toCard = (id: string, input: CardInput): NegotiationCard => ({ - id, - isWildcard: input.isWildcard, - code: input.code, - title: input.title, - scriptPreview: input.scriptPreview, - editorScript: generateSlateJSON(input.scriptPreview), - status: input.status, - triggerCondition: input.isWildcard ? input.triggerCondition : undefined, - memo: input.isWildcard ? input.memo : undefined, - }); - - const createCard = (input: CardInput) => { - setCards((prev) => [...prev, toCard(`card-${Date.now()}`, input)]); - }; - - const updateCard = (id: string, input: CardInput) => { - setCards((prev) => prev.map((c) => (c.id === id ? { ...c, ...toCard(id, input) } : c))); - }; - - const deleteCard = (id: string) => { - setCards((prev) => prev.filter((c) => c.id !== id)); - }; - - return { cards, createCard, updateCard, deleteCard }; +// 서버 공통응답(result.success=false)을 한글 사유로 변환. 정상이면 null. +function cardError(res: ResCard): string | null { + const r = res.result; + if (!r || r.success !== false) return null; + return r.desc || '카드 저장에 실패했습니다.'; +} + +// UI 입력 → 서버 요청 본문. 와일드카드 전용 필드(condition/memo)는 isWildcard 일 때만 전송. +function toReq(input: CardInput): ReqCreateCard { + return { + is_wildcard: input.isWildcard, + name: input.title, + number: input.code, + script: input.scriptPreview, + edit_script: generateSlateJSON(input.scriptPreview), + status: toCardStatusCode(input.status), + condition: input.isWildcard ? input.triggerCondition : undefined, + memo: input.isWildcard ? input.memo : undefined, + }; +} + +// 협상카드 카탈로그 서버 데이터 + CRUD. orval 뮤테이션 호출 후 목록 쿼리 무효화(재조회). +// 실패 시 throw → 호출부(폼/페이지)에서 toast 처리. +export function useCards() { + const queryClient = useQueryClient(); + const cardsQuery = useListCards(LIST_PARAMS); + + const refresh = () => + queryClient.invalidateQueries({ queryKey: getListCardsQueryKey(LIST_PARAMS) }); + + const createCardFn = async (input: CardInput) => { + const msg = cardError(await createCard(toReq(input))); + if (msg) throw new Error(msg); + await refresh(); + }; + const updateCardFn = async (id: string, input: CardInput) => { + const msg = cardError(await updateCard(id, toReq(input))); + if (msg) throw new Error(msg); + await refresh(); + }; + const deleteCardFn = async (id: string) => { + await deleteCard(id); + await refresh(); + }; + + // customFetch 가 본문을 그대로 주므로 cardsQuery.data 가 곧 ResCardList → .cards. + const cards: NegotiationCard[] = (cardsQuery.data?.cards ?? []).map(mapCardData); + + return { + cards, + createCard: createCardFn, + updateCard: updateCardFn, + deleteCard: deleteCardFn, + refresh, + cardsQuery, + }; } diff --git a/negodata/front/src/features/cards/types.ts b/negodata/front/src/features/cards/types.ts index ada17fc..16121f3 100644 --- a/negodata/front/src/features/cards/types.ts +++ b/negodata/front/src/features/cards/types.ts @@ -1,10 +1,34 @@ import type { NegotiationCard } from '@/types'; +import type { CardData } from '@/api/generated/model/cardData'; export type { NegotiationCard }; // 카드 목록 탭. 'ALL' 전체 / 'CARD' 일반 협상카드 / 'WILD' 와일드카드. export type CardTab = 'ALL' | 'CARD' | 'WILD'; +// 카드 status 코드(서버 CardStatus enum) ↔ UI 문자열. ACTIVE=1 / INACTIVE=2. +export const CARD_STATUS_ACTIVE = 1; +export const CARD_STATUS_INACTIVE = 2; +export const toCardStatusCode = (s: 'ACTIVE' | 'INACTIVE') => + s === 'ACTIVE' ? CARD_STATUS_ACTIVE : CARD_STATUS_INACTIVE; +export const toCardStatusLabel = (code?: number): 'ACTIVE' | 'INACTIVE' => + code === CARD_STATUS_INACTIVE ? 'INACTIVE' : 'ACTIVE'; + +// 서버 CardData(nego_cards/wild_cards 통합) → UI NegotiationCard. +export function mapCardData(c: CardData): NegotiationCard { + return { + id: c.nego_card_id, + isWildcard: c.is_wildcard ?? false, + code: c.number ?? '', + title: c.name ?? '', + scriptPreview: c.script ?? '', + editorScript: c.edit_script, + status: toCardStatusLabel(c.status), + triggerCondition: c.condition ?? undefined, + memo: c.memo ?? undefined, + }; +} + // 평문 스크립트를 Slate JSON 노드로 변환(카드 저장 시 editorScript 생성). export function generateSlateJSON(previewText: string): unknown[] { return [ diff --git a/negodata/front/src/features/partners/components/ExcelUploadModal.tsx b/negodata/front/src/features/partners/components/ExcelUploadModal.tsx index 89d09bb..541d027 100644 --- a/negodata/front/src/features/partners/components/ExcelUploadModal.tsx +++ b/negodata/front/src/features/partners/components/ExcelUploadModal.tsx @@ -1,8 +1,9 @@ import { useMemo, useRef, useState } from 'react'; -import { Upload, X, FileSpreadsheet, CheckCircle2, Download } from 'lucide-react'; +import { Upload, X, FileSpreadsheet, CheckCircle2, Download, Trash2 } from 'lucide-react'; import type { ReqCreateSupplier as SupplierCreate } from '@/api/generated/model/reqCreateSupplier'; import { showToast } from '@/lib/notify'; -import { downloadExcel, parseCsv } from '@/lib/excel'; +import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel'; +import { customFetch } from '@/api/mutator/custom-fetch'; import { Typography } from '@/components/ui/typography'; import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; @@ -27,12 +28,17 @@ type TemplateRow = { name: string; code: string; managerName: string; managerEma type ExcelUploadModalProps = { open: boolean; partners: Partner[]; // 코드 중복 검사용 - onConfirm: (rows: SupplierCreate[]) => Promise; + onConfirm: (rows: SupplierCreate[]) => Promise; onClose: () => void; }; // 행 검증 — 순수 함수. 우선순위 순으로 첫 위반 메시지를 매긴다. -function validateRows(rows: RawRow[], partners: Partner[]): ValidatedRow[] { +// serverErrors: 서버(DB) 검증에서 거부된 code→사유. 프론트 검증을 통과한 행만 마지막에 덧씌운다. +function validateRows( + rows: RawRow[], + partners: Partner[], + serverErrors: Record, +): ValidatedRow[] { return rows.map((row) => { const fail = (message: string): ValidatedRow => ({ ...row, status: '오류', message }); @@ -47,6 +53,9 @@ function validateRows(rows: RawRow[], partners: Partner[]): ValidatedRow[] { return fail('이메일 형식 규격 외 - 올바른 이메일 주소(@ 포함)가 필요합니다.'); } + // 프론트 검증 통과 후, 직전 전송에서 서버가 거부한 코드면 그 사유로 오류 처리. + if (serverErrors[row.code]) return fail(serverErrors[row.code]); + return { ...row, status: '정상', message: '등록 적격 - 정합성 검증 통과' }; }); } @@ -68,10 +77,14 @@ function toSupplierCreate(row: RawRow): SupplierCreate { export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUploadModalProps) { const [excelFile, setExcelFile] = useState(null); const [rows, setRows] = useState([]); + const [serverErrors, setServerErrors] = useState>({}); // 서버(DB) 거부 code→사유 const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(null); - const validated = useMemo(() => validateRows(rows, partners), [rows, partners]); + const validated = useMemo( + () => validateRows(rows, partners, serverErrors), + [rows, partners, serverErrors], + ); const validRows = validated.filter((r) => r.status === '정상'); const validCount = validRows.length; const errorCount = validated.length - validCount; @@ -81,6 +94,7 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp const close = () => { setExcelFile(null); setRows([]); + setServerErrors({}); onClose(); }; @@ -113,6 +127,23 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp })); setExcelFile(file.name); setRows(loaded); + setServerErrors({}); // 새 파일 → 직전 서버사유 초기화 + // 업로드 즉시 DB 중복코드 사전검사 → 미리보기에서 바로 빨강 처리. + const codes = [...new Set(loaded.map((r) => r.code.trim()).filter(Boolean))]; + if (codes.length === 0) return; + try { + const res = await customFetch<{ existing?: string[] }>({ + url: '/v1/supplier/check-codes', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + data: { codes }, + }); + const dup: Record = {}; + (res.existing ?? []).forEach((c) => { dup[c] = '코드 중복 — 이미 등록된 협력사코드입니다(DB).'; }); + setServerErrors(dup); + } catch { + // 사전검사 호출 실패는 조용히 무시 — 제출 시 서버가 최종 차단한다. + } }; // 인라인 편집 — 원본 필드만 갱신(재검증은 파생이 처리) @@ -120,15 +151,31 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp setRows((cur) => cur.map((row) => (row.id === id ? { ...row, [field]: value } : row))); }; + // 행 삭제 — 정상/오류 무관하게 미리보기에서 제거(서버 등록 전 단계). + const handleRemoveRow = (id: string) => { + setRows((cur) => cur.filter((row) => row.id !== id)); + }; + const handleConfirm = async () => { if (validRows.length === 0) { showToast('정합성이 무결한 파트너 행이 존재하지 않습니다.', 'error'); return; } try { - await onConfirm(validRows.map(toSupplierCreate)); - showToast(`총 ${validRows.length}개 협력사가 서버에 일괄 등록되었습니다.`, 'success'); - close(); + const failures = await onConfirm(validRows.map(toSupplierCreate)); + const okCount = validRows.length - failures.length; + if (failures.length === 0) { + showToast(`총 ${okCount}개 협력사가 서버에 일괄 등록되었습니다.`, 'success'); + close(); + return; + } + // 부분 성공: 등록 성공한 행만 제거하고, 서버(DB)가 거부한 행은 사유와 함께 남긴다. + const failMap: Record = {}; + failures.forEach((f) => { failMap[f.code] = f.message; }); + const okCodes = new Set(validRows.map((r) => r.code).filter((c) => failMap[c] === undefined)); + setServerErrors(failMap); + setRows((cur) => cur.filter((r) => !okCodes.has(r.code))); + showToast(`${okCount}건 등록 완료 · ${failures.length}건 서버 검증 실패(중복코드 등)`, 'error'); } catch (err) { showToast(err instanceof Error ? err.message : '엑셀 일괄 등록 실패', 'error'); } @@ -136,7 +183,7 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp return (
-
+
{/* Modal Title */}
@@ -227,18 +274,41 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp 행 + 삭제 + 자격 + 진단 내용 협력사명 * 협력사코드 * 담당자명 * 담당자 이메일 * - 자격 - 진단 내용 (직접 수정가능) {validated.map((row) => ( {row.rowNum} + + + + + + {row.status} + + + + {row.message} + handleUpdateField(row.id, 'managerEmail', e.target.value)} /> - - - {row.status} - - - - {row.message} - ))} @@ -313,7 +371,7 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp onClick={handleConfirm} className="py-1.5 px-4 bg-primary text-primary-foreground font-bold rounded hover:opacity-95 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer text-xs" > - 적격 협력사 추가 (총 {validCount}개사) + 적격 협력사 등록 (총 {validCount}개사)
diff --git a/negodata/front/src/features/partners/components/PartnerFormSheet.tsx b/negodata/front/src/features/partners/components/PartnerFormSheet.tsx index c27d651..61269e9 100644 --- a/negodata/front/src/features/partners/components/PartnerFormSheet.tsx +++ b/negodata/front/src/features/partners/components/PartnerFormSheet.tsx @@ -1,4 +1,4 @@ -import { useForm } from 'react-hook-form'; +import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Trash2 } from 'lucide-react'; @@ -9,6 +9,7 @@ import { Typography } from '@/components/ui/typography'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Sheet } from '@/components/ui/sheet'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { type Partner, priorityOptions } from '../types'; const schema = z.object({ @@ -67,6 +68,7 @@ export function PartnerFormSheet({ }: PartnerFormSheetProps) { const { register, + control, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ @@ -139,15 +141,24 @@ export function PartnerFormSheet({ {/* Priority */}
우선 선정 대상자 - + ( + + )} + />
diff --git a/negodata/front/src/features/partners/hooks/usePartnerFilters.ts b/negodata/front/src/features/partners/hooks/usePartnerFilters.ts deleted file mode 100644 index 21a6dca..0000000 --- a/negodata/front/src/features/partners/hooks/usePartnerFilters.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useState } from 'react'; -import type { Partner } from '../types'; - -const ITEMS_PER_PAGE = 5; - -// 협력사 목록에 대한 검색/우선순위/페이지네이션 UI state + 파생 결과. -// 검색·우선순위 변경 시 1페이지로 리셋한다. -export function usePartnerFilters(partners: Partner[]) { - const [search, setSearchRaw] = useState(''); - const [priorityFilter, setPriorityFilterRaw] = useState('ALL'); - const [page, setPage] = useState(1); - - const setSearch = (v: string) => { - setSearchRaw(v); - setPage(1); - }; - const setPriorityFilter = (v: string) => { - setPriorityFilterRaw(v); - setPage(1); - }; - - const filtered = partners.filter((part) => { - if (part.deleted) return false; // soft-deleted 제외 - const q = search.toLowerCase(); - const matchesSearch = - (part.name ?? '').toLowerCase().includes(q) || - (part.code ? part.code.toLowerCase().includes(q) : false) || - (part.manager_name ? part.manager_name.toLowerCase().includes(q) : false); - const matchesPriority = priorityFilter === 'ALL' || part.priority === priorityFilter; - return matchesSearch && matchesPriority; - }); - - const totalPages = Math.ceil(filtered.length / ITEMS_PER_PAGE) || 1; - const paginated = filtered.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE); - - return { - search, - setSearch, - priorityFilter, - setPriorityFilter, - page, - setPage, - paginated, - totalPages, - totalCount: filtered.length, - itemsPerPage: ITEMS_PER_PAGE, - }; -} diff --git a/negodata/front/src/features/partners/hooks/usePartners.ts b/negodata/front/src/features/partners/hooks/usePartners.ts index 55600c7..88e172d 100644 --- a/negodata/front/src/features/partners/hooks/usePartners.ts +++ b/negodata/front/src/features/partners/hooks/usePartners.ts @@ -1,28 +1,49 @@ -import { useQueryClient } from '@tanstack/react-query'; +import { keepPreviousData, useQueryClient } from '@tanstack/react-query'; import { useListSuppliers, createSupplier, updateSupplier, deleteSupplier, - getListSuppliersQueryKey, } from '@/api/generated/supplier/supplier'; +import type { ListSuppliersParams } from '@/api/generated/model/listSuppliersParams'; import type { ReqCreateSupplier } from '@/api/generated/model/reqCreateSupplier'; import type { ReqUpdateSupplier } from '@/api/generated/model/reqUpdateSupplier'; +import type { ResSupplier } from '@/api/generated/model/resSupplier'; +import type { SupplierData } from '@/api/generated/model/supplierData'; +import type { BulkFailure } from '@/lib/excel'; import type { Partner } from '../types'; -const LIST_PARAMS = { size: 100 }; +// 엑셀 중복검사 모달이 참조하는 "전체 협력사"용 메타 쿼리(최대 100건). +const META_PARAMS: ListSuppliersParams = { size: 100 }; -// 협력사 서버 데이터 + CRUD. orval 뮤테이션 호출 후 목록 쿼리 무효화(재조회). -// 실패 시 throw → 호출부에서 toast 처리. -export function usePartners() { +// 서버 SupplierData → UI Partner (그대로 + id 별칭만). +const toPartner = (sp: SupplierData): Partner => ({ ...sp, id: sp.supplier_id }); + +// 서버 공통응답(result.success=false)을 한글 사유로 변환. 정상이면 null. +function supplierError(res: ResSupplier): string | null { + const r = res.result; + if (!r || r.success !== false) return null; + if (r.desc === 'SUPPLIER_CODE_DUPLICATE') return '코드 중복 — 이미 등록된 협력사코드입니다(DB 검증).'; + return r.desc || '협력사 등록에 실패했습니다.'; +} + +// 협력사 서버 데이터 + CRUD. +// - params: 테이블용 서버 페이지네이션/검색/우선순위 (useServerList 가 만든다) +// orval 뮤테이션 호출 후 목록 쿼리 무효화(재조회). 실패 시 throw → 호출부에서 toast 처리. +export function usePartners(params: ListSuppliersParams) { const queryClient = useQueryClient(); - const suppliersQuery = useListSuppliers(LIST_PARAMS); - const refresh = () => - queryClient.invalidateQueries({ queryKey: getListSuppliersQueryKey(LIST_PARAMS) }); + // 테이블용(현재 페이지) — 페이지 이동 시 이전 데이터 유지(깜빡임 방지) + const suppliersQuery = useListSuppliers(params, { query: { placeholderData: keepPreviousData } }); + // 메타용(엑셀 모달이 참조하는 전체 목록) + const metaQuery = useListSuppliers(META_PARAMS); + + // /v1/supplier/list 로 시작하는 모든 쿼리(페이지·메타)를 prefix 매칭으로 재조회. + const refresh = () => queryClient.invalidateQueries({ queryKey: ['/v1/supplier/list'] }); const createPartner = async (data: ReqCreateSupplier) => { - await createSupplier(data); + const msg = supplierError(await createSupplier(data)); + if (msg) throw new Error(msg); await refresh(); }; const updatePartner = async (supplierId: string, data: ReqUpdateSupplier) => { @@ -33,18 +54,38 @@ export function usePartners() { await deleteSupplier(supplierId); await refresh(); }; - // 엑셀 일괄 등록 — 검증된 행들을 순차 생성 후 한 번만 재조회. - const bulkCreate = async (rows: ReqCreateSupplier[]) => { + // 엑셀 일괄 등록 — 행별로 순차 생성하되 실패해도 멈추지 않고 사유를 모은다. + // 서버 DB 검증(중복코드 등)에 걸린 행은 BulkFailure 로 반환 → 모달이 해당 행만 사유와 함께 남긴다. + const bulkCreate = async (rows: ReqCreateSupplier[]): Promise => { + const failures: BulkFailure[] = []; for (const row of rows) { - await createSupplier(row); + try { + const msg = supplierError(await createSupplier(row)); + if (msg) failures.push({ code: row.code ?? '', message: msg }); + } catch (err) { + failures.push({ code: row.code ?? '', message: err instanceof Error ? err.message : '등록 실패' }); + } } await refresh(); + return failures; }; - // 서버 SupplierData → UI Partner (그대로 + id 별칭만). - // customFetch 가 본문을 그대로 주므로 suppliersQuery.data 가 곧 ResSupplierList → .suppliers. - const suppliers = suppliersQuery.data?.suppliers ?? []; - const partners: Partner[] = suppliers.map((sp) => ({ ...sp, id: sp.supplier_id })); + // 테이블(현재 페이지) 협력사 + 서버 전체 건수. + const partners: Partner[] = (suppliersQuery.data?.suppliers ?? []).map(toPartner); + const total = suppliersQuery.data?.total ?? 0; - return { partners, createPartner, updatePartner, deletePartner, bulkCreate, refresh, suppliersQuery }; + // 전체(메타) 협력사 — 엑셀 중복검사 모달용(현재 페이지에 없을 수 있어 전체 기준). + const allPartners: Partner[] = (metaQuery.data?.suppliers ?? []).map(toPartner); + + return { + partners, + total, + allPartners, + createPartner, + updatePartner, + deletePartner, + bulkCreate, + refresh, + isLoading: suppliersQuery.isLoading, + }; } diff --git a/negodata/front/src/features/products/components/ExcelUploadModal.tsx b/negodata/front/src/features/products/components/ExcelUploadModal.tsx index 37a5e7d..de687cf 100644 --- a/negodata/front/src/features/products/components/ExcelUploadModal.tsx +++ b/negodata/front/src/features/products/components/ExcelUploadModal.tsx @@ -1,8 +1,9 @@ import { useMemo, useRef, useState } from 'react'; -import { Upload, X, FileSpreadsheet, CheckCircle2, Download } from 'lucide-react'; +import { Upload, X, FileSpreadsheet, CheckCircle2, Download, Trash2 } from 'lucide-react'; import type { ReqCreateItem as ItemCreate } from '@/api/generated/model/reqCreateItem'; import { showToast } from '@/lib/notify'; -import { downloadExcel, parseCsv } from '@/lib/excel'; +import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel'; +import { customFetch } from '@/api/mutator/custom-fetch'; import { Typography } from '@/components/ui/typography'; import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; @@ -14,25 +15,95 @@ type RawRow = { rowNum: number; name: string; code: string; + model_name: string; + category: string; + spec: string; + manufacturer: string; + made_in: string; price: number; minPrice: number; + image_url: string; + moq: string; + lead_time: number; + quantity_unit: string; + delivery_type: string; // 한글 라벨로 입력 → 전송 시 코드 변환 + vat_yn: string; // Y/N + delivery_fee_yn: string; // Y/N }; // 검증 결과가 붙은 행. UI는 이걸 그린다. type ValidatedRow = RawRow & { status: '정상' | '오류'; message: string }; -// 업로드 양식 한 줄(예시 행) -type TemplateRow = { name: string; code: string; price: number; minPrice: number }; +// 배송형태 라벨 ↔ delivery_type 코드(공용 enum 과 동일 집합) +const DELIVERY_LABEL_TO_CODE: Record = { + 협력사배송: 1, + 지정택배배송: 2, + 픽업배송: 3, +}; + +// 자유로운 Y/N 표기 → boolean (Y·예·true·O·포함·1 = true) +const parseYn = (s: string): boolean => /^(y|yes|true|1|예|o|포함)$/i.test(s.trim()); + +// Y/N 으로 인식 가능한 토큰(참/거짓 양쪽). 검증에서 '해석 가능한 값'인지 판단한다. +const isYnToken = (s: string): boolean => + /^(y|yes|true|1|예|o|포함|n|no|false|0|아니오|x|미포함)$/i.test(s.trim()); + +// 업로드 양식(.csv) 컬럼 정의 — 헤더 ↔ RawRow 필드. 양식/예시/파싱이 이 한 곳을 공유한다. +const UPLOAD_COLUMNS: { header: string; key: keyof RawRow }[] = [ + { header: '상품명', key: 'name' }, + { header: '상품코드', key: 'code' }, + { header: '모델번호', key: 'model_name' }, + { header: '카테고리', key: 'category' }, + { header: '규격', key: 'spec' }, + { header: '제조사', key: 'manufacturer' }, + { header: '원산지', key: 'made_in' }, + { header: '상품 단가', key: 'price' }, + { header: '최저한도', key: 'minPrice' }, + { header: '이미지URL', key: 'image_url' }, + { header: '최소주문수량', key: 'moq' }, + { header: '리드타임(일)', key: 'lead_time' }, + { header: '단위', key: 'quantity_unit' }, + { header: '배송형태', key: 'delivery_type' }, + { header: '부가세포함(Y/N)', key: 'vat_yn' }, + { header: '배송비포함(Y/N)', key: 'delivery_fee_yn' }, +]; + +// 숫자 입력 컬럼 / 필수 컬럼(헤더에 * 표기) +const NUMERIC_KEYS = new Set(['price', 'minPrice', 'lead_time']); +const REQUIRED_KEYS = new Set(['name', 'code', 'price']); + +// 양식에 채워 넣는 예시 행(시드 상품과 동일 셋). 다운로드 양식에 그대로 들어간다. +const EXAMPLE_ROWS: Record[] = [ + { + name: '리튬인산철 배터리 모듈', code: 'BAT-LFP-100', model_name: 'LFP-100A', + category: '에너지/배터리', spec: '3.2V 100Ah', manufacturer: '한성에너지', made_in: '대한민국', + price: 1250000, minPrice: 1037500, image_url: 'https://example.com/img/lfp-100a.jpg', + moq: '10 EA', lead_time: 14, quantity_unit: 'EA', delivery_type: '협력사배송', + vat_yn: 'Y', delivery_fee_yn: 'N', + }, + { + name: '산업용 6축 로봇암', code: 'ROB-6AX-22', model_name: 'RX-6A', + category: '자동화설비', spec: '가반하중 12kg', manufacturer: '오토메카', made_in: '일본', + price: 18900000, minPrice: 16065000, image_url: 'https://example.com/img/rx-6a.jpg', + moq: '1 EA', lead_time: 30, quantity_unit: 'EA', delivery_type: '지정택배배송', + vat_yn: 'Y', delivery_fee_yn: 'N', + }, +]; type ExcelUploadModalProps = { open: boolean; products: Product[]; // 코드 중복 검사용 - onConfirm: (rows: ItemCreate[]) => Promise; + onConfirm: (rows: ItemCreate[]) => Promise; onClose: () => void; }; // 행 검증 — 순수 함수. 우선순위 순으로 첫 위반 메시지를 매긴다. (상태에 저장하지 않고 렌더에서 파생) -function validateRows(rows: RawRow[], products: Product[]): ValidatedRow[] { +// serverErrors: 서버(DB) 검증에서 거부된 code→사유. 프론트 검증을 통과한 행만 마지막에 덧씌운다. +function validateRows( + rows: RawRow[], + products: Product[], + serverErrors: Record, +): ValidatedRow[] { return rows.map((row) => { const fail = (message: string): ValidatedRow => ({ ...row, status: '오류', message }); @@ -46,6 +117,23 @@ function validateRows(rows: RawRow[], products: Product[]): ValidatedRow[] { if (row.price <= 0) return fail('유효성 위반 - 상품 단가는 0보다 커야 합니다.'); if (row.minPrice > row.price) return fail('유효성 위반 - 최저 한도가 상품 단가보다 큽니다.'); + // 선택 필드 형식 검증(값이 있을 때만). 배송형태/부가세/배송비/이미지URL. + if (row.delivery_type.trim() && !(row.delivery_type.trim() in DELIVERY_LABEL_TO_CODE)) { + return fail('배송형태 - 협력사배송 / 지정택배배송 / 픽업배송 중 하나여야 합니다.'); + } + if (row.vat_yn.trim() && !isYnToken(row.vat_yn)) { + return fail('부가세포함 - Y 또는 N(예/아니오)으로 입력해 주십시오.'); + } + if (row.delivery_fee_yn.trim() && !isYnToken(row.delivery_fee_yn)) { + return fail('배송비포함 - Y 또는 N(예/아니오)으로 입력해 주십시오.'); + } + if (row.image_url.trim() && !/^https?:\/\//i.test(row.image_url.trim())) { + return fail('이미지URL - http:// 또는 https:// 로 시작하는 주소여야 합니다.'); + } + + // 프론트 검증 통과 후, 직전 전송에서 서버가 거부한 코드면 그 사유로 오류 처리. + if (serverErrors[row.code]) return fail(serverErrors[row.code]); + return { ...row, status: '정상', message: '등록 적격 - 정합성 검증 통과' }; }); } @@ -55,18 +143,19 @@ function toItemCreate(row: RawRow): ItemCreate { return { name: row.name, code: row.code, - category: '자동화설비', + model_name: row.model_name || undefined, + category: row.category || undefined, + spec: row.spec || undefined, + manufacturer: row.manufacturer || undefined, + made_in: row.made_in || undefined, price: row.price, - model_name: 'EXCEL-LOADED', - spec: '엑셀 일괄 업로드', - manufacturer: '벌크 대리 수급처', - made_in: '미지정', - quantity_unit: 'EA', - delivery_type: 1, // 협력사배송 (delivery_type 코드) - moq: '10 EA', - lead_time: 14, - vat_yn: true, - delivery_fee_yn: false, + image_url: row.image_url || undefined, + moq: row.moq || undefined, + lead_time: row.lead_time || undefined, + quantity_unit: row.quantity_unit || undefined, + delivery_type: DELIVERY_LABEL_TO_CODE[row.delivery_type.trim()] ?? undefined, + vat_yn: row.vat_yn.trim() ? parseYn(row.vat_yn) : undefined, + delivery_fee_yn: row.delivery_fee_yn.trim() ? parseYn(row.delivery_fee_yn) : undefined, internet_lowest_price_yn: false, }; } @@ -76,11 +165,15 @@ function toItemCreate(row: RawRow): ItemCreate { export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUploadModalProps) { const [excelFile, setExcelFile] = useState(null); const [rows, setRows] = useState([]); + const [serverErrors, setServerErrors] = useState>({}); // 서버(DB) 거부 code→사유 const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(null); // 파생: 검증 결과 + 카운트 (state로 저장하지 않음) - const validated = useMemo(() => validateRows(rows, products), [rows, products]); + const validated = useMemo( + () => validateRows(rows, products, serverErrors), + [rows, products, serverErrors], + ); const validRows = validated.filter((r) => r.status === '정상'); const validCount = validRows.length; const errorCount = validated.length - validCount; @@ -90,20 +183,16 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp const close = () => { setExcelFile(null); setRows([]); + setServerErrors({}); onClose(); }; - // 업로드 양식(.csv) 다운로드 — 채워 넣을 컬럼 헤더 + 예시 1행 (lib/excel 재사용) + // 업로드 양식(.csv) 다운로드 — 전체 컬럼 헤더 + 예시 행(시드 상품 셋). UPLOAD_COLUMNS 단일 정의 공유. const handleDownloadTemplate = () => { - downloadExcel( + downloadExcel>( '상품_업로드_양식', - [ - { header: '상품명', value: (r) => r.name }, - { header: '상품코드', value: (r) => r.code }, - { header: '상품 단가', value: (r) => r.price }, - { header: '최저한도', value: (r) => r.minPrice }, - ], - [{ name: '예시) 고용량 배터리 팩', code: 'PROD-EXAMPLE-001', price: 1000000, minPrice: 830000 }], + UPLOAD_COLUMNS.map((c) => ({ header: c.header, value: (r) => r[c.key] })), + EXAMPLE_ROWS, ); }; @@ -115,19 +204,48 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp rowNum: i + 2, name: r['상품명'] ?? '', code: r['상품코드'] ?? '', + model_name: r['모델번호'] ?? '', + category: r['카테고리'] ?? '', + spec: r['규격'] ?? '', + manufacturer: r['제조사'] ?? '', + made_in: r['원산지'] ?? '', price: Number(r['상품 단가']) || 0, minPrice: Number(r['최저한도']) || 0, + image_url: r['이미지URL'] ?? '', + moq: r['최소주문수량'] ?? '', + lead_time: Number(r['리드타임(일)']) || 0, + quantity_unit: r['단위'] ?? '', + delivery_type: r['배송형태'] ?? '', + vat_yn: r['부가세포함(Y/N)'] ?? '', + delivery_fee_yn: r['배송비포함(Y/N)'] ?? '', })); setExcelFile(file.name); setRows(loaded); + setServerErrors({}); // 새 파일 → 직전 서버사유 초기화 + // 업로드 즉시 DB 중복코드 사전검사 → 미리보기에서 바로 빨강 처리. + const codes = [...new Set(loaded.map((r) => r.code.trim()).filter(Boolean))]; + if (codes.length === 0) return; + try { + const res = await customFetch<{ existing?: string[] }>({ + url: '/v1/item/check-codes', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + data: { codes }, + }); + const dup: Record = {}; + (res.existing ?? []).forEach((c) => { dup[c] = '코드 중복 — 이미 등록된 상품코드입니다(DB).'; }); + setServerErrors(dup); + } catch { + // 사전검사 호출 실패는 조용히 무시 — 제출 시 서버가 최종 차단한다. + } }; - // 인라인 편집 — 원본 필드만 갱신(재검증은 파생이 처리) - const handleUpdateField = (id: string, field: 'name' | 'code' | 'price' | 'minPrice', value: string) => { + // 인라인 편집 — 원본 필드만 갱신(재검증은 파생이 처리). 숫자 컬럼은 정수화. + const handleUpdateField = (id: string, field: keyof RawRow, value: string) => { setRows((cur) => cur.map((row) => { if (row.id !== id) return row; - if (field === 'price' || field === 'minPrice') { + if (NUMERIC_KEYS.has(field)) { return { ...row, [field]: Math.max(0, parseInt(value, 10) || 0) }; } return { ...row, [field]: value }; @@ -135,15 +253,31 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp ); }; + // 행 삭제 — 정상/오류 무관하게 미리보기에서 제거(서버 전송 전 단계). + const handleRemoveRow = (id: string) => { + setRows((cur) => cur.filter((row) => row.id !== id)); + }; + const handleConfirm = async () => { if (validRows.length === 0) { showToast('정상으로 분류된 행이 존재하지 않아 업로드가 불가합니다.', 'error'); return; } try { - await onConfirm(validRows.map(toItemCreate)); - showToast(`총 ${validRows.length}개 상품이 서버에 일괄 등록되었습니다.`, 'success'); - close(); + const failures = await onConfirm(validRows.map(toItemCreate)); + const okCount = validRows.length - failures.length; + if (failures.length === 0) { + showToast(`총 ${okCount}개 상품이 서버에 일괄 등록되었습니다.`, 'success'); + close(); + return; + } + // 부분 성공: 등록 성공한 행만 제거하고, 서버(DB)가 거부한 행은 사유와 함께 남긴다. + const failMap: Record = {}; + failures.forEach((f) => { failMap[f.code] = f.message; }); + const okCodes = new Set(validRows.map((r) => r.code).filter((c) => failMap[c] === undefined)); + setServerErrors(failMap); + setRows((cur) => cur.filter((r) => !okCodes.has(r.code))); + showToast(`${okCount}건 등록 완료 · ${failures.length}건 서버 검증 실패(중복코드 등)`, 'error'); } catch (err) { showToast(err instanceof Error ? err.message : '엑셀 일괄 등록 실패', 'error'); } @@ -151,7 +285,7 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp return (
-
+
{/* Modal Title */}
@@ -237,57 +371,40 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
정합성 검출 미리보기 -
- +
+
- 행(Row) - 상품명 * - 상품코드 * - 상품 단가 (₩) * - 최저한도 (₩) * - 자격 - 진단 내용 (직접 수정가능) + 행 + 삭제 + 자격 + 진단 내용 + {UPLOAD_COLUMNS.map((c) => ( + + {c.header}{REQUIRED_KEYS.has(c.key) ? ' *' : ''} + + ))} {validated.map((row) => ( {row.rowNum} - - handleUpdateField(row.id, 'name', e.target.value)} - /> - - - handleUpdateField(row.id, 'code', e.target.value)} - /> - - - handleUpdateField(row.id, 'price', e.target.value)} - /> - - - handleUpdateField(row.id, 'minPrice', e.target.value)} - /> + + - - + {row.message} + {UPLOAD_COLUMNS.map((c) => { + const isNum = NUMERIC_KEYS.has(c.key); + return ( + + handleUpdateField(row.id, c.key, e.target.value)} + /> + + ); + })} ))} @@ -328,7 +459,7 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp onClick={handleConfirm} className="py-1.5 px-4 bg-primary text-primary-foreground font-bold rounded hover:opacity-95 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer text-xs" > - 적격 상품(총 {validCount}개) 최종 전송 + 적격 상품(총 {validCount}개) 등록 diff --git a/negodata/front/src/features/products/components/ProductFormSheet.tsx b/negodata/front/src/features/products/components/ProductFormSheet.tsx index 87f8f62..fec052a 100644 --- a/negodata/front/src/features/products/components/ProductFormSheet.tsx +++ b/negodata/front/src/features/products/components/ProductFormSheet.tsx @@ -10,13 +10,14 @@ import { Typography } from '@/components/ui/typography'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Sheet } from '@/components/ui/sheet'; -import { type Product, categoriesList, toMinPrice } from '../types'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { type Product, toMinPrice } from '../types'; // 폼 검증 스키마. 필수: 상품명/상품코드/단가/최저가. 나머지는 선택. const schema = z.object({ name: z.string().trim().min(1, '상품명을 작성하여 주십시오.'), code: z.string().trim().min(1, '상품코드를 입력해 주십시오.'), - category: z.string(), + category: z.string().trim().min(1, '분류 카테고리를 선택하거나 새로 입력해 주십시오.'), price: z.number({ message: '숫자를 입력해 주십시오.' }).min(0, '단가는 0 이상이어야 합니다.'), minPrice: z.number({ message: '숫자를 입력해 주십시오.' }).min(0, '최저가는 0 이상이어야 합니다.'), modelName: z.string(), @@ -39,6 +40,9 @@ type ProductFormSheetProps = { open: boolean; mode: 'create' | 'edit'; product: Product | null; // edit 모드일 때 초기값 출처 + categories: string[]; // 서버 items 에서 distinct 로 뽑은 카테고리 목록(선택지) + categoryTypeByName: Record; // 카테고리명 → category_type(id) 매핑 + nextCategoryType: number; // 신규 카테고리에 부여할 id(= 기존 max + 1) onCreate: (data: ItemCreate) => Promise; onUpdate: (itemId: string, data: ItemUpdate) => Promise; onDelete: (itemId: string, name: string) => void; @@ -51,7 +55,7 @@ function buildDefaults(mode: 'create' | 'edit', product: Product | null): FormVa return { name: product.name ?? '', code: product.code || '', - category: product.category || '에너지/배터리', + category: product.category || '', price: product.price || 0, minPrice: toMinPrice(product.price), modelName: product.model_name || '', @@ -71,7 +75,7 @@ function buildDefaults(mode: 'create' | 'edit', product: Product | null): FormVa return { name: '', code: `PROD-BAT-${Math.floor(100 + Math.random() * 900)}`, - category: '에너지/배터리', + category: '', price: 1000000, minPrice: 800000, modelName: '', @@ -98,6 +102,9 @@ export function ProductFormSheet({ open, mode, product, + categories, + categoryTypeByName, + nextCategoryType, onCreate, onUpdate, onDelete, @@ -119,10 +126,14 @@ export function ProductFormSheet({ // minPrice는 화면 전용(서버 미전송). 검증된 값만 payload로. const onValid = async (v: FormValues) => { + // 기존 카테고리면 그 category_type(id) 재사용, 처음 쓰는 카테고리면 max+1 부여. + const categoryName = v.category.trim(); + const category_type = categoryTypeByName[categoryName] ?? nextCategoryType; const common = { name: v.name, code: v.code, - category: v.category, + category: categoryName, + category_type, price: v.price, model_name: v.modelName, spec: v.specification, @@ -194,18 +205,22 @@ export function ProductFormSheet({ /> {errors.code &&

{errors.code.message}

} - {/* Category */} + {/* Category — 기존(서버 items distinct) 선택 또는 새 카테고리 직접 입력(datalist 콤보) */}
분류 카테고리 - + + {errors.category &&

{errors.category.message}

}
@@ -319,16 +334,18 @@ export function ProductFormSheet({ control={control} name="shippingType" render={({ field }) => ( - + )} /> {errors.shippingType &&

{errors.shippingType.message}

} diff --git a/negodata/front/src/features/products/hooks/useProductFilters.ts b/negodata/front/src/features/products/hooks/useProductFilters.ts deleted file mode 100644 index eb1c527..0000000 --- a/negodata/front/src/features/products/hooks/useProductFilters.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useState } from 'react'; -import type { Product } from '../types'; - -const ITEMS_PER_PAGE = 5; - -// 상품 목록에 대한 검색/카테고리/페이지네이션 UI state + 파생 결과. -// 검색·카테고리 변경 시 1페이지로 리셋한다. -export function useProductFilters(products: Product[]) { - const [search, setSearchRaw] = useState(''); - const [categoryFilter, setCategoryFilterRaw] = useState('ALL'); - const [page, setPage] = useState(1); - - const setSearch = (v: string) => { - setSearchRaw(v); - setPage(1); - }; - const setCategoryFilter = (v: string) => { - setCategoryFilterRaw(v); - setPage(1); - }; - - const filtered = products.filter((prod) => { - if (prod.deleted) return false; // soft-deleted 제외 - const q = search.toLowerCase(); - const matchesSearch = - (prod.name ?? '').toLowerCase().includes(q) || - (prod.code ? prod.code.toLowerCase().includes(q) : false); - const matchesCategory = categoryFilter === 'ALL' || prod.category === categoryFilter; - return matchesSearch && matchesCategory; - }); - - const totalPages = Math.ceil(filtered.length / ITEMS_PER_PAGE) || 1; - const paginated = filtered.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE); - - return { - search, - setSearch, - categoryFilter, - setCategoryFilter, - page, - setPage, - paginated, - totalPages, - totalCount: filtered.length, - itemsPerPage: ITEMS_PER_PAGE, - }; -} diff --git a/negodata/front/src/features/products/hooks/useProducts.ts b/negodata/front/src/features/products/hooks/useProducts.ts index a84f6ae..341db9a 100644 --- a/negodata/front/src/features/products/hooks/useProducts.ts +++ b/negodata/front/src/features/products/hooks/useProducts.ts @@ -1,28 +1,61 @@ -import { useQueryClient } from '@tanstack/react-query'; +import { keepPreviousData, useQueryClient } from '@tanstack/react-query'; import { useListItems, + useListItemCategories, createItem, updateItem, deleteItem, - getListItemsQueryKey, } from '@/api/generated/item/item'; +import type { ListItemsParams } from '@/api/generated/model/listItemsParams'; import type { ReqCreateItem } from '@/api/generated/model/reqCreateItem'; import type { ReqUpdateItem } from '@/api/generated/model/reqUpdateItem'; +import type { ResItem } from '@/api/generated/model/resItem'; +import type { ItemData } from '@/api/generated/model/itemData'; +import type { BulkFailure } from '@/lib/excel'; import { type Product, toMinPrice } from '../types'; -const LIST_PARAMS = { size: 100 }; +// 엑셀 중복검사 + 최저가 모달은 "현재 페이지 밖"의 상품도 코드/ID로 조회해야 해서 +// 전체 목록(최대 100건)을 따로 받는다. (카테고리 목록은 더 이상 여기서 만들지 않는다 — 아래 categoriesQuery.) +const META_PARAMS: ListItemsParams = { size: 100 }; -// 상품 서버 데이터 + CRUD. orval 뮤테이션 호출 후 목록 쿼리 무효화(재조회). -// 실패 시 throw → 호출부에서 toast 처리. -export function useProducts() { +// 서버 ItemData → UI Product (화면 전용 파생 필드 부여). +function toProduct(it: ItemData): Product { + return { ...it, id: it.item_id, minPrice: toMinPrice(it.price), status: 'ACTIVE' }; +} + +// 서버 공통응답(result.success=false)을 한글 사유로 변환. 정상이면 null. +// (HTTP 4xx 는 customFetch 가 throw, 앱레벨 거부는 200+result 로 오므로 여기서 본다.) +function itemError(res: ResItem): string | null { + const r = res.result; + if (!r || r.success !== false) return null; + if (r.desc === 'ITEM_CODE_DUPLICATE') return '코드 중복 — 이미 등록된 상품코드입니다(DB 검증).'; + return r.desc || '상품 등록에 실패했습니다.'; +} + +// 상품 서버 데이터 + CRUD. +// - params: 테이블용 서버 페이지네이션/검색/카테고리 (useServerList 가 만든다) +// - 페이지 이동 시 placeholderData 로 이전 데이터 유지(빈 화면 깜빡임 방지) +// orval 뮤테이션 호출 후 목록 쿼리 무효화(재조회). 실패 시 throw → 호출부에서 toast 처리. +export function useProducts(params: ListItemsParams) { const queryClient = useQueryClient(); - const itemsQuery = useListItems(LIST_PARAMS); + // 테이블용(현재 페이지) + const itemsQuery = useListItems(params, { query: { placeholderData: keepPreviousData } }); + // 모달(엑셀 중복검사·최저가)이 참조하는 전체 목록 + const metaQuery = useListItems(META_PARAMS); + // 카테고리 목록 — 백엔드가 전체 상품에서 distinct 로 돌려준다(프론트가 상품을 긁지 않는다). + const categoriesQuery = useListItemCategories(); + + // 변경 후 목록(/list·메타) + 카테고리(/categories) 둘 다 재조회. const refresh = () => - queryClient.invalidateQueries({ queryKey: getListItemsQueryKey(LIST_PARAMS) }); + Promise.all([ + queryClient.invalidateQueries({ queryKey: ['/v1/item/list'] }), + queryClient.invalidateQueries({ queryKey: ['/v1/item/categories'] }), + ]); const createProduct = async (data: ReqCreateItem) => { - await createItem(data); + const msg = itemError(await createItem(data)); + if (msg) throw new Error(msg); await refresh(); }; const updateProduct = async (itemId: string, data: ReqUpdateItem) => { @@ -33,23 +66,54 @@ export function useProducts() { await deleteItem(itemId); await refresh(); }; - // 엑셀 일괄 등록 — 검증된 행들을 순차 생성 후 한 번만 재조회. - const bulkCreate = async (rows: ReqCreateItem[]) => { + // 엑셀 일괄 등록 — 행별로 순차 생성하되 실패해도 멈추지 않고 사유를 모은다. + // 서버 DB 검증(중복코드 등)에 걸린 행은 BulkFailure 로 반환 → 모달이 해당 행만 사유와 함께 남긴다. + const bulkCreate = async (rows: ReqCreateItem[]): Promise => { + const failures: BulkFailure[] = []; for (const row of rows) { - await createItem(row); + try { + const msg = itemError(await createItem(row)); + if (msg) failures.push({ code: row.code ?? '', message: msg }); + } catch (err) { + failures.push({ code: row.code ?? '', message: err instanceof Error ? err.message : '등록 실패' }); + } } await refresh(); + return failures; }; - // 서버 ItemData → UI Product (화면 전용 파생 필드 부여). - // customFetch 가 본문을 그대로 주므로 itemsQuery.data 가 곧 ResItemList → .items. - const items = itemsQuery.data?.items ?? []; - const products: Product[] = items.map((it) => ({ - ...it, - id: it.item_id, - minPrice: toMinPrice(it.price), - status: 'ACTIVE', - })); + // 테이블(현재 페이지) 상품 + 서버 전체 건수. + const products: Product[] = (itemsQuery.data?.items ?? []).map(toProduct); + const total = itemsQuery.data?.total ?? 0; - return { products, createProduct, updateProduct, deleteProduct, bulkCreate, refresh, itemsQuery }; + // 전체(메타) 상품 — 엑셀 중복검사/최저가 모달이 코드·id 로 조회한다(현재 페이지에 없을 수 있어 전체 기준). + const allProducts: Product[] = (metaQuery.data?.items ?? []).map(toProduct); + + // 카테고리: 백엔드 distinct 결과를 그대로 쓴다(프론트가 상품을 긁어 만들지 않는다). + // category_type(카테고리 id)은 이름→코드로 보존하고, 신규 카테고리는 max+1 을 부여한다(폼에서 사용). + const categoryTypeByName: Record = {}; + let maxCategoryType = 0; + for (const c of categoriesQuery.data?.categories ?? []) { + const type = c.category_type ?? 1; + if (type > maxCategoryType) maxCategoryType = type; + const name = c.name.trim(); + if (name && !(name in categoryTypeByName)) categoryTypeByName[name] = type; + } + const categories = Object.keys(categoryTypeByName).sort((a, b) => a.localeCompare(b, 'ko')); + const nextCategoryType = maxCategoryType + 1; + + return { + products, + total, + allProducts, + categories, + categoryTypeByName, + nextCategoryType, + createProduct, + updateProduct, + deleteProduct, + bulkCreate, + refresh, + isLoading: itemsQuery.isLoading, + }; } diff --git a/negodata/front/src/features/products/types.ts b/negodata/front/src/features/products/types.ts index 9b5d9f3..59610e6 100644 --- a/negodata/front/src/features/products/types.ts +++ b/negodata/front/src/features/products/types.ts @@ -8,8 +8,7 @@ export type Product = ItemData & { status?: string; }; -// 분류 카테고리. 'ALL'은 필터 전용(폼에서는 제외). -export const categoriesList = ['ALL', '에너지/배터리', '자동화설비', '반도체소자/센서', '신소재', '광학/통신']; +// 분류 카테고리는 서버 items 에서 distinct 로 파생한다(useProducts). 하드코딩 상수 제거됨. // 인터넷 최저가 데모 산정(표준 단가의 83%). 서버 미연동 — 표시/초기값 용도. export const toMinPrice = (price?: number | null) => Math.round((price || 0) * 0.83); diff --git a/negodata/front/src/features/quotations/components/CreateQuotationWizard.tsx b/negodata/front/src/features/quotations/components/CreateQuotationWizard.tsx index 121adbc..3264857 100644 --- a/negodata/front/src/features/quotations/components/CreateQuotationWizard.tsx +++ b/negodata/front/src/features/quotations/components/CreateQuotationWizard.tsx @@ -3,6 +3,7 @@ import { X, PlusSquare, ArrowRight } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Typography } from '@/components/ui/typography'; import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types'; import type { CreateQuotationInput } from '../hooks/useQuotations'; @@ -102,15 +103,20 @@ export function CreateQuotationWizard({
유형 - + + + {(value) => (value === 'RE_ESTIMATE' ? '재견적' : '재협상')} + + + + 재협상 + 재견적 + +
@@ -127,19 +133,25 @@ export function CreateQuotationWizard({
상품 - +
)} @@ -185,18 +197,25 @@ export function CreateQuotationWizard({
적용할 견적 세팅 지정 - +
diff --git a/negodata/front/src/features/quotations/components/QuotationDetailDrawer.tsx b/negodata/front/src/features/quotations/components/QuotationDetailDrawer.tsx index 89e5f10..f1e642c 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailDrawer.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailDrawer.tsx @@ -6,22 +6,25 @@ import { MessageSquare, Layers, Info, + Sparkles, } from 'lucide-react'; -import SlateRenderer from '@/components/SlateRenderer'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; import { Typography } from '@/components/ui/typography'; import { Input } from '@/components/ui/input'; +import { + useGetQuotationSessions, + useGetSessionChat, + useGetQuotationCards, +} from '@/api/generated/quotation/quotation'; import { type Estimate, type Product, type Partner, type QuotationSetting, - type ChatSession, - type NegotiationCard, normalizeQuotationStatus, buildBidSummary, - buildSessions, - buildQuotationCards, + mapServerSessionView, + mapServerCardView, } from '../types'; type DrawerTab = 'status' | 'cards' | 'chat'; @@ -30,9 +33,7 @@ type QuotationDetailDrawerProps = { estimate: Estimate; products: Product[]; partners: Partner[]; - cards: NegotiationCard[]; quotationSettings: QuotationSetting[]; - sessions: ChatSession[]; onStop: (id: string, name: string) => void; onClose: () => void; }; @@ -41,25 +42,36 @@ export function QuotationDetailDrawer({ estimate, products, partners, - cards, quotationSettings, - sessions, onStop, onClose, }: QuotationDetailDrawerProps) { const [activeTab, setActiveTab] = useState('status'); - const [selectedChatPartnerId, setSelectedChatPartnerId] = useState( - sessions[0]?.id ?? null, - ); const [showHeaderCards, setShowHeaderCards] = useState(true); - const activeProduct = products.find((p) => p.id === estimate.productId) ?? null; - const currentChatSession = sessions.find((s) => s.id === selectedChatPartnerId) || sessions[0]; + const qtId = estimate.id ?? ''; + // 협상 세션·사용 카드는 견적 단위, 채팅은 선택 세션 단위로 서버에서 읽는다. + const sessionsQuery = useGetQuotationSessions(qtId, { query: { enabled: !!qtId } }); + const cardsQuery = useGetQuotationCards(qtId, { query: { enabled: !!qtId } }); + const serverSessions = sessionsQuery.data?.sessions ?? []; + const serverCards = cardsQuery.data?.cards ?? []; + + const [selectedSessionId, setSelectedSessionId] = useState(null); + const effectiveSessionId = selectedSessionId ?? serverSessions[0]?.session_id ?? null; + const chatQuery = useGetSessionChat(effectiveSessionId ?? '', { + query: { enabled: !!effectiveSessionId }, + }); + const chatMessages = chatQuery.data?.messages ?? []; + + const currentSession = serverSessions.find((s) => s.session_id === effectiveSessionId); + const currentSupplierName = + partners.find((p) => p.id === currentSession?.supplier_id)?.name || currentSession?.supplier_id || '-'; + const selectedSettingObj = quotationSettings.find((qs) => qs.qt_setting_id === estimate.settingApplied); const bidSummaryObj = buildBidSummary(estimate, partners); - const sessionViews = buildSessions(estimate, sessions, partners, products); - const quotationCardViews = buildQuotationCards(estimate, cards); + const sessionViews = serverSessions.map((sd) => mapServerSessionView(sd, partners, products)); + const quotationCardViews = serverCards.map(mapServerCardView); // Quotations DDL 표시값 const q_name = estimate.name || estimate.title || '미지정'; @@ -73,8 +85,8 @@ export function QuotationDetailDrawer({ const q_memo = estimate.memo || '안내사항 없음'; const statusKey = normalizeQuotationStatus(q_status); - const goToChat = (partnerId: string) => { - setSelectedChatPartnerId(partnerId); + const goToChat = (sessionId: string) => { + setSelectedSessionId(sessionId); setActiveTab('chat'); }; @@ -107,7 +119,7 @@ export function QuotationDetailDrawer({ > {showHeaderCards ? '데이터베이스 매핑 정보 접기 ▲' : '데이터베이스 매핑 정보 펼치기 ▼'} - {estimate.status === 'ACTIVE' && ( + {statusKey === '견적진행중' && ( ); @@ -473,32 +501,31 @@ export function QuotationDetailDrawer({
- 채널: {currentChatSession?.partnerName} + 채널: {currentSupplierName}
- 기록: {currentChatSession?.messages.length} 세션전화 + 기록: {chatMessages.length} 메시지
- {currentChatSession ? ( - currentChatSession.messages.map((msg) => { - const isBot = msg.sender === 'BOT'; - const isSystem = msg.sender === 'SYSTEM'; - - if (isSystem) { - return ( -
- - {msg.content} - -
- ); - } - + {!effectiveSessionId ? ( +
+ 선택된 대화 채널이 없습니다. +
+ ) : chatMessages.length === 0 ? ( +
+ 기록된 협상 대화가 없습니다. +
+ ) : ( + chatMessages.map((m) => { + const isBot = m.sender === 1; + const cardName = m.card_used_yn + ? serverCards.find((c) => c.session_card_id === m.chat_id)?.name + : null; return (
- {isBot ? 'Negosium AI Bot' : currentChatSession.partnerName} + {isBot ? 'Negosium AI Bot' : currentSupplierName} · - {msg.timestamp} + #{m.index}
- {msg.editorScript ? ( - - ) : ( - {msg.content} +
제시 단가 ₩{Number(m.target_price).toLocaleString()}
+ {cardName && ( +
+ 협상카드: {cardName} +
)}
); }) - ) : ( -
- 선택된 대화 채널 데이터가 기록되지 않았습니다. -
)}
diff --git a/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx b/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx index 25983ce..3fc9ece 100644 --- a/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx @@ -61,12 +61,19 @@ export function QuotationSettingsModal({ 목표 마진율 - 앵커링 설정 방식 + 앵커링 값 카드 사용 횟수 삭제 + {settings.length === 0 && ( + + + 등록된 견적 세팅이 없습니다. (리스트가 비어 있습니다) + + + )} {settings.map((qs) => ( {qs.target_margin} @@ -95,15 +102,15 @@ export function QuotationSettingsModal({
목표 마진율 (%) - setTargetMargin(e.target.value)} placeholder="예: 12%" /> + setTargetMargin(e.target.value)} placeholder="예: 12" />
- 앵커링 설정 방식 - setAnchoringValue(e.target.value)} placeholder="예: 최초 즉각 앵커링" /> + 앵커링 값 + setAnchoringValue(e.target.value)} placeholder="예: 0.01" />
카드 사용 횟수 - setCardUseCount(e.target.value)} placeholder="예: 3회 제한" /> + setCardUseCount(e.target.value)} placeholder="예: 3" />
diff --git a/negodata/front/src/features/quotations/components/QuotationTable.tsx b/negodata/front/src/features/quotations/components/QuotationTable.tsx index e291504..33dd085 100644 --- a/negodata/front/src/features/quotations/components/QuotationTable.tsx +++ b/negodata/front/src/features/quotations/components/QuotationTable.tsx @@ -118,7 +118,7 @@ export function QuotationTable({ data, products, onOpenDetail, onStop }: Quotati 상세 정보 - {est.status === 'ACTIVE' && ( + {normalizeQuotationStatus(est.status) === '견적진행중' && (