[feat] negodata: 마감 close_reason 개편 + 견적상세 단가조정 스펙트럼
- 마감사유 quotations.close_reason(SMALLINT, 8종) 신설 — 낙찰/재협상3종/유찰4종 구분, 재생성 한도 카운팅. quotation_settings 에 mid_action·over_action·regen_limit(3구간 가격정책) 추가. models.py·enums.py·01-schema.sql·crud·service·테스트 정합. - 프론트 노출: CloseReason/PriceGateAction 라벨·배지, 목록 마감결과 컬럼, 견적세팅 모달 3구간. orval 생성모델 갱신. - 견적상세 결과밴드: 목표가→낙찰가→절감 stat 트리오를 단가조정 흐름 스펙트럼(목표가·최저·최고 투찰가)으로 교체. lowestBid/highestBid 는 bid_price 파생(DB 컬럼 아님), 최초가 컬럼 없어 시작앵커=최고 투찰가로 대체. 라벨 방향 값순 번갈아 배치로 겹침 방지. - 라벨 정정: "단독 낙찰"→"낙찰"(낙찰은 단수), "목표 대비"→"목표가 대비". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d0b6e785af
commit
e23349c11e
@ -193,6 +193,10 @@ class quotation_settings(MainTableMixin, MAIN_BASE):
|
|||||||
target_margin_rate = Column(Numeric(8, 6), nullable=False)
|
target_margin_rate = Column(Numeric(8, 6), nullable=False)
|
||||||
anchoring_value = Column(Numeric(8, 6), nullable=False, default=0.01)
|
anchoring_value = Column(Numeric(8, 6), nullable=False, default=0.01)
|
||||||
card_count = Column(Integer, nullable=False, default=3)
|
card_count = Column(Integer, nullable=False, default=3)
|
||||||
|
# 마감 가격정책. 투찰가≤앵커링가는 항상 낙찰(설정없음). 아래 두 구간만 회사가 정한다. 기본 AWARD=현재 '무조건 낙찰' 동작(하위호환).
|
||||||
|
mid_action = Column(SmallInteger, nullable=False, default=1) # PriceGateAction: 앵커링가<투찰가≤목표가 처리(1=낙찰/2=재협상/3=유찰)
|
||||||
|
over_action = Column(SmallInteger, nullable=False, default=1) # PriceGateAction: 목표가<투찰가 처리(1=낙찰/2=재협상/3=유찰)
|
||||||
|
regen_limit = Column(SmallInteger, nullable=False, default=1) # 재생성 최대 횟수(체인 전체 총합, 사유 무관: 목표초과/동가/미참여 합산)
|
||||||
|
|
||||||
|
|
||||||
class quotations(MainTableMixin, MAIN_BASE):
|
class quotations(MainTableMixin, MAIN_BASE):
|
||||||
@ -225,6 +229,7 @@ class quotations(MainTableMixin, MAIN_BASE):
|
|||||||
preferred_sp_name = Column(String(20), nullable=True)
|
preferred_sp_name = Column(String(20), nullable=True)
|
||||||
equal_bid_yn = Column(Boolean, nullable=True)
|
equal_bid_yn = Column(Boolean, nullable=True)
|
||||||
equal_bid_data = Column(JSONB, nullable=True)
|
equal_bid_data = Column(JSONB, nullable=True)
|
||||||
|
close_reason = Column(SmallInteger, nullable=True) # CloseReason 코드. 마감 시 사유 기록(재생성 한도 카운팅·유찰 사유 구분). 미마감이면 NULL
|
||||||
|
|
||||||
|
|
||||||
class sessions(MainTableMixin, MAIN_BASE):
|
class sessions(MainTableMixin, MAIN_BASE):
|
||||||
|
|||||||
@ -171,6 +171,29 @@ class CloseOutcome(Enum):
|
|||||||
REGEN_FAILED = "regen_failed" # 재생성 시도했으나 실패 — 원본은 CLOSED 인데 다음 라운드가 없음(체인 끊김, 모니터링 필요)
|
REGEN_FAILED = "regen_failed" # 재생성 시도했으나 실패 — 원본은 CLOSED 인데 다음 라운드가 없음(체인 끊김, 모니터링 필요)
|
||||||
|
|
||||||
|
|
||||||
|
class CloseReason(CodeEnum):
|
||||||
|
"""quotations.close_reason 코드값(SMALLINT). 마감 사유 — 재생성 한도 카운팅(REGEN_*)과 유찰 사유 구분에 쓴다.
|
||||||
|
기존 preferred_sp_yn/equal_bid_yn 2플래그로는 4상태만 표현돼 '목표초과 재협상'이 미참여와 충돌하고 유찰 사유가 뭉개짐 → 이 컬럼으로 명시."""
|
||||||
|
|
||||||
|
AWARDED = 1 # 단독 낙찰
|
||||||
|
REGEN_PRICE = 2 # 가격 사유 재협상 (단독 최저가가 가격게이트 초과 → 낙찰 대신 다음 라운드로 더 깎기. 대표: 목표초과 재협상)
|
||||||
|
REGEN_EQUAL = 3 # 동가 재입찰
|
||||||
|
REGEN_NOSHOW = 4 # 미참여 재소집
|
||||||
|
FAIL_PRICE = 5 # 가격 사유 유찰 (가격게이트 초과인데 재협상 안 함/한도 소진)
|
||||||
|
FAIL_EQUAL = 6 # 동가 유찰
|
||||||
|
FAIL_NOSHOW = 7 # 미참여 유찰 (한도 소진)
|
||||||
|
FAIL_REJECT = 8 # 거부 유찰 (협상거부 존재)
|
||||||
|
|
||||||
|
|
||||||
|
class PriceGateAction(CodeEnum):
|
||||||
|
"""quotation_settings 의 가격 구간별 처리 정책. '앵커링가<투찰가≤목표가'(mid) / '목표가<투찰가'(over) 구간에 적용.
|
||||||
|
(투찰가≤앵커링가 는 항상 낙찰이라 설정 없음.)"""
|
||||||
|
|
||||||
|
AWARD = 1 # 낙찰
|
||||||
|
RENEGO = 2 # 재협상(다음 라운드 재생성)
|
||||||
|
FAIL = 3 # 유찰
|
||||||
|
|
||||||
|
|
||||||
class NotificationType(CodeEnum):
|
class NotificationType(CodeEnum):
|
||||||
"""company.notifications.type 코드값. 견적 생애 이벤트를 작성자에게 통지. 마감 결과 3종(SUCCESS/REGENERATED/FAILURE)은 close_and_decide 와 1:1. 네이밍은 KTC."""
|
"""company.notifications.type 코드값. 견적 생애 이벤트를 작성자에게 통지. 마감 결과 3종(SUCCESS/REGENERATED/FAILURE)은 close_and_decide 와 1:1. 네이밍은 KTC."""
|
||||||
|
|
||||||
|
|||||||
@ -117,7 +117,7 @@ class IQuotationCRUD(ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def list_chain_close_flags(self, cdb: AsyncSession, number, current_round) -> Tuple[ErrorType, list]:
|
async def list_chain_close_reasons(self, cdb: AsyncSession, number, current_round) -> Tuple[ErrorType, list]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@ -405,11 +405,15 @@ class QuotationCRUD(IQuotationCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, None
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]:
|
async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]:
|
||||||
"""견적 세팅의 율: {margin, anchoring}. 목표가·앵커링가 산정 입력. (인터넷 수수료는 상수)"""
|
"""견적 세팅의 율+마감정책: {margin, anchoring, mid_action, over_action, regen_limit}.
|
||||||
|
목표가·앵커링가 산정 입력 + 마감 가격게이트 정책. (인터넷 수수료는 상수)"""
|
||||||
try:
|
try:
|
||||||
query = select(
|
query = select(
|
||||||
quotation_settings.target_margin_rate,
|
quotation_settings.target_margin_rate,
|
||||||
quotation_settings.anchoring_value,
|
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)
|
).where(quotation_settings.qt_setting_id == qt_setting_id).limit(1)
|
||||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||||
if err_type != ErrorType.SUCCESS:
|
if err_type != ErrorType.SUCCESS:
|
||||||
@ -420,6 +424,9 @@ class QuotationCRUD(IQuotationCRUD):
|
|||||||
return ErrorType.SUCCESS, {
|
return ErrorType.SUCCESS, {
|
||||||
"margin": float(r[0]) if r[0] is not None else None,
|
"margin": float(r[0]) if r[0] is not None else None,
|
||||||
"anchoring": float(r[1]) if r[1] 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:
|
except Exception as ex:
|
||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
@ -554,15 +561,12 @@ class QuotationCRUD(IQuotationCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, []
|
return ErrorType.DB_RUN_FAILED, []
|
||||||
|
|
||||||
async def list_chain_close_flags(self, cdb: AsyncSession, number, current_round) -> Tuple[ErrorType, list]:
|
async def list_chain_close_reasons(self, cdb: AsyncSession, number, current_round) -> Tuple[ErrorType, list]:
|
||||||
"""[재생성 한도] 같은 견적번호(체인)의 이전 라운드(round < current_round)들의 (preferred_sp_yn, equal_bid_yn) 목록. 삭제 제외.
|
"""[재생성 한도] 같은 견적번호(체인)의 이전 라운드(round < current_round)들의 close_reason 코드 목록. 삭제 제외.
|
||||||
마감 사유 식별용 표식:
|
_chain_regen_counts 가 REGEN_* 값(목표초과재협상/동가재입찰/미참여재소집)만 사유별로 센다.
|
||||||
- equal_bid_yn=True → 동가 재생성
|
낙찰(AWARDED)·유찰(FAIL_*)·미마감(NULL)은 재생성 한도에 안 셈."""
|
||||||
- preferred_sp_yn=False AND equal_bid_yn=False → 미참여 재생성
|
|
||||||
- preferred_sp_yn=True → 단독낙찰(체인 어느 쪽에도 안 셈)
|
|
||||||
- 둘 다 NULL → 거부/한도 그냥 마감(안 셈)"""
|
|
||||||
try:
|
try:
|
||||||
query = select(quotations.preferred_sp_yn, quotations.equal_bid_yn).where(
|
query = select(quotations.close_reason).where(
|
||||||
quotations.number == number,
|
quotations.number == number,
|
||||||
quotations.round < current_round,
|
quotations.round < current_round,
|
||||||
quotations.deleted == False, # noqa: E712
|
quotations.deleted == False, # noqa: E712
|
||||||
@ -570,7 +574,8 @@ class QuotationCRUD(IQuotationCRUD):
|
|||||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||||
if err_type != ErrorType.SUCCESS:
|
if err_type != ErrorType.SUCCESS:
|
||||||
return err_type, []
|
return err_type, []
|
||||||
return ErrorType.SUCCESS, list(rows)
|
# 단일 컬럼 select → 각 행이 스칼라(close_reason 코드 or None)
|
||||||
|
return ErrorType.SUCCESS, [r for r in rows]
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, []
|
return ErrorType.DB_RUN_FAILED, []
|
||||||
@ -594,11 +599,13 @@ class QuotationCRUD(IQuotationCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, 0
|
return ErrorType.DB_RUN_FAILED, 0
|
||||||
|
|
||||||
async def list_sessions_status(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]:
|
async def list_sessions_status(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]:
|
||||||
"""[마감 판정] 견적의 모든 세션 → (status, supplier_id, bid_price, supplier_name). 삭제 제외.
|
"""[마감 판정] 견적의 모든 세션 → (status, supplier_id, bid_price, name, target_price, target_anchoring_price). 삭제 제외.
|
||||||
공급사가 지워졌어도 세션 집계엔 포함되도록 outerjoin(이때 name 은 None)."""
|
공급사가 지워졌어도 세션 집계엔 포함되도록 outerjoin(이때 name 은 None).
|
||||||
|
target/anchoring 은 마감 가격게이트 입력(견적당 상품 1개라 세션 공통값)."""
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
select(sessions.status, sessions.supplier_id, sessions.bid_price, suppliers.name)
|
select(sessions.status, sessions.supplier_id, sessions.bid_price, suppliers.name,
|
||||||
|
sessions.target_price, sessions.target_anchoring_price)
|
||||||
.outerjoin(suppliers, suppliers.supplier_id == sessions.supplier_id)
|
.outerjoin(suppliers, suppliers.supplier_id == sessions.supplier_id)
|
||||||
.where(sessions.quotation_id == qt_id, sessions.deleted == False) # noqa: E712
|
.where(sessions.quotation_id == qt_id, sessions.deleted == False) # noqa: E712
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,7 +4,7 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from pydantic import ConfigDict
|
from pydantic import ConfigDict
|
||||||
|
|
||||||
from common.enums import CardType, ChatSender, DeliveryType, QuotationStatus, QuotationType, SessionStatus, SupplierType
|
from common.enums import CardType, ChatSender, CloseReason, DeliveryType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus, SupplierType
|
||||||
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
|
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
|
||||||
|
|
||||||
|
|
||||||
@ -62,6 +62,7 @@ class QuotationData(WebPacketProtocol):
|
|||||||
preferred_sp_name: Optional[str] = None
|
preferred_sp_name: Optional[str] = None
|
||||||
equal_bid_yn: Optional[bool] = None
|
equal_bid_yn: Optional[bool] = None
|
||||||
equal_bid_data: Optional[Any] = None
|
equal_bid_data: Optional[Any] = None
|
||||||
|
close_reason: Optional[CloseReason] = None # 마감 사유(CloseReason). 미마감이면 None
|
||||||
participation_count: int = 0 # 견적별 참여 협력사 수(세션 distinct supplier). 목록 집계로 채움.
|
participation_count: int = 0 # 견적별 참여 협력사 수(세션 distinct supplier). 목록 집계로 채움.
|
||||||
item_id: Optional[uuid.UUID] = None # 대표 상품 id(세션의 첫 item). 목록 조인으로 채움.
|
item_id: Optional[uuid.UUID] = None # 대표 상품 id(세션의 첫 item). 목록 조인으로 채움.
|
||||||
item_name: Optional[str] = None # 대표 상품명. 목록 조인으로 채움.
|
item_name: Optional[str] = None # 대표 상품명. 목록 조인으로 채움.
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from pydantic import ConfigDict
|
from pydantic import ConfigDict
|
||||||
|
|
||||||
|
from common.enums import PriceGateAction
|
||||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||||
|
|
||||||
|
|
||||||
@ -15,12 +16,18 @@ class Req_CreateQuotationSetting(QuotationSettingProtocol):
|
|||||||
target_margin_rate: float
|
target_margin_rate: float
|
||||||
anchoring_value: float = 0.01
|
anchoring_value: float = 0.01
|
||||||
card_count: int = 3
|
card_count: int = 3
|
||||||
|
mid_action: PriceGateAction = PriceGateAction.AWARD # 앵커링가<투찰가≤목표가 마감 처리
|
||||||
|
over_action: PriceGateAction = PriceGateAction.AWARD # 목표가<투찰가 마감 처리
|
||||||
|
regen_limit: int = 1 # 재생성 최대 횟수(체인 전체, 사유 무관)
|
||||||
|
|
||||||
|
|
||||||
class Req_UpdateQuotationSetting(QuotationSettingProtocol):
|
class Req_UpdateQuotationSetting(QuotationSettingProtocol):
|
||||||
target_margin_rate: Optional[float] = None
|
target_margin_rate: Optional[float] = None
|
||||||
anchoring_value: Optional[float] = None
|
anchoring_value: Optional[float] = None
|
||||||
card_count: Optional[int] = None
|
card_count: Optional[int] = None
|
||||||
|
mid_action: Optional[PriceGateAction] = None
|
||||||
|
over_action: Optional[PriceGateAction] = None
|
||||||
|
regen_limit: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class QuotationSettingData(WebPacketProtocol):
|
class QuotationSettingData(WebPacketProtocol):
|
||||||
@ -31,6 +38,9 @@ class QuotationSettingData(WebPacketProtocol):
|
|||||||
target_margin_rate: float
|
target_margin_rate: float
|
||||||
anchoring_value: float
|
anchoring_value: float
|
||||||
card_count: int
|
card_count: int
|
||||||
|
mid_action: PriceGateAction = PriceGateAction.AWARD
|
||||||
|
over_action: PriceGateAction = PriceGateAction.AWARD
|
||||||
|
regen_limit: int = 1
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|||||||
151
negodata/backend/scripts/seed_demo_quotations.sql
Normal file
151
negodata/backend/scripts/seed_demo_quotations.sql
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
-- 견적상세 UI 케이스별 데모 시드 (dev DB: negosium_db)
|
||||||
|
-- 안전: 아래 데모 qt_id / 그 세션만 지우고 다시 넣는다(다른 데이터 무손상). 재실행 가능.
|
||||||
|
-- user=모모나(b874f33d) / setting=40614764 / version=...030 / item=고압 에어 컴프레서(a3d1fff5)
|
||||||
|
-- close_reason(CloseReason): 1=낙찰,2=가격재협상,3=동가재입찰,4=미참여재소집,5=가격유찰,6=동가유찰,7=미참여유찰,8=거부유찰
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DELETE FROM negotiation.sessions WHERE quotation_id IN (
|
||||||
|
'aaaa0001-0000-0000-0000-000000000001',
|
||||||
|
'aaaa0002-0000-0000-0000-000000000002',
|
||||||
|
'aaaa0003-0000-0000-0000-000000000003',
|
||||||
|
'aaaa0004-0000-0000-0000-000000000004',
|
||||||
|
'aaaa0005-0000-0000-0000-000000000005',
|
||||||
|
'aaaa0006-0000-0000-0000-000000000006',
|
||||||
|
'aaaa0007-0000-0000-0000-000000000007',
|
||||||
|
'aaaa0008-0000-0000-0000-000000000008',
|
||||||
|
'aaaa0009-0000-0000-0000-000000000009',
|
||||||
|
'aaaa0010-0000-0000-0000-000000000010'
|
||||||
|
);
|
||||||
|
DELETE FROM quotation.quotations WHERE qt_id IN (
|
||||||
|
'aaaa0001-0000-0000-0000-000000000001',
|
||||||
|
'aaaa0002-0000-0000-0000-000000000002',
|
||||||
|
'aaaa0003-0000-0000-0000-000000000003',
|
||||||
|
'aaaa0004-0000-0000-0000-000000000004',
|
||||||
|
'aaaa0005-0000-0000-0000-000000000005',
|
||||||
|
'aaaa0006-0000-0000-0000-000000000006',
|
||||||
|
'aaaa0007-0000-0000-0000-000000000007',
|
||||||
|
'aaaa0008-0000-0000-0000-000000000008',
|
||||||
|
'aaaa0009-0000-0000-0000-000000000009',
|
||||||
|
'aaaa0010-0000-0000-0000-000000000010'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── 견적 10건 (close_reason 8종 전부 + 진행중) ────────────────────────────
|
||||||
|
INSERT INTO quotation.quotations
|
||||||
|
(qt_id, user_id, qt_setting_id, version_id, name, number, type, round, status,
|
||||||
|
start_time, end_time, manager_name, manager_email, manager_contact_number, memo,
|
||||||
|
iteration, preferred_sp_yn, preferred_sp_id, preferred_sp_name, equal_bid_yn, equal_bid_data,
|
||||||
|
close_reason, md_price, supplier_type, created_at, updated_at, deleted)
|
||||||
|
VALUES
|
||||||
|
-- ① 낙찰(절감): 목표 400,000 → 낙찰 372,000
|
||||||
|
('aaaa0001-0000-0000-0000-000000000001','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||||
|
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||||
|
'① 낙찰(절감) 데모','EST-DEMO-01',4,1,3,
|
||||||
|
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','낙찰·목표대비 절감 케이스',
|
||||||
|
1, true,'9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09','(주)대한정밀', null, null,
|
||||||
|
1, 400000, 1, now(), now(), false),
|
||||||
|
-- ② 낙찰(목표초과): 목표 350,000 → 낙찰 368,000 (over_action=낙찰)
|
||||||
|
('aaaa0002-0000-0000-0000-000000000002','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||||
|
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||||
|
'② 낙찰(목표초과) 데모','EST-DEMO-02',4,1,3,
|
||||||
|
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','낙찰이지만 목표가 초과 케이스',
|
||||||
|
1, true,'69e7057a-5919-48cd-b329-13b833bff994','서울산업소재(주)', null, null,
|
||||||
|
1, 350000, 1, now(), now(), false),
|
||||||
|
-- ③ 동가 재입찰(REGEN_EQUAL): 두 곳 390,000 동률 → 재입찰 진행
|
||||||
|
('aaaa0003-0000-0000-0000-000000000003','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||||
|
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||||
|
'③ 동가 재입찰 데모','EST-DEMO-03',2,1,3,
|
||||||
|
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','동가 발생·다음 라운드 재입찰 케이스',
|
||||||
|
1, false, null, null, true,
|
||||||
|
'[{"supplier_id":"9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09","supplier_name":"(주)대한정밀","bid_price":390000},{"supplier_id":"747897ec-adab-4f3e-9076-15803ddf821f","supplier_name":"글로벌테크놀로지","bid_price":390000}]'::jsonb,
|
||||||
|
3, 400000, 1, now(), now(), false),
|
||||||
|
-- ④ 거부 유찰(FAIL_REJECT): 협상거부로 마감
|
||||||
|
('aaaa0004-0000-0000-0000-000000000004','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||||
|
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||||
|
'④ 거부 유찰 데모','EST-DEMO-04',2,1,3,
|
||||||
|
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','협상 거부로 유찰 케이스',
|
||||||
|
1, null, null, null, null, null,
|
||||||
|
8, 400000, 1, now(), now(), false),
|
||||||
|
-- ⑤ 미참여 재소집(REGEN_NOSHOW): 전원 미참여 → 다음 라운드 재소집
|
||||||
|
('aaaa0005-0000-0000-0000-000000000005','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||||
|
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||||
|
'⑤ 미참여 재소집 데모','EST-DEMO-05',1,1,3,
|
||||||
|
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','전원 미참여·다음 라운드 재소집 케이스',
|
||||||
|
1, false, null, null, false, null,
|
||||||
|
4, 400000, 1, now(), now(), false),
|
||||||
|
-- ⑥ 진행중: 일부 투찰 완료, 아직 안 닫힘 (close_reason NULL)
|
||||||
|
('aaaa0006-0000-0000-0000-000000000006','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||||
|
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||||
|
'⑥ 진행중 데모','EST-DEMO-06',4,1,2,
|
||||||
|
now()-interval '1 day', now()+interval '1 day','김담당','mgr@imk.kr','010-1111-1111','협상 진행중·현재 최저 투찰 케이스',
|
||||||
|
1, null, null, null, null, null,
|
||||||
|
null, 400000, 1, now(), now(), false),
|
||||||
|
-- ⑦ 가격 재협상(REGEN_PRICE): 단독 최저가 420,000 > 목표 350,000 → 재협상 진행
|
||||||
|
('aaaa0007-0000-0000-0000-000000000007','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||||
|
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||||
|
'⑦ 가격 재협상 데모','EST-DEMO-07',4,1,3,
|
||||||
|
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','목표 초과 → 재협상(더 깎기) 진행 케이스',
|
||||||
|
1, false, null, null, false, null,
|
||||||
|
2, 350000, 1, now(), now(), false),
|
||||||
|
-- ⑧ 가격 유찰(FAIL_PRICE): 단독 최저가 430,000 > 목표 350,000, over_action=유찰(또는 한도소진) → 유찰
|
||||||
|
('aaaa0008-0000-0000-0000-000000000008','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||||
|
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||||
|
'⑧ 가격 유찰 데모','EST-DEMO-08',4,1,3,
|
||||||
|
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','목표 초과 → 유찰 케이스',
|
||||||
|
1, false, null, null, false, null,
|
||||||
|
5, 350000, 1, now(), now(), false),
|
||||||
|
-- ⑨ 동가 유찰(FAIL_EQUAL): 동가지만 재입찰 한도 소진/유찰 정책 → 유찰
|
||||||
|
('aaaa0009-0000-0000-0000-000000000009','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||||
|
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||||
|
'⑨ 동가 유찰 데모','EST-DEMO-09',2,2,3,
|
||||||
|
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','동가지만 재입찰 한도 소진 → 유찰 케이스',
|
||||||
|
1, false, null, null, true,
|
||||||
|
'[{"supplier_id":"9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09","supplier_name":"(주)대한정밀","bid_price":405000},{"supplier_id":"69e7057a-5919-48cd-b329-13b833bff994","supplier_name":"서울산업소재(주)","bid_price":405000}]'::jsonb,
|
||||||
|
6, 400000, 1, now(), now(), false),
|
||||||
|
-- ⑩ 미참여 유찰(FAIL_NOSHOW): 전원 미참여 + 재소집 한도 소진 → 유찰
|
||||||
|
('aaaa0010-0000-0000-0000-000000000010','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||||
|
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||||
|
'⑩ 미참여 유찰 데모','EST-DEMO-10',1,2,3,
|
||||||
|
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','전원 미참여 + 재소집 한도 소진 → 유찰 케이스',
|
||||||
|
1, false, null, null, false, null,
|
||||||
|
7, 400000, 1, now(), now(), false);
|
||||||
|
|
||||||
|
-- ── 세션 ────────────────────────────────────────────────────────────────
|
||||||
|
-- 공통: item=a3d1fff5, DeliveryType 협력사배송=1
|
||||||
|
INSERT INTO negotiation.sessions
|
||||||
|
(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type,
|
||||||
|
target_price, status, bid_price, bid_at, end_time,
|
||||||
|
reject_reason, reject_price, reject_delivery_type, target_anchoring_price, email_sent_at,
|
||||||
|
created_at, updated_at, deleted)
|
||||||
|
VALUES
|
||||||
|
-- ① 낙찰(절감): S1 372,000(낙찰) / S2 389,000 / S3 395,000
|
||||||
|
('b0000001-0000-0000-0000-000000000001','aaaa0001-0000-0000-0000-000000000001','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09','EST-DEMO-01',1,4,400000,3,372000,now()-interval '2 hour',now()-interval '1 hour',null,null,null,396000,now()-interval '2 days',now(),now(),false),
|
||||||
|
('b0000001-0000-0000-0000-000000000002','aaaa0001-0000-0000-0000-000000000001','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','69e7057a-5919-48cd-b329-13b833bff994','EST-DEMO-01',1,4,400000,3,389000,now()-interval '2 hour',now()-interval '1 hour',null,null,null,396000,now()-interval '2 days',now(),now(),false),
|
||||||
|
('b0000001-0000-0000-0000-000000000003','aaaa0001-0000-0000-0000-000000000001','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','747897ec-adab-4f3e-9076-15803ddf821f','EST-DEMO-01',1,4,400000,3,395000,now()-interval '2 hour',now()-interval '1 hour',null,null,null,396000,null,now()-interval '2 days',now(),false),
|
||||||
|
-- ② 낙찰(초과): S2 368,000(낙찰, 목표 350k 초과) / S1 375,000
|
||||||
|
('b0000002-0000-0000-0000-000000000001','aaaa0002-0000-0000-0000-000000000002','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','69e7057a-5919-48cd-b329-13b833bff994','EST-DEMO-02',1,4,350000,3,368000,now()-interval '2 hour',now()-interval '1 hour',null,null,null,346500,now()-interval '2 days',now(),now(),false),
|
||||||
|
('b0000002-0000-0000-0000-000000000002','aaaa0002-0000-0000-0000-000000000002','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09','EST-DEMO-02',1,4,350000,3,375000,now()-interval '2 hour',now()-interval '1 hour',null,null,null,346500,now()-interval '2 days',now(),now(),false),
|
||||||
|
-- ③ 동가 재입찰: S1 390,000 / S3 390,000
|
||||||
|
('b0000003-0000-0000-0000-000000000001','aaaa0003-0000-0000-0000-000000000003','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09','EST-DEMO-03',1,2,400000,3,390000,now()-interval '2 hour',now()-interval '1 hour',null,null,null,396000,now()-interval '2 days',now(),now(),false),
|
||||||
|
('b0000003-0000-0000-0000-000000000002','aaaa0003-0000-0000-0000-000000000003','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','747897ec-adab-4f3e-9076-15803ddf821f','EST-DEMO-03',1,2,400000,3,390000,now()-interval '2 hour',now()-interval '1 hour',null,null,null,396000,now()-interval '2 days',now(),now(),false),
|
||||||
|
-- ④ 거부 유찰: S4 협상거부(reject) / S5 미참여
|
||||||
|
('b0000004-0000-0000-0000-000000000001','aaaa0004-0000-0000-0000-000000000004','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','bcee5c78-ee13-4499-8e3d-132e2645dd6a','EST-DEMO-04',1,2,400000,5,null,null,now()-interval '1 hour','단가가 맞지 않아 참여를 거부합니다',430000,1,396000,now()-interval '2 days',now()-interval '2 days',now(),false),
|
||||||
|
('b0000004-0000-0000-0000-000000000002','aaaa0004-0000-0000-0000-000000000004','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','ffaf086d-6630-4c2e-9ae9-01843b86ff5f','EST-DEMO-04',1,2,400000,4,null,null,now()-interval '1 hour',null,null,null,396000,null,now()-interval '2 days',now(),false),
|
||||||
|
-- ⑤ 미참여 재소집: S1, S2 둘 다 미참여
|
||||||
|
('b0000005-0000-0000-0000-000000000001','aaaa0005-0000-0000-0000-000000000005','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09','EST-DEMO-05',1,1,400000,4,null,null,now()-interval '1 hour',null,null,null,396000,now()-interval '2 days',now()-interval '2 days',now(),false),
|
||||||
|
('b0000005-0000-0000-0000-000000000002','aaaa0005-0000-0000-0000-000000000005','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','69e7057a-5919-48cd-b329-13b833bff994','EST-DEMO-05',1,1,400000,4,null,null,now()-interval '1 hour',null,null,null,396000,now()-interval '2 days',now()-interval '2 days',now(),false),
|
||||||
|
-- ⑥ 진행중: S1 385,000 투찰완료 / S2 협상중 / S3 생성됨
|
||||||
|
('b0000006-0000-0000-0000-000000000001','aaaa0006-0000-0000-0000-000000000006','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09','EST-DEMO-06',1,4,400000,3,385000,now()-interval '3 hour',now()+interval '1 day',null,null,null,396000,now()-interval '1 day',now(),now(),false),
|
||||||
|
('b0000006-0000-0000-0000-000000000002','aaaa0006-0000-0000-0000-000000000006','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','69e7057a-5919-48cd-b329-13b833bff994','EST-DEMO-06',1,4,400000,2,null,null,now()+interval '1 day',null,null,null,396000,now()-interval '1 day',now(),now(),false),
|
||||||
|
('b0000006-0000-0000-0000-000000000003','aaaa0006-0000-0000-0000-000000000006','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','747897ec-adab-4f3e-9076-15803ddf821f','EST-DEMO-06',1,4,400000,1,null,null,now()+interval '1 day',null,null,null,396000,null,now()-interval '1 day',now(),false),
|
||||||
|
-- ⑦ 가격 재협상: S1 420,000(단독, 목표 350k 초과)
|
||||||
|
('b0000007-0000-0000-0000-000000000001','aaaa0007-0000-0000-0000-000000000007','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09','EST-DEMO-07',1,4,350000,3,420000,now()-interval '2 hour',now()-interval '1 hour',null,null,null,346500,now()-interval '2 days',now(),now(),false),
|
||||||
|
-- ⑧ 가격 유찰: S1 430,000(단독, 목표 350k 초과)
|
||||||
|
('b0000008-0000-0000-0000-000000000001','aaaa0008-0000-0000-0000-000000000008','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','69e7057a-5919-48cd-b329-13b833bff994','EST-DEMO-08',1,4,350000,3,430000,now()-interval '2 hour',now()-interval '1 hour',null,null,null,346500,now()-interval '2 days',now(),now(),false),
|
||||||
|
-- ⑨ 동가 유찰: S1 405,000 / S2 405,000 (동률)
|
||||||
|
('b0000009-0000-0000-0000-000000000001','aaaa0009-0000-0000-0000-000000000009','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09','EST-DEMO-09',2,2,400000,3,405000,now()-interval '2 hour',now()-interval '1 hour',null,null,null,396000,now()-interval '2 days',now(),now(),false),
|
||||||
|
('b0000009-0000-0000-0000-000000000002','aaaa0009-0000-0000-0000-000000000009','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','69e7057a-5919-48cd-b329-13b833bff994','EST-DEMO-09',2,2,400000,3,405000,now()-interval '2 hour',now()-interval '1 hour',null,null,null,396000,now()-interval '2 days',now(),now(),false),
|
||||||
|
-- ⑩ 미참여 유찰: S1, S2 둘 다 미참여
|
||||||
|
('b0000010-0000-0000-0000-000000000001','aaaa0010-0000-0000-0000-000000000010','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09','EST-DEMO-10',2,1,400000,4,null,null,now()-interval '1 hour',null,null,null,396000,now()-interval '2 days',now()-interval '2 days',now(),false),
|
||||||
|
('b0000010-0000-0000-0000-000000000002','aaaa0010-0000-0000-0000-000000000010','a3d1fff5-3169-4717-8d12-48e2f3b0d32d','69e7057a-5919-48cd-b329-13b833bff994','EST-DEMO-10',2,1,400000,4,null,null,now()-interval '1 hour',null,null,null,396000,now()-interval '2 days',now()-interval '2 days',now(),false);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@ -7,7 +7,7 @@ from fastapi import Depends
|
|||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import quotations, sessions, chats, versions, version_nego_cards, version_wild_cards
|
from common.database.model.models import quotations, sessions, chats, versions, version_nego_cards, version_wild_cards
|
||||||
from common.enums import CloseOutcome, DBWRType, ErrorType, NotificationType, QuotationStatus, QuotationType, SessionStatus
|
from common.enums import CloseOutcome, CloseReason, DBWRType, ErrorType, NotificationType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from common.models.gmodel import PageParams
|
from common.models.gmodel import PageParams
|
||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
@ -268,13 +268,15 @@ class QuotationService:
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
async def create_quotation(self, user_id: str, req: Req_CreateQuotation) -> Res_CreateQuotation:
|
async def create_quotation(self, user_id: str, req: Req_CreateQuotation) -> Res_CreateQuotation:
|
||||||
"""[프론트] 신규 견적 생성. 요청값을 보정한 뒤 공통 빌더(_build_quotation)에 위임한다."""
|
"""[프론트] 신규 견적 생성. 요청값을 보정한 뒤 공통 빌더(_build_quotation)에 위임한다.
|
||||||
return await self._build_quotation(
|
생성 성공 시 작성자에게 CREATED 알림(인박스)."""
|
||||||
|
number = self._gen_number() # 견적번호는 항상 서버 생성(프론트 입력란 없음)
|
||||||
|
res = await self._build_quotation(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
qt_setting_id=req.qt_setting_id,
|
qt_setting_id=req.qt_setting_id,
|
||||||
version_id=req.version_id or self.DEFAULT_VERSION_ID,
|
version_id=req.version_id or self.DEFAULT_VERSION_ID,
|
||||||
name=req.name,
|
name=req.name,
|
||||||
number=self._gen_number(), # 견적번호는 항상 서버 생성(프론트 입력란 없음)
|
number=number,
|
||||||
type_=req.type,
|
type_=req.type,
|
||||||
status=req.status or QuotationStatus.CREATED.value,
|
status=req.status or QuotationStatus.CREATED.value,
|
||||||
round_=req.round or 1,
|
round_=req.round or 1,
|
||||||
@ -290,6 +292,13 @@ class QuotationService:
|
|||||||
supplier_ids=req.supplier_ids,
|
supplier_ids=req.supplier_ids,
|
||||||
card_ids=req.card_ids,
|
card_ids=req.card_ids,
|
||||||
)
|
)
|
||||||
|
if res.result.success:
|
||||||
|
await create_notification(
|
||||||
|
user_id, NotificationType.CREATED,
|
||||||
|
{"qt_name": req.name, "qt_number": number},
|
||||||
|
ref_qt_id=res.qt_id,
|
||||||
|
)
|
||||||
|
return res
|
||||||
|
|
||||||
async def regenerate_next_round(self, original_qt_id: uuid.UUID, supplier_ids: list) -> Res_CreateQuotation:
|
async def regenerate_next_round(self, original_qt_id: uuid.UUID, supplier_ids: list) -> Res_CreateQuotation:
|
||||||
"""[마감 후속] 결판 안 난 견적의 '다음 라운드'를 새로 만든다.
|
"""[마감 후속] 결판 안 난 견적의 '다음 라운드'를 새로 만든다.
|
||||||
@ -508,98 +517,71 @@ class QuotationService:
|
|||||||
if len(tied) > 1:
|
if len(tied) > 1:
|
||||||
equal = {"price": min_price, "suppliers": [{"supplier_id": str(sid), "name": name} for sid, _, name in tied]}
|
equal = {"price": min_price, "suppliers": [{"supplier_id": str(sid), "name": name} for sid, _, name in tied]}
|
||||||
return None, equal
|
return None, equal
|
||||||
return {"supplier_id": tied[0][0], "name": tied[0][2]}, None
|
return {"supplier_id": tied[0][0], "name": tied[0][2], "bid_price": min_price}, None
|
||||||
|
|
||||||
async def _award_and_close(self, qt_uuid, winner) -> None:
|
@staticmethod
|
||||||
"""단독 낙찰 확정 + 마감 + 미완료(미시작·진행중) 세션 미참여."""
|
def _gate_action(bid, target, anchor, mid_action, over_action) -> int:
|
||||||
data = {
|
"""가격게이트 판정 → PriceGateAction 코드.
|
||||||
"status": QuotationStatus.CLOSED.value,
|
bid ≤ 앵커링가 → 무조건 낙찰(AWARD)
|
||||||
"preferred_sp_yn": True,
|
앵커링가 < bid ≤ 목표가 → mid_action(설정)
|
||||||
"preferred_sp_id": winner["supplier_id"],
|
목표가 < bid → over_action(설정)
|
||||||
"preferred_sp_name": (winner["name"] or "")[:20],
|
target/bid 없으면(설정 불완전 등) AWARD 폴백(하위호환: 기존 '무조건 낙찰')."""
|
||||||
"equal_bid_yn": False,
|
if bid is None or target is None:
|
||||||
}
|
return PriceGateAction.AWARD.value
|
||||||
|
bid = int(bid)
|
||||||
|
if anchor is not None and bid <= int(anchor):
|
||||||
|
return PriceGateAction.AWARD.value
|
||||||
|
if bid <= int(target):
|
||||||
|
return mid_action or PriceGateAction.AWARD.value
|
||||||
|
return over_action or PriceGateAction.AWARD.value
|
||||||
|
|
||||||
|
async def _close(self, qt_uuid, close_reason: int, data: dict = None) -> None:
|
||||||
|
"""마감 공통: status→CLOSED + close_reason 기록 + (있으면)추가데이터 + 미완료(미시작·진행중) 세션→미참여.
|
||||||
|
close_reason(CloseReason)이 재생성 한도 카운팅·유찰 사유 구분의 단일 근거.
|
||||||
|
preferred_sp_*/equal_bid_* 는 프론트 표시용으로 함께 채운다(사유 판별은 close_reason 이 담당)."""
|
||||||
|
payload = {"status": QuotationStatus.CLOSED.value, "close_reason": close_reason}
|
||||||
|
if data:
|
||||||
|
payload.update(data)
|
||||||
await DB_SESSION_MNG.execute_lambda_run(
|
await DB_SESSION_MNG.execute_lambda_run(
|
||||||
[quotations.DBType()],
|
[quotations.DBType()],
|
||||||
[
|
[
|
||||||
lambda s: self.quotation_crud.update_quotation(s, qt_uuid, data),
|
lambda s: self.quotation_crud.update_quotation(s, qt_uuid, payload),
|
||||||
lambda s: self.quotation_crud.update_sessions_status(
|
lambda s: self.quotation_crud.update_sessions_status(
|
||||||
s, qt_uuid, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value
|
s, qt_uuid, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _just_close(self, qt_uuid) -> None:
|
async def _regen_and_notify(self, original, qt_uuid, supplier_ids: list, reason: str, equal=None) -> CloseOutcome:
|
||||||
"""그냥 마감 + 미완료 세션 미참여."""
|
"""마감 후 다음 라운드 재생성 + 성공 시 REGENERATED 알림. 실패 시 REGEN_FAILED(원본 CLOSED 인데 체인 끊김 → 노출)."""
|
||||||
await DB_SESSION_MNG.execute_lambda_run(
|
regen = await self.regenerate_next_round(qt_uuid, supplier_ids)
|
||||||
[quotations.DBType()],
|
if not regen.result.success:
|
||||||
[
|
LOG.e_no_callstack(
|
||||||
lambda s: self.quotation_crud.update_quotation(s, qt_uuid, {"status": QuotationStatus.CLOSED.value}),
|
f"[close] 재생성 실패({reason}) qt={qt_uuid} number={original.number} round={original.round} "
|
||||||
lambda s: self.quotation_crud.update_sessions_status(
|
f"code={regen.result.code}({regen.result.desc})"
|
||||||
s, qt_uuid, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _close_as_no_show(self, qt_uuid) -> None:
|
|
||||||
"""전원 미참여로 '다음 라운드 재생성' 하며 마감 + 미완료 세션 미참여.
|
|
||||||
재생성 사유(미참여)를 체인에 남기기 위해 preferred_sp_yn=False, equal_bid_yn=False 로 양성 표식한다
|
|
||||||
(단독낙찰=preferred_sp_yn True / 동가=equal_bid_yn True / 거부·한도 등 그냥 마감=둘 다 NULL 과 구분).
|
|
||||||
_chain_regen_counts 가 이 표식으로 '미참여 재생성 이력'만 정확히 센다."""
|
|
||||||
data = {
|
|
||||||
"status": QuotationStatus.CLOSED.value,
|
|
||||||
"preferred_sp_yn": False,
|
|
||||||
"equal_bid_yn": False,
|
|
||||||
}
|
|
||||||
await DB_SESSION_MNG.execute_lambda_run(
|
|
||||||
[quotations.DBType()],
|
|
||||||
[
|
|
||||||
lambda s: self.quotation_crud.update_quotation(s, qt_uuid, data),
|
|
||||||
lambda s: self.quotation_crud.update_sessions_status(
|
|
||||||
s, qt_uuid, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _close_as_equal(self, qt_uuid, equal) -> None:
|
|
||||||
"""동가로 마감 + 미완료 세션 미참여. equal_bid_yn/data 를 기록해 둔다
|
|
||||||
(재생성 한도 계산이 이 플래그로 동가 라운드를 식별하고, 프론트도 동가 정보를 그대로 쓴다)."""
|
|
||||||
data = {
|
|
||||||
"status": QuotationStatus.CLOSED.value,
|
|
||||||
"preferred_sp_yn": False,
|
|
||||||
"equal_bid_yn": True,
|
|
||||||
"equal_bid_data": equal,
|
|
||||||
}
|
|
||||||
await DB_SESSION_MNG.execute_lambda_run(
|
|
||||||
[quotations.DBType()],
|
|
||||||
[
|
|
||||||
lambda s: self.quotation_crud.update_quotation(s, qt_uuid, data),
|
|
||||||
lambda s: self.quotation_crud.update_sessions_status(
|
|
||||||
s, qt_uuid, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
return CloseOutcome.REGEN_FAILED
|
||||||
|
payload = {"qt_name": original.name, "qt_number": original.number, "reason": reason, "next_round": original.round + 1}
|
||||||
|
if equal is not None:
|
||||||
|
payload.update({"tied_price": equal["price"], "tied_count": len(equal["suppliers"])})
|
||||||
|
await create_notification(original.user_id, NotificationType.REGENERATED, payload, ref_qt_id=regen.qt_id)
|
||||||
|
return CloseOutcome.REGENERATED
|
||||||
|
|
||||||
async def close_and_decide(self, qt_id) -> CloseOutcome:
|
async def close_and_decide(self, qt_id) -> CloseOutcome:
|
||||||
"""[마감] 견적을 마감하면서 결과를 판정한다.
|
"""[마감] 견적을 마감하며 결과 판정. 낙찰자는 협상완료 최저가(단독/동가), 그 위에 회사 가격정책(가격게이트)으로 낙찰/재협상/유찰을 가른다.
|
||||||
1) 협상완료 중 최저가 단독 → 그 공급사 낙찰 확정
|
- 단독 최저가: 가격게이트(≤앵커 무조건낙찰 / 앵커~목표 mid_action / 목표초과 over_action) → 낙찰 / 재협상(한도내) / 유찰
|
||||||
2) 협상완료 중 최저가 동가 → 다음 라운드 재생성(동가 업체끼리) [체인에 동가 재생성 이력 없을 때만]
|
- 동가: 그 가격 구간 설정이 '유찰'이 아니고 한도 남으면 재입찰(tie 해소), 유찰 설정이거나 한도 소진이면 유찰
|
||||||
3) 협상거부 세션이 하나라도 있음 → 그냥 마감 (재생성 안 함)
|
- 협상거부: 유찰
|
||||||
4) 전원 미참여(완료·거부 0) → 다음 라운드 재생성(원 견적 공급사 전체) [체인에 미참여 재생성 이력 없을 때만]
|
- 전원 미참여: 재소집(한도내) / 유찰
|
||||||
5) 그 외 / 한도 도달 → 그냥 마감
|
재생성 한도: regen_limit = 체인(같은 견적번호) 전체 재생성 '총' 횟수(사유 무관). 마감 사유는 close_reason 에 기록(카운팅·유찰사유 구분의 단일 근거).
|
||||||
동가를 거부보다 먼저 본다: 동률은 '협상완료'한 업체들 간 경쟁이라 무관한 다른 업체의 거부로 막지 않는다.
|
공통: 원자적 status→CLOSED 선점, 미시작·진행중 세션→미참여."""
|
||||||
재생성 한도: 한 체인(같은 견적번호)에서 '미참여' 1번 + '동가' 1번(순서 무관, 같은 사유 2번은 불가).
|
|
||||||
공통: status→CLOSED, 미시작·진행중 세션→미참여."""
|
|
||||||
qt_uuid = qt_id if isinstance(qt_id, uuid.UUID) else uuid.UUID(str(qt_id))
|
qt_uuid = qt_id if isinstance(qt_id, uuid.UUID) else uuid.UUID(str(qt_id))
|
||||||
|
|
||||||
err_type, original = await self._fetch(qt_uuid)
|
err_type, original = await self._fetch(qt_uuid)
|
||||||
if err_type != ErrorType.SUCCESS or original is None:
|
if err_type != ErrorType.SUCCESS or original is None:
|
||||||
return CloseOutcome.CLOSED
|
return CloseOutcome.CLOSED
|
||||||
|
|
||||||
# [동시 마감 가드] 마감 판정 전에 원자적으로 status→CLOSED 를 선점한다.
|
# [동시 마감 가드] 원자적으로 status→CLOSED 선점. 실제로 전이한 호출자만 통과(이중 재생성·uq(number,round) 충돌 방지).
|
||||||
# 두 크론 잡(close_expired / close_negotiated)이나 수동 stop_quotation 이 같은 견적을
|
|
||||||
# 동시에 닫으려 해도, 실제로 CLOSED 로 전이한 호출자만 통과하고 진 호출자는 여기서 끝난다
|
|
||||||
# → 이중 재생성·uq(number,round) 충돌 방지. (이미 닫힌 견적의 재처리도 여기서 차단)
|
|
||||||
claim_err, claimed = await DB_SESSION_MNG.execute_lambda_claim(
|
claim_err, claimed = await DB_SESSION_MNG.execute_lambda_claim(
|
||||||
quotations.DBType(),
|
quotations.DBType(),
|
||||||
lambda s: self.quotation_crud.claim_for_close(s, qt_uuid),
|
lambda s: self.quotation_crud.claim_for_close(s, qt_uuid),
|
||||||
@ -618,69 +600,86 @@ class QuotationService:
|
|||||||
has_rejected = any(r.status == SessionStatus.REJECTED.value for r in rows)
|
has_rejected = any(r.status == SessionStatus.REJECTED.value for r in rows)
|
||||||
winner, equal = self._pick_winner(done)
|
winner, equal = self._pick_winner(done)
|
||||||
|
|
||||||
# 1) 단독 낙찰 → 확정
|
# 가격게이트 입력: 회사 정책(mid/over/limit) + 세션 목표가/앵커링가(견적당 상품 1개라 세션 공통값)
|
||||||
|
_e, rates = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
quotations.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.quotation_crud.get_setting_rates(s, original.qt_setting_id),
|
||||||
|
)
|
||||||
|
rates = rates or {}
|
||||||
|
mid_action = rates.get("mid_action") or PriceGateAction.AWARD.value
|
||||||
|
over_action = rates.get("over_action") or PriceGateAction.AWARD.value
|
||||||
|
limit = rates.get("regen_limit")
|
||||||
|
limit = limit if limit is not None else self.MAX_REGEN_PER_CAUSE
|
||||||
|
target = next((r.target_price for r in rows if r.target_price is not None), None)
|
||||||
|
anchor = next((r.target_anchoring_price for r in rows if r.target_anchoring_price is not None), None)
|
||||||
|
|
||||||
|
# 재생성 총 이력(체인, 사유 무관). regen_limit = 체인 전체 재생성 총 한도.
|
||||||
|
regen_used = await self._chain_regen_count(original.number, original.round)
|
||||||
|
|
||||||
|
# 1) 단독 최저가 → 가격게이트로 낙찰/재협상/유찰
|
||||||
if winner is not None:
|
if winner is not None:
|
||||||
await self._award_and_close(qt_uuid, winner)
|
action = self._gate_action(winner["bid_price"], target, anchor, mid_action, over_action)
|
||||||
winner_price = min((int(bp) for _, bp, _ in done if bp is not None), default=None)
|
if action == PriceGateAction.RENEGO.value and regen_used < limit:
|
||||||
|
await self._close(qt_uuid, CloseReason.REGEN_PRICE.value, {"preferred_sp_yn": False, "equal_bid_yn": False})
|
||||||
|
return await self._regen_and_notify(original, qt_uuid, [winner["supplier_id"]], "price")
|
||||||
|
if action == PriceGateAction.AWARD.value:
|
||||||
|
await self._close(qt_uuid, CloseReason.AWARDED.value, {
|
||||||
|
"preferred_sp_yn": True, "preferred_sp_id": winner["supplier_id"],
|
||||||
|
"preferred_sp_name": (winner["name"] or "")[:20], "equal_bid_yn": False,
|
||||||
|
})
|
||||||
await create_notification(
|
await create_notification(
|
||||||
original.user_id, NotificationType.SUCCESS,
|
original.user_id, NotificationType.SUCCESS,
|
||||||
{"qt_name": original.name, "qt_number": original.number,
|
{"qt_name": original.name, "qt_number": original.number,
|
||||||
"winner_name": winner["name"], "winner_price": winner_price},
|
"winner_name": winner["name"], "winner_price": winner["bid_price"]},
|
||||||
ref_qt_id=qt_uuid,
|
ref_qt_id=qt_uuid,
|
||||||
)
|
)
|
||||||
return CloseOutcome.AWARDED
|
return CloseOutcome.AWARDED
|
||||||
|
# FAIL 이거나, RENEGO 인데 한도 소진 → 가격 유찰
|
||||||
# 동가/미참여 재생성은 사유별 한도(각 1번, 순서 무관) 확인 후
|
await self._close(qt_uuid, CloseReason.FAIL_PRICE.value, {"preferred_sp_yn": False, "equal_bid_yn": False})
|
||||||
no_part_used, equal_used = await self._chain_regen_counts(original.number, original.round)
|
|
||||||
|
|
||||||
# 2) 동가 → 동가 업체끼리 다음 라운드 (거부보다 먼저: tie 해소 우선, 체인에 동가 이력 없을 때만)
|
|
||||||
if equal is not None and equal_used < self.MAX_REGEN_PER_CAUSE:
|
|
||||||
tied_ids = [uuid.UUID(sp["supplier_id"]) for sp in equal["suppliers"]]
|
|
||||||
await self._close_as_equal(qt_uuid, equal) # 동가 기록(equal_bid_yn) 후 마감
|
|
||||||
regen = await self.regenerate_next_round(qt_uuid, tied_ids)
|
|
||||||
if not regen.result.success:
|
|
||||||
# 원본은 이미 CLOSED 인데 다음 라운드 생성이 실패 → 체인이 끊긴 상태. 성공으로 위장하지 않고 드러낸다.
|
|
||||||
LOG.e_no_callstack(
|
|
||||||
f"[close] 동가 재생성 실패 qt={qt_uuid} number={original.number} round={original.round} "
|
|
||||||
f"code={regen.result.code}({regen.result.desc})"
|
|
||||||
)
|
|
||||||
return CloseOutcome.REGEN_FAILED
|
|
||||||
await create_notification(
|
await create_notification(
|
||||||
original.user_id, NotificationType.REGENERATED,
|
original.user_id, NotificationType.FAILURE,
|
||||||
{"qt_name": original.name, "qt_number": original.number, "reason": "equal",
|
{"qt_name": original.name, "qt_number": original.number, "reason": "price"},
|
||||||
"next_round": original.round + 1, "tied_price": equal["price"], "tied_count": len(equal["suppliers"])},
|
ref_qt_id=qt_uuid,
|
||||||
ref_qt_id=regen.qt_id,
|
|
||||||
)
|
)
|
||||||
return CloseOutcome.REGENERATED
|
return CloseOutcome.CLOSED
|
||||||
# 3) 협상거부 있음 → 마감만 (재생성 안 함)
|
|
||||||
|
# 2) 동가 → 그 가격 구간 설정이 '유찰'이 아니고 한도 남으면 재입찰(tie 해소), 유찰 설정이거나 한도 소진이면 유찰.
|
||||||
|
# (동가는 단독 낙찰 불가 → AWARD 설정이어도 '재입찰로 tie 해소'가 자연스러워 기본은 재입찰=하위호환. 회사가 FAIL 로 두면 유찰.)
|
||||||
|
if equal is not None:
|
||||||
|
action = self._gate_action(equal["price"], target, anchor, mid_action, over_action)
|
||||||
|
if action != PriceGateAction.FAIL.value and regen_used < limit:
|
||||||
|
tied_ids = [uuid.UUID(sp["supplier_id"]) for sp in equal["suppliers"]]
|
||||||
|
await self._close(qt_uuid, CloseReason.REGEN_EQUAL.value,
|
||||||
|
{"preferred_sp_yn": False, "equal_bid_yn": True, "equal_bid_data": equal})
|
||||||
|
return await self._regen_and_notify(original, qt_uuid, tied_ids, "equal", equal=equal)
|
||||||
|
await self._close(qt_uuid, CloseReason.FAIL_EQUAL.value,
|
||||||
|
{"preferred_sp_yn": False, "equal_bid_yn": True, "equal_bid_data": equal})
|
||||||
|
await create_notification(
|
||||||
|
original.user_id, NotificationType.FAILURE,
|
||||||
|
{"qt_name": original.name, "qt_number": original.number, "reason": "equal"},
|
||||||
|
ref_qt_id=qt_uuid,
|
||||||
|
)
|
||||||
|
return CloseOutcome.CLOSED
|
||||||
|
|
||||||
|
# 3) 협상거부 있음 → 유찰
|
||||||
if has_rejected:
|
if has_rejected:
|
||||||
await self._just_close(qt_uuid)
|
await self._close(qt_uuid, CloseReason.FAIL_REJECT.value)
|
||||||
await create_notification(
|
await create_notification(
|
||||||
original.user_id, NotificationType.FAILURE,
|
original.user_id, NotificationType.FAILURE,
|
||||||
{"qt_name": original.name, "qt_number": original.number, "reason": "rejected"},
|
{"qt_name": original.name, "qt_number": original.number, "reason": "rejected"},
|
||||||
ref_qt_id=qt_uuid,
|
ref_qt_id=qt_uuid,
|
||||||
)
|
)
|
||||||
return CloseOutcome.CLOSED
|
return CloseOutcome.CLOSED
|
||||||
# 4) 전원 미참여 → 공급사 전체로 다음 라운드 (체인에 미참여 재생성 이력 없을 때만)
|
|
||||||
if not done and rows and no_part_used < self.MAX_REGEN_PER_CAUSE:
|
# 4) 전원 미참여 → 공급사 전체 재소집(한도 남을 때)
|
||||||
|
if not done and rows and regen_used < limit:
|
||||||
supplier_ids = list({r.supplier_id for r in rows})
|
supplier_ids = list({r.supplier_id for r in rows})
|
||||||
await self._close_as_no_show(qt_uuid) # 미참여 재생성 표식(preferred_sp_yn=False, equal_bid_yn=False) 후 마감
|
await self._close(qt_uuid, CloseReason.REGEN_NOSHOW.value, {"preferred_sp_yn": False, "equal_bid_yn": False})
|
||||||
regen = await self.regenerate_next_round(qt_uuid, supplier_ids)
|
return await self._regen_and_notify(original, qt_uuid, supplier_ids, "no_show")
|
||||||
if not regen.result.success:
|
|
||||||
# 원본은 이미 CLOSED 인데 다음 라운드 생성이 실패 → 체인이 끊긴 상태. 성공으로 위장하지 않고 드러낸다.
|
# 5) 그 외 / 한도 도달 → 미참여 유찰
|
||||||
LOG.e_no_callstack(
|
await self._close(qt_uuid, CloseReason.FAIL_NOSHOW.value, {"preferred_sp_yn": False, "equal_bid_yn": False})
|
||||||
f"[close] 미참여 재생성 실패 qt={qt_uuid} number={original.number} round={original.round} "
|
|
||||||
f"code={regen.result.code}({regen.result.desc})"
|
|
||||||
)
|
|
||||||
return CloseOutcome.REGEN_FAILED
|
|
||||||
await create_notification(
|
|
||||||
original.user_id, NotificationType.REGENERATED,
|
|
||||||
{"qt_name": original.name, "qt_number": original.number, "reason": "no_show", "next_round": original.round + 1},
|
|
||||||
ref_qt_id=regen.qt_id,
|
|
||||||
)
|
|
||||||
return CloseOutcome.REGENERATED
|
|
||||||
# 5) 그 외 / 한도 도달 → 마감만
|
|
||||||
await self._just_close(qt_uuid)
|
|
||||||
await create_notification(
|
await create_notification(
|
||||||
original.user_id, NotificationType.FAILURE,
|
original.user_id, NotificationType.FAILURE,
|
||||||
{"qt_name": original.name, "qt_number": original.number, "reason": "closed"},
|
{"qt_name": original.name, "qt_number": original.number, "reason": "closed"},
|
||||||
@ -688,23 +687,18 @@ class QuotationService:
|
|||||||
)
|
)
|
||||||
return CloseOutcome.CLOSED
|
return CloseOutcome.CLOSED
|
||||||
|
|
||||||
async def _chain_regen_counts(self, number: str, current_round: int) -> tuple[int, int]:
|
async def _chain_regen_count(self, number: str, current_round: int) -> int:
|
||||||
"""체인(같은 견적번호) 이전 라운드들의 '재생성 사유' 횟수. 반환: (미참여 횟수, 동가 횟수).
|
"""체인(같은 견적번호) 이전 라운드들의 재생성 '총' 횟수(사유 무관). regen_limit 은 체인 전체 총 한도다.
|
||||||
마감 시 남긴 양성 표식으로만 센다(오집계 방지):
|
close_reason 의 REGEN_*(가격/동가/미참여) 만 센다 — 낙찰(AWARDED)·유찰(FAIL_*)·미마감(NULL)은 한도에 안 셈."""
|
||||||
- 동가 재생성 → equal_bid_yn=True
|
err_type, reasons = await DB_SESSION_MNG.execute_lambda(
|
||||||
- 미참여 재생성 → preferred_sp_yn=False AND equal_bid_yn=False
|
|
||||||
단독낙찰(preferred_sp_yn=True)·거부/한도 그냥 마감(둘 다 NULL)은 어느 쪽에도 세지 않는다.
|
|
||||||
(수동 regenerate_quotation 으로 단독낙찰·거부 라운드를 이어붙여도 자동 재생성 한도에 영향 없음.)"""
|
|
||||||
err_type, flags = await DB_SESSION_MNG.execute_lambda(
|
|
||||||
quotations.DBType(),
|
quotations.DBType(),
|
||||||
DBWRType.DB_READ.value,
|
DBWRType.DB_READ.value,
|
||||||
lambda s: self.quotation_crud.list_chain_close_flags(s, number, current_round),
|
lambda s: self.quotation_crud.list_chain_close_reasons(s, number, current_round),
|
||||||
)
|
)
|
||||||
if err_type != ErrorType.SUCCESS:
|
if err_type != ErrorType.SUCCESS:
|
||||||
return 0, 0
|
return 0
|
||||||
equal = sum(1 for _pref, eq in flags if eq is True)
|
regen = {CloseReason.REGEN_PRICE.value, CloseReason.REGEN_EQUAL.value, CloseReason.REGEN_NOSHOW.value}
|
||||||
no_part = sum(1 for pref, eq in flags if pref is False and eq is False)
|
return sum(1 for c in reasons if c in regen)
|
||||||
return no_part, equal
|
|
||||||
|
|
||||||
async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list) -> Res_CreateQuotation:
|
async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list) -> Res_CreateQuotation:
|
||||||
"""[프론트] 마감된 견적을 골라 수동으로 다음 라운드를 생성한다.
|
"""[프론트] 마감된 견적을 골라 수동으로 다음 라운드를 생성한다.
|
||||||
|
|||||||
@ -67,6 +67,9 @@ class QuotationSettingService:
|
|||||||
target_margin_rate=req.target_margin_rate,
|
target_margin_rate=req.target_margin_rate,
|
||||||
anchoring_value=req.anchoring_value,
|
anchoring_value=req.anchoring_value,
|
||||||
card_count=req.card_count,
|
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(
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
[quotation_settings.DBType()],
|
[quotation_settings.DBType()],
|
||||||
@ -83,6 +86,11 @@ class QuotationSettingService:
|
|||||||
user_uuid = uuid.UUID(user_id)
|
user_uuid = uuid.UUID(user_id)
|
||||||
setting_uuid = uuid.UUID(qt_setting_id)
|
setting_uuid = uuid.UUID(qt_setting_id)
|
||||||
data = req.model_dump(exclude_unset=True)
|
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)
|
err_type, _ = await self._fetch_owned(user_uuid, setting_uuid)
|
||||||
|
|||||||
@ -16,7 +16,7 @@ from datetime import datetime, timedelta
|
|||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from common.enums import CloseOutcome, QuotationStatus, QuotationType, SessionStatus
|
from common.enums import CloseOutcome, CloseReason, PriceGateAction, QuotationStatus, QuotationType, SessionStatus
|
||||||
from crud.quotation_crud import QuotationCRUD
|
from crud.quotation_crud import QuotationCRUD
|
||||||
from services.quotation_service import QuotationService
|
from services.quotation_service import QuotationService
|
||||||
|
|
||||||
@ -81,10 +81,10 @@ async def test_awarded_prior_round_not_counted_as_no_show(clean):
|
|||||||
기대결과: REGENERATED, 체인 [1,2,3] — 단독낙찰 라운드를 '미참여'로 오집계해 재생성을 막지 않는다."""
|
기대결과: REGENERATED, 체인 [1,2,3] — 단독낙찰 라운드를 '미참여'로 오집계해 재생성을 막지 않는다."""
|
||||||
engine = clean
|
engine = clean
|
||||||
number = "C-AWARDED-PRIOR"
|
number = "C-AWARDED-PRIOR"
|
||||||
# round 1: 단독낙찰로 마감(preferred_sp_yn=True). 수동 재생성 등으로 체인이 이어진 상황을 가정.
|
# round 1: 단독낙찰로 마감(close_reason=AWARDED). 수동 재생성 등으로 체인이 이어진 상황을 가정.
|
||||||
await _seed_quotation(
|
await _seed_quotation(
|
||||||
engine, number=number, round_=1, status=QuotationStatus.CLOSED.value,
|
engine, number=number, round_=1, status=QuotationStatus.CLOSED.value,
|
||||||
preferred_sp_yn=True, equal_bid_yn=False,
|
preferred_sp_yn=True, equal_bid_yn=False, close_reason=CloseReason.AWARDED.value,
|
||||||
)
|
)
|
||||||
# round 2: 전원 미참여 → 미참여 재생성이 일어나야 한다(round 1 은 미참여로 세면 안 됨)
|
# round 2: 전원 미참여 → 미참여 재생성이 일어나야 한다(round 1 은 미참여로 세면 안 됨)
|
||||||
qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.IN_PROGRESS.value)
|
qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.IN_PROGRESS.value)
|
||||||
@ -106,10 +106,10 @@ async def test_no_show_prior_round_consumes_budget(clean):
|
|||||||
기대결과: CLOSED, 체인 [1,2] — 미참여 재생성 한도(1) 소진돼 재생성 없이 그냥 마감(round3 없음)."""
|
기대결과: CLOSED, 체인 [1,2] — 미참여 재생성 한도(1) 소진돼 재생성 없이 그냥 마감(round3 없음)."""
|
||||||
engine = clean
|
engine = clean
|
||||||
number = "C-NOSHOW-PRIOR"
|
number = "C-NOSHOW-PRIOR"
|
||||||
# round 1: 미참여로 마감(양성 표식) → no_part 예산 1 소진
|
# round 1: 미참여 재생성으로 마감(close_reason=REGEN_NOSHOW) → 미참여 예산 1 소진
|
||||||
await _seed_quotation(
|
await _seed_quotation(
|
||||||
engine, number=number, round_=1, status=QuotationStatus.CLOSED.value,
|
engine, number=number, round_=1, status=QuotationStatus.CLOSED.value,
|
||||||
preferred_sp_yn=False, equal_bid_yn=False,
|
preferred_sp_yn=False, equal_bid_yn=False, close_reason=CloseReason.REGEN_NOSHOW.value,
|
||||||
)
|
)
|
||||||
# round 2: 또 전원 미참여 → 한도 도달이라 재생성 없이 그냥 마감
|
# round 2: 또 전원 미참여 → 한도 도달이라 재생성 없이 그냥 마감
|
||||||
qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.IN_PROGRESS.value)
|
qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.IN_PROGRESS.value)
|
||||||
@ -123,28 +123,63 @@ async def test_no_show_prior_round_consumes_budget(clean):
|
|||||||
assert [r.round for r in rounds] == [1, 2], "재생성되면 안 됨(round 3 없음)"
|
assert [r.round for r in rounds] == [1, 2], "재생성되면 안 됨(round 3 없음)"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_over_target_fail_closes(clean):
|
||||||
|
"""검증: over_action=유찰(FAIL) 회사에서 단독 최저가가 목표 초과(session target=0 < bid=100)인 견적을 마감.
|
||||||
|
기대결과: CLOSED + close_reason=FAIL_PRICE — 그 가격에 낙찰 안 하고 유찰(재생성 없음)."""
|
||||||
|
engine = clean
|
||||||
|
setting = await _seed_settings(engine, over_action=PriceGateAction.FAIL.value)
|
||||||
|
qt = await _seed_quotation(engine, number="C-OVER-FAIL", round_=1,
|
||||||
|
status=QuotationStatus.IN_PROGRESS.value, qt_setting_id=setting)
|
||||||
|
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100)
|
||||||
|
|
||||||
|
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
|
||||||
|
|
||||||
|
rounds = await _rounds(engine, "C-OVER-FAIL")
|
||||||
|
assert outcome == CloseOutcome.CLOSED, f"목표초과+유찰정책 → 유찰이어야 함, 실제 {outcome}"
|
||||||
|
assert [r.round for r in rounds] == [1], "재생성되면 안 됨(round 2 없음)"
|
||||||
|
assert rounds[0].close_reason == CloseReason.FAIL_PRICE.value, f"close_reason=FAIL_PRICE 여야 함, 실제 {rounds[0].close_reason}"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_over_target_renego_regenerates(clean):
|
||||||
|
"""검증: over_action=재협상(RENEGO) 회사에서 단독 최저가가 목표 초과인 견적을 첫 라운드에 마감.
|
||||||
|
기대결과: REGENERATED + 체인 [1,2] — 그 가격에 낙찰 안 하고 다음 라운드로 더 깎기(1차 close_reason=REGEN_PRICE)."""
|
||||||
|
engine = clean
|
||||||
|
setting = await _seed_settings(engine, over_action=PriceGateAction.RENEGO.value)
|
||||||
|
qt = await _seed_quotation(engine, number="C-OVER-RENEGO", round_=1,
|
||||||
|
status=QuotationStatus.IN_PROGRESS.value, qt_setting_id=setting)
|
||||||
|
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100)
|
||||||
|
|
||||||
|
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
|
||||||
|
|
||||||
|
rounds = await _rounds(engine, "C-OVER-RENEGO")
|
||||||
|
assert outcome == CloseOutcome.REGENERATED, f"목표초과+재협상정책 0회차 → 재생성이어야 함, 실제 {outcome}"
|
||||||
|
assert [r.round for r in rounds] == [1, 2], f"round 2 가 생성돼야 함, 실제 {[r.round for r in rounds]}"
|
||||||
|
assert rounds[0].close_reason == CloseReason.REGEN_PRICE.value, f"1차 close_reason=REGEN_PRICE 여야 함, 실제 {rounds[0].close_reason}"
|
||||||
|
|
||||||
|
|
||||||
# ===== 헬퍼 (위 테스트들이 쓰는 도우미. 세션 상태·마감 표식을 SQL 로 직접 세팅) =====
|
# ===== 헬퍼 (위 테스트들이 쓰는 도우미. 세션 상태·마감 표식을 SQL 로 직접 세팅) =====
|
||||||
async def _seed_quotation(
|
async def _seed_quotation(
|
||||||
engine, *, number, round_, status, start_time=PAST, end_time=PAST,
|
engine, *, number, round_, status, start_time=PAST, end_time=PAST,
|
||||||
preferred_sp_yn=None, equal_bid_yn=None,
|
preferred_sp_yn=None, equal_bid_yn=None, close_reason=None, qt_setting_id=None,
|
||||||
):
|
):
|
||||||
"""견적 1건 시드. number/round_ 로 체인을, preferred_sp_yn·equal_bid_yn 으로 '이전 라운드가 어떻게 마감됐는지'를 만든다."""
|
"""견적 1건 시드. number/round_ 로 체인을, close_reason 으로 '이전 라운드가 어떤 사유로 마감/재생성됐는지'를 만든다
|
||||||
|
(재생성 한도 카운팅은 close_reason 의 REGEN_* 만 센다). qt_setting_id 로 마감 가격정책(설정)을 연결. preferred_sp_yn/equal_bid_yn 은 프론트 표시용."""
|
||||||
qt_id = uuid.uuid4()
|
qt_id = uuid.uuid4()
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text(
|
text(
|
||||||
"INSERT INTO quotations "
|
"INSERT INTO quotations "
|
||||||
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, "
|
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, "
|
||||||
" round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn) VALUES "
|
" round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn, close_reason) VALUES "
|
||||||
"(:qt_id, :user_id, :qt_setting_id, :version_id, :name, :number, :type, :status, "
|
"(:qt_id, :user_id, :qt_setting_id, :version_id, :name, :number, :type, :status, "
|
||||||
" :round, 0, :start_time, :end_time, false, :pref, :eq)"
|
" :round, 0, :start_time, :end_time, false, :pref, :eq, :creason)"
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
"qt_id": qt_id, "user_id": uuid.uuid4(), "qt_setting_id": uuid.uuid4(),
|
"qt_id": qt_id, "user_id": uuid.uuid4(), "qt_setting_id": qt_setting_id or uuid.uuid4(),
|
||||||
"version_id": uuid.uuid4(), "name": "견적", "number": number,
|
"version_id": uuid.uuid4(), "name": "견적", "number": number,
|
||||||
"type": QuotationType.REQUOTE.value, "status": status, "round": round_,
|
"type": QuotationType.REQUOTE.value, "status": status, "round": round_,
|
||||||
"start_time": start_time, "end_time": end_time,
|
"start_time": start_time, "end_time": end_time,
|
||||||
"pref": preferred_sp_yn, "eq": equal_bid_yn,
|
"pref": preferred_sp_yn, "eq": equal_bid_yn, "creason": close_reason,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return qt_id
|
return qt_id
|
||||||
@ -171,10 +206,25 @@ async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=Non
|
|||||||
|
|
||||||
|
|
||||||
async def _rounds(engine, number):
|
async def _rounds(engine, number):
|
||||||
"""체인(number)의 (round, status, start_time, end_time) 목록 — round 오름차순."""
|
"""체인(number)의 (round, status, start_time, end_time, close_reason) 목록 — round 오름차순."""
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
return (await conn.execute(
|
return (await conn.execute(
|
||||||
text("SELECT round, status, start_time, end_time FROM quotations "
|
text("SELECT round, status, start_time, end_time, close_reason FROM quotations "
|
||||||
"WHERE number = :n ORDER BY round"),
|
"WHERE number = :n ORDER BY round"),
|
||||||
{"n": number},
|
{"n": number},
|
||||||
)).all()
|
)).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
|
||||||
|
|||||||
@ -17,7 +17,7 @@ from datetime import datetime
|
|||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from common.enums import CloseOutcome, NotificationType, QuotationStatus, QuotationType, SessionStatus
|
from common.enums import CloseOutcome, CloseReason, NotificationType, QuotationStatus, QuotationType, SessionStatus
|
||||||
from crud.quotation_crud import QuotationCRUD
|
from crud.quotation_crud import QuotationCRUD
|
||||||
from services.quotation_service import QuotationService
|
from services.quotation_service import QuotationService
|
||||||
|
|
||||||
@ -121,9 +121,10 @@ async def test_equal_bid_limit_exhausted_fails(clean):
|
|||||||
기대결과: 재견적 X — 판정 = 결렬(CLOSED) + 알림 FAILURE(reason=closed)."""
|
기대결과: 재견적 X — 판정 = 결렬(CLOSED) + 알림 FAILURE(reason=closed)."""
|
||||||
engine = clean
|
engine = clean
|
||||||
user_id = uuid.uuid4()
|
user_id = uuid.uuid4()
|
||||||
# 1차: 동가로 마감돼 2차를 만든 상황(equal_bid_yn=True 가 동가 재생성 표식) → 동가 한도 소진
|
# 1차: 동가로 재생성 마감(close_reason=REGEN_EQUAL) → 동가 한도 소진
|
||||||
await _seed_quotation(engine, user_id=user_id, number="N-EQUAL-LIMIT", round_=1,
|
await _seed_quotation(engine, user_id=user_id, number="N-EQUAL-LIMIT", round_=1,
|
||||||
status=QuotationStatus.CLOSED.value, equal_bid_yn=True)
|
status=QuotationStatus.CLOSED.value, equal_bid_yn=True,
|
||||||
|
close_reason=CloseReason.REGEN_EQUAL.value)
|
||||||
# 2차: 또 동가
|
# 2차: 또 동가
|
||||||
qt2 = await _seed_quotation(engine, user_id=user_id, number="N-EQUAL-LIMIT", round_=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)
|
||||||
@ -136,7 +137,7 @@ async def test_equal_bid_limit_exhausted_fails(clean):
|
|||||||
assert len(notis) == 1
|
assert len(notis) == 1
|
||||||
type_, data, ref = notis[0]
|
type_, data, ref = notis[0]
|
||||||
assert type_ == NotificationType.FAILURE.value
|
assert type_ == NotificationType.FAILURE.value
|
||||||
assert data["reason"] == "closed"
|
assert data["reason"] == "equal" # 동가 유찰(FAIL_EQUAL) 사유
|
||||||
assert str(ref) == str(qt2)
|
assert str(ref) == str(qt2)
|
||||||
|
|
||||||
|
|
||||||
@ -145,9 +146,10 @@ async def test_no_show_limit_exhausted_fails(clean):
|
|||||||
기대결과: 재견적 X — 판정 = 결렬(CLOSED) + 알림 FAILURE(reason=closed)."""
|
기대결과: 재견적 X — 판정 = 결렬(CLOSED) + 알림 FAILURE(reason=closed)."""
|
||||||
engine = clean
|
engine = clean
|
||||||
user_id = uuid.uuid4()
|
user_id = uuid.uuid4()
|
||||||
# 1차: 미참여로 마감돼 2차를 만든 상황(preferred_sp_yn=False·equal_bid_yn=False 가 미참여 재생성 표식) → 미참여 한도 소진
|
# 1차: 미참여로 재생성 마감(close_reason=REGEN_NOSHOW) → 미참여 한도 소진
|
||||||
await _seed_quotation(engine, user_id=user_id, number="N-NOSHOW-LIMIT", round_=1,
|
await _seed_quotation(engine, user_id=user_id, number="N-NOSHOW-LIMIT", round_=1,
|
||||||
status=QuotationStatus.CLOSED.value, preferred_sp_yn=False, equal_bid_yn=False)
|
status=QuotationStatus.CLOSED.value, preferred_sp_yn=False, equal_bid_yn=False,
|
||||||
|
close_reason=CloseReason.REGEN_NOSHOW.value)
|
||||||
# 2차: 또 전원 미참여
|
# 2차: 또 전원 미참여
|
||||||
qt2 = await _seed_quotation(engine, user_id=user_id, number="N-NOSHOW-LIMIT", round_=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)
|
||||||
@ -167,26 +169,26 @@ async def test_no_show_limit_exhausted_fails(clean):
|
|||||||
# ===== 헬퍼 (위 테스트들이 쓰는 도우미. 세션 입찰값·이전 라운드 표식을 SQL 로 직접 세팅) =====
|
# ===== 헬퍼 (위 테스트들이 쓰는 도우미. 세션 입찰값·이전 라운드 표식을 SQL 로 직접 세팅) =====
|
||||||
async def _seed_quotation(
|
async def _seed_quotation(
|
||||||
engine, *, user_id, number, round_=1, status=QuotationStatus.IN_PROGRESS.value,
|
engine, *, user_id, number, round_=1, status=QuotationStatus.IN_PROGRESS.value,
|
||||||
preferred_sp_yn=None, equal_bid_yn=None,
|
preferred_sp_yn=None, equal_bid_yn=None, close_reason=None,
|
||||||
):
|
):
|
||||||
"""견적 1건 시드(작성자=user_id). preferred_sp_yn·equal_bid_yn 으로 '이전 라운드가 어떤 사유로 재생성됐는지'를 표식한다
|
"""견적 1건 시드(작성자=user_id). close_reason 으로 '이전 라운드가 어떤 사유로 재생성됐는지'를 표식한다
|
||||||
(동가 재생성=equal_bid_yn True / 미참여 재생성=preferred_sp_yn False AND equal_bid_yn False)."""
|
(재생성 한도 카운팅은 close_reason 의 REGEN_* 만 센다). preferred_sp_yn/equal_bid_yn 은 프론트 표시용."""
|
||||||
qt_id = uuid.uuid4()
|
qt_id = uuid.uuid4()
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text(
|
text(
|
||||||
"INSERT INTO quotations "
|
"INSERT INTO quotations "
|
||||||
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, "
|
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, "
|
||||||
" round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn) VALUES "
|
" round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn, close_reason) VALUES "
|
||||||
"(:qt_id, :user_id, :qt_setting_id, :version_id, '견적A', :number, :type, :status, "
|
"(:qt_id, :user_id, :qt_setting_id, :version_id, '견적A', :number, :type, :status, "
|
||||||
" :round, 0, :start_time, :end_time, false, :pref, :eq)"
|
" :round, 0, :start_time, :end_time, false, :pref, :eq, :creason)"
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
"qt_id": qt_id, "user_id": user_id, "qt_setting_id": uuid.uuid4(),
|
"qt_id": qt_id, "user_id": user_id, "qt_setting_id": uuid.uuid4(),
|
||||||
"version_id": uuid.uuid4(), "number": number,
|
"version_id": uuid.uuid4(), "number": number,
|
||||||
"type": QuotationType.REQUOTE.value, "status": status, "round": round_,
|
"type": QuotationType.REQUOTE.value, "status": status, "round": round_,
|
||||||
"start_time": PAST, "end_time": PAST,
|
"start_time": PAST, "end_time": PAST,
|
||||||
"pref": preferred_sp_yn, "eq": equal_bid_yn,
|
"pref": preferred_sp_yn, "eq": equal_bid_yn, "creason": close_reason,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return qt_id
|
return qt_id
|
||||||
|
|||||||
25
negodata/front/src/api/generated/model/closeReason.ts
Normal file
25
negodata/front/src/api/generated/model/closeReason.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* quotations.close_reason 코드값(SMALLINT). 마감 사유 — 재생성 한도 카운팅(REGEN_*)과 유찰 사유 구분에 쓴다.
|
||||||
|
기존 preferred_sp_yn/equal_bid_yn 2플래그로는 4상태만 표현돼 '목표초과 재협상'이 미참여와 충돌하고 유찰 사유가 뭉개짐 → 이 컬럼으로 명시.
|
||||||
|
*/
|
||||||
|
export type CloseReason = typeof CloseReason[keyof typeof CloseReason];
|
||||||
|
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||||
|
export const CloseReason = {
|
||||||
|
AWARDED: 1,
|
||||||
|
REGEN_PRICE: 2,
|
||||||
|
REGEN_EQUAL: 3,
|
||||||
|
REGEN_NOSHOW: 4,
|
||||||
|
FAIL_PRICE: 5,
|
||||||
|
FAIL_EQUAL: 6,
|
||||||
|
FAIL_NOSHOW: 7,
|
||||||
|
FAIL_REJECT: 8,
|
||||||
|
} as const;
|
||||||
@ -27,6 +27,7 @@ export * from './chatMessageDataIndicatorValue';
|
|||||||
export * from './chatMessageDataScript';
|
export * from './chatMessageDataScript';
|
||||||
export * from './chatMessageDataStep';
|
export * from './chatMessageDataStep';
|
||||||
export * from './chatSender';
|
export * from './chatSender';
|
||||||
|
export * from './closeReason';
|
||||||
export * from './companyData';
|
export * from './companyData';
|
||||||
export * from './companyUserData';
|
export * from './companyUserData';
|
||||||
export * from './companyUserDataContactNumber';
|
export * from './companyUserDataContactNumber';
|
||||||
@ -81,6 +82,7 @@ export * from './notificationDataReadAt';
|
|||||||
export * from './notificationDataRefQtId';
|
export * from './notificationDataRefQtId';
|
||||||
export * from './notificationDataRefSessionId';
|
export * from './notificationDataRefSessionId';
|
||||||
export * from './notificationType';
|
export * from './notificationType';
|
||||||
|
export * from './priceGateAction';
|
||||||
export * from './quotationCardData';
|
export * from './quotationCardData';
|
||||||
export * from './quotationCardDataCondition';
|
export * from './quotationCardDataCondition';
|
||||||
export * from './quotationCardDataEditScript';
|
export * from './quotationCardDataEditScript';
|
||||||
@ -93,6 +95,7 @@ export * from './quotationCardDataScript';
|
|||||||
export * from './quotationCardDataType';
|
export * from './quotationCardDataType';
|
||||||
export * from './quotationCardDataWildCardId';
|
export * from './quotationCardDataWildCardId';
|
||||||
export * from './quotationData';
|
export * from './quotationData';
|
||||||
|
export * from './quotationDataCloseReason';
|
||||||
export * from './quotationDataCreatedAt';
|
export * from './quotationDataCreatedAt';
|
||||||
export * from './quotationDataCreatorName';
|
export * from './quotationDataCreatorName';
|
||||||
export * from './quotationDataEqualBidData';
|
export * from './quotationDataEqualBidData';
|
||||||
@ -204,6 +207,9 @@ export * from './reqUpdateMePassword';
|
|||||||
export * from './reqUpdateQuotationSetting';
|
export * from './reqUpdateQuotationSetting';
|
||||||
export * from './reqUpdateQuotationSettingAnchoringValue';
|
export * from './reqUpdateQuotationSettingAnchoringValue';
|
||||||
export * from './reqUpdateQuotationSettingCardCount';
|
export * from './reqUpdateQuotationSettingCardCount';
|
||||||
|
export * from './reqUpdateQuotationSettingMidAction';
|
||||||
|
export * from './reqUpdateQuotationSettingOverAction';
|
||||||
|
export * from './reqUpdateQuotationSettingRegenLimit';
|
||||||
export * from './reqUpdateQuotationSettingTargetMarginRate';
|
export * from './reqUpdateQuotationSettingTargetMarginRate';
|
||||||
export * from './reqUpdateSupplier';
|
export * from './reqUpdateSupplier';
|
||||||
export * from './reqUpdateSupplierCode';
|
export * from './reqUpdateSupplierCode';
|
||||||
|
|||||||
20
negodata/front/src/api/generated/model/priceGateAction.ts
Normal file
20
negodata/front/src/api/generated/model/priceGateAction.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* quotation_settings 의 가격 구간별 처리 정책. '앵커링가<투찰가≤목표가'(mid) / '목표가<투찰가'(over) 구간에 적용.
|
||||||
|
(투찰가≤앵커링가 는 항상 낙찰이라 설정 없음.)
|
||||||
|
*/
|
||||||
|
export type PriceGateAction = typeof PriceGateAction[keyof typeof PriceGateAction];
|
||||||
|
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||||
|
export const PriceGateAction = {
|
||||||
|
AWARD: 1,
|
||||||
|
RENEGO: 2,
|
||||||
|
FAIL: 3,
|
||||||
|
} as const;
|
||||||
@ -17,6 +17,7 @@ import type { QuotationDataPreferredSpId } from './quotationDataPreferredSpId';
|
|||||||
import type { QuotationDataPreferredSpName } from './quotationDataPreferredSpName';
|
import type { QuotationDataPreferredSpName } from './quotationDataPreferredSpName';
|
||||||
import type { QuotationDataEqualBidYn } from './quotationDataEqualBidYn';
|
import type { QuotationDataEqualBidYn } from './quotationDataEqualBidYn';
|
||||||
import type { QuotationDataEqualBidData } from './quotationDataEqualBidData';
|
import type { QuotationDataEqualBidData } from './quotationDataEqualBidData';
|
||||||
|
import type { QuotationDataCloseReason } from './quotationDataCloseReason';
|
||||||
import type { QuotationDataItemId } from './quotationDataItemId';
|
import type { QuotationDataItemId } from './quotationDataItemId';
|
||||||
import type { QuotationDataItemName } from './quotationDataItemName';
|
import type { QuotationDataItemName } from './quotationDataItemName';
|
||||||
import type { QuotationDataCreatorName } from './quotationDataCreatorName';
|
import type { QuotationDataCreatorName } from './quotationDataCreatorName';
|
||||||
@ -47,6 +48,7 @@ export interface QuotationData {
|
|||||||
preferred_sp_name?: QuotationDataPreferredSpName;
|
preferred_sp_name?: QuotationDataPreferredSpName;
|
||||||
equal_bid_yn?: QuotationDataEqualBidYn;
|
equal_bid_yn?: QuotationDataEqualBidYn;
|
||||||
equal_bid_data?: QuotationDataEqualBidData;
|
equal_bid_data?: QuotationDataEqualBidData;
|
||||||
|
close_reason?: QuotationDataCloseReason;
|
||||||
participation_count?: number;
|
participation_count?: number;
|
||||||
item_id?: QuotationDataItemId;
|
item_id?: QuotationDataItemId;
|
||||||
item_name?: QuotationDataItemName;
|
item_name?: QuotationDataItemName;
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { CloseReason } from './closeReason';
|
||||||
|
|
||||||
|
export type QuotationDataCloseReason = CloseReason | null;
|
||||||
@ -5,6 +5,7 @@
|
|||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
import type { QuotationSettingDataUserId } from './quotationSettingDataUserId';
|
import type { QuotationSettingDataUserId } from './quotationSettingDataUserId';
|
||||||
|
import type { PriceGateAction } from './priceGateAction';
|
||||||
import type { QuotationSettingDataCreatedAt } from './quotationSettingDataCreatedAt';
|
import type { QuotationSettingDataCreatedAt } from './quotationSettingDataCreatedAt';
|
||||||
import type { QuotationSettingDataUpdatedAt } from './quotationSettingDataUpdatedAt';
|
import type { QuotationSettingDataUpdatedAt } from './quotationSettingDataUpdatedAt';
|
||||||
|
|
||||||
@ -14,6 +15,9 @@ export interface QuotationSettingData {
|
|||||||
target_margin_rate: number;
|
target_margin_rate: number;
|
||||||
anchoring_value: number;
|
anchoring_value: number;
|
||||||
card_count: number;
|
card_count: number;
|
||||||
|
mid_action?: PriceGateAction;
|
||||||
|
over_action?: PriceGateAction;
|
||||||
|
regen_limit?: number;
|
||||||
created_at?: QuotationSettingDataCreatedAt;
|
created_at?: QuotationSettingDataCreatedAt;
|
||||||
updated_at?: QuotationSettingDataUpdatedAt;
|
updated_at?: QuotationSettingDataUpdatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,13 @@
|
|||||||
* Negodata Api Server
|
* Negodata Api Server
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
import type { PriceGateAction } from './priceGateAction';
|
||||||
|
|
||||||
export interface ReqCreateQuotationSetting {
|
export interface ReqCreateQuotationSetting {
|
||||||
target_margin_rate: number;
|
target_margin_rate: number;
|
||||||
anchoring_value?: number;
|
anchoring_value?: number;
|
||||||
card_count?: number;
|
card_count?: number;
|
||||||
|
mid_action?: PriceGateAction;
|
||||||
|
over_action?: PriceGateAction;
|
||||||
|
regen_limit?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,15 @@
|
|||||||
import type { ReqUpdateQuotationSettingTargetMarginRate } from './reqUpdateQuotationSettingTargetMarginRate';
|
import type { ReqUpdateQuotationSettingTargetMarginRate } from './reqUpdateQuotationSettingTargetMarginRate';
|
||||||
import type { ReqUpdateQuotationSettingAnchoringValue } from './reqUpdateQuotationSettingAnchoringValue';
|
import type { ReqUpdateQuotationSettingAnchoringValue } from './reqUpdateQuotationSettingAnchoringValue';
|
||||||
import type { ReqUpdateQuotationSettingCardCount } from './reqUpdateQuotationSettingCardCount';
|
import type { ReqUpdateQuotationSettingCardCount } from './reqUpdateQuotationSettingCardCount';
|
||||||
|
import type { ReqUpdateQuotationSettingMidAction } from './reqUpdateQuotationSettingMidAction';
|
||||||
|
import type { ReqUpdateQuotationSettingOverAction } from './reqUpdateQuotationSettingOverAction';
|
||||||
|
import type { ReqUpdateQuotationSettingRegenLimit } from './reqUpdateQuotationSettingRegenLimit';
|
||||||
|
|
||||||
export interface ReqUpdateQuotationSetting {
|
export interface ReqUpdateQuotationSetting {
|
||||||
target_margin_rate?: ReqUpdateQuotationSettingTargetMarginRate;
|
target_margin_rate?: ReqUpdateQuotationSettingTargetMarginRate;
|
||||||
anchoring_value?: ReqUpdateQuotationSettingAnchoringValue;
|
anchoring_value?: ReqUpdateQuotationSettingAnchoringValue;
|
||||||
card_count?: ReqUpdateQuotationSettingCardCount;
|
card_count?: ReqUpdateQuotationSettingCardCount;
|
||||||
|
mid_action?: ReqUpdateQuotationSettingMidAction;
|
||||||
|
over_action?: ReqUpdateQuotationSettingOverAction;
|
||||||
|
regen_limit?: ReqUpdateQuotationSettingRegenLimit;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { PriceGateAction } from './priceGateAction';
|
||||||
|
|
||||||
|
export type ReqUpdateQuotationSettingMidAction = PriceGateAction | null;
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { PriceGateAction } from './priceGateAction';
|
||||||
|
|
||||||
|
export type ReqUpdateQuotationSettingOverAction = PriceGateAction | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ReqUpdateQuotationSettingRegenLimit = number | null;
|
||||||
@ -6,19 +6,17 @@ import { cn } from '@/lib/utils';
|
|||||||
import { typographyVariants } from '@/components/ui/typography';
|
import { typographyVariants } from '@/components/ui/typography';
|
||||||
import { InfoField } from './InfoField';
|
import { InfoField } from './InfoField';
|
||||||
import { QuotationStatusBadge } from './StatusPill';
|
import { QuotationStatusBadge } from './StatusPill';
|
||||||
|
import { ResultSummaryBand } from './ResultSummaryBand';
|
||||||
import type { QuotationData } from '@/api/generated/model/quotationData';
|
import type { QuotationData } from '@/api/generated/model/quotationData';
|
||||||
import {
|
import {
|
||||||
type Product,
|
type Product,
|
||||||
type Partner,
|
|
||||||
type QuotationSetting,
|
type QuotationSetting,
|
||||||
buildBidSummary,
|
type SessionView,
|
||||||
quotationTypeLabel,
|
quotationTypeLabel,
|
||||||
|
priceGateActionLabel,
|
||||||
fmtDateTime,
|
fmtDateTime,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
|
||||||
const fmtYn = (b: boolean | null | undefined, yes: string, no: string) =>
|
|
||||||
b == null ? '-' : b ? yes : no;
|
|
||||||
|
|
||||||
/** 헤더 정보 카드 컨테이너. ui/Card 의 넉넉한 기본 여백을 촘촘하게 덮어쓴다. */
|
/** 헤더 정보 카드 컨테이너. ui/Card 의 넉넉한 기본 여백을 촘촘하게 덮어쓴다. */
|
||||||
function SectionCard({ title, children }: { title: string; children: ReactNode }) {
|
function SectionCard({ title, children }: { title: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@ -33,16 +31,17 @@ function SectionCard({ title, children }: { title: string; children: ReactNode }
|
|||||||
|
|
||||||
type DrawerHeaderCardsProps = {
|
type DrawerHeaderCardsProps = {
|
||||||
quotation: QuotationData;
|
quotation: QuotationData;
|
||||||
partners: Partner[];
|
|
||||||
quotationSettings: QuotationSetting[];
|
quotationSettings: QuotationSetting[];
|
||||||
|
/** 협상 세션 뷰 — 결과 밴드가 낙찰가/절감 계산에 쓴다. */
|
||||||
|
sessionViews: SessionView[];
|
||||||
/** 현재 선택 세션의 상품(없으면 상품 카드는 빈 상태). */
|
/** 현재 선택 세션의 상품(없으면 상품 카드는 빈 상태). */
|
||||||
currentProduct: Product | undefined;
|
currentProduct: Product | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DrawerHeaderCards({
|
export function DrawerHeaderCards({
|
||||||
quotation,
|
quotation,
|
||||||
partners,
|
|
||||||
quotationSettings,
|
quotationSettings,
|
||||||
|
sessionViews,
|
||||||
currentProduct,
|
currentProduct,
|
||||||
}: DrawerHeaderCardsProps) {
|
}: DrawerHeaderCardsProps) {
|
||||||
// Quotations DDL 표시값
|
// Quotations DDL 표시값
|
||||||
@ -57,33 +56,26 @@ export function DrawerHeaderCards({
|
|||||||
: '-';
|
: '-';
|
||||||
const q_memo = quotation.memo || '-';
|
const q_memo = quotation.memo || '-';
|
||||||
|
|
||||||
const bidSummaryObj = buildBidSummary(quotation, partners);
|
|
||||||
const selectedSettingObj = quotationSettings.find((qs) => qs.qt_setting_id === quotation.qt_setting_id);
|
const selectedSettingObj = quotationSettings.find((qs) => qs.qt_setting_id === quotation.qt_setting_id);
|
||||||
|
|
||||||
// 상품 상세 패널 행(negowiz 협상대화의 상품 정보 대응). negodata 컬럼명: maker_name→manufacturer, min_order_quantity→moq.
|
// 상품 카드는 '가격 관련' 값만 노출(모델명·규격·제조사 등 스펙은 상품 상세에서 확인).
|
||||||
const productSpecRows = currentProduct
|
const won = (n?: number | null) => (n != null ? `₩${Number(n).toLocaleString()}` : '-');
|
||||||
|
const productPriceRows = currentProduct
|
||||||
? [
|
? [
|
||||||
{ label: '상품코드', value: currentProduct.code || '-' },
|
{ label: '상품단가', value: won(currentProduct.price) },
|
||||||
{ label: '상품단가', value: currentProduct.price != null ? `₩${Number(currentProduct.price).toLocaleString()}` : '-' },
|
{ label: '매입가', value: won(currentProduct.purchase_price) },
|
||||||
{ label: '매입가', value: currentProduct.purchase_price != null ? `₩${Number(currentProduct.purchase_price).toLocaleString()}` : '-' },
|
{ label: '판매가', value: won(currentProduct.selling_price) },
|
||||||
{ label: '판매가', value: currentProduct.selling_price != null ? `₩${Number(currentProduct.selling_price).toLocaleString()}` : '-' },
|
{ label: '인터넷 최저가', value: won(currentProduct.internet_lowest_price) },
|
||||||
{ label: '인터넷 최저가', value: currentProduct.internet_lowest_price != null ? `₩${Number(currentProduct.internet_lowest_price).toLocaleString()}` : '-' },
|
|
||||||
{ label: '모델명', value: currentProduct.model_name || '-' },
|
|
||||||
{ label: '규격', value: currentProduct.spec || '-' },
|
|
||||||
{ label: '제조사', value: currentProduct.manufacturer || '-' },
|
|
||||||
{ label: '원산지', value: currentProduct.made_in || '-' },
|
|
||||||
{ label: 'MOQ', value: currentProduct.moq || '-' },
|
|
||||||
{ label: '리드타임', value: currentProduct.lead_time != null ? `${currentProduct.lead_time}일` : '-' },
|
|
||||||
{ label: 'VAT', value: fmtYn(currentProduct.vat_yn, '포함', '별도') },
|
|
||||||
{ label: '배송비', value: fmtYn(currentProduct.delivery_fee_yn, '포함', '별도') },
|
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4 text-xs font-mono">
|
<div className="mt-4 space-y-4 text-xs font-mono">
|
||||||
{/* 좌측 컬럼: 견적정보 + 진행상태 */}
|
{/* 결과 요약: 목표가 대비 낙찰/절감 */}
|
||||||
<div className="space-y-4">
|
<ResultSummaryBand quotation={quotation} sessionViews={sessionViews} />
|
||||||
{/* Quotations */}
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* 견적 정보 */}
|
||||||
<SectionCard title="견적 정보">
|
<SectionCard title="견적 정보">
|
||||||
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
|
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
|
||||||
<InfoField label="견적명" value={q_name} valueClassName="font-sans" />
|
<InfoField label="견적명" value={q_name} valueClassName="font-sans" />
|
||||||
@ -105,36 +97,9 @@ export function DrawerHeaderCards({
|
|||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{/* Bid Summary */}
|
{/* 상품 정보(가격) + 견적 세팅 */}
|
||||||
<SectionCard title="견적 진행상태/결과">
|
|
||||||
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
|
|
||||||
<InfoField label="식별자" value={bidSummaryObj.bid_summary_id} />
|
|
||||||
<InfoField label="진행/결과 상태" value={bidSummaryObj.status} />
|
|
||||||
<InfoField label="반복횟수" value={`${bidSummaryObj.qt_iteration}회`} />
|
|
||||||
<InfoField
|
|
||||||
label="우선협상자 존재여부"
|
|
||||||
value={bidSummaryObj.has_preferred ? '존재' : '미존재'}
|
|
||||||
valueClassName={bidSummaryObj.has_preferred ? 'text-emerald-600 font-bold' : undefined}
|
|
||||||
/>
|
|
||||||
<InfoField
|
|
||||||
label="우선협상자명"
|
|
||||||
value={bidSummaryObj.preferred_sp_name}
|
|
||||||
className="col-span-2"
|
|
||||||
valueClassName="font-sans"
|
|
||||||
/>
|
|
||||||
<InfoField label="동가입찰정보" className="col-span-2">
|
|
||||||
<code className="text-foreground font-semibold bg-muted/60 p-1 rounded text-[10px] block overflow-x-auto whitespace-pre">
|
|
||||||
{bidSummaryObj.equal_data}
|
|
||||||
</code>
|
|
||||||
</InfoField>
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 우측 컬럼: 상품정보 + 세팅 */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 상품 정보 (협상 대상 상품) */}
|
<SectionCard title="상품 정보 (가격)">
|
||||||
<SectionCard title="상품 정보">
|
|
||||||
{currentProduct ? (
|
{currentProduct ? (
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="h-24 w-24 shrink-0 rounded-md border border-border bg-background overflow-hidden flex items-center justify-center">
|
<div className="h-24 w-24 shrink-0 rounded-md border border-border bg-background overflow-hidden flex items-center justify-center">
|
||||||
@ -156,14 +121,14 @@ export function DrawerHeaderCards({
|
|||||||
>
|
>
|
||||||
{currentProduct.name || '-'}
|
{currentProduct.name || '-'}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-x-3 gap-y-1.5 font-mono text-muted-foreground">
|
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5 font-mono text-muted-foreground">
|
||||||
{productSpecRows.map((r) => (
|
{productPriceRows.map((row) => (
|
||||||
<InfoField
|
<InfoField
|
||||||
key={r.label}
|
key={row.label}
|
||||||
label={r.label}
|
label={row.label}
|
||||||
value={r.value}
|
value={row.value}
|
||||||
valueClassName="font-sans truncate block"
|
valueClassName="font-sans truncate block"
|
||||||
title={r.value}
|
title={row.value}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -174,7 +139,6 @@ export function DrawerHeaderCards({
|
|||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{/* Quotation Settings */}
|
|
||||||
<SectionCard title="견적 세팅">
|
<SectionCard title="견적 세팅">
|
||||||
{selectedSettingObj ? (
|
{selectedSettingObj ? (
|
||||||
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
|
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
|
||||||
@ -184,10 +148,10 @@ export function DrawerHeaderCards({
|
|||||||
valueClassName="font-bold text-emerald-600 dark:text-emerald-400 font-sans"
|
valueClassName="font-bold text-emerald-600 dark:text-emerald-400 font-sans"
|
||||||
/>
|
/>
|
||||||
<InfoField label="앵커링 설정 값" value={selectedSettingObj.anchoring_value} valueClassName="font-sans" />
|
<InfoField label="앵커링 설정 값" value={selectedSettingObj.anchoring_value} valueClassName="font-sans" />
|
||||||
<InfoField
|
<InfoField label="카드 사용 횟수" value={selectedSettingObj.card_use_count} />
|
||||||
label="카드 사용 횟수"
|
<InfoField label="앵커~목표 마감처리" value={priceGateActionLabel(selectedSettingObj.mid_action)} />
|
||||||
value={selectedSettingObj.card_use_count}
|
<InfoField label="목표초과 마감처리" value={priceGateActionLabel(selectedSettingObj.over_action)} />
|
||||||
/>
|
<InfoField label="재생성 한도" value={`${selectedSettingObj.regen_limit}회`} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-muted-foreground py-6 text-center">적용된 견적 세팅이 비어있습니다.</div>
|
<div className="text-muted-foreground py-6 text-center">적용된 견적 세팅이 비어있습니다.</div>
|
||||||
@ -195,5 +159,6 @@ export function DrawerHeaderCards({
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,152 @@
|
|||||||
|
import { Fragment } from 'react';
|
||||||
|
import { Typography } from '@/components/ui/typography';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const won = (n?: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-');
|
||||||
|
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
|
||||||
|
|
||||||
|
type Tone = 'amber' | 'emerald' | 'zinc';
|
||||||
|
const TONE_DOT: Record<Tone, string> = {
|
||||||
|
amber: 'bg-amber-500',
|
||||||
|
emerald: 'bg-emerald-500',
|
||||||
|
zinc: 'bg-zinc-400',
|
||||||
|
};
|
||||||
|
const TONE_TEXT: Record<Tone, string> = {
|
||||||
|
amber: 'text-amber-600',
|
||||||
|
emerald: 'text-emerald-600',
|
||||||
|
zinc: 'text-muted-foreground',
|
||||||
|
};
|
||||||
|
|
||||||
|
type Point = { name: string; value: number; tone: Tone; strong?: boolean; place?: 'above' | 'below' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단가 조정 흐름 스펙트럼 — 얇은 바에 목표가·최저·최고 투찰가를 찍고 값 라벨을 바로 옆에 붙인다.
|
||||||
|
* 값은 buildQuotationResult 파생값(별도 컬럼 없음). 위치는 세 값 min~max 도메인 선형 배치라 목표가가 범위 밖이어도 안 깨진다.
|
||||||
|
* 라벨 방향(위/아래)은 값 순 정렬 후 번갈아 배치 → 위치가 가까운(정렬상 인접) 라벨끼리 항상 반대쪽이라 안 겹친다.
|
||||||
|
*/
|
||||||
|
export function PricingSpectrum({
|
||||||
|
targetPrice,
|
||||||
|
lowestBid,
|
||||||
|
highestBid,
|
||||||
|
}: {
|
||||||
|
targetPrice: number | null;
|
||||||
|
lowestBid: number | null;
|
||||||
|
highestBid: number | null;
|
||||||
|
}) {
|
||||||
|
// 투찰 0건 → 목표가만 한 줄(폴백). lowestBid/highestBid 는 같은 배열에서 나와 함께 null.
|
||||||
|
if (lowestBid == null || highestBid == null) {
|
||||||
|
return (
|
||||||
|
<Typography as="p" variant="caption" className="text-[11px]">
|
||||||
|
<span className="text-amber-600">목표가</span>{' '}
|
||||||
|
<span className="font-mono font-semibold text-foreground">{won(targetPrice)}</span>
|
||||||
|
<span className="text-muted-foreground"> · 투찰 없음</span>
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const singleBid = lowestBid === highestBid; // 투찰 1건 → 최저=최고, 라벨 하나로 축약.
|
||||||
|
|
||||||
|
// 표시할 점(목표가는 없을 수 있음). 값 순으로 정렬해 라벨 방향을 번갈아(below/above) 주면
|
||||||
|
// 위치가 가까운(정렬상 인접) 라벨끼리 항상 반대쪽에 놓여 안 겹친다.
|
||||||
|
const points: Point[] = [
|
||||||
|
targetPrice != null ? { name: '목표가', value: targetPrice, tone: 'amber' } : null,
|
||||||
|
{ name: singleBid ? '투찰가' : '최저 투찰가', value: lowestBid, tone: 'emerald', strong: true },
|
||||||
|
singleBid ? null : { name: '최고 투찰가', value: highestBid, tone: 'zinc' },
|
||||||
|
].filter((p): p is Point => p != null);
|
||||||
|
|
||||||
|
const vals = points.map((p) => p.value);
|
||||||
|
const min = Math.min(...vals);
|
||||||
|
const max = Math.max(...vals);
|
||||||
|
const span = max - min;
|
||||||
|
const posOf = (v: number) => (span > 0 ? ((v - min) / span) * 100 : 50);
|
||||||
|
|
||||||
|
[...points]
|
||||||
|
.sort((a, b) => a.value - b.value)
|
||||||
|
.forEach((p, i) => {
|
||||||
|
p.place = i % 2 === 0 ? 'below' : 'above';
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full px-1">
|
||||||
|
<div className="relative mx-1 my-3.5 h-1">
|
||||||
|
{/* 트랙 */}
|
||||||
|
<div className="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-border/60" />
|
||||||
|
{/* 투찰 스프레드(최저~최고) */}
|
||||||
|
{!singleBid && (
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 h-1 -translate-y-1/2 rounded-full bg-gradient-to-r from-emerald-400 to-zinc-300"
|
||||||
|
style={{ left: `${posOf(lowestBid)}%`, width: `${posOf(highestBid) - posOf(lowestBid)}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{points.map((p) => (
|
||||||
|
<Fragment key={p.name}>
|
||||||
|
<Dot pos={posOf(p.value)} tone={p.tone} strong={p.strong} />
|
||||||
|
<BarLabel
|
||||||
|
pos={posOf(p.value)}
|
||||||
|
place={p.place ?? 'below'}
|
||||||
|
name={p.name}
|
||||||
|
value={won(p.value)}
|
||||||
|
tone={p.tone}
|
||||||
|
strong={p.strong}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 트랙 위 점. 위치는 0~100% 로 클램프. */
|
||||||
|
function Dot({ pos, tone, strong }: { pos: number; tone: Tone; strong?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full ring-2 ring-background',
|
||||||
|
strong ? 'h-2.5 w-2.5' : 'h-2 w-2',
|
||||||
|
TONE_DOT[tone],
|
||||||
|
)}
|
||||||
|
style={{ left: `${clamp(pos, 0, 100)}%` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 점 위/아래 한 줄 라벨(이름+금액). 양 끝에선 안쪽으로 정렬해 넘침 방지. */
|
||||||
|
function BarLabel({
|
||||||
|
pos,
|
||||||
|
place,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
strong,
|
||||||
|
}: {
|
||||||
|
pos: number;
|
||||||
|
place: 'above' | 'below';
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
tone: Tone;
|
||||||
|
strong?: boolean;
|
||||||
|
}) {
|
||||||
|
const align =
|
||||||
|
pos <= 8 ? 'translate-x-0 text-left' : pos >= 92 ? '-translate-x-full text-right' : '-translate-x-1/2 text-center';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute whitespace-nowrap leading-none',
|
||||||
|
place === 'above' ? 'bottom-full mb-1' : 'top-full mt-1',
|
||||||
|
align,
|
||||||
|
)}
|
||||||
|
style={{ left: `${clamp(pos, 0, 100)}%` }}
|
||||||
|
>
|
||||||
|
<Typography as="span" variant="caption" className={cn('text-[10px]', TONE_TEXT[tone])}>
|
||||||
|
{name}{' '}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
as="span"
|
||||||
|
variant="small"
|
||||||
|
className={cn('font-mono text-[11px] text-foreground', strong ? 'font-bold' : 'font-semibold')}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,107 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { BadgeCheck, TrendingDown, TrendingUp } from 'lucide-react';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Typography } from '@/components/ui/typography';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { QuotationData } from '@/api/generated/model/quotationData';
|
||||||
|
import { StatusPill, type PillTone } from './StatusPill';
|
||||||
|
import { PricingSpectrum } from './PricingSpectrum';
|
||||||
|
import {
|
||||||
|
buildQuotationResult,
|
||||||
|
CHAIN_ROUND_STATE_LABEL,
|
||||||
|
type ChainRoundState,
|
||||||
|
type SessionView,
|
||||||
|
} from '../../types';
|
||||||
|
|
||||||
|
const won = (n?: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-');
|
||||||
|
|
||||||
|
// 결과 상태별 배지 톤(협상현황 pill 팔레트 재사용).
|
||||||
|
const OUTCOME_TONE: Record<ChainRoundState, PillTone> = {
|
||||||
|
awarded: 'emerald',
|
||||||
|
regenerated: 'amber',
|
||||||
|
failed: 'blue',
|
||||||
|
active: 'zinc',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 견적 결과 요약 밴드 — 마감 사유/낙찰 협력사 + 절감, 하단에 단가 조정 흐름 스펙트럼(최고·최저 투찰가/목표가). */
|
||||||
|
export function ResultSummaryBand({
|
||||||
|
quotation,
|
||||||
|
sessionViews,
|
||||||
|
}: {
|
||||||
|
quotation: QuotationData;
|
||||||
|
sessionViews: SessionView[];
|
||||||
|
}) {
|
||||||
|
const r = buildQuotationResult(quotation, sessionViews);
|
||||||
|
const good = r.savings != null && r.savings >= 0;
|
||||||
|
const pct = r.savingsRate != null ? `${Math.abs(r.savingsRate * 100).toFixed(1)}%` : null;
|
||||||
|
const SavingsIcon = good ? TrendingDown : TrendingUp;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-2.5 gap-2 rounded shadow-xs border-border/80 flex flex-col">
|
||||||
|
{/* 결과/사유 + 낙찰 협력사 + 절감 */}
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<StatusPill tone={OUTCOME_TONE[r.outcome]}>{CHAIN_ROUND_STATE_LABEL[r.outcome]}</StatusPill>
|
||||||
|
{/* 낙찰 건은 협력사명을 주줄로(사유 '낙찰'은 pill 과 중복이라 생략), 그 외엔 마감사유를 표기. */}
|
||||||
|
{r.winnerName ? (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Typography as="p" variant="caption" className="text-[10px] leading-tight text-muted-foreground">
|
||||||
|
낙찰 협력사
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
as="p"
|
||||||
|
variant="small"
|
||||||
|
className="flex items-center gap-1 text-[12px] font-bold font-sans text-foreground truncate"
|
||||||
|
>
|
||||||
|
<BadgeCheck size={13} className="text-emerald-600 shrink-0" />
|
||||||
|
{r.winnerName}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Typography as="p" variant="small" className="text-[12px] font-bold truncate min-w-0">
|
||||||
|
{r.closeReason}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Stat label={r.provisional ? '예상 절감' : '목표가 대비'}>
|
||||||
|
{r.savings == null ? (
|
||||||
|
<Typography as="span" variant="small" className="font-mono text-muted-foreground text-[13px]">
|
||||||
|
-
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Typography
|
||||||
|
as="span"
|
||||||
|
variant="small"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 font-mono font-bold text-[13px]',
|
||||||
|
good ? 'text-emerald-600' : 'text-amber-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SavingsIcon size={13} />
|
||||||
|
{good ? '' : '−'}
|
||||||
|
{won(Math.abs(r.savings))}
|
||||||
|
{pct ? ` (${pct})` : ''}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stat>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 단가 조정 흐름 스펙트럼: 최고 투찰가 → 최저 투찰가 · 목표가 */}
|
||||||
|
<PricingSpectrum targetPrice={r.targetPrice} lowestBid={r.lowestBid} highestBid={r.highestBid} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 밴드 안의 라벨/값 한 칸. */
|
||||||
|
function Stat({ label, sub, children }: { label: string; sub?: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col justify-center rounded border border-border bg-muted/30 px-3 py-1.5 min-w-[108px]">
|
||||||
|
<Typography as="span" variant="caption" className="text-[10px] leading-tight">
|
||||||
|
{label}
|
||||||
|
{sub ? ` · ${sub}` : ''}
|
||||||
|
</Typography>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -98,7 +98,7 @@ export function SessionsStatusTab({
|
|||||||
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
|
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="p-3 font-semibold font-sans">협력사</TableHead>
|
<TableHead className="p-3 font-semibold font-sans">협력사</TableHead>
|
||||||
<TableHead className="p-3 font-semibold font-sans">협상 URL</TableHead>
|
<TableHead className="p-2 font-semibold text-center w-10">URL</TableHead>
|
||||||
<TableHead className="p-3 font-semibold text-center font-sans">초청메일</TableHead>
|
<TableHead className="p-3 font-semibold text-center font-sans">초청메일</TableHead>
|
||||||
<TableHead className="p-3 font-semibold font-sans">상품</TableHead>
|
<TableHead className="p-3 font-semibold font-sans">상품</TableHead>
|
||||||
<TableHead className="p-3 font-semibold text-center font-sans">협상상태</TableHead>
|
<TableHead className="p-3 font-semibold text-center font-sans">협상상태</TableHead>
|
||||||
@ -133,7 +133,7 @@ export function SessionsStatusTab({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="p-3 font-mono">
|
<TableCell className="p-2 font-mono text-center w-10">
|
||||||
{sess.url ? (
|
{sess.url ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@ -202,8 +202,8 @@ export function QuotationDetailSheet({
|
|||||||
>
|
>
|
||||||
<DrawerHeaderCards
|
<DrawerHeaderCards
|
||||||
quotation={quotation}
|
quotation={quotation}
|
||||||
partners={partners}
|
|
||||||
quotationSettings={quotationSettings}
|
quotationSettings={quotationSettings}
|
||||||
|
sessionViews={sessionViews}
|
||||||
currentProduct={currentProduct}
|
currentProduct={currentProduct}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Typography } from '@/components/ui/typography';
|
import { Typography } from '@/components/ui/typography';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
||||||
import type { QuotationSetting } from '../types';
|
import { type QuotationSetting, priceGateActionLabel, PRICE_GATE_ACTION_OPTIONS } from '../types';
|
||||||
import type { SettingInput } from '../hooks/useQuotations';
|
import type { SettingInput } from '../hooks/useQuotations';
|
||||||
|
|
||||||
type QuotationSettingsModalProps = {
|
type QuotationSettingsModalProps = {
|
||||||
@ -25,16 +25,25 @@ export function QuotationSettingsModal({
|
|||||||
const [targetMargin, setTargetMargin] = useState('');
|
const [targetMargin, setTargetMargin] = useState('');
|
||||||
const [anchoringValue, setAnchoringValue] = useState('');
|
const [anchoringValue, setAnchoringValue] = useState('');
|
||||||
const [cardUseCount, setCardUseCount] = 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;
|
if (!open) return null;
|
||||||
|
|
||||||
const handleAdd = (e: React.FormEvent) => {
|
const handleAdd = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const ok = onAdd({ targetMargin, anchoringValue, cardUseCount });
|
const ok = onAdd({
|
||||||
|
targetMargin, anchoringValue, cardUseCount,
|
||||||
|
midAction, overAction, regenLimit: parseInt(regenLimit, 10),
|
||||||
|
});
|
||||||
if (ok) {
|
if (ok) {
|
||||||
setTargetMargin('');
|
setTargetMargin('');
|
||||||
setAnchoringValue('');
|
setAnchoringValue('');
|
||||||
setCardUseCount('');
|
setCardUseCount('');
|
||||||
|
setMidAction(1);
|
||||||
|
setOverAction(1);
|
||||||
|
setRegenLimit('1');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -63,13 +72,16 @@ export function QuotationSettingsModal({
|
|||||||
<TableHead className="p-2">목표 마진율</TableHead>
|
<TableHead className="p-2">목표 마진율</TableHead>
|
||||||
<TableHead className="p-2">앵커링 값</TableHead>
|
<TableHead className="p-2">앵커링 값</TableHead>
|
||||||
<TableHead className="p-2">카드 사용 횟수</TableHead>
|
<TableHead className="p-2">카드 사용 횟수</TableHead>
|
||||||
|
<TableHead className="p-2">앵커~목표</TableHead>
|
||||||
|
<TableHead className="p-2">목표초과</TableHead>
|
||||||
|
<TableHead className="p-2">재생성</TableHead>
|
||||||
<TableHead className="p-2 text-center w-12">삭제</TableHead>
|
<TableHead className="p-2 text-center w-12">삭제</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody className="divide-y divide-border bg-background">
|
<TableBody className="divide-y divide-border bg-background">
|
||||||
{settings.length === 0 && (
|
{settings.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4} className="p-6 text-center text-muted-foreground">
|
<TableCell colSpan={7} className="p-6 text-center text-muted-foreground">
|
||||||
등록된 견적 세팅이 없습니다. (리스트가 비어 있습니다)
|
등록된 견적 세팅이 없습니다. (리스트가 비어 있습니다)
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -79,6 +91,9 @@ export function QuotationSettingsModal({
|
|||||||
<TableCell className="p-2 font-bold text-primary">{qs.target_margin}</TableCell>
|
<TableCell className="p-2 font-bold text-primary">{qs.target_margin}</TableCell>
|
||||||
<TableCell className="p-2 text-foreground">{qs.anchoring_value}</TableCell>
|
<TableCell className="p-2 text-foreground">{qs.anchoring_value}</TableCell>
|
||||||
<TableCell className="p-2 text-muted-foreground">{qs.card_use_count}</TableCell>
|
<TableCell className="p-2 text-muted-foreground">{qs.card_use_count}</TableCell>
|
||||||
|
<TableCell className="p-2 text-muted-foreground">{priceGateActionLabel(qs.mid_action)}</TableCell>
|
||||||
|
<TableCell className="p-2 text-muted-foreground">{priceGateActionLabel(qs.over_action)}</TableCell>
|
||||||
|
<TableCell className="p-2 text-muted-foreground">{qs.regen_limit}회</TableCell>
|
||||||
<TableCell className="p-2 text-center">
|
<TableCell className="p-2 text-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -112,6 +127,34 @@ export function QuotationSettingsModal({
|
|||||||
<Typography as="label" variant="muted" className="text-[10px] font-semibold">카드 사용 횟수</Typography>
|
<Typography as="label" variant="muted" className="text-[10px] font-semibold">카드 사용 횟수</Typography>
|
||||||
<Input type="number" step="1" value={cardUseCount} onChange={(e) => setCardUseCount(e.target.value)} placeholder="예: 3" />
|
<Input type="number" step="1" value={cardUseCount} onChange={(e) => setCardUseCount(e.target.value)} placeholder="예: 3" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Typography as="label" variant="muted" className="text-[10px] font-semibold">앵커링가~목표가 마감처리</Typography>
|
||||||
|
<select
|
||||||
|
value={midAction}
|
||||||
|
onChange={(e) => setMidAction(Number(e.target.value))}
|
||||||
|
className="w-full h-9 rounded-md border border-input bg-background px-3 text-xs"
|
||||||
|
>
|
||||||
|
{PRICE_GATE_ACTION_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Typography as="label" variant="muted" className="text-[10px] font-semibold">목표가초과 마감처리</Typography>
|
||||||
|
<select
|
||||||
|
value={overAction}
|
||||||
|
onChange={(e) => setOverAction(Number(e.target.value))}
|
||||||
|
className="w-full h-9 rounded-md border border-input bg-background px-3 text-xs"
|
||||||
|
>
|
||||||
|
{PRICE_GATE_ACTION_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Typography as="label" variant="muted" className="text-[10px] font-semibold">재생성 최대 횟수 (체인 전체)</Typography>
|
||||||
|
<Input type="number" step="1" min="0" value={regenLimit} onChange={(e) => setRegenLimit(e.target.value)} placeholder="예: 1" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end pt-2">
|
<div className="flex justify-end pt-2">
|
||||||
|
|||||||
@ -7,8 +7,11 @@ import { useQuotationChain } from '../hooks/useQuotationChain';
|
|||||||
import {
|
import {
|
||||||
type Estimate,
|
type Estimate,
|
||||||
type Product,
|
type Product,
|
||||||
|
type ChainRoundState,
|
||||||
quotationStatusLabel,
|
quotationStatusLabel,
|
||||||
quotationTypeLabel,
|
quotationTypeLabel,
|
||||||
|
chainRoundState,
|
||||||
|
quotationOutcomeLabel,
|
||||||
CHAIN_ROUND_STATE_LABEL,
|
CHAIN_ROUND_STATE_LABEL,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { QuotationType, QuotationStatus } from '@/api/generated/model';
|
import { QuotationType, QuotationStatus } from '@/api/generated/model';
|
||||||
@ -35,6 +38,20 @@ const statusBadgeClass = (status?: number | null) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 마감결과 배지 색. 낙찰=초록/재생성=주황/유찰=로즈/진행중=회색(아직 마감 전).
|
||||||
|
const outcomeBadgeClass = (state: ChainRoundState) => {
|
||||||
|
switch (state) {
|
||||||
|
case 'awarded':
|
||||||
|
return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/25 dark:text-emerald-400 border-emerald-300/50';
|
||||||
|
case 'regenerated':
|
||||||
|
return 'bg-amber-50 text-amber-700 dark:bg-amber-950/25 dark:text-amber-400 border-amber-300/50';
|
||||||
|
case 'failed':
|
||||||
|
return 'bg-rose-50 text-rose-700 dark:bg-rose-950/25 dark:text-rose-400 border-rose-300/50';
|
||||||
|
default:
|
||||||
|
return 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800/40 dark:text-zinc-400 border-zinc-300/40';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export function QuotationTable({ data, products, onOpenDetail, onFilterChain, footer }: QuotationTableProps) {
|
export function QuotationTable({ data, products, onOpenDetail, onFilterChain, footer }: QuotationTableProps) {
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
@ -124,6 +141,22 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo
|
|||||||
</Typography>
|
</Typography>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: '마감결과',
|
||||||
|
align: 'center',
|
||||||
|
cell: (est) => {
|
||||||
|
const state = chainRoundState(est); // 배지 색 그룹
|
||||||
|
return (
|
||||||
|
<Typography
|
||||||
|
as="span"
|
||||||
|
variant="small"
|
||||||
|
className={`inline-flex items-center gap-1 px-2.5 py-0.5 text-[10px] font-semibold rounded-full border ${outcomeBadgeClass(state)}`}
|
||||||
|
>
|
||||||
|
{quotationOutcomeLabel(est)}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: '마감기한',
|
header: '마감기한',
|
||||||
cellClassName: 'font-mono text-muted-foreground whitespace-nowrap',
|
cellClassName: 'font-mono text-muted-foreground whitespace-nowrap',
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export type ChainRound = {
|
|||||||
// 같은 견적번호(체인)의 모든 차수를 라운드 오름차순으로 돌려준다.
|
// 같은 견적번호(체인)의 모든 차수를 라운드 오름차순으로 돌려준다.
|
||||||
// 체인은 parent_id 없이 number 공유로만 묶이므로, 목록 검색(number ILIKE)으로 모은 뒤
|
// 체인은 parent_id 없이 number 공유로만 묶이므로, 목록 검색(number ILIKE)으로 모은 뒤
|
||||||
// 정확매칭(q.number === number)으로 좁힌다 — search 가 이름까지 매칭하는 오염을 제거.
|
// 정확매칭(q.number === number)으로 좁힌다 — search 가 이름까지 매칭하는 오염을 제거.
|
||||||
// 재생성은 사유별 1회 한도라 체인은 최대 ~3라운드 → size 한 번이면 전부 들어온다.
|
// 재생성은 체인 전체 총 한도(regen_limit)라 체인 라운드 수가 유한 → size 한 번이면 전부 들어온다.
|
||||||
export function useQuotationChain(number?: string | null) {
|
export function useQuotationChain(number?: string | null) {
|
||||||
const enabled = !!number;
|
const enabled = !!number;
|
||||||
const query = useListQuotations(
|
const query = useListQuotations(
|
||||||
|
|||||||
@ -27,7 +27,7 @@ import { confirm } from '@/lib/confirm';
|
|||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import type { Estimate } from '../types';
|
import type { Estimate } from '../types';
|
||||||
import { mapItem, mapSupplier, mapSetting, mapQuotation } from '../types';
|
import { mapItem, mapSupplier, mapSetting, mapQuotation } from '../types';
|
||||||
import { QuotationStatus } from '@/api/generated/model';
|
import { QuotationStatus, type PriceGateAction } from '@/api/generated/model';
|
||||||
|
|
||||||
export type CreateQuotationInput = {
|
export type CreateQuotationInput = {
|
||||||
title: string;
|
title: string;
|
||||||
@ -46,6 +46,9 @@ export type SettingInput = {
|
|||||||
targetMargin: string;
|
targetMargin: string;
|
||||||
anchoringValue: string;
|
anchoringValue: string;
|
||||||
cardUseCount: string;
|
cardUseCount: string;
|
||||||
|
midAction?: number; // PriceGateAction (앵커~목표 마감처리). 미지정=1(낙찰)
|
||||||
|
overAction?: number; // PriceGateAction (목표초과 마감처리). 미지정=1(낙찰)
|
||||||
|
regenLimit?: number; // 재생성 최대 횟수(체인 전체, 사유 무관). 미지정=1
|
||||||
};
|
};
|
||||||
|
|
||||||
// 견적 화면 데이터 허브.
|
// 견적 화면 데이터 허브.
|
||||||
@ -128,7 +131,12 @@ export function useQuotations(params: ListQuotationsParams) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
createSettingMutation.mutate(
|
createSettingMutation.mutate(
|
||||||
{ data: { target_margin_rate: marginPct / 100, anchoring_value: anchoring, card_count: cardCount } },
|
{ data: {
|
||||||
|
target_margin_rate: marginPct / 100, anchoring_value: anchoring, card_count: cardCount,
|
||||||
|
mid_action: (input.midAction ?? 1) as PriceGateAction,
|
||||||
|
over_action: (input.overAction ?? 1) as PriceGateAction,
|
||||||
|
regen_limit: Number.isInteger(input.regenLimit) ? (input.regenLimit as number) : 1,
|
||||||
|
} },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateSettings();
|
invalidateSettings();
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import type { QuotationSettingData } from '@/api/generated/model/quotationSettin
|
|||||||
import type { QuotationData } from '@/api/generated/model/quotationData';
|
import type { QuotationData } from '@/api/generated/model/quotationData';
|
||||||
import type { SessionData } from '@/api/generated/model/sessionData';
|
import type { SessionData } from '@/api/generated/model/sessionData';
|
||||||
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
|
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
|
||||||
import { QuotationType, QuotationStatus, SessionStatus, CardType } from '@/api/generated/model';
|
import { QuotationType, QuotationStatus, SessionStatus, CardType, CloseReason, PriceGateAction } from '@/api/generated/model';
|
||||||
import { DELIVERY_TYPE_LABEL } from '@/lib/enumLabels';
|
import { DELIVERY_TYPE_LABEL } from '@/lib/enumLabels';
|
||||||
import type { Product, Partner, NegotiationCard } from '@/types';
|
import type { Product, Partner, NegotiationCard } from '@/types';
|
||||||
|
|
||||||
@ -33,11 +33,28 @@ export interface QuotationSetting {
|
|||||||
target_margin: string;
|
target_margin: string;
|
||||||
anchoring_value: string;
|
anchoring_value: string;
|
||||||
card_use_count: string;
|
card_use_count: string;
|
||||||
|
mid_action: PriceGateAction; // 앵커링가<투찰가≤목표가 마감 처리
|
||||||
|
over_action: PriceGateAction; // 목표가<투찰가 마감 처리
|
||||||
|
regen_limit: number; // 재생성 최대 횟수(체인 전체, 사유 무관)
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 마감 가격정책 처리 라벨(견적세팅 표시·선택).
|
||||||
|
export const PRICE_GATE_ACTION_LABEL: Record<PriceGateAction, string> = {
|
||||||
|
[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 모델 매퍼 ─────────────────────────────────────────────
|
// ── 서버 응답 → UI 모델 매퍼 ─────────────────────────────────────────────
|
||||||
|
|
||||||
export function mapItem(it: ItemData): Product {
|
export function mapItem(it: ItemData): Product {
|
||||||
@ -65,6 +82,9 @@ export function mapSetting(s: QuotationSettingData): QuotationSetting {
|
|||||||
target_margin: `${Number.isFinite(ratePct) ? +ratePct.toFixed(2) : 0}%`,
|
target_margin: `${Number.isFinite(ratePct) ? +ratePct.toFixed(2) : 0}%`,
|
||||||
anchoring_value: String(s.anchoring_value ?? ''),
|
anchoring_value: String(s.anchoring_value ?? ''),
|
||||||
card_use_count: `${s.card_count ?? 0}회`,
|
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 ?? '',
|
created_at: s.created_at ?? '',
|
||||||
updated_at: s.updated_at ?? '',
|
updated_at: s.updated_at ?? '',
|
||||||
deleted: false,
|
deleted: false,
|
||||||
@ -159,33 +179,35 @@ export { SUPPLIER_TYPE_OPTIONS as supplierTypeOptions } from '@/lib/enumLabels';
|
|||||||
|
|
||||||
// ── 라운드 체인(같은 견적번호) ───────────────────────────────────────────
|
// ── 라운드 체인(같은 견적번호) ───────────────────────────────────────────
|
||||||
// 한 라운드(견적)의 결과를 한 단어로. 낙찰=종료, 동가/마감=후속 라운드 가능, 진행중=아직 안 닫힘.
|
// 한 라운드(견적)의 결과를 한 단어로. 낙찰=종료, 동가/마감=후속 라운드 가능, 진행중=아직 안 닫힘.
|
||||||
export type ChainRoundState = 'awarded' | 'equal' | 'closed' | 'active';
|
export type ChainRoundState = 'awarded' | 'regenerated' | 'failed' | 'active';
|
||||||
export const CHAIN_ROUND_STATE_LABEL: Record<ChainRoundState, string> = {
|
export const CHAIN_ROUND_STATE_LABEL: Record<ChainRoundState, string> = {
|
||||||
awarded: '낙찰',
|
awarded: '낙찰',
|
||||||
equal: '동가',
|
regenerated: '재생성',
|
||||||
closed: '마감',
|
failed: '유찰',
|
||||||
active: '진행중',
|
active: '진행중',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const REGEN_CLOSE_REASONS: CloseReason[] = [CloseReason.REGEN_PRICE, CloseReason.REGEN_EQUAL, CloseReason.REGEN_NOSHOW];
|
||||||
|
const FAIL_CLOSE_REASONS: CloseReason[] = [CloseReason.FAIL_PRICE, CloseReason.FAIL_EQUAL, CloseReason.FAIL_NOSHOW, CloseReason.FAIL_REJECT];
|
||||||
|
|
||||||
|
// 마감결과 한 단어. 서버 close_reason(CloseReason) 을 '단일 근거'로 판정한다.
|
||||||
|
// close_reason 이 아직 없는 옛 데이터만 preferred_sp_id/equal_bid_yn 플래그로 폴백.
|
||||||
|
// QuotationData(전체)·Estimate(Partial) 둘 다 받도록 필요한 필드만 optional.
|
||||||
export function chainRoundState(
|
export function chainRoundState(
|
||||||
q: Pick<QuotationData, 'status' | 'preferred_sp_id' | 'equal_bid_yn'>,
|
q: { status?: number | null; close_reason?: number | null; preferred_sp_id?: string | null; equal_bid_yn?: boolean | null },
|
||||||
): ChainRoundState {
|
): ChainRoundState {
|
||||||
|
if (q.close_reason != null) {
|
||||||
|
if (q.close_reason === CloseReason.AWARDED) return 'awarded';
|
||||||
|
if (REGEN_CLOSE_REASONS.includes(q.close_reason as CloseReason)) return 'regenerated';
|
||||||
|
if (FAIL_CLOSE_REASONS.includes(q.close_reason as CloseReason)) return 'failed';
|
||||||
|
}
|
||||||
if (q.preferred_sp_id) return 'awarded';
|
if (q.preferred_sp_id) return 'awarded';
|
||||||
if (q.equal_bid_yn) return 'equal';
|
if (q.equal_bid_yn) return 'regenerated';
|
||||||
if (q.status === QuotationStatus.CLOSED) return 'closed';
|
if (q.status === QuotationStatus.CLOSED) return 'failed';
|
||||||
return 'active';
|
return 'active';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 상세 드로어용 파생 뷰 모델(서버 미연동 영역의 목업 보강 포함) ────────
|
// ── 상세 드로어용 파생 뷰 모델 ───────────────────────────────────────────
|
||||||
|
|
||||||
export type BidSummaryView = {
|
|
||||||
bid_summary_id: string;
|
|
||||||
status: string;
|
|
||||||
qt_iteration: number;
|
|
||||||
has_preferred: boolean;
|
|
||||||
preferred_sp_id: string | null;
|
|
||||||
preferred_sp_name: string;
|
|
||||||
equal_data: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SessionView = {
|
export type SessionView = {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@ -214,19 +236,94 @@ export type QuotationCardView = {
|
|||||||
type: string;
|
type: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildBidSummary(q: QuotationData, partners: Partner[]): BidSummaryView {
|
// ── 견적 결과 요약(목표가 대비 낙찰/절감) ─────────────────────────────────
|
||||||
|
// DB 저장값이 아니라 이미 로드된 quotation + 세션들로 파생한다(별도 컬럼·엔드포인트 없음).
|
||||||
|
// · 목표가 = md_price 우선(없으면 세션 목표가)
|
||||||
|
// · 낙찰가 = 우선협상자 세션의 투찰가(미낙찰이면 현재 최저 투찰가 = 잠정)
|
||||||
|
// · 절감 = 목표가 − 낙찰가 (양수 = 목표보다 저렴하게 낙찰)
|
||||||
|
export type QuotationResultView = {
|
||||||
|
outcome: ChainRoundState;
|
||||||
|
closeReason: string;
|
||||||
|
targetPrice: number | null;
|
||||||
|
targetBasis: 'md' | 'session' | null;
|
||||||
|
winnerName: string | null;
|
||||||
|
winnerPrice: number | null;
|
||||||
|
provisional: boolean; // true = 미낙찰 상태에서 보여주는 '현재 최저 투찰가'
|
||||||
|
lowestBid: number | null; // 투찰가 최소 — 스펙트럼 '최저 투찰가'
|
||||||
|
highestBid: number | null; // 투찰가 최대 — 스펙트럼 '최고 투찰가'(협상 시작점)
|
||||||
|
savings: number | null;
|
||||||
|
savingsRate: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildQuotationResult(q: QuotationData, sessions: SessionView[]): QuotationResultView {
|
||||||
const winnerId = q.preferred_sp_id ?? null;
|
const winnerId = q.preferred_sp_id ?? null;
|
||||||
|
const winnerSession = winnerId ? sessions.find((s) => s.supplier_id === winnerId) ?? null : null;
|
||||||
|
|
||||||
|
// 투찰가 있는 세션들 중 최저 = 잠정 낙찰가(우선협상자 미확정 시).
|
||||||
|
const bids = sessions.map((s) => s.bid_price).filter((v): v is number => v != null);
|
||||||
|
const lowestBid = bids.length ? Math.min(...bids) : null;
|
||||||
|
const highestBid = bids.length ? Math.max(...bids) : null;
|
||||||
|
const winnerPrice = winnerSession?.bid_price ?? lowestBid;
|
||||||
|
const provisional = !winnerSession;
|
||||||
|
|
||||||
|
const sessionTarget =
|
||||||
|
winnerSession?.target_price || sessions.find((s) => s.target_price > 0)?.target_price || null;
|
||||||
|
const targetPrice = q.md_price ?? sessionTarget;
|
||||||
|
const targetBasis: QuotationResultView['targetBasis'] =
|
||||||
|
q.md_price != null ? 'md' : sessionTarget != null ? 'session' : null;
|
||||||
|
|
||||||
|
const savings = targetPrice != null && winnerPrice != null ? targetPrice - winnerPrice : null;
|
||||||
|
const savingsRate = savings != null && targetPrice ? savings / targetPrice : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bid_summary_id: `bid-summary-${q.qt_id}`,
|
outcome: chainRoundState(q),
|
||||||
status: q.status === QuotationStatus.CLOSED ? '입찰종료 (COMPLETED)' : '입찰활성화 (ACTIVE)',
|
closeReason: quotationCloseReason(q, sessions),
|
||||||
qt_iteration: q.iteration ?? 1,
|
targetPrice,
|
||||||
has_preferred: !!winnerId,
|
targetBasis,
|
||||||
preferred_sp_id: winnerId,
|
winnerName: q.preferred_sp_name || winnerSession?.supplier_name || null,
|
||||||
preferred_sp_name: q.preferred_sp_name || (winnerId ? partners.find((p) => p.id === winnerId)?.name || '-' : '-'),
|
winnerPrice,
|
||||||
equal_data: typeof q.equal_bid_data === 'string' ? q.equal_bid_data : '-',
|
provisional,
|
||||||
|
lowestBid,
|
||||||
|
highestBid,
|
||||||
|
savings,
|
||||||
|
savingsRate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 마감 사유 라벨. 서버 close_reason(CloseReason) 단일 근거로 표기.
|
||||||
|
export const CLOSE_REASON_LABEL: Record<CloseReason, string> = {
|
||||||
|
[CloseReason.AWARDED]: '낙찰',
|
||||||
|
[CloseReason.REGEN_PRICE]: '목표초과 — 재협상 진행',
|
||||||
|
[CloseReason.REGEN_EQUAL]: '동가 — 재입찰 진행',
|
||||||
|
[CloseReason.REGEN_NOSHOW]: '전원 미참여 — 재소집 진행',
|
||||||
|
[CloseReason.FAIL_PRICE]: '목표초과로 유찰',
|
||||||
|
[CloseReason.FAIL_EQUAL]: '동가로 유찰',
|
||||||
|
[CloseReason.FAIL_NOSHOW]: '미참여로 유찰',
|
||||||
|
[CloseReason.FAIL_REJECT]: '협상 거부로 유찰',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 마감결과 텍스트(정밀). 마감결과 배지·라벨은 이 값을 쓴다 — close_reason(CloseReason) 을 그대로 표기.
|
||||||
|
// 미마감=진행중, close_reason 없는 옛 데이터만 코스 상태 라벨로 폴백. (배지 '색'은 chainRoundState 로 그룹핑)
|
||||||
|
export function quotationOutcomeLabel(
|
||||||
|
q: { status?: number | null; close_reason?: number | null; preferred_sp_id?: string | null; equal_bid_yn?: boolean | null },
|
||||||
|
): string {
|
||||||
|
if (q.status !== QuotationStatus.CLOSED) return '진행중';
|
||||||
|
if (q.close_reason != null) return CLOSE_REASON_LABEL[q.close_reason as CloseReason] ?? '마감';
|
||||||
|
return CHAIN_ROUND_STATE_LABEL[chainRoundState(q)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마감 사유 한 줄. 서버 close_reason(CloseReason) 을 그대로 표기(단일 근거).
|
||||||
|
// close_reason 이 아직 없는 옛 데이터만 preferred_sp_yn/equal_bid_yn 플래그 + 세션상태로 폴백 되짚는다.
|
||||||
|
function quotationCloseReason(q: QuotationData, sessions: SessionView[]): string {
|
||||||
|
if (q.status !== QuotationStatus.CLOSED) return '협상 진행중';
|
||||||
|
if (q.close_reason != null) return CLOSE_REASON_LABEL[q.close_reason as CloseReason] ?? '마감';
|
||||||
|
if (q.preferred_sp_yn) return '낙찰';
|
||||||
|
if (q.equal_bid_yn) return '동가 — 다음 라운드 재생성';
|
||||||
|
if (q.preferred_sp_yn === false && q.equal_bid_yn === false) return '전원 미참여 — 다음 라운드 재생성';
|
||||||
|
if (sessions.some((s) => s.status === SessionStatus.REJECTED)) return '협상 거부로 마감';
|
||||||
|
return '마감';
|
||||||
|
}
|
||||||
|
|
||||||
// ── 서버 연동 매퍼(negotiation.sessions / chats / 사용 카드) ──────────────
|
// ── 서버 연동 매퍼(negotiation.sessions / chats / 사용 카드) ──────────────
|
||||||
|
|
||||||
// 세션상태 코드→라벨. 서버 /v1/enums(session_status) · SHARED_ENUMS.md 5-state 와 동일해야 한다.
|
// 세션상태 코드→라벨. 서버 /v1/enums(session_status) · SHARED_ENUMS.md 5-state 와 동일해야 한다.
|
||||||
|
|||||||
@ -249,6 +249,9 @@ CREATE TABLE IF NOT EXISTS quotation.quotation_settings (
|
|||||||
target_margin_rate NUMERIC(8,6) NOT NULL, -- 목표 마진율 (정수부 2자리 + 소수 6자리, -99.999999~99.999999)
|
target_margin_rate NUMERIC(8,6) NOT NULL, -- 목표 마진율 (정수부 2자리 + 소수 6자리, -99.999999~99.999999)
|
||||||
anchoring_value NUMERIC(8,6) NOT NULL DEFAULT 0.01, -- 앵커링 값 (정수부 2자리 + 소수 6자리)
|
anchoring_value NUMERIC(8,6) NOT NULL DEFAULT 0.01, -- 앵커링 값 (정수부 2자리 + 소수 6자리)
|
||||||
card_count INTEGER NOT NULL DEFAULT 3, -- 한개의 협상 안에서 협상카드 사용 횟수
|
card_count INTEGER NOT NULL DEFAULT 3, -- 한개의 협상 안에서 협상카드 사용 횟수
|
||||||
|
mid_action SMALLINT NOT NULL DEFAULT 1, -- 마감 가격정책(PriceGateAction): 앵커링가<투찰가≤목표가 처리(1=낙찰/2=재협상/3=유찰)
|
||||||
|
over_action SMALLINT NOT NULL DEFAULT 1, -- 마감 가격정책(PriceGateAction): 목표가<투찰가 처리(1=낙찰/2=재협상/3=유찰). 투찰가≤앵커링가는 항상 낙찰(설정없음)
|
||||||
|
regen_limit SMALLINT NOT NULL DEFAULT 1, -- 재생성 최대 횟수(체인 전체 총합, 사유 무관: 목표초과/동가/미참여 합산)
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||||
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
||||||
@ -278,6 +281,7 @@ CREATE TABLE IF NOT EXISTS quotation.quotations (
|
|||||||
preferred_sp_name VARCHAR(20) NULL, -- 선호 공급사명(스냅샷)
|
preferred_sp_name VARCHAR(20) NULL, -- 선호 공급사명(스냅샷)
|
||||||
equal_bid_yn BOOLEAN NULL, -- 동일가 입찰 발생 여부
|
equal_bid_yn BOOLEAN NULL, -- 동일가 입찰 발생 여부
|
||||||
equal_bid_data JSONB NULL, -- 동일가 입찰 상세(JSON)
|
equal_bid_data JSONB NULL, -- 동일가 입찰 상세(JSON)
|
||||||
|
close_reason SMALLINT NULL, -- 마감 사유(CloseReason): 1=낙찰,2=가격재협상,3=동가재입찰,4=미참여재소집,5=가격유찰,6=동가유찰,7=미참여유찰,8=거부유찰. 미마감이면 NULL
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||||
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user