[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:
Mina Choi 2026-07-02 17:30:40 +09:00
parent d0b6e785af
commit e23349c11e
32 changed files with 1087 additions and 325 deletions

View File

@ -193,6 +193,10 @@ class quotation_settings(MainTableMixin, MAIN_BASE):
target_margin_rate = Column(Numeric(8, 6), nullable=False)
anchoring_value = Column(Numeric(8, 6), nullable=False, default=0.01)
card_count = Column(Integer, nullable=False, default=3)
# 마감 가격정책. 투찰가≤앵커링가는 항상 낙찰(설정없음). 아래 두 구간만 회사가 정한다. 기본 AWARD=현재 '무조건 낙찰' 동작(하위호환).
mid_action = Column(SmallInteger, nullable=False, default=1) # PriceGateAction: 앵커링가<투찰가≤목표가 처리(1=낙찰/2=재협상/3=유찰)
over_action = Column(SmallInteger, nullable=False, default=1) # PriceGateAction: 목표가<투찰가 처리(1=낙찰/2=재협상/3=유찰)
regen_limit = Column(SmallInteger, nullable=False, default=1) # 재생성 최대 횟수(체인 전체 총합, 사유 무관: 목표초과/동가/미참여 합산)
class quotations(MainTableMixin, MAIN_BASE):
@ -225,6 +229,7 @@ class quotations(MainTableMixin, MAIN_BASE):
preferred_sp_name = Column(String(20), nullable=True)
equal_bid_yn = Column(Boolean, nullable=True)
equal_bid_data = Column(JSONB, nullable=True)
close_reason = Column(SmallInteger, nullable=True) # CloseReason 코드. 마감 시 사유 기록(재생성 한도 카운팅·유찰 사유 구분). 미마감이면 NULL
class sessions(MainTableMixin, MAIN_BASE):

View File

@ -171,6 +171,29 @@ class CloseOutcome(Enum):
REGEN_FAILED = "regen_failed" # 재생성 시도했으나 실패 — 원본은 CLOSED 인데 다음 라운드가 없음(체인 끊김, 모니터링 필요)
class CloseReason(CodeEnum):
"""quotations.close_reason 코드값(SMALLINT). 마감 사유 — 재생성 한도 카운팅(REGEN_*)과 유찰 사유 구분에 쓴다.
기존 preferred_sp_yn/equal_bid_yn 2플래그로는 4상태만 표현돼 '목표초과 재협상' 미참여와 충돌하고 유찰 사유가 뭉개짐 컬럼으로 명시."""
AWARDED = 1 # 단독 낙찰
REGEN_PRICE = 2 # 가격 사유 재협상 (단독 최저가가 가격게이트 초과 → 낙찰 대신 다음 라운드로 더 깎기. 대표: 목표초과 재협상)
REGEN_EQUAL = 3 # 동가 재입찰
REGEN_NOSHOW = 4 # 미참여 재소집
FAIL_PRICE = 5 # 가격 사유 유찰 (가격게이트 초과인데 재협상 안 함/한도 소진)
FAIL_EQUAL = 6 # 동가 유찰
FAIL_NOSHOW = 7 # 미참여 유찰 (한도 소진)
FAIL_REJECT = 8 # 거부 유찰 (협상거부 존재)
class PriceGateAction(CodeEnum):
"""quotation_settings 의 가격 구간별 처리 정책. '앵커링가<투찰가≤목표가'(mid) / '목표가<투찰가'(over) 구간에 적용.
(투찰가앵커링가 항상 낙찰이라 설정 없음.)"""
AWARD = 1 # 낙찰
RENEGO = 2 # 재협상(다음 라운드 재생성)
FAIL = 3 # 유찰
class NotificationType(CodeEnum):
"""company.notifications.type 코드값. 견적 생애 이벤트를 작성자에게 통지. 마감 결과 3종(SUCCESS/REGENERATED/FAILURE)은 close_and_decide 와 1:1. 네이밍은 KTC."""

View File

@ -117,7 +117,7 @@ class IQuotationCRUD(ABC):
pass
@abstractmethod
async def list_chain_close_flags(self, cdb: AsyncSession, number, current_round) -> Tuple[ErrorType, list]:
async def list_chain_close_reasons(self, cdb: AsyncSession, number, current_round) -> Tuple[ErrorType, list]:
pass
@abstractmethod
@ -405,11 +405,15 @@ class QuotationCRUD(IQuotationCRUD):
return ErrorType.DB_RUN_FAILED, None
async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]:
"""견적 세팅의 율: {margin, anchoring}. 목표가·앵커링가 산정 입력. (인터넷 수수료는 상수)"""
"""견적 세팅의 율+마감정책: {margin, anchoring, mid_action, over_action, regen_limit}.
목표가·앵커링가 산정 입력 + 마감 가격게이트 정책. (인터넷 수수료는 상수)"""
try:
query = select(
quotation_settings.target_margin_rate,
quotation_settings.anchoring_value,
quotation_settings.mid_action,
quotation_settings.over_action,
quotation_settings.regen_limit,
).where(quotation_settings.qt_setting_id == qt_setting_id).limit(1)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
@ -420,6 +424,9 @@ class QuotationCRUD(IQuotationCRUD):
return ErrorType.SUCCESS, {
"margin": float(r[0]) if r[0] is not None else None,
"anchoring": float(r[1]) if r[1] is not None else None,
"mid_action": r[2],
"over_action": r[3],
"regen_limit": r[4],
}
except Exception as ex:
LOG.e_no_callstack(ex)
@ -554,15 +561,12 @@ class QuotationCRUD(IQuotationCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def list_chain_close_flags(self, cdb: AsyncSession, number, current_round) -> Tuple[ErrorType, list]:
"""[재생성 한도] 같은 견적번호(체인)의 이전 라운드(round < current_round)들의 (preferred_sp_yn, equal_bid_yn) 목록. 삭제 제외.
마감 사유 식별용 표식:
- equal_bid_yn=True 동가 재생성
- preferred_sp_yn=False AND equal_bid_yn=False 미참여 재생성
- preferred_sp_yn=True 단독낙찰(체인 어느 쪽에도 )
- NULL 거부/한도 그냥 마감( )"""
async def list_chain_close_reasons(self, cdb: AsyncSession, number, current_round) -> Tuple[ErrorType, list]:
"""[재생성 한도] 같은 견적번호(체인)의 이전 라운드(round < current_round)들의 close_reason 코드 목록. 삭제 제외.
_chain_regen_counts REGEN_* (목표초과재협상/동가재입찰/미참여재소집) 사유별로 센다.
낙찰(AWARDED)·유찰(FAIL_*)·미마감(NULL) 재생성 한도에 ."""
try:
query = select(quotations.preferred_sp_yn, quotations.equal_bid_yn).where(
query = select(quotations.close_reason).where(
quotations.number == number,
quotations.round < current_round,
quotations.deleted == False, # noqa: E712
@ -570,7 +574,8 @@ class QuotationCRUD(IQuotationCRUD):
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, []
return ErrorType.SUCCESS, list(rows)
# 단일 컬럼 select → 각 행이 스칼라(close_reason 코드 or None)
return ErrorType.SUCCESS, [r for r in rows]
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
@ -594,11 +599,13 @@ class QuotationCRUD(IQuotationCRUD):
return ErrorType.DB_RUN_FAILED, 0
async def list_sessions_status(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]:
"""[마감 판정] 견적의 모든 세션 → (status, supplier_id, bid_price, supplier_name). 삭제 제외.
공급사가 지워졌어도 세션 집계엔 포함되도록 outerjoin(이때 name None)."""
"""[마감 판정] 견적의 모든 세션 → (status, supplier_id, bid_price, name, target_price, target_anchoring_price). 삭제 제외.
공급사가 지워졌어도 세션 집계엔 포함되도록 outerjoin(이때 name None).
target/anchoring 마감 가격게이트 입력(견적당 상품 1개라 세션 공통값)."""
try:
query = (
select(sessions.status, sessions.supplier_id, sessions.bid_price, suppliers.name)
select(sessions.status, sessions.supplier_id, sessions.bid_price, suppliers.name,
sessions.target_price, sessions.target_anchoring_price)
.outerjoin(suppliers, suppliers.supplier_id == sessions.supplier_id)
.where(sessions.quotation_id == qt_id, sessions.deleted == False) # noqa: E712
)

View File

@ -4,7 +4,7 @@ from typing import Any, Optional
from pydantic import ConfigDict
from common.enums import CardType, ChatSender, DeliveryType, QuotationStatus, QuotationType, SessionStatus, SupplierType
from common.enums import CardType, ChatSender, CloseReason, DeliveryType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus, SupplierType
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
@ -62,6 +62,7 @@ class QuotationData(WebPacketProtocol):
preferred_sp_name: Optional[str] = None
equal_bid_yn: Optional[bool] = None
equal_bid_data: Optional[Any] = None
close_reason: Optional[CloseReason] = None # 마감 사유(CloseReason). 미마감이면 None
participation_count: int = 0 # 견적별 참여 협력사 수(세션 distinct supplier). 목록 집계로 채움.
item_id: Optional[uuid.UUID] = None # 대표 상품 id(세션의 첫 item). 목록 조인으로 채움.
item_name: Optional[str] = None # 대표 상품명. 목록 조인으로 채움.

View File

@ -4,6 +4,7 @@ from typing import Optional
from pydantic import ConfigDict
from common.enums import PriceGateAction
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
@ -15,12 +16,18 @@ class Req_CreateQuotationSetting(QuotationSettingProtocol):
target_margin_rate: float
anchoring_value: float = 0.01
card_count: int = 3
mid_action: PriceGateAction = PriceGateAction.AWARD # 앵커링가<투찰가≤목표가 마감 처리
over_action: PriceGateAction = PriceGateAction.AWARD # 목표가<투찰가 마감 처리
regen_limit: int = 1 # 재생성 최대 횟수(체인 전체, 사유 무관)
class Req_UpdateQuotationSetting(QuotationSettingProtocol):
target_margin_rate: Optional[float] = None
anchoring_value: Optional[float] = None
card_count: Optional[int] = None
mid_action: Optional[PriceGateAction] = None
over_action: Optional[PriceGateAction] = None
regen_limit: Optional[int] = None
class QuotationSettingData(WebPacketProtocol):
@ -31,6 +38,9 @@ class QuotationSettingData(WebPacketProtocol):
target_margin_rate: float
anchoring_value: float
card_count: int
mid_action: PriceGateAction = PriceGateAction.AWARD
over_action: PriceGateAction = PriceGateAction.AWARD
regen_limit: int = 1
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None

View 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;

View File

@ -7,7 +7,7 @@ from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import quotations, sessions, chats, versions, version_nego_cards, version_wild_cards
from common.enums import CloseOutcome, DBWRType, ErrorType, NotificationType, QuotationStatus, QuotationType, SessionStatus
from common.enums import CloseOutcome, CloseReason, DBWRType, ErrorType, NotificationType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus
from common.logger import LOG
from common.models.gmodel import PageParams
from common.utils.gtime import GTime
@ -268,13 +268,15 @@ class QuotationService:
return res
async def create_quotation(self, user_id: str, req: Req_CreateQuotation) -> Res_CreateQuotation:
"""[프론트] 신규 견적 생성. 요청값을 보정한 뒤 공통 빌더(_build_quotation)에 위임한다."""
return await self._build_quotation(
"""[프론트] 신규 견적 생성. 요청값을 보정한 뒤 공통 빌더(_build_quotation)에 위임한다.
생성 성공 작성자에게 CREATED 알림(인박스)."""
number = self._gen_number() # 견적번호는 항상 서버 생성(프론트 입력란 없음)
res = await self._build_quotation(
user_id=user_id,
qt_setting_id=req.qt_setting_id,
version_id=req.version_id or self.DEFAULT_VERSION_ID,
name=req.name,
number=self._gen_number(), # 견적번호는 항상 서버 생성(프론트 입력란 없음)
number=number,
type_=req.type,
status=req.status or QuotationStatus.CREATED.value,
round_=req.round or 1,
@ -290,6 +292,13 @@ class QuotationService:
supplier_ids=req.supplier_ids,
card_ids=req.card_ids,
)
if res.result.success:
await create_notification(
user_id, NotificationType.CREATED,
{"qt_name": req.name, "qt_number": number},
ref_qt_id=res.qt_id,
)
return res
async def regenerate_next_round(self, original_qt_id: uuid.UUID, supplier_ids: list) -> Res_CreateQuotation:
"""[마감 후속] 결판 안 난 견적의 '다음 라운드'를 새로 만든다.
@ -508,98 +517,71 @@ class QuotationService:
if len(tied) > 1:
equal = {"price": min_price, "suppliers": [{"supplier_id": str(sid), "name": name} for sid, _, name in tied]}
return None, equal
return {"supplier_id": tied[0][0], "name": tied[0][2]}, None
return {"supplier_id": tied[0][0], "name": tied[0][2], "bid_price": min_price}, None
async def _award_and_close(self, qt_uuid, winner) -> None:
"""단독 낙찰 확정 + 마감 + 미완료(미시작·진행중) 세션 미참여."""
data = {
"status": QuotationStatus.CLOSED.value,
"preferred_sp_yn": True,
"preferred_sp_id": winner["supplier_id"],
"preferred_sp_name": (winner["name"] or "")[:20],
"equal_bid_yn": False,
}
@staticmethod
def _gate_action(bid, target, anchor, mid_action, over_action) -> int:
"""가격게이트 판정 → PriceGateAction 코드.
bid 앵커링가 무조건 낙찰(AWARD)
앵커링가 < bid 목표가 mid_action(설정)
목표가 < bid over_action(설정)
target/bid 없으면(설정 불완전 ) AWARD 폴백(하위호환: 기존 '무조건 낙찰')."""
if bid is None or target is None:
return PriceGateAction.AWARD.value
bid = int(bid)
if anchor is not None and bid <= int(anchor):
return PriceGateAction.AWARD.value
if bid <= int(target):
return mid_action or PriceGateAction.AWARD.value
return over_action or PriceGateAction.AWARD.value
async def _close(self, qt_uuid, close_reason: int, data: dict = None) -> None:
"""마감 공통: status→CLOSED + close_reason 기록 + (있으면)추가데이터 + 미완료(미시작·진행중) 세션→미참여.
close_reason(CloseReason) 재생성 한도 카운팅·유찰 사유 구분의 단일 근거.
preferred_sp_*/equal_bid_* 프론트 표시용으로 함께 채운다(사유 판별은 close_reason 담당)."""
payload = {"status": QuotationStatus.CLOSED.value, "close_reason": close_reason}
if data:
payload.update(data)
await DB_SESSION_MNG.execute_lambda_run(
[quotations.DBType()],
[
lambda s: self.quotation_crud.update_quotation(s, qt_uuid, data),
lambda s: self.quotation_crud.update_quotation(s, qt_uuid, payload),
lambda s: self.quotation_crud.update_sessions_status(
s, qt_uuid, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value
),
],
)
async def _just_close(self, qt_uuid) -> None:
"""그냥 마감 + 미완료 세션 미참여."""
await DB_SESSION_MNG.execute_lambda_run(
[quotations.DBType()],
[
lambda s: self.quotation_crud.update_quotation(s, qt_uuid, {"status": QuotationStatus.CLOSED.value}),
lambda s: self.quotation_crud.update_sessions_status(
s, qt_uuid, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value
),
],
)
async def _close_as_no_show(self, qt_uuid) -> None:
"""전원 미참여로 '다음 라운드 재생성' 하며 마감 + 미완료 세션 미참여.
재생성 사유(미참여) 체인에 남기기 위해 preferred_sp_yn=False, equal_bid_yn=False 양성 표식한다
(단독낙찰=preferred_sp_yn True / 동가=equal_bid_yn True / 거부·한도 그냥 마감= NULL 구분).
_chain_regen_counts 표식으로 '미참여 재생성 이력' 정확히 센다."""
data = {
"status": QuotationStatus.CLOSED.value,
"preferred_sp_yn": False,
"equal_bid_yn": False,
}
await DB_SESSION_MNG.execute_lambda_run(
[quotations.DBType()],
[
lambda s: self.quotation_crud.update_quotation(s, qt_uuid, data),
lambda s: self.quotation_crud.update_sessions_status(
s, qt_uuid, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value
),
],
)
async def _close_as_equal(self, qt_uuid, equal) -> None:
"""동가로 마감 + 미완료 세션 미참여. equal_bid_yn/data 를 기록해 둔다
(재생성 한도 계산이 플래그로 동가 라운드를 식별하고, 프론트도 동가 정보를 그대로 쓴다)."""
data = {
"status": QuotationStatus.CLOSED.value,
"preferred_sp_yn": False,
"equal_bid_yn": True,
"equal_bid_data": equal,
}
await DB_SESSION_MNG.execute_lambda_run(
[quotations.DBType()],
[
lambda s: self.quotation_crud.update_quotation(s, qt_uuid, data),
lambda s: self.quotation_crud.update_sessions_status(
s, qt_uuid, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value
),
],
async def _regen_and_notify(self, original, qt_uuid, supplier_ids: list, reason: str, equal=None) -> CloseOutcome:
"""마감 후 다음 라운드 재생성 + 성공 시 REGENERATED 알림. 실패 시 REGEN_FAILED(원본 CLOSED 인데 체인 끊김 → 노출)."""
regen = await self.regenerate_next_round(qt_uuid, supplier_ids)
if not regen.result.success:
LOG.e_no_callstack(
f"[close] 재생성 실패({reason}) qt={qt_uuid} number={original.number} round={original.round} "
f"code={regen.result.code}({regen.result.desc})"
)
return CloseOutcome.REGEN_FAILED
payload = {"qt_name": original.name, "qt_number": original.number, "reason": reason, "next_round": original.round + 1}
if equal is not None:
payload.update({"tied_price": equal["price"], "tied_count": len(equal["suppliers"])})
await create_notification(original.user_id, NotificationType.REGENERATED, payload, ref_qt_id=regen.qt_id)
return CloseOutcome.REGENERATED
async def close_and_decide(self, qt_id) -> CloseOutcome:
"""[마감] 견적을 마감하면서 결과를 판정한다.
1) 협상완료 최저가 단독 공급사 낙찰 확정
2) 협상완료 최저가 동가 다음 라운드 재생성(동가 업체끼리) [체인에 동가 재생성 이력 없을 때만]
3) 협상거부 세션이 하나라도 있음 그냥 마감 (재생성 )
4) 전원 미참여(완료·거부 0) 다음 라운드 재생성( 견적 공급사 전체) [체인에 미참여 재생성 이력 없을 때만]
5) / 한도 도달 그냥 마감
동가를 거부보다 먼저 본다: 동률은 '협상완료' 업체들 경쟁이라 무관한 다른 업체의 거부로 막지 않는다.
재생성 한도: 체인(같은 견적번호)에서 '미참여' 1 + '동가' 1(순서 무관, 같은 사유 2번은 불가).
공통: statusCLOSED, 미시작·진행중 세션미참여."""
"""[마감] 견적을 마감하며 결과 판정. 낙찰자는 협상완료 최저가(단독/동가), 그 위에 회사 가격정책(가격게이트)으로 낙찰/재협상/유찰을 가른다.
- 단독 최저가: 가격게이트(앵커 무조건낙찰 / 앵커~목표 mid_action / 목표초과 over_action) 낙찰 / 재협상(한도내) / 유찰
- 동가: 가격 구간 설정이 '유찰' 아니고 한도 남으면 재입찰(tie 해소), 유찰 설정이거나 한도 소진이면 유찰
- 협상거부: 유찰
- 전원 미참여: 재소집(한도내) / 유찰
재생성 한도: regen_limit = 체인(같은 견적번호) 전체 재생성 '' 횟수(사유 무관). 마감 사유는 close_reason 기록(카운팅·유찰사유 구분의 단일 근거).
공통: 원자적 statusCLOSED 선점, 미시작·진행중 세션미참여."""
qt_uuid = qt_id if isinstance(qt_id, uuid.UUID) else uuid.UUID(str(qt_id))
err_type, original = await self._fetch(qt_uuid)
if err_type != ErrorType.SUCCESS or original is None:
return CloseOutcome.CLOSED
# [동시 마감 가드] 마감 판정 전에 원자적으로 status→CLOSED 를 선점한다.
# 두 크론 잡(close_expired / close_negotiated)이나 수동 stop_quotation 이 같은 견적을
# 동시에 닫으려 해도, 실제로 CLOSED 로 전이한 호출자만 통과하고 진 호출자는 여기서 끝난다
# → 이중 재생성·uq(number,round) 충돌 방지. (이미 닫힌 견적의 재처리도 여기서 차단)
# [동시 마감 가드] 원자적으로 status→CLOSED 선점. 실제로 전이한 호출자만 통과(이중 재생성·uq(number,round) 충돌 방지).
claim_err, claimed = await DB_SESSION_MNG.execute_lambda_claim(
quotations.DBType(),
lambda s: self.quotation_crud.claim_for_close(s, qt_uuid),
@ -618,69 +600,86 @@ class QuotationService:
has_rejected = any(r.status == SessionStatus.REJECTED.value for r in rows)
winner, equal = self._pick_winner(done)
# 1) 단독 낙찰 → 확정
# 가격게이트 입력: 회사 정책(mid/over/limit) + 세션 목표가/앵커링가(견적당 상품 1개라 세션 공통값)
_e, rates = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.get_setting_rates(s, original.qt_setting_id),
)
rates = rates or {}
mid_action = rates.get("mid_action") or PriceGateAction.AWARD.value
over_action = rates.get("over_action") or PriceGateAction.AWARD.value
limit = rates.get("regen_limit")
limit = limit if limit is not None else self.MAX_REGEN_PER_CAUSE
target = next((r.target_price for r in rows if r.target_price is not None), None)
anchor = next((r.target_anchoring_price for r in rows if r.target_anchoring_price is not None), None)
# 재생성 총 이력(체인, 사유 무관). regen_limit = 체인 전체 재생성 총 한도.
regen_used = await self._chain_regen_count(original.number, original.round)
# 1) 단독 최저가 → 가격게이트로 낙찰/재협상/유찰
if winner is not None:
await self._award_and_close(qt_uuid, winner)
winner_price = min((int(bp) for _, bp, _ in done if bp is not None), default=None)
action = self._gate_action(winner["bid_price"], target, anchor, mid_action, over_action)
if action == PriceGateAction.RENEGO.value and regen_used < limit:
await self._close(qt_uuid, CloseReason.REGEN_PRICE.value, {"preferred_sp_yn": False, "equal_bid_yn": False})
return await self._regen_and_notify(original, qt_uuid, [winner["supplier_id"]], "price")
if action == PriceGateAction.AWARD.value:
await self._close(qt_uuid, CloseReason.AWARDED.value, {
"preferred_sp_yn": True, "preferred_sp_id": winner["supplier_id"],
"preferred_sp_name": (winner["name"] or "")[:20], "equal_bid_yn": False,
})
await create_notification(
original.user_id, NotificationType.SUCCESS,
{"qt_name": original.name, "qt_number": original.number,
"winner_name": winner["name"], "winner_price": winner_price},
"winner_name": winner["name"], "winner_price": winner["bid_price"]},
ref_qt_id=qt_uuid,
)
return CloseOutcome.AWARDED
# 동가/미참여 재생성은 사유별 한도(각 1번, 순서 무관) 확인 후
no_part_used, equal_used = await self._chain_regen_counts(original.number, original.round)
# 2) 동가 → 동가 업체끼리 다음 라운드 (거부보다 먼저: tie 해소 우선, 체인에 동가 이력 없을 때만)
if equal is not None and equal_used < self.MAX_REGEN_PER_CAUSE:
tied_ids = [uuid.UUID(sp["supplier_id"]) for sp in equal["suppliers"]]
await self._close_as_equal(qt_uuid, equal) # 동가 기록(equal_bid_yn) 후 마감
regen = await self.regenerate_next_round(qt_uuid, tied_ids)
if not regen.result.success:
# 원본은 이미 CLOSED 인데 다음 라운드 생성이 실패 → 체인이 끊긴 상태. 성공으로 위장하지 않고 드러낸다.
LOG.e_no_callstack(
f"[close] 동가 재생성 실패 qt={qt_uuid} number={original.number} round={original.round} "
f"code={regen.result.code}({regen.result.desc})"
)
return CloseOutcome.REGEN_FAILED
# FAIL 이거나, RENEGO 인데 한도 소진 → 가격 유찰
await self._close(qt_uuid, CloseReason.FAIL_PRICE.value, {"preferred_sp_yn": False, "equal_bid_yn": False})
await create_notification(
original.user_id, NotificationType.REGENERATED,
{"qt_name": original.name, "qt_number": original.number, "reason": "equal",
"next_round": original.round + 1, "tied_price": equal["price"], "tied_count": len(equal["suppliers"])},
ref_qt_id=regen.qt_id,
original.user_id, NotificationType.FAILURE,
{"qt_name": original.name, "qt_number": original.number, "reason": "price"},
ref_qt_id=qt_uuid,
)
return CloseOutcome.REGENERATED
# 3) 협상거부 있음 → 마감만 (재생성 안 함)
return CloseOutcome.CLOSED
# 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:
await self._just_close(qt_uuid)
await self._close(qt_uuid, CloseReason.FAIL_REJECT.value)
await create_notification(
original.user_id, NotificationType.FAILURE,
{"qt_name": original.name, "qt_number": original.number, "reason": "rejected"},
ref_qt_id=qt_uuid,
)
return CloseOutcome.CLOSED
# 4) 전원 미참여 → 공급사 전체로 다음 라운드 (체인에 미참여 재생성 이력 없을 때만)
if not done and rows and no_part_used < self.MAX_REGEN_PER_CAUSE:
# 4) 전원 미참여 → 공급사 전체 재소집(한도 남을 때)
if not done and rows and regen_used < limit:
supplier_ids = list({r.supplier_id for r in rows})
await self._close_as_no_show(qt_uuid) # 미참여 재생성 표식(preferred_sp_yn=False, equal_bid_yn=False) 후 마감
regen = await self.regenerate_next_round(qt_uuid, supplier_ids)
if not regen.result.success:
# 원본은 이미 CLOSED 인데 다음 라운드 생성이 실패 → 체인이 끊긴 상태. 성공으로 위장하지 않고 드러낸다.
LOG.e_no_callstack(
f"[close] 미참여 재생성 실패 qt={qt_uuid} number={original.number} round={original.round} "
f"code={regen.result.code}({regen.result.desc})"
)
return CloseOutcome.REGEN_FAILED
await create_notification(
original.user_id, NotificationType.REGENERATED,
{"qt_name": original.name, "qt_number": original.number, "reason": "no_show", "next_round": original.round + 1},
ref_qt_id=regen.qt_id,
)
return CloseOutcome.REGENERATED
# 5) 그 외 / 한도 도달 → 마감만
await self._just_close(qt_uuid)
await self._close(qt_uuid, CloseReason.REGEN_NOSHOW.value, {"preferred_sp_yn": False, "equal_bid_yn": False})
return await self._regen_and_notify(original, qt_uuid, supplier_ids, "no_show")
# 5) 그 외 / 한도 도달 → 미참여 유찰
await self._close(qt_uuid, CloseReason.FAIL_NOSHOW.value, {"preferred_sp_yn": False, "equal_bid_yn": False})
await create_notification(
original.user_id, NotificationType.FAILURE,
{"qt_name": original.name, "qt_number": original.number, "reason": "closed"},
@ -688,23 +687,18 @@ class QuotationService:
)
return CloseOutcome.CLOSED
async def _chain_regen_counts(self, number: str, current_round: int) -> tuple[int, int]:
"""체인(같은 견적번호) 이전 라운드들의 '재생성 사유' 횟수. 반환: (미참여 횟수, 동가 횟수).
마감 남긴 양성 표식으로만 센다(오집계 방지):
- 동가 재생성 equal_bid_yn=True
- 미참여 재생성 preferred_sp_yn=False AND equal_bid_yn=False
단독낙찰(preferred_sp_yn=True)·거부/한도 그냥 마감( NULL) 어느 쪽에도 세지 않는다.
(수동 regenerate_quotation 으로 단독낙찰·거부 라운드를 이어붙여도 자동 재생성 한도에 영향 없음.)"""
err_type, flags = await DB_SESSION_MNG.execute_lambda(
async def _chain_regen_count(self, number: str, current_round: int) -> int:
"""체인(같은 견적번호) 이전 라운드들의 재생성 '' 횟수(사유 무관). regen_limit 은 체인 전체 총 한도다.
close_reason REGEN_*(가격/동가/미참여) 센다 낙찰(AWARDED)·유찰(FAIL_*)·미마감(NULL) 한도에 ."""
err_type, reasons = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.list_chain_close_flags(s, number, current_round),
lambda s: self.quotation_crud.list_chain_close_reasons(s, number, current_round),
)
if err_type != ErrorType.SUCCESS:
return 0, 0
equal = sum(1 for _pref, eq in flags if eq is True)
no_part = sum(1 for pref, eq in flags if pref is False and eq is False)
return no_part, equal
return 0
regen = {CloseReason.REGEN_PRICE.value, CloseReason.REGEN_EQUAL.value, CloseReason.REGEN_NOSHOW.value}
return sum(1 for c in reasons if c in regen)
async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list) -> Res_CreateQuotation:
"""[프론트] 마감된 견적을 골라 수동으로 다음 라운드를 생성한다.

