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 ( -
- {/* 좌측 컬럼: 견적정보 + 진행상태 */} -
- {/* Quotations */} +
+ {/* 결과 요약: 목표가 대비 낙찰/절감 */} + + +
+ {/* 견적 정보 */}
@@ -105,94 +97,67 @@ export function DrawerHeaderCards({
- {/* Bid Summary */} - -
- - - - - - - - {bidSummaryObj.equal_data} - - -
-
-
- - {/* 우측 컬럼: 상품정보 + 세팅 */} -
- {/* 상품 정보 (협상 대상 상품) */} - - {currentProduct ? ( -
-
- {currentProduct.image_url ? ( - {currentProduct.name - ) : ( - - )} -
-
- - {currentProduct.name || '-'} - -
- {productSpecRows.map((r) => ( - + + {currentProduct ? ( +
+
+ {currentProduct.image_url ? ( + {currentProduct.name - ))} + ) : ( + + )} +
+
+ + {currentProduct.name || '-'} + +
+ {productPriceRows.map((row) => ( + + ))} +
-
- ) : ( -
상품 정보가 비어있습니다.
- )} - + ) : ( +
상품 정보가 비어있습니다.
+ )} + - {/* Quotation Settings */} - - {selectedSettingObj ? ( -
- - - -
- ) : ( -
적용된 견적 세팅이 비어있습니다.
- )} -
+ + {selectedSettingObj ? ( +
+ + + + + + +
+ ) : ( +
적용된 견적 세팅이 비어있습니다.
+ )} +
+
); diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/PricingSpectrum.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/PricingSpectrum.tsx new file mode 100644 index 0000000..c2ecd8e --- /dev/null +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/PricingSpectrum.tsx @@ -0,0 +1,152 @@ +import { Fragment } from 'react'; +import { Typography } from '@/components/ui/typography'; +import { cn } from '@/lib/utils'; + +const won = (n?: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-'); +const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n)); + +type Tone = 'amber' | 'emerald' | 'zinc'; +const TONE_DOT: Record = { + amber: 'bg-amber-500', + emerald: 'bg-emerald-500', + zinc: 'bg-zinc-400', +}; +const TONE_TEXT: Record = { + amber: 'text-amber-600', + emerald: 'text-emerald-600', + zinc: 'text-muted-foreground', +}; + +type Point = { name: string; value: number; tone: Tone; strong?: boolean; place?: 'above' | 'below' }; + +/** + * 단가 조정 흐름 스펙트럼 — 얇은 바에 목표가·최저·최고 투찰가를 찍고 값 라벨을 바로 옆에 붙인다. + * 값은 buildQuotationResult 파생값(별도 컬럼 없음). 위치는 세 값 min~max 도메인 선형 배치라 목표가가 범위 밖이어도 안 깨진다. + * 라벨 방향(위/아래)은 값 순 정렬 후 번갈아 배치 → 위치가 가까운(정렬상 인접) 라벨끼리 항상 반대쪽이라 안 겹친다. + */ +export function PricingSpectrum({ + targetPrice, + lowestBid, + highestBid, +}: { + targetPrice: number | null; + lowestBid: number | null; + highestBid: number | null; +}) { + // 투찰 0건 → 목표가만 한 줄(폴백). lowestBid/highestBid 는 같은 배열에서 나와 함께 null. + if (lowestBid == null || highestBid == null) { + return ( + + 목표가{' '} + {won(targetPrice)} + · 투찰 없음 + + ); + } + + const singleBid = lowestBid === highestBid; // 투찰 1건 → 최저=최고, 라벨 하나로 축약. + + // 표시할 점(목표가는 없을 수 있음). 값 순으로 정렬해 라벨 방향을 번갈아(below/above) 주면 + // 위치가 가까운(정렬상 인접) 라벨끼리 항상 반대쪽에 놓여 안 겹친다. + const points: Point[] = [ + targetPrice != null ? { name: '목표가', value: targetPrice, tone: 'amber' } : null, + { name: singleBid ? '투찰가' : '최저 투찰가', value: lowestBid, tone: 'emerald', strong: true }, + singleBid ? null : { name: '최고 투찰가', value: highestBid, tone: 'zinc' }, + ].filter((p): p is Point => p != null); + + const vals = points.map((p) => p.value); + const min = Math.min(...vals); + const max = Math.max(...vals); + const span = max - min; + const posOf = (v: number) => (span > 0 ? ((v - min) / span) * 100 : 50); + + [...points] + .sort((a, b) => a.value - b.value) + .forEach((p, i) => { + p.place = i % 2 === 0 ? 'below' : 'above'; + }); + + return ( +
+
+ {/* 트랙 */} +
+ {/* 투찰 스프레드(최저~최고) */} + {!singleBid && ( +
+ )} + {points.map((p) => ( + + + + + ))} +
+
+ ); +} + +/** 트랙 위 점. 위치는 0~100% 로 클램프. */ +function Dot({ pos, tone, strong }: { pos: number; tone: Tone; strong?: boolean }) { + return ( +
+ ); +} + +/** 점 위/아래 한 줄 라벨(이름+금액). 양 끝에선 안쪽으로 정렬해 넘침 방지. */ +function BarLabel({ + pos, + place, + name, + value, + tone, + strong, +}: { + pos: number; + place: 'above' | 'below'; + name: string; + value: string; + tone: Tone; + strong?: boolean; +}) { + const align = + pos <= 8 ? 'translate-x-0 text-left' : pos >= 92 ? '-translate-x-full text-right' : '-translate-x-1/2 text-center'; + return ( +
+ + {name}{' '} + + + {value} + +
+ ); +} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ResultSummaryBand.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ResultSummaryBand.tsx new file mode 100644 index 0000000..5fbf282 --- /dev/null +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ResultSummaryBand.tsx @@ -0,0 +1,107 @@ +import type { ReactNode } from 'react'; +import { BadgeCheck, TrendingDown, TrendingUp } from 'lucide-react'; +import { Card } from '@/components/ui/card'; +import { Typography } from '@/components/ui/typography'; +import { cn } from '@/lib/utils'; +import type { QuotationData } from '@/api/generated/model/quotationData'; +import { StatusPill, type PillTone } from './StatusPill'; +import { PricingSpectrum } from './PricingSpectrum'; +import { + buildQuotationResult, + CHAIN_ROUND_STATE_LABEL, + type ChainRoundState, + type SessionView, +} from '../../types'; + +const won = (n?: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-'); + +// 결과 상태별 배지 톤(협상현황 pill 팔레트 재사용). +const OUTCOME_TONE: Record = { + awarded: 'emerald', + regenerated: 'amber', + failed: 'blue', + active: 'zinc', +}; + +/** 견적 결과 요약 밴드 — 마감 사유/낙찰 협력사 + 절감, 하단에 단가 조정 흐름 스펙트럼(최고·최저 투찰가/목표가). */ +export function ResultSummaryBand({ + quotation, + sessionViews, +}: { + quotation: QuotationData; + sessionViews: SessionView[]; +}) { + const r = buildQuotationResult(quotation, sessionViews); + const good = r.savings != null && r.savings >= 0; + const pct = r.savingsRate != null ? `${Math.abs(r.savingsRate * 100).toFixed(1)}%` : null; + const SavingsIcon = good ? TrendingDown : TrendingUp; + + return ( + + {/* 결과/사유 + 낙찰 협력사 + 절감 */} +
+
+ {CHAIN_ROUND_STATE_LABEL[r.outcome]} + {/* 낙찰 건은 협력사명을 주줄로(사유 '낙찰'은 pill 과 중복이라 생략), 그 외엔 마감사유를 표기. */} + {r.winnerName ? ( +
+ + 낙찰 협력사 + + + + {r.winnerName} + +
+ ) : ( + + {r.closeReason} + + )} +
+ + + {r.savings == null ? ( + + - + + ) : ( + + + {good ? '' : '−'} + {won(Math.abs(r.savings))} + {pct ? ` (${pct})` : ''} + + )} + +
+ + {/* 단가 조정 흐름 스펙트럼: 최고 투찰가 → 최저 투찰가 · 목표가 */} + +
+ ); +} + +/** 밴드 안의 라벨/값 한 칸. */ +function Stat({ label, sub, children }: { label: string; sub?: string; children: ReactNode }) { + return ( +
+ + {label} + {sub ? ` · ${sub}` : ''} + + {children} +
+ ); +} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx index 32e7e5d..dbf68a2 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx @@ -98,7 +98,7 @@ export function SessionsStatusTab({ 협력사 - 협상 URL + URL 초청메일 상품 협상상태 @@ -133,7 +133,7 @@ export function SessionsStatusTab({
- + {sess.url ? (
diff --git a/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx b/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx index e6cd150..a67b33a 100644 --- a/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx @@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'; import { Typography } from '@/components/ui/typography'; import { Input } from '@/components/ui/input'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; -import type { QuotationSetting } from '../types'; +import { type QuotationSetting, priceGateActionLabel, PRICE_GATE_ACTION_OPTIONS } from '../types'; import type { SettingInput } from '../hooks/useQuotations'; type QuotationSettingsModalProps = { @@ -25,16 +25,25 @@ export function QuotationSettingsModal({ const [targetMargin, setTargetMargin] = useState(''); const [anchoringValue, setAnchoringValue] = useState(''); const [cardUseCount, setCardUseCount] = useState(''); + const [midAction, setMidAction] = useState(1); // PriceGateAction 기본 낙찰 + const [overAction, setOverAction] = useState(1); + const [regenLimit, setRegenLimit] = useState('1'); if (!open) return null; const handleAdd = (e: React.FormEvent) => { e.preventDefault(); - const ok = onAdd({ targetMargin, anchoringValue, cardUseCount }); + const ok = onAdd({ + targetMargin, anchoringValue, cardUseCount, + midAction, overAction, regenLimit: parseInt(regenLimit, 10), + }); if (ok) { setTargetMargin(''); setAnchoringValue(''); setCardUseCount(''); + setMidAction(1); + setOverAction(1); + setRegenLimit('1'); } }; @@ -63,13 +72,16 @@ export function QuotationSettingsModal({ 목표 마진율 앵커링 값 카드 사용 횟수 + 앵커~목표 + 목표초과 + 재생성 삭제 {settings.length === 0 && ( - + 등록된 견적 세팅이 없습니다. (리스트가 비어 있습니다) @@ -79,6 +91,9 @@ export function QuotationSettingsModal({ {qs.target_margin} {qs.anchoring_value} {qs.card_use_count} + {priceGateActionLabel(qs.mid_action)} + {priceGateActionLabel(qs.over_action)} + {qs.regen_limit}회
+
+ 앵커링가~목표가 마감처리 + +
+
+ 목표가초과 마감처리 + +
+
+ 재생성 최대 횟수 (체인 전체) + setRegenLimit(e.target.value)} placeholder="예: 1" /> +
diff --git a/negodata/front/src/features/quotations/components/QuotationTable.tsx b/negodata/front/src/features/quotations/components/QuotationTable.tsx index c9006c0..e96909f 100644 --- a/negodata/front/src/features/quotations/components/QuotationTable.tsx +++ b/negodata/front/src/features/quotations/components/QuotationTable.tsx @@ -7,8 +7,11 @@ import { useQuotationChain } from '../hooks/useQuotationChain'; import { type Estimate, type Product, + type ChainRoundState, quotationStatusLabel, quotationTypeLabel, + chainRoundState, + quotationOutcomeLabel, CHAIN_ROUND_STATE_LABEL, } from '../types'; import { QuotationType, QuotationStatus } from '@/api/generated/model'; @@ -35,6 +38,20 @@ const statusBadgeClass = (status?: number | null) => { } }; +// 마감결과 배지 색. 낙찰=초록/재생성=주황/유찰=로즈/진행중=회색(아직 마감 전). +const outcomeBadgeClass = (state: ChainRoundState) => { + switch (state) { + case 'awarded': + return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/25 dark:text-emerald-400 border-emerald-300/50'; + case 'regenerated': + return 'bg-amber-50 text-amber-700 dark:bg-amber-950/25 dark:text-amber-400 border-amber-300/50'; + case 'failed': + return 'bg-rose-50 text-rose-700 dark:bg-rose-950/25 dark:text-rose-400 border-rose-300/50'; + default: + return 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800/40 dark:text-zinc-400 border-zinc-300/40'; + } +}; + export function QuotationTable({ data, products, onOpenDetail, onFilterChain, footer }: QuotationTableProps) { return ( ), }, + { + header: '마감결과', + align: 'center', + cell: (est) => { + const state = chainRoundState(est); // 배지 색 그룹 + return ( + + {quotationOutcomeLabel(est)} + + ); + }, + }, { header: '마감기한', cellClassName: 'font-mono text-muted-foreground whitespace-nowrap', diff --git a/negodata/front/src/features/quotations/hooks/useQuotationChain.ts b/negodata/front/src/features/quotations/hooks/useQuotationChain.ts index 008e6fa..57bf334 100644 --- a/negodata/front/src/features/quotations/hooks/useQuotationChain.ts +++ b/negodata/front/src/features/quotations/hooks/useQuotationChain.ts @@ -11,7 +11,7 @@ export type ChainRound = { // 같은 견적번호(체인)의 모든 차수를 라운드 오름차순으로 돌려준다. // 체인은 parent_id 없이 number 공유로만 묶이므로, 목록 검색(number ILIKE)으로 모은 뒤 // 정확매칭(q.number === number)으로 좁힌다 — search 가 이름까지 매칭하는 오염을 제거. -// 재생성은 사유별 1회 한도라 체인은 최대 ~3라운드 → size 한 번이면 전부 들어온다. +// 재생성은 체인 전체 총 한도(regen_limit)라 체인 라운드 수가 유한 → size 한 번이면 전부 들어온다. export function useQuotationChain(number?: string | null) { const enabled = !!number; const query = useListQuotations( diff --git a/negodata/front/src/features/quotations/hooks/useQuotations.ts b/negodata/front/src/features/quotations/hooks/useQuotations.ts index c077963..6e0b505 100644 --- a/negodata/front/src/features/quotations/hooks/useQuotations.ts +++ b/negodata/front/src/features/quotations/hooks/useQuotations.ts @@ -27,7 +27,7 @@ import { confirm } from '@/lib/confirm'; import { useAuthStore } from '@/stores/auth'; import type { Estimate } from '../types'; import { mapItem, mapSupplier, mapSetting, mapQuotation } from '../types'; -import { QuotationStatus } from '@/api/generated/model'; +import { QuotationStatus, type PriceGateAction } from '@/api/generated/model'; export type CreateQuotationInput = { title: string; @@ -46,6 +46,9 @@ export type SettingInput = { targetMargin: string; anchoringValue: string; cardUseCount: string; + midAction?: number; // PriceGateAction (앵커~목표 마감처리). 미지정=1(낙찰) + overAction?: number; // PriceGateAction (목표초과 마감처리). 미지정=1(낙찰) + regenLimit?: number; // 재생성 최대 횟수(체인 전체, 사유 무관). 미지정=1 }; // 견적 화면 데이터 허브. @@ -128,7 +131,12 @@ export function useQuotations(params: ListQuotationsParams) { return false; } createSettingMutation.mutate( - { data: { target_margin_rate: marginPct / 100, anchoring_value: anchoring, card_count: cardCount } }, + { data: { + target_margin_rate: marginPct / 100, anchoring_value: anchoring, card_count: cardCount, + mid_action: (input.midAction ?? 1) as PriceGateAction, + over_action: (input.overAction ?? 1) as PriceGateAction, + regen_limit: Number.isInteger(input.regenLimit) ? (input.regenLimit as number) : 1, + } }, { onSuccess: () => { invalidateSettings(); diff --git a/negodata/front/src/features/quotations/types.ts b/negodata/front/src/features/quotations/types.ts index c4cbe80..b9ff90b 100644 --- a/negodata/front/src/features/quotations/types.ts +++ b/negodata/front/src/features/quotations/types.ts @@ -4,7 +4,7 @@ import type { QuotationSettingData } from '@/api/generated/model/quotationSettin import type { QuotationData } from '@/api/generated/model/quotationData'; import type { SessionData } from '@/api/generated/model/sessionData'; import type { QuotationCardData } from '@/api/generated/model/quotationCardData'; -import { QuotationType, QuotationStatus, SessionStatus, CardType } from '@/api/generated/model'; +import { QuotationType, QuotationStatus, SessionStatus, CardType, CloseReason, PriceGateAction } from '@/api/generated/model'; import { DELIVERY_TYPE_LABEL } from '@/lib/enumLabels'; import type { Product, Partner, NegotiationCard } from '@/types'; @@ -33,11 +33,28 @@ export interface QuotationSetting { target_margin: string; anchoring_value: string; card_use_count: string; + mid_action: PriceGateAction; // 앵커링가<투찰가≤목표가 마감 처리 + over_action: PriceGateAction; // 목표가<투찰가 마감 처리 + regen_limit: number; // 재생성 최대 횟수(체인 전체, 사유 무관) created_at: string; updated_at: string; deleted: boolean; } +// 마감 가격정책 처리 라벨(견적세팅 표시·선택). +export const PRICE_GATE_ACTION_LABEL: Record = { + [PriceGateAction.AWARD]: '낙찰', + [PriceGateAction.RENEGO]: '재협상', + [PriceGateAction.FAIL]: '유찰', +}; +export const priceGateActionLabel = (a?: number | null): string => + a != null ? PRICE_GATE_ACTION_LABEL[a as PriceGateAction] ?? String(a) : ''; +export const PRICE_GATE_ACTION_OPTIONS = [ + PriceGateAction.AWARD, + PriceGateAction.RENEGO, + PriceGateAction.FAIL, +].map((value) => ({ value, label: PRICE_GATE_ACTION_LABEL[value] })); + // ── 서버 응답 → UI 모델 매퍼 ───────────────────────────────────────────── export function mapItem(it: ItemData): Product { @@ -65,6 +82,9 @@ export function mapSetting(s: QuotationSettingData): QuotationSetting { target_margin: `${Number.isFinite(ratePct) ? +ratePct.toFixed(2) : 0}%`, anchoring_value: String(s.anchoring_value ?? ''), card_use_count: `${s.card_count ?? 0}회`, + mid_action: s.mid_action ?? PriceGateAction.AWARD, + over_action: s.over_action ?? PriceGateAction.AWARD, + regen_limit: s.regen_limit ?? 1, created_at: s.created_at ?? '', updated_at: s.updated_at ?? '', deleted: false, @@ -159,33 +179,35 @@ export { SUPPLIER_TYPE_OPTIONS as supplierTypeOptions } from '@/lib/enumLabels'; // ── 라운드 체인(같은 견적번호) ─────────────────────────────────────────── // 한 라운드(견적)의 결과를 한 단어로. 낙찰=종료, 동가/마감=후속 라운드 가능, 진행중=아직 안 닫힘. -export type ChainRoundState = 'awarded' | 'equal' | 'closed' | 'active'; +export type ChainRoundState = 'awarded' | 'regenerated' | 'failed' | 'active'; export const CHAIN_ROUND_STATE_LABEL: Record = { awarded: '낙찰', - equal: '동가', - closed: '마감', + regenerated: '재생성', + failed: '유찰', active: '진행중', }; + +const REGEN_CLOSE_REASONS: CloseReason[] = [CloseReason.REGEN_PRICE, CloseReason.REGEN_EQUAL, CloseReason.REGEN_NOSHOW]; +const FAIL_CLOSE_REASONS: CloseReason[] = [CloseReason.FAIL_PRICE, CloseReason.FAIL_EQUAL, CloseReason.FAIL_NOSHOW, CloseReason.FAIL_REJECT]; + +// 마감결과 한 단어. 서버 close_reason(CloseReason) 을 '단일 근거'로 판정한다. +// close_reason 이 아직 없는 옛 데이터만 preferred_sp_id/equal_bid_yn 플래그로 폴백. +// QuotationData(전체)·Estimate(Partial) 둘 다 받도록 필요한 필드만 optional. export function chainRoundState( - q: Pick, + q: { status?: number | null; close_reason?: number | null; preferred_sp_id?: string | null; equal_bid_yn?: boolean | null }, ): ChainRoundState { + if (q.close_reason != null) { + if (q.close_reason === CloseReason.AWARDED) return 'awarded'; + if (REGEN_CLOSE_REASONS.includes(q.close_reason as CloseReason)) return 'regenerated'; + if (FAIL_CLOSE_REASONS.includes(q.close_reason as CloseReason)) return 'failed'; + } if (q.preferred_sp_id) return 'awarded'; - if (q.equal_bid_yn) return 'equal'; - if (q.status === QuotationStatus.CLOSED) return 'closed'; + if (q.equal_bid_yn) return 'regenerated'; + if (q.status === QuotationStatus.CLOSED) return 'failed'; return 'active'; } -// ── 상세 드로어용 파생 뷰 모델(서버 미연동 영역의 목업 보강 포함) ──────── - -export type BidSummaryView = { - bid_summary_id: string; - status: string; - qt_iteration: number; - has_preferred: boolean; - preferred_sp_id: string | null; - preferred_sp_name: string; - equal_data: string; -}; +// ── 상세 드로어용 파생 뷰 모델 ─────────────────────────────────────────── export type SessionView = { session_id: string; @@ -214,19 +236,94 @@ export type QuotationCardView = { type: string; }; -export function buildBidSummary(q: QuotationData, partners: Partner[]): BidSummaryView { +// ── 견적 결과 요약(목표가 대비 낙찰/절감) ───────────────────────────────── +// DB 저장값이 아니라 이미 로드된 quotation + 세션들로 파생한다(별도 컬럼·엔드포인트 없음). +// · 목표가 = md_price 우선(없으면 세션 목표가) +// · 낙찰가 = 우선협상자 세션의 투찰가(미낙찰이면 현재 최저 투찰가 = 잠정) +// · 절감 = 목표가 − 낙찰가 (양수 = 목표보다 저렴하게 낙찰) +export type QuotationResultView = { + outcome: ChainRoundState; + closeReason: string; + targetPrice: number | null; + targetBasis: 'md' | 'session' | null; + winnerName: string | null; + winnerPrice: number | null; + provisional: boolean; // true = 미낙찰 상태에서 보여주는 '현재 최저 투찰가' + lowestBid: number | null; // 투찰가 최소 — 스펙트럼 '최저 투찰가' + highestBid: number | null; // 투찰가 최대 — 스펙트럼 '최고 투찰가'(협상 시작점) + savings: number | null; + savingsRate: number | null; +}; + +export function buildQuotationResult(q: QuotationData, sessions: SessionView[]): QuotationResultView { const winnerId = q.preferred_sp_id ?? null; + const winnerSession = winnerId ? sessions.find((s) => s.supplier_id === winnerId) ?? null : null; + + // 투찰가 있는 세션들 중 최저 = 잠정 낙찰가(우선협상자 미확정 시). + const bids = sessions.map((s) => s.bid_price).filter((v): v is number => v != null); + const lowestBid = bids.length ? Math.min(...bids) : null; + const highestBid = bids.length ? Math.max(...bids) : null; + const winnerPrice = winnerSession?.bid_price ?? lowestBid; + const provisional = !winnerSession; + + const sessionTarget = + winnerSession?.target_price || sessions.find((s) => s.target_price > 0)?.target_price || null; + const targetPrice = q.md_price ?? sessionTarget; + const targetBasis: QuotationResultView['targetBasis'] = + q.md_price != null ? 'md' : sessionTarget != null ? 'session' : null; + + const savings = targetPrice != null && winnerPrice != null ? targetPrice - winnerPrice : null; + const savingsRate = savings != null && targetPrice ? savings / targetPrice : null; + return { - bid_summary_id: `bid-summary-${q.qt_id}`, - status: q.status === QuotationStatus.CLOSED ? '입찰종료 (COMPLETED)' : '입찰활성화 (ACTIVE)', - qt_iteration: q.iteration ?? 1, - has_preferred: !!winnerId, - preferred_sp_id: winnerId, - preferred_sp_name: q.preferred_sp_name || (winnerId ? partners.find((p) => p.id === winnerId)?.name || '-' : '-'), - equal_data: typeof q.equal_bid_data === 'string' ? q.equal_bid_data : '-', + outcome: chainRoundState(q), + closeReason: quotationCloseReason(q, sessions), + targetPrice, + targetBasis, + winnerName: q.preferred_sp_name || winnerSession?.supplier_name || null, + winnerPrice, + provisional, + lowestBid, + highestBid, + savings, + savingsRate, }; } +// 마감 사유 라벨. 서버 close_reason(CloseReason) 단일 근거로 표기. +export const CLOSE_REASON_LABEL: Record = { + [CloseReason.AWARDED]: '낙찰', + [CloseReason.REGEN_PRICE]: '목표초과 — 재협상 진행', + [CloseReason.REGEN_EQUAL]: '동가 — 재입찰 진행', + [CloseReason.REGEN_NOSHOW]: '전원 미참여 — 재소집 진행', + [CloseReason.FAIL_PRICE]: '목표초과로 유찰', + [CloseReason.FAIL_EQUAL]: '동가로 유찰', + [CloseReason.FAIL_NOSHOW]: '미참여로 유찰', + [CloseReason.FAIL_REJECT]: '협상 거부로 유찰', +}; + +// 마감결과 텍스트(정밀). 마감결과 배지·라벨은 이 값을 쓴다 — close_reason(CloseReason) 을 그대로 표기. +// 미마감=진행중, close_reason 없는 옛 데이터만 코스 상태 라벨로 폴백. (배지 '색'은 chainRoundState 로 그룹핑) +export function quotationOutcomeLabel( + q: { status?: number | null; close_reason?: number | null; preferred_sp_id?: string | null; equal_bid_yn?: boolean | null }, +): string { + if (q.status !== QuotationStatus.CLOSED) return '진행중'; + if (q.close_reason != null) return CLOSE_REASON_LABEL[q.close_reason as CloseReason] ?? '마감'; + return CHAIN_ROUND_STATE_LABEL[chainRoundState(q)]; +} + +// 마감 사유 한 줄. 서버 close_reason(CloseReason) 을 그대로 표기(단일 근거). +// close_reason 이 아직 없는 옛 데이터만 preferred_sp_yn/equal_bid_yn 플래그 + 세션상태로 폴백 되짚는다. +function quotationCloseReason(q: QuotationData, sessions: SessionView[]): string { + if (q.status !== QuotationStatus.CLOSED) return '협상 진행중'; + if (q.close_reason != null) return CLOSE_REASON_LABEL[q.close_reason as CloseReason] ?? '마감'; + if (q.preferred_sp_yn) return '낙찰'; + if (q.equal_bid_yn) return '동가 — 다음 라운드 재생성'; + if (q.preferred_sp_yn === false && q.equal_bid_yn === false) return '전원 미참여 — 다음 라운드 재생성'; + if (sessions.some((s) => s.status === SessionStatus.REJECTED)) return '협상 거부로 마감'; + return '마감'; +} + // ── 서버 연동 매퍼(negotiation.sessions / chats / 사용 카드) ────────────── // 세션상태 코드→라벨. 서버 /v1/enums(session_status) · SHARED_ENUMS.md 5-state 와 동일해야 한다. diff --git a/postgres-init/01-schema.sql b/postgres-init/01-schema.sql index 8dd1289..81be772 100644 --- a/postgres-init/01-schema.sql +++ b/postgres-init/01-schema.sql @@ -249,6 +249,9 @@ CREATE TABLE IF NOT EXISTS quotation.quotation_settings ( target_margin_rate NUMERIC(8,6) NOT NULL, -- 목표 마진율 (정수부 2자리 + 소수 6자리, -99.999999~99.999999) anchoring_value NUMERIC(8,6) NOT NULL DEFAULT 0.01, -- 앵커링 값 (정수부 2자리 + 소수 6자리) card_count INTEGER NOT NULL DEFAULT 3, -- 한개의 협상 안에서 협상카드 사용 횟수 + mid_action SMALLINT NOT NULL DEFAULT 1, -- 마감 가격정책(PriceGateAction): 앵커링가<투찰가≤목표가 처리(1=낙찰/2=재협상/3=유찰) + over_action SMALLINT NOT NULL DEFAULT 1, -- 마감 가격정책(PriceGateAction): 목표가<투찰가 처리(1=낙찰/2=재협상/3=유찰). 투찰가≤앵커링가는 항상 낙찰(설정없음) + regen_limit SMALLINT NOT NULL DEFAULT 1, -- 재생성 최대 횟수(체인 전체 총합, 사유 무관: 목표초과/동가/미참여 합산) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 @@ -278,6 +281,7 @@ CREATE TABLE IF NOT EXISTS quotation.quotations ( preferred_sp_name VARCHAR(20) NULL, -- 선호 공급사명(스냅샷) equal_bid_yn BOOLEAN NULL, -- 동일가 입찰 발생 여부 equal_bid_data JSONB NULL, -- 동일가 입찰 상세(JSON) + close_reason SMALLINT NULL, -- 마감 사유(CloseReason): 1=낙찰,2=가격재협상,3=동가재입찰,4=미참여재소집,5=가격유찰,6=동가유찰,7=미참여유찰,8=거부유찰. 미마감이면 NULL created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부