diff --git a/agent/docs/q-table 상세 설명.pdf b/agent/docs/q-table 상세 설명.pdf new file mode 100644 index 0000000..be0fb08 Binary files /dev/null and b/agent/docs/q-table 상세 설명.pdf differ diff --git a/agent/docs/동적_협상_알고리즘_설계서.pdf b/agent/docs/동적_협상_알고리즘_설계서.pdf new file mode 100644 index 0000000..b50da48 Binary files /dev/null and b/agent/docs/동적_협상_알고리즘_설계서.pdf differ diff --git a/agent/docs/조견표(Test-Set).csv b/agent/docs/조견표(Test-Set).csv new file mode 100644 index 0000000..a7e4b85 --- /dev/null +++ b/agent/docs/조견표(Test-Set).csv @@ -0,0 +1,30 @@ +ID,??MD,?????,?????,??? ???,????,???,???,????,????,???,???,1???,1? ?? ???,1? ????,????,2???,2? ?? ???,2? ????,????,3???,3? ?? ???,3? ????,????,4???,,,,, +1,,???,,,AT2025042400050,?????,"163,600",??,None (??),"8,180","8,099",,,,,,,,,,,,,,,,,, +2,,???,,,AT2025042200057,????,"350,000",??,Single (??),"35,000","33,600",35000,0,?? ?? ?? ??,o,34900,0.00286533,??? ?? ?? ??,o,33700,0.038575668,??? ?? ?? ??,x,33600,,,,, +3,,???,,,AT2025042300003,TV???,"6,790,000",??,Multiple (??),"48,500","46,560",50000,0,?? ?? ?? ??,x,46560,,?? ??,,,,,,,,,,, +4,,???,,,AT2025042200041,?????,"18,700,000",??,Multiple (??),"1,804,550","1,678,232",2200000,0,?? ?? ?? ??,x,2000000,0.1,?? ?? ?? ??,,1700000,0.294117647,??? ?? ?? ??,,1700000,,,,, +5,,???,,,AT2025041600034,???,"760,320",??,Multiple (??),"73,304","70,372",70000,,?? ??,,,,,,,,,,,,,,, +6,,???,,,AT2025042400010,????? ??,"780,000",??,Multiple (??),"75,642"," 72,617 ",,,,,,,,,,,,,,,,,, +7,,???,,,AT2025042400030,???,"313,000",??,Multiple (??),"31,300","29,735",50000,0,?? ?? ?? ??,,40000,0.25,?? ?? ?? ??,,30500,0.639344262,??? ?? ?? ??,,30500,,,,, +8,,???,,,AT2025042300096,?????,"706,800",??,None (??),"68,162","65,436",115800,0,?? ?? ?? ??,,70000,0.654285714,?? ?? ?? ??,,69000,0.67826087,??? ?? ?? ??,,65400,,,,, +9,,???,,,AT2025042300082,??????,"12,000",??,Multiple (??),"1,200","1,188",,,,,,,,,,,,,,,,,, +2.1,,???,ID 2 ???? ??(??>??),,AT2025042200057,????,"350,000",??,Single (??),"35,000"," 33,250 ",,,,,,,,,,,,,,,,,, +6.1,,???,ID 6 ???? ??(??>??),,AT2025042400010,????? ??,780000,??,Multiple (??),"75,642"," 71,860 ",,,,,,,,,,,,,,,,,, +,,,,,,,,,,,,,,,,,,,,,,,,,,,,, +,,,,,,,,,,,,,,,,,,,,,,,,,,,,, +,,,,,,,,,,,,,,,,,,,,,,,,,,,,, +,,,,,,,,,,,,,,,,,,,,,,,,,,,,, +,,,,1,AT2025042400050,SOOT777,?????,,,,,,,,,,,,,,,,,,,,,, +,,,,2,AT2025042200057,JAJE89,????,,,,,,,,,,,,,,,,,,,,,, +,,,,3,AT2025042300003,VOV2,TV???,,,,,,,,,,,,,,,,,,,,,, +,,,,4,AT2025042200041,HEMEELY,?????,,,,,,,,,,,,,,,,,,,,,, +,,,,5,AT2025041600034,KMS5939,???,,,,,,,,,,,,,,,,,,,,,, +,,,,6,AT2025042400010,YTS111,????? ??,,,,,,,,,,,,,,,,,,,,,, +,,,,7,AT2025042400030,SJUN98,???,,,,,,,,,,,,,,,,,,,,,, +,,,,8,AT2025042300096,DREAMBIZ,?????,,,,,,,,,,,,,,,,,,,,,, +,,,,9,AT2025042300082,BWTCO40,??????,,,,,,,,,,,,,,,,,,,,,, +,,,,2.1,AT2025042200057,JAJE89,????,,,,,,,,,,,,,,,,,,,,,, +,,,,6.1,AT2025042400010,YTS111,????? ??,,,,,,,,,,,,,,,,,,,,,, +,,,,,,,,,,,,,,,,,,,,,,,,,,,,, +,,,,,,,,,,,,,,,,,,,,,,,,,,,,, +,,,,,,,,,,,,,,,,,,,,,,,,,,,,, diff --git a/agent/negotiation/chat/infra/repository/nego_context_crud.py b/agent/negotiation/chat/infra/repository/nego_context_crud.py index 24e5d81..9605ef3 100644 --- a/agent/negotiation/chat/infra/repository/nego_context_crud.py +++ b/agent/negotiation/chat/infra/repository/nego_context_crud.py @@ -28,7 +28,31 @@ _SESSIONS = table( ) _ITEMS = table("items", column("item_id"), column("price"), column("deleted"), schema="partner") _SUPPLIERS = table("suppliers", column("supplier_id"), column("total_revenue"), column("deleted"), schema="partner") -_QUOTATIONS = table("quotations", column("qt_id"), column("supplier_type"), column("deleted"), schema="quotation") +_QUOTATIONS = table( + "quotations", + column("qt_id"), column("version_id"), column("supplier_type"), column("deleted"), + schema="quotation", +) +_VERSION_NEGO_CARDS = table( + "version_nego_cards", + column("version_id"), column("nego_card_id"), column("created_at"), column("deleted"), + schema="card", +) +_VERSION_WILD_CARDS = table( + "version_wild_cards", + column("version_id"), column("wild_card_id"), column("created_at"), column("deleted"), + schema="card", +) +_NEGO_CARDS = table( + "nego_cards", + column("nego_card_id"), column("number"), column("deleted"), + schema="card", +) +_WILD_CARDS = table( + "wild_cards", + column("wild_card_id"), column("number"), column("deleted"), + schema="card", +) # 상품↔협력사 매핑 (2026-07-07 신설): supply_type = 이 협력사가 이 상품을 공급하는 방식(SupplierType). _SUPPLIER_ITEMS = table( "supplier_items", @@ -59,11 +83,6 @@ class INegoContextCRUD(ABC): 매핑이 없으면 None.""" pass - @abstractmethod - async def get_quotation_supplier_type(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, Optional[int]]: - """견적의 협력사 유형(quotations.supplier_type — supplier_items 매핑 부재 시 폴백). 미지정 시 None.""" - pass - @abstractmethod async def count_item_suppliers(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]: """상품에 연결된 협력사 수 — supplier_items 매핑 기준 distinct supplier.""" @@ -74,6 +93,11 @@ class INegoContextCRUD(ABC): """상품에 연결된 협력사 수 — 협상 세션 이력 기준(supplier_items 매핑 부재 시 폴백).""" pass + @abstractmethod + async def get_quotation_card_numbers(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, tuple[list[str], list[str]]]: + """견적 version_id 에 연결된 (일반카드 번호 목록, 와일드카드 번호 목록). 없으면 빈 목록.""" + pass + class NegoContextCRUD(INegoContextCRUD): async def get_session_row(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[tuple]]: @@ -139,21 +163,6 @@ class NegoContextCRUD(INegoContextCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, None - async def get_quotation_supplier_type(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, Optional[int]]: - try: - query = ( - select(_QUOTATIONS.c.supplier_type) - .where(_QUOTATIONS.c.qt_id == quotation_id, _QUOTATIONS.c.deleted == False) # noqa: E712 - .limit(1) - ) - err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_quotation_supplier_type failed.", raise_error=False) - if err_type != ErrorType.SUCCESS or not rows or rows[0] is None: - return err_type, None - return ErrorType.SUCCESS, int(rows[0]) - except Exception as ex: - LOG.e_no_callstack(ex) - return ErrorType.DB_RUN_FAILED, None - async def count_item_suppliers(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]: try: query = ( @@ -181,3 +190,63 @@ class NegoContextCRUD(INegoContextCRUD): except Exception as ex: LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, 0 + + async def get_quotation_card_numbers(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, tuple[list[str], list[str]]]: + try: + version_q = ( + select(_QUOTATIONS.c.version_id) + .where(_QUOTATIONS.c.qt_id == quotation_id, _QUOTATIONS.c.deleted == False) # noqa: E712 + .limit(1) + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, version_q, "get_quotation_version failed.", raise_error=False) + if err_type != ErrorType.SUCCESS: + return err_type, ([], []) + if not rows or rows[0] is None: + return ErrorType.SUCCESS, ([], []) + version_id = rows[0] + + nego_q = ( + select(_NEGO_CARDS.c.number) + .select_from( + _VERSION_NEGO_CARDS.join( + _NEGO_CARDS, + _VERSION_NEGO_CARDS.c.nego_card_id == _NEGO_CARDS.c.nego_card_id, + ) + ) + .where( + _VERSION_NEGO_CARDS.c.version_id == version_id, + _VERSION_NEGO_CARDS.c.deleted == False, # noqa: E712 + _NEGO_CARDS.c.deleted == False, # noqa: E712 + ) + .order_by(_VERSION_NEGO_CARDS.c.created_at) + ) + n_err, n_rows = await DB_SESSION_MNG.execute(cdb, nego_q, "get_quotation_nego_cards failed.", raise_error=False) + if n_err != ErrorType.SUCCESS: + return n_err, ([], []) + + wild_q = ( + select(_WILD_CARDS.c.number) + .select_from( + _VERSION_WILD_CARDS.join( + _WILD_CARDS, + _VERSION_WILD_CARDS.c.wild_card_id == _WILD_CARDS.c.wild_card_id, + ) + ) + .where( + _VERSION_WILD_CARDS.c.version_id == version_id, + _VERSION_WILD_CARDS.c.deleted == False, # noqa: E712 + _WILD_CARDS.c.deleted == False, # noqa: E712 + ) + .order_by(_VERSION_WILD_CARDS.c.created_at) + ) + w_err, w_rows = await DB_SESSION_MNG.execute(cdb, wild_q, "get_quotation_wild_cards failed.", raise_error=False) + if w_err != ErrorType.SUCCESS: + return w_err, ([], []) + + return ErrorType.SUCCESS, ( + [str(r) for r in n_rows if r is not None], + [str(r) for r in w_rows if r is not None], + ) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, ([], []) diff --git a/agent/negotiation/chat/service/chat_engine.py b/agent/negotiation/chat/service/chat_engine.py index af362d3..77e1790 100644 --- a/agent/negotiation/chat/service/chat_engine.py +++ b/agent/negotiation/chat/service/chat_engine.py @@ -146,7 +146,15 @@ class ChatEngine: cond = c.get("condition") ok = False if cond == "check_wildcard_entry": - ok = (not ctx.get("wildcard_used")) and anchor < price <= anchor * 1.05 + ok = ( + (not ctx.get("wildcard_used")) + and anchor > 0 + and anchor < price + and ( + price <= anchor * 1.02 + or (bool(ctx.get("allow_selected_wildcards", True)) and price <= anchor * 1.05) + ) + ) elif cond == "check_is_supplier_type_c": ok = False # 공급사 유형 미보유 (PoC 단순화) elif cond == "check_price_match": # = 우선협상: 제시가가 앵커가 이하 @@ -176,6 +184,8 @@ class ChatEngine: if anchor > 0 and price <= anchor * 1.02: ctx["offer_1pct"] = int(round(price * 0.99)) # 1% 인하가 return "wild_card_1pct" + if not ctx.get("allow_selected_wildcards", True): + return "가격협상" return "wild_card_budget" # ---- render -------------------------------------------------------- diff --git a/agent/negotiation/chat/service/negotiation_context_loader.py b/agent/negotiation/chat/service/negotiation_context_loader.py index 0bc7bb4..afbdb90 100644 --- a/agent/negotiation/chat/service/negotiation_context_loader.py +++ b/agent/negotiation/chat/service/negotiation_context_loader.py @@ -27,8 +27,7 @@ _ONE_TO_ONE_QT_TYPES = (1, 3) # 유통 코드: SupplierType(1=distribution 유통, 2=manufacture 제조, 3=sole_agency 총판) # → 테넌트 code_map 키(A/B/C). 제조→A, 총판→B, 유통→C (0=none/NULL 은 미지정 → 호출부 기본값). -# 소스 우선순위: partner.supplier_items.supply_type(이 협력사×이 상품 매핑, 2026-07-07 신설) -# → quotations.supplier_type(재견적 1:1 견적 기록 — 매핑 부재 시 폴백). +# 소스: partner.supplier_items.supply_type(이 협력사×이 상품 매핑). _SUPPLIER_TYPE_TO_CODE = {2: "A", 3: "B", 1: "C"} @@ -42,7 +41,9 @@ class NegotiationDbContext: item_price: int # 기존 공급가(품목 기준가, items.price) — 인하율 멘트용. 없으면 0 partner_type: PartnerType # 상품에 연결된 협력사 수(supplier_items 매핑, 없으면 세션 이력) → NONE/SINGLE/MULTIPLE revenue_amount: float # 매출액(원) — suppliers.total_revenue(KTC 미러). 없으면 0 - distribution_code: Optional[str] # 유통 코드(A/B/C) — supplier_items.supply_type → quotations.supplier_type. 미지정 시 None + distribution_code: Optional[str] # 유통 코드(A/B/C) — supplier_items.supply_type. 미지정 시 None + selected_nego_card_numbers: list[str] # 견적 생성 시 선택된 일반 협상카드 번호(card.nego_cards.number) + selected_wild_card_numbers: list[str] # 견적 생성 시 선택된 와일드카드 번호(card.wild_cards.number) class NegotiationContextLoader: @@ -76,11 +77,9 @@ class NegotiationContextLoader: # 매출액: 협력사 총매출(KTC total_revenue 미러). 미기재 시 0 → 호출부 기본값. _, revenue_amount = await self.crud.get_supplier_total_revenue(s, supplier_id) - # 유통 코드: 이 협력사×이 상품의 공급 방식(supplier_items.supply_type) 우선. - # 매핑이 없으면 견적 기록(quotations.supplier_type) 폴백. 미지정 시 None → 호출부 기본값. + # 유통 코드: 이 협력사×이 상품의 공급 방식(supplier_items.supply_type). + # 매핑이 없거나 미지정이면 None → 호출부 기본값. _, supplier_type = await self.crud.get_supply_type(s, supplier_id, item_id) - if not supplier_type: - _, supplier_type = await self.crud.get_quotation_supplier_type(s, quotation_id) # 기존 공급가(품목 기준가) — 없으면 0(인하율 멘트 미표시). _, item_price = await self.crud.get_item_price(s, item_id) @@ -93,6 +92,11 @@ class NegotiationContextLoader: if err != ErrorType.SUCCESS: supplier_count = 1 + # 견적 생성 모달에서 고른 카드셋. 값이 없으면 운영 DB 기준으로 "선택 카드 없음"이다. + # 데모/직접호출 경로(DB context 없음)만 ChatService 에서 기존 기본 카드셋으로 폴백한다. + _, selected_cards = await self.crud.get_quotation_card_numbers(s, quotation_id) + selected_nego_cards, selected_wild_cards = selected_cards + return NegotiationDbContext( rq_type="재협상" if int(qt_type) in _ONE_TO_ONE_QT_TYPES else "재견적", target_price=target, @@ -101,6 +105,8 @@ class NegotiationContextLoader: partner_type=PartnerType.from_count(supplier_count), revenue_amount=revenue_amount, distribution_code=_SUPPLIER_TYPE_TO_CODE.get(supplier_type) if supplier_type else None, + selected_nego_card_numbers=selected_nego_cards, + selected_wild_card_numbers=selected_wild_cards, ) try: diff --git a/agent/negotiation/chat/service/script_repository.py b/agent/negotiation/chat/service/script_repository.py index 61a1d5d..c5c78f0 100644 --- a/agent/negotiation/chat/service/script_repository.py +++ b/agent/negotiation/chat/service/script_repository.py @@ -90,14 +90,15 @@ class ScriptRepository: return self.format_script(text, variables) async def resolve_card_script(self, action_id: int, card_id: Optional[str], - variables: Optional[Dict[str, Any]] = None) -> Optional[str]: + variables: Optional[Dict[str, Any]] = None, + prefer_db: bool = False) -> Optional[str]: """카드 멘트 해석. cards.source_type == 'backoffice_db' 면 card.nego_cards.script(DB)를 우선 조회하고, 없거나 file 모드면 scripts_cards.json(파일) 폴백. 변수 치환 후 반환. DB 멘트는 백오피스(negodata)가 편집한 정본이라 파일보다 우선한다. 마커(**굵게** 등)가 섞여 있어도 agent 는 불투명 텍스트로 취급 — 표현 렌더는 프론트 소유. """ - if self._config.cards.source_type == _CARD_SOURCE_DB and card_id: + if (prefer_db or self._config.cards.source_type == _CARD_SOURCE_DB) and card_id: db_text = await self._fetch_card_script_db(card_id) if db_text: return self.format_script(db_text, variables) diff --git a/agent/router/v1/chat/protocol.py b/agent/router/v1/chat/protocol.py index 64e4187..166c313 100644 --- a/agent/router/v1/chat/protocol.py +++ b/agent/router/v1/chat/protocol.py @@ -13,7 +13,7 @@ class Req_Chat(Req_WebPacketProtocol): 협상 컨텍스트(rq_type/목표가/앵커링가/품목가/매출액/유통코드/파트너 유형)는 요청에 싣지 않는다 — 세션 시작 시 agent 가 DB 에서 1회 조회해 확정한다(NegotiationContextLoader): negotiation.sessions(qt_type·target_price·anchoring_price), partner.items(price), - partner.suppliers(total_revenue), quotation.quotations(supplier_type), 상품별 협력사 수. + partner.suppliers(total_revenue), partner.supplier_items(supply_type), 상품별 협력사 수. 행이 없으면(데모/테스트 직접 호출) 기본값 폴백. 가격 수용률은 세션 내 라운드별 제시가로 매 턴 동적 계산: max(0, (기존 공급가−현재가)/기존 공급가) — 첫 제시가부터 기존 공급가 대비 인하가 반영되므로 첫 라운드도 실값. 기존 공급가 없으면 첫 제시가 기준. diff --git a/agent/services/chat_service.py b/agent/services/chat_service.py index 88df879..a25bc29 100644 --- a/agent/services/chat_service.py +++ b/agent/services/chat_service.py @@ -31,7 +31,7 @@ _DEFAULT_RQ_TYPE = "재협상" _DEFAULT_TARGET_PRICE = 10000 # KT 목표 매입가 _DEFAULT_ANCHOR_PRICE = 9900 # 앵커링가(목표가보다 낮음). 제시가 ≤ anchor → 우선협상 _DEFAULT_REVENUE_AMOUNT = 20_000_000 # 매출액(원) — suppliers.total_revenue 미기재 시 폴백 -_DEFAULT_DISTRIBUTION_CODE = "A" # 유통 코드 — quotations.supplier_type 미지정 시 폴백 +_DEFAULT_DISTRIBUTION_CODE = "A" # 유통 코드 — supplier_items.supply_type 미지정 시 폴백 class ChatService: @@ -63,13 +63,22 @@ class ChatService: ) if session is None: + selected_nego_cards = db_ctx.selected_nego_card_numbers if db_ctx else [] + selected_wild_cards = db_ctx.selected_wild_card_numbers if db_ctx else [] + # 운영 DB 세션은 견적 version_id 에 묶인 카드만 사용한다. 직접 호출/데모(DB context 없음)는 + # 기존 테넌트 기본 action mapping 으로 폴백해 로컬 테스트와 콘솔 데모를 유지한다. + action_space_size = ( + min(len(selected_nego_cards), engine.action_space_size) + if db_ctx is not None + else engine.action_space_size + ) # session_id honoring: backend 가 보낸 session_id(= negotiation.sessions.session_id)를 # 새 uuid 발급 없이 그대로 세션 키로 쓴다. 없으면(직접 호출/데모) 생성. session = ChatSession( session_id=req.session_id or str(uuid.uuid4()), tenant_id=engine.tenant_id, company_id=engine.company_id, - rq_type=rq_type, action_space_size=engine.action_space_size, + rq_type=rq_type, action_space_size=action_space_size, context={ - # 매출액 = suppliers.total_revenue, 유통코드 = quotations.supplier_type 매핑 (loader). + # 매출액 = suppliers.total_revenue, 유통코드 = supplier_items.supply_type 매핑 (loader). # 미기재/미지정이면 기본값 폴백. "revenue_amount": db_ctx.revenue_amount if db_ctx and db_ctx.revenue_amount > 0 else _DEFAULT_REVENUE_AMOUNT, "distribution_code": db_ctx.distribution_code if db_ctx and db_ctx.distribution_code else _DEFAULT_DISTRIBUTION_CODE, @@ -83,6 +92,13 @@ class ChatService: "round": 0, # 기존 공급가(품목 기준가) — 가격협상_확인 인하율 산출용. "item_price": db_ctx.item_price if db_ctx else 0, + # 견적 생성 시 선택한 카드. 일반카드는 action_id 0..N-1 에 그대로 매핑한다. + # 1% 인하는 기본 와일드카드로 항상 열고, 재원부족 등 선택형 와일드카드는 + # 선택된 와일드카드가 있을 때만 허용한다. + "db_context_loaded": db_ctx is not None, + "selected_nego_card_numbers": selected_nego_cards, + "selected_wild_card_numbers": selected_wild_cards, + "allow_selected_wildcards": True if db_ctx is None else bool(selected_wild_cards), }, ) view = chat_engine.start(session) @@ -105,7 +121,7 @@ class ChatService: res.settled_price = int(session.context["input_price"]) # 3) 학습 결합 (가격협상 카드선택 → 카드 스크립트·협상지표 / 종료 보상) - if view.error is None and engine.action_space_size > 0: + if view.error is None and session.action_space_size > 0: if view.needs_card_selection: await self._select_and_learn(engine, chat_engine, repo, session, res) elif view.outcome is not None: @@ -172,11 +188,11 @@ class ChatService: LOG.e_no_callstack(f"[ChatService] state error: {ex}") return policy, version_id, repo = await QTablePolicyStore.load(engine) - ctx = PolicyContext(state_index=idx, snapshot=snap, action_space_size=engine.action_space_size, + ctx = PolicyContext(state_index=idx, snapshot=snap, action_space_size=session.action_space_size, episode=EpisodeState(used_action_ids=set(session.used_action_ids))) decision = policy.select(ctx) session.used_action_ids.add(decision.action_id) - card_id = engine.mapper.get_card_id(decision.action_id) + card_id = self._card_id_for_action(engine, session, decision.action_id) reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap) policy.update(Transition(state_index=idx, action_id=decision.action_id, reward=reward.total, done=False)) await QTablePolicyStore.persist_cell(repo, version_id, policy, idx, decision.action_id) @@ -195,7 +211,12 @@ class ChatService: # ① 선택된 카드의 스크립트를 봇 메시지(script)로 출력 ② 협상지표 게이지(indicator_value) 동봉. # backend/front 가 indicator/bot_chat_type 패스스루·게이지 렌더 준비 완료 → 값만 채우면 표시된다. # 카드 멘트: backoffice_db 모드면 card.nego_cards.script(negodata 편집 정본), 아니면 파일 폴백. - card_script = await scripts.resolve_card_script(decision.action_id, card_id, chat_engine.vars_for(session)) + card_script = await scripts.resolve_card_script( + decision.action_id, + card_id, + chat_engine.vars_for(session), + prefer_db=bool(session.context.get("selected_nego_card_numbers")), + ) if card_script: res.script = card_script c = session.context @@ -218,9 +239,16 @@ class ChatService: policy.update(Transition(state_index=last_state, action_id=last_action, reward=reward.total, done=True)) await QTablePolicyStore.persist_cell(repo, version_id, policy, last_state, last_action) await self._log(repo, session, last_state, last_action, - engine.mapper.get_card_id(last_action), snap, reward, None, done=True) + self._card_id_for_action(engine, session, last_action), snap, reward, None, done=True) res.updated_q = float(policy.qtable.q[last_state, last_action]) + @staticmethod + def _card_id_for_action(engine: TenantEngine, session: ChatSession, action_id: int) -> Optional[str]: + selected = session.context.get("selected_nego_card_numbers") or [] + if 0 <= action_id < len(selected): + return str(selected[action_id]) + return engine.mapper.get_card_id(action_id) + async def _log(self, repo: LearningRepository, session, state_index, action_id, card_id, snap, reward, propensity, done): data = { "session_id": session.session_id, "state_index": state_index, "action_id": action_id, diff --git a/agent/tests/test_context_loader.py b/agent/tests/test_context_loader.py index 034e597..f51c851 100644 --- a/agent/tests/test_context_loader.py +++ b/agent/tests/test_context_loader.py @@ -34,7 +34,7 @@ _T_QUOTATIONS = table( "quotations", column("qt_id"), column("user_id"), column("qt_setting_id"), column("version_id"), column("name"), column("number"), column("type"), column("status"), - column("start_time"), column("end_time"), column("supplier_type"), + column("start_time"), column("end_time"), schema="quotation", ) _T_ITEMS = table( @@ -80,7 +80,6 @@ async def test_context_loaded_from_db(db_engine): qt_id=qid, user_id=uuid.uuid4(), qt_setting_id=uuid.uuid4(), version_id=uuid.uuid4(), name="로더 테스트", number="QT-LOADER-TEST", type=3, status=2, start_time=now, end_time=now + timedelta(days=1), - supplier_type=2, # manufacture(제조) → 유통 코드 "A" )) def _ins_sess(s, session_id, supplier_id): @@ -125,7 +124,7 @@ async def test_context_loaded_from_db(db_engine): assert c["distribution_code"] == "B" # supplier_items.supply_type=3(총판) → B (매핑 우선) assert c["partner_count"] == 2 # 매핑 기준 취급 협력사 2곳 → MULTIPLE - # 매핑 삭제 후 새 세션(sid2) → 폴백 경로: 유통코드=quotations.supplier_type, 파트너=세션 이력 + # 매핑 삭제 후 새 세션(sid2) → 유통코드는 ChatService 기본값, 파트너는 세션 이력 폴백 err = await DB_SESSION_MNG.execute_lambda_run( [DBType.MAIN.value], [lambda s: DB_SESSION_MNG.add(s, delete(_T_SUPPLIER_ITEMS).where(_T_SUPPLIER_ITEMS.c.item_id == iid))], @@ -133,7 +132,7 @@ async def test_context_loaded_from_db(db_engine): assert err == ErrorType.SUCCESS await ChatService().chat(eng, Req_Chat(session_id=str(sid2))) c2 = (await ChatSessionRepository(eng.company_id).get(str(sid2))).context - assert c2["distribution_code"] == "A" # 폴백: quotations.supplier_type=2(제조) → A + assert c2["distribution_code"] == "A" # 매핑 없음 → ChatService 기본값 assert c2["partner_count"] == 2 # 폴백: 세션 이력 distinct supplier 2곳 finally: await DB_SESSION_MNG.execute_lambda_run( @@ -197,9 +196,6 @@ async def test_loader_with_crud_double(db_engine): return ErrorType.SUCCESS, 12_000_000.0 async def get_supply_type(self, cdb, supplier_id, item_id): - return ErrorType.SUCCESS, None # 매핑 없음 → 견적 기록 폴백 - - async def get_quotation_supplier_type(self, cdb, quotation_id): return ErrorType.SUCCESS, 3 # sole_agency(총판) → "B" async def count_item_suppliers(self, cdb, item_id): @@ -215,7 +211,7 @@ async def test_loader_with_crud_double(db_engine): assert ctx.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target) assert ctx.item_price == 7000 assert ctx.revenue_amount == 12_000_000.0 - assert ctx.distribution_code == "B" # supplier_type=3(총판) → B + assert ctx.distribution_code == "B" # supply_type=3(총판) → B assert ctx.partner_type is PartnerType.NONE diff --git a/agent/tests/test_p7_chat.py b/agent/tests/test_p7_chat.py index 7c15fd3..42fce19 100644 --- a/agent/tests/test_p7_chat.py +++ b/agent/tests/test_p7_chat.py @@ -15,6 +15,8 @@ import pytest from router.v1.chat.protocol import Req_Chat from services.chat_service import ChatService, reset_sessions +from negotiation.chat.service.chat_engine import ChatEngine, ChatSession +from negotiation.chat.service.script_repository import ScriptRepository from negotiation.qtable.infra.repository.learning_repository import LearningRepository from tenancy.config_loader import TenantConfigLoader from tenancy.registry import TenantEngineRegistry @@ -122,6 +124,75 @@ def test_acceptance_ratio_dynamic_calc(): assert calc({"input_price": 9800}) == 0.0 # 기준가 전무(과거 세션 호환) +def test_selected_card_mapping_overrides_tenant_default(): + """운영 세션은 견적 version_id 에 묶인 카드 번호를 action_id 순서로 사용한다.""" + session = ChatSession( + session_id="00000000-0000-0000-0000-000000000001", + tenant_id="ktcommerce", + company_id="ktcommerce", + context={"selected_nego_card_numbers": ["2", "7"]}, + action_space_size=2, + ) + class _Mapper: + def get_card_id(self, action_id): + return f"DEFAULT-{action_id}" + class _Engine: + mapper = _Mapper() + + assert ChatService._card_id_for_action(_Engine(), session, 0) == "2" + assert ChatService._card_id_for_action(_Engine(), session, 1) == "7" + assert ChatService._card_id_for_action(_Engine(), session, 8) == "DEFAULT-8" + + +def test_default_1pct_wildcard_still_runs_without_selected_wildcard(): + """1% 인하는 기본 제공 카드라 DB 견적에서 와일드카드를 선택하지 않아도 발동한다.""" + cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("ktcommerce") + repo = ScriptRepository(cfg, _TENANTS_DIR) + engine = ChatEngine(repo, rq_type="재협상") + session = ChatSession( + session_id="00000000-0000-0000-0000-000000000002", + tenant_id="ktcommerce", + company_id="ktcommerce", + step="가격협상_확인", + action_space_size=0, + context={ + "input_price": 10000, + "anchor_price": 9900, + "target_price": 10000, + "round": 1, + "allow_selected_wildcards": False, + }, + ) + + view = engine.advance(session, "예") + assert view.step == "wild_card_1pct" + + +def test_budget_wildcard_requires_selected_wildcard_for_db_context(): + """재원부족 구간은 DB 견적에서 와일드카드를 선택했을 때만 발동한다.""" + cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("ktcommerce") + repo = ScriptRepository(cfg, _TENANTS_DIR) + engine = ChatEngine(repo, rq_type="재협상") + session = ChatSession( + session_id="00000000-0000-0000-0000-000000000003", + tenant_id="ktcommerce", + company_id="ktcommerce", + step="가격협상_확인", + action_space_size=0, + context={ + "input_price": 10200, + "anchor_price": 9900, + "target_price": 10000, + "round": 1, + "allow_selected_wildcards": False, + }, + ) + + view = engine.advance(session, "예") + assert view.step == "가격협상" + assert not view.step.startswith("wild_card_") + + @pytest.mark.asyncio async def test_priority_completes_without_wildcard(db_engine): reset_sessions() diff --git a/agent/tests/test_scripts_resources.py b/agent/tests/test_scripts_resources.py index d977b32..418e011 100644 --- a/agent/tests/test_scripts_resources.py +++ b/agent/tests/test_scripts_resources.py @@ -151,3 +151,20 @@ async def test_resolve_card_script_db_mode_falls_back_to_file(monkeypatch): out = await repo.resolve_card_script(0, "NGC-A001", {"input_price": 9800}) assert out and "9800" in out # 파일 폴백 멘트 + + +@pytest.mark.asyncio +async def test_resolve_card_script_prefer_db_for_selected_cards(monkeypatch): + """견적에서 선택된 백오피스 카드 번호는 file 모드여도 DB 멘트를 우선한다.""" + cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("ktcommerce") + assert cfg.cards.source_type == "file" + fake = _FakeCardRepo({"2": "선택 카드 DB 멘트 **{input_price}원**"}) + repo = ScriptRepository(cfg, _TENANTS_DIR, card_repo=fake) + + async def _fake_lambda(_db, _wr, func): + return await func(None) + from negotiation.chat.service import script_repository as _sr + monkeypatch.setattr(_sr.DB_SESSION_MNG, "execute_lambda", _fake_lambda) + + out = await repo.resolve_card_script(1, "2", {"input_price": 10200}, prefer_db=True) + assert out == "선택 카드 DB 멘트 **10200원**" diff --git a/backend/common/database/model/models.py b/backend/common/database/model/models.py index 366ce44..c1e5ea2 100644 --- a/backend/common/database/model/models.py +++ b/backend/common/database/model/models.py @@ -153,7 +153,6 @@ class quotations(MAIN_BASE): manager_contact_number = Column(String(20), nullable=True) # 담당자 연락처 memo = Column(String(100), nullable=True) # 메모 md_price = Column(BigInteger, nullable=True) - supplier_type = Column(SmallInteger, nullable=True) iteration = Column(Integer, nullable=False, server_default=text("0")) # 반복 횟수 preferred_sp_yn = Column(Boolean, nullable=True) # 선호 공급사 지정 여부 preferred_sp_id = Column(UUID(as_uuid=True), nullable=True) # 선호 공급사(partner.suppliers.supplier_id) diff --git a/backend/tests/test_anchoring_chat.py b/backend/tests/test_anchoring_chat.py index 3aaede0..779355d 100644 --- a/backend/tests/test_anchoring_chat.py +++ b/backend/tests/test_anchoring_chat.py @@ -105,10 +105,15 @@ async def anchor_seed(db_engine): {"iid": item_id, "name": f"앵커상품 {code}", "code": f"{MARK}{code}"}, ) await conn.execute( - text("INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time, supplier_type) " - "VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, 1, 2, now(), now() + interval '2 hours', 1)"), + text("INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time) " + "VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, 1, 2, now(), now() + interval '2 hours')"), {"qid": qt_id, "name": f"앵커견적 {code}", "num": f"{MARK}{code}"}, ) + await conn.execute( + text("INSERT INTO partner.supplier_items (supplier_item_id, supplier_id, item_id, supply_type) " + "VALUES (gen_random_uuid(), :sid, :iid, 1)"), + {"sid": supplier_id, "iid": item_id}, + ) await conn.execute( text("INSERT INTO negotiation.sessions " "(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, " diff --git a/negodata/backend/common/anchoring/reader.py b/negodata/backend/common/anchoring/reader.py index ac7a93b..b82ab48 100644 --- a/negodata/backend/common/anchoring/reader.py +++ b/negodata/backend/common/anchoring/reader.py @@ -25,21 +25,23 @@ _current_values = table( ) -async def fetch_current_values(db: AsyncSession, company_ids: list, supplier_type: int) -> dict: - """칸별 현재 앵커링 값 일괄 조회. {(company_id, price_range_index): rate‰} 반환. +async def fetch_current_values(db: AsyncSession, company_ids: list, supplier_types: list[int]) -> dict: + """칸별 현재 앵커링 값 일괄 조회. {(company_id, supplier_type, price_range_index): rate‰} 반환. - supplier_type 은 견적 단위로 하나뿐이라 키에 넣지 않는다. + supplier_type 은 세션의 (item_id, supplier_id) 매핑에서 온 supply_type 이다. 조정 이력이 없는 칸은 결과에 없다(호출측 정적 폴백). 조회 실패 시 빈 dict.""" - if not company_ids or supplier_type not in SAMPLEABLE_SUPPLIER_TYPES: + valid_types = sorted({int(t) for t in supplier_types if t in SAMPLEABLE_SUPPLIER_TYPES}) + if not company_ids or not valid_types: return {} try: stmt = select( _current_values.c.company_id, + _current_values.c.supplier_type, _current_values.c.price_range_index, _current_values.c.anchoring_value, ).where( _current_values.c.company_id.in_(company_ids), - _current_values.c.supplier_type == supplier_type, + _current_values.c.supplier_type.in_(valid_types), ) rows = (await db.execute(stmt)).all() except Exception as ex: @@ -47,9 +49,9 @@ async def fetch_current_values(db: AsyncSession, company_ids: list, supplier_typ return {} out = {} - for company_id, price_range_index, rate in rows: + for company_id, supplier_type, price_range_index, rate in rows: if not ANCHORING_VALUE_MIN <= rate <= ANCHORING_VALUE_MAX: # 범위 밖 값은 오염 방어 — 버리고 정적 폴백 - LOG.w(f"[앵커링] rate 범위 밖 — 무시(정적 폴백): company={company_id} bracket={price_range_index} rate={rate}") + LOG.w(f"[앵커링] rate 범위 밖 — 무시(정적 폴백): company={company_id} type={supplier_type} bracket={price_range_index} rate={rate}") continue - out[(company_id, price_range_index)] = rate + out[(company_id, supplier_type, price_range_index)] = rate return out diff --git a/negodata/backend/common/authz.py b/negodata/backend/common/authz.py new file mode 100644 index 0000000..50b0ee3 --- /dev/null +++ b/negodata/backend/common/authz.py @@ -0,0 +1,9 @@ +from common.enums import UserRole + + +def is_owner_or_admin(resource_user_id, user_id, role) -> bool: + """변경 액션 공용 소유권 판정 — 리소스 소유자(user_id 일치) 또는 최고관리자(OWNER)면 True. + + 프론트의 버튼 게이팅과 같은 규칙을 백엔드에서 강제하는 단일 출처. + 소유자 없는 공용 리소스(예: user_id NULL 공용카드)는 이 판정 대상이 아니다(도메인별 별도 처리).""" + return str(resource_user_id) == str(user_id) or role == UserRole.OWNER.value diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index 48e7eaf..135183f 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -135,7 +135,7 @@ class supplier_items(MainTableMixin, MAIN_BASE): supplier_item_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) supplier_id = Column(UUID(as_uuid=True), nullable=False, index=True) # suppliers.supplier_id item_id = Column(UUID(as_uuid=True), nullable=False, index=True) # items.item_id - # SupplierType: 이 협력사가 이 상품을 공급하는 방식(0=없음/1=유통/2=제조/3=총판). quotations.supplier_type 와 값은 같으나 의미 단위가 (협력사,상품)이라 컬럼명은 supply_type. + # SupplierType: 이 협력사가 이 상품을 공급하는 방식(0=없음/1=유통/2=제조/3=총판). supply_type = Column(SmallInteger, nullable=False, server_default=text("0"), default=0) @@ -229,8 +229,6 @@ class quotations(MainTableMixin, MAIN_BASE): manager_contact_number = Column(String(20), nullable=True) memo = Column(String(100), nullable=True) md_price = Column(BigInteger, nullable=True) - supplier_type = Column(SmallInteger, 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) diff --git a/negodata/backend/common/enums.py b/negodata/backend/common/enums.py index 80410ee..e735dfa 100644 --- a/negodata/backend/common/enums.py +++ b/negodata/backend/common/enums.py @@ -237,8 +237,8 @@ class CardType(CodeEnum): class SupplierType(CodeEnum): - """quotations.supplier_type 코드값. 없음(0,미지정)/유통(1)/제조(2)/총판(3). - 없음은 프론트 폼에 '없음'으로 노출. KTC 앵커링 코드(기타=0)와 매핑 시 0↔없음 대응.""" + """partner.supplier_items.supply_type 코드값. 없음(0,미지정)/유통(1)/제조(2)/총판(3). + 없음은 상품-협력사 매핑에서 유형 미지정 상태를 뜻한다.""" NONE = 0 # 없음(미지정) DISTRIBUTION = 1 # 유통 diff --git a/negodata/backend/crud/card_crud.py b/negodata/backend/crud/card_crud.py index c05019f..729df96 100644 --- a/negodata/backend/crud/card_crud.py +++ b/negodata/backend/crud/card_crud.py @@ -5,6 +5,7 @@ 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 users from common.enums import ErrorType from common.logger import LOG from common.utils.gtime import GTime @@ -32,13 +33,21 @@ class ICardCRUD(ABC): async def soft_delete(self, cdb: AsyncSession, model, pk_col, card_id) -> ErrorType: pass + @abstractmethod + async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]: + 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 + # 내 개인 카드 + 전체(공용, user_id NULL) 카드. 남의 개인 카드는 제외. + conditions = [ + model.deleted == False, # noqa: E712 + or_(model.user_id == user_id, model.user_id.is_(None)), + ] if search: conditions.append( or_( @@ -102,3 +111,18 @@ class CardCRUD(ICardCRUD): except Exception as ex: LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED + + async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]: + """user_id 목록 → {user_id: name}. 카드 목록 '작성자(등록자)' 표기용(company.users 조인). + 공용(user_id NULL) 카드는 호출 전에 걸러 넘긴다 → 맵에 없으면 작성자 없음.""" + try: + if not user_ids: + return ErrorType.SUCCESS, {} + query = select(users.user_id, users.name).where(users.user_id.in_(user_ids)) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, {} + return ErrorType.SUCCESS, {uid: name for uid, name in rows} + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, {} diff --git a/negodata/backend/crud/item_crud.py b/negodata/backend/crud/item_crud.py index 0995d08..622d0b6 100644 --- a/negodata/backend/crud/item_crud.py +++ b/negodata/backend/crud/item_crud.py @@ -5,7 +5,7 @@ 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.database.model.models import items, users from common.enums import ErrorType from common.logger import LOG from common.utils.gtime import GTime @@ -45,6 +45,10 @@ class IItemCRUD(ABC): async def soft_delete(self, cdb: AsyncSession, item_id) -> ErrorType: pass + @abstractmethod + async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]: + pass + class ItemCRUD(IItemCRUD): async def search( @@ -166,3 +170,17 @@ class ItemCRUD(IItemCRUD): except Exception as ex: LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED + + async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]: + """user_id 목록 → {user_id: name}. 상품 목록/상세 '등록자(작성자)' 표기용(company.users 조인).""" + try: + if not user_ids: + return ErrorType.SUCCESS, {} + query = select(users.user_id, users.name).where(users.user_id.in_(user_ids)) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, {} + return ErrorType.SUCCESS, {uid: name for uid, name in rows} + 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 4ce3ec8..17caad6 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -8,9 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import ( quotations, sessions, chats, nego_cards, wild_cards, items, suppliers, quotation_settings, - version_nego_cards, version_wild_cards, users, + version_nego_cards, version_wild_cards, users, supplier_items, ) -from common.enums import ErrorType, QuotationStatus, SessionStatus +from common.enums import CloseReason, ErrorType, QuotationStatus, SessionStatus from common.logger import LOG from common.utils.gtime import GTime @@ -44,7 +44,7 @@ class IQuotationCRUD(ABC): pass @abstractmethod - async def get_last_supplier_type(self, cdb: AsyncSession, supplier_id, company_id=None) -> Tuple[ErrorType, Optional[tuple]]: + async def get_supply_types(self, cdb: AsyncSession, item_ids, supplier_ids) -> Tuple[ErrorType, dict]: pass @abstractmethod @@ -148,6 +148,10 @@ class IQuotationCRUD(ABC): async def claim_for_close(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, int]: pass + @abstractmethod + async def claim_for_award(self, cdb: AsyncSession, qt_id, supplier_id, supplier_name) -> Tuple[ErrorType, int]: + pass + class QuotationCRUD(IQuotationCRUD): async def search( @@ -396,33 +400,30 @@ class QuotationCRUD(IQuotationCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, {} - async def get_last_supplier_type(self, cdb: AsyncSession, supplier_id, company_id=None) -> Tuple[ErrorType, Optional[tuple]]: - """협력사의 직전 견적 supplier_type. (supplier_type, qt_number) | None. - sessions(supplier_id) ⨝ quotations 에서 supplier_type 가 있는 최신 견적 1건.""" + async def get_supply_types(self, cdb: AsyncSession, item_ids, supplier_ids) -> Tuple[ErrorType, dict]: + """(item_id, supplier_id) -> supplier_items.supply_type 매핑. + + 매핑이 없거나 supply_type 이 0/NULL 이면 호출측이 정적 앵커링 값으로 폴백한다. + """ try: - conds = [ - sessions.supplier_id == supplier_id, - quotations.supplier_type.isnot(None), - quotations.deleted == False, # noqa: E712 - ] - if company_id is not None: - conds.append(quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id))) - query = ( - select(quotations.supplier_type, quotations.number) - .join(sessions, sessions.quotation_id == quotations.qt_id) - .where(*conds) - .order_by(quotations.created_at.desc()) - .limit(1) + if not item_ids or not supplier_ids: + return ErrorType.SUCCESS, {} + query = select( + supplier_items.item_id, + supplier_items.supplier_id, + supplier_items.supply_type, + ).where( + supplier_items.item_id.in_(item_ids), + supplier_items.supplier_id.in_(supplier_ids), + supplier_items.deleted == False, # noqa: E712 ) err_type, rows = await DB_SESSION_MNG.execute(cdb, query) if err_type != ErrorType.SUCCESS: - return err_type, None - if not rows: - return ErrorType.SUCCESS, None - return ErrorType.SUCCESS, (rows[0][0], rows[0][1]) + return err_type, {} + return ErrorType.SUCCESS, {(r[0], r[1]): r[2] for r in rows} except Exception as ex: LOG.e_no_callstack(ex) - return ErrorType.DB_RUN_FAILED, None + return ErrorType.DB_RUN_FAILED, {} async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]: """견적 세팅의 목표 마진율: {margin}. 목표가 산정 입력(인터넷 수수료는 상수). @@ -474,6 +475,36 @@ class QuotationCRUD(IQuotationCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, 0 + async def claim_for_award(self, cdb: AsyncSession, qt_id, supplier_id, supplier_name) -> Tuple[ErrorType, int]: + """[동시 직접낙찰 가드] 개찰(마감·낙찰자 미정, close_reason ∈ OPEN_*)인 견적만 낙찰(AWARDED)로 선점 전이. + 선택 협력사를 낙찰자(preferred_sp_*)로 박고 동가 플래그는 내린다. status 는 이미 CLOSED 라 유지. + 반환: (ErrorType, 적용행수). 이미 낙찰됐거나(재클릭) 개찰이 아니면 0 → 서비스가 한 번만 통과시킨다.""" + try: + query = ( + update(quotations) + .where( + quotations.qt_id == qt_id, + quotations.status == QuotationStatus.CLOSED.value, + quotations.close_reason.in_( + [CloseReason.OPEN_PRICE.value, CloseReason.OPEN_EQUAL.value, + CloseReason.OPEN_NOSHOW.value, CloseReason.OPEN_REJECT.value] + ), + quotations.deleted == False, # noqa: E712 + ) + .values( + close_reason=CloseReason.AWARDED.value, + preferred_sp_yn=True, + preferred_sp_id=supplier_id, + preferred_sp_name=supplier_name, + equal_bid_yn=False, + updated_at=GTime.UTC(), + ) + ) + return await DB_SESSION_MNG.add_with_rowcount(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, 0 + async def update_sessions_status(self, cdb: AsyncSession, qt_id, from_statuses: list[int], to_status: int) -> ErrorType: # 견적에 딸린 세션 중 from_statuses 에 속한 것만 to_status 로 일괄 전이(삭제 제외). 다른 상태는 건드리지 않는다. try: diff --git a/negodata/backend/crud/statistics_crud.py b/negodata/backend/crud/statistics_crud.py new file mode 100644 index 0000000..2e07174 --- /dev/null +++ b/negodata/backend/crud/statistics_crud.py @@ -0,0 +1,214 @@ +from abc import ABC, abstractmethod +from typing import Tuple + +from sqlalchemy import select, func, and_, case +from sqlalchemy.ext.asyncio import AsyncSession + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import quotations, sessions, items, chats, users +from common.enums import ErrorType, QuotationStatus, CloseReason, SessionStatus +from common.logger import LOG + + +# 통계 유니버스 = 현재 마감사유 5코드로 마감된 견적. 레거시 REGEN_*(2·3·4) 은 제외해 +# KPI(낙찰률·마감수)와 유형별/결과분해의 분모를 일치시킨다(프로덕션엔 레거시 없어 전체 마감과 동일). +CURRENT_CLOSE_REASONS = [ + CloseReason.AWARDED.value, + CloseReason.OPEN_PRICE.value, + CloseReason.OPEN_EQUAL.value, + CloseReason.OPEN_NOSHOW.value, + CloseReason.OPEN_REJECT.value, +] + + +# 통계 집계 CRUD. 대시보드와 동일하게 회사 스코프(작성자 user_id→users.company_id)로 건다. +# owner(user_id) 가 주어지면 '내가 만든 견적'으로 더 좁힌다. quotations 엔 company_id 컬럼이 없어 서브쿼리로. +def _company_scope(company_id, owner) -> list: + conds = [ + quotations.deleted == False, # noqa: E712 + quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)), + ] + if owner is not None: + conds.append(quotations.user_id == owner) + return conds + + +class IStatisticsCRUD(ABC): + @abstractmethod + async def winning_sessions(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]: + pass + + @abstractmethod + async def outcome_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]: + pass + + @abstractmethod + async def type_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]: + pass + + @abstractmethod + async def participation_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]: + pass + + @abstractmethod + async def regen_avg_round(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]: + pass + + @abstractmethod + async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]: + pass + + +class StatisticsCRUD(IStatisticsCRUD): + async def winning_sessions(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]: + # 낙찰 마감 견적의 '낙찰 세션'(supplier_id=preferred_sp_id) 행 — 절감/추이/유형/카테고리/앵커도달률의 단일 원천. + # 파생: 저장 안 하고 조회 때 조인. category 는 items LEFT JOIN(자유텍스트·NULL 허용). + try: + stmt = ( + select( + quotations.updated_at, + quotations.type, + items.category, + sessions.target_price, + sessions.bid_price, + sessions.anchoring_price, + ) + .select_from(quotations) + .join( + sessions, + and_( + sessions.quotation_id == quotations.qt_id, + sessions.supplier_id == quotations.preferred_sp_id, + sessions.bid_price.isnot(None), + sessions.deleted == False, # noqa: E712 + ), + ) + .join(items, items.item_id == sessions.item_id, isouter=True) + .where( + and_( + *_company_scope(company_id, owner), + quotations.status == QuotationStatus.CLOSED.value, + quotations.close_reason == CloseReason.AWARDED.value, + quotations.updated_at >= since, + ) + ) + ) + err, rows = await DB_SESSION_MNG.execute(cdb, stmt) + return (err, list(rows) if err == ErrorType.SUCCESS else []) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + + async def outcome_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]: + # 마감 결과 분해: close_reason 별 건수. 낙찰률·마감건수도 여기서 파생. + try: + stmt = ( + select(quotations.close_reason, func.count()) + .where( + and_( + *_company_scope(company_id, owner), + quotations.status == QuotationStatus.CLOSED.value, + quotations.close_reason.in_(CURRENT_CLOSE_REASONS), + quotations.updated_at >= since, + ) + ) + .group_by(quotations.close_reason) + ) + err, rows = await DB_SESSION_MNG.execute(cdb, stmt) + return (err, list(rows) if err == ErrorType.SUCCESS else []) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + + async def type_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]: + # 유형별(협상/경매) 마감 건수 + 낙찰 건수 → 유형별 낙찰률. + try: + awarded = func.sum(case((quotations.close_reason == CloseReason.AWARDED.value, 1), else_=0)) + stmt = ( + select(quotations.type, func.count(), awarded) + .where( + and_( + *_company_scope(company_id, owner), + quotations.status == QuotationStatus.CLOSED.value, + quotations.close_reason.in_(CURRENT_CLOSE_REASONS), + quotations.updated_at >= since, + ) + ) + .group_by(quotations.type) + ) + err, rows = await DB_SESSION_MNG.execute(cdb, stmt) + return (err, list(rows) if err == ErrorType.SUCCESS else []) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + + async def participation_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]: + # 협력사 참여: 회사 견적(창 내 생성)의 세션을 status 별 집계(응찰/미응찰/거부). + try: + conds = [ + sessions.deleted == False, # noqa: E712 + quotations.deleted == False, # noqa: E712 + quotations.created_at >= since, + quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)), + ] + if owner is not None: + conds.append(quotations.user_id == owner) + stmt = ( + select(sessions.status, func.count()) + .select_from(sessions) + .join(quotations, quotations.qt_id == sessions.quotation_id) + .where(and_(*conds)) + .group_by(sessions.status) + ) + err, rows = await DB_SESSION_MNG.execute(cdb, stmt) + return (err, list(rows) if err == ErrorType.SUCCESS else []) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + + async def regen_avg_round(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]: + # 평균 재견적 라운드. TODO: 체인키 없어 avg(round) 단순버전 — 체인당 최대 라운드 정의는 root_qt_id 도입 후. + try: + stmt = select(func.avg(quotations.round)).where( + and_( + *_company_scope(company_id, owner), + quotations.status == QuotationStatus.CLOSED.value, + quotations.close_reason.in_(CURRENT_CLOSE_REASONS), + quotations.updated_at >= since, + ) + ) + err, rows = await DB_SESSION_MNG.execute(cdb, stmt) + if err != ErrorType.SUCCESS: + return err, 0.0 + # 단일컬럼 집계는 execute 가 스칼라 리스트를 반환한다(대시보드 _count 와 동일). rows[0] 이 곧 avg 값. + val = rows[0] if rows else None + return ErrorType.SUCCESS, float(val) if val is not None else 0.0 + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, 0.0 + + async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]: + # 카드 유형별 사용 빈도: card_used_yn=True 채팅을 card_type 별 집계(협상형 견적에서만 채팅 생성). + try: + conds = [ + chats.deleted == False, # noqa: E712 + chats.card_used_yn.is_(True), + quotations.deleted == False, # noqa: E712 + quotations.created_at >= since, + quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)), + ] + if owner is not None: + conds.append(quotations.user_id == owner) + stmt = ( + select(chats.card_type, func.count()) + .select_from(chats) + .join(sessions, sessions.session_id == chats.session_id) + .join(quotations, quotations.qt_id == sessions.quotation_id) + .where(and_(*conds)) + .group_by(chats.card_type) + ) + err, rows = await DB_SESSION_MNG.execute(cdb, stmt) + return (err, list(rows) if err == ErrorType.SUCCESS else []) + 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 index 84e54a5..365b230 100644 --- a/negodata/backend/crud/supplier_crud.py +++ b/negodata/backend/crud/supplier_crud.py @@ -5,7 +5,7 @@ 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.database.model.models import suppliers, users from common.enums import ErrorType from common.logger import LOG from common.utils.gtime import GTime @@ -41,6 +41,10 @@ class ISupplierCRUD(ABC): async def soft_delete(self, cdb: AsyncSession, supplier_id) -> ErrorType: pass + @abstractmethod + async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]: + pass + class SupplierCRUD(ISupplierCRUD): async def search( @@ -144,3 +148,17 @@ class SupplierCRUD(ISupplierCRUD): except Exception as ex: LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED + + async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]: + """user_id 목록 → {user_id: name}. 협력사 목록/상세 '등록자(작성자)' 표기용(company.users 조인).""" + try: + if not user_ids: + return ErrorType.SUCCESS, {} + query = select(users.user_id, users.name).where(users.user_id.in_(user_ids)) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, {} + return ErrorType.SUCCESS, {uid: name for uid, name in 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 3bc2dd3..93fbf05 100644 --- a/negodata/backend/router/router.py +++ b/negodata/backend/router/router.py @@ -19,6 +19,7 @@ import router.v1.card.card import router.v1.quotation.quotation import router.v1.quotation_setting.quotation_setting import router.v1.dashboard.dashboard +import router.v1.statistics.statistics import router.v1.notification.notification API_SERVER_START_TIME = GTime.UTCStr() @@ -74,4 +75,5 @@ 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.dashboard.dashboard.router) +app.include_router(router.v1.statistics.statistics.router) app.include_router(router.v1.notification.notification.router) diff --git a/negodata/backend/router/v1/card/protocol.py b/negodata/backend/router/v1/card/protocol.py index bd54f39..5740813 100644 --- a/negodata/backend/router/v1/card/protocol.py +++ b/negodata/backend/router/v1/card/protocol.py @@ -14,6 +14,7 @@ class CardProtocol(WebPacketProtocol): class Req_CreateCard(CardProtocol): is_wildcard: bool = False + is_shared: bool = False # True=전체(공용, user_id NULL 저장) / False=개인(등록 유저 소유) name: Optional[str] = None number: Optional[str] = None script: Optional[str] = None @@ -42,6 +43,8 @@ class CardData(WebPacketProtocol): nego_card_id: uuid.UUID # 통합 식별자(일반=nego_card_id / 와일드=wild_card_id) user_id: Optional[uuid.UUID] = None is_wildcard: bool = False + is_shared: bool = False # 전체(공용) 카드 여부 = user_id NULL. 목록 배지/폼 스코프 표시용 + creator_name: Optional[str] = None # 작성자(등록자) 이름. user_id→company.users.name 조인. 공용 카드는 None name: Optional[str] = None number: Optional[str] = None script: Optional[str] = None diff --git a/negodata/backend/router/v1/item/item.py b/negodata/backend/router/v1/item/item.py index 2e21d81..b423a88 100644 --- a/negodata/backend/router/v1/item/item.py +++ b/negodata/backend/router/v1/item/item.py @@ -69,12 +69,12 @@ async def get_item(item_id: UUID, service: ItemService = Depends(), user_info: U 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)) + return RemoveNoneResponse(await service.update_item(user_info.company_id, str(item_id), req, user_info.user_id, user_info.role)) @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))) + return RemoveNoneResponse(await service.delete_item(user_info.company_id, str(item_id), user_info.user_id, user_info.role)) @router.post(path="/{item_id}/lowest-price", response_model=Res_LowestPriceTrigger, summary="최저가 수집 요청(스텁)") diff --git a/negodata/backend/router/v1/item/protocol.py b/negodata/backend/router/v1/item/protocol.py index fd8d7e6..fa145a9 100644 --- a/negodata/backend/router/v1/item/protocol.py +++ b/negodata/backend/router/v1/item/protocol.py @@ -64,6 +64,7 @@ class ItemData(WebPacketProtocol): item_id: uuid.UUID company_id: uuid.UUID user_id: uuid.UUID + creator_name: Optional[str] = None # 등록자(작성자) 이름. user_id→company.users.name 조인 name: str code: Optional[str] = None category: Optional[str] = None diff --git a/negodata/backend/router/v1/quotation/protocol.py b/negodata/backend/router/v1/quotation/protocol.py index e7cb321..571862f 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.enums import CardType, ChatSender, CloseReason, DeliveryType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus, SupplierType +from common.enums import CardType, ChatSender, CloseReason, DeliveryType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol @@ -26,7 +26,6 @@ class Req_CreateQuotation(QuotationProtocol): manager_contact_number: Optional[str] = None memo: Optional[str] = None md_price: Optional[int] = None # MD 제시가(원). 세션 목표가 산정 최우선값 - supplier_type: Optional[int] = None # 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록 item_ids: list[uuid.UUID] = [] # 협상 대상 상품. item×supplier 조합마다 세션 1개 생성 supplier_ids: list[uuid.UUID] = [] # 협상 초청 공급사 card_ids: list[uuid.UUID] = [] # 선택 협상카드. 버전을 만들어 묶고 quotation.version_id 로 연결 @@ -40,6 +39,10 @@ class Req_RegenerateQuotation(QuotationProtocol): supplier_ids: list[uuid.UUID] = [] # 다음 라운드에 부를 공급사(프론트 선택). 상품·기간·번호는 원 견적에서 이어받음 +class Req_AwardQuotation(QuotationProtocol): + winner_supplier_id: uuid.UUID # 담당자가 직접 낙찰시킬 협력사(투찰한 협상완료 세션 중 선택) + + class QuotationData(WebPacketProtocol): model_config = ConfigDict(from_attributes=True) @@ -59,7 +62,6 @@ class QuotationData(WebPacketProtocol): manager_contact_number: Optional[str] = None memo: Optional[str] = None md_price: Optional[int] = None - supplier_type: Optional[SupplierType] = None iteration: int = 0 preferred_sp_yn: Optional[bool] = None preferred_sp_id: Optional[uuid.UUID] = None @@ -185,11 +187,6 @@ class Res_QuotationCards(Res_WebPacketProtocol): cards: list[QuotationCardData] = [] -class Res_LastSupplierType(Res_WebPacketProtocol): - supplier_type: Optional[SupplierType] = None # 협력사 직전 견적의 유형(없으면 None) - qt_number: Optional[str] = None # 그 견적의 번호(이전 견적 값임을 표시용) - - class TargetCandidate(WebPacketProtocol): basis: str label: str diff --git a/negodata/backend/router/v1/quotation/quotation.py b/negodata/backend/router/v1/quotation/quotation.py index ec1326f..e5cc288 100644 --- a/negodata/backend/router/v1/quotation/quotation.py +++ b/negodata/backend/router/v1/quotation/quotation.py @@ -7,11 +7,11 @@ from common.models.gmodel import PageParams, UserInfo from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse from services.quotation_service import QuotationService from .protocol import ( + Req_AwardQuotation, Req_CreateQuotation, Req_RegenerateQuotation, Res_CreateQuotation, Res_DeleteQuotation, - Res_LastSupplierType, Res_NotifySessions, Res_Quotation, Res_QuotationCards, @@ -55,14 +55,24 @@ async def create_quotation( @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), user_info.company_id)) + return RemoveNoneResponse(await service.stop_quotation(str(qt_id), user_info.company_id, user_info.user_id, user_info.role)) + + +@router.post(path="/award/{qt_id}", response_model=Res_Quotation, summary="개찰 견적 직접 낙찰") +async def award_quotation( + qt_id: UUID, req: Req_AwardQuotation, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) +): + # 권한(본인 견적만/OWNER 예외)은 서비스에서 판정하도록 호출자 user_id·role 을 넘긴다. + return RemoveNoneResponse( + await service.award_quotation(str(qt_id), user_info.company_id, user_info.user_id, user_info.role, req.winner_supplier_id) + ) @router.post(path="/regenerate/{qt_id}", response_model=Res_CreateQuotation, summary="견적 재생성(다음 라운드)") async def regenerate_quotation( qt_id: UUID, req: Req_RegenerateQuotation, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) ): - return RemoveNoneResponse(await service.regenerate_quotation(str(qt_id), user_info.company_id, req.supplier_ids)) + return RemoveNoneResponse(await service.regenerate_quotation(str(qt_id), user_info.company_id, req.supplier_ids, user_info.user_id, user_info.role)) # ----- 견적 상세 (FK로 연결된 하위 데이터 / 일부는 모델 미존재로 스텁) ----- @@ -78,7 +88,7 @@ async def get_quotation_sessions(qt_id: UUID, service: QuotationService = Depend @router.post(path="/{qt_id}/notify", response_model=Res_NotifySessions, summary="협상 초청 메일 발송") async def notify_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): - return RemoveNoneResponse(await service.notify_sessions(str(qt_id), user_info.company_id)) + return RemoveNoneResponse(await service.notify_sessions(str(qt_id), user_info.company_id, user_info.user_id, user_info.role)) @router.get(path="/session/{session_id}/chat", response_model=Res_SessionChat, summary="채팅 상세") @@ -93,7 +103,7 @@ async def get_target_breakdown(session_id: UUID, service: QuotationService = Dep @router.post(path="/session/{session_id}/notify", response_model=Res_NotifySessions, summary="세션 초청 메일 재발송") async def notify_session(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): - return RemoveNoneResponse(await service.notify_session(str(session_id), user_info.company_id)) + return RemoveNoneResponse(await service.notify_session(str(session_id), user_info.company_id, user_info.user_id, user_info.role)) @router.get(path="/{qt_id}/result", response_model=Res_QuotationResult, summary="낙찰 결과") @@ -108,12 +118,7 @@ async def get_quotation_cards(qt_id: UUID, service: QuotationService = Depends() @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), user_info.company_id)) - - -@router.get(path="/supplier/{supplier_id}/last-type", response_model=Res_LastSupplierType, summary="협력사 직전 견적 유형") -async def get_supplier_last_type(supplier_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): - return RemoveNoneResponse(await service.get_last_supplier_type(str(supplier_id), user_info.company_id)) + return RemoveNoneResponse(await service.delete_quotation(str(qt_id), user_info.company_id, user_info.user_id, user_info.role)) # ----- 단건 조회 (정적/하위 경로 뒤에 선언) ----- diff --git a/negodata/backend/router/v1/statistics/protocol.py b/negodata/backend/router/v1/statistics/protocol.py new file mode 100644 index 0000000..b4c22c9 --- /dev/null +++ b/negodata/backend/router/v1/statistics/protocol.py @@ -0,0 +1,72 @@ +from pydantic import Field + +from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol + + +class StatisticsProtocol(WebPacketProtocol): + pass + + +class StatKpi(WebPacketProtocol): + total_savings: int = 0 # 총 절감액(목표가 대비, 낙찰세션 target−bid 합) + savings_rate: float = 0.0 # 평균 절감률 = 절감합/목표합 + award_rate: float = 0.0 # 낙찰률 = 낙찰 마감 / 전체 마감 + anchor_reach_rate: float = 0.0 # 평균 앵커 도달률 = (목표−투찰)/(목표−앵커) + savings_delta_mom: int = 0 # 전월 대비 절감액 증감 + closed_count: int = 0 # 마감 견적 수(창) + regen_avg_round: float = 0.0 # 평균 재견적 라운드 + + +class StatMonthPoint(WebPacketProtocol): + month: str # 'YYYY-MM' + savings: int = 0 + rate: float = 0.0 + + +class StatOutcome(WebPacketProtocol): + awarded: int = 0 # CloseReason 1 + open_price: int = 0 # 5 가격 미달 + open_equal: int = 0 # 6 동가 + open_noshow: int = 0 # 7 미응찰 + open_reject: int = 0 # 8 거부 + + +class StatParticipation(WebPacketProtocol): + bid: int = 0 # 응찰(SessionStatus DONE) + no_participate: int = 0 # 미응찰(NOT_PARTICIPATED) + rejected: int = 0 # 거부(REJECTED) + + +class StatTypeRow(WebPacketProtocol): + label: str + award_rate: float = 0.0 + avg_savings: int = 0 + count: int = 0 + + +class StatCategory(WebPacketProtocol): + category: str + savings: int = 0 + count: int = 0 + + +class StatCardUsage(WebPacketProtocol): + type: str # 'nego' | 'wild' + label: str + uses: int = 0 + avg_drop: int = 0 # 사용 직후 상대 제시가 평균 하락. TODO: v1은 0(유형별 빈도만) — chats seq 델타 계산은 다음 단계. + + +class StatScope(WebPacketProtocol): + kpi: StatKpi = Field(default_factory=StatKpi) + trend: list[StatMonthPoint] = [] + outcome: StatOutcome = Field(default_factory=StatOutcome) + participation: StatParticipation = Field(default_factory=StatParticipation) + type_split: list[StatTypeRow] = [] + categories: list[StatCategory] = [] + cards: list[StatCardUsage] = [] + + +class Res_StatisticsSummary(Res_WebPacketProtocol): + company: StatScope = Field(default_factory=StatScope) # 회사 전체(company_id 스코프) + mine: StatScope = Field(default_factory=StatScope) # 내가 만든 견적(user_id 추가 스코프) diff --git a/negodata/backend/router/v1/statistics/statistics.py b/negodata/backend/router/v1/statistics/statistics.py new file mode 100644 index 0000000..297969b --- /dev/null +++ b/negodata/backend/router/v1/statistics/statistics.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter, Depends + +from common.models.gmodel import UserInfo +from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse +from services.statistics_service import StatisticsService +from .protocol import Res_StatisticsSummary + +# 라우터(컨트롤러). 인증(Depends(IsValidAccessToken))의 UserInfo 로 company/user 스코프 집계를 한 번에 내린다. +router = APIRouter(prefix="/v1/statistics", tags=["Statistics"], responses={404: {"description": "Not found"}}) + + +@router.get(path="/summary", response_model=Res_StatisticsSummary, summary="통계 요약(회사 전체 + 내 견적)") +async def get_statistics_summary(service: StatisticsService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.get_summary(user_info.company_id, user_info.user_id)) diff --git a/negodata/backend/router/v1/supplier/protocol.py b/negodata/backend/router/v1/supplier/protocol.py index 5340c74..7e85216 100644 --- a/negodata/backend/router/v1/supplier/protocol.py +++ b/negodata/backend/router/v1/supplier/protocol.py @@ -35,6 +35,7 @@ class SupplierData(WebPacketProtocol): supplier_id: uuid.UUID company_id: uuid.UUID user_id: uuid.UUID + creator_name: Optional[str] = None # 등록자(작성자) 이름. user_id→company.users.name 조인 name: str code: Optional[str] = None manager_name: Optional[str] = None diff --git a/negodata/backend/router/v1/supplier/supplier.py b/negodata/backend/router/v1/supplier/supplier.py index d1d81a9..7925879 100644 --- a/negodata/backend/router/v1/supplier/supplier.py +++ b/negodata/backend/router/v1/supplier/supplier.py @@ -3,7 +3,7 @@ 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 router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, RequireOwner from services.supplier_service import SupplierService from .protocol import ( Req_CheckCodes, @@ -59,6 +59,7 @@ async def update_supplier( ) -@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)): +@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(RequireOwner)): + # 협력사 명부는 회사 공유 자원 — 파괴적 삭제는 최고관리자만(RequireOwner 가 비-OWNER 를 403 차단). return RemoveNoneResponse(await service.delete_supplier(user_info.company_id, str(supplier_id))) diff --git a/negodata/backend/scripts/seed_demo_quotations.sql b/negodata/backend/scripts/seed_demo_quotations.sql index 90d7755..45e2ee6 100644 --- a/negodata/backend/scripts/seed_demo_quotations.sql +++ b/negodata/backend/scripts/seed_demo_quotations.sql @@ -34,7 +34,7 @@ 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_id, preferred_sp_name, equal_bid_yn, equal_bid_data, - close_reason, md_price, supplier_type, created_at, updated_at, deleted) + close_reason, md_price, created_at, updated_at, deleted) VALUES -- ① 낙찰(절감): 목표 400,000 → 낙찰 372,000 ('aaaa0001-0000-0000-0000-000000000001','b874f33d-afc3-4498-a9c2-19bc5ae8faba', @@ -42,14 +42,14 @@ VALUES '① 낙찰(절감) 데모','EST-DEMO-01',4,1,3, now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','낙찰·목표대비 절감 케이스', 1, true,'9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09','(주)대한정밀', null, null, - 1, 400000, 1, now(), now(), false), + 1, 400000, now(), now(), false), -- ② 낙찰(목표초과): 목표 350,000 → 낙찰 368,000 (over_action=낙찰) ('aaaa0002-0000-0000-0000-000000000002','b874f33d-afc3-4498-a9c2-19bc5ae8faba', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '② 낙찰(목표초과) 데모','EST-DEMO-02',4,1,3, now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','낙찰이지만 목표가 초과 케이스', 1, true,'69e7057a-5919-48cd-b329-13b833bff994','서울산업소재(주)', null, null, - 1, 350000, 1, now(), now(), false), + 1, 350000, now(), now(), false), -- ③ 동가 재입찰(REGEN_EQUAL): 두 곳 390,000 동률 → 재입찰 진행 ('aaaa0003-0000-0000-0000-000000000003','b874f33d-afc3-4498-a9c2-19bc5ae8faba', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', @@ -57,42 +57,42 @@ VALUES now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','동가 발생·다음 라운드 재입찰 케이스', 1, false, null, null, true, '[{"supplier_id":"9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09","supplier_name":"(주)대한정밀","bid_price":390000},{"supplier_id":"747897ec-adab-4f3e-9076-15803ddf821f","supplier_name":"글로벌테크놀로지","bid_price":390000}]'::jsonb, - 6, 400000, 1, now(), now(), false), + 6, 400000, now(), now(), false), -- ④ 거부 유찰(FAIL_REJECT): 협상거부로 마감 ('aaaa0004-0000-0000-0000-000000000004','b874f33d-afc3-4498-a9c2-19bc5ae8faba', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '④ 거부 개찰 데모','EST-DEMO-04',2,1,3, now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','협상 거부로 유찰 케이스', 1, null, null, null, null, null, - 8, 400000, 1, now(), now(), false), + 8, 400000, now(), now(), false), -- ⑤ 미참여 재소집(REGEN_NOSHOW): 전원 미참여 → 다음 라운드 재소집 ('aaaa0005-0000-0000-0000-000000000005','b874f33d-afc3-4498-a9c2-19bc5ae8faba', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '⑤ 미응찰 개찰 데모','EST-DEMO-05',1,1,3, now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','전원 미참여·다음 라운드 재소집 케이스', 1, false, null, null, false, null, - 7, 400000, 1, now(), now(), false), + 7, 400000, now(), now(), false), -- ⑥ 진행중: 일부 투찰 완료, 아직 안 닫힘 (close_reason NULL) ('aaaa0006-0000-0000-0000-000000000006','b874f33d-afc3-4498-a9c2-19bc5ae8faba', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '⑥ 진행중 데모','EST-DEMO-06',4,1,2, now()-interval '1 day', now()+interval '1 day','김담당','mgr@imk.kr','010-1111-1111','협상 진행중·현재 최저 투찰 케이스', 1, null, null, null, null, null, - null, 400000, 1, now(), now(), false), + null, 400000, now(), now(), false), -- ⑦ 가격 재협상(REGEN_PRICE): 단독 최저가 420,000 > 목표 350,000 → 재협상 진행 ('aaaa0007-0000-0000-0000-000000000007','b874f33d-afc3-4498-a9c2-19bc5ae8faba', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '⑦ 목표초과 개찰 데모','EST-DEMO-07',4,1,3, now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','목표 초과 → 재협상(더 깎기) 진행 케이스', 1, false, null, null, false, null, - 5, 350000, 1, now(), now(), false), + 5, 350000, now(), now(), false), -- ⑧ 가격 유찰(FAIL_PRICE): 단독 최저가 430,000 > 목표 350,000, over_action=유찰(또는 한도소진) → 유찰 ('aaaa0008-0000-0000-0000-000000000008','b874f33d-afc3-4498-a9c2-19bc5ae8faba', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '⑧ 목표초과 개찰 데모(2)','EST-DEMO-08',4,1,3, now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','목표 초과 → 유찰 케이스', 1, false, null, null, false, null, - 5, 350000, 1, now(), now(), false), + 5, 350000, now(), now(), false), -- ⑨ 동가 유찰(FAIL_EQUAL): 동가지만 재입찰 한도 소진/유찰 정책 → 유찰 ('aaaa0009-0000-0000-0000-000000000009','b874f33d-afc3-4498-a9c2-19bc5ae8faba', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', @@ -100,14 +100,14 @@ VALUES now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','동가지만 재입찰 한도 소진 → 유찰 케이스', 1, false, null, null, true, '[{"supplier_id":"9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09","supplier_name":"(주)대한정밀","bid_price":405000},{"supplier_id":"69e7057a-5919-48cd-b329-13b833bff994","supplier_name":"서울산업소재(주)","bid_price":405000}]'::jsonb, - 6, 400000, 1, now(), now(), false), + 6, 400000, now(), now(), false), -- ⑩ 미참여 유찰(FAIL_NOSHOW): 전원 미참여 + 재소집 한도 소진 → 유찰 ('aaaa0010-0000-0000-0000-000000000010','b874f33d-afc3-4498-a9c2-19bc5ae8faba', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '⑩ 미응찰 개찰 데모(2)','EST-DEMO-10',1,2,3, now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','전원 미참여 + 재소집 한도 소진 → 유찰 케이스', 1, false, null, null, false, null, - 7, 400000, 1, now(), now(), false); + 7, 400000, now(), now(), false); -- ── 세션 ──────────────────────────────────────────────────────────────── -- 공통: item=a3d1fff5, DeliveryType 협력사배송=1 diff --git a/negodata/backend/services/card_service.py b/negodata/backend/services/card_service.py index 513086d..26cd65b 100644 --- a/negodata/backend/services/card_service.py +++ b/negodata/backend/services/card_service.py @@ -25,6 +25,7 @@ class CardService: nego_card_id=row.nego_card_id, user_id=row.user_id, is_wildcard=False, + is_shared=row.user_id is None, name=row.name, number=row.number, script=row.script, @@ -42,6 +43,7 @@ class CardService: nego_card_id=row.wild_card_id, user_id=row.user_id, is_wildcard=True, + is_shared=row.user_id is None, name=row.name, number=row.number, script=row.script, @@ -56,7 +58,8 @@ class CardService: # ---- 소유 카드 탐색(어느 테이블인지 모를 때) ------------------------------ async def _find_owned(self, user_uuid: uuid.UUID, card_id: uuid.UUID): - """card_id 를 nego_cards → wild_cards 순으로 찾고 소유권 확인. + """card_id 를 nego_cards → wild_cards 순으로 찾고 접근권 확인. + 전체(공용, user_id NULL) 카드는 누구나 조회·수정·삭제 가능. 개인 카드는 소유자만. (ErrorType, model, pk_col, row, is_wildcard) 반환.""" err, row = await DB_SESSION_MNG.execute_lambda( nego_cards.DBType(), @@ -64,7 +67,7 @@ class CardService: 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: + if row.user_id is not None and 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 @@ -74,7 +77,7 @@ class CardService: 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: + if row.user_id is not None and 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 @@ -114,7 +117,21 @@ class CardService: 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] + page = merged[pg.skip : pg.skip + pg.size] + + # 작성자명 배치 조인 — 페이지 카드의 작성자 id를 모아 IN 쿼리 1회로 {id:name} 맵을 만들어 매핑. + # (공용 카드는 user_id=NULL → 맵에 없어 creator_name=None). 행마다 조회하지 않으므로 부하 없음. + author_ids = list({c.user_id for c in page if c.user_id is not None}) + if author_ids: + nm_err, name_map = await DB_SESSION_MNG.execute_lambda( + nego_cards.DBType(), + DBWRType.DB_READ.value, + lambda s: self.card_crud.user_name_map(s, author_ids), + ) + if nm_err == ErrorType.SUCCESS: + for c in page: + c.creator_name = name_map.get(c.user_id) + res.cards = page res.total_nego = total_n res.total_wild = total_w # 선택된 탭 기준 페이지네이션 총건수(전체=합산). @@ -134,6 +151,14 @@ class CardService: res.result.SetResult(err) return res res.card = self._wild_to_data(row) if is_wild else self._nego_to_data(row) + # 등록자명 — 공용(user_id NULL) 카드는 작성자 없음(None 유지). + if row.user_id is not None: + nm_err, name_map = await DB_SESSION_MNG.execute_lambda( + nego_cards.DBType(), DBWRType.DB_READ.value, + lambda s: self.card_crud.user_name_map(s, [row.user_id]), + ) + if nm_err == ErrorType.SUCCESS: + res.card.creator_name = name_map.get(row.user_id) return res # ---- 등록 ---------------------------------------------------------------- @@ -141,9 +166,11 @@ class CardService: res = Res_Card() user_uuid = uuid.UUID(user_id) is_wildcard = req.is_wildcard + # 전체(공용) 카드는 소유자 없이 저장(user_id NULL) → 모든 유저 목록에 노출. + owner_id = None if req.is_shared else user_uuid common = dict( - user_id=user_uuid, + user_id=owner_id, name=req.name, number=req.number, script=req.script, diff --git a/negodata/backend/services/item_service.py b/negodata/backend/services/item_service.py index 5dfd86d..684e2fe 100644 --- a/negodata/backend/services/item_service.py +++ b/negodata/backend/services/item_service.py @@ -2,6 +2,7 @@ import uuid from fastapi import Depends, UploadFile +from common.authz import is_owner_or_admin from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import items from common.enums import DBWRType, ErrorType @@ -56,6 +57,16 @@ class ItemService: res.result.SetResult(err_type) return res res.items = [ItemData.model_validate(r) for r in rows] + # 등록자명 배치 조인 — 페이지 상품의 user_id를 모아 IN 쿼리 1회로 {id:name} 맵을 만들어 매핑(행별 조회 아님). + author_ids = list({r.user_id for r in rows if r.user_id is not None}) + if author_ids: + nm_err, name_map = await DB_SESSION_MNG.execute_lambda( + items.DBType(), DBWRType.DB_READ.value, + lambda s: self.item_crud.user_name_map(s, author_ids), + ) + if nm_err == ErrorType.SUCCESS: + for d in res.items: + d.creator_name = name_map.get(d.user_id) res.total = total return res @@ -81,6 +92,13 @@ class ItemService: res.result.SetResult(err_type) return res res.item = ItemData.model_validate(item) + if item.user_id is not None: + nm_err, name_map = await DB_SESSION_MNG.execute_lambda( + items.DBType(), DBWRType.DB_READ.value, + lambda s: self.item_crud.user_name_map(s, [item.user_id]), + ) + if nm_err == ErrorType.SUCCESS: + res.item.creator_name = name_map.get(item.user_id) return res async def check_codes(self, company_id: str, codes: list) -> Res_CheckCodes: @@ -128,17 +146,22 @@ class ItemService: # 서버 기본값(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, req: Req_UpdateItem) -> Res_Item: + async def update_item(self, company_id: str, item_id: str, req: Req_UpdateItem, user_id=None, role=None) -> Res_Item: res = Res_Item() company_uuid = uuid.UUID(company_id) item_uuid = uuid.UUID(item_id) data = req.model_dump(exclude_unset=True) - # 소유권 확인 - err_type, _ = await self._fetch_owned(company_uuid, item_uuid) - if err_type != ErrorType.SUCCESS: + # 회사 스코프 확인 + err_type, item = await self._fetch_owned(company_uuid, item_uuid) + if err_type != ErrorType.SUCCESS or item is None: res.result.SetResult(err_type) return res + # 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정(user_id 미지정=내부 호출은 스킵). + if user_id is not None and not is_owner_or_admin(item.user_id, user_id, role): + res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN) + res.msg = "본인이 등록한 상품만 수정할 수 있습니다." + return res err_type = await DB_SESSION_MNG.execute_lambda_run( [items.DBType()], @@ -151,15 +174,20 @@ class ItemService: # 갱신 후 재조회 return await self.get_item(company_id, item_id) - async def delete_item(self, company_id: str, item_id: str) -> Res_DeleteItem: + async def delete_item(self, company_id: str, item_id: str, user_id=None, role=None) -> 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: + err_type, item = await self._fetch_owned(company_uuid, item_uuid) + if err_type != ErrorType.SUCCESS or item is None: res.result.SetResult(err_type) return res + # 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 삭제(user_id 미지정=내부 호출은 스킵). + if user_id is not None and not is_owner_or_admin(item.user_id, user_id, role): + res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN) + res.msg = "본인이 등록한 상품만 삭제할 수 있습니다." + return res err_type = await DB_SESSION_MNG.execute_lambda_run( [items.DBType()], diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index d876f7f..34fec7f 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -12,6 +12,7 @@ from common.anchoring import ( fetch_current_values, get_base_anchoring_value, ) +from common.authz import is_owner_or_admin from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import quotations, sessions, chats, versions, version_nego_cards, version_wild_cards from common.enums import CloseOutcome, CloseReason, DBWRType, ErrorType, NotificationType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus @@ -28,7 +29,6 @@ from router.v1.quotation.protocol import ( Req_CreateQuotation, Res_CreateQuotation, Res_DeleteQuotation, - Res_LastSupplierType, Res_NotifySessions, Res_Quotation, Res_QuotationCards, @@ -254,22 +254,6 @@ class QuotationService: res.quotation = QuotationData.model_validate(quotation) return res - async def get_last_supplier_type(self, supplier_id: str, company_id=None) -> Res_LastSupplierType: - """협력사의 직전 견적 supplier_type(견적생성 모달 프리필용). 이력 없으면 비워서 반환.""" - res = Res_LastSupplierType() - err_type, got = await DB_SESSION_MNG.execute_lambda( - quotations.DBType(), - DBWRType.DB_READ.value, - lambda s: self.quotation_crud.get_last_supplier_type(s, uuid.UUID(supplier_id), company_id), - ) - if err_type != ErrorType.SUCCESS: - res.result.SetResult(err_type) - return res - if got: - res.supplier_type = got[0] - res.qt_number = got[1] - return res - async def create_quotation(self, user_id: str, req: Req_CreateQuotation) -> Res_CreateQuotation: """[프론트] 신규 견적 생성. 요청값을 보정한 뒤 공통 빌더(_build_quotation)에 위임한다. 생성 성공 시 작성자에게 CREATED 알림(인박스).""" @@ -290,7 +274,6 @@ class QuotationService: manager_contact_number=req.manager_contact_number, memo=req.memo, md_price=req.md_price, - supplier_type=req.supplier_type, item_ids=req.item_ids, supplier_ids=req.supplier_ids, card_ids=req.card_ids, @@ -369,7 +352,6 @@ class QuotationService: manager_contact_number=original.manager_contact_number, memo=original.memo, md_price=original.md_price, - supplier_type=original.supplier_type, item_ids=item_ids, supplier_ids=list(supplier_ids), card_ids=[], # 새 버전 안 만듦(원본 version_id 재사용) @@ -382,7 +364,7 @@ class QuotationService: self, *, user_id: str, qt_setting_id, version_id, name: str, number: str, type_: int, status: int, round_: int, start_time, end_time, - manager_name, manager_email, manager_contact_number, memo, md_price, supplier_type, + manager_name, manager_email, manager_contact_number, memo, md_price, item_ids: list, supplier_ids: list, card_ids: list, mid_action: Optional[int] = None, # 낙찰 기준(견적 단위). 앵커링가<투찰가≤목표가 처리(AWARD/OPEN) over_action: Optional[int] = None, # 목표가<투찰가 처리(1:1 협상은 항상 OPEN) @@ -417,7 +399,7 @@ class QuotationService: fee = self.INTERNET_AVERAGE_FEE # 인터넷가 차감 수수료율(상수) margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율 # 앵커링가는 quotation_settings.anchoring_value(구 float 비율)를 더 이상 쓰지 않는다(앵커링 v1.2) — - # 칸(회사×협력사유형×가격구간)별 조정 anchoring_value(정수 ‰)로 계산한다. 아래 세션 생성부 ②. + # 칸(회사×상품-협력사 공급유형×가격구간)별 조정 anchoring_value(정수 ‰)로 계산한다. 아래 세션 생성부 ②. # 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다. # (quotation↔card 는 version → version_nego_cards/version_wild_cards 로 연결.) @@ -464,7 +446,6 @@ class QuotationService: manager_contact_number=manager_contact_number, memo=memo, md_price=md_price, - supplier_type=supplier_type, mid_action=mid_action, over_action=over_action, ) @@ -490,8 +471,8 @@ class QuotationService: res.result.SetResult(ErrorType.QUOTATION_TARGET_PRICE_UNAVAILABLE) return res - # ② 앵커가 산출 — 칸(items.company_id × quotations.supplier_type × 목표가 구간) anchoring_value 조회 후 - # 정수 연산으로 박제(앵커링 v1.2, 인수인계.md §1.3). 유형 미지정/조정 이력 없음/조회 실패는 + # ② 앵커가 산출 — 칸(items.company_id × supplier_items.supply_type × 목표가 구간) anchoring_value 조회 후 + # 정수 연산으로 박제(앵커링 v1.2, 인수인계.md §1.3). 매핑 미지정/조정 이력 없음/조회 실패는 # 정적 테이블 시작값 폴백 — 값 조회 때문에 견적 생성이 실패하지 않는다(규칙 6). _err, item_companies = await DB_SESSION_MNG.execute_lambda( quotations.DBType(), @@ -499,12 +480,19 @@ class QuotationService: lambda s: self.quotation_crud.get_item_companies(s, item_ids), ) item_companies = item_companies if _err == ErrorType.SUCCESS else {} + _err, supply_types = await DB_SESSION_MNG.execute_lambda( + quotations.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.get_supply_types(s, item_ids, supplier_ids), + ) + supply_types = supply_types if _err == ErrorType.SUCCESS else {} value_map = {} - if supplier_type in SAMPLEABLE_SUPPLIER_TYPES and item_companies: + sampleable_supply_types = sorted({t for t in supply_types.values() if t in SAMPLEABLE_SUPPLIER_TYPES}) + if sampleable_supply_types and item_companies: value_map = await DB_SESSION_MNG.execute_lambda( quotations.DBType(), DBWRType.DB_READ.value, - lambda s: fetch_current_values(s, list(set(item_companies.values())), supplier_type), + lambda s: fetch_current_values(s, list(set(item_companies.values())), sampleable_supply_types), ) session_objs = [] @@ -512,11 +500,12 @@ class QuotationService: tp = target_prices[iid] price_range = calc_price_range_index(tp) company = item_companies.get(iid) - value = value_map.get((company, price_range)) if company is not None else None - if value is None: - value = get_base_anchoring_value(price_range) - ap = calc_anchoring_price(tp, value) # 목표가×(1000−value)//1000 — float 곱셈 금지(1원 내림 정확성) for sid in supplier_ids: + supply_type = supply_types.get((iid, sid)) + value = value_map.get((company, supply_type, price_range)) if company is not None else None + if value is None: + value = get_base_anchoring_value(price_range) + ap = calc_anchoring_price(tp, value) # 목표가×(1000−value)//1000 — float 곱셈 금지(1원 내림 정확성) session_objs.append( sessions( session_id=uuid.uuid4(), @@ -678,7 +667,7 @@ class QuotationService: # 4) 전원 미응찰 → 개찰(미응찰). return await self._open(qt_uuid, original, CloseReason.OPEN_NOSHOW.value, "no_show") - async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list) -> Res_CreateQuotation: + async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list, user_id=None, role=None) -> Res_CreateQuotation: """[프론트] 마감된 견적을 골라 수동으로 다음 라운드를 생성한다. 크론/수동마감의 자동 재생성과 달리 사유·체인 한도 판정 없이, 프론트가 고른 공급사로 바로 만든다. 상품·기간·견적번호·카드버전은 원 견적에서 이어받는다(regenerate_next_round).""" @@ -689,6 +678,11 @@ class QuotationService: if err_type != ErrorType.SUCCESS or original is None: res.result.SetResult(err_type) return res + # 소유자 게이팅 — 본인 견적 또는 최고관리자만 재생성(user_id 미지정=내부 호출은 스킵). + if user_id is not None and not is_owner_or_admin(original.user_id, user_id, role): + res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN) + res.msg = "본인이 생성한 견적만 재생성할 수 있습니다." + return res # 마감된 견적만 재생성(진행 중인 라운드를 또 찍어 같은 번호가 동시에 살아있는 걸 막는다). if original.status != QuotationStatus.CLOSED.value: res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) @@ -711,29 +705,106 @@ class QuotationService: return await self.regenerate_next_round(qt_uuid, supplier_ids) - async def stop_quotation(self, qt_id: str, company_id=None) -> Res_Quotation: + async def stop_quotation(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_Quotation: """[프론트] 수동 견적마감. 크론과 똑같은 마감 판정(close_and_decide)을 탄다 (단독낙찰 확정 / 동가·미참여면 다음 라운드 재생성 / 거부·한도면 그냥 마감).""" res = Res_Quotation() qt_uuid = uuid.UUID(qt_id) # 존재 확인(+회사 가드) - err_type, _ = await self._fetch(qt_uuid, company_id) - if err_type != ErrorType.SUCCESS: + err_type, original = await self._fetch(qt_uuid, company_id) + if err_type != ErrorType.SUCCESS or original is None: res.result.SetResult(err_type) return res + # 소유자 게이팅 — 본인 견적 또는 최고관리자만 마감(user_id 미지정=내부 호출은 스킵). + if user_id is not None and not is_owner_or_admin(original.user_id, user_id, role): + res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN) + res.msg = "본인이 생성한 견적만 마감할 수 있습니다." + return res await self.close_and_decide(qt_uuid) return await self.get_quotation(qt_id, company_id) - async def delete_quotation(self, qt_id: str, company_id=None) -> Res_DeleteQuotation: + async def award_quotation(self, qt_id: str, company_id, user_id, role, winner_supplier_id) -> Res_Quotation: + """[프론트] 개찰(낙찰자 미정 마감) 견적을 담당자가 직접 낙찰 처리한다. + 투찰한 협상완료(DONE) 세션 중 고른 협력사를 낙찰자로 박고 close_reason 을 AWARDED 로 바꾼다(직접 낙찰). + 자동 낙찰(close_and_decide)과 결과 컬럼은 같되, 알림에 manual 플래그로 '직접 낙찰'임을 남긴다. + 권한: 본인이 생성한 견적만. 단 최고관리자(OWNER)는 회사 내 남의 견적도 낙찰할 수 있다.""" + res = Res_Quotation() + qt_uuid = uuid.UUID(qt_id) + sp_uuid = winner_supplier_id if isinstance(winner_supplier_id, uuid.UUID) else uuid.UUID(str(winner_supplier_id)) + + # 존재 확인(+회사 가드) — 남의 회사 견적은 NOT_FOUND. + err_type, original = await self._fetch(qt_uuid, company_id) + if err_type != ErrorType.SUCCESS or original is None: + res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND) + return res + + # 자기 견적만 직접 낙찰 — 최고관리자(OWNER)만 회사 내 남의 견적도 허용. 되돌릴 수 없는 낙찰이라 백엔드에서 강제한다. + if not is_owner_or_admin(original.user_id, user_id, role): + res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN) + res.msg = "본인이 생성한 견적만 낙찰할 수 있습니다." + return res + + # 개찰(마감·낙찰자 미정, close_reason ∈ OPEN_*)만 직접 낙찰 대상. 진행중/이미 낙찰은 거부. + if original.status != QuotationStatus.CLOSED.value or original.close_reason not in ( + CloseReason.OPEN_PRICE.value, CloseReason.OPEN_EQUAL.value, + CloseReason.OPEN_NOSHOW.value, CloseReason.OPEN_REJECT.value, + ): + res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) + res.msg = "개찰(낙찰자 미정) 상태의 견적만 직접 낙찰할 수 있습니다." + return res + + # 낙찰 후보 = 투찰한 협상완료(DONE) 세션. close_and_decide 와 같은 조회(list_sessions_status, + # 공급사 삭제돼도 포함되는 outerjoin)를 써서 자동낙찰과 후보 집합을 일치시킨다. + err_type, rows = await DB_SESSION_MNG.execute_lambda( + sessions.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.list_sessions_status(s, qt_uuid), + ) + rows = rows if err_type == ErrorType.SUCCESS else [] + winner = next( + (r for r in rows + if r.status == SessionStatus.DONE.value and r.bid_price is not None and r.supplier_id == sp_uuid), + None, + ) + if winner is None: + res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) + res.msg = "선택한 협력사는 이 견적의 낙찰 후보(투찰한 협상완료 협력사)가 아닙니다." + return res + + # [동시 직접낙찰 가드] 개찰→낙찰 원자 선점. 실제로 전이한 호출자만 통과(재클릭·경합 방어). + claim_err, claimed = await DB_SESSION_MNG.execute_lambda_claim( + quotations.DBType(), + lambda s: self.quotation_crud.claim_for_award(s, qt_uuid, sp_uuid, (winner.name or "")[:20]), + ) + if claim_err != ErrorType.SUCCESS or claimed == 0: + res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) + res.msg = "이미 낙찰 처리된 견적입니다." + return res + + # 작성자 알림 — 자동낙찰과 같은 SUCCESS 코드, manual 플래그로 '직접 낙찰' 구분. + await create_notification( + original.user_id, NotificationType.SUCCESS, + {"qt_name": original.name, "qt_number": original.number, + "winner_name": winner.name, "winner_price": winner.bid_price, "manual": True}, + ref_qt_id=qt_uuid, + ) + return await self.get_quotation(qt_id, company_id) + + async def delete_quotation(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_DeleteQuotation: res = Res_DeleteQuotation() qt_uuid = uuid.UUID(qt_id) - err_type, _ = await self._fetch(qt_uuid, company_id) - if err_type != ErrorType.SUCCESS: + err_type, original = await self._fetch(qt_uuid, company_id) + if err_type != ErrorType.SUCCESS or original is None: res.result.SetResult(err_type) return res + # 소유자 게이팅 — 본인 견적 또는 최고관리자만 삭제(user_id 미지정=내부 호출은 스킵). + if user_id is not None and not is_owner_or_admin(original.user_id, user_id, role): + res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN) + res.msg = "본인이 생성한 견적만 삭제할 수 있습니다." + return res err_type = await DB_SESSION_MNG.execute_lambda_run( [quotations.DBType()], @@ -815,15 +886,20 @@ class QuotationService: return res # ----- 협상 초청 메일 (수동 발송) - async def notify_sessions(self, qt_id: str, company_id=None) -> Res_NotifySessions: + async def notify_sessions(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_NotifySessions: """[수동 발송] 견적의 '미발송' 세션(공급사 담당자)에게 협상 초청 메일을 일괄 발송한다. 대상 = email_sent_at IS NULL + 담당자 이메일 보유.""" res = Res_NotifySessions() qt_uuid = uuid.UUID(qt_id) err_type, quotation = await self._fetch(qt_uuid, company_id) - if err_type != ErrorType.SUCCESS: + if err_type != ErrorType.SUCCESS or quotation is None: res.result.SetResult(err_type) return res + # 소유자 게이팅 — 본인 견적 또는 최고관리자만 초청메일 발송(user_id 미지정=내부 호출은 스킵). + if user_id is not None and not is_owner_or_admin(quotation.user_id, user_id, role): + res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN) + res.msg = "본인이 생성한 견적만 초청 메일을 발송할 수 있습니다." + return res err_type, rows = await DB_SESSION_MNG.execute_lambda( sessions.DBType(), @@ -851,7 +927,7 @@ class QuotationService: await self._mark_emailed(sent_ids) return res - async def notify_session(self, session_id: str, company_id=None) -> Res_NotifySessions: + async def notify_session(self, session_id: str, company_id=None, user_id=None, role=None) -> Res_NotifySessions: """[수동 재발송] 단일 세션(공급사)에 초청 메일 발송(이미 보냈어도 강제 재발송).""" res = Res_NotifySessions() sess_uuid = uuid.UUID(session_id) @@ -866,9 +942,14 @@ class QuotationService: sess, sp_name, email = got[0], got[1], got[2] res.total = 1 err_type, quotation = await self._fetch(sess.quotation_id, company_id) - if err_type != ErrorType.SUCCESS: + if err_type != ErrorType.SUCCESS or quotation is None: res.result.SetResult(err_type) return res + # 소유자 게이팅 — 본인 견적 또는 최고관리자만 재발송(user_id 미지정=내부 호출은 스킵). + if user_id is not None and not is_owner_or_admin(quotation.user_id, user_id, role): + res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN) + res.msg = "본인이 생성한 견적만 초청 메일을 발송할 수 있습니다." + return res if not email: res.skipped = 1 return res diff --git a/negodata/backend/services/statistics_service.py b/negodata/backend/services/statistics_service.py new file mode 100644 index 0000000..b612651 --- /dev/null +++ b/negodata/backend/services/statistics_service.py @@ -0,0 +1,193 @@ +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, QuotationType, SessionStatus, CardType, CloseReason +from common.utils.gtime import GTime +from crud.statistics_crud import StatisticsCRUD, IStatisticsCRUD +from router.v1.statistics.protocol import ( + Res_StatisticsSummary, + StatScope, + StatKpi, + StatMonthPoint, + StatOutcome, + StatParticipation, + StatTypeRow, + StatCategory, + StatCardUsage, +) + +WINDOW_MONTHS = 6 # 최근 6개월(당월 포함) 창 + + +class StatisticsService: + """통계(성과 분석) 집계. 회사 전체(company)와 내 견적(mine) 두 스코프를 한 응답으로 내린다. + + 전부 파생(저장 안 함) — 조회 때 sessions/items/chats 조인 집계한다. 읽기 전용. + 절감 원천은 '낙찰 세션'(preferred_sp_id) 이며, 여기서 총절감·추이·유형·카테고리·앵커도달률을 모두 파생한다. + """ + + def __init__(self, stat_crud: IStatisticsCRUD = Depends(StatisticsCRUD)): + self.stat_crud = stat_crud + + async def get_summary(self, company_id: str, user_id: str) -> Res_StatisticsSummary: + res = Res_StatisticsSummary() + company_uuid = uuid.UUID(company_id) + user_uuid = uuid.UUID(user_id) + labels, window_start = self._window(GTime.UTC()) + + res.company = await self._scope(company_uuid, None, labels, window_start) + res.mine = await self._scope(company_uuid, user_uuid, labels, window_start) + return res + + # ── 스코프 집계 ───────────────────────────────────────────── + async def _scope(self, company_uuid, owner_uuid, labels, since) -> StatScope: + scope = StatScope() + + win_rows = await self._read(lambda s: self.stat_crud.winning_sessions(s, company_uuid, owner_uuid, since)) + outcome_rows = await self._read(lambda s: self.stat_crud.outcome_counts(s, company_uuid, owner_uuid, since)) + type_rows = await self._read(lambda s: self.stat_crud.type_counts(s, company_uuid, owner_uuid, since)) + part_rows = await self._read(lambda s: self.stat_crud.participation_counts(s, company_uuid, owner_uuid, since)) + regen = await self._read_scalar(lambda s: self.stat_crud.regen_avg_round(s, company_uuid, owner_uuid, since)) + card_rows = await self._read(lambda s: self.stat_crud.card_usage(s, company_uuid, owner_uuid, since)) + + scope.trend = self._trend(win_rows, labels) + scope.categories = self._categories(win_rows) + scope.outcome = self._outcome(outcome_rows) + scope.participation = self._participation(part_rows) + scope.type_split = self._type_split(type_rows, win_rows) + scope.cards = self._cards(card_rows) + scope.kpi = self._kpi(win_rows, scope.trend, scope.outcome, regen) + return scope + + # ── 파생 계산 ─────────────────────────────────────────────── + def _kpi(self, win_rows, trend, outcome, regen) -> StatKpi: + k = StatKpi() + total_saving = sum(int(r.target_price) - int(r.bid_price) for r in win_rows) + total_target = sum(int(r.target_price) for r in win_rows) + k.total_savings = total_saving + k.savings_rate = (total_saving / total_target) if total_target else 0.0 + k.anchor_reach_rate = self._anchor_reach(win_rows) + + closed = outcome.awarded + outcome.open_price + outcome.open_equal + outcome.open_noshow + outcome.open_reject + k.closed_count = closed + k.award_rate = (outcome.awarded / closed) if closed else 0.0 + k.regen_avg_round = round(regen, 2) + # 전월 대비: 마지막 두 달 절감액 차(창에 2개월 미만이면 0). + k.savings_delta_mom = (trend[-1].savings - trend[-2].savings) if len(trend) >= 2 else 0 + return k + + def _anchor_reach(self, win_rows) -> float: + # (목표−투찰)/(목표−앵커), 앵커 있고 목표>앵커인 세션만 평균. + # 세션별로 [0,100%] 클램프 후 평균 — '도달률'이라 앵커 도달=100% 상한(앵커 뚫어도 100%로). + # (앵커 gap 이 작으면 원비율이 100% 훌쩍 넘어 평균이 왜곡되므로 캡한다.) + vals = [] + for r in win_rows: + if r.anchoring_price is None: + continue + target, bid, anchor = int(r.target_price), int(r.bid_price), int(r.anchoring_price) + span = target - anchor + if span > 0: + reach = (target - bid) / span + vals.append(min(1.0, max(0.0, reach))) + return (sum(vals) / len(vals)) if vals else 0.0 + + def _trend(self, win_rows, labels) -> list: + bucket = {m: {"savings": 0, "target": 0} for m in labels} + for r in win_rows: + m = f"{r.updated_at.year:04d}-{r.updated_at.month:02d}" + if m in bucket: + bucket[m]["savings"] += int(r.target_price) - int(r.bid_price) + bucket[m]["target"] += int(r.target_price) + out = [] + for m in labels: + b = bucket[m] + rate = (b["savings"] / b["target"]) if b["target"] else 0.0 + out.append(StatMonthPoint(month=m, savings=b["savings"], rate=rate)) + return out + + def _categories(self, win_rows) -> list: + # TODO: items.category 자유텍스트 그룹 — 표기 흔들리면 지저분. 카테고리 정규화(코드/테이블) 후 개선. + agg: dict = {} + for r in win_rows: + key = r.category or "미분류" + a = agg.setdefault(key, {"savings": 0, "count": 0}) + a["savings"] += int(r.target_price) - int(r.bid_price) + a["count"] += 1 + rows = [StatCategory(category=k, savings=v["savings"], count=v["count"]) for k, v in agg.items()] + rows.sort(key=lambda x: x.savings, reverse=True) + return rows + + def _outcome(self, outcome_rows) -> StatOutcome: + by = {int(cr): int(n) for cr, n in outcome_rows if cr is not None} + return StatOutcome( + awarded=by.get(CloseReason.AWARDED.value, 0), + open_price=by.get(CloseReason.OPEN_PRICE.value, 0), + open_equal=by.get(CloseReason.OPEN_EQUAL.value, 0), + open_noshow=by.get(CloseReason.OPEN_NOSHOW.value, 0), + open_reject=by.get(CloseReason.OPEN_REJECT.value, 0), + ) + + def _participation(self, part_rows) -> StatParticipation: + by = {int(st): int(n) for st, n in part_rows if st is not None} + return StatParticipation( + bid=by.get(SessionStatus.DONE.value, 0), + no_participate=by.get(SessionStatus.NOT_PARTICIPATED.value, 0), + rejected=by.get(SessionStatus.REJECTED.value, 0), + ) + + def _type_split(self, type_rows, win_rows) -> list: + # 4개 코드(협상 1·3 / 견적 2·4=1:N)를 2그룹으로 묶는다. 낙찰률·건수=type_counts, 평균절감=낙찰세션. + grp = {"nego": {"count": 0, "awarded": 0}, "auction": {"count": 0, "awarded": 0}} + for t, cnt, awarded in type_rows: + g = "auction" if QuotationType.is_auction(int(t)) else "nego" + grp[g]["count"] += int(cnt or 0) + grp[g]["awarded"] += int(awarded or 0) + + sav = {"nego": [], "auction": []} + for r in win_rows: + g = "auction" if QuotationType.is_auction(int(r.type)) else "nego" + sav[g].append(int(r.target_price) - int(r.bid_price)) + + out = [] + for g, label in (("nego", "협상 (1:1)"), ("auction", "견적 (1:N)")): + cnt = grp[g]["count"] + rate = (grp[g]["awarded"] / cnt) if cnt else 0.0 + avg = int(sum(sav[g]) / len(sav[g])) if sav[g] else 0 + out.append(StatTypeRow(label=label, award_rate=rate, avg_savings=avg, count=cnt)) + return out + + def _cards(self, card_rows) -> list: + by = {int(ct): int(n) for ct, n in card_rows if ct is not None} + return [ + StatCardUsage(type="nego", label="협상카드", uses=by.get(CardType.NEGO.value, 0), avg_drop=0), + StatCardUsage(type="wild", label="와일드카드", uses=by.get(CardType.WILD.value, 0), avg_drop=0), + ] + + # ── 창(최근 6개월) ───────────────────────────────────────── + def _window(self, now): + yy, mm = now.year, now.month + mm -= (WINDOW_MONTHS - 1) + while mm <= 0: + mm += 12 + yy -= 1 + window_start = now.replace(year=yy, month=mm, day=1, hour=0, minute=0, second=0, microsecond=0) + labels, ly, lm = [], yy, mm + for _ in range(WINDOW_MONTHS): + labels.append(f"{ly:04d}-{lm:02d}") + lm += 1 + if lm > 12: + lm = 1 + ly += 1 + return labels, window_start + + # ── DB 실행 헬퍼 ─────────────────────────────────────────── + async def _read(self, fn) -> list: + err, rows = await DB_SESSION_MNG.execute_lambda(quotations.DBType(), DBWRType.DB_READ.value, fn) + return rows if err == ErrorType.SUCCESS else [] + + async def _read_scalar(self, fn) -> float: + err, val = await DB_SESSION_MNG.execute_lambda(quotations.DBType(), DBWRType.DB_READ.value, fn) + return val if err == ErrorType.SUCCESS else 0.0 diff --git a/negodata/backend/services/supplier_service.py b/negodata/backend/services/supplier_service.py index 6b52403..d7705f0 100644 --- a/negodata/backend/services/supplier_service.py +++ b/negodata/backend/services/supplier_service.py @@ -51,6 +51,16 @@ class SupplierService: res.result.SetResult(err_type) return res res.suppliers = [SupplierData.model_validate(r) for r in rows] + # 등록자명 배치 조인 — 페이지 협력사의 user_id를 모아 IN 쿼리 1회로 {id:name} 맵을 만들어 매핑(행별 조회 아님). + author_ids = list({r.user_id for r in rows if r.user_id is not None}) + if author_ids: + nm_err, name_map = await DB_SESSION_MNG.execute_lambda( + suppliers.DBType(), DBWRType.DB_READ.value, + lambda s: self.supplier_crud.user_name_map(s, author_ids), + ) + if nm_err == ErrorType.SUCCESS: + for d in res.suppliers: + d.creator_name = name_map.get(d.user_id) res.total = total return res @@ -61,6 +71,13 @@ class SupplierService: res.result.SetResult(err_type) return res res.supplier = SupplierData.model_validate(supplier) + if supplier.user_id is not None: + nm_err, name_map = await DB_SESSION_MNG.execute_lambda( + suppliers.DBType(), DBWRType.DB_READ.value, + lambda s: self.supplier_crud.user_name_map(s, [supplier.user_id]), + ) + if nm_err == ErrorType.SUCCESS: + res.supplier.creator_name = name_map.get(supplier.user_id) return res async def check_codes(self, company_id: str, codes: list) -> Res_CheckCodes: diff --git a/negodata/backend/tests/test_card.py b/negodata/backend/tests/test_card.py new file mode 100644 index 0000000..2445001 --- /dev/null +++ b/negodata/backend/tests/test_card.py @@ -0,0 +1,65 @@ +"""card 도메인 스코프 e2e — 개인 카드는 소유자만, 전체(공용) 카드는 누구나. +스코프 규칙: user_id 있으면 개인(본인만 조회·관리) / NULL 이면 전체(모든 유저 조회·수정·삭제). 로그인은 auth_headers.""" + + +async def _create_card(client, headers, *, number, name="카드", is_shared=False, is_wildcard=False): + r = await client.post( + "/v1/card/create", + json={ + "is_wildcard": is_wildcard, + "is_shared": is_shared, + "name": name, + "number": number, + "script": "안녕하세요", + }, + headers=headers, + ) + body = r.json() + assert body["result"]["success"] is True, body + return body["card"]["nego_card_id"] + + +async def _list_numbers(client, headers): + r = await client.get("/v1/card/list", headers=headers) + return {c["number"] for c in r.json().get("cards", [])} + + +async def test_personal_card_is_owner_only(client, auth_headers): + """검증: user_id 가 박힌 개인 카드는 소유자 목록·단건조회에만 노출되고 타 유저에겐 숨는다. + 기대결과: A 목록엔 있고 B 목록엔 없음, B 의 단건 조회는 success=False(CARD_NOT_FOUND).""" + ha = await auth_headers("cardA") + hb = await auth_headers("cardB") + cid = await _create_card(client, ha, number="P-1", is_shared=False) + + assert "P-1" in await _list_numbers(client, ha) + assert "P-1" not in await _list_numbers(client, hb) + assert (await client.get(f"/v1/card/{cid}", headers=hb)).json()["result"]["success"] is False + + +async def test_shared_card_visible_to_all(client, auth_headers): + """검증: is_shared=True 카드는 user_id NULL 로 저장돼 모든 유저 목록·단건조회에 노출된다. + 기대결과: A·B 목록 모두에 존재, 비생성자 B 의 단건 조회 success=True, is_shared 플래그 True.""" + ha = await auth_headers("cardSA") + hb = await auth_headers("cardSB") + cid = await _create_card(client, ha, number="S-1", is_shared=True) + + assert "S-1" in await _list_numbers(client, ha) + assert "S-1" in await _list_numbers(client, hb) + got = await client.get(f"/v1/card/{cid}", headers=hb) + assert got.json()["result"]["success"] is True + assert got.json()["card"]["is_shared"] is True + + +async def test_shared_card_editable_and_deletable_by_anyone(client, auth_headers): + """검증: 전체(공용) 카드는 소유자가 없어 아무 유저나 수정·삭제 가능(정책: 누구나). + 기대결과: 비생성자 B 의 수정·삭제 모두 success=True, 삭제 후 목록에서 사라짐.""" + ha = await auth_headers("cardEA") + hb = await auth_headers("cardEB") + cid = await _create_card(client, ha, number="S-EDIT", is_shared=True) + + upd = await client.patch(f"/v1/card/update/{cid}", json={"name": "B가 수정"}, headers=hb) + assert upd.json()["result"]["success"] is True + + dele = await client.delete(f"/v1/card/delete/{cid}", headers=hb) + assert dele.json()["result"]["success"] is True + assert "S-EDIT" not in await _list_numbers(client, hb) diff --git a/negodata/backend/tests/test_ownership_gating.py b/negodata/backend/tests/test_ownership_gating.py new file mode 100644 index 0000000..6ebb2cd --- /dev/null +++ b/negodata/backend/tests/test_ownership_gating.py @@ -0,0 +1,122 @@ +"""소유자 게이팅 — 변경 액션은 '본인 소유' 또는 '최고관리자(OWNER)'만. 프론트 버튼 차단과 같은 규칙을 백엔드가 강제한다. + +- item(상품): 같은 회사의 다른 일반유저는 남의 상품을 수정·삭제 못 한다(OWNER는 가능). +- quotation(견적): 남의 견적 삭제는 비소유 일반유저 차단, OWNER 허용, user_id 미지정(내부 호출)은 스킵. +공용 판정은 common.authz.is_owner_or_admin — 여기 통과하면 다른 변경 액션(마감·재생성·초청메일)도 같은 규칙을 탄다. +""" +import uuid +from datetime import datetime + +from sqlalchemy import text + +from common.enums import ErrorType, QuotationStatus, QuotationType, UserRole +from crud.quotation_crud import QuotationCRUD +from services.quotation_service import QuotationService + +PAST = datetime(2020, 1, 1) + + +# ===== item(상품) — HTTP e2e: 같은 회사, 다른 유저 ===== +async def test_item_update_delete_blocked_for_non_owner(client, auth_headers): + """검증: A가 등록한 상품을 같은 회사의 다른 일반유저 B가 수정·삭제 시도. + 기대결과: 둘 다 거부(ACCOUNT_FORBIDDEN), 상품은 원값 그대로 남는다.""" + ha = await auth_headers("item_owner") # 소유자 + hb = await auth_headers("item_other") # 같은 회사, USER + item_id = (await client.post( + "/v1/item/create", json={"name": "상품A", "price": 1000, "code": "OWN1"}, headers=ha + )).json()["item"]["item_id"] + + r_upd = await client.patch(f"/v1/item/update/{item_id}", json={"price": 9999}, headers=hb) + assert r_upd.json()["result"]["success"] is False + assert r_upd.json()["result"]["code"] == ErrorType.ACCOUNT_FORBIDDEN.value + + r_del = await client.delete(f"/v1/item/delete/{item_id}", headers=hb) + assert r_del.json()["result"]["success"] is False + assert r_del.json()["result"]["code"] == ErrorType.ACCOUNT_FORBIDDEN.value + + # 수정·삭제 모두 무산 — 원값으로 조회된다 + assert (await client.get(f"/v1/item/{item_id}", headers=ha)).json()["item"]["price"] == 1000 + + +async def test_item_delete_allowed_for_owner_role(client, auth_headers): + """검증: A가 등록한 상품을 같은 회사 최고관리자(OWNER)가 삭제. + 기대결과: 성공 — 소유자가 아니어도 OWNER는 허용.""" + ha = await auth_headers("item_owner2") + hadmin = await auth_headers("item_admin", role=UserRole.OWNER.value) + item_id = (await client.post( + "/v1/item/create", json={"name": "상품B", "price": 500, "code": "OWN2"}, headers=ha + )).json()["item"]["item_id"] + + r = await client.delete(f"/v1/item/delete/{item_id}", headers=hadmin) + assert r.json()["result"]["success"] is True + + +# ===== quotation(견적) — 서비스 직접: 삭제 게이팅 ===== +async def test_quotation_delete_blocked_for_non_owner(db_engine): + """검증: 남의 견적을 비소유 일반유저(USER)가 삭제 시도. + 기대결과: 거부(ACCOUNT_FORBIDDEN) + soft-delete 안 됨(deleted=false).""" + owner, other = uuid.uuid4(), uuid.uuid4() + qt = await _seed_quotation(db_engine, user_id=owner, number="DEL-NONOWNER") + + res = await _service().delete_quotation(str(qt), None, other, UserRole.USER.value) + + assert res.result.success is False + assert res.result.code == ErrorType.ACCOUNT_FORBIDDEN.value + assert await _deleted(db_engine, qt) is False + + +async def test_quotation_delete_allowed_for_owner_role(db_engine): + """검증: 남의 견적을 최고관리자(OWNER)가 삭제. + 기대결과: 성공 + deleted=true.""" + creator, admin = uuid.uuid4(), uuid.uuid4() + qt = await _seed_quotation(db_engine, user_id=creator, number="DEL-OWNER") + + res = await _service().delete_quotation(str(qt), None, admin, UserRole.OWNER.value) + + assert res.result.success is True + assert await _deleted(db_engine, qt) is True + + +async def test_quotation_delete_skips_gate_for_internal_call(db_engine): + """검증: user_id 미지정(내부/스케줄러 호출)로 삭제. + 기대결과: 소유권 검사 스킵 → 성공(회사 스코프만 적용).""" + creator = uuid.uuid4() + qt = await _seed_quotation(db_engine, user_id=creator, number="DEL-INTERNAL") + + res = await _service().delete_quotation(str(qt), None) + + assert res.result.success is True + assert await _deleted(db_engine, qt) is True + + +# ===== 헬퍼 ===== +async def _seed_quotation(engine, *, user_id, number): + """견적 1건 시드(삭제 게이팅 확인용 — 상태는 무관하므로 CLOSED로 고정).""" + qt_id = uuid.uuid4() + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO quotations " + "(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, " + " round, iteration, start_time, end_time, deleted) VALUES " + "(:qt_id, :user_id, :qt_setting_id, :version_id, '견적', :number, :type, :status, " + " 1, 0, :t, :t, false)" + ), + { + "qt_id": qt_id, "user_id": user_id, "qt_setting_id": uuid.uuid4(), + "version_id": uuid.uuid4(), "number": number, "type": QuotationType.REQUOTE.value, + "status": QuotationStatus.CLOSED.value, "t": PAST, + }, + ) + return qt_id + + +async def _deleted(engine, qt_id) -> bool: + async with engine.begin() as conn: + return (await conn.execute( + text("SELECT deleted FROM quotations WHERE qt_id = :q"), {"q": qt_id} + )).scalar_one() + + +def _service(): + return QuotationService(QuotationCRUD()) diff --git a/negodata/backend/tests/test_quotation_anchoring.py b/negodata/backend/tests/test_quotation_anchoring.py index 7ef8947..54120d8 100644 --- a/negodata/backend/tests/test_quotation_anchoring.py +++ b/negodata/backend/tests/test_quotation_anchoring.py @@ -1,8 +1,8 @@ -"""앵커링 v1.2 — 견적 생성 시 칸(회사×협력사유형×가격구간) anchoring_value 로 앵커가를 박제하는지 검증. +"""앵커링 v1.2 — 견적 생성 시 칸(회사×상품-협력사 공급유형×가격구간) anchoring_value 로 앵커가를 박제하는지 검증. 이식 명세: schedules/anchoring/docs/인수인계.md §1. - 앵커가 = 목표가 × (1000 − anchoring_value) // 1000 (정수 연산), anchoring_value 동시 박제 -- 조정 이력 없음 / 유형 미지정 / anchoring 스키마 미적용 → 정적 테이블 시작값(10‰) 폴백, +- 조정 이력 없음 / 매핑 유형 미지정 / anchoring 스키마 미적용 → 정적 테이블 시작값(10‰) 폴백, 견적 생성은 실패하지 않는다(규칙 6) - 재생성 라운드는 목표가만 상속하고 앵커는 생성 시점 anchoring_value 로 재계산(규칙 1 — 상속 폐지) """ @@ -22,12 +22,12 @@ BASE_VALUE = 10 # 정적 테이블 시작값(‰) — anchoring_base.json 전 async def test_create_without_anchoring_schema_falls_back_to_base_value(db_engine, company_id): - """검증: anchoring 스키마가 아예 없는 DB 에서 supplier_type=1(유통) 견적 생성. + """검증: anchoring 스키마가 아예 없는 DB 에서 supply_type=1(유통) 매핑으로 견적 생성. 기대결과: 조회 실패에도 생성 성공 + 앵커가=목표가×990‰(시작값), anchoring_value=10 박제.""" await _drop_anchoring(db_engine) item = await _seed_item(db_engine, company_id, internet_lowest=100_000) - res = await _create(item_ids=[item], supplier_type=1) + res = await _create(db_engine, item_ids=[item], supply_type=1) assert res.result.success is True tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) # 92,200 @@ -45,7 +45,7 @@ async def test_create_uses_latest_adjusted_value_per_cell(db_engine, company_id) tp_miss = int(5_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) await _seed_adjustment(db_engine, company_id, supplier_type=1, price_range=calc_price_range_index(tp_hit), value_after=50) - res = await _create(item_ids=[item_hit, item_miss], supplier_type=1) + res = await _create(db_engine, item_ids=[item_hit, item_miss], supply_type=1) assert res.result.success is True rows = await _session_anchor_rows(db_engine, res.qt_id) @@ -53,15 +53,15 @@ async def test_create_uses_latest_adjusted_value_per_cell(db_engine, company_id) assert rows[item_miss] == (tp_miss, tp_miss * 990 // 1000, BASE_VALUE) -async def test_supplier_type_unset_uses_base_value(db_engine, company_id): - """검증: supplier_type 미지정(None) 견적 생성 — 칸(회사×유형×구간) 구성 불가. +async def test_supply_type_unset_uses_base_value(db_engine, company_id): + """검증: supply_type 미지정(None) 매핑으로 견적 생성 — 칸(회사×유형×구간) 구성 불가. 기대결과: 같은 회사·구간에 조정 이력이 있어도 쓰지 않고 시작값 10‰ 박제.""" await _reset_anchoring(db_engine) item = await _seed_item(db_engine, company_id, internet_lowest=100_000) tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) await _seed_adjustment(db_engine, company_id, supplier_type=1, price_range=calc_price_range_index(tp), value_after=50) - res = await _create(item_ids=[item], supplier_type=None) + res = await _create(db_engine, item_ids=[item], supply_type=None) assert res.result.success is True rows = await _session_anchor_rows(db_engine, res.qt_id) @@ -75,7 +75,7 @@ async def test_regenerate_inherits_target_but_recomputes_anchor(db_engine, compa item = await _seed_item(db_engine, company_id, internet_lowest=100_000) supplier = uuid.uuid4() - res1 = await _create(item_ids=[item], supplier_type=1, supplier_ids=[supplier]) + res1 = await _create(db_engine, item_ids=[item], supply_type=1, supplier_ids=[supplier]) assert res1.result.success is True tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) rows1 = await _session_anchor_rows(db_engine, res1.qt_id) @@ -108,20 +108,40 @@ def _service(): return QuotationService(QuotationCRUD()) -async def _create(*, item_ids, supplier_type, supplier_ids=None): - """supplier_type 을 지정해 견적 1건 생성(공급사 기본 1곳).""" +async def _create(engine, *, item_ids, supply_type, supplier_ids=None): + """supplier_items.supply_type 매핑을 시드한 뒤 견적 1건 생성(공급사 기본 1곳).""" + supplier_ids = supplier_ids or [uuid.uuid4()] + await _seed_supplier_items(engine, item_ids, supplier_ids, supply_type=supply_type) req = Req_CreateQuotation( qt_setting_id=uuid.uuid4(), # FK 미설정 — 세팅 없으면 율 0(앵커는 세팅과 무관해짐) name="앵커링검증", type=QuotationType.NEW_QUOTE.value, end_time=FUTURE, - supplier_type=supplier_type, item_ids=list(item_ids), - supplier_ids=supplier_ids or [uuid.uuid4()], + supplier_ids=supplier_ids, ) return await _service().create_quotation(str(uuid.uuid4()), req) +async def _seed_supplier_items(engine, item_ids, supplier_ids, *, supply_type): + async with engine.begin() as conn: + for item_id in item_ids: + for supplier_id in supplier_ids: + await conn.execute( + text( + "INSERT INTO supplier_items " + "(supplier_item_id, supplier_id, item_id, supply_type) " + "VALUES (:id, :supplier_id, :item_id, :supply_type)" + ), + { + "id": uuid.uuid4(), + "supplier_id": supplier_id, + "item_id": item_id, + "supply_type": supply_type if supply_type is not None else 0, + }, + ) + + async def _seed_item(engine, company_id, *, internet_lowest): """상품 1건 시드(인터넷최저가만). NOT NULL 컬럼은 명시(ORM default 는 raw INSERT 에 안 먹음).""" item_id = uuid.uuid4() diff --git a/negodata/backend/tests/test_quotation_award.py b/negodata/backend/tests/test_quotation_award.py new file mode 100644 index 0000000..ac61b8b --- /dev/null +++ b/negodata/backend/tests/test_quotation_award.py @@ -0,0 +1,206 @@ +"""개찰 견적 직접 낙찰(award_quotation) 테스트 — 담당자가 개찰(낙찰자 미정 마감) 견적의 낙찰자를 직접 지정. + +직접 낙찰은 자동 낙찰(close_and_decide)과 결과 컬럼은 같되(close_reason=AWARDED, preferred_sp_*), +알림에 manual 플래그로 '직접' 낙찰임을 남긴다. 다음을 본다: + · 개찰 + 투찰(DONE) 협력사 지정 → 낙찰 확정 + 알림 SUCCESS(manual=True) + · 개찰 아님(이미 낙찰) → 거부(INVALID_REQUEST_DATA), 알림 없음 + · 후보 아닌 협력사 지정 → 거부, close_reason 유지 + · 낙찰 후 재지정(재클릭) → 거부(동시성 가드), 알림 1건 유지 + +세션의 협상 결과(협상완료/입찰가)와 개찰 상태(close_reason)는 협상/마감에서만 생기는 값이라 SQL 로 직접 넣는다. +""" +import uuid +from datetime import datetime + +import pytest_asyncio +from sqlalchemy import text + +from common.enums import CloseReason, ErrorType, QuotationStatus, QuotationType, SessionStatus, UserRole +from crud.quotation_crud import QuotationCRUD +from services.quotation_service import QuotationService + +PAST = datetime(2020, 1, 1) + + +@pytest_asyncio.fixture +async def clean(db_engine): + """conftest 는 notifications 를 비우지 않는다 → 알림 단언이 다른 테스트에 안 흔들리게 여기서 함께 비운다.""" + async with db_engine.begin() as conn: + await conn.execute(text("TRUNCATE TABLE sessions, quotations, notifications RESTART IDENTITY CASCADE")) + return db_engine + + +async def test_award_opened_sets_winner_and_notifies(clean): + """검증: 개찰(동가) 견적 + 투찰 협력사 2건(A=100, B=120) 중 A 를 직접 낙찰. + 기대결과: close_reason→낙찰, preferred_sp=A, 동가플래그 해제 + 알림 SUCCESS(manual=True, winner_price=100).""" + engine = clean + user_id, supplier_a = uuid.uuid4(), uuid.uuid4() + qt = await _seed_opened(engine, user_id=user_id, number="A-WIN", close_reason=CloseReason.OPEN_EQUAL.value) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=120) + + res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a) + + assert res.result.success is True + row = await _quotation(engine, qt) + assert row.close_reason == CloseReason.AWARDED.value + assert row.preferred_sp_yn is True + assert str(row.preferred_sp_id) == str(supplier_a) + assert row.equal_bid_yn is False + + notis = await _notifications(engine, user_id) + assert len(notis) == 1 + type_, data, ref = notis[0] + assert type_ == 1 # NotificationType.SUCCESS + assert data["manual"] is True + assert data["winner_price"] == 100 + assert str(ref) == str(qt) + + +async def test_award_rejects_when_not_opened(clean): + """검증: 이미 낙찰된 견적(close_reason=AWARDED)에 직접 낙찰을 다시 시도. + 기대결과: 거부(INVALID_REQUEST_DATA) + 새 알림 없음.""" + engine = clean + user_id, supplier_a = uuid.uuid4(), uuid.uuid4() + qt = await _seed_opened(engine, user_id=user_id, number="A-DONE", close_reason=CloseReason.AWARDED.value) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a) + + res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a) + + assert res.result.success is False + assert res.result.code == ErrorType.INVALID_REQUEST_DATA.value + assert len(await _notifications(engine, user_id)) == 0 + + +async def test_award_rejects_unknown_supplier(clean): + """검증: 개찰 견적에, 투찰 후보가 아닌 협력사 id 를 지정. + 기대결과: 거부 + close_reason 은 개찰(OPEN_PRICE) 그대로 유지, 알림 없음.""" + engine = clean + user_id, bidder, stranger = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + qt = await _seed_opened(engine, user_id=user_id, number="A-STRANGER", close_reason=CloseReason.OPEN_PRICE.value) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=bidder) + + res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, stranger) + + assert res.result.success is False + row = await _quotation(engine, qt) + assert row.close_reason == CloseReason.OPEN_PRICE.value + assert row.preferred_sp_id is None + assert len(await _notifications(engine, user_id)) == 0 + + +async def test_award_is_idempotent(clean): + """검증: 직접 낙찰 성공 후 같은 견적에 재지정(재클릭/경합). + 기대결과: 2번째는 거부(이미 낙찰) + 알림은 1건만 유지(동시성 가드가 한 번만 통과).""" + engine = clean + user_id, supplier_a = uuid.uuid4(), uuid.uuid4() + qt = await _seed_opened(engine, user_id=user_id, number="A-IDEMP", close_reason=CloseReason.OPEN_REJECT.value) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a) + + first = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a) + second = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a) + + assert first.result.success is True + assert second.result.success is False + assert len(await _notifications(engine, user_id)) == 1 + + +async def test_award_rejects_non_owner(clean): + """검증: 남의 개찰 견적을 일반 유저(비소유·USER)가 직접 낙찰 시도. + 기대결과: 거부(ACCOUNT_FORBIDDEN) + close_reason 개찰 유지 + 알림 없음.""" + engine = clean + owner, other, supplier_a = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + qt = await _seed_opened(engine, user_id=owner, number="A-NONOWNER", close_reason=CloseReason.OPEN_PRICE.value) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a) + + res = await _service().award_quotation(str(qt), None, other, UserRole.USER.value, supplier_a) + + assert res.result.success is False + assert res.result.code == ErrorType.ACCOUNT_FORBIDDEN.value + row = await _quotation(engine, qt) + assert row.close_reason == CloseReason.OPEN_PRICE.value + assert row.preferred_sp_id is None + assert len(await _notifications(engine, owner)) == 0 + + +async def test_award_allows_owner_role(clean): + """검증: 남의 개찰 견적을 최고관리자(OWNER)가 직접 낙찰. + 기대결과: 낙찰 성공 + 알림은 견적 작성자(owner) 인박스에 남는다(호출자가 아니라).""" + engine = clean + creator, admin, supplier_a = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + qt = await _seed_opened(engine, user_id=creator, number="A-OWNER", close_reason=CloseReason.OPEN_EQUAL.value) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a) + + res = await _service().award_quotation(str(qt), None, admin, UserRole.OWNER.value, supplier_a) + + assert res.result.success is True + row = await _quotation(engine, qt) + assert row.close_reason == CloseReason.AWARDED.value + assert str(row.preferred_sp_id) == str(supplier_a) + assert len(await _notifications(engine, creator)) == 1 + assert len(await _notifications(engine, admin)) == 0 + + +# ===== 헬퍼 ===== +async def _seed_opened(engine, *, user_id, number, close_reason, round_=1): + """개찰/낙찰 상태(status=CLOSED + close_reason)로 견적 1건 시드. 낙찰자 컬럼은 비운 채 시작.""" + qt_id = uuid.uuid4() + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO quotations " + "(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, close_reason, " + " round, iteration, start_time, end_time, deleted) VALUES " + "(:qt_id, :user_id, :qt_setting_id, :version_id, '견적A', :number, :type, :status, :close_reason, " + " :round, 0, :start_time, :end_time, false)" + ), + { + "qt_id": qt_id, "user_id": user_id, "qt_setting_id": uuid.uuid4(), + "version_id": uuid.uuid4(), "number": number, "type": QuotationType.REQUOTE.value, + "status": QuotationStatus.CLOSED.value, "close_reason": close_reason, + "round": round_, "start_time": PAST, "end_time": PAST, + }, + ) + return qt_id + + +async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None): + """세션 1건 시드(공급사 협상 1건). status/bid_price 로 협상완료·입찰가를 만든다.""" + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO sessions " + "(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, " + " target_price, status, bid_price, end_time) VALUES " + "(:session_id, :quotation_id, :item_id, :supplier_id, 'Q', 1, :qt_type, " + " 0, :status, :bid_price, :end_time)" + ), + { + "session_id": uuid.uuid4(), "quotation_id": qt_id, "item_id": uuid.uuid4(), + "supplier_id": supplier_id or uuid.uuid4(), "qt_type": QuotationType.REQUOTE.value, + "status": status, "bid_price": bid_price, "end_time": PAST, + }, + ) + + +async def _quotation(engine, qt_id): + """견적 1행(마감 결과 컬럼 확인용).""" + async with engine.begin() as conn: + return (await conn.execute( + text("SELECT close_reason, preferred_sp_yn, preferred_sp_id, equal_bid_yn " + "FROM quotations WHERE qt_id = :qt_id"), + {"qt_id": qt_id}, + )).one() + + +async def _notifications(engine, user_id): + """user_id(작성자) 인박스 알림 (type, data, ref_qt_id) — 생성순.""" + async with engine.begin() as conn: + return (await conn.execute( + text("SELECT type, data, ref_qt_id FROM notifications WHERE user_id = :uid ORDER BY created_at"), + {"uid": user_id}, + )).all() + + +def _service(): + return QuotationService(QuotationCRUD()) diff --git a/negodata/front/package-lock.json b/negodata/front/package-lock.json index 996f37f..a7d25ad 100644 --- a/negodata/front/package-lock.json +++ b/negodata/front/package-lock.json @@ -25,6 +25,7 @@ "react-dom": "^19.0.1", "react-hook-form": "^7.79.0", "react-router": "^7.17.0", + "recharts": "^3.8.0", "shadcn": "^4.11.0", "slate": "^0.118.1", "slate-dom": "^0.119.0", @@ -2431,6 +2432,42 @@ } } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.11", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz", + "integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -2829,6 +2866,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@standard-schema/utils": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", @@ -3639,6 +3682,69 @@ "@types/node": "*" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/es-aggregate-error": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/es-aggregate-error/-/es-aggregate-error-1.0.6.tgz", @@ -3808,6 +3914,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -4699,6 +4811,127 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -4779,6 +5012,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -5249,6 +5488,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/es6-promise": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", @@ -5354,6 +5603,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -6319,6 +6574,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -8836,6 +9100,36 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-is": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -8979,6 +9273,67 @@ "node": ">= 4" } }, + "node_modules/recharts": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz", + "integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts/node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/recharts/node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -11043,6 +11398,28 @@ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", diff --git a/negodata/front/package.json b/negodata/front/package.json index 51ffdc0..6a2c03a 100644 --- a/negodata/front/package.json +++ b/negodata/front/package.json @@ -29,6 +29,7 @@ "react-dom": "^19.0.1", "react-hook-form": "^7.79.0", "react-router": "^7.17.0", + "recharts": "^3.8.0", "shadcn": "^4.11.0", "slate": "^0.118.1", "slate-dom": "^0.119.0", diff --git a/negodata/front/src/api/generated/model/cardData.ts b/negodata/front/src/api/generated/model/cardData.ts index ea26d31..9f1b37c 100644 --- a/negodata/front/src/api/generated/model/cardData.ts +++ b/negodata/front/src/api/generated/model/cardData.ts @@ -5,6 +5,7 @@ * OpenAPI spec version: 0.1.0 */ import type { CardDataUserId } from './cardDataUserId'; +import type { CardDataCreatorName } from './cardDataCreatorName'; import type { CardDataName } from './cardDataName'; import type { CardDataNumber } from './cardDataNumber'; import type { CardDataScript } from './cardDataScript'; @@ -20,6 +21,8 @@ export interface CardData { nego_card_id: string; user_id?: CardDataUserId; is_wildcard?: boolean; + is_shared?: boolean; + creator_name?: CardDataCreatorName; name?: CardDataName; number?: CardDataNumber; script?: CardDataScript; diff --git a/negodata/front/src/api/generated/model/resLastSupplierTypeMsg.ts b/negodata/front/src/api/generated/model/cardDataCreatorName.ts similarity index 70% rename from negodata/front/src/api/generated/model/resLastSupplierTypeMsg.ts rename to negodata/front/src/api/generated/model/cardDataCreatorName.ts index ee369fc..5da6efb 100644 --- a/negodata/front/src/api/generated/model/resLastSupplierTypeMsg.ts +++ b/negodata/front/src/api/generated/model/cardDataCreatorName.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ResLastSupplierTypeMsg = string | null; +export type CardDataCreatorName = string | null; diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 0a19228..65b0c7a 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -9,6 +9,7 @@ export * from './bodyUploadItemImageV1ItemImagePost'; export * from './cardData'; export * from './cardDataCondition'; export * from './cardDataCreatedAt'; +export * from './cardDataCreatorName'; export * from './cardDataEditScript'; export * from './cardDataMemo'; export * from './cardDataName'; @@ -53,6 +54,7 @@ export * from './itemData'; export * from './itemDataCategory'; export * from './itemDataCode'; export * from './itemDataCreatedAt'; +export * from './itemDataCreatorName'; export * from './itemDataDeliveryFeeYn'; export * from './itemDataDeliveryType'; export * from './itemDataImageUrl'; @@ -112,7 +114,6 @@ export * from './quotationDataOverAction'; export * from './quotationDataPreferredSpId'; export * from './quotationDataPreferredSpName'; export * from './quotationDataPreferredSpYn'; -export * from './quotationDataSupplierType'; export * from './quotationDataUpdatedAt'; export * from './quotationSettingData'; export * from './quotationSettingDataCreatedAt'; @@ -120,6 +121,7 @@ export * from './quotationSettingDataUpdatedAt'; export * from './quotationSettingDataUserId'; export * from './quotationStatus'; export * from './quotationType'; +export * from './reqAwardQuotation'; export * from './reqBulkMapByNames'; export * from './reqCheckCodes'; export * from './reqCreateCard'; @@ -158,7 +160,6 @@ export * from './reqCreateQuotationMidAction'; export * from './reqCreateQuotationOverAction'; export * from './reqCreateQuotationSetting'; export * from './reqCreateQuotationStartTime'; -export * from './reqCreateQuotationSupplierType'; export * from './reqCreateQuotationVersionId'; export * from './reqCreateSupplier'; export * from './reqCreateSupplierCode'; @@ -266,10 +267,6 @@ export * from './resItemListMsg'; export * from './resItemMsg'; export * from './resItemSupplyTypeList'; export * from './resItemSupplyTypeListMsg'; -export * from './resLastSupplierType'; -export * from './resLastSupplierTypeMsg'; -export * from './resLastSupplierTypeQtNumber'; -export * from './resLastSupplierTypeSupplierType'; export * from './resLogin'; export * from './resLoginMsg'; export * from './resLowestPriceResult'; @@ -319,6 +316,8 @@ export * from './resRefreshTokenMsg'; export * from './resSessionChat'; export * from './resSessionChatMsg'; export * from './resSessionChatSessionId'; +export * from './resStatisticsSummary'; +export * from './resStatisticsSummaryMsg'; export * from './resSupplier'; export * from './resSupplierItem'; export * from './resSupplierItemList'; @@ -348,9 +347,18 @@ export * from './sessionDataRejectDeliveryType'; export * from './sessionDataRejectPrice'; export * from './sessionDataRejectReason'; export * from './sessionStatus'; +export * from './statCardUsage'; +export * from './statCategory'; +export * from './statKpi'; +export * from './statMonthPoint'; +export * from './statOutcome'; +export * from './statParticipation'; +export * from './statScope'; +export * from './statTypeRow'; export * from './supplierData'; export * from './supplierDataCode'; export * from './supplierDataCreatedAt'; +export * from './supplierDataCreatorName'; export * from './supplierDataManagerContactNumber'; export * from './supplierDataManagerEmail'; export * from './supplierDataManagerName'; @@ -366,4 +374,4 @@ export * from './userRole'; export * from './userStatus'; export * from './validationError'; export * from './validationErrorCtx'; -export * from './validationErrorLocItem'; \ No newline at end of file +export * from './validationErrorLocItem'; diff --git a/negodata/front/src/api/generated/model/itemData.ts b/negodata/front/src/api/generated/model/itemData.ts index 1477144..c27d404 100644 --- a/negodata/front/src/api/generated/model/itemData.ts +++ b/negodata/front/src/api/generated/model/itemData.ts @@ -4,6 +4,7 @@ * Negodata Api Server * OpenAPI spec version: 0.1.0 */ +import type { ItemDataCreatorName } from './itemDataCreatorName'; import type { ItemDataCode } from './itemDataCode'; import type { ItemDataCategory } from './itemDataCategory'; import type { ItemDataImageUrl } from './itemDataImageUrl'; @@ -28,6 +29,7 @@ export interface ItemData { item_id: string; company_id: string; user_id: string; + creator_name?: ItemDataCreatorName; name: string; code?: ItemDataCode; category?: ItemDataCategory; diff --git a/negodata/front/src/api/generated/model/resLastSupplierTypeQtNumber.ts b/negodata/front/src/api/generated/model/itemDataCreatorName.ts similarity index 68% rename from negodata/front/src/api/generated/model/resLastSupplierTypeQtNumber.ts rename to negodata/front/src/api/generated/model/itemDataCreatorName.ts index 01ac822..a7c6eaa 100644 --- a/negodata/front/src/api/generated/model/resLastSupplierTypeQtNumber.ts +++ b/negodata/front/src/api/generated/model/itemDataCreatorName.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ResLastSupplierTypeQtNumber = string | null; +export type ItemDataCreatorName = string | null; diff --git a/negodata/front/src/api/generated/model/quotationData.ts b/negodata/front/src/api/generated/model/quotationData.ts index 3b3f5fd..cdfd361 100644 --- a/negodata/front/src/api/generated/model/quotationData.ts +++ b/negodata/front/src/api/generated/model/quotationData.ts @@ -11,7 +11,6 @@ import type { QuotationDataManagerEmail } from './quotationDataManagerEmail'; import type { QuotationDataManagerContactNumber } from './quotationDataManagerContactNumber'; import type { QuotationDataMemo } from './quotationDataMemo'; import type { QuotationDataMdPrice } from './quotationDataMdPrice'; -import type { QuotationDataSupplierType } from './quotationDataSupplierType'; import type { QuotationDataPreferredSpYn } from './quotationDataPreferredSpYn'; import type { QuotationDataPreferredSpId } from './quotationDataPreferredSpId'; import type { QuotationDataPreferredSpName } from './quotationDataPreferredSpName'; @@ -43,7 +42,6 @@ export interface QuotationData { manager_contact_number?: QuotationDataManagerContactNumber; memo?: QuotationDataMemo; md_price?: QuotationDataMdPrice; - supplier_type?: QuotationDataSupplierType; iteration?: number; preferred_sp_yn?: QuotationDataPreferredSpYn; preferred_sp_id?: QuotationDataPreferredSpId; diff --git a/negodata/front/src/api/generated/model/quotationDataSupplierType.ts b/negodata/front/src/api/generated/model/reqAwardQuotation.ts similarity index 52% rename from negodata/front/src/api/generated/model/quotationDataSupplierType.ts rename to negodata/front/src/api/generated/model/reqAwardQuotation.ts index 8f47105..0e85381 100644 --- a/negodata/front/src/api/generated/model/quotationDataSupplierType.ts +++ b/negodata/front/src/api/generated/model/reqAwardQuotation.ts @@ -4,6 +4,7 @@ * Negodata Api Server * OpenAPI spec version: 0.1.0 */ -import type { SupplierType } from './supplierType'; -export type QuotationDataSupplierType = SupplierType | null; +export interface ReqAwardQuotation { + winner_supplier_id: string; +} diff --git a/negodata/front/src/api/generated/model/reqCreateCard.ts b/negodata/front/src/api/generated/model/reqCreateCard.ts index 467e7da..46e2b63 100644 --- a/negodata/front/src/api/generated/model/reqCreateCard.ts +++ b/negodata/front/src/api/generated/model/reqCreateCard.ts @@ -13,6 +13,7 @@ import type { ReqCreateCardMemo } from './reqCreateCardMemo'; export interface ReqCreateCard { is_wildcard?: boolean; + is_shared?: boolean; name?: ReqCreateCardName; number?: ReqCreateCardNumber; script?: ReqCreateCardScript; diff --git a/negodata/front/src/api/generated/model/reqCreateQuotation.ts b/negodata/front/src/api/generated/model/reqCreateQuotation.ts index c750921..42b2b65 100644 --- a/negodata/front/src/api/generated/model/reqCreateQuotation.ts +++ b/negodata/front/src/api/generated/model/reqCreateQuotation.ts @@ -11,7 +11,6 @@ import type { ReqCreateQuotationManagerEmail } from './reqCreateQuotationManager import type { ReqCreateQuotationManagerContactNumber } from './reqCreateQuotationManagerContactNumber'; import type { ReqCreateQuotationMemo } from './reqCreateQuotationMemo'; import type { ReqCreateQuotationMdPrice } from './reqCreateQuotationMdPrice'; -import type { ReqCreateQuotationSupplierType } from './reqCreateQuotationSupplierType'; import type { ReqCreateQuotationMidAction } from './reqCreateQuotationMidAction'; import type { ReqCreateQuotationOverAction } from './reqCreateQuotationOverAction'; @@ -29,7 +28,6 @@ export interface ReqCreateQuotation { manager_contact_number?: ReqCreateQuotationManagerContactNumber; memo?: ReqCreateQuotationMemo; md_price?: ReqCreateQuotationMdPrice; - supplier_type?: ReqCreateQuotationSupplierType; item_ids?: string[]; supplier_ids?: string[]; card_ids?: string[]; diff --git a/negodata/front/src/api/generated/model/resLastSupplierType.ts b/negodata/front/src/api/generated/model/resLastSupplierType.ts deleted file mode 100644 index a6f3f29..0000000 --- a/negodata/front/src/api/generated/model/resLastSupplierType.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * 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 { ResLastSupplierTypeMsg } from './resLastSupplierTypeMsg'; -import type { ResLastSupplierTypeSupplierType } from './resLastSupplierTypeSupplierType'; -import type { ResLastSupplierTypeQtNumber } from './resLastSupplierTypeQtNumber'; - -export interface ResLastSupplierType { - result?: ErrorInfo; - msg?: ResLastSupplierTypeMsg; - supplier_type?: ResLastSupplierTypeSupplierType; - qt_number?: ResLastSupplierTypeQtNumber; -} diff --git a/negodata/front/src/api/generated/model/resLastSupplierTypeSupplierType.ts b/negodata/front/src/api/generated/model/resLastSupplierTypeSupplierType.ts deleted file mode 100644 index 313d621..0000000 --- a/negodata/front/src/api/generated/model/resLastSupplierTypeSupplierType.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * Negodata Api Server - * OpenAPI spec version: 0.1.0 - */ -import type { SupplierType } from './supplierType'; - -export type ResLastSupplierTypeSupplierType = SupplierType | null; diff --git a/negodata/front/src/api/generated/model/resStatisticsSummary.ts b/negodata/front/src/api/generated/model/resStatisticsSummary.ts new file mode 100644 index 0000000..b8a6e4f --- /dev/null +++ b/negodata/front/src/api/generated/model/resStatisticsSummary.ts @@ -0,0 +1,16 @@ +/** + * 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 { ResStatisticsSummaryMsg } from './resStatisticsSummaryMsg'; +import type { StatScope } from './statScope'; + +export interface ResStatisticsSummary { + result?: ErrorInfo; + msg?: ResStatisticsSummaryMsg; + company?: StatScope; + mine?: StatScope; +} diff --git a/negodata/front/src/api/generated/model/reqCreateQuotationSupplierType.ts b/negodata/front/src/api/generated/model/resStatisticsSummaryMsg.ts similarity index 67% rename from negodata/front/src/api/generated/model/reqCreateQuotationSupplierType.ts rename to negodata/front/src/api/generated/model/resStatisticsSummaryMsg.ts index 38bc008..0451d80 100644 --- a/negodata/front/src/api/generated/model/reqCreateQuotationSupplierType.ts +++ b/negodata/front/src/api/generated/model/resStatisticsSummaryMsg.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ReqCreateQuotationSupplierType = number | null; +export type ResStatisticsSummaryMsg = string | null; diff --git a/negodata/front/src/api/generated/model/statCardUsage.ts b/negodata/front/src/api/generated/model/statCardUsage.ts new file mode 100644 index 0000000..afed500 --- /dev/null +++ b/negodata/front/src/api/generated/model/statCardUsage.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export interface StatCardUsage { + type: string; + label: string; + uses?: number; + avg_drop?: number; +} diff --git a/negodata/front/src/api/generated/model/statCategory.ts b/negodata/front/src/api/generated/model/statCategory.ts new file mode 100644 index 0000000..490e143 --- /dev/null +++ b/negodata/front/src/api/generated/model/statCategory.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 StatCategory { + category: string; + savings?: number; + count?: number; +} diff --git a/negodata/front/src/api/generated/model/statKpi.ts b/negodata/front/src/api/generated/model/statKpi.ts new file mode 100644 index 0000000..df2e526 --- /dev/null +++ b/negodata/front/src/api/generated/model/statKpi.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export interface StatKpi { + total_savings?: number; + savings_rate?: number; + award_rate?: number; + anchor_reach_rate?: number; + savings_delta_mom?: number; + closed_count?: number; + regen_avg_round?: number; +} diff --git a/negodata/front/src/api/generated/model/statMonthPoint.ts b/negodata/front/src/api/generated/model/statMonthPoint.ts new file mode 100644 index 0000000..92d1647 --- /dev/null +++ b/negodata/front/src/api/generated/model/statMonthPoint.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 StatMonthPoint { + month: string; + savings?: number; + rate?: number; +} diff --git a/negodata/front/src/api/generated/model/statOutcome.ts b/negodata/front/src/api/generated/model/statOutcome.ts new file mode 100644 index 0000000..0ff3e71 --- /dev/null +++ b/negodata/front/src/api/generated/model/statOutcome.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export interface StatOutcome { + awarded?: number; + open_price?: number; + open_equal?: number; + open_noshow?: number; + open_reject?: number; +} diff --git a/negodata/front/src/api/generated/model/statParticipation.ts b/negodata/front/src/api/generated/model/statParticipation.ts new file mode 100644 index 0000000..0a2dc29 --- /dev/null +++ b/negodata/front/src/api/generated/model/statParticipation.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 StatParticipation { + bid?: number; + no_participate?: number; + rejected?: number; +} diff --git a/negodata/front/src/api/generated/model/statScope.ts b/negodata/front/src/api/generated/model/statScope.ts new file mode 100644 index 0000000..0d5a4af --- /dev/null +++ b/negodata/front/src/api/generated/model/statScope.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 { StatKpi } from './statKpi'; +import type { StatMonthPoint } from './statMonthPoint'; +import type { StatOutcome } from './statOutcome'; +import type { StatParticipation } from './statParticipation'; +import type { StatTypeRow } from './statTypeRow'; +import type { StatCategory } from './statCategory'; +import type { StatCardUsage } from './statCardUsage'; + +export interface StatScope { + kpi?: StatKpi; + trend?: StatMonthPoint[]; + outcome?: StatOutcome; + participation?: StatParticipation; + type_split?: StatTypeRow[]; + categories?: StatCategory[]; + cards?: StatCardUsage[]; +} diff --git a/negodata/front/src/api/generated/model/statTypeRow.ts b/negodata/front/src/api/generated/model/statTypeRow.ts new file mode 100644 index 0000000..d36b801 --- /dev/null +++ b/negodata/front/src/api/generated/model/statTypeRow.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export interface StatTypeRow { + label: string; + award_rate?: number; + avg_savings?: number; + count?: number; +} diff --git a/negodata/front/src/api/generated/model/supplierData.ts b/negodata/front/src/api/generated/model/supplierData.ts index 6da4f05..a28220c 100644 --- a/negodata/front/src/api/generated/model/supplierData.ts +++ b/negodata/front/src/api/generated/model/supplierData.ts @@ -4,6 +4,7 @@ * Negodata Api Server * OpenAPI spec version: 0.1.0 */ +import type { SupplierDataCreatorName } from './supplierDataCreatorName'; import type { SupplierDataCode } from './supplierDataCode'; import type { SupplierDataManagerName } from './supplierDataManagerName'; import type { SupplierDataManagerEmail } from './supplierDataManagerEmail'; @@ -16,6 +17,7 @@ export interface SupplierData { supplier_id: string; company_id: string; user_id: string; + creator_name?: SupplierDataCreatorName; name: string; code?: SupplierDataCode; manager_name?: SupplierDataManagerName; diff --git a/negodata/front/src/api/generated/model/supplierDataCreatorName.ts b/negodata/front/src/api/generated/model/supplierDataCreatorName.ts new file mode 100644 index 0000000..031aa49 --- /dev/null +++ b/negodata/front/src/api/generated/model/supplierDataCreatorName.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 SupplierDataCreatorName = string | null; diff --git a/negodata/front/src/api/generated/model/supplierType.ts b/negodata/front/src/api/generated/model/supplierType.ts index 38cc6ca..fe44d31 100644 --- a/negodata/front/src/api/generated/model/supplierType.ts +++ b/negodata/front/src/api/generated/model/supplierType.ts @@ -6,8 +6,8 @@ */ /** - * quotations.supplier_type 코드값. 없음(0,미지정)/유통(1)/제조(2)/총판(3). -없음은 프론트 폼에 '없음'으로 노출. KTC 앵커링 코드(기타=0)와 매핑 시 0↔없음 대응. + * partner.supplier_items.supply_type 코드값. 없음(0,미지정)/유통(1)/제조(2)/총판(3). +없음은 상품-협력사 매핑에서 유형 미지정 상태를 뜻한다. */ export type SupplierType = typeof SupplierType[keyof typeof SupplierType]; diff --git a/negodata/front/src/api/generated/quotation/quotation.ts b/negodata/front/src/api/generated/quotation/quotation.ts index a0a91a2..dc357b6 100644 --- a/negodata/front/src/api/generated/quotation/quotation.ts +++ b/negodata/front/src/api/generated/quotation/quotation.ts @@ -26,11 +26,11 @@ import type { import type { HTTPValidationError, ListQuotationsParams, + ReqAwardQuotation, ReqCreateQuotation, ReqRegenerateQuotation, ResCreateQuotation, ResDeleteQuotation, - ResLastSupplierType, ResNotifySessions, ResQuotation, ResQuotationCards, @@ -269,6 +269,71 @@ export const useStopQuotation = ,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/quotation/award/${qtId}`, method: 'POST', + headers: {'Content-Type': 'application/json', }, + data: reqAwardQuotation, signal + }, + options); + } + + + +export const getAwardQuotationMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{qtId: string;data: ReqAwardQuotation}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{qtId: string;data: ReqAwardQuotation}, TContext> => { + +const mutationKey = ['awardQuotation']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {qtId: string;data: ReqAwardQuotation}> = (props) => { + const {qtId,data} = props ?? {}; + + return awardQuotation(qtId,data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type AwardQuotationMutationResult = NonNullable>> + export type AwardQuotationMutationBody = ReqAwardQuotation + export type AwardQuotationMutationError = void | HTTPValidationError + + /** + * @summary 개찰 견적 직접 낙찰 + */ +export const useAwardQuotation = (options?: { mutation?:UseMutationOptions>, TError,{qtId: string;data: ReqAwardQuotation}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {qtId: string;data: ReqAwardQuotation}, + TContext + > => { + + const mutationOptions = getAwardQuotationMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** * @summary 견적 재생성(다음 라운드) */ export const regenerateQuotation = ( @@ -1070,96 +1135,6 @@ export const useDeleteQuotation = ,signal?: AbortSignal -) => { - - - return customFetch( - {url: `/v1/quotation/supplier/${supplierId}/last-type`, method: 'GET', signal - }, - options); - } - - - - -export const getGetSupplierLastTypeQueryKey = (supplierId?: string,) => { - return [ - `/v1/quotation/supplier/${supplierId}/last-type` - ] as const; - } - - -export const getGetSupplierLastTypeQueryOptions = >, TError = void | HTTPValidationError>(supplierId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} -) => { - -const {query: queryOptions, request: requestOptions} = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getGetSupplierLastTypeQueryKey(supplierId); - - - - const queryFn: QueryFunction>> = ({ signal }) => getSupplierLastType(supplierId, requestOptions, signal); - - - - - - return { queryKey, queryFn, enabled: !!(supplierId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } -} - -export type GetSupplierLastTypeQueryResult = NonNullable>> -export type GetSupplierLastTypeQueryError = void | HTTPValidationError - - -export function useGetSupplierLastType>, TError = void | HTTPValidationError>( - supplierId: string, options: { query:Partial>, TError, TData>> & Pick< - DefinedInitialDataOptions< - Awaited>, - TError, - Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): DefinedUseQueryResult & { queryKey: DataTag } -export function useGetSupplierLastType>, TError = void | HTTPValidationError>( - supplierId: string, options?: { query?:Partial>, TError, TData>> & Pick< - UndefinedInitialDataOptions< - Awaited>, - TError, - Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } -export function useGetSupplierLastType>, TError = void | HTTPValidationError>( - supplierId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } -/** - * @summary 협력사 직전 견적 유형 - */ - -export function useGetSupplierLastType>, TError = void | HTTPValidationError>( - supplierId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } { - - const queryOptions = getGetSupplierLastTypeQueryOptions(supplierId,options) - - const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; - - query.queryKey = queryOptions.queryKey ; - - return query; -} - - /** @@ -1253,4 +1228,3 @@ export function useGetQuotation> - diff --git a/negodata/front/src/api/generated/statistics/statistics.ts b/negodata/front/src/api/generated/statistics/statistics.ts new file mode 100644 index 0000000..81ed0ab --- /dev/null +++ b/negodata/front/src/api/generated/statistics/statistics.ts @@ -0,0 +1,124 @@ +/** + * 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 { + ResStatisticsSummary +} from '.././model'; + +import { customFetch } from '../../mutator/custom-fetch'; + + +type SecondParameter unknown> = Parameters[1]; + + + +/** + * @summary 통계 요약(회사 전체 + 내 견적) + */ +export const getStatisticsSummary = ( + + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/statistics/summary`, method: 'GET', signal + }, + options); + } + + + + +export const getGetStatisticsSummaryQueryKey = () => { + return [ + `/v1/statistics/summary` + ] as const; + } + + +export const getGetStatisticsSummaryQueryOptions = >, TError = void>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetStatisticsSummaryQueryKey(); + + + + const queryFn: QueryFunction>> = ({ signal }) => getStatisticsSummary(requestOptions, signal); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetStatisticsSummaryQueryResult = NonNullable>> +export type GetStatisticsSummaryQueryError = void + + +export function useGetStatisticsSummary>, TError = void>( + options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetStatisticsSummary>, TError = void>( + options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetStatisticsSummary>, TError = void>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 통계 요약(회사 전체 + 내 견적) + */ + +export function useGetStatisticsSummary>, TError = void>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetStatisticsSummaryQueryOptions(options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + diff --git a/negodata/front/src/app/router.tsx b/negodata/front/src/app/router.tsx index b19501d..72c15cd 100644 --- a/negodata/front/src/app/router.tsx +++ b/negodata/front/src/app/router.tsx @@ -4,6 +4,7 @@ import {isLoggedIn, hasRole} from '../stores/auth'; import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout'; import LoginPage from '../pages/login'; import DashboardPage from '../pages/dashboard'; +import StatisticsPage from '../pages/statistics'; import ForbiddenPage from '../pages/forbidden'; import NotFoundPage from '../pages/not-found'; import ProductsPage from '../pages/products'; @@ -56,6 +57,7 @@ export const router = createBrowserRouter([ Component: AuthenticatedLayout, children: [ {path: 'dashboard', Component: DashboardPage}, + {path: 'statistics', Component: StatisticsPage}, {path: 'products', Component: ProductsPage}, {path: 'partners', Component: PartnersPage}, {path: 'quotation', Component: QuotationPage}, diff --git a/negodata/front/src/components/layout/AuthenticatedLayout.tsx b/negodata/front/src/components/layout/AuthenticatedLayout.tsx index 9bd8977..7c40180 100644 --- a/negodata/front/src/components/layout/AuthenticatedLayout.tsx +++ b/negodata/front/src/components/layout/AuthenticatedLayout.tsx @@ -6,6 +6,7 @@ import {showToast} from '@/lib/notify'; const PAGE_TO_PATH: Record = { DASHBOARD: '/dashboard', + STATISTICS: '/statistics', PRODUCTS: '/products', PARTNERS: '/partners', QUOTATION: '/quotation', diff --git a/negodata/front/src/components/layout/Layout.tsx b/negodata/front/src/components/layout/Layout.tsx index 57bec99..e9589ab 100644 --- a/negodata/front/src/components/layout/Layout.tsx +++ b/negodata/front/src/components/layout/Layout.tsx @@ -9,6 +9,7 @@ import { cn } from '@/lib/utils'; import { NotificationBell } from './NotificationBell'; import { LayoutDashboard, + BarChart3, Briefcase, Users, UserCog, @@ -36,6 +37,7 @@ type SidebarUser = ReturnType['user']; // ownerOnly 항목은 최고관리자에게만 노출된다(렌더 시 user.role 로 필터). const menuItems: { type: PageType; label: string; icon: ElementType; id: string; ownerOnly?: boolean }[] = [ { type: 'DASHBOARD', label: '대시보드', icon: LayoutDashboard, id: 'sidebar-dashboard' }, + { type: 'STATISTICS', label: '통계', icon: BarChart3, id: 'sidebar-statistics' }, { type: 'PRODUCTS', label: '상품관리', icon: Briefcase, id: 'sidebar-products' }, { type: 'PARTNERS', label: '협력사관리', icon: Users, id: 'sidebar-partners' }, { type: 'QUOTATION', label: '견적관리', icon: FileSpreadsheet, id: 'sidebar-quotation' }, @@ -45,6 +47,7 @@ const menuItems: { type: PageType; label: string; icon: ElementType; id: string; const pageLabelMap: Record = { DASHBOARD: '대시보드', + STATISTICS: '통계', PRODUCTS: '상품관리', PARTNERS: '협력사관리', QUOTATION: '견적관리', diff --git a/negodata/front/src/components/ui/chart.tsx b/negodata/front/src/components/ui/chart.tsx new file mode 100644 index 0000000..c2351e3 --- /dev/null +++ b/negodata/front/src/components/ui/chart.tsx @@ -0,0 +1,281 @@ +import * as React from 'react'; +import * as RechartsPrimitive from 'recharts'; + +import { cn } from '@/lib/utils'; + +// shadcn Chart 래퍼 (Recharts 기반). CLI 대화형 프롬프트 회피 위해 직접 작성. +// config 의 각 키 색을 [data-chart] 스코프의 --color- CSS 변수로 주입 → 다크모드/토큰 일관. +const THEMES = { light: '', dark: '.dark' } as const; + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ); +}; + +type ChartContextProps = { config: ChartConfig }; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + if (!context) { + throw new Error('useChart must be used within a '); + } + return context; +} + +function ChartContainer({ + id, + className, + children, + config, + ...props +}: React.ComponentProps<'div'> & { + config: ChartConfig; + children: React.ComponentProps['children']; +}) { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`; + + return ( + +
+ + {children} +
+
+ ); +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter(([, c]) => c.theme || c.color); + if (!colorConfig.length) return null; + + return ( +