diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index ce9c570..4d989c2 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -193,6 +193,10 @@ class quotation_settings(MainTableMixin, MAIN_BASE): target_margin_rate = Column(Numeric(8, 6), nullable=False) anchoring_value = Column(Numeric(8, 6), nullable=False, default=0.01) card_count = Column(Integer, nullable=False, default=3) + # 마감 가격정책. 투찰가≤앵커링가는 항상 낙찰(설정없음). 아래 두 구간만 회사가 정한다. 기본 AWARD=현재 '무조건 낙찰' 동작(하위호환). + mid_action = Column(SmallInteger, nullable=False, default=1) # PriceGateAction: 앵커링가<투찰가≤목표가 처리(1=낙찰/2=재협상/3=유찰) + over_action = Column(SmallInteger, nullable=False, default=1) # PriceGateAction: 목표가<투찰가 처리(1=낙찰/2=재협상/3=유찰) + regen_limit = Column(SmallInteger, nullable=False, default=1) # 재생성 최대 횟수(체인 전체 총합, 사유 무관: 목표초과/동가/미참여 합산) class quotations(MainTableMixin, MAIN_BASE): @@ -225,6 +229,7 @@ class quotations(MainTableMixin, MAIN_BASE): preferred_sp_name = Column(String(20), nullable=True) equal_bid_yn = Column(Boolean, nullable=True) equal_bid_data = Column(JSONB, nullable=True) + close_reason = Column(SmallInteger, nullable=True) # CloseReason 코드. 마감 시 사유 기록(재생성 한도 카운팅·유찰 사유 구분). 미마감이면 NULL class sessions(MainTableMixin, MAIN_BASE): diff --git a/negodata/backend/common/enums.py b/negodata/backend/common/enums.py index 64c8e70..8fcabfb 100644 --- a/negodata/backend/common/enums.py +++ b/negodata/backend/common/enums.py @@ -171,6 +171,29 @@ class CloseOutcome(Enum): REGEN_FAILED = "regen_failed" # 재생성 시도했으나 실패 — 원본은 CLOSED 인데 다음 라운드가 없음(체인 끊김, 모니터링 필요) +class CloseReason(CodeEnum): + """quotations.close_reason 코드값(SMALLINT). 마감 사유 — 재생성 한도 카운팅(REGEN_*)과 유찰 사유 구분에 쓴다. + 기존 preferred_sp_yn/equal_bid_yn 2플래그로는 4상태만 표현돼 '목표초과 재협상'이 미참여와 충돌하고 유찰 사유가 뭉개짐 → 이 컬럼으로 명시.""" + + AWARDED = 1 # 단독 낙찰 + REGEN_PRICE = 2 # 가격 사유 재협상 (단독 최저가가 가격게이트 초과 → 낙찰 대신 다음 라운드로 더 깎기. 대표: 목표초과 재협상) + REGEN_EQUAL = 3 # 동가 재입찰 + REGEN_NOSHOW = 4 # 미참여 재소집 + FAIL_PRICE = 5 # 가격 사유 유찰 (가격게이트 초과인데 재협상 안 함/한도 소진) + FAIL_EQUAL = 6 # 동가 유찰 + FAIL_NOSHOW = 7 # 미참여 유찰 (한도 소진) + FAIL_REJECT = 8 # 거부 유찰 (협상거부 존재) + + +class PriceGateAction(CodeEnum): + """quotation_settings 의 가격 구간별 처리 정책. '앵커링가<투찰가≤목표가'(mid) / '목표가<투찰가'(over) 구간에 적용. + (투찰가≤앵커링가 는 항상 낙찰이라 설정 없음.)""" + + AWARD = 1 # 낙찰 + RENEGO = 2 # 재협상(다음 라운드 재생성) + FAIL = 3 # 유찰 + + class NotificationType(CodeEnum): """company.notifications.type 코드값. 견적 생애 이벤트를 작성자에게 통지. 마감 결과 3종(SUCCESS/REGENERATED/FAILURE)은 close_and_decide 와 1:1. 네이밍은 KTC.""" diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py index 83d3cc1..eab039f 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -117,7 +117,7 @@ class IQuotationCRUD(ABC): pass @abstractmethod - async def list_chain_close_flags(self, cdb: AsyncSession, number, current_round) -> Tuple[ErrorType, list]: + async def list_chain_close_reasons(self, cdb: AsyncSession, number, current_round) -> Tuple[ErrorType, list]: pass @abstractmethod @@ -405,11 +405,15 @@ class QuotationCRUD(IQuotationCRUD): return ErrorType.DB_RUN_FAILED, None async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]: - """견적 세팅의 율: {margin, anchoring}. 목표가·앵커링가 산정 입력. (인터넷 수수료는 상수)""" + """견적 세팅의 율+마감정책: {margin, anchoring, mid_action, over_action, regen_limit}. + 목표가·앵커링가 산정 입력 + 마감 가격게이트 정책. (인터넷 수수료는 상수)""" try: query = select( quotation_settings.target_margin_rate, quotation_settings.anchoring_value, + quotation_settings.mid_action, + quotation_settings.over_action, + quotation_settings.regen_limit, ).where(quotation_settings.qt_setting_id == qt_setting_id).limit(1) err_type, rows = await DB_SESSION_MNG.execute(cdb, query) if err_type != ErrorType.SUCCESS: @@ -420,6 +424,9 @@ class QuotationCRUD(IQuotationCRUD): return ErrorType.SUCCESS, { "margin": float(r[0]) if r[0] is not None else None, "anchoring": float(r[1]) if r[1] is not None else None, + "mid_action": r[2], + "over_action": r[3], + "regen_limit": r[4], } except Exception as ex: LOG.e_no_callstack(ex) @@ -554,15 +561,12 @@ class QuotationCRUD(IQuotationCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, [] - async def list_chain_close_flags(self, cdb: AsyncSession, number, current_round) -> Tuple[ErrorType, list]: - """[재생성 한도] 같은 견적번호(체인)의 이전 라운드(round < current_round)들의 (preferred_sp_yn, equal_bid_yn) 목록. 삭제 제외. - 마감 사유 식별용 표식: - - equal_bid_yn=True → 동가 재생성 - - preferred_sp_yn=False AND equal_bid_yn=False → 미참여 재생성 - - preferred_sp_yn=True → 단독낙찰(체인 어느 쪽에도 안 셈) - - 둘 다 NULL → 거부/한도 그냥 마감(안 셈)""" + async def list_chain_close_reasons(self, cdb: AsyncSession, number, current_round) -> Tuple[ErrorType, list]: + """[재생성 한도] 같은 견적번호(체인)의 이전 라운드(round < current_round)들의 close_reason 코드 목록. 삭제 제외. + _chain_regen_counts 가 REGEN_* 값(목표초과재협상/동가재입찰/미참여재소집)만 사유별로 센다. + 낙찰(AWARDED)·유찰(FAIL_*)·미마감(NULL)은 재생성 한도에 안 셈.""" try: - query = select(quotations.preferred_sp_yn, quotations.equal_bid_yn).where( + query = select(quotations.close_reason).where( quotations.number == number, quotations.round < current_round, quotations.deleted == False, # noqa: E712 @@ -570,7 +574,8 @@ class QuotationCRUD(IQuotationCRUD): err_type, rows = await DB_SESSION_MNG.execute(cdb, query) if err_type != ErrorType.SUCCESS: return err_type, [] - return ErrorType.SUCCESS, list(rows) + # 단일 컬럼 select → 각 행이 스칼라(close_reason 코드 or None) + return ErrorType.SUCCESS, [r for r in rows] except Exception as ex: LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, [] @@ -594,11 +599,13 @@ class QuotationCRUD(IQuotationCRUD): return ErrorType.DB_RUN_FAILED, 0 async def list_sessions_status(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]: - """[마감 판정] 견적의 모든 세션 → (status, supplier_id, bid_price, supplier_name). 삭제 제외. - 공급사가 지워졌어도 세션 집계엔 포함되도록 outerjoin(이때 name 은 None).""" + """[마감 판정] 견적의 모든 세션 → (status, supplier_id, bid_price, name, target_price, target_anchoring_price). 삭제 제외. + 공급사가 지워졌어도 세션 집계엔 포함되도록 outerjoin(이때 name 은 None). + target/anchoring 은 마감 가격게이트 입력(견적당 상품 1개라 세션 공통값).""" try: query = ( - select(sessions.status, sessions.supplier_id, sessions.bid_price, suppliers.name) + select(sessions.status, sessions.supplier_id, sessions.bid_price, suppliers.name, + sessions.target_price, sessions.target_anchoring_price) .outerjoin(suppliers, suppliers.supplier_id == sessions.supplier_id) .where(sessions.quotation_id == qt_id, sessions.deleted == False) # noqa: E712 ) diff --git a/negodata/backend/router/v1/quotation/protocol.py b/negodata/backend/router/v1/quotation/protocol.py index 31901e6..0a9078e 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, DeliveryType, QuotationStatus, QuotationType, SessionStatus, SupplierType +from common.enums import CardType, ChatSender, CloseReason, DeliveryType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus, SupplierType from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol @@ -62,6 +62,7 @@ class QuotationData(WebPacketProtocol): preferred_sp_name: Optional[str] = None equal_bid_yn: Optional[bool] = None equal_bid_data: Optional[Any] = None + close_reason: Optional[CloseReason] = None # 마감 사유(CloseReason). 미마감이면 None participation_count: int = 0 # 견적별 참여 협력사 수(세션 distinct supplier). 목록 집계로 채움. item_id: Optional[uuid.UUID] = None # 대표 상품 id(세션의 첫 item). 목록 조인으로 채움. item_name: Optional[str] = None # 대표 상품명. 목록 조인으로 채움. diff --git a/negodata/backend/router/v1/quotation_setting/protocol.py b/negodata/backend/router/v1/quotation_setting/protocol.py index 0518f40..bcedabf 100644 --- a/negodata/backend/router/v1/quotation_setting/protocol.py +++ b/negodata/backend/router/v1/quotation_setting/protocol.py @@ -4,6 +4,7 @@ from typing import Optional from pydantic import ConfigDict +from common.enums import PriceGateAction from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol @@ -15,12 +16,18 @@ class Req_CreateQuotationSetting(QuotationSettingProtocol): target_margin_rate: float anchoring_value: float = 0.01 card_count: int = 3 + mid_action: PriceGateAction = PriceGateAction.AWARD # 앵커링가<투찰가≤목표가 마감 처리 + over_action: PriceGateAction = PriceGateAction.AWARD # 목표가<투찰가 마감 처리 + regen_limit: int = 1 # 재생성 최대 횟수(체인 전체, 사유 무관) class Req_UpdateQuotationSetting(QuotationSettingProtocol): target_margin_rate: Optional[float] = None anchoring_value: Optional[float] = None card_count: Optional[int] = None + mid_action: Optional[PriceGateAction] = None + over_action: Optional[PriceGateAction] = None + regen_limit: Optional[int] = None class QuotationSettingData(WebPacketProtocol): @@ -31,6 +38,9 @@ class QuotationSettingData(WebPacketProtocol): target_margin_rate: float anchoring_value: float card_count: int + mid_action: PriceGateAction = PriceGateAction.AWARD + over_action: PriceGateAction = PriceGateAction.AWARD + regen_limit: int = 1 created_at: Optional[datetime] = None updated_at: Optional[datetime] = None diff --git a/negodata/backend/scripts/seed_demo_quotations.sql b/negodata/backend/scripts/seed_demo_quotations.sql new file mode 100644 index 0000000..9be3f89 --- /dev/null +++ b/negodata/backend/scripts/seed_demo_quotations.sql @@ -0,0 +1,151 @@ +-- 견적상세 UI 케이스별 데모 시드 (dev DB: negosium_db) +-- 안전: 아래 데모 qt_id / 그 세션만 지우고 다시 넣는다(다른 데이터 무손상). 재실행 가능. +-- user=모모나(b874f33d) / setting=40614764 / version=...030 / item=고압 에어 컴프레서(a3d1fff5) +-- close_reason(CloseReason): 1=낙찰,2=가격재협상,3=동가재입찰,4=미참여재소집,5=가격유찰,6=동가유찰,7=미참여유찰,8=거부유찰 +BEGIN; + +DELETE FROM negotiation.sessions WHERE quotation_id IN ( + 'aaaa0001-0000-0000-0000-000000000001', + 'aaaa0002-0000-0000-0000-000000000002', + 'aaaa0003-0000-0000-0000-000000000003', + 'aaaa0004-0000-0000-0000-000000000004', + 'aaaa0005-0000-0000-0000-000000000005', + 'aaaa0006-0000-0000-0000-000000000006', + 'aaaa0007-0000-0000-0000-000000000007', + 'aaaa0008-0000-0000-0000-000000000008', + 'aaaa0009-0000-0000-0000-000000000009', + 'aaaa0010-0000-0000-0000-000000000010' +); +DELETE FROM quotation.quotations WHERE qt_id IN ( + 'aaaa0001-0000-0000-0000-000000000001', + 'aaaa0002-0000-0000-0000-000000000002', + 'aaaa0003-0000-0000-0000-000000000003', + 'aaaa0004-0000-0000-0000-000000000004', + 'aaaa0005-0000-0000-0000-000000000005', + 'aaaa0006-0000-0000-0000-000000000006', + 'aaaa0007-0000-0000-0000-000000000007', + 'aaaa0008-0000-0000-0000-000000000008', + 'aaaa0009-0000-0000-0000-000000000009', + 'aaaa0010-0000-0000-0000-000000000010' +); + +-- ── 견적 10건 (close_reason 8종 전부 + 진행중) ──────────────────────────── +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) +VALUES + -- ① 낙찰(절감): 목표 400,000 → 낙찰 372,000 + ('aaaa0001-0000-0000-0000-000000000001','b874f33d-afc3-4498-a9c2-19bc5ae8faba', + '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', + '① 낙찰(절감) 데모','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), + -- ② 낙찰(목표초과): 목표 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), + -- ③ 동가 재입찰(REGEN_EQUAL): 두 곳 390,000 동률 → 재입찰 진행 + ('aaaa0003-0000-0000-0000-000000000003','b874f33d-afc3-4498-a9c2-19bc5ae8faba', + '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', + '③ 동가 재입찰 데모','EST-DEMO-03',2,1,3, + 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, + 3, 400000, 1, 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), + -- ⑤ 미참여 재소집(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, + 4, 400000, 1, 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), + -- ⑦ 가격 재협상(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, + 2, 350000, 1, 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', + '⑧ 가격 유찰 데모','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), + -- ⑨ 동가 유찰(FAIL_EQUAL): 동가지만 재입찰 한도 소진/유찰 정책 → 유찰 + ('aaaa0009-0000-0000-0000-000000000009','b874f33d-afc3-4498-a9c2-19bc5ae8faba', + '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', + '⑨ 동가 유찰 데모','EST-DEMO-09',2,2,3, + 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), + -- ⑩ 미참여 유찰(FAIL_NOSHOW): 전원 미참여 + 재소집 한도 소진 → 유찰 + ('aaaa0010-0000-0000-0000-000000000010','b874f33d-afc3-4498-a9c2-19bc5ae8faba', + '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', + '⑩ 미참여 유찰 데모','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); + +-- ── 세션 ──────────────────────────────────────────────────────────────── +-- 공통: item=a3d1fff5, DeliveryType 협력사배송=1 +INSERT INTO negotiation.sessions + (session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, + target_price, status, bid_price, bid_at, end_time, + reject_reason, reject_price, reject_delivery_type, target_anchoring_price, email_sent_at, + created_at, updated_at, deleted) +VALUES + -- ① 낙찰(절감): S1 372,000(낙찰) / S2 389,000 / S3 395,000 + ('b0000001-0000-0000-0000-000000000001','aaaa0001-0000-0000-0000-000000000001','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09','EST-DEMO-01',1,4,400000,3,372000,now()-interval '2 hour',now()-interval '1 hour',null,null,null,396000,now()-interval '2 days',now(),now(),false), + ('b0000001-0000-0000-0000-000000000002','aaaa0001-0000-0000-0000-000000000001','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','69e7057a-5919-48cd-b329-13b833bff994','EST-DEMO-01',1,4,400000,3,389000,now()-interval '2 hour',now()-interval '1 hour',null,null,null,396000,now()-interval '2 days',now(),now(),false), + ('b0000001-0000-0000-0000-000000000003','aaaa0001-0000-0000-0000-000000000001','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','747897ec-adab-4f3e-9076-15803ddf821f','EST-DEMO-01',1,4,400000,3,395000,now()-interval '2 hour',now()-interval '1 hour',null,null,null,396000,null,now()-interval '2 days',now(),false), + -- ② 낙찰(초과): S2 368,000(낙찰, 목표 350k 초과) / S1 375,000 + ('b0000002-0000-0000-0000-000000000001','aaaa0002-0000-0000-0000-000000000002','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','69e7057a-5919-48cd-b329-13b833bff994','EST-DEMO-02',1,4,350000,3,368000,now()-interval '2 hour',now()-interval '1 hour',null,null,null,346500,now()-interval '2 days',now(),now(),false), + ('b0000002-0000-0000-0000-000000000002','aaaa0002-0000-0000-0000-000000000002','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09','EST-DEMO-02',1,4,350000,3,375000,now()-interval '2 hour',now()-interval '1 hour',null,null,null,346500,now()-interval '2 days',now(),now(),false), + -- ③ 동가 재입찰: S1 390,000 / S3 390,000 + ('b0000003-0000-0000-0000-000000000001','aaaa0003-0000-0000-0000-000000000003','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09','EST-DEMO-03',1,2,400000,3,390000,now()-interval '2 hour',now()-interval '1 hour',null,null,null,396000,now()-interval '2 days',now(),now(),false), + ('b0000003-0000-0000-0000-000000000002','aaaa0003-0000-0000-0000-000000000003','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','747897ec-adab-4f3e-9076-15803ddf821f','EST-DEMO-03',1,2,400000,3,390000,now()-interval '2 hour',now()-interval '1 hour',null,null,null,396000,now()-interval '2 days',now(),now(),false), + -- ④ 거부 유찰: S4 협상거부(reject) / S5 미참여 + ('b0000004-0000-0000-0000-000000000001','aaaa0004-0000-0000-0000-000000000004','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','bcee5c78-ee13-4499-8e3d-132e2645dd6a','EST-DEMO-04',1,2,400000,5,null,null,now()-interval '1 hour','단가가 맞지 않아 참여를 거부합니다',430000,1,396000,now()-interval '2 days',now()-interval '2 days',now(),false), + ('b0000004-0000-0000-0000-000000000002','aaaa0004-0000-0000-0000-000000000004','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','ffaf086d-6630-4c2e-9ae9-01843b86ff5f','EST-DEMO-04',1,2,400000,4,null,null,now()-interval '1 hour',null,null,null,396000,null,now()-interval '2 days',now(),false), + -- ⑤ 미참여 재소집: S1, S2 둘 다 미참여 + ('b0000005-0000-0000-0000-000000000001','aaaa0005-0000-0000-0000-000000000005','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09','EST-DEMO-05',1,1,400000,4,null,null,now()-interval '1 hour',null,null,null,396000,now()-interval '2 days',now()-interval '2 days',now(),false), + ('b0000005-0000-0000-0000-000000000002','aaaa0005-0000-0000-0000-000000000005','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','69e7057a-5919-48cd-b329-13b833bff994','EST-DEMO-05',1,1,400000,4,null,null,now()-interval '1 hour',null,null,null,396000,now()-interval '2 days',now()-interval '2 days',now(),false), + -- ⑥ 진행중: S1 385,000 투찰완료 / S2 협상중 / S3 생성됨 + ('b0000006-0000-0000-0000-000000000001','aaaa0006-0000-0000-0000-000000000006','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09','EST-DEMO-06',1,4,400000,3,385000,now()-interval '3 hour',now()+interval '1 day',null,null,null,396000,now()-interval '1 day',now(),now(),false), + ('b0000006-0000-0000-0000-000000000002','aaaa0006-0000-0000-0000-000000000006','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','69e7057a-5919-48cd-b329-13b833bff994','EST-DEMO-06',1,4,400000,2,null,null,now()+interval '1 day',null,null,null,396000,now()-interval '1 day',now(),now(),false), + ('b0000006-0000-0000-0000-000000000003','aaaa0006-0000-0000-0000-000000000006','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','747897ec-adab-4f3e-9076-15803ddf821f','EST-DEMO-06',1,4,400000,1,null,null,now()+interval '1 day',null,null,null,396000,null,now()-interval '1 day',now(),false), + -- ⑦ 가격 재협상: S1 420,000(단독, 목표 350k 초과) + ('b0000007-0000-0000-0000-000000000001','aaaa0007-0000-0000-0000-000000000007','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09','EST-DEMO-07',1,4,350000,3,420000,now()-interval '2 hour',now()-interval '1 hour',null,null,null,346500,now()-interval '2 days',now(),now(),false), + -- ⑧ 가격 유찰: S1 430,000(단독, 목표 350k 초과) + ('b0000008-0000-0000-0000-000000000001','aaaa0008-0000-0000-0000-000000000008','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','69e7057a-5919-48cd-b329-13b833bff994','EST-DEMO-08',1,4,350000,3,430000,now()-interval '2 hour',now()-interval '1 hour',null,null,null,346500,now()-interval '2 days',now(),now(),false), + -- ⑨ 동가 유찰: S1 405,000 / S2 405,000 (동률) + ('b0000009-0000-0000-0000-000000000001','aaaa0009-0000-0000-0000-000000000009','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09','EST-DEMO-09',2,2,400000,3,405000,now()-interval '2 hour',now()-interval '1 hour',null,null,null,396000,now()-interval '2 days',now(),now(),false), + ('b0000009-0000-0000-0000-000000000002','aaaa0009-0000-0000-0000-000000000009','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','69e7057a-5919-48cd-b329-13b833bff994','EST-DEMO-09',2,2,400000,3,405000,now()-interval '2 hour',now()-interval '1 hour',null,null,null,396000,now()-interval '2 days',now(),now(),false), + -- ⑩ 미참여 유찰: S1, S2 둘 다 미참여 + ('b0000010-0000-0000-0000-000000000001','aaaa0010-0000-0000-0000-000000000010','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09','EST-DEMO-10',2,1,400000,4,null,null,now()-interval '1 hour',null,null,null,396000,now()-interval '2 days',now()-interval '2 days',now(),false), + ('b0000010-0000-0000-0000-000000000002','aaaa0010-0000-0000-0000-000000000010','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','69e7057a-5919-48cd-b329-13b833bff994','EST-DEMO-10',2,1,400000,4,null,null,now()-interval '1 hour',null,null,null,396000,now()-interval '2 days',now()-interval '2 days',now(),false); + +COMMIT; diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index c7fe91f..b282815 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -7,7 +7,7 @@ from fastapi import Depends 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, DBWRType, ErrorType, NotificationType, QuotationStatus, QuotationType, SessionStatus +from common.enums import CloseOutcome, CloseReason, DBWRType, ErrorType, NotificationType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus from common.logger import LOG from common.models.gmodel import PageParams from common.utils.gtime import GTime @@ -268,13 +268,15 @@ class QuotationService: return res async def create_quotation(self, user_id: str, req: Req_CreateQuotation) -> Res_CreateQuotation: - """[프론트] 신규 견적 생성. 요청값을 보정한 뒤 공통 빌더(_build_quotation)에 위임한다.""" - return await self._build_quotation( + """[프론트] 신규 견적 생성. 요청값을 보정한 뒤 공통 빌더(_build_quotation)에 위임한다. + 생성 성공 시 작성자에게 CREATED 알림(인박스).""" + number = self._gen_number() # 견적번호는 항상 서버 생성(프론트 입력란 없음) + res = await self._build_quotation( user_id=user_id, qt_setting_id=req.qt_setting_id, version_id=req.version_id or self.DEFAULT_VERSION_ID, name=req.name, - number=self._gen_number(), # 견적번호는 항상 서버 생성(프론트 입력란 없음) + number=number, type_=req.type, status=req.status or QuotationStatus.CREATED.value, round_=req.round or 1, @@ -290,6 +292,13 @@ class QuotationService: supplier_ids=req.supplier_ids, card_ids=req.card_ids, ) + if res.result.success: + await create_notification( + user_id, NotificationType.CREATED, + {"qt_name": req.name, "qt_number": number}, + ref_qt_id=res.qt_id, + ) + return res async def regenerate_next_round(self, original_qt_id: uuid.UUID, supplier_ids: list) -> Res_CreateQuotation: """[마감 후속] 결판 안 난 견적의 '다음 라운드'를 새로 만든다. @@ -508,98 +517,71 @@ class QuotationService: if len(tied) > 1: equal = {"price": min_price, "suppliers": [{"supplier_id": str(sid), "name": name} for sid, _, name in tied]} return None, equal - return {"supplier_id": tied[0][0], "name": tied[0][2]}, None + return {"supplier_id": tied[0][0], "name": tied[0][2], "bid_price": min_price}, None - async def _award_and_close(self, qt_uuid, winner) -> None: - """단독 낙찰 확정 + 마감 + 미완료(미시작·진행중) 세션 미참여.""" - data = { - "status": QuotationStatus.CLOSED.value, - "preferred_sp_yn": True, - "preferred_sp_id": winner["supplier_id"], - "preferred_sp_name": (winner["name"] or "")[:20], - "equal_bid_yn": False, - } + @staticmethod + def _gate_action(bid, target, anchor, mid_action, over_action) -> int: + """가격게이트 판정 → PriceGateAction 코드. + bid ≤ 앵커링가 → 무조건 낙찰(AWARD) + 앵커링가 < bid ≤ 목표가 → mid_action(설정) + 목표가 < bid → over_action(설정) + target/bid 없으면(설정 불완전 등) AWARD 폴백(하위호환: 기존 '무조건 낙찰').""" + if bid is None or target is None: + return PriceGateAction.AWARD.value + bid = int(bid) + if anchor is not None and bid <= int(anchor): + return PriceGateAction.AWARD.value + if bid <= int(target): + return mid_action or PriceGateAction.AWARD.value + return over_action or PriceGateAction.AWARD.value + + async def _close(self, qt_uuid, close_reason: int, data: dict = None) -> None: + """마감 공통: status→CLOSED + close_reason 기록 + (있으면)추가데이터 + 미완료(미시작·진행중) 세션→미참여. + close_reason(CloseReason)이 재생성 한도 카운팅·유찰 사유 구분의 단일 근거. + preferred_sp_*/equal_bid_* 는 프론트 표시용으로 함께 채운다(사유 판별은 close_reason 이 담당).""" + payload = {"status": QuotationStatus.CLOSED.value, "close_reason": close_reason} + if data: + payload.update(data) await DB_SESSION_MNG.execute_lambda_run( [quotations.DBType()], [ - lambda s: self.quotation_crud.update_quotation(s, qt_uuid, data), + lambda s: self.quotation_crud.update_quotation(s, qt_uuid, payload), lambda s: self.quotation_crud.update_sessions_status( s, qt_uuid, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value ), ], ) - async def _just_close(self, qt_uuid) -> None: - """그냥 마감 + 미완료 세션 미참여.""" - await DB_SESSION_MNG.execute_lambda_run( - [quotations.DBType()], - [ - lambda s: self.quotation_crud.update_quotation(s, qt_uuid, {"status": QuotationStatus.CLOSED.value}), - lambda s: self.quotation_crud.update_sessions_status( - s, qt_uuid, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value - ), - ], - ) - - async def _close_as_no_show(self, qt_uuid) -> None: - """전원 미참여로 '다음 라운드 재생성' 하며 마감 + 미완료 세션 미참여. - 재생성 사유(미참여)를 체인에 남기기 위해 preferred_sp_yn=False, equal_bid_yn=False 로 양성 표식한다 - (단독낙찰=preferred_sp_yn True / 동가=equal_bid_yn True / 거부·한도 등 그냥 마감=둘 다 NULL 과 구분). - _chain_regen_counts 가 이 표식으로 '미참여 재생성 이력'만 정확히 센다.""" - data = { - "status": QuotationStatus.CLOSED.value, - "preferred_sp_yn": False, - "equal_bid_yn": False, - } - await DB_SESSION_MNG.execute_lambda_run( - [quotations.DBType()], - [ - lambda s: self.quotation_crud.update_quotation(s, qt_uuid, data), - lambda s: self.quotation_crud.update_sessions_status( - s, qt_uuid, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value - ), - ], - ) - - async def _close_as_equal(self, qt_uuid, equal) -> None: - """동가로 마감 + 미완료 세션 미참여. equal_bid_yn/data 를 기록해 둔다 - (재생성 한도 계산이 이 플래그로 동가 라운드를 식별하고, 프론트도 동가 정보를 그대로 쓴다).""" - data = { - "status": QuotationStatus.CLOSED.value, - "preferred_sp_yn": False, - "equal_bid_yn": True, - "equal_bid_data": equal, - } - await DB_SESSION_MNG.execute_lambda_run( - [quotations.DBType()], - [ - lambda s: self.quotation_crud.update_quotation(s, qt_uuid, data), - lambda s: self.quotation_crud.update_sessions_status( - s, qt_uuid, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value - ), - ], - ) + async def _regen_and_notify(self, original, qt_uuid, supplier_ids: list, reason: str, equal=None) -> CloseOutcome: + """마감 후 다음 라운드 재생성 + 성공 시 REGENERATED 알림. 실패 시 REGEN_FAILED(원본 CLOSED 인데 체인 끊김 → 노출).""" + regen = await self.regenerate_next_round(qt_uuid, supplier_ids) + if not regen.result.success: + LOG.e_no_callstack( + f"[close] 재생성 실패({reason}) qt={qt_uuid} number={original.number} round={original.round} " + f"code={regen.result.code}({regen.result.desc})" + ) + return CloseOutcome.REGEN_FAILED + payload = {"qt_name": original.name, "qt_number": original.number, "reason": reason, "next_round": original.round + 1} + if equal is not None: + payload.update({"tied_price": equal["price"], "tied_count": len(equal["suppliers"])}) + await create_notification(original.user_id, NotificationType.REGENERATED, payload, ref_qt_id=regen.qt_id) + return CloseOutcome.REGENERATED async def close_and_decide(self, qt_id) -> CloseOutcome: - """[마감] 견적을 마감하면서 결과를 판정한다. - 1) 협상완료 중 최저가 단독 → 그 공급사 낙찰 확정 - 2) 협상완료 중 최저가 동가 → 다음 라운드 재생성(동가 업체끼리) [체인에 동가 재생성 이력 없을 때만] - 3) 협상거부 세션이 하나라도 있음 → 그냥 마감 (재생성 안 함) - 4) 전원 미참여(완료·거부 0) → 다음 라운드 재생성(원 견적 공급사 전체) [체인에 미참여 재생성 이력 없을 때만] - 5) 그 외 / 한도 도달 → 그냥 마감 - 동가를 거부보다 먼저 본다: 동률은 '협상완료'한 업체들 간 경쟁이라 무관한 다른 업체의 거부로 막지 않는다. - 재생성 한도: 한 체인(같은 견적번호)에서 '미참여' 1번 + '동가' 1번(순서 무관, 같은 사유 2번은 불가). - 공통: status→CLOSED, 미시작·진행중 세션→미참여.""" + """[마감] 견적을 마감하며 결과 판정. 낙찰자는 협상완료 최저가(단독/동가), 그 위에 회사 가격정책(가격게이트)으로 낙찰/재협상/유찰을 가른다. + - 단독 최저가: 가격게이트(≤앵커 무조건낙찰 / 앵커~목표 mid_action / 목표초과 over_action) → 낙찰 / 재협상(한도내) / 유찰 + - 동가: 그 가격 구간 설정이 '유찰'이 아니고 한도 남으면 재입찰(tie 해소), 유찰 설정이거나 한도 소진이면 유찰 + - 협상거부: 유찰 + - 전원 미참여: 재소집(한도내) / 유찰 + 재생성 한도: regen_limit = 체인(같은 견적번호) 전체 재생성 '총' 횟수(사유 무관). 마감 사유는 close_reason 에 기록(카운팅·유찰사유 구분의 단일 근거). + 공통: 원자적 status→CLOSED 선점, 미시작·진행중 세션→미참여.""" qt_uuid = qt_id if isinstance(qt_id, uuid.UUID) else uuid.UUID(str(qt_id)) err_type, original = await self._fetch(qt_uuid) if err_type != ErrorType.SUCCESS or original is None: return CloseOutcome.CLOSED - # [동시 마감 가드] 마감 판정 전에 원자적으로 status→CLOSED 를 선점한다. - # 두 크론 잡(close_expired / close_negotiated)이나 수동 stop_quotation 이 같은 견적을 - # 동시에 닫으려 해도, 실제로 CLOSED 로 전이한 호출자만 통과하고 진 호출자는 여기서 끝난다 - # → 이중 재생성·uq(number,round) 충돌 방지. (이미 닫힌 견적의 재처리도 여기서 차단) + # [동시 마감 가드] 원자적으로 status→CLOSED 선점. 실제로 전이한 호출자만 통과(이중 재생성·uq(number,round) 충돌 방지). claim_err, claimed = await DB_SESSION_MNG.execute_lambda_claim( quotations.DBType(), lambda s: self.quotation_crud.claim_for_close(s, qt_uuid), @@ -618,69 +600,86 @@ class QuotationService: has_rejected = any(r.status == SessionStatus.REJECTED.value for r in rows) winner, equal = self._pick_winner(done) - # 1) 단독 낙찰 → 확정 + # 가격게이트 입력: 회사 정책(mid/over/limit) + 세션 목표가/앵커링가(견적당 상품 1개라 세션 공통값) + _e, rates = await DB_SESSION_MNG.execute_lambda( + quotations.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.get_setting_rates(s, original.qt_setting_id), + ) + rates = rates or {} + mid_action = rates.get("mid_action") or PriceGateAction.AWARD.value + over_action = rates.get("over_action") or PriceGateAction.AWARD.value + limit = rates.get("regen_limit") + limit = limit if limit is not None else self.MAX_REGEN_PER_CAUSE + target = next((r.target_price for r in rows if r.target_price is not None), None) + anchor = next((r.target_anchoring_price for r in rows if r.target_anchoring_price is not None), None) + + # 재생성 총 이력(체인, 사유 무관). regen_limit = 체인 전체 재생성 총 한도. + regen_used = await self._chain_regen_count(original.number, original.round) + + # 1) 단독 최저가 → 가격게이트로 낙찰/재협상/유찰 if winner is not None: - await self._award_and_close(qt_uuid, winner) - winner_price = min((int(bp) for _, bp, _ in done if bp is not None), default=None) + action = self._gate_action(winner["bid_price"], target, anchor, mid_action, over_action) + if action == PriceGateAction.RENEGO.value and regen_used < limit: + await self._close(qt_uuid, CloseReason.REGEN_PRICE.value, {"preferred_sp_yn": False, "equal_bid_yn": False}) + return await self._regen_and_notify(original, qt_uuid, [winner["supplier_id"]], "price") + if action == PriceGateAction.AWARD.value: + await self._close(qt_uuid, CloseReason.AWARDED.value, { + "preferred_sp_yn": True, "preferred_sp_id": winner["supplier_id"], + "preferred_sp_name": (winner["name"] or "")[:20], "equal_bid_yn": False, + }) + 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"]}, + ref_qt_id=qt_uuid, + ) + return CloseOutcome.AWARDED + # FAIL 이거나, RENEGO 인데 한도 소진 → 가격 유찰 + await self._close(qt_uuid, CloseReason.FAIL_PRICE.value, {"preferred_sp_yn": False, "equal_bid_yn": False}) await create_notification( - original.user_id, NotificationType.SUCCESS, - {"qt_name": original.name, "qt_number": original.number, - "winner_name": winner["name"], "winner_price": winner_price}, + original.user_id, NotificationType.FAILURE, + {"qt_name": original.name, "qt_number": original.number, "reason": "price"}, ref_qt_id=qt_uuid, ) - return CloseOutcome.AWARDED + return CloseOutcome.CLOSED - # 동가/미참여 재생성은 사유별 한도(각 1번, 순서 무관) 확인 후 - no_part_used, equal_used = await self._chain_regen_counts(original.number, original.round) - - # 2) 동가 → 동가 업체끼리 다음 라운드 (거부보다 먼저: tie 해소 우선, 체인에 동가 이력 없을 때만) - if equal is not None and equal_used < self.MAX_REGEN_PER_CAUSE: - tied_ids = [uuid.UUID(sp["supplier_id"]) for sp in equal["suppliers"]] - await self._close_as_equal(qt_uuid, equal) # 동가 기록(equal_bid_yn) 후 마감 - regen = await self.regenerate_next_round(qt_uuid, tied_ids) - if not regen.result.success: - # 원본은 이미 CLOSED 인데 다음 라운드 생성이 실패 → 체인이 끊긴 상태. 성공으로 위장하지 않고 드러낸다. - LOG.e_no_callstack( - f"[close] 동가 재생성 실패 qt={qt_uuid} number={original.number} round={original.round} " - f"code={regen.result.code}({regen.result.desc})" - ) - return CloseOutcome.REGEN_FAILED + # 2) 동가 → 그 가격 구간 설정이 '유찰'이 아니고 한도 남으면 재입찰(tie 해소), 유찰 설정이거나 한도 소진이면 유찰. + # (동가는 단독 낙찰 불가 → AWARD 설정이어도 '재입찰로 tie 해소'가 자연스러워 기본은 재입찰=하위호환. 회사가 FAIL 로 두면 유찰.) + if equal is not None: + action = self._gate_action(equal["price"], target, anchor, mid_action, over_action) + if action != PriceGateAction.FAIL.value and regen_used < limit: + tied_ids = [uuid.UUID(sp["supplier_id"]) for sp in equal["suppliers"]] + await self._close(qt_uuid, CloseReason.REGEN_EQUAL.value, + {"preferred_sp_yn": False, "equal_bid_yn": True, "equal_bid_data": equal}) + return await self._regen_and_notify(original, qt_uuid, tied_ids, "equal", equal=equal) + await self._close(qt_uuid, CloseReason.FAIL_EQUAL.value, + {"preferred_sp_yn": False, "equal_bid_yn": True, "equal_bid_data": equal}) await create_notification( - original.user_id, NotificationType.REGENERATED, - {"qt_name": original.name, "qt_number": original.number, "reason": "equal", - "next_round": original.round + 1, "tied_price": equal["price"], "tied_count": len(equal["suppliers"])}, - ref_qt_id=regen.qt_id, + original.user_id, NotificationType.FAILURE, + {"qt_name": original.name, "qt_number": original.number, "reason": "equal"}, + ref_qt_id=qt_uuid, ) - return CloseOutcome.REGENERATED - # 3) 협상거부 있음 → 마감만 (재생성 안 함) + return CloseOutcome.CLOSED + + # 3) 협상거부 있음 → 유찰 if has_rejected: - await self._just_close(qt_uuid) + await self._close(qt_uuid, CloseReason.FAIL_REJECT.value) await create_notification( original.user_id, NotificationType.FAILURE, {"qt_name": original.name, "qt_number": original.number, "reason": "rejected"}, ref_qt_id=qt_uuid, ) return CloseOutcome.CLOSED - # 4) 전원 미참여 → 공급사 전체로 다음 라운드 (체인에 미참여 재생성 이력 없을 때만) - if not done and rows and no_part_used < self.MAX_REGEN_PER_CAUSE: + + # 4) 전원 미참여 → 공급사 전체 재소집(한도 남을 때) + if not done and rows and regen_used < limit: supplier_ids = list({r.supplier_id for r in rows}) - await self._close_as_no_show(qt_uuid) # 미참여 재생성 표식(preferred_sp_yn=False, equal_bid_yn=False) 후 마감 - regen = await self.regenerate_next_round(qt_uuid, supplier_ids) - if not regen.result.success: - # 원본은 이미 CLOSED 인데 다음 라운드 생성이 실패 → 체인이 끊긴 상태. 성공으로 위장하지 않고 드러낸다. - LOG.e_no_callstack( - f"[close] 미참여 재생성 실패 qt={qt_uuid} number={original.number} round={original.round} " - f"code={regen.result.code}({regen.result.desc})" - ) - return CloseOutcome.REGEN_FAILED - await create_notification( - original.user_id, NotificationType.REGENERATED, - {"qt_name": original.name, "qt_number": original.number, "reason": "no_show", "next_round": original.round + 1}, - ref_qt_id=regen.qt_id, - ) - return CloseOutcome.REGENERATED - # 5) 그 외 / 한도 도달 → 마감만 - await self._just_close(qt_uuid) + await self._close(qt_uuid, CloseReason.REGEN_NOSHOW.value, {"preferred_sp_yn": False, "equal_bid_yn": False}) + return await self._regen_and_notify(original, qt_uuid, supplier_ids, "no_show") + + # 5) 그 외 / 한도 도달 → 미참여 유찰 + await self._close(qt_uuid, CloseReason.FAIL_NOSHOW.value, {"preferred_sp_yn": False, "equal_bid_yn": False}) await create_notification( original.user_id, NotificationType.FAILURE, {"qt_name": original.name, "qt_number": original.number, "reason": "closed"}, @@ -688,23 +687,18 @@ class QuotationService: ) return CloseOutcome.CLOSED - async def _chain_regen_counts(self, number: str, current_round: int) -> tuple[int, int]: - """체인(같은 견적번호) 이전 라운드들의 '재생성 사유' 횟수. 반환: (미참여 횟수, 동가 횟수). - 마감 시 남긴 양성 표식으로만 센다(오집계 방지): - - 동가 재생성 → equal_bid_yn=True - - 미참여 재생성 → preferred_sp_yn=False AND equal_bid_yn=False - 단독낙찰(preferred_sp_yn=True)·거부/한도 그냥 마감(둘 다 NULL)은 어느 쪽에도 세지 않는다. - (수동 regenerate_quotation 으로 단독낙찰·거부 라운드를 이어붙여도 자동 재생성 한도에 영향 없음.)""" - err_type, flags = await DB_SESSION_MNG.execute_lambda( + async def _chain_regen_count(self, number: str, current_round: int) -> int: + """체인(같은 견적번호) 이전 라운드들의 재생성 '총' 횟수(사유 무관). regen_limit 은 체인 전체 총 한도다. + close_reason 의 REGEN_*(가격/동가/미참여) 만 센다 — 낙찰(AWARDED)·유찰(FAIL_*)·미마감(NULL)은 한도에 안 셈.""" + err_type, reasons = await DB_SESSION_MNG.execute_lambda( quotations.DBType(), DBWRType.DB_READ.value, - lambda s: self.quotation_crud.list_chain_close_flags(s, number, current_round), + lambda s: self.quotation_crud.list_chain_close_reasons(s, number, current_round), ) if err_type != ErrorType.SUCCESS: - return 0, 0 - equal = sum(1 for _pref, eq in flags if eq is True) - no_part = sum(1 for pref, eq in flags if pref is False and eq is False) - return no_part, equal + return 0 + regen = {CloseReason.REGEN_PRICE.value, CloseReason.REGEN_EQUAL.value, CloseReason.REGEN_NOSHOW.value} + return sum(1 for c in reasons if c in regen) async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list) -> Res_CreateQuotation: """[프론트] 마감된 견적을 골라 수동으로 다음 라운드를 생성한다. diff --git a/negodata/backend/services/quotation_setting_service.py b/negodata/backend/services/quotation_setting_service.py index e614324..392c63e 100644 --- a/negodata/backend/services/quotation_setting_service.py +++ b/negodata/backend/services/quotation_setting_service.py @@ -67,6 +67,9 @@ class QuotationSettingService: target_margin_rate=req.target_margin_rate, anchoring_value=req.anchoring_value, card_count=req.card_count, + mid_action=req.mid_action.value, + over_action=req.over_action.value, + regen_limit=req.regen_limit, ) err_type = await DB_SESSION_MNG.execute_lambda_run( [quotation_settings.DBType()], @@ -83,6 +86,11 @@ class QuotationSettingService: user_uuid = uuid.UUID(user_id) setting_uuid = uuid.UUID(qt_setting_id) data = req.model_dump(exclude_unset=True) + # PriceGateAction enum → SMALLINT 코드값으로 변환(모델 컬럼이 int) + for k in ("mid_action", "over_action"): + v = data.get(k) + if v is not None and hasattr(v, "value"): + data[k] = v.value # 소유권 확인 err_type, _ = await self._fetch_owned(user_uuid, setting_uuid) diff --git a/negodata/backend/tests/test_close_and_decide_fixes.py b/negodata/backend/tests/test_close_and_decide_fixes.py index 45f9b6f..01d65fc 100644 --- a/negodata/backend/tests/test_close_and_decide_fixes.py +++ b/negodata/backend/tests/test_close_and_decide_fixes.py @@ -16,7 +16,7 @@ from datetime import datetime, timedelta import pytest_asyncio from sqlalchemy import text -from common.enums import CloseOutcome, QuotationStatus, QuotationType, SessionStatus +from common.enums import CloseOutcome, CloseReason, PriceGateAction, QuotationStatus, QuotationType, SessionStatus from crud.quotation_crud import QuotationCRUD from services.quotation_service import QuotationService @@ -81,10 +81,10 @@ async def test_awarded_prior_round_not_counted_as_no_show(clean): 기대결과: REGENERATED, 체인 [1,2,3] — 단독낙찰 라운드를 '미참여'로 오집계해 재생성을 막지 않는다.""" engine = clean number = "C-AWARDED-PRIOR" - # round 1: 단독낙찰로 마감(preferred_sp_yn=True). 수동 재생성 등으로 체인이 이어진 상황을 가정. + # round 1: 단독낙찰로 마감(close_reason=AWARDED). 수동 재생성 등으로 체인이 이어진 상황을 가정. await _seed_quotation( engine, number=number, round_=1, status=QuotationStatus.CLOSED.value, - preferred_sp_yn=True, equal_bid_yn=False, + preferred_sp_yn=True, equal_bid_yn=False, close_reason=CloseReason.AWARDED.value, ) # round 2: 전원 미참여 → 미참여 재생성이 일어나야 한다(round 1 은 미참여로 세면 안 됨) qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.IN_PROGRESS.value) @@ -106,10 +106,10 @@ async def test_no_show_prior_round_consumes_budget(clean): 기대결과: CLOSED, 체인 [1,2] — 미참여 재생성 한도(1) 소진돼 재생성 없이 그냥 마감(round3 없음).""" engine = clean number = "C-NOSHOW-PRIOR" - # round 1: 미참여로 마감(양성 표식) → no_part 예산 1 소진 + # round 1: 미참여 재생성으로 마감(close_reason=REGEN_NOSHOW) → 미참여 예산 1 소진 await _seed_quotation( engine, number=number, round_=1, status=QuotationStatus.CLOSED.value, - preferred_sp_yn=False, equal_bid_yn=False, + preferred_sp_yn=False, equal_bid_yn=False, close_reason=CloseReason.REGEN_NOSHOW.value, ) # round 2: 또 전원 미참여 → 한도 도달이라 재생성 없이 그냥 마감 qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.IN_PROGRESS.value) @@ -123,28 +123,63 @@ async def test_no_show_prior_round_consumes_budget(clean): assert [r.round for r in rounds] == [1, 2], "재생성되면 안 됨(round 3 없음)" +async def test_over_target_fail_closes(clean): + """검증: over_action=유찰(FAIL) 회사에서 단독 최저가가 목표 초과(session target=0 < bid=100)인 견적을 마감. + 기대결과: CLOSED + close_reason=FAIL_PRICE — 그 가격에 낙찰 안 하고 유찰(재생성 없음).""" + engine = clean + setting = await _seed_settings(engine, over_action=PriceGateAction.FAIL.value) + qt = await _seed_quotation(engine, number="C-OVER-FAIL", round_=1, + status=QuotationStatus.IN_PROGRESS.value, qt_setting_id=setting) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100) + + outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt) + + rounds = await _rounds(engine, "C-OVER-FAIL") + assert outcome == CloseOutcome.CLOSED, f"목표초과+유찰정책 → 유찰이어야 함, 실제 {outcome}" + assert [r.round for r in rounds] == [1], "재생성되면 안 됨(round 2 없음)" + assert rounds[0].close_reason == CloseReason.FAIL_PRICE.value, f"close_reason=FAIL_PRICE 여야 함, 실제 {rounds[0].close_reason}" + + +async def test_over_target_renego_regenerates(clean): + """검증: over_action=재협상(RENEGO) 회사에서 단독 최저가가 목표 초과인 견적을 첫 라운드에 마감. + 기대결과: REGENERATED + 체인 [1,2] — 그 가격에 낙찰 안 하고 다음 라운드로 더 깎기(1차 close_reason=REGEN_PRICE).""" + engine = clean + setting = await _seed_settings(engine, over_action=PriceGateAction.RENEGO.value) + qt = await _seed_quotation(engine, number="C-OVER-RENEGO", round_=1, + status=QuotationStatus.IN_PROGRESS.value, qt_setting_id=setting) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100) + + outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt) + + rounds = await _rounds(engine, "C-OVER-RENEGO") + assert outcome == CloseOutcome.REGENERATED, f"목표초과+재협상정책 0회차 → 재생성이어야 함, 실제 {outcome}" + assert [r.round for r in rounds] == [1, 2], f"round 2 가 생성돼야 함, 실제 {[r.round for r in rounds]}" + assert rounds[0].close_reason == CloseReason.REGEN_PRICE.value, f"1차 close_reason=REGEN_PRICE 여야 함, 실제 {rounds[0].close_reason}" + + # ===== 헬퍼 (위 테스트들이 쓰는 도우미. 세션 상태·마감 표식을 SQL 로 직접 세팅) ===== async def _seed_quotation( engine, *, number, round_, status, start_time=PAST, end_time=PAST, - preferred_sp_yn=None, equal_bid_yn=None, + preferred_sp_yn=None, equal_bid_yn=None, close_reason=None, qt_setting_id=None, ): - """견적 1건 시드. number/round_ 로 체인을, preferred_sp_yn·equal_bid_yn 으로 '이전 라운드가 어떻게 마감됐는지'를 만든다.""" + """견적 1건 시드. number/round_ 로 체인을, close_reason 으로 '이전 라운드가 어떤 사유로 마감/재생성됐는지'를 만든다 + (재생성 한도 카운팅은 close_reason 의 REGEN_* 만 센다). qt_setting_id 로 마감 가격정책(설정)을 연결. preferred_sp_yn/equal_bid_yn 은 프론트 표시용.""" 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, preferred_sp_yn, equal_bid_yn) VALUES " + " round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn, close_reason) VALUES " "(:qt_id, :user_id, :qt_setting_id, :version_id, :name, :number, :type, :status, " - " :round, 0, :start_time, :end_time, false, :pref, :eq)" + " :round, 0, :start_time, :end_time, false, :pref, :eq, :creason)" ), { - "qt_id": qt_id, "user_id": uuid.uuid4(), "qt_setting_id": uuid.uuid4(), + "qt_id": qt_id, "user_id": uuid.uuid4(), "qt_setting_id": qt_setting_id or uuid.uuid4(), "version_id": uuid.uuid4(), "name": "견적", "number": number, "type": QuotationType.REQUOTE.value, "status": status, "round": round_, "start_time": start_time, "end_time": end_time, - "pref": preferred_sp_yn, "eq": equal_bid_yn, + "pref": preferred_sp_yn, "eq": equal_bid_yn, "creason": close_reason, }, ) return qt_id @@ -171,10 +206,25 @@ async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=Non async def _rounds(engine, number): - """체인(number)의 (round, status, start_time, end_time) 목록 — round 오름차순.""" + """체인(number)의 (round, status, start_time, end_time, close_reason) 목록 — round 오름차순.""" async with engine.begin() as conn: return (await conn.execute( - text("SELECT round, status, start_time, end_time FROM quotations " + text("SELECT round, status, start_time, end_time, close_reason FROM quotations " "WHERE number = :n ORDER BY round"), {"n": number}, )).all() + + +async def _seed_settings(engine, *, mid_action=1, over_action=1, regen_limit=1): + """quotation_settings 1건 시드(마감 가격정책 지정). 반환: qt_setting_id.""" + sid = uuid.uuid4() + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO quotation_settings " + "(qt_setting_id, user_id, target_margin_rate, anchoring_value, card_count, mid_action, over_action, regen_limit) " + "VALUES (:sid, :uid, 0.1, 0.01, 3, :mid, :over, :lim)" + ), + {"sid": sid, "uid": uuid.uuid4(), "mid": mid_action, "over": over_action, "lim": regen_limit}, + ) + return sid diff --git a/negodata/backend/tests/test_quotation_close_notify.py b/negodata/backend/tests/test_quotation_close_notify.py index 95d0ce9..5540dae 100644 --- a/negodata/backend/tests/test_quotation_close_notify.py +++ b/negodata/backend/tests/test_quotation_close_notify.py @@ -17,7 +17,7 @@ from datetime import datetime import pytest_asyncio from sqlalchemy import text -from common.enums import CloseOutcome, NotificationType, QuotationStatus, QuotationType, SessionStatus +from common.enums import CloseOutcome, CloseReason, NotificationType, QuotationStatus, QuotationType, SessionStatus from crud.quotation_crud import QuotationCRUD from services.quotation_service import QuotationService @@ -121,9 +121,10 @@ async def test_equal_bid_limit_exhausted_fails(clean): 기대결과: 재견적 X — 판정 = 결렬(CLOSED) + 알림 FAILURE(reason=closed).""" engine = clean user_id = uuid.uuid4() - # 1차: 동가로 마감돼 2차를 만든 상황(equal_bid_yn=True 가 동가 재생성 표식) → 동가 한도 소진 + # 1차: 동가로 재생성 마감(close_reason=REGEN_EQUAL) → 동가 한도 소진 await _seed_quotation(engine, user_id=user_id, number="N-EQUAL-LIMIT", round_=1, - status=QuotationStatus.CLOSED.value, equal_bid_yn=True) + status=QuotationStatus.CLOSED.value, equal_bid_yn=True, + close_reason=CloseReason.REGEN_EQUAL.value) # 2차: 또 동가 qt2 = await _seed_quotation(engine, user_id=user_id, number="N-EQUAL-LIMIT", round_=2) await _add_session(engine, qt2, status=SessionStatus.DONE.value, bid_price=100) @@ -136,7 +137,7 @@ async def test_equal_bid_limit_exhausted_fails(clean): assert len(notis) == 1 type_, data, ref = notis[0] assert type_ == NotificationType.FAILURE.value - assert data["reason"] == "closed" + assert data["reason"] == "equal" # 동가 유찰(FAIL_EQUAL) 사유 assert str(ref) == str(qt2) @@ -145,9 +146,10 @@ async def test_no_show_limit_exhausted_fails(clean): 기대결과: 재견적 X — 판정 = 결렬(CLOSED) + 알림 FAILURE(reason=closed).""" engine = clean user_id = uuid.uuid4() - # 1차: 미참여로 마감돼 2차를 만든 상황(preferred_sp_yn=False·equal_bid_yn=False 가 미참여 재생성 표식) → 미참여 한도 소진 + # 1차: 미참여로 재생성 마감(close_reason=REGEN_NOSHOW) → 미참여 한도 소진 await _seed_quotation(engine, user_id=user_id, number="N-NOSHOW-LIMIT", round_=1, - status=QuotationStatus.CLOSED.value, preferred_sp_yn=False, equal_bid_yn=False) + status=QuotationStatus.CLOSED.value, preferred_sp_yn=False, equal_bid_yn=False, + close_reason=CloseReason.REGEN_NOSHOW.value) # 2차: 또 전원 미참여 qt2 = await _seed_quotation(engine, user_id=user_id, number="N-NOSHOW-LIMIT", round_=2) await _add_session(engine, qt2, status=SessionStatus.CREATED.value) @@ -167,26 +169,26 @@ async def test_no_show_limit_exhausted_fails(clean): # ===== 헬퍼 (위 테스트들이 쓰는 도우미. 세션 입찰값·이전 라운드 표식을 SQL 로 직접 세팅) ===== async def _seed_quotation( engine, *, user_id, number, round_=1, status=QuotationStatus.IN_PROGRESS.value, - preferred_sp_yn=None, equal_bid_yn=None, + preferred_sp_yn=None, equal_bid_yn=None, close_reason=None, ): - """견적 1건 시드(작성자=user_id). preferred_sp_yn·equal_bid_yn 으로 '이전 라운드가 어떤 사유로 재생성됐는지'를 표식한다 - (동가 재생성=equal_bid_yn True / 미참여 재생성=preferred_sp_yn False AND equal_bid_yn False).""" + """견적 1건 시드(작성자=user_id). close_reason 으로 '이전 라운드가 어떤 사유로 재생성됐는지'를 표식한다 + (재생성 한도 카운팅은 close_reason 의 REGEN_* 만 센다). preferred_sp_yn/equal_bid_yn 은 프론트 표시용.""" 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, preferred_sp_yn, equal_bid_yn) VALUES " + " round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn, close_reason) VALUES " "(:qt_id, :user_id, :qt_setting_id, :version_id, '견적A', :number, :type, :status, " - " :round, 0, :start_time, :end_time, false, :pref, :eq)" + " :round, 0, :start_time, :end_time, false, :pref, :eq, :creason)" ), { "qt_id": qt_id, "user_id": user_id, "qt_setting_id": uuid.uuid4(), "version_id": uuid.uuid4(), "number": number, "type": QuotationType.REQUOTE.value, "status": status, "round": round_, "start_time": PAST, "end_time": PAST, - "pref": preferred_sp_yn, "eq": equal_bid_yn, + "pref": preferred_sp_yn, "eq": equal_bid_yn, "creason": close_reason, }, ) return qt_id diff --git a/negodata/front/src/api/generated/model/closeReason.ts b/negodata/front/src/api/generated/model/closeReason.ts new file mode 100644 index 0000000..477629c --- /dev/null +++ b/negodata/front/src/api/generated/model/closeReason.ts @@ -0,0 +1,25 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +/** + * quotations.close_reason 코드값(SMALLINT). 마감 사유 — 재생성 한도 카운팅(REGEN_*)과 유찰 사유 구분에 쓴다. +기존 preferred_sp_yn/equal_bid_yn 2플래그로는 4상태만 표현돼 '목표초과 재협상'이 미참여와 충돌하고 유찰 사유가 뭉개짐 → 이 컬럼으로 명시. + */ +export type CloseReason = typeof CloseReason[keyof typeof CloseReason]; + + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CloseReason = { + AWARDED: 1, + REGEN_PRICE: 2, + REGEN_EQUAL: 3, + REGEN_NOSHOW: 4, + FAIL_PRICE: 5, + FAIL_EQUAL: 6, + FAIL_NOSHOW: 7, + FAIL_REJECT: 8, +} as const; diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 89ffeec..15890bd 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -27,6 +27,7 @@ export * from './chatMessageDataIndicatorValue'; export * from './chatMessageDataScript'; export * from './chatMessageDataStep'; export * from './chatSender'; +export * from './closeReason'; export * from './companyData'; export * from './companyUserData'; export * from './companyUserDataContactNumber'; @@ -81,6 +82,7 @@ export * from './notificationDataReadAt'; export * from './notificationDataRefQtId'; export * from './notificationDataRefSessionId'; export * from './notificationType'; +export * from './priceGateAction'; export * from './quotationCardData'; export * from './quotationCardDataCondition'; export * from './quotationCardDataEditScript'; @@ -93,6 +95,7 @@ export * from './quotationCardDataScript'; export * from './quotationCardDataType'; export * from './quotationCardDataWildCardId'; export * from './quotationData'; +export * from './quotationDataCloseReason'; export * from './quotationDataCreatedAt'; export * from './quotationDataCreatorName'; export * from './quotationDataEqualBidData'; @@ -204,6 +207,9 @@ export * from './reqUpdateMePassword'; export * from './reqUpdateQuotationSetting'; export * from './reqUpdateQuotationSettingAnchoringValue'; export * from './reqUpdateQuotationSettingCardCount'; +export * from './reqUpdateQuotationSettingMidAction'; +export * from './reqUpdateQuotationSettingOverAction'; +export * from './reqUpdateQuotationSettingRegenLimit'; export * from './reqUpdateQuotationSettingTargetMarginRate'; export * from './reqUpdateSupplier'; export * from './reqUpdateSupplierCode'; diff --git a/negodata/front/src/api/generated/model/priceGateAction.ts b/negodata/front/src/api/generated/model/priceGateAction.ts new file mode 100644 index 0000000..0a3287e --- /dev/null +++ b/negodata/front/src/api/generated/model/priceGateAction.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +/** + * quotation_settings 의 가격 구간별 처리 정책. '앵커링가<투찰가≤목표가'(mid) / '목표가<투찰가'(over) 구간에 적용. +(투찰가≤앵커링가 는 항상 낙찰이라 설정 없음.) + */ +export type PriceGateAction = typeof PriceGateAction[keyof typeof PriceGateAction]; + + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const PriceGateAction = { + AWARD: 1, + RENEGO: 2, + FAIL: 3, +} as const; diff --git a/negodata/front/src/api/generated/model/quotationData.ts b/negodata/front/src/api/generated/model/quotationData.ts index e8f0e8b..d2d1770 100644 --- a/negodata/front/src/api/generated/model/quotationData.ts +++ b/negodata/front/src/api/generated/model/quotationData.ts @@ -17,6 +17,7 @@ import type { QuotationDataPreferredSpId } from './quotationDataPreferredSpId'; import type { QuotationDataPreferredSpName } from './quotationDataPreferredSpName'; import type { QuotationDataEqualBidYn } from './quotationDataEqualBidYn'; import type { QuotationDataEqualBidData } from './quotationDataEqualBidData'; +import type { QuotationDataCloseReason } from './quotationDataCloseReason'; import type { QuotationDataItemId } from './quotationDataItemId'; import type { QuotationDataItemName } from './quotationDataItemName'; import type { QuotationDataCreatorName } from './quotationDataCreatorName'; @@ -47,6 +48,7 @@ export interface QuotationData { preferred_sp_name?: QuotationDataPreferredSpName; equal_bid_yn?: QuotationDataEqualBidYn; equal_bid_data?: QuotationDataEqualBidData; + close_reason?: QuotationDataCloseReason; participation_count?: number; item_id?: QuotationDataItemId; item_name?: QuotationDataItemName; diff --git a/negodata/front/src/api/generated/model/quotationDataCloseReason.ts b/negodata/front/src/api/generated/model/quotationDataCloseReason.ts new file mode 100644 index 0000000..18e5af7 --- /dev/null +++ b/negodata/front/src/api/generated/model/quotationDataCloseReason.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { CloseReason } from './closeReason'; + +export type QuotationDataCloseReason = CloseReason | null; diff --git a/negodata/front/src/api/generated/model/quotationSettingData.ts b/negodata/front/src/api/generated/model/quotationSettingData.ts index 8c49ff9..f6aab9a 100644 --- a/negodata/front/src/api/generated/model/quotationSettingData.ts +++ b/negodata/front/src/api/generated/model/quotationSettingData.ts @@ -5,6 +5,7 @@ * OpenAPI spec version: 0.1.0 */ import type { QuotationSettingDataUserId } from './quotationSettingDataUserId'; +import type { PriceGateAction } from './priceGateAction'; import type { QuotationSettingDataCreatedAt } from './quotationSettingDataCreatedAt'; import type { QuotationSettingDataUpdatedAt } from './quotationSettingDataUpdatedAt'; @@ -14,6 +15,9 @@ export interface QuotationSettingData { target_margin_rate: number; anchoring_value: number; card_count: number; + mid_action?: PriceGateAction; + over_action?: PriceGateAction; + regen_limit?: number; created_at?: QuotationSettingDataCreatedAt; updated_at?: QuotationSettingDataUpdatedAt; } diff --git a/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts b/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts index 4bc40a5..f3f39f0 100644 --- a/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts +++ b/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts @@ -4,9 +4,13 @@ * Negodata Api Server * OpenAPI spec version: 0.1.0 */ +import type { PriceGateAction } from './priceGateAction'; export interface ReqCreateQuotationSetting { target_margin_rate: number; anchoring_value?: number; card_count?: number; + mid_action?: PriceGateAction; + over_action?: PriceGateAction; + regen_limit?: number; } diff --git a/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts b/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts index 857bcd5..32ec724 100644 --- a/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts +++ b/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts @@ -7,9 +7,15 @@ import type { ReqUpdateQuotationSettingTargetMarginRate } from './reqUpdateQuotationSettingTargetMarginRate'; import type { ReqUpdateQuotationSettingAnchoringValue } from './reqUpdateQuotationSettingAnchoringValue'; import type { ReqUpdateQuotationSettingCardCount } from './reqUpdateQuotationSettingCardCount'; +import type { ReqUpdateQuotationSettingMidAction } from './reqUpdateQuotationSettingMidAction'; +import type { ReqUpdateQuotationSettingOverAction } from './reqUpdateQuotationSettingOverAction'; +import type { ReqUpdateQuotationSettingRegenLimit } from './reqUpdateQuotationSettingRegenLimit'; export interface ReqUpdateQuotationSetting { target_margin_rate?: ReqUpdateQuotationSettingTargetMarginRate; anchoring_value?: ReqUpdateQuotationSettingAnchoringValue; card_count?: ReqUpdateQuotationSettingCardCount; + mid_action?: ReqUpdateQuotationSettingMidAction; + over_action?: ReqUpdateQuotationSettingOverAction; + regen_limit?: ReqUpdateQuotationSettingRegenLimit; } diff --git a/negodata/front/src/api/generated/model/reqUpdateQuotationSettingMidAction.ts b/negodata/front/src/api/generated/model/reqUpdateQuotationSettingMidAction.ts new file mode 100644 index 0000000..34712e8 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateQuotationSettingMidAction.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { PriceGateAction } from './priceGateAction'; + +export type ReqUpdateQuotationSettingMidAction = PriceGateAction | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateQuotationSettingOverAction.ts b/negodata/front/src/api/generated/model/reqUpdateQuotationSettingOverAction.ts new file mode 100644 index 0000000..5c525f6 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateQuotationSettingOverAction.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { PriceGateAction } from './priceGateAction'; + +export type ReqUpdateQuotationSettingOverAction = PriceGateAction | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateQuotationSettingRegenLimit.ts b/negodata/front/src/api/generated/model/reqUpdateQuotationSettingRegenLimit.ts new file mode 100644 index 0000000..6790922 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateQuotationSettingRegenLimit.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 ReqUpdateQuotationSettingRegenLimit = number | null; diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx index 657dbcc..94af6da 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx @@ -6,19 +6,17 @@ import { cn } from '@/lib/utils'; import { typographyVariants } from '@/components/ui/typography'; import { InfoField } from './InfoField'; import { QuotationStatusBadge } from './StatusPill'; +import { ResultSummaryBand } from './ResultSummaryBand'; import type { QuotationData } from '@/api/generated/model/quotationData'; import { type Product, - type Partner, type QuotationSetting, - buildBidSummary, + type SessionView, quotationTypeLabel, + priceGateActionLabel, fmtDateTime, } from '../../types'; -const fmtYn = (b: boolean | null | undefined, yes: string, no: string) => - b == null ? '-' : b ? yes : no; - /** 헤더 정보 카드 컨테이너. ui/Card 의 넉넉한 기본 여백을 촘촘하게 덮어쓴다. */ function SectionCard({ title, children }: { title: string; children: ReactNode }) { return ( @@ -33,16 +31,17 @@ function SectionCard({ title, children }: { title: string; children: ReactNode } type DrawerHeaderCardsProps = { quotation: QuotationData; - partners: Partner[]; quotationSettings: QuotationSetting[]; + /** 협상 세션 뷰 — 결과 밴드가 낙찰가/절감 계산에 쓴다. */ + sessionViews: SessionView[]; /** 현재 선택 세션의 상품(없으면 상품 카드는 빈 상태). */ currentProduct: Product | undefined; }; export function DrawerHeaderCards({ quotation, - partners, quotationSettings, + sessionViews, currentProduct, }: DrawerHeaderCardsProps) { // Quotations DDL 표시값 @@ -57,33 +56,26 @@ export function DrawerHeaderCards({ : '-'; const q_memo = quotation.memo || '-'; - const bidSummaryObj = buildBidSummary(quotation, partners); const selectedSettingObj = quotationSettings.find((qs) => qs.qt_setting_id === quotation.qt_setting_id); - // 상품 상세 패널 행(negowiz 협상대화의 상품 정보 대응). negodata 컬럼명: maker_name→manufacturer, min_order_quantity→moq. - const productSpecRows = currentProduct + // 상품 카드는 '가격 관련' 값만 노출(모델명·규격·제조사 등 스펙은 상품 상세에서 확인). + const won = (n?: number | null) => (n != null ? `₩${Number(n).toLocaleString()}` : '-'); + const productPriceRows = currentProduct ? [ - { label: '상품코드', value: currentProduct.code || '-' }, - { label: '상품단가', value: currentProduct.price != null ? `₩${Number(currentProduct.price).toLocaleString()}` : '-' }, - { label: '매입가', value: currentProduct.purchase_price != null ? `₩${Number(currentProduct.purchase_price).toLocaleString()}` : '-' }, - { label: '판매가', value: currentProduct.selling_price != null ? `₩${Number(currentProduct.selling_price).toLocaleString()}` : '-' }, - { label: '인터넷 최저가', value: currentProduct.internet_lowest_price != null ? `₩${Number(currentProduct.internet_lowest_price).toLocaleString()}` : '-' }, - { label: '모델명', value: currentProduct.model_name || '-' }, - { label: '규격', value: currentProduct.spec || '-' }, - { label: '제조사', value: currentProduct.manufacturer || '-' }, - { label: '원산지', value: currentProduct.made_in || '-' }, - { label: 'MOQ', value: currentProduct.moq || '-' }, - { label: '리드타임', value: currentProduct.lead_time != null ? `${currentProduct.lead_time}일` : '-' }, - { label: 'VAT', value: fmtYn(currentProduct.vat_yn, '포함', '별도') }, - { label: '배송비', value: fmtYn(currentProduct.delivery_fee_yn, '포함', '별도') }, + { label: '상품단가', value: won(currentProduct.price) }, + { label: '매입가', value: won(currentProduct.purchase_price) }, + { label: '판매가', value: won(currentProduct.selling_price) }, + { label: '인터넷 최저가', value: won(currentProduct.internet_lowest_price) }, ] : []; return ( -
- {bidSummaryObj.equal_data}
-
-