View File

@ -67,6 +67,9 @@ class QuotationSettingService:
target_margin_rate=req.target_margin_rate,
anchoring_value=req.anchoring_value,
card_count=req.card_count,
mid_action=req.mid_action.value,
over_action=req.over_action.value,
regen_limit=req.regen_limit,
)
err_type = await DB_SESSION_MNG.execute_lambda_run(
[quotation_settings.DBType()],
@ -83,6 +86,11 @@ class QuotationSettingService:
user_uuid = uuid.UUID(user_id)
setting_uuid = uuid.UUID(qt_setting_id)
data = req.model_dump(exclude_unset=True)
# PriceGateAction enum → SMALLINT 코드값으로 변환(모델 컬럼이 int)
for k in ("mid_action", "over_action"):
v = data.get(k)
if v is not None and hasattr(v, "value"):
data[k] = v.value
# 소유권 확인
err_type, _ = await self._fetch_owned(user_uuid, setting_uuid)

View File

@ -16,7 +16,7 @@ from datetime import datetime, timedelta
import pytest_asyncio
from sqlalchemy import text
from common.enums import CloseOutcome, QuotationStatus, QuotationType, SessionStatus
from common.enums import CloseOutcome, CloseReason, PriceGateAction, QuotationStatus, QuotationType, SessionStatus
from crud.quotation_crud import QuotationCRUD
from services.quotation_service import QuotationService
@ -81,10 +81,10 @@ async def test_awarded_prior_round_not_counted_as_no_show(clean):
기대결과: REGENERATED, 체인 [1,2,3] 단독낙찰 라운드를 '미참여' 오집계해 재생성을 막지 않는다."""
engine = clean
number = "C-AWARDED-PRIOR"
# round 1: 단독낙찰로 마감(preferred_sp_yn=True). 수동 재생성 등으로 체인이 이어진 상황을 가정.
# round 1: 단독낙찰로 마감(close_reason=AWARDED). 수동 재생성 등으로 체인이 이어진 상황을 가정.
await _seed_quotation(
engine, number=number, round_=1, status=QuotationStatus.CLOSED.value,
preferred_sp_yn=True, equal_bid_yn=False,
preferred_sp_yn=True, equal_bid_yn=False, close_reason=CloseReason.AWARDED.value,
)
# round 2: 전원 미참여 → 미참여 재생성이 일어나야 한다(round 1 은 미참여로 세면 안 됨)
qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.IN_PROGRESS.value)
@ -106,10 +106,10 @@ async def test_no_show_prior_round_consumes_budget(clean):
기대결과: CLOSED, 체인 [1,2] 미참여 재생성 한도(1) 소진돼 재생성 없이 그냥 마감(round3 없음)."""
engine = clean
number = "C-NOSHOW-PRIOR"
# round 1: 미참여로 마감(양성 표식) → no_part 예산 1 소진
# round 1: 미참여 재생성으로 마감(close_reason=REGEN_NOSHOW) → 미참여 예산 1 소진
await _seed_quotation(
engine, number=number, round_=1, status=QuotationStatus.CLOSED.value,
preferred_sp_yn=False, equal_bid_yn=False,
preferred_sp_yn=False, equal_bid_yn=False, close_reason=CloseReason.REGEN_NOSHOW.value,
)
# round 2: 또 전원 미참여 → 한도 도달이라 재생성 없이 그냥 마감
qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.IN_PROGRESS.value)
@ -123,28 +123,63 @@ async def test_no_show_prior_round_consumes_budget(clean):
assert [r.round for r in rounds] == [1, 2], "재생성되면 안 됨(round 3 없음)"
async def test_over_target_fail_closes(clean):
"""검증: over_action=유찰(FAIL) 회사에서 단독 최저가가 목표 초과(session target=0 < bid=100)인 견적을 마감.
기대결과: CLOSED + close_reason=FAIL_PRICE 가격에 낙찰 하고 유찰(재생성 없음)."""
engine = clean
setting = await _seed_settings(engine, over_action=PriceGateAction.FAIL.value)
qt = await _seed_quotation(engine, number="C-OVER-FAIL", round_=1,
status=QuotationStatus.IN_PROGRESS.value, qt_setting_id=setting)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100)
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
rounds = await _rounds(engine, "C-OVER-FAIL")
assert outcome == CloseOutcome.CLOSED, f"목표초과+유찰정책 → 유찰이어야 함, 실제 {outcome}"
assert [r.round for r in rounds] == [1], "재생성되면 안 됨(round 2 없음)"
assert rounds[0].close_reason == CloseReason.FAIL_PRICE.value, f"close_reason=FAIL_PRICE 여야 함, 실제 {rounds[0].close_reason}"
async def test_over_target_renego_regenerates(clean):
"""검증: over_action=재협상(RENEGO) 회사에서 단독 최저가가 목표 초과인 견적을 첫 라운드에 마감.
기대결과: REGENERATED + 체인 [1,2] 가격에 낙찰 하고 다음 라운드로 깎기(1 close_reason=REGEN_PRICE)."""
engine = clean
setting = await _seed_settings(engine, over_action=PriceGateAction.RENEGO.value)
qt = await _seed_quotation(engine, number="C-OVER-RENEGO", round_=1,
status=QuotationStatus.IN_PROGRESS.value, qt_setting_id=setting)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100)
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
rounds = await _rounds(engine, "C-OVER-RENEGO")
assert outcome == CloseOutcome.REGENERATED, f"목표초과+재협상정책 0회차 → 재생성이어야 함, 실제 {outcome}"
assert [r.round for r in rounds] == [1, 2], f"round 2 가 생성돼야 함, 실제 {[r.round for r in rounds]}"
assert rounds[0].close_reason == CloseReason.REGEN_PRICE.value, f"1차 close_reason=REGEN_PRICE 여야 함, 실제 {rounds[0].close_reason}"
# ===== 헬퍼 (위 테스트들이 쓰는 도우미. 세션 상태·마감 표식을 SQL 로 직접 세팅) =====
async def _seed_quotation(
engine, *, number, round_, status, start_time=PAST, end_time=PAST,
preferred_sp_yn=None, equal_bid_yn=None,
preferred_sp_yn=None, equal_bid_yn=None, close_reason=None, qt_setting_id=None,
):
"""견적 1건 시드. number/round_ 로 체인을, preferred_sp_yn·equal_bid_yn 으로 '이전 라운드가 어떻게 마감됐는지'를 만든다."""
"""견적 1건 시드. number/round_ 로 체인을, close_reason 으로 '이전 라운드가 어떤 사유로 마감/재생성됐는지'를 만든다
(재생성 한도 카운팅은 close_reason REGEN_* 센다). qt_setting_id 마감 가격정책(설정) 연결. preferred_sp_yn/equal_bid_yn 프론트 표시용."""
qt_id = uuid.uuid4()
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO quotations "
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, "
" round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn) VALUES "
" round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn, close_reason) VALUES "
"(:qt_id, :user_id, :qt_setting_id, :version_id, :name, :number, :type, :status, "
" :round, 0, :start_time, :end_time, false, :pref, :eq)"
" :round, 0, :start_time, :end_time, false, :pref, :eq, :creason)"
),
{
"qt_id": qt_id, "user_id": uuid.uuid4(), "qt_setting_id": uuid.uuid4(),
"qt_id": qt_id, "user_id": uuid.uuid4(), "qt_setting_id": qt_setting_id or uuid.uuid4(),
"version_id": uuid.uuid4(), "name": "견적", "number": number,
"type": QuotationType.REQUOTE.value, "status": status, "round": round_,
"start_time": start_time, "end_time": end_time,
"pref": preferred_sp_yn, "eq": equal_bid_yn,
"pref": preferred_sp_yn, "eq": equal_bid_yn, "creason": close_reason,
},
)
return qt_id
@ -171,10 +206,25 @@ async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=Non
async def _rounds(engine, number):
"""체인(number)의 (round, status, start_time, end_time) 목록 — round 오름차순."""
"""체인(number)의 (round, status, start_time, end_time, close_reason) 목록 — round 오름차순."""
async with engine.begin() as conn:
return (await conn.execute(
text("SELECT round, status, start_time, end_time FROM quotations "
text("SELECT round, status, start_time, end_time, close_reason FROM quotations "
"WHERE number = :n ORDER BY round"),
{"n": number},
)).all()
async def _seed_settings(engine, *, mid_action=1, over_action=1, regen_limit=1):
"""quotation_settings 1건 시드(마감 가격정책 지정). 반환: qt_setting_id."""
sid = uuid.uuid4()
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO quotation_settings "
"(qt_setting_id, user_id, target_margin_rate, anchoring_value, card_count, mid_action, over_action, regen_limit) "
"VALUES (:sid, :uid, 0.1, 0.01, 3, :mid, :over, :lim)"
),
{"sid": sid, "uid": uuid.uuid4(), "mid": mid_action, "over": over_action, "lim": regen_limit},
)
return sid

