diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index 349fdc7..e6207a0 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -190,13 +190,9 @@ class quotation_settings(MainTableMixin, MAIN_BASE): qt_setting_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) user_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 설정 소유 유저 - target_margin_rate = Column(Numeric(8, 6), nullable=False) - anchoring_value = Column(Numeric(8, 6), nullable=False, default=0.01) + target_margin_rate = Column(Numeric(8, 6), nullable=False) # 목표 마진율(목표가 산정에 사용) 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) # 재생성 최대 횟수(체인 전체 총합, 사유 무관: 목표초과/동가/미참여 합산) + # 낙찰 가격정책(mid/over/regen)은 견적 단위로 이관, 앵커링은 칸 rate(anchoring v1.2)로 대체 → 세팅 컬럼 제거됨. class quotations(MainTableMixin, MAIN_BASE): @@ -231,6 +227,11 @@ class quotations(MainTableMixin, MAIN_BASE): equal_bid_data = Column(JSONB, nullable=True) close_reason = Column(SmallInteger, nullable=True) # CloseReason 코드. 마감 시 사유 기록(재생성 한도 카운팅·유찰 사유 구분). 미마감이면 NULL + # 낙찰 기준(가격게이트) — 견적 단위. 마감 판정(close_and_decide)이 이 행값을 읽는다. 기준 미달이면 개찰(낙찰자 미정 마감). + # 1:1 협상: over 는 항상 OPEN(목표 초과=개찰), mid 만 앵커/목표 택1. 1:N 경매: mid=over=AWARD 강제(무조건 최저가 낙찰). + mid_action = Column(SmallInteger, nullable=False, server_default=text("1"), default=1) # PriceGateAction: 앵커링가<투찰가≤목표가 처리(1=낙찰/2=개찰) + over_action = Column(SmallInteger, nullable=False, server_default=text("1"), default=1) # PriceGateAction: 목표가<투찰가 처리(1=낙찰/2=개찰) + class sessions(MainTableMixin, MAIN_BASE): __tablename__ = "sessions" diff --git a/negodata/backend/common/enums.py b/negodata/backend/common/enums.py index 8fcabfb..82f2e2e 100644 --- a/negodata/backend/common/enums.py +++ b/negodata/backend/common/enums.py @@ -143,6 +143,12 @@ class QuotationType(CodeEnum): """신규(NEW_*) 견적유형이면 True. 목표가 후보(신규=인터넷최저가만)가 이 분기에 의존하므로 한 곳에서만 판단한다.""" return code in (cls.NEW_NEGO.value, cls.NEW_QUOTE.value) + @classmethod + def is_auction(cls, code) -> bool: + """1:N 경매(REQUOTE/NEW_QUOTE)면 True. 경매는 낙찰 가격정책 없이 '무조건 최저가 낙찰'(mid=over=AWARD 강제). + 나머지(RENEGO/NEW_NEGO)는 1:1 협상 — 사용자가 낙찰 기준을 정하고 협상카드가 발동한다.""" + return code in (cls.REQUOTE.value, cls.NEW_QUOTE.value) + class QuotationStatus(CodeEnum): """quotations.status 코드값(SMALLINT). 프론트 견적상태 뱃지와 매핑된다.""" @@ -165,33 +171,30 @@ class SessionStatus(CodeEnum): class CloseOutcome(Enum): """견적 마감 판정 결과(close_and_decide 반환값). 내부 제어·로그용 — DB 저장/프론트 노출 안 함.""" - AWARDED = "awarded" # 단독 낙찰 확정 - REGENERATED = "regenerated" # 다음 라운드 재생성 - CLOSED = "closed" # 그냥 마감 (선점 실패로 이미 닫혀 있던 경우 포함) - REGEN_FAILED = "regen_failed" # 재생성 시도했으나 실패 — 원본은 CLOSED 인데 다음 라운드가 없음(체인 끊김, 모니터링 필요) + AWARDED = "awarded" # 낙찰 확정(기준 충족 단독 최저가) + OPENED = "opened" # 개찰 — 낙찰자 미정으로 마감(자동 재협상/재생성·결렬 없음, 담당자 수동 처리) + CLOSED = "closed" # 그냥 마감 (선점 실패로 이미 닫혀 있던 경우 포함) class CloseReason(CodeEnum): - """quotations.close_reason 코드값(SMALLINT). 마감 사유 — 재생성 한도 카운팅(REGEN_*)과 유찰 사유 구분에 쓴다. - 기존 preferred_sp_yn/equal_bid_yn 2플래그로는 4상태만 표현돼 '목표초과 재협상'이 미참여와 충돌하고 유찰 사유가 뭉개짐 → 이 컬럼으로 명시.""" + """quotations.close_reason 코드값(SMALLINT). 마감 사유 — 낙찰(AWARDED) 또는 개찰(OPEN_*)로 가른다. + 개찰=결렬(유찰)이 아니라 '낙찰자 미정으로 마감' — 자동 재협상/재생성 없이 담당자가 수동 처리(수동 재생성 등)한다. + (구 자동재협상 사유 REGEN_*(2~4)·유찰 개념은 폐지. 사유 플래그 값 5~8은 보존해 OPEN_* 로 재명명.)""" - AWARDED = 1 # 단독 낙찰 - REGEN_PRICE = 2 # 가격 사유 재협상 (단독 최저가가 가격게이트 초과 → 낙찰 대신 다음 라운드로 더 깎기. 대표: 목표초과 재협상) - REGEN_EQUAL = 3 # 동가 재입찰 - REGEN_NOSHOW = 4 # 미참여 재소집 - FAIL_PRICE = 5 # 가격 사유 유찰 (가격게이트 초과인데 재협상 안 함/한도 소진) - FAIL_EQUAL = 6 # 동가 유찰 - FAIL_NOSHOW = 7 # 미참여 유찰 (한도 소진) - FAIL_REJECT = 8 # 거부 유찰 (협상거부 존재) + AWARDED = 1 # 낙찰 (기준 충족 단독 최저가) + OPEN_PRICE = 5 # 개찰: 최저가가 낙찰 기준 미달(목표 초과 등) → 낙찰자 미정 + OPEN_EQUAL = 6 # 개찰: 동가(최저가 동점) → 낙찰자 미정 + OPEN_NOSHOW = 7 # 개찰: 전원 미응찰 + OPEN_REJECT = 8 # 개찰: 협상거부 존재 class PriceGateAction(CodeEnum): - """quotation_settings 의 가격 구간별 처리 정책. '앵커링가<투찰가≤목표가'(mid) / '목표가<투찰가'(over) 구간에 적용. - (투찰가≤앵커링가 는 항상 낙찰이라 설정 없음.)""" + """낙찰 기준(견적 단위) 가격게이트 판정값. '앵커링가<투찰가≤목표가'(mid) / '목표가<투찰가'(over) 구간에 적용. + (투찰가≤앵커링가 는 항상 낙찰.) 기준 미달이면 개찰(낙찰자 미정 마감) — 재협상·결렬 없음. + 1:1 협상: over 는 항상 OPEN(목표 초과는 개찰), mid 만 앵커/목표 선택. 1:N 경매: mid=over=AWARD(무조건 최저가 낙찰).""" - AWARD = 1 # 낙찰 - RENEGO = 2 # 재협상(다음 라운드 재생성) - FAIL = 3 # 유찰 + AWARD = 1 # 낙찰(자동) + OPEN = 2 # 개찰(낙찰자 미정 마감) class NotificationType(CodeEnum): diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py index 0e77377..18612f9 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -425,15 +425,11 @@ 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, mid_action, over_action, regen_limit}. - 목표가·앵커링가 산정 입력 + 마감 가격게이트 정책. (인터넷 수수료는 상수)""" + """견적 세팅의 목표 마진율: {margin}. 목표가 산정 입력(인터넷 수수료는 상수). + (낙찰 정책은 견적 단위 이관, 앵커링은 칸 rate v1.2 → 세팅 컬럼 제거됨.)""" 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: @@ -443,10 +439,6 @@ class QuotationCRUD(IQuotationCRUD): r = rows[0] 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) diff --git a/negodata/backend/router/v1/quotation/protocol.py b/negodata/backend/router/v1/quotation/protocol.py index 0a9078e..a3e53a7 100644 --- a/negodata/backend/router/v1/quotation/protocol.py +++ b/negodata/backend/router/v1/quotation/protocol.py @@ -30,6 +30,10 @@ class Req_CreateQuotation(QuotationProtocol): item_ids: list[uuid.UUID] = [] # 협상 대상 상품. item×supplier 조합마다 세션 1개 생성 supplier_ids: list[uuid.UUID] = [] # 협상 초청 공급사 card_ids: list[uuid.UUID] = [] # 선택 협상카드. 버전을 만들어 묶고 quotation.version_id 로 연결 + # 낙찰 기준 — 1:1 협상만 프론트가 2전략(앵커까지/목표까지)을 mid/over 로 전개해 전송(over 는 항상 OPEN). + # 1:N 경매는 미전송 → 서버가 mid=over=AWARD 강제('무조건 최저가 낙찰'). + mid_action: Optional[int] = None # PriceGateAction: 앵커링가<투찰가≤목표가 처리 + over_action: Optional[int] = None # PriceGateAction: 목표가<투찰가 처리 class Req_RegenerateQuotation(QuotationProtocol): @@ -63,6 +67,8 @@ class QuotationData(WebPacketProtocol): equal_bid_yn: Optional[bool] = None equal_bid_data: Optional[Any] = None close_reason: Optional[CloseReason] = None # 마감 사유(CloseReason). 미마감이면 None + mid_action: Optional[int] = None # 낙찰 기준(견적 단위). 상세 드로어 낙찰기준 표시용 + over_action: Optional[int] = None participation_count: int = 0 # 견적별 참여 협력사 수(세션 distinct supplier). 목록 집계로 채움. item_id: Optional[uuid.UUID] = None # 대표 상품 id(세션의 첫 item). 목록 조인으로 채움. item_name: Optional[str] = None # 대표 상품명. 목록 조인으로 채움. @@ -199,7 +205,6 @@ class Res_TargetBreakdown(Res_WebPacketProtocol): selling: Optional[int] = None fee: float = 0.0 margin: float = 0.0 - anchoring_value: float = 0.0 candidates: list[TargetCandidate] = [] chosen_basis: Optional[str] = None target_price: int = 0 diff --git a/negodata/backend/router/v1/quotation_setting/protocol.py b/negodata/backend/router/v1/quotation_setting/protocol.py index bcedabf..f8e5f89 100644 --- a/negodata/backend/router/v1/quotation_setting/protocol.py +++ b/negodata/backend/router/v1/quotation_setting/protocol.py @@ -4,7 +4,6 @@ from typing import Optional from pydantic import ConfigDict -from common.enums import PriceGateAction from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol @@ -12,22 +11,15 @@ class QuotationSettingProtocol(WebPacketProtocol): pass +# 세팅은 목표 마진율·카드수만. 낙찰 정책(mid/over/regen)은 견적 단위 이관, 앵커링은 칸 rate(v1.2)로 대체 → 컬럼 제거됨. 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): @@ -36,11 +28,7 @@ class QuotationSettingData(WebPacketProtocol): qt_setting_id: uuid.UUID user_id: Optional[uuid.UUID] = None 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/scheduler/jobs.py b/negodata/backend/scheduler/jobs.py index c8b9966..2b5d94c 100644 --- a/negodata/backend/scheduler/jobs.py +++ b/negodata/backend/scheduler/jobs.py @@ -31,15 +31,15 @@ async def _close_each(service: QuotationService, qt_ids) -> Counter: def _format_results(results: Counter) -> str: return ( - f"낙찰 {results[CloseOutcome.AWARDED]} / 재생성 {results[CloseOutcome.REGENERATED]} / " - f"재생성실패 {results[CloseOutcome.REGEN_FAILED]} / 마감 {results[CloseOutcome.CLOSED]} / 오류 {results['error']}" + f"낙찰 {results[CloseOutcome.AWARDED]} / 개찰 {results[CloseOutcome.OPENED]} / " + f"마감 {results[CloseOutcome.CLOSED]} / 오류 {results['error']}" ) async def close_expired_quotations() -> int: """[잡①] 마감일이 지난 견적을 자동 마감 처리한다. 하루 한 번 실행. 대상: 마감 시각이 이미 지났는데 아직 마감되지 않은(삭제되지도 않은) 견적. - 처리: 견적마다 close_and_decide 로 결과 판정(낙찰 확정 / 다음 라운드 재생성 / 그냥 마감). + 처리: 견적마다 close_and_decide 로 결과 판정(낙찰 확정 / 개찰=낙찰자 미정 마감). 반환: 처리한 견적 수.""" crud = QuotationCRUD() service = QuotationService(crud) diff --git a/negodata/backend/scripts/seed_demo_quotations.sql b/negodata/backend/scripts/seed_demo_quotations.sql index 9be3f89..e67b46d 100644 --- a/negodata/backend/scripts/seed_demo_quotations.sql +++ b/negodata/backend/scripts/seed_demo_quotations.sql @@ -1,7 +1,7 @@ -- 견적상세 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=거부유찰 +-- close_reason(CloseReason): 1=낙찰, 5=가격개찰, 6=동가개찰, 7=미응찰개찰, 8=거부개찰 (개찰=낙찰자 미정 마감, 결렬 아님) BEGIN; DELETE FROM negotiation.sessions WHERE quotation_id IN ( @@ -29,7 +29,7 @@ DELETE FROM quotation.quotations WHERE qt_id IN ( 'aaaa0010-0000-0000-0000-000000000010' ); --- ── 견적 10건 (close_reason 8종 전부 + 진행중) ──────────────────────────── +-- ── 견적 10건 (close_reason 5종: 낙찰+개찰4 + 진행중) ──────────────────────────── 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, @@ -53,25 +53,25 @@ VALUES -- ③ 동가 재입찰(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, + '③ 동가 개찰 데모','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), + 6, 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, + '④ 거부 개찰 데모','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, + '⑤ 미응찰 개찰 데모','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), + 7, 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', @@ -82,21 +82,21 @@ VALUES -- ⑦ 가격 재협상(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, + '⑦ 목표초과 개찰 데모','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), + 5, 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, + '⑧ 목표초과 개찰 데모(2)','EST-DEMO-08',4,1,3, now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','목표 초과 → 유찰 케이스', 1, false, null, null, false, null, 5, 350000, 1, now(), now(), false), -- ⑨ 동가 유찰(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, + '⑨ 동가 개찰 데모(2)','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, @@ -104,7 +104,7 @@ VALUES -- ⑩ 미참여 유찰(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, + '⑩ 미응찰 개찰 데모(2)','EST-DEMO-10',1,2,3, now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','전원 미참여 + 재소집 한도 소진 → 유찰 케이스', 1, false, null, null, false, null, 7, 400000, 1, now(), now(), false); diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index 927990b..1b023ff 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -54,9 +54,6 @@ class QuotationService: # 기본 전략 버전(card.versions 시드). 견적 생성 시 version_id 미지정이면 이 값으로 채운다. DEFAULT_VERSION_ID = uuid.UUID("00000000-0000-0000-0000-000000000030") - # 재생성 한도: 한 체인(같은 견적번호)에서 사유(미참여/동가)별 최대 1번까지 재생성(순서 무관, 같은 사유 2번 불가). - MAX_REGEN_PER_CAUSE = 1 - # 재생성 라운드의 최소 협상기간(방어적 하한). 원본 협상기간이 비정상적으로 짧으면(또는 0/음수면) # 새 라운드가 생성 즉시 만료돼 다음 크론 tick(*/5분)에 또 마감되는 연쇄를 막는다. # 정상 견적(수 시간~수일)은 원본 기간을 그대로 쓰며, 이 하한은 비정상적으로 짧은 경우에만 적용된다. @@ -168,7 +165,6 @@ class QuotationService: rates = rates or {} fee = self.INTERNET_AVERAGE_FEE margin = rates.get("margin") or 0.0 - anchoring = rates.get("anchoring") or 0.0 is_new = QuotationType.is_new(quotation.type) md = quotation.md_price @@ -187,7 +183,6 @@ class QuotationService: res.selling = int(selling) if selling is not None else None res.fee = fee res.margin = margin - res.anchoring_value = anchoring res.candidates = [TargetCandidate(basis=b, label=self._CANDIDATE_LABELS.get(b, b), value=int(v)) for b, v in cands] res.chosen_basis = None if is_inherited else chosen_basis res.target_price = sess.target_price @@ -298,6 +293,8 @@ class QuotationService: item_ids=req.item_ids, supplier_ids=req.supplier_ids, card_ids=req.card_ids, + mid_action=req.mid_action, + over_action=req.over_action, ) if res.result.success: await create_notification( @@ -375,6 +372,8 @@ class QuotationService: item_ids=item_ids, supplier_ids=list(supplier_ids), card_ids=[], # 새 버전 안 만듦(원본 version_id 재사용) + mid_action=original.mid_action, # 낙찰 기준 상속(타입이 REQUOTE 로 바뀌면 빌더가 AWARD 로 재정규화) + over_action=original.over_action, inherited=inherited, # 직전 라운드 목표가 상속(앵커링가는 현재 rate 로 재계산) ) @@ -384,11 +383,21 @@ class QuotationService: type_: int, status: int, round_: int, start_time, end_time, manager_name, manager_email, manager_contact_number, memo, md_price, supplier_type, item_ids: list, supplier_ids: list, card_ids: list, + mid_action: Optional[int] = None, # 낙찰 기준(견적 단위). 앵커링가<투찰가≤목표가 처리(AWARD/OPEN) + over_action: Optional[int] = None, # 목표가<투찰가 처리(1:1 협상은 항상 OPEN) inherited: Optional[dict] = None, # 재생성 시 {item_id: target_price} 상속(KTC) — 목표가만. 앵커는 항상 재계산 ) -> Res_CreateQuotation: """견적 1건 + (상품×공급사) 세션들을 한 트랜잭션으로 생성하는 공통 빌더.""" res = Res_CreateQuotation() + # 낙찰 기준 정규화 — 1:N 경매는 항상 최저가 낙찰(mid=over=AWARD 강제). 1:1 협상은 요청값(미지정=AWARD). + # create/regenerate 양 경로가 이 빌더를 타므로 불변식을 여기 한 곳에서 강제한다(재생성 시 타입 전환도 자동 재정규화). + if QuotationType.is_auction(type_): + mid_action = over_action = PriceGateAction.AWARD.value + else: + mid_action = mid_action or PriceGateAction.AWARD.value + over_action = over_action or PriceGateAction.AWARD.value + # 세션 목표가 입력(상품별 인터넷최저가/매입가/판매가 + 세팅 율). 읽기 트랜잭션에서 먼저 조회. prices = {} if item_ids: @@ -455,6 +464,8 @@ class QuotationService: memo=memo, md_price=md_price, supplier_type=supplier_type, + mid_action=mid_action, + over_action=over_action, ) # 상품 × 공급사 조합마다 세션 1개. md/매입/판매/인터넷 후보가 하나도 없으면 목표가 산정 불가 → 생성 실패. @@ -557,11 +568,11 @@ class QuotationService: @staticmethod def _gate_action(bid, target, anchor, mid_action, over_action) -> int: - """가격게이트 판정 → PriceGateAction 코드. + """가격게이트 판정 → PriceGateAction 코드(AWARD=낙찰 / OPEN=개찰). bid ≤ 앵커링가 → 무조건 낙찰(AWARD) - 앵커링가 < bid ≤ 목표가 → mid_action(설정) - 목표가 < bid → over_action(설정) - target/bid 없으면(설정 불완전 등) AWARD 폴백(하위호환: 기존 '무조건 낙찰').""" + 앵커링가 < bid ≤ 목표가 → mid_action(견적 낙찰 기준) + 목표가 < bid → over_action(1:1 협상은 항상 OPEN=개찰) + target/bid 없으면(산정 불가 등) AWARD 폴백(최저가 그대로 낙찰).""" if bid is None or target is None: return PriceGateAction.AWARD.value bid = int(bid) @@ -571,9 +582,9 @@ class QuotationService: 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: + async def _close(self, qt_uuid, close_reason: int, data: Optional[dict] = None) -> None: """마감 공통: status→CLOSED + close_reason 기록 + (있으면)추가데이터 + 미완료(미시작·진행중) 세션→미참여. - close_reason(CloseReason)이 재생성 한도 카운팅·유찰 사유 구분의 단일 근거. + close_reason(CloseReason)이 낙찰/개찰 사유 구분의 단일 근거. preferred_sp_*/equal_bid_* 는 프론트 표시용으로 함께 채운다(사유 판별은 close_reason 이 담당).""" payload = {"status": QuotationStatus.CLOSED.value, "close_reason": close_reason} if data: @@ -588,28 +599,22 @@ class QuotationService: ], ) - 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 _open(self, qt_uuid, original, close_reason: int, reason: str, data: Optional[dict] = None) -> CloseOutcome: + """개찰 마감 — 낙찰자 미정으로 CLOSED + close_reason(OPEN_*) 기록 + 작성자 알림. 자동 재협상/재생성 없음(담당자 수동 처리). + 결렬(유찰) 아님. 알림 코드는 유지하되 프론트에서 '개찰'로 표기한다.""" + await self._close(qt_uuid, close_reason, data) + await create_notification( + original.user_id, NotificationType.FAILURE, + {"qt_name": original.name, "qt_number": original.number, "reason": reason}, + ref_qt_id=qt_uuid, + ) + return CloseOutcome.OPENED async def close_and_decide(self, qt_id) -> CloseOutcome: - """[마감] 견적을 마감하며 결과 판정. 낙찰자는 협상완료 최저가(단독/동가), 그 위에 회사 가격정책(가격게이트)으로 낙찰/재협상/유찰을 가른다. - - 단독 최저가: 가격게이트(≤앵커 무조건낙찰 / 앵커~목표 mid_action / 목표초과 over_action) → 낙찰 / 재협상(한도내) / 유찰 - - 동가: 그 가격 구간 설정이 '유찰'이 아니고 한도 남으면 재입찰(tie 해소), 유찰 설정이거나 한도 소진이면 유찰 - - 협상거부: 유찰 - - 전원 미참여: 재소집(한도내) / 유찰 - 재생성 한도: regen_limit = 체인(같은 견적번호) 전체 재생성 '총' 횟수(사유 무관). 마감 사유는 close_reason 에 기록(카운팅·유찰사유 구분의 단일 근거). + """[마감] 견적을 마감하며 결과 판정. 협상완료 단독 최저가가 낙찰 기준(가격게이트)을 통과할 때만 낙찰(AWARDED). + - 단독 최저가: ≤앵커 항상 낙찰 / 앵커~목표 mid_action / 목표초과 over_action(1:1 협상은 항상 OPEN=개찰). + - 그 외(기준 미달·동가·협상거부·전원 미응찰)는 결렬(유찰)이 아니라 개찰(OPEN_*) — 낙찰자 미정으로 마감. + 자동 재협상/재생성 없음. 다음 라운드는 담당자가 상세에서 수동 재생성(regenerate_quotation)한다. 공통: 원자적 status→CLOSED 선점, 미시작·진행중 세션→미참여.""" qt_uuid = qt_id if isinstance(qt_id, uuid.UUID) else uuid.UUID(str(qt_id)) @@ -617,7 +622,7 @@ class QuotationService: if err_type != ErrorType.SUCCESS or original is None: return CloseOutcome.CLOSED - # [동시 마감 가드] 원자적으로 status→CLOSED 선점. 실제로 전이한 호출자만 통과(이중 재생성·uq(number,round) 충돌 방지). + # [동시 마감 가드] 원자적으로 status→CLOSED 선점. 실제로 전이한 호출자만 통과. claim_err, claimed = await DB_SESSION_MNG.execute_lambda_claim( quotations.DBType(), lambda s: self.quotation_crud.claim_for_close(s, qt_uuid), @@ -636,29 +641,15 @@ class QuotationService: has_rejected = any(r.status == SessionStatus.REJECTED.value for r in rows) winner, equal = self._pick_winner(done) - # 가격게이트 입력: 회사 정책(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 + # 가격게이트 입력: 견적 단위 낙찰 기준(mid/over, 생성 시점 박제) + 세션 목표가/앵커링가(견적당 상품 1개라 세션 공통값) + mid_action = original.mid_action or PriceGateAction.AWARD.value + over_action = original.over_action or PriceGateAction.AWARD.value 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) 단독 최저가 → 가격게이트로 낙찰/재협상/유찰 + # 1) 단독 최저가가 낙찰 기준 통과 → 낙찰. 미달 → 개찰(가격). if winner is not 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"], @@ -671,70 +662,20 @@ class QuotationService: 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.FAILURE, - {"qt_name": original.name, "qt_number": original.number, "reason": "price"}, - ref_qt_id=qt_uuid, - ) - return CloseOutcome.CLOSED + # 개찰(가격) — 낙찰/동가 플래그는 NULL 로 둔다(대시보드 '개찰' 스코프가 preferred/equal 둘 다 NULL 로 집계). + return await self._open(qt_uuid, original, CloseReason.OPEN_PRICE.value, "price") - # 2) 동가 → 그 가격 구간 설정이 '유찰'이 아니고 한도 남으면 재입찰(tie 해소), 유찰 설정이거나 한도 소진이면 유찰. - # (동가는 단독 낙찰 불가 → AWARD 설정이어도 '재입찰로 tie 해소'가 자연스러워 기본은 재입찰=하위호환. 회사가 FAIL 로 두면 유찰.) + # 2) 동가(최저가 동점) → 개찰(동가). 낙찰자 미정. equal_bid_yn 으로 표기(대시보드 '동가' 스코프). 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.FAILURE, - {"qt_name": original.name, "qt_number": original.number, "reason": "equal"}, - ref_qt_id=qt_uuid, - ) - return CloseOutcome.CLOSED + return await self._open(qt_uuid, original, CloseReason.OPEN_EQUAL.value, "equal", + {"equal_bid_yn": True, "equal_bid_data": equal}) - # 3) 협상거부 있음 → 유찰 + # 3) 협상거부 있음 → 개찰(거부). if has_rejected: - 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 + return await self._open(qt_uuid, original, CloseReason.OPEN_REJECT.value, "rejected") - # 4) 전원 미참여 → 공급사 전체 재소집(한도 남을 때) - if not done and rows and regen_used < limit: - supplier_ids = list({r.supplier_id for r in rows}) - 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"}, - ref_qt_id=qt_uuid, - ) - return CloseOutcome.CLOSED - - 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_reasons(s, number, current_round), - ) - if err_type != ErrorType.SUCCESS: - 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) + # 4) 전원 미응찰 → 개찰(미응찰). + return await self._open(qt_uuid, original, CloseReason.OPEN_NOSHOW.value, "no_show") async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list) -> Res_CreateQuotation: """[프론트] 마감된 견적을 골라 수동으로 다음 라운드를 생성한다. diff --git a/negodata/backend/services/quotation_setting_service.py b/negodata/backend/services/quotation_setting_service.py index 392c63e..167763e 100644 --- a/negodata/backend/services/quotation_setting_service.py +++ b/negodata/backend/services/quotation_setting_service.py @@ -65,11 +65,7 @@ class QuotationSettingService: setting = quotation_settings( user_id=uuid.UUID(user_id), 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()], @@ -86,11 +82,6 @@ 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 01d65fc..1376cd7 100644 --- a/negodata/backend/tests/test_close_and_decide_fixes.py +++ b/negodata/backend/tests/test_close_and_decide_fixes.py @@ -1,17 +1,15 @@ -"""close_and_decide 동시성·정합성 수정 검증 (코드리뷰 후속). +"""close_and_decide 마감 판정 검증 — 낙찰(AWARDED) 또는 개찰(OPEN_*, 낙찰자 미정 마감). 검증 대상: - - #2 동시 이중 마감 가드: 같은 견적을 동시에 close_and_decide 해도 다음 라운드는 1개만 생성 - - #3 차수 매김: 다음 라운드 round = 체인 최신 round + 1 - - #4 재생성 사유 집계: 단독낙찰(preferred_sp_yn=True) 이전 라운드를 '미참여'로 오집계하지 않음 - - #6 재생성 라운드 최소 협상기간 하한(즉시 재마감 캐스케이드 방지) + - 동시 이중 마감 가드: 같은 견적을 동시에 close_and_decide 해도 실제 마감 전이는 1번만 + - 낙찰 기준 게이트: 단독 최저가가 기준 통과면 낙찰, 미달이면 개찰(가격) + - 동가 / 협상거부 / 전원 미응찰 → 각각 개찰(OPEN_EQUAL / OPEN_REJECT / OPEN_NOSHOW) -용어: 체인 = 같은 견적번호(number)로 이어지는 라운드들 / 미참여 = 공급사가 협상에 안 들어온 채 마감됨 / - 재생성 = 결판 안 난 견적의 '다음 라운드'를 자동 생성 / 재생성 한도 = 사유(미참여·동가)별로 체인당 1번까지만. +용어: 개찰 = 낙찰자 미정으로 마감(결렬 아님). 자동 재협상/재생성 없음 — 다음 라운드는 담당자가 수동 재생성. """ import asyncio import uuid -from datetime import datetime, timedelta +from datetime import datetime import pytest_asyncio from sqlalchemy import text @@ -30,149 +28,125 @@ async def clean(db_engine): return db_engine -async def test_concurrent_close_creates_only_one_next_round(clean): - """검증: 같은 견적(전원 미참여)을 5번 동시에 close_and_decide. - 기대결과: 재생성은 1번만(REGENERATED=1), 체인은 [1,2] — 이중 재생성/충돌 없음.""" +async def test_concurrent_close_single_transition(clean): + """검증: 전원 미응찰 견적을 5번 동시에 close_and_decide. + 기대결과: 실제 마감 전이(OPENED)는 1번만·나머지는 no-op(CLOSED), 체인 [1](자동 재생성 없음), close_reason=OPEN_NOSHOW.""" engine = clean number = "C-CONCURRENT" qt = await _seed_quotation(engine, number=number, round_=1, status=QuotationStatus.IN_PROGRESS.value) - # 전원 미참여(미시작 세션만) → close_and_decide 가 '다음 라운드 재생성' 경로를 탄다 + # 전원 미응찰(미시작 세션만) → 개찰(미응찰) 경로 await _add_session(engine, qt, status=SessionStatus.CREATED.value) await _add_session(engine, qt, status=SessionStatus.CREATED.value) service = QuotationService(QuotationCRUD()) outcomes = await asyncio.gather(*[service.close_and_decide(qt) for _ in range(5)]) - regenerated = sum(1 for o in outcomes if o == CloseOutcome.REGENERATED) + opened = sum(1 for o in outcomes if o == CloseOutcome.OPENED) rounds = await _rounds(engine, number) - round_numbers = [r.round for r in rounds] - - assert regenerated == 1, f"재생성은 1번만 일어나야 함, 실제 {regenerated} ({outcomes})" - assert round_numbers == [1, 2], f"체인은 [1,2] 여야 함(중복/충돌 없음), 실제 {round_numbers}" + assert opened == 1, f"실제 마감 전이는 1번만이어야 함, 실제 {opened} ({outcomes})" + assert [r.round for r in rounds] == [1], "자동 재생성 없음 — round 2 가 생기면 안 됨" + assert rounds[0].close_reason == CloseReason.OPEN_NOSHOW.value -async def test_next_round_numbering_and_min_duration(clean): - """검증: 협상기간이 0인 견적을 미참여로 재생성. - 기대결과: 체인 [1,2](round=최신+1), 새 라운드 협상기간 ≥ MIN_REGEN_DURATION(즉시 재마감 방지).""" +async def test_single_lowest_meets_target_awarded(clean): + """검증: 단독 최저가(bid=100)가 목표가(200) 이내 · 목표까지 낙찰(mid=AWARD). + 기대결과: AWARDED — 그 협력사로 낙찰(preferred_sp_yn=True), close_reason=AWARDED.""" engine = clean - number = "C-DURATION" - # start==end (협상기간 0) → 하한이 적용되지 않으면 새 라운드도 0 길이가 된다 - qt = await _seed_quotation( - engine, number=number, round_=1, status=QuotationStatus.IN_PROGRESS.value, - start_time=PAST, end_time=PAST, - ) - await _add_session(engine, qt, status=SessionStatus.CREATED.value) - - service = QuotationService(QuotationCRUD()) - outcome = await service.close_and_decide(qt) - - assert outcome == CloseOutcome.REGENERATED - rounds = await _rounds(engine, number) - assert [r.round for r in rounds] == [1, 2] - nxt = rounds[1] - duration = nxt.end_time - nxt.start_time - assert duration >= QuotationService.MIN_REGEN_DURATION, ( - f"재생성 라운드 협상기간({duration})이 최소 하한({QuotationService.MIN_REGEN_DURATION}) 이상이어야 함" - ) - - -async def test_awarded_prior_round_not_counted_as_no_show(clean): - """검증: round1=단독낙찰 + round2=전원 미참여 인 체인에서 round2 를 마감. - 기대결과: REGENERATED, 체인 [1,2,3] — 단독낙찰 라운드를 '미참여'로 오집계해 재생성을 막지 않는다.""" - engine = clean - number = "C-AWARDED-PRIOR" - # 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, close_reason=CloseReason.AWARDED.value, - ) - # round 2: 전원 미참여 → 미참여 재생성이 일어나야 한다(round 1 은 미참여로 세면 안 됨) - qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.IN_PROGRESS.value) - await _add_session(engine, qt2, status=SessionStatus.CREATED.value) - - service = QuotationService(QuotationCRUD()) - outcome = await service.close_and_decide(qt2) - - rounds = await _rounds(engine, number) - round_numbers = [r.round for r in rounds] - assert outcome == CloseOutcome.REGENERATED, ( - f"단독낙찰 이전 라운드는 미참여 예산을 소진하지 않아 round2 가 재생성돼야 함, 실제 {outcome}" - ) - assert round_numbers == [1, 2, 3], f"round 3 이 생성돼야 함, 실제 {round_numbers}" - - -async def test_no_show_prior_round_consumes_budget(clean): - """검증: round1=미참여 재생성 + round2=전원 미참여 인 체인에서 round2 를 마감. - 기대결과: CLOSED, 체인 [1,2] — 미참여 재생성 한도(1) 소진돼 재생성 없이 그냥 마감(round3 없음).""" - engine = clean - number = "C-NOSHOW-PRIOR" - # 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, close_reason=CloseReason.REGEN_NOSHOW.value, - ) - # round 2: 또 전원 미참여 → 한도 도달이라 재생성 없이 그냥 마감 - qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.IN_PROGRESS.value) - await _add_session(engine, qt2, status=SessionStatus.CREATED.value) - - service = QuotationService(QuotationCRUD()) - outcome = await service.close_and_decide(qt2) - - rounds = await _rounds(engine, number) - assert outcome == CloseOutcome.CLOSED, f"미참여 예산 소진 → 그냥 마감이어야 함, 실제 {outcome}" - 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) + qt = await _seed_quotation(engine, number="C-AWARD", round_=1, + status=QuotationStatus.IN_PROGRESS.value, + mid_action=PriceGateAction.AWARD.value, over_action=PriceGateAction.OPEN.value) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, target_price=200) 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}" + rounds = await _rounds(engine, "C-AWARD") + assert outcome == CloseOutcome.AWARDED, f"기준 통과 단독 최저가 → 낙찰이어야 함, 실제 {outcome}" + assert rounds[0].close_reason == CloseReason.AWARDED.value -async def test_over_target_renego_regenerates(clean): - """검증: over_action=재협상(RENEGO) 회사에서 단독 최저가가 목표 초과인 견적을 첫 라운드에 마감. - 기대결과: REGENERATED + 체인 [1,2] — 그 가격에 낙찰 안 하고 다음 라운드로 더 깎기(1차 close_reason=REGEN_PRICE).""" +async def test_over_target_opens(clean): + """검증: 단독 최저가(bid=200)가 목표가(100) 초과 · 목표초과=개찰(over=OPEN). + 기대결과: OPENED + close_reason=OPEN_PRICE — 낙찰 안 하고 개찰(자동 재생성 없이 체인 [1]).""" 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) + qt = await _seed_quotation(engine, number="C-OVER", round_=1, + status=QuotationStatus.IN_PROGRESS.value, + mid_action=PriceGateAction.AWARD.value, over_action=PriceGateAction.OPEN.value) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=200, target_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}" + rounds = await _rounds(engine, "C-OVER") + assert outcome == CloseOutcome.OPENED, f"목표초과 → 개찰이어야 함, 실제 {outcome}" + assert [r.round for r in rounds] == [1], "자동 재생성 없음(round 2 없음)" + assert rounds[0].close_reason == CloseReason.OPEN_PRICE.value -# ===== 헬퍼 (위 테스트들이 쓰는 도우미. 세션 상태·마감 표식을 SQL 로 직접 세팅) ===== +async def test_anchor_only_opens_within_target(clean): + """검증: '앵커링가까지만 낙찰'(mid=OPEN) 에서 앵커(80)<최저가(120)≤목표(200). + 기대결과: OPENED + OPEN_PRICE — 앵커 위 구간은 낙찰 안 하고 개찰.""" + engine = clean + qt = await _seed_quotation(engine, number="C-ANCHOR", round_=1, + status=QuotationStatus.IN_PROGRESS.value, + mid_action=PriceGateAction.OPEN.value, over_action=PriceGateAction.OPEN.value) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=120, + target_price=200, target_anchoring_price=80) + + outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt) + + rounds = await _rounds(engine, "C-ANCHOR") + assert outcome == CloseOutcome.OPENED + assert rounds[0].close_reason == CloseReason.OPEN_PRICE.value + + +async def test_equal_lowest_opens(clean): + """검증: 최저가 동점(둘 다 100, 목표 200 이내). + 기대결과: OPENED + close_reason=OPEN_EQUAL(낙찰자 미정) — 자동 재입찰 없음(체인 [1]).""" + engine = clean + qt = await _seed_quotation(engine, number="C-EQUAL", round_=1, status=QuotationStatus.IN_PROGRESS.value) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, target_price=200) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, target_price=200) + + outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt) + + rounds = await _rounds(engine, "C-EQUAL") + assert outcome == CloseOutcome.OPENED, f"동가 → 개찰이어야 함, 실제 {outcome}" + assert [r.round for r in rounds] == [1] + assert rounds[0].close_reason == CloseReason.OPEN_EQUAL.value + + +async def test_rejected_opens(clean): + """검증: 완료 투찰 없이 협상거부 세션만 존재. + 기대결과: OPENED + close_reason=OPEN_REJECT.""" + engine = clean + qt = await _seed_quotation(engine, number="C-REJECT", round_=1, status=QuotationStatus.IN_PROGRESS.value) + await _add_session(engine, qt, status=SessionStatus.REJECTED.value) + + outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt) + + rounds = await _rounds(engine, "C-REJECT") + assert outcome == CloseOutcome.OPENED, f"협상거부 → 개찰이어야 함, 실제 {outcome}" + assert rounds[0].close_reason == CloseReason.OPEN_REJECT.value + + +# ===== 헬퍼 (세션 상태·마감 표식을 SQL 로 직접 세팅) ===== async def _seed_quotation( engine, *, number, round_, status, start_time=PAST, end_time=PAST, preferred_sp_yn=None, equal_bid_yn=None, close_reason=None, qt_setting_id=None, + mid_action=1, over_action=1, ): - """견적 1건 시드. number/round_ 로 체인을, close_reason 으로 '이전 라운드가 어떤 사유로 마감/재생성됐는지'를 만든다 - (재생성 한도 카운팅은 close_reason 의 REGEN_* 만 센다). qt_setting_id 로 마감 가격정책(설정)을 연결. preferred_sp_yn/equal_bid_yn 은 프론트 표시용.""" + """견적 1건 시드. number/round_ 로 체인을, close_reason 으로 이전 라운드 마감 사유를 만든다. + 낙찰 기준(mid/over)은 견적 행에 직접 박제 — close_and_decide 가 이 행에서 읽는다(세팅 아님).""" 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, close_reason) VALUES " + " round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn, close_reason, " + " mid_action, over_action) VALUES " "(:qt_id, :user_id, :qt_setting_id, :version_id, :name, :number, :type, :status, " - " :round, 0, :start_time, :end_time, false, :pref, :eq, :creason)" + " :round, 0, :start_time, :end_time, false, :pref, :eq, :creason, " + " :mid, :over)" ), { "qt_id": qt_id, "user_id": uuid.uuid4(), "qt_setting_id": qt_setting_id or uuid.uuid4(), @@ -180,51 +154,38 @@ async def _seed_quotation( "type": QuotationType.REQUOTE.value, "status": status, "round": round_, "start_time": start_time, "end_time": end_time, "pref": preferred_sp_yn, "eq": equal_bid_yn, "creason": close_reason, + "mid": mid_action, "over": over_action, }, ) return qt_id -async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None): - """세션 1건 시드(공급사 협상 1건).""" +async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None, + target_price=0, target_anchoring_price=None): + """세션 1건 시드(공급사 협상 1건). target_price/앵커링가로 가격게이트 입력을 만든다.""" async with engine.begin() as conn: await conn.execute( text( "INSERT INTO sessions " "(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, " - " target_price, status, bid_price, end_time) VALUES " + " target_price, target_anchoring_price, status, bid_price, end_time) VALUES " "(:session_id, :quotation_id, :item_id, :supplier_id, :qt_number, :qt_round, :qt_type, " - " 0, :status, :bid_price, :end_time)" + " :target_price, :anchor, :status, :bid_price, :end_time)" ), { "session_id": uuid.uuid4(), "quotation_id": qt_id, "item_id": uuid.uuid4(), "supplier_id": supplier_id or uuid.uuid4(), "qt_number": "Q", "qt_round": 1, "qt_type": QuotationType.REQUOTE.value, "status": status, + "target_price": target_price, "anchor": target_anchoring_price, "bid_price": bid_price, "end_time": PAST, }, ) async def _rounds(engine, number): - """체인(number)의 (round, status, start_time, end_time, close_reason) 목록 — round 오름차순.""" + """체인(number)의 (round, status, close_reason) 목록 — round 오름차순.""" async with engine.begin() as conn: return (await conn.execute( - text("SELECT round, status, start_time, end_time, close_reason FROM quotations " - "WHERE number = :n ORDER BY round"), + text("SELECT round, status, 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 5540dae..1f58f35 100644 --- a/negodata/backend/tests/test_quotation_close_notify.py +++ b/negodata/backend/tests/test_quotation_close_notify.py @@ -1,15 +1,14 @@ """견적 마감(close_and_decide) 테스트 — 마감하면 상황별로 결과가 맞게 판정되고, 그 결과가 작성자에게 알림으로 남는지 확인. -핵심은 '재견적(다음 라운드 재생성)이 나오는 경우 vs 안 나오는 경우'의 구분이다. +마감 결과는 낙찰(AWARDED) 또는 개찰(OPENED, 낙찰자 미정 마감). 개찰은 결렬(유찰)이 아니며 자동 재협상/재생성도 없다. 각 경우에 (1) 판정이 맞고 (2) 작성자 알림함에 알맞은 알림 1건이 남는지 본다: - · 단독 최저가 → 낙찰 (SUCCESS) [재견적 X] - · 협상 거부 → 결렬 (FAILURE, reason=rejected) [재견적 X] - · 동가/미참여 + 한도 남음 → 재생성 (REGENERATED) [재견적 O] - · 동가/미참여 + 한도 소진 → 결렬 (FAILURE, reason=closed) [재견적 X] -재생성 한도: 사유(동가·미참여)별로 한 체인(같은 견적번호)에서 각 1번까지만. + · 단독 최저가(기준 통과) → 낙찰 (SUCCESS) + · 협상 거부 → 개찰 (알림 FAILURE, reason=rejected — 프론트에서 '개찰'로 표기) + · 동가 → 개찰 (알림 FAILURE, reason=equal) + · 전원 미응찰 → 개찰 (알림 FAILURE, reason=no_show) 공급사의 협상 결과(협상완료/거부/입찰가)는 협상 화면에서만 생기는 값이라 API 로 못 만든다 → SQL 로 직접 넣는다. -마감 판정 로직 자체를 더 깊게 파는 건 test_scheduler·test_close_and_decide_fixes. +마감 판정 로직 자체를 더 깊게 파는 건 test_close_and_decide_fixes. """ import uuid from datetime import datetime @@ -17,7 +16,7 @@ from datetime import datetime import pytest_asyncio from sqlalchemy import text -from common.enums import CloseOutcome, CloseReason, NotificationType, QuotationStatus, QuotationType, SessionStatus +from common.enums import CloseOutcome, NotificationType, QuotationStatus, QuotationType, SessionStatus from crud.quotation_crud import QuotationCRUD from services.quotation_service import QuotationService @@ -32,10 +31,9 @@ async def clean(db_engine): return db_engine -# ----- 재견적 X (낙찰·거부) ----- async def test_award_notifies_success(clean): - """검증: 협상완료 세션 2건(입찰 100·200) — 단독 최저가로 마감. - 기대결과: 재견적 X, 판정 = 낙찰(AWARDED) + 알림 SUCCESS(winner_price=100=최저가, ref_qt_id=그 견적).""" + """검증: 협상완료 세션 2건(입찰 100·200) — 단독 최저가로 마감(기본 낙찰 기준=최저가 낙찰). + 기대결과: 판정 = 낙찰(AWARDED) + 알림 SUCCESS(winner_price=100=최저가, ref_qt_id=그 견적).""" engine = clean user_id = uuid.uuid4() winner = uuid.uuid4() @@ -54,9 +52,9 @@ async def test_award_notifies_success(clean): assert str(ref) == str(qt) -async def test_rejected_notifies_failure(clean): +async def test_rejected_notifies_open(clean): """검증: 협상거부 세션만 있는 상태로 마감. - 기대결과: 재견적 X, 판정 = 결렬(CLOSED) + 알림 FAILURE(reason=rejected).""" + 기대결과: 판정 = 개찰(OPENED) + 알림(reason=rejected). 결렬 아님·자동 재생성 없음.""" engine = clean user_id = uuid.uuid4() qt = await _seed_quotation(engine, user_id=user_id, number="N-REJECT") @@ -64,7 +62,7 @@ async def test_rejected_notifies_failure(clean): outcome = await _service().close_and_decide(qt) - assert outcome == CloseOutcome.CLOSED + assert outcome == CloseOutcome.OPENED notis = await _notifications(engine, user_id) assert len(notis) == 1 type_, data, ref = notis[0] @@ -73,10 +71,9 @@ async def test_rejected_notifies_failure(clean): assert str(ref) == str(qt) -# ----- 재견적 O (동가·미참여, 한도 남음) ----- -async def test_equal_bid_regenerates(clean): - """검증: 협상완료 세션 2건이 '동가'(둘 다 100), 체인에 동가 재생성 이력 없음(한도 남음). - 기대결과: 재견적 O, 판정 = 재생성(REGENERATED) + 알림 REGENERATED(reason=equal, tied_price=100, next_round=2).""" +async def test_equal_bid_opens(clean): + """검증: 협상완료 세션 2건이 '동가'(둘 다 100). + 기대결과: 판정 = 개찰(OPENED, 낙찰자 미정) + 알림(reason=equal). 자동 재입찰 없음.""" engine = clean user_id = uuid.uuid4() qt = await _seed_quotation(engine, user_id=user_id, number="N-EQUAL") @@ -85,19 +82,17 @@ async def test_equal_bid_regenerates(clean): outcome = await _service().close_and_decide(qt) - assert outcome == CloseOutcome.REGENERATED + assert outcome == CloseOutcome.OPENED notis = await _notifications(engine, user_id) assert len(notis) == 1 type_, data, _ = notis[0] - assert type_ == NotificationType.REGENERATED.value + assert type_ == NotificationType.FAILURE.value assert data["reason"] == "equal" - assert data["tied_price"] == 100 - assert data["next_round"] == 2 -async def test_no_show_regenerates(clean): - """검증: 전원 미참여(미시작 세션만), 체인에 미참여 재생성 이력 없음(한도 남음). - 기대결과: 재견적 O, 판정 = 재생성(REGENERATED) + 알림 REGENERATED(reason=no_show, next_round=2).""" +async def test_no_show_opens(clean): + """검증: 전원 미응찰(미시작 세션만). + 기대결과: 판정 = 개찰(OPENED) + 알림(reason=no_show). 자동 재소집 없음.""" engine = clean user_id = uuid.uuid4() qt = await _seed_quotation(engine, user_id=user_id, number="N-NOSHOW") @@ -106,89 +101,32 @@ async def test_no_show_regenerates(clean): outcome = await _service().close_and_decide(qt) - assert outcome == CloseOutcome.REGENERATED + assert outcome == CloseOutcome.OPENED notis = await _notifications(engine, user_id) assert len(notis) == 1 type_, data, _ = notis[0] - assert type_ == NotificationType.REGENERATED.value + assert type_ == NotificationType.FAILURE.value assert data["reason"] == "no_show" - assert data["next_round"] == 2 -# ----- 재견적 X (동가·미참여지만 한도 소진 → 결렬) ----- -async def test_equal_bid_limit_exhausted_fails(clean): - """검증: 1차가 이미 '동가'로 재생성된 체인(동가 한도 1 소진)에서, 2차도 또 동가로 마감. - 기대결과: 재견적 X — 판정 = 결렬(CLOSED) + 알림 FAILURE(reason=closed).""" - engine = clean - user_id = uuid.uuid4() - # 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, - 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) - await _add_session(engine, qt2, status=SessionStatus.DONE.value, bid_price=100) - - outcome = await _service().close_and_decide(qt2) - - assert outcome == CloseOutcome.CLOSED # 동가 한도 소진 → 재생성 없이 결렬 - notis = await _notifications(engine, user_id) - assert len(notis) == 1 - type_, data, ref = notis[0] - assert type_ == NotificationType.FAILURE.value - assert data["reason"] == "equal" # 동가 유찰(FAIL_EQUAL) 사유 - assert str(ref) == str(qt2) - - -async def test_no_show_limit_exhausted_fails(clean): - """검증: 1차가 이미 '미참여'로 재생성된 체인(미참여 한도 1 소진)에서, 2차도 또 전원 미참여로 마감. - 기대결과: 재견적 X — 판정 = 결렬(CLOSED) + 알림 FAILURE(reason=closed).""" - engine = clean - user_id = uuid.uuid4() - # 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, - 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) - await _add_session(engine, qt2, status=SessionStatus.CREATED.value) - - outcome = await _service().close_and_decide(qt2) - - assert outcome == CloseOutcome.CLOSED # 미참여 한도 소진 → 재생성 없이 결렬 - notis = await _notifications(engine, user_id) - assert len(notis) == 1 - type_, data, ref = notis[0] - assert type_ == NotificationType.FAILURE.value - assert data["reason"] == "closed" - assert str(ref) == str(qt2) - - -# ===== 헬퍼 (위 테스트들이 쓰는 도우미. 세션 입찰값·이전 라운드 표식을 SQL 로 직접 세팅) ===== -async def _seed_quotation( - engine, *, user_id, number, round_=1, status=QuotationStatus.IN_PROGRESS.value, - preferred_sp_yn=None, equal_bid_yn=None, close_reason=None, -): - """견적 1건 시드(작성자=user_id). close_reason 으로 '이전 라운드가 어떤 사유로 재생성됐는지'를 표식한다 - (재생성 한도 카운팅은 close_reason 의 REGEN_* 만 센다). preferred_sp_yn/equal_bid_yn 은 프론트 표시용.""" +# ===== 헬퍼 (세션 입찰값을 SQL 로 직접 세팅) ===== +async def _seed_quotation(engine, *, user_id, number, round_=1, status=QuotationStatus.IN_PROGRESS.value): + """견적 1건 시드(작성자=user_id). 낙찰 기준 mid/over 는 서버 기본(AWARD)=최저가 낙찰.""" 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, close_reason) VALUES " + " round, iteration, start_time, end_time, deleted) VALUES " "(:qt_id, :user_id, :qt_setting_id, :version_id, '견적A', :number, :type, :status, " - " :round, 0, :start_time, :end_time, false, :pref, :eq, :creason)" + " :round, 0, :start_time, :end_time, false)" ), { "qt_id": qt_id, "user_id": user_id, "qt_setting_id": uuid.uuid4(), "version_id": uuid.uuid4(), "number": number, "type": QuotationType.REQUOTE.value, "status": status, "round": round_, "start_time": PAST, "end_time": PAST, - "pref": preferred_sp_yn, "eq": equal_bid_yn, "creason": close_reason, }, ) return qt_id diff --git a/negodata/backend/tests/test_quotation_create.py b/negodata/backend/tests/test_quotation_create.py index f8192ce..9c192d4 100644 --- a/negodata/backend/tests/test_quotation_create.py +++ b/negodata/backend/tests/test_quotation_create.py @@ -9,7 +9,7 @@ from datetime import datetime from sqlalchemy import text -from common.enums import QuotationType +from common.enums import PriceGateAction, QuotationType from crud.quotation_crud import QuotationCRUD from router.v1.quotation.protocol import Req_CreateQuotation from services.quotation_service import QuotationService @@ -65,11 +65,67 @@ async def test_create_without_price_fails(db_engine, company_id): assert rows == [] +async def test_auction_forces_lowest_price_award(db_engine, company_id): + """검증: 1:N 경매(NEW_QUOTE)에 낙찰 기준(OPEN/OPEN)을 실어 생성 요청. + 기대결과: 경매는 무조건 최저가 낙찰 — 견적 행에 mid=over=AWARD 로 강제 저장(요청값 무시).""" + item = await _seed_item(db_engine, company_id, internet_lowest=100_000) + + req = Req_CreateQuotation( + qt_setting_id=uuid.uuid4(), + name="경매정책강제", + type=QuotationType.NEW_QUOTE.value, + end_time=FUTURE, + item_ids=[item], + supplier_ids=[uuid.uuid4(), uuid.uuid4()], + mid_action=PriceGateAction.OPEN.value, # 경매엔 의미 없음 — 서버가 덮어야 함 + over_action=PriceGateAction.OPEN.value, + ) + res = await _service().create_quotation(str(uuid.uuid4()), req) + + assert res.result.success is True + mid, over = await _quotation_policy(db_engine, res.qt_id) + assert mid == PriceGateAction.AWARD.value, f"경매 mid_action 은 AWARD 강제여야 함, 실제 {mid}" + assert over == PriceGateAction.AWARD.value, f"경매 over_action 은 AWARD 강제여야 함, 실제 {over}" + + +async def test_nego_persists_award_criterion(db_engine, company_id): + """검증: 1:1 협상(NEW_NEGO)에 낙찰 기준(mid=AWARD/over=OPEN=목표까지 낙찰)을 실어 생성. + 기대결과: 요청값이 견적 행에 그대로 박제(협상은 사용자가 낙찰 기준을 정한다. 목표초과=개찰).""" + item = await _seed_item(db_engine, company_id, internet_lowest=100_000) + + req = Req_CreateQuotation( + qt_setting_id=uuid.uuid4(), + name="협상정책박제", + type=QuotationType.NEW_NEGO.value, + end_time=FUTURE, + item_ids=[item], + supplier_ids=[uuid.uuid4()], + mid_action=PriceGateAction.AWARD.value, + over_action=PriceGateAction.OPEN.value, + ) + res = await _service().create_quotation(str(uuid.uuid4()), req) + + assert res.result.success is True + mid, over = await _quotation_policy(db_engine, res.qt_id) + assert (mid, over) == ( + PriceGateAction.AWARD.value, PriceGateAction.OPEN.value, + ), f"협상 낙찰 기준이 그대로 저장돼야 함, 실제 {(mid, over)}" + + # ===== 헬퍼 (위 테스트들이 쓰는 도우미) ===== def _service(): return QuotationService(QuotationCRUD()) +async def _quotation_policy(engine, qt_id): + """생성된 견적의 낙찰 기준 (mid_action, over_action).""" + async with engine.begin() as conn: + return (await conn.execute( + text("SELECT mid_action, over_action FROM quotations WHERE qt_id = :qt"), + {"qt": qt_id}, + )).one() + + async def _seed_item(engine, company_id, *, internet_lowest): """상품 1건 시드(인터넷최저가만). category_type·internet_lowest_price_yn 은 NOT NULL — ORM default 는 raw INSERT 에 안 먹으므로 명시한다(conftest companies.status 와 같은 이유).""" diff --git a/postgres-init/01-schema_202607031710.sql b/postgres-init/01-schema_202607061200.sql similarity index 97% rename from postgres-init/01-schema_202607031710.sql rename to postgres-init/01-schema_202607061200.sql index 81be772..30fb496 100644 --- a/postgres-init/01-schema_202607031710.sql +++ b/postgres-init/01-schema_202607061200.sql @@ -247,11 +247,8 @@ CREATE TABLE IF NOT EXISTS quotation.quotation_settings ( qt_setting_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 견적 설정 식별자(PK) user_id uuid NOT NULL, -- 견적 설정을 생성한 유저 아이디(company.users.user_id) 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, -- 재생성 최대 횟수(체인 전체 총합, 사유 무관: 목표초과/동가/미참여 합산) + -- 낙찰 정책(mid/over/regen)은 견적 단위로 이관, 앵커링은 칸 rate(anchoring v1.2)로 대체 → 세팅 컬럼 없음 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 @@ -281,7 +278,9 @@ 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 + close_reason SMALLINT NULL, -- 마감 사유(CloseReason): 1=낙찰, 5=가격개찰, 6=동가개찰, 7=미응찰개찰, 8=거부개찰. 미마감이면 NULL + mid_action SMALLINT NOT NULL DEFAULT 1, -- 낙찰 기준(PriceGateAction 1=낙찰/2=개찰): 앵커링가<투찰가≤목표가 처리. 1:1 협상만 사용자 선택, 1:N 경매는 AWARD 강제 + over_action SMALLINT NOT NULL DEFAULT 1, -- 낙찰 기준(PriceGateAction 1=낙찰/2=개찰): 목표가<투찰가 처리(1:1 협상은 항상 개찰). 투찰가≤앵커링가는 항상 낙찰 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 diff --git a/postgres-init/04-alter_202607031710.sql b/postgres-init/04-alter_202607061200.sql similarity index 72% rename from postgres-init/04-alter_202607031710.sql rename to postgres-init/04-alter_202607061200.sql index 0c6d53d..48f513c 100644 --- a/postgres-init/04-alter_202607031710.sql +++ b/postgres-init/04-alter_202607061200.sql @@ -70,3 +70,27 @@ ALTER TABLE quotation.quotation_settings ADD COLUMN IF NOT EXISTS mid_action SMALLINT NOT NULL DEFAULT 1, -- 가격정책(PriceGateAction): 앵커링가<투찰가≤목표가 처리 ADD COLUMN IF NOT EXISTS over_action SMALLINT NOT NULL DEFAULT 1, -- 가격정책(PriceGateAction): 목표가<투찰가 처리 ADD COLUMN IF NOT EXISTS regen_limit SMALLINT NOT NULL DEFAULT 1; -- 재생성 최대 횟수(체인 전체 총합, 사유 무관) + +-- ───────────────────────────────────────────────────────────── +-- [2026-07-06] 낙찰 기준 견적 단위 이관 + 마감 개편(개찰 모델) — quotations(견적 행)에 낙찰 기준 mid/over 미러. +-- 마감 판정이 견적 행에서 읽는다. 기준 미달/동가/거부/미응찰은 결렬(유찰)이 아니라 개찰(낙찰자 미정 마감). +-- 자동 재협상/재생성 폐지 → regen_limit 제거(설정 템플릿 quotation_settings.regen_limit 은 미사용 잔존). +-- (신규 DB 는 01-schema*.sql 에 반영됨. 기존 행은 DEFAULT 1=AWARD 로 백필.) +ALTER TABLE quotation.quotations + ADD COLUMN IF NOT EXISTS mid_action SMALLINT NOT NULL DEFAULT 1, -- 낙찰 기준(PriceGateAction 1=낙찰/2=개찰): 앵커링가<투찰가≤목표가 처리 + ADD COLUMN IF NOT EXISTS over_action SMALLINT NOT NULL DEFAULT 1; -- 낙찰 기준(PriceGateAction 1=낙찰/2=개찰): 목표가<투찰가 처리(1:1 협상은 항상 개찰) +ALTER TABLE quotation.quotations + DROP COLUMN IF EXISTS regen_limit; -- 자동 재생성 폐지로 제거(먼저 추가됐던 dev DB 대비 멱등 DROP) + +-- 폐기된 close_reason 코드(구 REGEN_* 2~4) → 개찰(OPEN_*) 로 이관. 미이관 시 QuotationData(CloseReason enum) 검증 실패로 견적 목록 500. +UPDATE quotation.quotations SET close_reason = 5 WHERE close_reason = 2; -- REGEN_PRICE → OPEN_PRICE +UPDATE quotation.quotations SET close_reason = 6 WHERE close_reason = 3; -- REGEN_EQUAL → OPEN_EQUAL +UPDATE quotation.quotations SET close_reason = 7 WHERE close_reason = 4; -- REGEN_NOSHOW → OPEN_NOSHOW + +-- quotation_settings 정리 — 낙찰 정책(mid/over/regen)은 견적 단위 이관, 앵커링은 칸 rate(v1.2)로 대체 → 세팅 컬럼 제거. +-- 미제거 시 QuotationSettingData(PriceGateAction enum, 값 3=옛 FAIL) 검증 실패로 견적 세팅 목록 500. +ALTER TABLE quotation.quotation_settings + DROP COLUMN IF EXISTS mid_action, + DROP COLUMN IF EXISTS over_action, + DROP COLUMN IF EXISTS regen_limit, + DROP COLUMN IF EXISTS anchoring_value;