diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index c532691..caffd17 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -124,7 +124,7 @@ class suppliers(MainTableMixin, MAIN_BASE): manager_name = Column(String(50), nullable=True) manager_email = Column(String(255), nullable=True) manager_contact_number = Column(String(20), nullable=True) # ERD 오타(manger) 교정 - priority = Column(String(10), nullable=True) # True/False 가 아닌 string value 가능 + total_revenue = Column(BigInteger, nullable=True) # 총매출액(원) class nego_cards(MainTableMixin, MAIN_BASE): @@ -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 f144288..4ce3ec8 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/crud/supplier_crud.py b/negodata/backend/crud/supplier_crud.py index e0178a7..84e54a5 100644 --- a/negodata/backend/crud/supplier_crud.py +++ b/negodata/backend/crud/supplier_crud.py @@ -14,7 +14,7 @@ from common.utils.gtime import GTime # 협력사 CRUD. 모든 조회/변경은 company_id 로 스코프된다(멀티테넌트). class ISupplierCRUD(ABC): @abstractmethod - async def search(self, cdb: AsyncSession, company_id, search, priority, skip, limit) -> Tuple[ErrorType, list, int]: + async def search(self, cdb: AsyncSession, company_id, search, skip, limit) -> Tuple[ErrorType, list, int]: pass @abstractmethod @@ -44,7 +44,7 @@ class ISupplierCRUD(ABC): class SupplierCRUD(ISupplierCRUD): async def search( - self, cdb: AsyncSession, company_id, search: Optional[str], priority: Optional[str], skip: int, limit: int + self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int ) -> Tuple[ErrorType, list, int]: try: conditions = [suppliers.deleted == False, suppliers.company_id == company_id] # noqa: E712 @@ -56,8 +56,6 @@ class SupplierCRUD(ISupplierCRUD): suppliers.manager_name.ilike(f"%{search}%"), ) ) - if priority: - conditions.append(suppliers.priority == priority) where = and_(*conditions) cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(suppliers).where(where)) diff --git a/negodata/backend/router/v1/quotation/protocol.py b/negodata/backend/router/v1/quotation/protocol.py index 0d57776..e7cb321 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,7 @@ class Res_TargetBreakdown(Res_WebPacketProtocol): selling: Optional[int] = None fee: float = 0.0 margin: float = 0.0 - anchoring_value: float = 0.0 + anchoring_value: float = 0.0 # 앵커링율(비율). 세션 앵커링값(‰)을 /1000 환산 — main 프론트 표시용 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/router/v1/supplier/protocol.py b/negodata/backend/router/v1/supplier/protocol.py index c4b5165..5340c74 100644 --- a/negodata/backend/router/v1/supplier/protocol.py +++ b/negodata/backend/router/v1/supplier/protocol.py @@ -17,7 +17,7 @@ class Req_CreateSupplier(SupplierProtocol): manager_name: Optional[str] = None manager_email: Optional[str] = None manager_contact_number: Optional[str] = None - priority: Optional[str] = None + total_revenue: Optional[int] = None # 총매출액(원) class Req_UpdateSupplier(SupplierProtocol): @@ -26,7 +26,7 @@ class Req_UpdateSupplier(SupplierProtocol): manager_name: Optional[str] = None manager_email: Optional[str] = None manager_contact_number: Optional[str] = None - priority: Optional[str] = None + total_revenue: Optional[int] = None class SupplierData(WebPacketProtocol): @@ -40,7 +40,7 @@ class SupplierData(WebPacketProtocol): manager_name: Optional[str] = None manager_email: Optional[str] = None manager_contact_number: Optional[str] = None - priority: Optional[str] = None + total_revenue: Optional[int] = None # 총매출액(원) created_at: Optional[datetime] = None updated_at: Optional[datetime] = None diff --git a/negodata/backend/router/v1/supplier/supplier.py b/negodata/backend/router/v1/supplier/supplier.py index a556670..d1d81a9 100644 --- a/negodata/backend/router/v1/supplier/supplier.py +++ b/negodata/backend/router/v1/supplier/supplier.py @@ -24,10 +24,9 @@ async def list_suppliers( service: SupplierService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken), search: str | None = Query(None, description="협력사명/코드/담당자명 검색"), - priority: str | None = Query(None, description="우선순위 필터(HIGH/MEDIUM/LOW)"), pg: PageParams = Depends(), ): - return RemoveNoneResponse(await service.list_suppliers(user_info.company_id, search, priority, pg)) + return RemoveNoneResponse(await service.list_suppliers(user_info.company_id, search, pg)) @router.post(path="/create", response_model=Res_Supplier, summary="협력사 등록") 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 9f8f9f0..90d7755 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 ff2739c..d876f7f 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,11 +183,11 @@ 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 res.anchoring_price = sess.anchoring_price + res.anchoring_value = (sess.anchoring_value or 0) / 1000 # 세션 ‰ → 비율(main 프론트 '목표가×(1−값)' 표시용) return res async def list_quotations(self, company_id, owner, search, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList: @@ -298,6 +294,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 +373,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 +384,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 +465,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 +569,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 +583,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 +600,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 +623,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 +642,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.anchoring_price for r in rows if r.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 +663,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/services/supplier_service.py b/negodata/backend/services/supplier_service.py index 54941c2..6b52403 100644 --- a/negodata/backend/services/supplier_service.py +++ b/negodata/backend/services/supplier_service.py @@ -38,14 +38,14 @@ class SupplierService: return ErrorType.SUPPLIER_NOT_FOUND, None return ErrorType.SUCCESS, supplier - async def list_suppliers(self, company_id: str, search, priority, pg: PageParams) -> Res_SupplierList: + async def list_suppliers(self, company_id: str, search, pg: PageParams) -> Res_SupplierList: res = Res_SupplierList(page=pg.page, size=pg.size) company_uuid = uuid.UUID(company_id) err_type, rows, total = await DB_SESSION_MNG.execute_lambda( suppliers.DBType(), DBWRType.DB_READ.value, - lambda s: self.supplier_crud.search(s, company_uuid, search, priority, pg.skip, pg.size), + lambda s: self.supplier_crud.search(s, company_uuid, search, pg.skip, pg.size), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) @@ -105,7 +105,7 @@ class SupplierService: manager_name=req.manager_name, manager_email=req.manager_email, manager_contact_number=req.manager_contact_number, - priority=req.priority, + total_revenue=req.total_revenue, ) err_type = await DB_SESSION_MNG.execute_lambda_run( [suppliers.DBType()], diff --git a/negodata/backend/tests/test_close_and_decide_fixes.py b/negodata/backend/tests/test_close_and_decide_fixes.py index 01d65fc..b97af4b 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, 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, 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, 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": 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/negodata/front/src/api/generated/model/closeReason.ts b/negodata/front/src/api/generated/model/closeReason.ts index 477629c..ed560b2 100644 --- a/negodata/front/src/api/generated/model/closeReason.ts +++ b/negodata/front/src/api/generated/model/closeReason.ts @@ -6,8 +6,9 @@ */ /** - * 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_* 로 재명명.) */ export type CloseReason = typeof CloseReason[keyof typeof CloseReason]; @@ -15,11 +16,8 @@ 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, + OPEN_PRICE: 5, + OPEN_EQUAL: 6, + OPEN_NOSHOW: 7, + OPEN_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 26ef284..91d6398 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -82,7 +82,6 @@ export * from './notificationDataReadAt'; export * from './notificationDataRefQtId'; export * from './notificationDataRefSessionId'; export * from './notificationType'; -export * from './priceGateAction'; export * from './quotationCardData'; export * from './quotationCardDataCondition'; export * from './quotationCardDataEditScript'; @@ -107,6 +106,8 @@ export * from './quotationDataManagerEmail'; export * from './quotationDataManagerName'; export * from './quotationDataMdPrice'; export * from './quotationDataMemo'; +export * from './quotationDataMidAction'; +export * from './quotationDataOverAction'; export * from './quotationDataPreferredSpId'; export * from './quotationDataPreferredSpName'; export * from './quotationDataPreferredSpYn'; @@ -151,6 +152,8 @@ export * from './reqCreateQuotationManagerEmail'; export * from './reqCreateQuotationManagerName'; export * from './reqCreateQuotationMdPrice'; export * from './reqCreateQuotationMemo'; +export * from './reqCreateQuotationMidAction'; +export * from './reqCreateQuotationOverAction'; export * from './reqCreateQuotationSetting'; export * from './reqCreateQuotationStartTime'; export * from './reqCreateQuotationSupplierType'; @@ -160,7 +163,7 @@ export * from './reqCreateSupplierCode'; export * from './reqCreateSupplierManagerContactNumber'; export * from './reqCreateSupplierManagerEmail'; export * from './reqCreateSupplierManagerName'; -export * from './reqCreateSupplierPriority'; +export * from './reqCreateSupplierTotalRevenue'; export * from './reqLogin'; export * from './reqRegenerateQuotation'; export * from './reqUpdateCard'; @@ -205,11 +208,7 @@ export * from './reqUpdateMeEmail'; export * from './reqUpdateMeName'; 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'; @@ -217,7 +216,7 @@ export * from './reqUpdateSupplierManagerContactNumber'; export * from './reqUpdateSupplierManagerEmail'; export * from './reqUpdateSupplierManagerName'; export * from './reqUpdateSupplierName'; -export * from './reqUpdateSupplierPriority'; +export * from './reqUpdateSupplierTotalRevenue'; export * from './resCard'; export * from './resCardCard'; export * from './resCardList'; @@ -318,21 +317,21 @@ export * from './resSupplierListMsg'; export * from './resSupplierMsg'; export * from './resSupplierSupplier'; export * from './resTargetBreakdown'; +export * from './resTargetBreakdownAnchoringPrice'; export * from './resTargetBreakdownChosenBasis'; export * from './resTargetBreakdownInternetLowest'; export * from './resTargetBreakdownMdPrice'; export * from './resTargetBreakdownMsg'; export * from './resTargetBreakdownPurchase'; export * from './resTargetBreakdownSelling'; -export * from './resTargetBreakdownAnchoringPrice'; export * from './sessionData'; +export * from './sessionDataAnchoringPrice'; export * from './sessionDataBidAt'; export * from './sessionDataBidPrice'; export * from './sessionDataEmailSentAt'; export * from './sessionDataRejectDeliveryType'; export * from './sessionDataRejectPrice'; export * from './sessionDataRejectReason'; -export * from './sessionDataAnchoringPrice'; export * from './sessionStatus'; export * from './supplierData'; export * from './supplierDataCode'; @@ -340,7 +339,7 @@ export * from './supplierDataCreatedAt'; export * from './supplierDataManagerContactNumber'; export * from './supplierDataManagerEmail'; export * from './supplierDataManagerName'; -export * from './supplierDataPriority'; +export * from './supplierDataTotalRevenue'; export * from './supplierDataUpdatedAt'; export * from './supplierType'; export * from './targetCandidate'; diff --git a/negodata/front/src/api/generated/model/listSuppliersParams.ts b/negodata/front/src/api/generated/model/listSuppliersParams.ts index 856b4f6..f5e4545 100644 --- a/negodata/front/src/api/generated/model/listSuppliersParams.ts +++ b/negodata/front/src/api/generated/model/listSuppliersParams.ts @@ -10,10 +10,6 @@ export type ListSuppliersParams = { * 협력사명/코드/담당자명 검색 */ search?: string | null; -/** - * 우선순위 필터(HIGH/MEDIUM/LOW) - */ -priority?: string | null; /** * @minimum 1 */ diff --git a/negodata/front/src/api/generated/model/priceGateAction.ts b/negodata/front/src/api/generated/model/priceGateAction.ts deleted file mode 100644 index 0a3287e..0000000 --- a/negodata/front/src/api/generated/model/priceGateAction.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * 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 d2d1770..3b3f5fd 100644 --- a/negodata/front/src/api/generated/model/quotationData.ts +++ b/negodata/front/src/api/generated/model/quotationData.ts @@ -18,6 +18,8 @@ import type { QuotationDataPreferredSpName } from './quotationDataPreferredSpNam import type { QuotationDataEqualBidYn } from './quotationDataEqualBidYn'; import type { QuotationDataEqualBidData } from './quotationDataEqualBidData'; import type { QuotationDataCloseReason } from './quotationDataCloseReason'; +import type { QuotationDataMidAction } from './quotationDataMidAction'; +import type { QuotationDataOverAction } from './quotationDataOverAction'; import type { QuotationDataItemId } from './quotationDataItemId'; import type { QuotationDataItemName } from './quotationDataItemName'; import type { QuotationDataCreatorName } from './quotationDataCreatorName'; @@ -49,6 +51,8 @@ export interface QuotationData { equal_bid_yn?: QuotationDataEqualBidYn; equal_bid_data?: QuotationDataEqualBidData; close_reason?: QuotationDataCloseReason; + mid_action?: QuotationDataMidAction; + over_action?: QuotationDataOverAction; participation_count?: number; item_id?: QuotationDataItemId; item_name?: QuotationDataItemName; diff --git a/negodata/front/src/api/generated/model/supplierDataPriority.ts b/negodata/front/src/api/generated/model/quotationDataMidAction.ts similarity index 70% rename from negodata/front/src/api/generated/model/supplierDataPriority.ts rename to negodata/front/src/api/generated/model/quotationDataMidAction.ts index 33093cf..e187563 100644 --- a/negodata/front/src/api/generated/model/supplierDataPriority.ts +++ b/negodata/front/src/api/generated/model/quotationDataMidAction.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type SupplierDataPriority = string | null; +export type QuotationDataMidAction = number | null; diff --git a/negodata/front/src/api/generated/model/reqCreateSupplierPriority.ts b/negodata/front/src/api/generated/model/quotationDataOverAction.ts similarity index 69% rename from negodata/front/src/api/generated/model/reqCreateSupplierPriority.ts rename to negodata/front/src/api/generated/model/quotationDataOverAction.ts index fbb2d3b..90751ca 100644 --- a/negodata/front/src/api/generated/model/reqCreateSupplierPriority.ts +++ b/negodata/front/src/api/generated/model/quotationDataOverAction.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ReqCreateSupplierPriority = string | null; +export type QuotationDataOverAction = number | null; diff --git a/negodata/front/src/api/generated/model/quotationSettingData.ts b/negodata/front/src/api/generated/model/quotationSettingData.ts index f6aab9a..b8980ce 100644 --- a/negodata/front/src/api/generated/model/quotationSettingData.ts +++ b/negodata/front/src/api/generated/model/quotationSettingData.ts @@ -5,7 +5,6 @@ * 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'; @@ -13,11 +12,7 @@ export interface QuotationSettingData { qt_setting_id: string; user_id?: QuotationSettingDataUserId; 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/reqCreateQuotation.ts b/negodata/front/src/api/generated/model/reqCreateQuotation.ts index e51bce6..c750921 100644 --- a/negodata/front/src/api/generated/model/reqCreateQuotation.ts +++ b/negodata/front/src/api/generated/model/reqCreateQuotation.ts @@ -12,6 +12,8 @@ import type { ReqCreateQuotationManagerContactNumber } from './reqCreateQuotatio import type { ReqCreateQuotationMemo } from './reqCreateQuotationMemo'; import type { ReqCreateQuotationMdPrice } from './reqCreateQuotationMdPrice'; import type { ReqCreateQuotationSupplierType } from './reqCreateQuotationSupplierType'; +import type { ReqCreateQuotationMidAction } from './reqCreateQuotationMidAction'; +import type { ReqCreateQuotationOverAction } from './reqCreateQuotationOverAction'; export interface ReqCreateQuotation { qt_setting_id: string; @@ -31,4 +33,6 @@ export interface ReqCreateQuotation { item_ids?: string[]; supplier_ids?: string[]; card_ids?: string[]; + mid_action?: ReqCreateQuotationMidAction; + over_action?: ReqCreateQuotationOverAction; } diff --git a/negodata/front/src/api/generated/model/reqUpdateQuotationSettingRegenLimit.ts b/negodata/front/src/api/generated/model/reqCreateQuotationMidAction.ts similarity index 65% rename from negodata/front/src/api/generated/model/reqUpdateQuotationSettingRegenLimit.ts rename to negodata/front/src/api/generated/model/reqCreateQuotationMidAction.ts index 6790922..151a51c 100644 --- a/negodata/front/src/api/generated/model/reqUpdateQuotationSettingRegenLimit.ts +++ b/negodata/front/src/api/generated/model/reqCreateQuotationMidAction.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ReqUpdateQuotationSettingRegenLimit = number | null; +export type ReqCreateQuotationMidAction = number | null; diff --git a/negodata/front/src/api/generated/model/reqCreateQuotationOverAction.ts b/negodata/front/src/api/generated/model/reqCreateQuotationOverAction.ts new file mode 100644 index 0000000..553582d --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateQuotationOverAction.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 ReqCreateQuotationOverAction = number | null; diff --git a/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts b/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts index f3f39f0..31a2d7e 100644 --- a/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts +++ b/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts @@ -4,13 +4,8 @@ * 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/reqCreateSupplier.ts b/negodata/front/src/api/generated/model/reqCreateSupplier.ts index 9333986..6a47f23 100644 --- a/negodata/front/src/api/generated/model/reqCreateSupplier.ts +++ b/negodata/front/src/api/generated/model/reqCreateSupplier.ts @@ -8,7 +8,7 @@ import type { ReqCreateSupplierCode } from './reqCreateSupplierCode'; import type { ReqCreateSupplierManagerName } from './reqCreateSupplierManagerName'; import type { ReqCreateSupplierManagerEmail } from './reqCreateSupplierManagerEmail'; import type { ReqCreateSupplierManagerContactNumber } from './reqCreateSupplierManagerContactNumber'; -import type { ReqCreateSupplierPriority } from './reqCreateSupplierPriority'; +import type { ReqCreateSupplierTotalRevenue } from './reqCreateSupplierTotalRevenue'; export interface ReqCreateSupplier { name?: string; @@ -16,5 +16,5 @@ export interface ReqCreateSupplier { manager_name?: ReqCreateSupplierManagerName; manager_email?: ReqCreateSupplierManagerEmail; manager_contact_number?: ReqCreateSupplierManagerContactNumber; - priority?: ReqCreateSupplierPriority; + total_revenue?: ReqCreateSupplierTotalRevenue; } diff --git a/negodata/front/src/api/generated/model/reqCreateSupplierTotalRevenue.ts b/negodata/front/src/api/generated/model/reqCreateSupplierTotalRevenue.ts new file mode 100644 index 0000000..0935deb --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateSupplierTotalRevenue.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 ReqCreateSupplierTotalRevenue = number | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts b/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts index 32ec724..0070c42 100644 --- a/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts +++ b/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts @@ -5,17 +5,9 @@ * OpenAPI spec version: 0.1.0 */ 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/reqUpdateQuotationSettingAnchoringValue.ts b/negodata/front/src/api/generated/model/reqUpdateQuotationSettingAnchoringValue.ts deleted file mode 100644 index 75e7046..0000000 --- a/negodata/front/src/api/generated/model/reqUpdateQuotationSettingAnchoringValue.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * Negodata Api Server - * OpenAPI spec version: 0.1.0 - */ - -export type ReqUpdateQuotationSettingAnchoringValue = number | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateQuotationSettingMidAction.ts b/negodata/front/src/api/generated/model/reqUpdateQuotationSettingMidAction.ts deleted file mode 100644 index 34712e8..0000000 --- a/negodata/front/src/api/generated/model/reqUpdateQuotationSettingMidAction.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * Negodata Api Server - * OpenAPI spec version: 0.1.0 - */ -import type { 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 deleted file mode 100644 index 5c525f6..0000000 --- a/negodata/front/src/api/generated/model/reqUpdateQuotationSettingOverAction.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * Negodata Api Server - * OpenAPI spec version: 0.1.0 - */ -import type { PriceGateAction } from './priceGateAction'; - -export type ReqUpdateQuotationSettingOverAction = PriceGateAction | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateSupplier.ts b/negodata/front/src/api/generated/model/reqUpdateSupplier.ts index 952c2ca..312fe03 100644 --- a/negodata/front/src/api/generated/model/reqUpdateSupplier.ts +++ b/negodata/front/src/api/generated/model/reqUpdateSupplier.ts @@ -9,7 +9,7 @@ import type { ReqUpdateSupplierCode } from './reqUpdateSupplierCode'; import type { ReqUpdateSupplierManagerName } from './reqUpdateSupplierManagerName'; import type { ReqUpdateSupplierManagerEmail } from './reqUpdateSupplierManagerEmail'; import type { ReqUpdateSupplierManagerContactNumber } from './reqUpdateSupplierManagerContactNumber'; -import type { ReqUpdateSupplierPriority } from './reqUpdateSupplierPriority'; +import type { ReqUpdateSupplierTotalRevenue } from './reqUpdateSupplierTotalRevenue'; export interface ReqUpdateSupplier { name?: ReqUpdateSupplierName; @@ -17,5 +17,5 @@ export interface ReqUpdateSupplier { manager_name?: ReqUpdateSupplierManagerName; manager_email?: ReqUpdateSupplierManagerEmail; manager_contact_number?: ReqUpdateSupplierManagerContactNumber; - priority?: ReqUpdateSupplierPriority; + total_revenue?: ReqUpdateSupplierTotalRevenue; } diff --git a/negodata/front/src/api/generated/model/reqUpdateSupplierTotalRevenue.ts b/negodata/front/src/api/generated/model/reqUpdateSupplierTotalRevenue.ts new file mode 100644 index 0000000..cdbf249 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateSupplierTotalRevenue.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 ReqUpdateSupplierTotalRevenue = number | null; diff --git a/negodata/front/src/api/generated/model/supplierData.ts b/negodata/front/src/api/generated/model/supplierData.ts index ed9f4ee..6da4f05 100644 --- a/negodata/front/src/api/generated/model/supplierData.ts +++ b/negodata/front/src/api/generated/model/supplierData.ts @@ -8,7 +8,7 @@ import type { SupplierDataCode } from './supplierDataCode'; import type { SupplierDataManagerName } from './supplierDataManagerName'; import type { SupplierDataManagerEmail } from './supplierDataManagerEmail'; import type { SupplierDataManagerContactNumber } from './supplierDataManagerContactNumber'; -import type { SupplierDataPriority } from './supplierDataPriority'; +import type { SupplierDataTotalRevenue } from './supplierDataTotalRevenue'; import type { SupplierDataCreatedAt } from './supplierDataCreatedAt'; import type { SupplierDataUpdatedAt } from './supplierDataUpdatedAt'; @@ -21,7 +21,7 @@ export interface SupplierData { manager_name?: SupplierDataManagerName; manager_email?: SupplierDataManagerEmail; manager_contact_number?: SupplierDataManagerContactNumber; - priority?: SupplierDataPriority; + total_revenue?: SupplierDataTotalRevenue; created_at?: SupplierDataCreatedAt; updated_at?: SupplierDataUpdatedAt; } diff --git a/negodata/front/src/api/generated/model/reqUpdateSupplierPriority.ts b/negodata/front/src/api/generated/model/supplierDataTotalRevenue.ts similarity index 69% rename from negodata/front/src/api/generated/model/reqUpdateSupplierPriority.ts rename to negodata/front/src/api/generated/model/supplierDataTotalRevenue.ts index 08a6ed6..c20ccdf 100644 --- a/negodata/front/src/api/generated/model/reqUpdateSupplierPriority.ts +++ b/negodata/front/src/api/generated/model/supplierDataTotalRevenue.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ReqUpdateSupplierPriority = string | null; +export type SupplierDataTotalRevenue = number | null; diff --git a/negodata/front/src/features/dashboard/components/ScopeSection.tsx b/negodata/front/src/features/dashboard/components/ScopeSection.tsx index f6666c0..4994350 100644 --- a/negodata/front/src/features/dashboard/components/ScopeSection.tsx +++ b/negodata/front/src/features/dashboard/components/ScopeSection.tsx @@ -35,7 +35,7 @@ export function ScopeSection({
{errors.code.message}
} - {/* Priority */} + {/* 총매출액 */}