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({
- +
); diff --git a/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx b/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx index baeec92..dd74866 100644 --- a/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx +++ b/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx @@ -13,7 +13,7 @@ import { Badge } from '@/components/ui/badge'; import { Typography } from '@/components/ui/typography'; import { cn } from '@/lib/utils'; -// 신규 유저용 프로세스 안내. '흐름 설명'(6단계)·'견적 유형'(4종)·'마감 판정'(가격게이트+재생성 한도 결정표) 세 뷰. +// 신규 유저용 프로세스 안내. '흐름 설명'(6단계)·'견적 유형'(4종)·'마감 판정'(가격게이트+개찰 결정표) 세 뷰. // 읽기 전용 정적 안내(테이블/서버 없음). 첫 방문 자동 노출 + 대시보드 버튼으로 재오픈은 호출부(대시보드)에서 제어. type Actor = 'user' | 'partner' | 'system'; @@ -92,9 +92,9 @@ const STEPS: Step[] = [ actor: '시스템', actorType: 'system', icon: Award, - short: '단독최저+정책통과=낙찰 / 목표초과·동가·미참여=재생성 / 결렬', + short: '단독최저+기준통과=낙찰 / 그 외(목표가 초과·동가·미응찰·거부)=개찰', detail: - '낙찰 후보는 협상완료한 협력사 중 최저가입니다. 다만 최저가라고 바로 낙찰이 아니라, 그 최저가가 단독(동점 아님)이고 회사 가격정책의 가격게이트를 낙찰로 통과해야 낙찰됩니다. 최저가가 목표가 구간을 넘어 정책이 재협상이면 다음 차수로 더 깎고, 동가·전원 미참여도 다음 차수를 재생성합니다(모두 체인 전체 재생성 한도 안에서, 사유 무관 합산·기본 1회). 정책이 결렬이거나 한도를 소진하면, 또는 완료 투찰 없이 거부만 있으면 결렬됩니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.', + '낙찰 후보는 협상완료한 협력사 중 최저가입니다. 다만 최저가라고 바로 낙찰이 아니라, 그 최저가가 단독(동점 아님)이고 견적의 낙찰 기준(가격게이트)을 통과해야 낙찰됩니다. 낙찰 기준을 못 넘기거나(목표가 초과 등), 동가·전원 미응찰·협상거부인 경우는 결렬이 아니라 개찰 — 낙찰자 미정으로 마감됩니다. 개찰은 자동 재협상/재생성 없이 담당자가 상세에서 수동으로 다음 라운드를 생성하거나 처리합니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.', }, ]; @@ -104,14 +104,16 @@ const ACTOR_CLASS: Record = { system: 'text-muted-foreground', }; -// ----- 견적 마감 판정(close_and_decide 기준): 낙찰 후보=협상완료 최저가 → (1)단독/동가/미완료 (2)가격게이트(mid/over_action) (3)재생성 한도(체인 전체 총·사유무관, regen_limit)로 낙찰/재생성/결렬. 아래 결정표 8행 = close_reason 8종 ----- -type Tone = 'win' | 'tie' | 'fail'; +// ----- 견적 마감 판정(close_and_decide): 낙찰 후보=협상완료 최저가 → 단독 최저가가 낙찰 기준(가격게이트) 통과면 낙찰, 아니면 개찰. +// 동가·협상거부·전원 미응찰도 결렬이 아니라 개찰(낙찰자 미정 마감). 자동 재협상/재생성 없음 — 다음 라운드는 담당자가 수동 재생성. +// 결정표 = close_reason 5종(낙찰 + 개찰 4: 가격/동가/미응찰/거부). ----- +type Tone = 'win' | 'open'; -// (1) 가격게이트: 투찰가가 앵커링가/목표가 대비 어느 구간이냐로 회사 정책(mid/over_action) 적용. +// (1) 가격게이트(1:1 협상): 투찰가가 앵커링가/목표가 대비 어느 구간이냐로 견적 낙찰 기준 적용. 미달이면 개찰. const GATE_ZONES: { range: string; action: string; note: string }[] = [ { range: '투찰가 ≤ 앵커링가', action: '무조건 낙찰', note: '고정 · 설정 불가' }, - { range: '앵커링가 < 투찰가 ≤ 목표가', action: '견적세팅 mid_action 대로', note: '기본값 = 낙찰' }, - { range: '목표가 < 투찰가', action: '견적세팅 over_action 대로', note: '기본값 = 낙찰' }, + { range: '앵커링가 < 투찰가 ≤ 목표가', action: '낙찰 기준대로 (낙찰 / 개찰)', note: '목표가까지 낙찰이면 낙찰 · 앵커링가까지면 개찰' }, + { range: '목표가 < 투찰가', action: '개찰', note: '항상 개찰(목표가 초과)' }, ]; // (2) 완료 양상별 결정. rows 는 위에서부터 순서대로 판정(close_and_decide 분기 순서와 동일). @@ -131,34 +133,30 @@ const DECISION_TREE: DecisionGroup[] = [ group: '단독 최저가', desc: '협상완료 협력사 중 최저가가 한 곳', rows: [ - { cond: '가격게이트 = 낙찰', result: '낙찰', tone: 'win' }, - { cond: '가격게이트 = 재협상 · 재생성 한도 남음', result: '재협상 · 더 깎기', tone: 'tie' }, - { cond: '가격게이트 = 결렬, 또는 재협상인데 한도 소진', result: '결렬', tone: 'fail' }, + { cond: '낙찰 기준 통과(앵커 이하 · 또는 목표가 이내·목표가까지 낙찰)', result: '낙찰', tone: 'win' }, + { cond: '낙찰 기준 미달(목표가 초과 등)', result: '개찰 · 낙찰자 미정', tone: 'open' }, ], }, { group: '동가', desc: '최저가가 2곳 이상 동일 → 단독 낙찰 불가', rows: [ - { cond: '가격게이트 ≠ 결렬 · 한도 남음', result: '재입찰 · 동가끼리', tone: 'tie' }, - { cond: '가격게이트 = 결렬, 또는 한도 소진', result: '결렬', tone: 'fail' }, + { cond: '항상', result: '개찰 · 낙찰자 미정', tone: 'open' }, ], }, { group: '완료한 투찰 없음', desc: '아무도 협상을 완료(투찰 확정)하지 않음', rows: [ - { cond: '거부한 협력사가 있음', result: '결렬', tone: 'fail' }, - { cond: '전원 미참여 · 한도 남음', result: '재소집 · 공급사 전체', tone: 'tie' }, - { cond: '전원 미참여 · 한도 소진', result: '결렬', tone: 'fail' }, + { cond: '거부한 협력사가 있음', result: '개찰 · 거부', tone: 'open' }, + { cond: '전원 미응찰', result: '개찰 · 미응찰', tone: 'open' }, ], }, ]; const TONE_CHIP: Record = { win: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', - tie: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', - fail: 'bg-destructive/10 text-destructive', + open: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', }; // ----- 견적 유형(4종): 두 축으로 갈림 — 신규/재(목표가 산정 후보) × 협상 1:1 / 견적 1:N(부르는 협력사 수). @@ -434,16 +432,16 @@ export function OnboardingGuideModal({ 자동 - 낙찰 후보는 협상완료한 협력사 중 최저가입니다. 최저가라고 곧 낙찰이 아니라, 그 가격을 견적세팅의 가격정책(가격게이트)에 통과시켜 낙찰 · 재협상 · 결렬이 갈립니다. + 낙찰 후보는 협상완료한 협력사 중 최저가입니다. 최저가라고 곧 낙찰이 아니라, 그 가격을 견적의 낙찰 기준(가격게이트)에 통과시켜 낙찰 · 개찰이 갈립니다. - {/* 핵심: 처리 방식은 견적세팅이 정하고, 기본은 낙찰 */} + {/* 핵심: 낙찰 기준은 견적 생성 시 정하고, 미달은 개찰 */}
- 낙찰 · 재협상 · 결렬은 견적세팅에서 정합니다 + 낙찰 기준은 견적 생성 시 정합니다 (1:1 협상) - 각 가격 구간을 어떻게 처리할지는 견적세팅의 가격정책(mid_action · over_action)에서 지정합니다. 기본값은 전 구간 낙찰이라, 따로 바꾸지 않으면 목표가를 초과해도 낙찰됩니다. + 1:1 협상은 낙찰선을 앵커링가까지 / 목표가까지 중 택1합니다. 앵커링가 이하는 항상 낙찰, 목표가 초과는 항상 개찰이며, 그 사이 구간만 이 선택으로 갈립니다. 낙찰 기준을 못 넘기면 결렬이 아니라 개찰(낙찰자 미정 마감)이고, 자동 재협상/재생성은 없습니다. 1:N 견적은 무조건 최저가 낙찰.
@@ -509,13 +507,13 @@ export function OnboardingGuideModal({ ))} - {/* 3) 재생성 한도 */} + {/* 3) 개찰 처리 */}
- 3) 재생성 한도 + 3) 개찰(낙찰자 미정 마감) - 재협상 · 재입찰 · 재소집은 모두 다음 차수를 자동 생성합니다. 한도(regen_limit)는 체인(같은 견적번호) 전체에서 사유 무관 합산한 총 횟수이며 기본 1회이고, 소진하면 결렬됩니다. 모든 결과는 알림함으로 통지됩니다. + 낙찰이 아닌 마감(기준 미달·동가·미응찰·거부)은 결렬이 아니라 개찰입니다. 자동 재협상/재생성은 없고, 다음 라운드가 필요하면 담당자가 상세 화면에서 수동으로 만듭니다. 모든 결과는 알림함으로 통지됩니다.
diff --git a/negodata/front/src/features/onboarding/steps.ts b/negodata/front/src/features/onboarding/steps.ts index 5761836..e6e6c61 100644 --- a/negodata/front/src/features/onboarding/steps.ts +++ b/negodata/front/src/features/onboarding/steps.ts @@ -74,9 +74,9 @@ export const STEPS: Step[] = [ actor: '시스템', actorType: 'system', icon: Award, - short: '단독최저=낙찰 / 동가·미참여=재생성 / 결렬', + short: '단독최저+기준통과=낙찰 / 그 외=개찰(낙찰자 미정)', detail: - '단독 최저가면 그 협력사로 낙찰됩니다. 최저가가 둘 이상 같은 동가이거나 전원 미참여면 다음 차수가 자동 재생성되고, 거절·한도 등으로 낙찰자가 없으면 결렬 처리됩니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.', + '단독 최저가가 견적의 낙찰 기준을 통과하면 그 협력사로 낙찰됩니다. 기준을 못 넘기거나 동가·전원 미응찰·협상거부인 경우는 결렬이 아니라 개찰 — 낙찰자 미정으로 마감되고, 다음 라운드는 담당자가 상세에서 수동으로 만듭니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.', }, ]; diff --git a/negodata/front/src/features/partners/components/ExcelUploadModal.tsx b/negodata/front/src/features/partners/components/ExcelUploadModal.tsx index 58b6b03..2a9e4e3 100644 --- a/negodata/front/src/features/partners/components/ExcelUploadModal.tsx +++ b/negodata/front/src/features/partners/components/ExcelUploadModal.tsx @@ -17,13 +17,13 @@ type RawRow = { code: string; managerName: string; managerEmail: string; - priority: string; + totalRevenue: string; }; type ValidatedRow = RawRow & { status: '정상' | '오류'; message: string }; // 업로드 양식 한 줄(예시 행) -type TemplateRow = { name: string; code: string; managerName: string; managerEmail: string; priority: string }; +type TemplateRow = { name: string; code: string; managerName: string; managerEmail: string; totalRevenue: string }; type ExcelUploadModalProps = { open: boolean; @@ -68,7 +68,7 @@ function toSupplierCreate(row: RawRow): SupplierCreate { manager_name: row.managerName, manager_email: row.managerEmail, manager_contact_number: '010-0000-0000', - priority: row.priority, + total_revenue: row.totalRevenue?.trim() ? Number(row.totalRevenue.replace(/[^0-9]/g, '')) : undefined, }; } @@ -81,9 +81,9 @@ export function downloadPartnerTemplate() { { header: '식별코드', value: (r) => r.code }, { header: '담당자명', value: (r) => r.managerName }, { header: '담당자이메일', value: (r) => r.managerEmail }, - { header: '우선순위', value: (r) => r.priority }, + { header: '총매출액', value: (r) => r.totalRevenue }, ], - [{ name: '예시) (주)한빛정밀', code: 'PART-EXAMPLE-001', managerName: '김철수 과장', managerEmail: 'cs.kim@example.com', priority: 'HIGH' }], + [{ name: '예시) (주)한빛정밀', code: 'PART-EXAMPLE-001', managerName: '김철수 과장', managerEmail: 'cs.kim@example.com', totalRevenue: '5000000000' }], ); } @@ -123,7 +123,7 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp code: r['식별코드'] ?? '', managerName: r['담당자명'] ?? '', managerEmail: r['담당자이메일'] ?? '', - priority: r['우선순위'] ?? 'MEDIUM', + totalRevenue: r['총매출액'] ?? '', })); setExcelFile(file.name); setRows(loaded); diff --git a/negodata/front/src/features/partners/components/PartnerFormSheet.tsx b/negodata/front/src/features/partners/components/PartnerFormSheet.tsx index 61269e9..2c27569 100644 --- a/negodata/front/src/features/partners/components/PartnerFormSheet.tsx +++ b/negodata/front/src/features/partners/components/PartnerFormSheet.tsx @@ -1,4 +1,4 @@ -import { useForm, Controller } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Trash2 } from 'lucide-react'; @@ -9,8 +9,7 @@ import { Typography } from '@/components/ui/typography'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Sheet } from '@/components/ui/sheet'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { type Partner, priorityOptions } from '../types'; +import { type Partner } from '../types'; const schema = z.object({ name: z.string().trim().min(1, '회사명/협력사명을 작성해 주십시오.'), @@ -18,7 +17,7 @@ const schema = z.object({ managerName: z.string().trim().min(1, '담당자명을 입력해 주십시오.'), managerEmail: z.string().trim().email('정확한 담당자 이메일 형식을 점검해 주십시오.'), managerPhone: z.string().trim().min(1, '담당자 연락처를 입력해 주십시오.'), - priority: z.string(), + totalRevenue: z.string().trim().optional(), // 총매출액(원) }); type FormValues = z.infer; @@ -42,7 +41,7 @@ function buildDefaults(mode: 'create' | 'edit', partner: Partner | null): FormVa managerName: partner.manager_name || '', managerEmail: partner.manager_email || '', managerPhone: partner.manager_contact_number || '', - priority: partner.priority || 'MEDIUM', + totalRevenue: partner.total_revenue != null ? String(partner.total_revenue) : '', }; } return { @@ -51,7 +50,7 @@ function buildDefaults(mode: 'create' | 'edit', partner: Partner | null): FormVa managerName: '', managerEmail: '', managerPhone: '010-', - priority: 'MEDIUM', + totalRevenue: '', }; } @@ -68,7 +67,6 @@ export function PartnerFormSheet({ }: PartnerFormSheetProps) { const { register, - control, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ @@ -83,7 +81,7 @@ export function PartnerFormSheet({ manager_name: v.managerName, manager_email: v.managerEmail, manager_contact_number: v.managerPhone, - priority: v.priority, + total_revenue: v.totalRevenue?.trim() ? Number(v.totalRevenue.replace(/[^0-9]/g, '')) : undefined, }; if (mode === 'create') { @@ -138,26 +136,16 @@ export function PartnerFormSheet({ /> {errors.code &&