View File

@ -17,7 +17,7 @@ from datetime import datetime
import pytest_asyncio
from sqlalchemy import text
from common.enums import CloseOutcome, NotificationType, QuotationStatus, QuotationType, SessionStatus
from common.enums import CloseOutcome, CloseReason, NotificationType, QuotationStatus, QuotationType, SessionStatus
from crud.quotation_crud import QuotationCRUD
from services.quotation_service import QuotationService
@ -121,9 +121,10 @@ async def test_equal_bid_limit_exhausted_fails(clean):
기대결과: 재견적 X 판정 = 결렬(CLOSED) + 알림 FAILURE(reason=closed)."""
engine = clean
user_id = uuid.uuid4()
# 1차: 동가로 마감돼 2차를 만든 상황(equal_bid_yn=True 가 동가 재생성 표식) → 동가 한도 소진
# 1차: 동가로 재생성 마감(close_reason=REGEN_EQUAL) → 동가 한도 소진
await _seed_quotation(engine, user_id=user_id, number="N-EQUAL-LIMIT", round_=1,
status=QuotationStatus.CLOSED.value, equal_bid_yn=True)
status=QuotationStatus.CLOSED.value, equal_bid_yn=True,
close_reason=CloseReason.REGEN_EQUAL.value)
# 2차: 또 동가
qt2 = await _seed_quotation(engine, user_id=user_id, number="N-EQUAL-LIMIT", round_=2)
await _add_session(engine, qt2, status=SessionStatus.DONE.value, bid_price=100)
@ -136,7 +137,7 @@ async def test_equal_bid_limit_exhausted_fails(clean):
assert len(notis) == 1
type_, data, ref = notis[0]
assert type_ == NotificationType.FAILURE.value
assert data["reason"] == "closed"
assert data["reason"] == "equal" # 동가 유찰(FAIL_EQUAL) 사유
assert str(ref) == str(qt2)
@ -145,9 +146,10 @@ async def test_no_show_limit_exhausted_fails(clean):
기대결과: 재견적 X 판정 = 결렬(CLOSED) + 알림 FAILURE(reason=closed)."""
engine = clean
user_id = uuid.uuid4()
# 1차: 미참여로 마감돼 2차를 만든 상황(preferred_sp_yn=False·equal_bid_yn=False 가 미참여 재생성 표식) → 미참여 한도 소진
# 1차: 미참여로 재생성 마감(close_reason=REGEN_NOSHOW) → 미참여 한도 소진
await _seed_quotation(engine, user_id=user_id, number="N-NOSHOW-LIMIT", round_=1,
status=QuotationStatus.CLOSED.value, preferred_sp_yn=False, equal_bid_yn=False)
status=QuotationStatus.CLOSED.value, preferred_sp_yn=False, equal_bid_yn=False,
close_reason=CloseReason.REGEN_NOSHOW.value)
# 2차: 또 전원 미참여
qt2 = await _seed_quotation(engine, user_id=user_id, number="N-NOSHOW-LIMIT", round_=2)
await _add_session(engine, qt2, status=SessionStatus.CREATED.value)
@ -167,26 +169,26 @@ async def test_no_show_limit_exhausted_fails(clean):
# ===== 헬퍼 (위 테스트들이 쓰는 도우미. 세션 입찰값·이전 라운드 표식을 SQL 로 직접 세팅) =====
async def _seed_quotation(
engine, *, user_id, number, round_=1, status=QuotationStatus.IN_PROGRESS.value,
preferred_sp_yn=None, equal_bid_yn=None,
preferred_sp_yn=None, equal_bid_yn=None, close_reason=None,
):
"""견적 1건 시드(작성자=user_id). preferred_sp_yn·equal_bid_yn 으로 '이전 라운드가 어떤 사유로 재생성됐는지'를 표식한다
(동가 재생성=equal_bid_yn True / 미참여 재생성=preferred_sp_yn False AND equal_bid_yn False)."""
"""견적 1건 시드(작성자=user_id). close_reason 으로 '이전 라운드가 어떤 사유로 재생성됐는지'를 표식한다
(재생성 한도 카운팅은 close_reason REGEN_* 센다). preferred_sp_yn/equal_bid_yn 프론트 표시용."""
qt_id = uuid.uuid4()
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO quotations "
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, "
" round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn) VALUES "
" round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn, close_reason) VALUES "
"(:qt_id, :user_id, :qt_setting_id, :version_id, '견적A', :number, :type, :status, "
" :round, 0, :start_time, :end_time, false, :pref, :eq)"
" :round, 0, :start_time, :end_time, false, :pref, :eq, :creason)"
),
{
"qt_id": qt_id, "user_id": user_id, "qt_setting_id": uuid.uuid4(),
"version_id": uuid.uuid4(), "number": number,
"type": QuotationType.REQUOTE.value, "status": status, "round": round_,
"start_time": PAST, "end_time": PAST,
"pref": preferred_sp_yn, "eq": equal_bid_yn,
"pref": preferred_sp_yn, "eq": equal_bid_yn, "creason": close_reason,
},
)
return qt_id

