[feat] negodata/backend: 견적 협상대화에 사용 협상카드 내용 노출

QuotationCardData 에 number/edit_script/condition/memo 추가, card 카탈로그(nego/wild)
라이브 조인으로 채움. DB 컬럼 변경 없음(기존 카드 테이블 조인만 확장).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-06-18 16:21:58 +09:00
parent 3280cab088
commit df68609c49
5 changed files with 59 additions and 7 deletions

View File

@ -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):

View File

@ -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))

View File

@ -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)

View File

@ -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):

View File

@ -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