From df68609c49444ca85a8bbfadf9303ded9090fe73 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Thu, 18 Jun 2026 16:21:58 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20negodata/backend:=20=EA=B2=AC=EC=A0=81?= =?UTF-8?q?=20=ED=98=91=EC=83=81=EB=8C=80=ED=99=94=EC=97=90=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=ED=98=91=EC=83=81=EC=B9=B4=EB=93=9C=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=20=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QuotationCardData 에 number/edit_script/condition/memo 추가, card 카탈로그(nego/wild) 라이브 조인으로 채움. DB 컬럼 변경 없음(기존 카드 테이블 조인만 확장). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../backend/common/database/model/models.py | 4 +-- negodata/backend/crud/quotation_crud.py | 29 ++++++++++++++++++- negodata/backend/router/v1/card/protocol.py | 2 +- .../backend/router/v1/quotation/protocol.py | 7 ++++- .../backend/services/quotation_service.py | 24 +++++++++++++-- 5 files changed, 59 insertions(+), 7 deletions(-) diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index 1ee8139..b4fcbce 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -59,8 +59,8 @@ class users(MainTableMixin, MAIN_BASE): 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 + status = Column(SmallInteger, nullable=False, default=UserStatus.ACTIVE.value) + role = Column(SmallInteger, nullable=False, default=UserRole.USER.value) class items(MainTableMixin, MAIN_BASE): diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py index c213073..025dcf0 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -48,6 +48,10 @@ class IQuotationCRUD(ABC): async def list_used_cards(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]: pass + @abstractmethod + async def session_counts(self, cdb: AsyncSession, qt_ids) -> Tuple[ErrorType, dict]: + pass + class QuotationCRUD(IQuotationCRUD): async def search( @@ -88,6 +92,24 @@ class QuotationCRUD(IQuotationCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, [], 0 + async def session_counts(self, cdb: AsyncSession, qt_ids) -> Tuple[ErrorType, dict]: + """견적 id 목록에 대해 참여 협력사 수(distinct supplier)를 한 번에 센다. {qt_id: count}.""" + try: + if not qt_ids: + return ErrorType.SUCCESS, {} + query = ( + select(sessions.quotation_id, func.count(func.distinct(sessions.supplier_id))) + .where(sessions.quotation_id.in_(qt_ids), sessions.deleted == False) # noqa: E712 + .group_by(sessions.quotation_id) + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, {} + return ErrorType.SUCCESS, {r[0]: int(r[1] or 0) for r in rows} + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, {} + 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 @@ -159,16 +181,21 @@ class QuotationCRUD(IQuotationCRUD): async def list_used_cards(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]: """견적의 세션들에서 실제 사용된 카드(chats.card_used_yn)를 카드 카탈로그와 조인. - 반환: [(chat_row, card_id, name, script), ...]. + 반환: [(chat_row, card_id, number, name, script, edit_script, condition, memo), ...]. card_type 1=nego_cards / 2=wild_cards 양쪽을 LEFT JOIN 해서 어느 쪽이든 잡는다. + condition/memo 는 wild_cards 에만 있는 컬럼이라 nego 카드면 NULL 로 나온다. """ try: query = ( select( chats, func.coalesce(nego_cards.nego_card_id, wild_cards.wild_card_id).label("card_pk"), + func.coalesce(nego_cards.number, wild_cards.number).label("card_number"), func.coalesce(nego_cards.name, wild_cards.name).label("card_name"), func.coalesce(nego_cards.script, wild_cards.script).label("card_script"), + func.coalesce(nego_cards.edit_script, wild_cards.edit_script).label("card_edit_script"), + wild_cards.condition.label("card_condition"), + wild_cards.memo.label("card_memo"), ) .join(sessions, sessions.session_id == chats.session_id) .outerjoin(nego_cards, and_(nego_cards.nego_card_id == chats.card_id, chats.card_type == 1)) diff --git a/negodata/backend/router/v1/card/protocol.py b/negodata/backend/router/v1/card/protocol.py index c6c1524..c8bec4b 100644 --- a/negodata/backend/router/v1/card/protocol.py +++ b/negodata/backend/router/v1/card/protocol.py @@ -33,7 +33,7 @@ class Req_UpdateCard(CardProtocol): memo: Optional[str] = None -# 통합 카드 표현(nego_cards + wild_cards 공통). nego_card_id 는 출처 테이블의 PK 를 그대로 담는다. +# 통합 카드 표현(nego_cards + wild_cards 공통). class CardData(WebPacketProtocol): model_config = ConfigDict(from_attributes=True) diff --git a/negodata/backend/router/v1/quotation/protocol.py b/negodata/backend/router/v1/quotation/protocol.py index fe55a0c..1bb3812 100644 --- a/negodata/backend/router/v1/quotation/protocol.py +++ b/negodata/backend/router/v1/quotation/protocol.py @@ -51,6 +51,7 @@ class QuotationData(WebPacketProtocol): preferred_sp_name: Optional[str] = None equal_bid_yn: Optional[bool] = None equal_bid_data: Optional[Any] = None + participation_count: int = 0 # 견적별 참여 협력사 수(세션 distinct supplier). 목록 집계로 채움. created_at: Optional[datetime] = None updated_at: Optional[datetime] = None @@ -143,8 +144,12 @@ class QuotationCardData(WebPacketProtocol): nego_card_id: Optional[uuid.UUID] = None wild_card_id: Optional[uuid.UUID] = None type: Optional[int] = None + number: Optional[str] = None name: Optional[str] = None - script: Optional[str] = None + script: Optional[str] = None # 협상 멘트(평문) + edit_script: Optional[Any] = None # 협상 멘트(Slate 서식본) + condition: Optional[str] = None # 와일드카드 전용: 사용 조건(트리거) + memo: Optional[str] = None # 와일드카드 전용: 메모 class Res_QuotationCards(Res_WebPacketProtocol): diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index dee18d4..86b92db 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -57,6 +57,21 @@ class QuotationService: if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res + + # 참여 협력사 수(세션 distinct supplier)를 이 페이지 견적들에 대해 한 방으로 세서 합친다(메인 쿼리 비건드림). + qt_ids = [r.qt_id for r in rows] + counts = {} + if qt_ids: + cnt_err, got = await DB_SESSION_MNG.execute_lambda( + quotations.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.session_counts(s, qt_ids), + ) + if cnt_err == ErrorType.SUCCESS: + counts = got + for r in rows: + r.participation_count = counts.get(r.qt_id, 0) + res.quotations = [QuotationData.model_validate(r) for r in rows] res.total = total return res @@ -247,9 +262,10 @@ class QuotationService: return res res.qt_id = quotation.qt_id - # rows = [(chat_row, nego_card_id, name, script), ...]. nego/wild 구분은 chats.card_type. + # rows = [(chat_row, card_id, number, name, script, edit_script, condition, memo), ...]. + # nego/wild 구분은 chats.card_type. condition/memo 는 와일드카드에만 존재. cards = [] - for chat_row, nc_id, nc_name, nc_script in rows: + for chat_row, nc_id, nc_number, nc_name, nc_script, nc_edit, wc_condition, wc_memo in rows: is_wild = chat_row.card_type == 2 cards.append( QuotationCardData( @@ -258,8 +274,12 @@ class QuotationService: 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, + number=nc_number, name=nc_name, script=nc_script, + edit_script=nc_edit, + condition=wc_condition if is_wild else None, + memo=wc_memo if is_wild else None, ) ) res.cards = cards