View 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;

View File

@ -27,6 +27,7 @@ export * from './chatMessageDataIndicatorValue';
export * from './chatMessageDataScript';
export * from './chatMessageDataStep';
export * from './chatSender';
export * from './closeReason';
export * from './companyData';
export * from './companyUserData';
export * from './companyUserDataContactNumber';
@ -81,6 +82,7 @@ export * from './notificationDataReadAt';
export * from './notificationDataRefQtId';
export * from './notificationDataRefSessionId';
export * from './notificationType';
export * from './priceGateAction';
export * from './quotationCardData';
export * from './quotationCardDataCondition';
export * from './quotationCardDataEditScript';
@ -93,6 +95,7 @@ export * from './quotationCardDataScript';
export * from './quotationCardDataType';
export * from './quotationCardDataWildCardId';
export * from './quotationData';
export * from './quotationDataCloseReason';
export * from './quotationDataCreatedAt';
export * from './quotationDataCreatorName';
export * from './quotationDataEqualBidData';
@ -204,6 +207,9 @@ export * from './reqUpdateMePassword';
export * from './reqUpdateQuotationSetting';
export * from './reqUpdateQuotationSettingAnchoringValue';
export * from './reqUpdateQuotationSettingCardCount';
export * from './reqUpdateQuotationSettingMidAction';
export * from './reqUpdateQuotationSettingOverAction';
export * from './reqUpdateQuotationSettingRegenLimit';
export * from './reqUpdateQuotationSettingTargetMarginRate';
export * from './reqUpdateSupplier';
export * from './reqUpdateSupplierCode';