{errors.code.message}

} - {/* Priority */} + {/* 총매출액 */}
- 우선 선정 대상자 - ( - - )} + 총매출액 (원, 선택) +
diff --git a/negodata/front/src/features/partners/components/PartnerTable.tsx b/negodata/front/src/features/partners/components/PartnerTable.tsx index f9f8a09..0807259 100644 --- a/negodata/front/src/features/partners/components/PartnerTable.tsx +++ b/negodata/front/src/features/partners/components/PartnerTable.tsx @@ -1,4 +1,3 @@ -import { Badge } from '@/components/ui/badge'; import { DataTable } from '@/components/ui/data-table'; import { TablePagination } from '@/components/ui/table-pagination'; import type { Partner } from '../types'; @@ -13,14 +12,6 @@ type PartnerTableProps = { onPageChange: (page: number) => void; }; -// 우선순위 배지 색상 — HIGH(빨강)/MEDIUM(주황)/그외(회색) -const priorityBadgeClass = (priority?: string | null) => - priority === 'HIGH' - ? 'bg-red-50 text-red-700 dark:bg-rose-950/20 dark:text-rose-400 border border-red-200' - : priority === 'MEDIUM' - ? 'bg-amber-50 text-amber-700 dark:bg-amber-950/20 dark:text-amber-400 border border-amber-200' - : 'bg-zinc-100 text-zinc-600 border border-zinc-300'; - export function PartnerTable({ data, onRowClick, @@ -74,16 +65,10 @@ export function PartnerTable({ ), }, { - header: '우선 선정 대상자', - align: 'center', - cell: (part) => ( - - {part.priority} - - ), + header: '총매출액', + align: 'right', + cellClassName: 'font-mono text-muted-foreground', + cell: (part) => (part.total_revenue != null ? `₩${Number(part.total_revenue).toLocaleString()}` : '-'), }, ]} /> diff --git a/negodata/front/src/features/partners/types.ts b/negodata/front/src/features/partners/types.ts index 3608843..279bbd8 100644 --- a/negodata/front/src/features/partners/types.ts +++ b/negodata/front/src/features/partners/types.ts @@ -1,11 +1 @@ export type { Partner } from '@/types'; - -// 우선순위 필터 목록. 'ALL'은 필터 전용(폼에서는 제외). -export const prioritiesList = ['ALL', 'HIGH', 'MEDIUM', 'LOW']; - -// 폼 우선순위 선택지(라벨 포함). -export const priorityOptions: { value: string; label: string }[] = [ - { value: 'HIGH', label: 'HIGH (핵심 조달처)' }, - { value: 'MEDIUM', label: 'MEDIUM (일반 벤더)' }, - { value: 'LOW', label: 'LOW (서브 보조처)' }, -]; diff --git a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx index ac53594..b06cf7c 100644 --- a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { X, PlusSquare, ArrowRight, Loader2 } from 'lucide-react'; +import { X, PlusSquare, ArrowRight, Loader2, Gavel } from 'lucide-react'; import { useNavigate } from 'react-router'; import { useGetSupplierLastType } from '@/api/generated/quotation/quotation'; import { Button } from '@/components/ui/button'; @@ -10,7 +10,16 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types'; import type { CreateQuotationInput } from '../hooks/useQuotations'; import { QuotationType } from '@/api/generated/model'; -import { QUOTATION_TYPE_OPTIONS, supplierTypeOptions, isNewQuotationType } from '../types'; +import { + supplierTypeOptions, + is1v1, + toQuotationType, + awardStrategySummary, + PriceGateAction, + DEFAULT_MID_ACTION, + DEFAULT_OVER_ACTION, + type QuotationMode, +} from '../types'; import { supplierTypeLabel } from '@/lib/enumLabels'; import { showToast } from '@/lib/notify'; @@ -42,7 +51,9 @@ export function QuotationCreateModal({ }: QuotationCreateModalProps) { const [step, setStep] = useState(1); const [title, setTitle] = useState(''); - const [type, setType] = useState(QuotationType.REQUOTE); + // 유형은 '진행 방식(협상/경매) × 대상(신규/후속)' 2축으로 받아 제출 직전 4코드로 합성한다. + const [mode, setMode] = useState('nego'); // 1:1 협상 / 1:N 경매 + const [isNew, setIsNew] = useState(true); // 신규 / 후속(재) const [productId, setProductId] = useState(''); const [selectedPartnerIds, setSelectedPartnerIds] = useState([]); const [dueDate, setDueDate] = useState(nowKstLocalInput); @@ -51,8 +62,18 @@ export function QuotationCreateModal({ const [memo, setMemo] = useState(''); const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 상품값으로 목표가 산정 const [supplierType, setSupplierType] = useState(''); // 협력사 유형(SupplierType). 전 유형에서 입력 → 견적에 기록 + const [midAction, setMidAction] = useState(DEFAULT_MID_ACTION); // 앵커~목표가 구간: 낙찰/개찰 (1:1 전용) + const [overAction, setOverAction] = useState(DEFAULT_OVER_ACTION); // 목표가 초과 구간: 낙찰/개찰 (1:1 전용) const [submitting, setSubmitting] = useState(false); - const typeOptions = QUOTATION_TYPE_OPTIONS; + + const type = toQuotationType(mode, isNew); // 4코드 합성값 + const oneToOne = is1v1(type); // 1:1 협상 여부 — 협력사 단일선택·낙찰기준·협상카드 노출을 가른다 + const isReType = !isNew; + // 스텝 구성 — 1:1 협상만 협상카드 스텝 추가(작은 화면 과밀 방지). 경매는 3스텝. + const steps = oneToOne + ? ['기본 정보', '협력사 초청', '낙찰 기준', '협상카드'] + : ['기본 정보', '협력사 초청', '확인·완료']; + const totalSteps = steps.length; // 협력사 유형은 전 유형에서 입력받되, 재협상(1:1)이면 선택 협력사의 직전 견적 supplier_type 을 조회해 디폴트로 채운다. const renegoSupplierId = type === QuotationType.RENEGO ? (selectedPartnerIds[0] ?? '') : ''; @@ -69,7 +90,6 @@ export function QuotationCreateModal({ const navigate = useNavigate(); // 인터넷최저가·매입가·판매가는 상품 속성 — 모달에선 읽기전용으로만 보여주고, 수정은 상품 상세에서 한다. const selectedProduct = products.find((p) => p.id === productId); - const isReType = !isNewQuotationType(type); const internetLowest = selectedProduct?.internet_lowest_price ?? null; const purchase = selectedProduct?.purchase_price ?? null; const selling = selectedProduct?.selling_price ?? null; @@ -82,16 +102,25 @@ export function QuotationCreateModal({ if (!open) return null; + const selectMode = (next: QuotationMode) => { + setMode(next); + // 경매→협상 전환 시 다중선택으로 담긴 협력사를 1곳으로 줄인다(1:1 단일선택). + if (next === 'nego') setSelectedPartnerIds((prev) => prev.slice(0, 1)); + // 경매는 3스텝뿐 — 협상카드 스텝(4)에 있던 상태면 마지막(3)으로 당긴다. + if (next === 'auction') setStep((s) => Math.min(s, 3)); + }; const togglePartner = (id: string) => setSelectedPartnerIds((prev) => - type === QuotationType.RENEGO - ? prev.includes(id) ? [] : [id] - : prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id], + oneToOne + ? prev.includes(id) + ? [] + : [id] + : prev.includes(id) + ? prev.filter((p) => p !== id) + : [...prev, id], ); const toggleCard = (id: string) => - setSelectedCardIds((prev) => - prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id], - ); + setSelectedCardIds((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id])); const handleSubmit = async () => { if (submitting) return; @@ -102,7 +131,7 @@ export function QuotationCreateModal({ showToast('목표가 산정에 쓸 값이 없습니다 — MD 제시가를 입력하거나, 상품 상세에서 인터넷최저가·매입가를 채워주세요.', 'error'); return; // finally 에서 submitting 해제 } - // 서버가 견적+세션 생성을 끝내고 응답할 때까지 기다린 뒤에 완료(닫기) 처리한다. + // 낙찰 기준은 1:1 협상만 전송(경매는 미전송 → 서버가 mid=over=AWARD 강제). 카드도 1:1 전용. const ok = await onCreate({ title, type, @@ -110,10 +139,12 @@ export function QuotationCreateModal({ partnerIds: selectedPartnerIds, dueDate, settingId, - cardIds: selectedCardIds, + cardIds: oneToOne ? selectedCardIds : [], memo, mdPrice: mdPrice ? Number(mdPrice) : null, supplierType: supplierType ? Number(supplierType) : null, + midAction: oneToOne ? midAction : undefined, + overAction: oneToOne ? overAction : undefined, }); if (ok) onClose(); } finally { @@ -138,26 +169,26 @@ export function QuotationCreateModal({
- 신규 견적 등록 (단계 {step}/3) + 신규 견적 등록 (단계 {step}/{totalSteps})
- {/* Steps indicator */} + {/* Steps indicator — 스텝 수는 유형에 따라 3(경매)/4(협상) */}
- - - - - + {steps.map((label, i) => { + const n = i + 1; + return ( +
+ {i > 0 && } + +
+ ); + })}
{/* Step content */} @@ -165,41 +196,41 @@ export function QuotationCreateModal({ {step === 1 && (
- {/* 견적 유형이 맨 위 — 신규/재 여부가 아래 매입가 필수 여부까지 결정한다 */} + {/* 진행 방식 × 대상 2축 — 협상/경매 갈림이 아래 카드·낙찰기준 노출까지 결정한다 */}
- 견적 유형 - -
- -
- 마감기한 - setDueDate(e.target.value)} + 진행 방식 + selectMode(v as QuotationMode)} />
+
+ 대상 + setIsNew(v === 'new')} + /> +
+
+ +
+ 마감기한 + setDueDate(e.target.value)} + />
@@ -216,7 +247,7 @@ export function QuotationCreateModal({
상품 - setProductId(v ?? '')}> {(value) => { @@ -296,7 +327,7 @@ export function QuotationCreateModal({ {step === 2 && (
- 협력사 초청 ({type === QuotationType.RENEGO ? '단일선택' : '다중선택'}) + 협력사 초청 ({oneToOne ? '단일선택' : '다중선택'})
{partners.map((part) => { const isChecked = selectedPartnerIds.includes(part.id ?? ''); @@ -314,16 +345,9 @@ export function QuotationCreateModal({ />
{part.name} - 이메일: {part.managerEmail} · 등급: {part.rank} + 이메일: {part.managerEmail}
- - {part.priority} - ); })} @@ -357,13 +381,13 @@ export function QuotationCreateModal({
적용할 견적 세팅 지정 - setSettingId(v ?? '')}> {(value) => { const qs = quotationSettings.find((s) => s.qt_setting_id === value); return qs - ? `[목표 마진: ${qs.target_margin}] ${qs.anchoring_value} (${qs.card_use_count})` + ? `[목표 마진: ${qs.target_margin}] 카드 ${qs.card_use_count}` : ''; }} @@ -371,45 +395,42 @@ export function QuotationCreateModal({ {quotationSettings.map((qs) => ( - [목표 마진: {qs.target_margin}] {qs.anchoring_value} ({qs.card_use_count}) + [목표 마진: {qs.target_margin}] 카드 {qs.card_use_count} ))}
-
- 협상카드 및 와일드카드 선택 -
- {cards.filter((c) => !c.isWildcard || c.status === 'ACTIVE').map((card) => { - const isChecked = selectedCardIds.includes(card.id); - return ( -
toggleCard(card.id)} - className={`p-2.5 rounded border cursor-pointer transition-all flex items-start gap-2 ${ - isChecked ? 'bg-primary/5 border-primary font-bold' : 'bg-background border-border hover:bg-muted/10' - }`} - > - -
-
- {card.code} - - {card.isWildcard ? '와일드' : '협상'} - -
- {card.title} -
-
- ); - })} + {oneToOne ? ( + <> + {/* 낙찰 기준(1:1 전용) — 스펙트럼 = 선택. 낙찰선을 앵커/목표가 중 택1, 목표가 초과는 항상 개찰. */} + { + setMidAction(v); + if (v === PriceGateAction.OPEN) setOverAction(PriceGateAction.OPEN); + }} + onOver={(v) => { + setOverAction(v); + if (v === PriceGateAction.AWARD) setMidAction(PriceGateAction.AWARD); + }} + /> + + ) : ( + /* 경매(1:N) — 낙찰 기준·협상카드 없음. 최저가 자동 낙찰 안내만. */ +
+ +
+ 최저가 자동 낙찰 + + 1:N 견적은 가장 낮은 투찰가가 자동 낙찰됩니다. 낙찰 기준·협상카드 설정이 없습니다. + +
-
+ )}
메모 (선택) @@ -426,6 +447,45 @@ export function QuotationCreateModal({
)} + {/* Step 4 — 협상카드(1:1 협상 전용, 별도 스텝으로 분리해 과밀 방지) */} + {step === 4 && oneToOne && ( +
+ 협상카드 및 와일드카드 선택 (선택) + + 1:1 협상에서 AI 협상봇이 발동할 카드입니다. + +
+ {cards.filter((c) => !c.isWildcard || c.status === 'ACTIVE').map((card) => { + const isChecked = selectedCardIds.includes(card.id); + return ( +
toggleCard(card.id)} + className={`p-2.5 rounded border cursor-pointer transition-all flex items-start gap-2 ${ + isChecked ? 'bg-primary/5 border-primary font-bold' : 'bg-background border-border hover:bg-muted/10' + }`} + > + +
+
+ {card.code} + + {card.isWildcard ? '와일드' : '협상'} + +
+ {card.title} +
+
+ ); + })} +
+
+ )} +
{/* Footer nav */} @@ -440,7 +500,7 @@ export function QuotationCreateModal({
- {step < 3 ? ( + {step < totalSteps ? ( + ); + })} +
+ ); +} + +// 낙찰 기준 컨트롤 — 스펙트럼 선(線) 위 구간을 눌러 낙찰↔개찰 전환. 앵커 이하는 항상 낙찰(고정, 표시만) — +// '앵커~목표가'(mid_action)·'목표가 초과'(over_action) 두 구간만 사용자가 각각 낙찰/개찰로 정한다. +function AwardLinePicker({ + mid, over, onMid, onOver, +}: { + mid: number; + over: number; + onMid: (v: number) => void; + onOver: (v: number) => void; +}) { + const A = PriceGateAction.AWARD; + const O = PriceGateAction.OPEN; + const zones = [ + { label: '앵커링가 이하', win: true, locked: true, toggle: undefined }, + { label: '앵커링가~목표가', win: mid === A, locked: false, toggle: () => onMid(mid === A ? O : A) }, + { label: '목표가 초과', win: over === A, locked: false, toggle: () => onOver(over === A ? O : A) }, + ]; + return ( +
+
+ 낙찰 기준 + 구간을 눌러 낙찰↔개찰 · 싸다◀▶비싸다 +
+ + 협력사 최저 투찰가가 어느 구간에 오느냐로 낙찰/개찰이 정해집니다. + + {/* 스펙트럼 선: 구간이 곧 선택 버튼 */} +
+ {zones.map((z, i) => { + const body = ( + <> + {z.label} + + {z.win ? '낙찰' : '개찰'}{z.locked ? ' 🔒' : ''} + + + ); + const cls = cn('flex-1 px-1 py-2', i > 0 && 'border-l border-border', z.win ? 'bg-emerald-50 dark:bg-emerald-950/30' : 'bg-muted'); + return z.locked ? ( +
{body}
+ ) : ( + + ); + })} +
+ {/* 경계 마커 — 구간 경계(1/3·2/3)에 ▲ 중앙 정렬(앵커링가·목표가) */} +
+ {[ + { left: '33.3333%', label: '앵커링가' }, + { left: '66.6667%', label: '목표가' }, + ].map((mk) => ( + + ▲ + {mk.label} + + ))} +
+ {/* 전략 한 줄 요약(관대/기본/엄격 전략) — 기본 전략(목표가까지 낙찰·초과 개찰)일 때만 경계가 애매하니 '목표가 포함' 부기 */} + {(() => { + const t = awardStrategySummary(mid, over); + const isBasic = mid === PriceGateAction.AWARD && over === PriceGateAction.OPEN; + return ( + + {t.strategy} + · {t.desc} + {isBasic && ( + · 목표가 포함 + )} + + ); + })()} +
+ ); +} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx index 0684956..d5e2257 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx @@ -13,7 +13,7 @@ import { type QuotationSetting, type SessionView, quotationTypeLabel, - priceGateActionLabel, + awardCriterionLabel, fmtDateTime, } from '../../types'; @@ -89,6 +89,7 @@ export function DrawerHeaderCards({ + {repSessionId ? (
) : (
적용된 견적 세팅이 비어있습니다.
diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/RegenerateModal.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/RegenerateModal.tsx index b3923c1..6fbc367 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/RegenerateModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/RegenerateModal.tsx @@ -78,7 +78,7 @@ export function RegenerateModal({ open, partners, sessionStatusBySupplier, defau
{part.name} - 이메일: {part.managerEmail} · 등급: {part.rank} + 이메일: {part.managerEmail}
diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ResultSummaryBand.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ResultSummaryBand.tsx index 5fbf282..d79ee36 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ResultSummaryBand.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ResultSummaryBand.tsx @@ -15,11 +15,10 @@ import { const won = (n?: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-'); -// 결과 상태별 배지 톤(협상현황 pill 팔레트 재사용). +// 결과 상태별 배지 톤(협상현황 pill 팔레트 재사용). 개찰=주황(낙찰자 미정, 수동 처리 필요). const OUTCOME_TONE: Record = { awarded: 'emerald', - regenerated: 'amber', - failed: 'blue', + opened: 'amber', active: 'zinc', }; diff --git a/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx b/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx index a67b33a..940a3ec 100644 --- a/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx @@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'; import { Typography } from '@/components/ui/typography'; import { Input } from '@/components/ui/input'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; -import { type QuotationSetting, priceGateActionLabel, PRICE_GATE_ACTION_OPTIONS } from '../types'; +import { type QuotationSetting } from '../types'; import type { SettingInput } from '../hooks/useQuotations'; type QuotationSettingsModalProps = { @@ -23,27 +23,17 @@ export function QuotationSettingsModal({ onClose, }: QuotationSettingsModalProps) { const [targetMargin, setTargetMargin] = useState(''); - const [anchoringValue, setAnchoringValue] = useState(''); const [cardUseCount, setCardUseCount] = useState(''); - const [midAction, setMidAction] = useState(1); // PriceGateAction 기본 낙찰 - const [overAction, setOverAction] = useState(1); - const [regenLimit, setRegenLimit] = useState('1'); if (!open) return null; + // 세팅은 목표 마진율·카드 사용 횟수만. 낙찰 정책은 견적 생성으로 이관, 앵커링은 칸 rate(v1.2)로 대체. const handleAdd = (e: React.FormEvent) => { e.preventDefault(); - const ok = onAdd({ - targetMargin, anchoringValue, cardUseCount, - midAction, overAction, regenLimit: parseInt(regenLimit, 10), - }); + const ok = onAdd({ targetMargin, cardUseCount }); if (ok) { setTargetMargin(''); - setAnchoringValue(''); setCardUseCount(''); - setMidAction(1); - setOverAction(1); - setRegenLimit('1'); } }; @@ -70,18 +60,14 @@ export function QuotationSettingsModal({ 목표 마진율 - 앵커링 값 카드 사용 횟수 - 앵커~목표 - 목표초과 - 재생성 삭제 {settings.length === 0 && ( - + 등록된 견적 세팅이 없습니다. (리스트가 비어 있습니다) @@ -89,11 +75,7 @@ export function QuotationSettingsModal({ {settings.map((qs) => ( {qs.target_margin} - {qs.anchoring_value} {qs.card_use_count} - {priceGateActionLabel(qs.mid_action)} - {priceGateActionLabel(qs.over_action)} - {qs.regen_limit}회
-
- 앵커링 값 - setAnchoringValue(e.target.value)} placeholder="예: 0.01" /> -
카드 사용 횟수 setCardUseCount(e.target.value)} placeholder="예: 3" />
-
- 앵커링가~목표가 마감처리 - -
-
- 목표가초과 마감처리 - -
-
- 재생성 최대 횟수 (체인 전체) - setRegenLimit(e.target.value)} placeholder="예: 1" /> -
diff --git a/negodata/front/src/features/quotations/components/QuotationTable.tsx b/negodata/front/src/features/quotations/components/QuotationTable.tsx index 69fc3b4..c07913c 100644 --- a/negodata/front/src/features/quotations/components/QuotationTable.tsx +++ b/negodata/front/src/features/quotations/components/QuotationTable.tsx @@ -11,9 +11,10 @@ import { quotationStatusLabel, quotationTypeLabel, chainRoundState, + is1v1, CHAIN_ROUND_STATE_LABEL, } from '../types'; -import { QuotationType, QuotationStatus } from '@/api/generated/model'; +import { QuotationStatus } from '@/api/generated/model'; type QuotationTableProps = { data: Estimate[]; @@ -37,15 +38,13 @@ const statusBadgeClass = (status?: number | null) => { } }; -// 마감결과 배지 색. 낙찰=초록/재생성=주황/결렬=로즈/진행중=회색(아직 마감 전). +// 마감결과 배지 색. 낙찰=초록 / 개찰(낙찰자 미정, 수동 처리)=주황 / 진행중=회색(아직 마감 전). const outcomeBadgeClass = (state: ChainRoundState) => { switch (state) { case 'awarded': return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/25 dark:text-emerald-400 border-emerald-300/50'; - case 'regenerated': + case 'opened': return 'bg-amber-50 text-amber-700 dark:bg-amber-950/25 dark:text-amber-400 border-amber-300/50'; - case 'failed': - return 'bg-rose-50 text-rose-700 dark:bg-rose-950/25 dark:text-rose-400 border-rose-300/50'; default: return 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800/40 dark:text-zinc-400 border-zinc-300/40'; } @@ -104,15 +103,12 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo { header: '유형', align: 'center', + // 유형은 고정 속성이라 pill 대신 평문 — 협상(1:1)만 살짝 진하게, 경매(1:N)는 연하게. cell: (est) => ( {quotationTypeLabel(est.type)} diff --git a/negodata/front/src/features/quotations/hooks/useQuotations.ts b/negodata/front/src/features/quotations/hooks/useQuotations.ts index 6e0b505..114ac47 100644 --- a/negodata/front/src/features/quotations/hooks/useQuotations.ts +++ b/negodata/front/src/features/quotations/hooks/useQuotations.ts @@ -27,7 +27,7 @@ import { confirm } from '@/lib/confirm'; import { useAuthStore } from '@/stores/auth'; import type { Estimate } from '../types'; import { mapItem, mapSupplier, mapSetting, mapQuotation } from '../types'; -import { QuotationStatus, type PriceGateAction } from '@/api/generated/model'; +import { QuotationStatus } from '@/api/generated/model'; export type CreateQuotationInput = { title: string; @@ -40,15 +40,14 @@ export type CreateQuotationInput = { memo: string; mdPrice?: number | null; // MD 제시가(원). 비우면 미전송 → 서버가 기존 마진식으로 목표가 산정 supplierType?: number | null; // 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록 + // 낙찰 기준 — 1:1 협상만 전송(경매는 미전송 → 서버가 mid=over=AWARD 강제). 2전략을 mid/over 로 전개해 담는다(over 항상 OPEN). + midAction?: number; // PriceGateAction (앵커~목표가 처리: 낙찰/개찰) + overAction?: number; // PriceGateAction (목표가 초과 처리: 협상은 항상 개찰) }; export type SettingInput = { targetMargin: string; - anchoringValue: string; cardUseCount: string; - midAction?: number; // PriceGateAction (앵커~목표 마감처리). 미지정=1(낙찰) - overAction?: number; // PriceGateAction (목표초과 마감처리). 미지정=1(낙찰) - regenLimit?: number; // 재생성 최대 횟수(체인 전체, 사유 무관). 미지정=1 }; // 견적 화면 데이터 허브. @@ -124,18 +123,15 @@ export function useQuotations(params: ListQuotationsParams) { // 백엔드는 target_margin_rate 를 비율(0.12)로 저장하므로 % 입력을 100 으로 나눠 보낸다. const addSetting = (input: SettingInput): boolean => { const marginPct = Number(String(input.targetMargin).replace('%', '').trim()); - const anchoring = Number(String(input.anchoringValue).trim()); const cardCount = parseInt(String(input.cardUseCount).replace(/[^0-9-]/g, ''), 10); - if (!Number.isFinite(marginPct) || !Number.isFinite(anchoring) || !Number.isInteger(cardCount)) { - showToast('목표 마진율·앵커링 값·카드 사용 횟수를 숫자로 입력해야 합니다.', 'error'); + if (!Number.isFinite(marginPct) || !Number.isInteger(cardCount)) { + showToast('목표 마진율·카드 사용 횟수를 숫자로 입력해야 합니다.', 'error'); return false; } createSettingMutation.mutate( + // 낙찰 정책은 견적 생성으로 이관, 앵커링은 칸 rate(v1.2) → 세팅은 목표 마진율·카드 사용 횟수만. { data: { - target_margin_rate: marginPct / 100, anchoring_value: anchoring, card_count: cardCount, - mid_action: (input.midAction ?? 1) as PriceGateAction, - over_action: (input.overAction ?? 1) as PriceGateAction, - regen_limit: Number.isInteger(input.regenLimit) ? (input.regenLimit as number) : 1, + target_margin_rate: marginPct / 100, card_count: cardCount, } }, { onSuccess: () => { @@ -199,6 +195,9 @@ export function useQuotations(params: ListQuotationsParams) { memo: input.memo.trim() || undefined, md_price: input.mdPrice && input.mdPrice > 0 ? input.mdPrice : undefined, supplier_type: input.supplierType ?? undefined, + // 낙찰 기준은 1:1 협상만 전송(모달이 미리 걸러 담음) — 경매면 미전송 → 서버가 AWARD 강제. + mid_action: input.midAction ?? undefined, + over_action: input.overAction ?? undefined, }; try { diff --git a/negodata/front/src/features/quotations/types.ts b/negodata/front/src/features/quotations/types.ts index bdeea51..e84fb3b 100644 --- a/negodata/front/src/features/quotations/types.ts +++ b/negodata/front/src/features/quotations/types.ts @@ -4,12 +4,17 @@ import type { QuotationSettingData } from '@/api/generated/model/quotationSettin import type { QuotationData } from '@/api/generated/model/quotationData'; import type { SessionData } from '@/api/generated/model/sessionData'; import type { QuotationCardData } from '@/api/generated/model/quotationCardData'; -import { QuotationType, QuotationStatus, SessionStatus, CardType, CloseReason, PriceGateAction } from '@/api/generated/model'; +import { QuotationType, QuotationStatus, SessionStatus, CardType, CloseReason } from '@/api/generated/model'; import { DELIVERY_TYPE_LABEL } from '@/lib/enumLabels'; import type { Product, Partner, NegotiationCard } from '@/types'; export type { Product, Partner, NegotiationCard } from '@/types'; +// 낙찰 기준 가격게이트 값 — 백엔드 mid_action/over_action(SMALLINT)에 그대로 저장. +// (백엔드 스키마가 int 라 orval 이 enum 을 안 만들어 → 프론트 로컬 정의.) AWARD=낙찰, OPEN=개찰(낙찰자 미정 마감). +export const PriceGateAction = { AWARD: 1, OPEN: 2 } as const; +export type PriceGateAction = (typeof PriceGateAction)[keyof typeof PriceGateAction]; + export type Estimate = Partial & { id?: string; dueDate?: string; @@ -31,30 +36,12 @@ export interface QuotationSetting { qt_setting_id: string; user_id: string; target_margin: string; - anchoring_value: string; card_use_count: string; - mid_action: PriceGateAction; // 앵커링가<투찰가≤목표가 마감 처리 - over_action: PriceGateAction; // 목표가<투찰가 마감 처리 - regen_limit: number; // 재생성 최대 횟수(체인 전체, 사유 무관) created_at: string; updated_at: string; deleted: boolean; } -// 마감 가격정책 처리 라벨(견적세팅 표시·선택). -export const PRICE_GATE_ACTION_LABEL: Record = { - [PriceGateAction.AWARD]: '낙찰', - [PriceGateAction.RENEGO]: '재협상', - [PriceGateAction.FAIL]: '결렬', -}; -export const priceGateActionLabel = (a?: number | null): string => - a != null ? PRICE_GATE_ACTION_LABEL[a as PriceGateAction] ?? String(a) : ''; -export const PRICE_GATE_ACTION_OPTIONS = [ - PriceGateAction.AWARD, - PriceGateAction.RENEGO, - PriceGateAction.FAIL, -].map((value) => ({ value, label: PRICE_GATE_ACTION_LABEL[value] })); - // ── 서버 응답 → UI 모델 매퍼 ───────────────────────────────────────────── export function mapItem(it: ItemData): Product { @@ -68,7 +55,6 @@ export function mapSupplier(sp: SupplierData): Partner { managerName: sp.manager_name || '', managerEmail: sp.manager_email || '', managerPhone: sp.manager_contact_number || '', - rank: sp.priority === 'HIGH' ? 'S' : sp.priority === 'MEDIUM' ? 'A' : 'B', status: 'ACTIVE', }; } @@ -80,11 +66,7 @@ export function mapSetting(s: QuotationSettingData): QuotationSetting { qt_setting_id: s.qt_setting_id, user_id: s.user_id || '', target_margin: `${Number.isFinite(ratePct) ? +ratePct.toFixed(2) : 0}%`, - anchoring_value: String(s.anchoring_value ?? ''), card_use_count: `${s.card_count ?? 0}회`, - mid_action: s.mid_action ?? PriceGateAction.AWARD, - over_action: s.over_action ?? PriceGateAction.AWARD, - regen_limit: s.regen_limit ?? 1, created_at: s.created_at ?? '', updated_at: s.updated_at ?? '', deleted: false, @@ -174,36 +156,77 @@ export const QUOTATION_TYPE_OPTIONS = [ export const isNewQuotationType = (t?: number | null): boolean => t === QuotationType.NEW_NEGO || t === QuotationType.NEW_QUOTE; +// 1:1 협상(RENEGO/NEW_NEGO) 여부. 협상카드·낙찰기준 노출, 협력사 단일선택이 이 분기에 의존. +// 나머지(REQUOTE/NEW_QUOTE)는 1:N 경매 — 무조건 최저가 낙찰. +export const is1v1 = (t?: number | null): boolean => + t === QuotationType.RENEGO || t === QuotationType.NEW_NEGO; + +// 견적 생성 폼의 2축(진행 방식 × 대상) → 4코드 유형 합성. +export type QuotationMode = 'nego' | 'auction'; // 1:1 협상 / 1:N 경매 +export const toQuotationType = (mode: QuotationMode, isNew: boolean): QuotationType => + mode === 'nego' + ? isNew + ? QuotationType.NEW_NEGO + : QuotationType.RENEGO + : isNew + ? QuotationType.NEW_QUOTE + : QuotationType.REQUOTE; + +// ── 낙찰 기준(1:1 협상 전용) ────────────────────────────────────────────── +// 앵커링가 이하는 항상 낙찰(고정·선택 불가, 표시만). 사용자는 '앵커~목표가'(mid_action)와 '목표가 초과'(over_action) +// 두 구간만 각각 낙찰(AWARD)/개찰(OPEN)로 정한다. 개찰 = 낙찰자 미정으로 마감(결렬 아님, 담당자 수동 처리). +export const DEFAULT_MID_ACTION: PriceGateAction = PriceGateAction.AWARD; // 앵커~목표가 기본 낙찰 +export const DEFAULT_OVER_ACTION: PriceGateAction = PriceGateAction.OPEN; // 목표가 초과 기본 개찰 + +// 견적의 낙찰 기준 한 줄 라벨(상세 드로어). 경매(1:N)=최저가 자동 낙찰. 협상=두 구간 조합 요약. +export function awardCriterionLabel(type?: number | null, mid?: number | null, over?: number | null): string { + if (type != null && !is1v1(type)) return '최저가 자동 낙찰'; + const m = (mid ?? PriceGateAction.AWARD) === PriceGateAction.AWARD; + const o = (over ?? PriceGateAction.AWARD) === PriceGateAction.AWARD; + if (m && o) return '목표가 초과도 낙찰'; + if (m && !o) return '목표가까지 낙찰'; + if (!m && !o) return '앵커링가까지 낙찰'; + return '목표가 초과만 낙찰'; // 비정상 조합(앵커~목표가 개찰인데 초과 낙찰) +} + +// 낙찰 기준(mid, over) 조합 → 한 줄 전략 요약(생성 모달 안내). 낙찰 범위가 넓을수록 관대. +export function awardStrategySummary(mid?: number | null, over?: number | null): { strategy: string; desc: string } { + const m = (mid ?? PriceGateAction.AWARD) === PriceGateAction.AWARD; + const o = (over ?? PriceGateAction.AWARD) === PriceGateAction.AWARD; + if (m && o) return { strategy: '관대 전략', desc: '목표가를 초과해도 최저가면 낙찰' }; + if (m && !o) return { strategy: '균형 전략', desc: '목표가 이내면 낙찰 · 초과는 개찰' }; + if (!m && !o) return { strategy: '엄격 전략', desc: '앵커링가 이하만 낙찰 · 그 위는 개찰' }; + return { strategy: '혼합 전략', desc: '앵커~목표가는 개찰인데 초과만 낙찰 · 권장 안 함' }; +} + // 협력사 유형 선택지(견적생성 모달) — 라벨은 lib/enumLabels.ts 의 SUPPLIER_TYPE 단일 출처에서 파생. export { SUPPLIER_TYPE_OPTIONS as supplierTypeOptions } from '@/lib/enumLabels'; // ── 라운드 체인(같은 견적번호) ─────────────────────────────────────────── -// 한 라운드(견적)의 결과를 한 단어로. 낙찰=종료, 동가/마감=후속 라운드 가능, 진행중=아직 안 닫힘. -export type ChainRoundState = 'awarded' | 'regenerated' | 'failed' | 'active'; +// 한 라운드(견적)의 결과를 한 단어로. 낙찰=종료 / 개찰=낙찰자 미정 마감(수동 재생성 가능) / 진행중=아직 안 닫힘. +export type ChainRoundState = 'awarded' | 'opened' | 'active'; export const CHAIN_ROUND_STATE_LABEL: Record = { awarded: '낙찰', - regenerated: '재생성', - failed: '결렬', + opened: '개찰', active: '진행중', }; -const REGEN_CLOSE_REASONS: CloseReason[] = [CloseReason.REGEN_PRICE, CloseReason.REGEN_EQUAL, CloseReason.REGEN_NOSHOW]; -const FAIL_CLOSE_REASONS: CloseReason[] = [CloseReason.FAIL_PRICE, CloseReason.FAIL_EQUAL, CloseReason.FAIL_NOSHOW, CloseReason.FAIL_REJECT]; +const OPEN_CLOSE_REASONS: CloseReason[] = [ + CloseReason.OPEN_PRICE, CloseReason.OPEN_EQUAL, CloseReason.OPEN_NOSHOW, CloseReason.OPEN_REJECT, +]; // 마감결과 한 단어. 서버 close_reason(CloseReason) 을 '단일 근거'로 판정한다. -// close_reason 이 아직 없는 옛 데이터만 preferred_sp_id/equal_bid_yn 플래그로 폴백. +// close_reason 이 아직 없는 옛 데이터만 preferred_sp_id 플래그로 폴백(마감됐는데 낙찰자 없으면 개찰). // QuotationData(전체)·Estimate(Partial) 둘 다 받도록 필요한 필드만 optional. export function chainRoundState( - q: { status?: number | null; close_reason?: number | null; preferred_sp_id?: string | null; equal_bid_yn?: boolean | null }, + q: { status?: number | null; close_reason?: number | null; preferred_sp_id?: string | null }, ): ChainRoundState { if (q.close_reason != null) { if (q.close_reason === CloseReason.AWARDED) return 'awarded'; - if (REGEN_CLOSE_REASONS.includes(q.close_reason as CloseReason)) return 'regenerated'; - if (FAIL_CLOSE_REASONS.includes(q.close_reason as CloseReason)) return 'failed'; + if (OPEN_CLOSE_REASONS.includes(q.close_reason as CloseReason)) return 'opened'; } if (q.preferred_sp_id) return 'awarded'; - if (q.equal_bid_yn) return 'regenerated'; - if (q.status === QuotationStatus.CLOSED) return 'failed'; + if (q.status === QuotationStatus.CLOSED) return 'opened'; return 'active'; } @@ -240,7 +263,7 @@ export type QuotationCardView = { // DB 저장값이 아니라 이미 로드된 quotation + 세션들로 파생한다(별도 컬럼·엔드포인트 없음). // · 목표가 = md_price 우선(없으면 세션 목표가) // · 낙찰가 = 우선협상자 세션의 투찰가(미낙찰이면 현재 최저 투찰가 = 잠정) -// · 절감 = 목표가 − 낙찰가 (양수 = 목표보다 저렴하게 낙찰) +// · 절감 = 목표가 − 낙찰가 (양수 = 목표가보다 저렴하게 낙찰) export type QuotationResultView = { outcome: ChainRoundState; closeReason: string; @@ -290,16 +313,13 @@ export function buildQuotationResult(q: QuotationData, sessions: SessionView[]): }; } -// 마감 사유 라벨. 서버 close_reason(CloseReason) 단일 근거로 표기. +// 마감 사유 라벨. 서버 close_reason(CloseReason) 단일 근거로 표기. 개찰 = 낙찰자 미정으로 마감(결렬 아님). export const CLOSE_REASON_LABEL: Record = { [CloseReason.AWARDED]: '낙찰', - [CloseReason.REGEN_PRICE]: '목표초과 — 재협상 진행', - [CloseReason.REGEN_EQUAL]: '동가 — 재입찰 진행', - [CloseReason.REGEN_NOSHOW]: '전원 미참여 — 재소집 진행', - [CloseReason.FAIL_PRICE]: '목표초과로 결렬', - [CloseReason.FAIL_EQUAL]: '동가로 결렬', - [CloseReason.FAIL_NOSHOW]: '미참여로 결렬', - [CloseReason.FAIL_REJECT]: '협상 거부로 결렬', + [CloseReason.OPEN_PRICE]: '목표가 초과 — 개찰(낙찰자 미정)', + [CloseReason.OPEN_EQUAL]: '동가 — 개찰(낙찰자 미정)', + [CloseReason.OPEN_NOSHOW]: '전원 미응찰 — 개찰', + [CloseReason.OPEN_REJECT]: '협상 거부 — 개찰', }; // 마감결과 텍스트(정밀). 마감결과 배지·라벨은 이 값을 쓴다 — close_reason(CloseReason) 을 그대로 표기. @@ -318,10 +338,9 @@ function quotationCloseReason(q: QuotationData, sessions: SessionView[]): string if (q.status !== QuotationStatus.CLOSED) return '협상 진행중'; if (q.close_reason != null) return CLOSE_REASON_LABEL[q.close_reason as CloseReason] ?? '마감'; if (q.preferred_sp_yn) return '낙찰'; - if (q.equal_bid_yn) return '동가 — 다음 라운드 재생성'; - if (q.preferred_sp_yn === false && q.equal_bid_yn === false) return '전원 미참여 — 다음 라운드 재생성'; - if (sessions.some((s) => s.status === SessionStatus.REJECTED)) return '협상 거부로 마감'; - return '마감'; + if (q.equal_bid_yn) return '동가 — 개찰(낙찰자 미정)'; + if (sessions.some((s) => s.status === SessionStatus.REJECTED)) return '협상 거부 — 개찰'; + return '개찰(낙찰자 미정)'; } // ── 서버 연동 매퍼(negotiation.sessions / chats / 사용 카드) ────────────── diff --git a/negodata/front/src/pages/notifications.tsx b/negodata/front/src/pages/notifications.tsx index 797b36f..7071945 100644 --- a/negodata/front/src/pages/notifications.tsx +++ b/negodata/front/src/pages/notifications.tsx @@ -177,9 +177,14 @@ function render(n: NotificationData): { icon: ReactNode; tone: string; event: st number, }; case NotificationType.FAILURE: + // 결렬 폐지 → '개찰'(낙찰자 미정으로 마감). reason 으로 사유만 부기. return { - icon: , tone: 'text-rose-600', event: '견적 결렬', - line: `${name} — 낙찰 없이 마감`, + icon: , tone: 'text-amber-600', event: '견적 개찰', + line: `${name} — 낙찰자 미정 (${ + ({ price: '목표가 초과', equal: '동가', rejected: '협상거부', no_show: '전원 미응찰' } as Record)[ + String(d.reason) + ] ?? '마감' + })`, number, }; default: diff --git a/negodata/front/src/pages/partners.tsx b/negodata/front/src/pages/partners.tsx index 75590b5..a2b2b1b 100644 --- a/negodata/front/src/pages/partners.tsx +++ b/negodata/front/src/pages/partners.tsx @@ -4,7 +4,6 @@ import { showToast } from '@/lib/notify'; import { confirm } from '@/lib/confirm'; import { PageContainer } from '@/components/layout/PageContainer'; import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; import { useServerList } from '@/lib/useServerList'; @@ -13,15 +12,13 @@ import type { ListSuppliersParams } from '@/api/generated/model/listSuppliersPar import { PartnerTable } from '@/features/partners/components/PartnerTable'; import { PartnerFormSheet } from '@/features/partners/components/PartnerFormSheet'; import { ExcelUploadModal, downloadPartnerTemplate } from '@/features/partners/components/ExcelUploadModal'; -import { prioritiesList, type Partner } from '@/features/partners/types'; +import { type Partner } from '@/features/partners/types'; export default function PartnersPage() { - // 검색/우선순위/페이지 상태(재사용 훅) → 서버 쿼리 파라미터로 변환. - const list = useServerList({ pageSize: 10, initialFilters: { priority: 'ALL' } }); - const priorityFilter = list.filters.priority; + // 검색/페이지 상태(재사용 훅) → 서버 쿼리 파라미터로 변환. + const list = useServerList({ pageSize: 10 }); const params: ListSuppliersParams = { search: list.debouncedSearch || undefined, - priority: priorityFilter !== 'ALL' ? priorityFilter : undefined, page: list.page, size: list.pageSize, }; @@ -91,21 +88,6 @@ export default function PartnersPage() { onClear={list.clearSearch} placeholder="협력사명, 코드 또는 담당자명으로 추적 검색..." /> - -