View 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;

View File

@ -17,6 +17,7 @@ import type { QuotationDataPreferredSpId } from './quotationDataPreferredSpId';
import type { QuotationDataPreferredSpName } from './quotationDataPreferredSpName';
import type { QuotationDataEqualBidYn } from './quotationDataEqualBidYn';
import type { QuotationDataEqualBidData } from './quotationDataEqualBidData';
import type { QuotationDataCloseReason } from './quotationDataCloseReason';
import type { QuotationDataItemId } from './quotationDataItemId';
import type { QuotationDataItemName } from './quotationDataItemName';
import type { QuotationDataCreatorName } from './quotationDataCreatorName';
@ -47,6 +48,7 @@ export interface QuotationData {
preferred_sp_name?: QuotationDataPreferredSpName;
equal_bid_yn?: QuotationDataEqualBidYn;
equal_bid_data?: QuotationDataEqualBidData;
close_reason?: QuotationDataCloseReason;
participation_count?: number;
item_id?: QuotationDataItemId;
item_name?: QuotationDataItemName;

View File

@ -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;

View File

@ -5,6 +5,7 @@
* OpenAPI spec version: 0.1.0
*/
import type { QuotationSettingDataUserId } from './quotationSettingDataUserId';
import type { PriceGateAction } from './priceGateAction';
import type { QuotationSettingDataCreatedAt } from './quotationSettingDataCreatedAt';
import type { QuotationSettingDataUpdatedAt } from './quotationSettingDataUpdatedAt';
@ -14,6 +15,9 @@ export interface QuotationSettingData {
target_margin_rate: number;
anchoring_value: number;
card_count: number;
mid_action?: PriceGateAction;
over_action?: PriceGateAction;
regen_limit?: number;
created_at?: QuotationSettingDataCreatedAt;
updated_at?: QuotationSettingDataUpdatedAt;
}

View File

@ -4,9 +4,13 @@
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { PriceGateAction } from './priceGateAction';
export interface ReqCreateQuotationSetting {
target_margin_rate: number;
anchoring_value?: number;
card_count?: number;
mid_action?: PriceGateAction;
over_action?: PriceGateAction;
regen_limit?: number;
}

View File

@ -7,9 +7,15 @@
import type { ReqUpdateQuotationSettingTargetMarginRate } from './reqUpdateQuotationSettingTargetMarginRate';
import type { ReqUpdateQuotationSettingAnchoringValue } from './reqUpdateQuotationSettingAnchoringValue';
import type { ReqUpdateQuotationSettingCardCount } from './reqUpdateQuotationSettingCardCount';
import type { ReqUpdateQuotationSettingMidAction } from './reqUpdateQuotationSettingMidAction';
import type { ReqUpdateQuotationSettingOverAction } from './reqUpdateQuotationSettingOverAction';
import type { ReqUpdateQuotationSettingRegenLimit } from './reqUpdateQuotationSettingRegenLimit';
export interface ReqUpdateQuotationSetting {
target_margin_rate?: ReqUpdateQuotationSettingTargetMarginRate;
anchoring_value?: ReqUpdateQuotationSettingAnchoringValue;
card_count?: ReqUpdateQuotationSettingCardCount;
mid_action?: ReqUpdateQuotationSettingMidAction;
over_action?: ReqUpdateQuotationSettingOverAction;
regen_limit?: ReqUpdateQuotationSettingRegenLimit;
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -6,19 +6,17 @@ import { cn } from '@/lib/utils';
import { typographyVariants } from '@/components/ui/typography';
import { InfoField } from './InfoField';
import { QuotationStatusBadge } from './StatusPill';
import { ResultSummaryBand } from './ResultSummaryBand';
import type { QuotationData } from '@/api/generated/model/quotationData';
import {
type Product,
type Partner,
type QuotationSetting,
buildBidSummary,
type SessionView,
quotationTypeLabel,
priceGateActionLabel,
fmtDateTime,
} from '../../types';
const fmtYn = (b: boolean | null | undefined, yes: string, no: string) =>
b == null ? '-' : b ? yes : no;
/** 헤더 정보 카드 컨테이너. ui/Card 의 넉넉한 기본 여백을 촘촘하게 덮어쓴다. */
function SectionCard({ title, children }: { title: string; children: ReactNode }) {
return (
@ -33,16 +31,17 @@ function SectionCard({ title, children }: { title: string; children: ReactNode }
type DrawerHeaderCardsProps = {
quotation: QuotationData;
partners: Partner[];
quotationSettings: QuotationSetting[];
/** 협상 세션 뷰 — 결과 밴드가 낙찰가/절감 계산에 쓴다. */
sessionViews: SessionView[];
/** 현재 선택 세션의 상품(없으면 상품 카드는 빈 상태). */
currentProduct: Product | undefined;
};
export function DrawerHeaderCards({
quotation,
partners,
quotationSettings,
sessionViews,
currentProduct,
}: DrawerHeaderCardsProps) {
// Quotations DDL 표시값
@ -57,33 +56,26 @@ export function DrawerHeaderCards({
: '-';
const q_memo = quotation.memo || '-';
const bidSummaryObj = buildBidSummary(quotation, partners);
const selectedSettingObj = quotationSettings.find((qs) => qs.qt_setting_id === quotation.qt_setting_id);
// 상품 상세 패널 행(negowiz 협상대화의 상품 정보 대응). negodata 컬럼명: maker_name→manufacturer, min_order_quantity→moq.
const productSpecRows = currentProduct
// 상품 카드는 '가격 관련' 값만 노출(모델명·규격·제조사 등 스펙은 상품 상세에서 확인).
const won = (n?: number | null) => (n != null ? `${Number(n).toLocaleString()}` : '-');
const productPriceRows = currentProduct
? [
{ label: '상품코드', value: currentProduct.code || '-' },
{ label: '상품단가', value: currentProduct.price != null ? `${Number(currentProduct.price).toLocaleString()}` : '-' },
{ label: '매입가', value: currentProduct.purchase_price != null ? `${Number(currentProduct.purchase_price).toLocaleString()}` : '-' },
{ label: '판매가', value: currentProduct.selling_price != null ? `${Number(currentProduct.selling_price).toLocaleString()}` : '-' },
{ label: '인터넷 최저가', value: currentProduct.internet_lowest_price != null ? `${Number(currentProduct.internet_lowest_price).toLocaleString()}` : '-' },
{ label: '모델명', value: currentProduct.model_name || '-' },
{ label: '규격', value: currentProduct.spec || '-' },
{ label: '제조사', value: currentProduct.manufacturer || '-' },
{ label: '원산지', value: currentProduct.made_in || '-' },
{ label: 'MOQ', value: currentProduct.moq || '-' },
{ label: '리드타임', value: currentProduct.lead_time != null ? `${currentProduct.lead_time}` : '-' },
{ label: 'VAT', value: fmtYn(currentProduct.vat_yn, '포함', '별도') },
{ label: '배송비', value: fmtYn(currentProduct.delivery_fee_yn, '포함', '별도') },
{ label: '상품단가', value: won(currentProduct.price) },
{ label: '매입가', value: won(currentProduct.purchase_price) },
{ label: '판매가', value: won(currentProduct.selling_price) },
{ label: '인터넷 최저가', value: won(currentProduct.internet_lowest_price) },
]
: [];
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4 text-xs font-mono">
{/* 좌측 컬럼: 견적정보 + 진행상태 */}
<div className="space-y-4">
{/* Quotations */}
<div className="mt-4 space-y-4 text-xs font-mono">
{/* 결과 요약: 목표가 대비 낙찰/절감 */}
<ResultSummaryBand quotation={quotation} sessionViews={sessionViews} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* 견적 정보 */}
<SectionCard title="견적 정보">
<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" />
@ -105,36 +97,9 @@ export function DrawerHeaderCards({
</div>
</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">
{/* 상품 정보 (협상 대상 상품) */}
<SectionCard title="상품 정보">
<SectionCard title="상품 정보 (가격)">
{currentProduct ? (
<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">
@ -156,14 +121,14 @@ export function DrawerHeaderCards({
>
{currentProduct.name || '-'}
</Link>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-x-3 gap-y-1.5 font-mono text-muted-foreground">
{productSpecRows.map((r) => (
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5 font-mono text-muted-foreground">
{productPriceRows.map((row) => (
<InfoField
key={r.label}
label={r.label}
value={r.value}
key={row.label}
label={row.label}
value={row.value}
valueClassName="font-sans truncate block"
title={r.value}
title={row.value}
/>
))}
</div>
@ -174,7 +139,6 @@ export function DrawerHeaderCards({
)}
</SectionCard>
{/* Quotation Settings */}
<SectionCard title="견적 세팅">
{selectedSettingObj ? (
<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"
/>
<InfoField label="앵커링 설정 값" value={selectedSettingObj.anchoring_value} valueClassName="font-sans" />
<InfoField
label="카드 사용 횟수"
value={selectedSettingObj.card_use_count}
/>
<InfoField label="카드 사용 횟수" value={selectedSettingObj.card_use_count} />
<InfoField label="앵커~목표 마감처리" value={priceGateActionLabel(selectedSettingObj.mid_action)} />
<InfoField label="목표초과 마감처리" value={priceGateActionLabel(selectedSettingObj.over_action)} />
<InfoField label="재생성 한도" value={`${selectedSettingObj.regen_limit}`} />
</div>
) : (
<div className="text-muted-foreground py-6 text-center"> .</div>
@ -195,5 +159,6 @@ export function DrawerHeaderCards({
</SectionCard>
</div>
</div>
</div>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -98,7 +98,7 @@ export function SessionsStatusTab({
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
<TableRow>
<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 font-sans"></TableHead>
<TableHead className="p-3 font-semibold text-center font-sans"></TableHead>
@ -133,7 +133,7 @@ export function SessionsStatusTab({
</button>
</div>
</TableCell>
<TableCell className="p-3 font-mono">
<TableCell className="p-2 font-mono text-center w-10">
{sess.url ? (
<button
onClick={() => {

View File

@ -202,8 +202,8 @@ export function QuotationDetailSheet({
>
<DrawerHeaderCards
quotation={quotation}
partners={partners}
quotationSettings={quotationSettings}
sessionViews={sessionViews}
currentProduct={currentProduct}
/>
</div>

View File

@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button';
import { Typography } from '@/components/ui/typography';
import { Input } from '@/components/ui/input';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import type { QuotationSetting } from '../types';
import { type QuotationSetting, priceGateActionLabel, PRICE_GATE_ACTION_OPTIONS } from '../types';
import type { SettingInput } from '../hooks/useQuotations';
type QuotationSettingsModalProps = {
@ -25,16 +25,25 @@ export function QuotationSettingsModal({
const [targetMargin, setTargetMargin] = useState('');
const [anchoringValue, setAnchoringValue] = useState('');
const [cardUseCount, setCardUseCount] = useState('');
const [midAction, setMidAction] = useState(1); // PriceGateAction 기본 낙찰
const [overAction, setOverAction] = useState(1);
const [regenLimit, setRegenLimit] = useState('1');
if (!open) return null;
const handleAdd = (e: React.FormEvent) => {
e.preventDefault();
const ok = onAdd({ targetMargin, anchoringValue, cardUseCount });
const ok = onAdd({
targetMargin, anchoringValue, cardUseCount,
midAction, overAction, regenLimit: parseInt(regenLimit, 10),
});
if (ok) {
setTargetMargin('');
setAnchoringValue('');
setCardUseCount('');
setMidAction(1);
setOverAction(1);
setRegenLimit('1');
}
};
@ -63,13 +72,16 @@ export function QuotationSettingsModal({
<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>
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-border bg-background">
{settings.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="p-6 text-center text-muted-foreground">
<TableCell colSpan={7} className="p-6 text-center text-muted-foreground">
. ( )
</TableCell>
</TableRow>
@ -79,6 +91,9 @@ export function QuotationSettingsModal({
<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-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">
<button
type="button"
@ -112,6 +127,34 @@ export function QuotationSettingsModal({
<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" />
</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 className="flex justify-end pt-2">

View File

@ -7,8 +7,11 @@ import { useQuotationChain } from '../hooks/useQuotationChain';
import {
type Estimate,
type Product,
type ChainRoundState,
quotationStatusLabel,
quotationTypeLabel,
chainRoundState,
quotationOutcomeLabel,
CHAIN_ROUND_STATE_LABEL,
} from '../types';
import { QuotationType, QuotationStatus } from '@/api/generated/model';
@ -35,6 +38,20 @@ const statusBadgeClass = (status?: number | null) => {
}
};
// 마감결과 배지 색. 낙찰=초록/재생성=주황/유찰=로즈/진행중=회색(아직 마감 전).
const outcomeBadgeClass = (state: ChainRoundState) => {
switch (state) {
case 'awarded':
return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/25 dark:text-emerald-400 border-emerald-300/50';
case 'regenerated':
return 'bg-amber-50 text-amber-700 dark:bg-amber-950/25 dark:text-amber-400 border-amber-300/50';
case 'failed':
return 'bg-rose-50 text-rose-700 dark:bg-rose-950/25 dark:text-rose-400 border-rose-300/50';
default:
return 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800/40 dark:text-zinc-400 border-zinc-300/40';
}
};
export function QuotationTable({ data, products, onOpenDetail, onFilterChain, footer }: QuotationTableProps) {
return (
<DataTable
@ -124,6 +141,22 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo
</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: '마감기한',
cellClassName: 'font-mono text-muted-foreground whitespace-nowrap',

View File

@ -11,7 +11,7 @@ export type ChainRound = {
// 같은 견적번호(체인)의 모든 차수를 라운드 오름차순으로 돌려준다.
// 체인은 parent_id 없이 number 공유로만 묶이므로, 목록 검색(number ILIKE)으로 모은 뒤
// 정확매칭(q.number === number)으로 좁힌다 — search 가 이름까지 매칭하는 오염을 제거.
// 재생성은 사유별 1회 한도라 체인은 최대 ~3라운드 → size 한 번이면 전부 들어온다.
// 재생성은 체인 전체 총 한도(regen_limit)라 체인 라운드 수가 유한 → size 한 번이면 전부 들어온다.
export function useQuotationChain(number?: string | null) {
const enabled = !!number;
const query = useListQuotations(

View File

@ -27,7 +27,7 @@ import { confirm } from '@/lib/confirm';
import { useAuthStore } from '@/stores/auth';
import type { Estimate } from '../types';
import { mapItem, mapSupplier, mapSetting, mapQuotation } from '../types';
import { QuotationStatus } from '@/api/generated/model';
import { QuotationStatus, type PriceGateAction } from '@/api/generated/model';
export type CreateQuotationInput = {
title: string;
@ -46,6 +46,9 @@ export type SettingInput = {
targetMargin: string;
anchoringValue: string;
cardUseCount: string;
midAction?: number; // PriceGateAction (앵커~목표 마감처리). 미지정=1(낙찰)
overAction?: number; // PriceGateAction (목표초과 마감처리). 미지정=1(낙찰)
regenLimit?: number; // 재생성 최대 횟수(체인 전체, 사유 무관). 미지정=1
};
// 견적 화면 데이터 허브.
@ -128,7 +131,12 @@ export function useQuotations(params: ListQuotationsParams) {
return false;
}
createSettingMutation.mutate(
{ data: { target_margin_rate: marginPct / 100, anchoring_value: anchoring, card_count: cardCount } },
{ data: {
target_margin_rate: marginPct / 100, anchoring_value: anchoring, card_count: cardCount,
mid_action: (input.midAction ?? 1) as PriceGateAction,
over_action: (input.overAction ?? 1) as PriceGateAction,
regen_limit: Number.isInteger(input.regenLimit) ? (input.regenLimit as number) : 1,
} },
{
onSuccess: () => {
invalidateSettings();

View File

@ -4,7 +4,7 @@ import type { QuotationSettingData } from '@/api/generated/model/quotationSettin
import type { QuotationData } from '@/api/generated/model/quotationData';
import type { SessionData } from '@/api/generated/model/sessionData';
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
import { QuotationType, QuotationStatus, SessionStatus, CardType } from '@/api/generated/model';
import { QuotationType, QuotationStatus, SessionStatus, CardType, CloseReason, PriceGateAction } from '@/api/generated/model';
import { DELIVERY_TYPE_LABEL } from '@/lib/enumLabels';
import type { Product, Partner, NegotiationCard } from '@/types';
@ -33,11 +33,28 @@ export interface QuotationSetting {
target_margin: string;
anchoring_value: string;
card_use_count: string;
mid_action: PriceGateAction; // 앵커링가<투찰가≤목표가 마감 처리
over_action: PriceGateAction; // 목표가<투찰가 마감 처리
regen_limit: number; // 재생성 최대 횟수(체인 전체, 사유 무관)
created_at: string;
updated_at: string;
deleted: boolean;
}
// 마감 가격정책 처리 라벨(견적세팅 표시·선택).
export const PRICE_GATE_ACTION_LABEL: Record<PriceGateAction, 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 모델 매퍼 ─────────────────────────────────────────────
export function mapItem(it: ItemData): Product {
@ -65,6 +82,9 @@ export function mapSetting(s: QuotationSettingData): QuotationSetting {
target_margin: `${Number.isFinite(ratePct) ? +ratePct.toFixed(2) : 0}%`,
anchoring_value: String(s.anchoring_value ?? ''),
card_use_count: `${s.card_count ?? 0}`,
mid_action: s.mid_action ?? PriceGateAction.AWARD,
over_action: s.over_action ?? PriceGateAction.AWARD,
regen_limit: s.regen_limit ?? 1,
created_at: s.created_at ?? '',
updated_at: s.updated_at ?? '',
deleted: false,
@ -159,33 +179,35 @@ export { SUPPLIER_TYPE_OPTIONS as supplierTypeOptions } from '@/lib/enumLabels';
// ── 라운드 체인(같은 견적번호) ───────────────────────────────────────────
// 한 라운드(견적)의 결과를 한 단어로. 낙찰=종료, 동가/마감=후속 라운드 가능, 진행중=아직 안 닫힘.
export type ChainRoundState = 'awarded' | 'equal' | 'closed' | 'active';
export type ChainRoundState = 'awarded' | 'regenerated' | 'failed' | 'active';
export const CHAIN_ROUND_STATE_LABEL: Record<ChainRoundState, string> = {
awarded: '낙찰',
equal: '동가',
closed: '마감',
regenerated: '재생성',
failed: '유찰',
active: '진행중',
};
const REGEN_CLOSE_REASONS: CloseReason[] = [CloseReason.REGEN_PRICE, CloseReason.REGEN_EQUAL, CloseReason.REGEN_NOSHOW];
const FAIL_CLOSE_REASONS: CloseReason[] = [CloseReason.FAIL_PRICE, CloseReason.FAIL_EQUAL, CloseReason.FAIL_NOSHOW, CloseReason.FAIL_REJECT];
// 마감결과 한 단어. 서버 close_reason(CloseReason) 을 '단일 근거'로 판정한다.
// close_reason 이 아직 없는 옛 데이터만 preferred_sp_id/equal_bid_yn 플래그로 폴백.
// QuotationData(전체)·Estimate(Partial) 둘 다 받도록 필요한 필드만 optional.
export function chainRoundState(
q: Pick<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 {
if (q.close_reason != null) {
if (q.close_reason === CloseReason.AWARDED) return 'awarded';
if (REGEN_CLOSE_REASONS.includes(q.close_reason as CloseReason)) return 'regenerated';
if (FAIL_CLOSE_REASONS.includes(q.close_reason as CloseReason)) return 'failed';
}
if (q.preferred_sp_id) return 'awarded';
if (q.equal_bid_yn) return 'equal';
if (q.status === QuotationStatus.CLOSED) return 'closed';
if (q.equal_bid_yn) return 'regenerated';
if (q.status === QuotationStatus.CLOSED) return 'failed';
return 'active';
}
// ── 상세 드로어용 파생 뷰 모델(서버 미연동 영역의 목업 보강 포함) ────────
export type BidSummaryView = {
bid_summary_id: string;
status: string;
qt_iteration: number;
has_preferred: boolean;
preferred_sp_id: string | null;
preferred_sp_name: string;
equal_data: string;
};
// ── 상세 드로어용 파생 뷰 모델 ───────────────────────────────────────────
export type SessionView = {
session_id: string;
@ -214,17 +236,92 @@ export type QuotationCardView = {
type: string;
};
export function buildBidSummary(q: QuotationData, partners: Partner[]): BidSummaryView {
const winnerId = q.preferred_sp_id ?? null;
return {
bid_summary_id: `bid-summary-${q.qt_id}`,
status: q.status === QuotationStatus.CLOSED ? '입찰종료 (COMPLETED)' : '입찰활성화 (ACTIVE)',
qt_iteration: q.iteration ?? 1,
has_preferred: !!winnerId,
preferred_sp_id: winnerId,
preferred_sp_name: q.preferred_sp_name || (winnerId ? partners.find((p) => p.id === winnerId)?.name || '-' : '-'),
equal_data: typeof q.equal_bid_data === 'string' ? q.equal_bid_data : '-',
// ── 견적 결과 요약(목표가 대비 낙찰/절감) ─────────────────────────────────
// DB 저장값이 아니라 이미 로드된 quotation + 세션들로 파생한다(별도 컬럼·엔드포인트 없음).
// · 목표가 = md_price 우선(없으면 세션 목표가)
// · 낙찰가 = 우선협상자 세션의 투찰가(미낙찰이면 현재 최저 투찰가 = 잠정)
// · 절감 = 목표가 낙찰가 (양수 = 목표보다 저렴하게 낙찰)
export type QuotationResultView = {
outcome: ChainRoundState;
closeReason: string;
targetPrice: number | null;
targetBasis: 'md' | 'session' | null;
winnerName: string | null;
winnerPrice: number | null;
provisional: boolean; // true = 미낙찰 상태에서 보여주는 '현재 최저 투찰가'
lowestBid: number | null; // 투찰가 최소 — 스펙트럼 '최저 투찰가'
highestBid: number | null; // 투찰가 최대 — 스펙트럼 '최고 투찰가'(협상 시작점)
savings: number | null;
savingsRate: number | null;
};
export function buildQuotationResult(q: QuotationData, sessions: SessionView[]): QuotationResultView {
const winnerId = q.preferred_sp_id ?? null;
const winnerSession = winnerId ? sessions.find((s) => s.supplier_id === winnerId) ?? null : null;
// 투찰가 있는 세션들 중 최저 = 잠정 낙찰가(우선협상자 미확정 시).
const bids = sessions.map((s) => s.bid_price).filter((v): v is number => v != null);
const lowestBid = bids.length ? Math.min(...bids) : null;
const highestBid = bids.length ? Math.max(...bids) : null;
const winnerPrice = winnerSession?.bid_price ?? lowestBid;
const provisional = !winnerSession;
const sessionTarget =
winnerSession?.target_price || sessions.find((s) => s.target_price > 0)?.target_price || null;
const targetPrice = q.md_price ?? sessionTarget;
const targetBasis: QuotationResultView['targetBasis'] =
q.md_price != null ? 'md' : sessionTarget != null ? 'session' : null;
const savings = targetPrice != null && winnerPrice != null ? targetPrice - winnerPrice : null;
const savingsRate = savings != null && targetPrice ? savings / targetPrice : null;
return {
outcome: chainRoundState(q),
closeReason: quotationCloseReason(q, sessions),
targetPrice,
targetBasis,
winnerName: q.preferred_sp_name || winnerSession?.supplier_name || null,
winnerPrice,
provisional,
lowestBid,
highestBid,
savings,
savingsRate,
};
}
// 마감 사유 라벨. 서버 close_reason(CloseReason) 단일 근거로 표기.
export const CLOSE_REASON_LABEL: Record<CloseReason, 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 / 사용 카드) ──────────────

View File

@ -249,6 +249,9 @@ CREATE TABLE IF NOT EXISTS quotation.quotation_settings (
target_margin_rate NUMERIC(8,6) NOT NULL, -- 목표 마진율 (정수부 2자리 + 소수 6자리, -99.999999~99.999999)
anchoring_value NUMERIC(8,6) NOT NULL DEFAULT 0.01, -- 앵커링 값 (정수부 2자리 + 소수 6자리)
card_count INTEGER NOT NULL DEFAULT 3, -- 한개의 협상 안에서 협상카드 사용 횟수
mid_action SMALLINT NOT NULL DEFAULT 1, -- 마감 가격정책(PriceGateAction): 앵커링가<투찰가≤목표가 처리(1=낙찰/2=재협상/3=유찰)
over_action SMALLINT NOT NULL DEFAULT 1, -- 마감 가격정책(PriceGateAction): 목표가<투찰가 처리(1=낙찰/2=재협상/3=유찰). 투찰가≤앵커링가는 항상 낙찰(설정없음)
regen_limit SMALLINT NOT NULL DEFAULT 1, -- 재생성 최대 횟수(체인 전체 총합, 사유 무관: 목표초과/동가/미참여 합산)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
@ -278,6 +281,7 @@ CREATE TABLE IF NOT EXISTS quotation.quotations (
preferred_sp_name VARCHAR(20) NULL, -- 선호 공급사명(스냅샷)
equal_bid_yn BOOLEAN NULL, -- 동일가 입찰 발생 여부
equal_bid_data JSONB NULL, -- 동일가 입찰 상세(JSON)
close_reason SMALLINT NULL, -- 마감 사유(CloseReason): 1=낙찰,2=가격재협상,3=동가재입찰,4=미참여재소집,5=가격유찰,6=동가유찰,7=미참여유찰,8=거부유찰. 미마감이면 NULL
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부