Merge branch 'main' into design/frontend

This commit is contained in:
민헌 2026-07-07 09:18:30 +09:00
commit d9191420c1
58 changed files with 824 additions and 876 deletions

View File

@ -124,7 +124,7 @@ class suppliers(MainTableMixin, MAIN_BASE):
manager_name = Column(String(50), nullable=True)
manager_email = Column(String(255), nullable=True)
manager_contact_number = Column(String(20), nullable=True) # ERD 오타(manger) 교정
priority = Column(String(10), nullable=True) # True/False 가 아닌 string value 가능
total_revenue = Column(BigInteger, nullable=True) # 총매출액(원)
class nego_cards(MainTableMixin, MAIN_BASE):
@ -190,13 +190,9 @@ class quotation_settings(MainTableMixin, MAIN_BASE):
qt_setting_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 설정 소유 유저
target_margin_rate = Column(Numeric(8, 6), nullable=False)
anchoring_value = Column(Numeric(8, 6), nullable=False, default=0.01)
target_margin_rate = Column(Numeric(8, 6), nullable=False) # 목표 마진율(목표가 산정에 사용)
card_count = Column(Integer, nullable=False, default=3)
# 마감 가격정책. 투찰가≤앵커링가는 항상 낙찰(설정없음). 아래 두 구간만 회사가 정한다. 기본 AWARD=현재 '무조건 낙찰' 동작(하위호환).
mid_action = Column(SmallInteger, nullable=False, default=1) # PriceGateAction: 앵커링가<투찰가≤목표가 처리(1=낙찰/2=재협상/3=유찰)
over_action = Column(SmallInteger, nullable=False, default=1) # PriceGateAction: 목표가<투찰가 처리(1=낙찰/2=재협상/3=유찰)
regen_limit = Column(SmallInteger, nullable=False, default=1) # 재생성 최대 횟수(체인 전체 총합, 사유 무관: 목표초과/동가/미참여 합산)
# 낙찰 가격정책(mid/over/regen)은 견적 단위로 이관, 앵커링은 칸 rate(anchoring v1.2)로 대체 → 세팅 컬럼 제거됨.
class quotations(MainTableMixin, MAIN_BASE):
@ -231,6 +227,11 @@ class quotations(MainTableMixin, MAIN_BASE):
equal_bid_data = Column(JSONB, nullable=True)
close_reason = Column(SmallInteger, nullable=True) # CloseReason 코드. 마감 시 사유 기록(재생성 한도 카운팅·유찰 사유 구분). 미마감이면 NULL
# 낙찰 기준(가격게이트) — 견적 단위. 마감 판정(close_and_decide)이 이 행값을 읽는다. 기준 미달이면 개찰(낙찰자 미정 마감).
# 1:1 협상: over 는 항상 OPEN(목표 초과=개찰), mid 만 앵커/목표 택1. 1:N 경매: mid=over=AWARD 강제(무조건 최저가 낙찰).
mid_action = Column(SmallInteger, nullable=False, server_default=text("1"), default=1) # PriceGateAction: 앵커링가<투찰가≤목표가 처리(1=낙찰/2=개찰)
over_action = Column(SmallInteger, nullable=False, server_default=text("1"), default=1) # PriceGateAction: 목표가<투찰가 처리(1=낙찰/2=개찰)
class sessions(MainTableMixin, MAIN_BASE):
__tablename__ = "sessions"

View File

@ -143,6 +143,12 @@ class QuotationType(CodeEnum):
"""신규(NEW_*) 견적유형이면 True. 목표가 후보(신규=인터넷최저가만)가 이 분기에 의존하므로 한 곳에서만 판단한다."""
return code in (cls.NEW_NEGO.value, cls.NEW_QUOTE.value)
@classmethod
def is_auction(cls, code) -> bool:
"""1:N 경매(REQUOTE/NEW_QUOTE)면 True. 경매는 낙찰 가격정책 없이 '무조건 최저가 낙찰'(mid=over=AWARD 강제).
나머지(RENEGO/NEW_NEGO) 1:1 협상 사용자가 낙찰 기준을 정하고 협상카드가 발동한다."""
return code in (cls.REQUOTE.value, cls.NEW_QUOTE.value)
class QuotationStatus(CodeEnum):
"""quotations.status 코드값(SMALLINT). 프론트 견적상태 뱃지와 매핑된다."""
@ -165,33 +171,30 @@ class SessionStatus(CodeEnum):
class CloseOutcome(Enum):
"""견적 마감 판정 결과(close_and_decide 반환값). 내부 제어·로그용 — DB 저장/프론트 노출 안 함."""
AWARDED = "awarded" # 단독 낙찰 확정
REGENERATED = "regenerated" # 다음 라운드 재생성
CLOSED = "closed" # 그냥 마감 (선점 실패로 이미 닫혀 있던 경우 포함)
REGEN_FAILED = "regen_failed" # 재생성 시도했으나 실패 — 원본은 CLOSED 인데 다음 라운드가 없음(체인 끊김, 모니터링 필요)
AWARDED = "awarded" # 낙찰 확정(기준 충족 단독 최저가)
OPENED = "opened" # 개찰 — 낙찰자 미정으로 마감(자동 재협상/재생성·결렬 없음, 담당자 수동 처리)
CLOSED = "closed" # 그냥 마감 (선점 실패로 이미 닫혀 있던 경우 포함)
class CloseReason(CodeEnum):
"""quotations.close_reason 코드값(SMALLINT). 마감 사유 — 재생성 한도 카운팅(REGEN_*)과 유찰 사유 구분에 쓴다.
기존 preferred_sp_yn/equal_bid_yn 2플래그로는 4상태만 표현돼 '목표초과 재협상' 미참여와 충돌하고 유찰 사유가 뭉개짐 컬럼으로 명시."""
"""quotations.close_reason 코드값(SMALLINT). 마감 사유 — 낙찰(AWARDED) 또는 개찰(OPEN_*)로 가른다.
개찰=결렬(유찰) 아니라 '낙찰자 미정으로 마감' 자동 재협상/재생성 없이 담당자가 수동 처리(수동 재생성 )한다.
( 자동재협상 사유 REGEN_*(2~4)·유찰 개념은 폐지. 사유 플래그 5~8 보존해 OPEN_* 재명명.)"""
AWARDED = 1 # 단독 낙찰
REGEN_PRICE = 2 # 가격 사유 재협상 (단독 최저가가 가격게이트 초과 → 낙찰 대신 다음 라운드로 더 깎기. 대표: 목표초과 재협상)
REGEN_EQUAL = 3 # 동가 재입찰
REGEN_NOSHOW = 4 # 미참여 재소집
FAIL_PRICE = 5 # 가격 사유 유찰 (가격게이트 초과인데 재협상 안 함/한도 소진)
FAIL_EQUAL = 6 # 동가 유찰
FAIL_NOSHOW = 7 # 미참여 유찰 (한도 소진)
FAIL_REJECT = 8 # 거부 유찰 (협상거부 존재)
AWARDED = 1 # 낙찰 (기준 충족 단독 최저가)
OPEN_PRICE = 5 # 개찰: 최저가가 낙찰 기준 미달(목표 초과 등) → 낙찰자 미정
OPEN_EQUAL = 6 # 개찰: 동가(최저가 동점) → 낙찰자 미정
OPEN_NOSHOW = 7 # 개찰: 전원 미응찰
OPEN_REJECT = 8 # 개찰: 협상거부 존재
class PriceGateAction(CodeEnum):
"""quotation_settings 의 가격 구간별 처리 정책. '앵커링가<투찰가≤목표가'(mid) / '목표가<투찰가'(over) 구간에 적용.
(투찰가앵커링가 항상 낙찰이라 설정 없음.)"""
"""낙찰 기준(견적 단위) 가격게이트 판정값. '앵커링가<투찰가≤목표가'(mid) / '목표가<투찰가'(over) 구간에 적용.
(투찰가앵커링가 항상 낙찰.) 기준 미달이면 개찰(낙찰자 미정 마감) 재협상·결렬 없음.
1:1 협상: over 항상 OPEN(목표 초과는 개찰), mid 앵커/목표 선택. 1:N 경매: mid=over=AWARD(무조건 최저가 낙찰)."""
AWARD = 1 # 낙찰
RENEGO = 2 # 재협상(다음 라운드 재생성)
FAIL = 3 # 유찰
AWARD = 1 # 낙찰(자동)
OPEN = 2 # 개찰(낙찰자 미정 마감)
class NotificationType(CodeEnum):

View File

@ -425,15 +425,11 @@ class QuotationCRUD(IQuotationCRUD):
return ErrorType.DB_RUN_FAILED, None
async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]:
"""견적 세팅의 율+마감정책: {margin, anchoring, mid_action, over_action, regen_limit}.
목표가·앵커링가 산정 입력 + 마감 가격게이트 정책. (인터넷 수수료는 상수)"""
"""견적 세팅의 목표 마진율: {margin}. 목표가 산정 입력(인터넷 수수료는 상수).
(낙찰 정책은 견적 단위 이관, 앵커링은 rate v1.2 세팅 컬럼 제거됨.)"""
try:
query = select(
quotation_settings.target_margin_rate,
quotation_settings.anchoring_value,
quotation_settings.mid_action,
quotation_settings.over_action,
quotation_settings.regen_limit,
).where(quotation_settings.qt_setting_id == qt_setting_id).limit(1)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
@ -443,10 +439,6 @@ class QuotationCRUD(IQuotationCRUD):
r = rows[0]
return ErrorType.SUCCESS, {
"margin": float(r[0]) if r[0] is not None else None,
"anchoring": float(r[1]) if r[1] is not None else None,
"mid_action": r[2],
"over_action": r[3],
"regen_limit": r[4],
}
except Exception as ex:
LOG.e_no_callstack(ex)

View File

@ -14,7 +14,7 @@ from common.utils.gtime import GTime
# 협력사 CRUD. 모든 조회/변경은 company_id 로 스코프된다(멀티테넌트).
class ISupplierCRUD(ABC):
@abstractmethod
async def search(self, cdb: AsyncSession, company_id, search, priority, skip, limit) -> Tuple[ErrorType, list, int]:
async def search(self, cdb: AsyncSession, company_id, search, skip, limit) -> Tuple[ErrorType, list, int]:
pass
@abstractmethod
@ -44,7 +44,7 @@ class ISupplierCRUD(ABC):
class SupplierCRUD(ISupplierCRUD):
async def search(
self, cdb: AsyncSession, company_id, search: Optional[str], priority: Optional[str], skip: int, limit: int
self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int
) -> Tuple[ErrorType, list, int]:
try:
conditions = [suppliers.deleted == False, suppliers.company_id == company_id] # noqa: E712
@ -56,8 +56,6 @@ class SupplierCRUD(ISupplierCRUD):
suppliers.manager_name.ilike(f"%{search}%"),
)
)
if priority:
conditions.append(suppliers.priority == priority)
where = and_(*conditions)
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(suppliers).where(where))

View File

@ -30,6 +30,10 @@ class Req_CreateQuotation(QuotationProtocol):
item_ids: list[uuid.UUID] = [] # 협상 대상 상품. item×supplier 조합마다 세션 1개 생성
supplier_ids: list[uuid.UUID] = [] # 협상 초청 공급사
card_ids: list[uuid.UUID] = [] # 선택 협상카드. 버전을 만들어 묶고 quotation.version_id 로 연결
# 낙찰 기준 — 1:1 협상만 프론트가 2전략(앵커까지/목표까지)을 mid/over 로 전개해 전송(over 는 항상 OPEN).
# 1:N 경매는 미전송 → 서버가 mid=over=AWARD 강제('무조건 최저가 낙찰').
mid_action: Optional[int] = None # PriceGateAction: 앵커링가<투찰가≤목표가 처리
over_action: Optional[int] = None # PriceGateAction: 목표가<투찰가 처리
class Req_RegenerateQuotation(QuotationProtocol):
@ -63,6 +67,8 @@ class QuotationData(WebPacketProtocol):
equal_bid_yn: Optional[bool] = None
equal_bid_data: Optional[Any] = None
close_reason: Optional[CloseReason] = None # 마감 사유(CloseReason). 미마감이면 None
mid_action: Optional[int] = None # 낙찰 기준(견적 단위). 상세 드로어 낙찰기준 표시용
over_action: Optional[int] = None
participation_count: int = 0 # 견적별 참여 협력사 수(세션 distinct supplier). 목록 집계로 채움.
item_id: Optional[uuid.UUID] = None # 대표 상품 id(세션의 첫 item). 목록 조인으로 채움.
item_name: Optional[str] = None # 대표 상품명. 목록 조인으로 채움.
@ -199,7 +205,7 @@ class Res_TargetBreakdown(Res_WebPacketProtocol):
selling: Optional[int] = None
fee: float = 0.0
margin: float = 0.0
anchoring_value: float = 0.0
anchoring_value: float = 0.0 # 앵커링율(비율). 세션 앵커링값(‰)을 /1000 환산 — main 프론트 표시용
candidates: list[TargetCandidate] = []
chosen_basis: Optional[str] = None
target_price: int = 0

View File

@ -4,7 +4,6 @@ from typing import Optional
from pydantic import ConfigDict
from common.enums import PriceGateAction
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
@ -12,22 +11,15 @@ class QuotationSettingProtocol(WebPacketProtocol):
pass
# 세팅은 목표 마진율·카드수만. 낙찰 정책(mid/over/regen)은 견적 단위 이관, 앵커링은 칸 rate(v1.2)로 대체 → 컬럼 제거됨.
class Req_CreateQuotationSetting(QuotationSettingProtocol):
target_margin_rate: float
anchoring_value: float = 0.01
card_count: int = 3
mid_action: PriceGateAction = PriceGateAction.AWARD # 앵커링가<투찰가≤목표가 마감 처리
over_action: PriceGateAction = PriceGateAction.AWARD # 목표가<투찰가 마감 처리
regen_limit: int = 1 # 재생성 최대 횟수(체인 전체, 사유 무관)
class Req_UpdateQuotationSetting(QuotationSettingProtocol):
target_margin_rate: Optional[float] = None
anchoring_value: Optional[float] = None
card_count: Optional[int] = None
mid_action: Optional[PriceGateAction] = None
over_action: Optional[PriceGateAction] = None
regen_limit: Optional[int] = None
class QuotationSettingData(WebPacketProtocol):
@ -36,11 +28,7 @@ class QuotationSettingData(WebPacketProtocol):
qt_setting_id: uuid.UUID
user_id: Optional[uuid.UUID] = None
target_margin_rate: float
anchoring_value: float
card_count: int
mid_action: PriceGateAction = PriceGateAction.AWARD
over_action: PriceGateAction = PriceGateAction.AWARD
regen_limit: int = 1
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None

View File

@ -17,7 +17,7 @@ class Req_CreateSupplier(SupplierProtocol):
manager_name: Optional[str] = None
manager_email: Optional[str] = None
manager_contact_number: Optional[str] = None
priority: Optional[str] = None
total_revenue: Optional[int] = None # 총매출액(원)
class Req_UpdateSupplier(SupplierProtocol):
@ -26,7 +26,7 @@ class Req_UpdateSupplier(SupplierProtocol):
manager_name: Optional[str] = None
manager_email: Optional[str] = None
manager_contact_number: Optional[str] = None
priority: Optional[str] = None
total_revenue: Optional[int] = None
class SupplierData(WebPacketProtocol):
@ -40,7 +40,7 @@ class SupplierData(WebPacketProtocol):
manager_name: Optional[str] = None
manager_email: Optional[str] = None
manager_contact_number: Optional[str] = None
priority: Optional[str] = None
total_revenue: Optional[int] = None # 총매출액(원)
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None

View File

@ -24,10 +24,9 @@ async def list_suppliers(
service: SupplierService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
search: str | None = Query(None, description="협력사명/코드/담당자명 검색"),
priority: str | None = Query(None, description="우선순위 필터(HIGH/MEDIUM/LOW)"),
pg: PageParams = Depends(),
):
return RemoveNoneResponse(await service.list_suppliers(user_info.company_id, search, priority, pg))
return RemoveNoneResponse(await service.list_suppliers(user_info.company_id, search, pg))
@router.post(path="/create", response_model=Res_Supplier, summary="협력사 등록")

View File

@ -31,15 +31,15 @@ async def _close_each(service: QuotationService, qt_ids) -> Counter:
def _format_results(results: Counter) -> str:
return (
f"낙찰 {results[CloseOutcome.AWARDED]} / 재생성 {results[CloseOutcome.REGENERATED]} / "
f"재생성실패 {results[CloseOutcome.REGEN_FAILED]} / 마감 {results[CloseOutcome.CLOSED]} / 오류 {results['error']}"
f"낙찰 {results[CloseOutcome.AWARDED]} / 개찰 {results[CloseOutcome.OPENED]} / "
f"마감 {results[CloseOutcome.CLOSED]} / 오류 {results['error']}"
)
async def close_expired_quotations() -> int:
"""[잡①] 마감일이 지난 견적을 자동 마감 처리한다. 하루 한 번 실행.
대상: 마감 시각이 이미 지났는데 아직 마감되지 않은(삭제되지도 않은) 견적.
처리: 견적마다 close_and_decide 결과 판정(낙찰 확정 / 다음 라운드 재생성 / 그냥 마감).
처리: 견적마다 close_and_decide 결과 판정(낙찰 확정 / 개찰=낙찰자 미정 마감).
반환: 처리한 견적 ."""
crud = QuotationCRUD()
service = QuotationService(crud)

View File

@ -1,7 +1,7 @@
-- 견적상세 UI 케이스별 데모 시드 (dev DB: negosium_db)
-- 안전: 아래 데모 qt_id / 그 세션만 지우고 다시 넣는다(다른 데이터 무손상). 재실행 가능.
-- user=모모나(b874f33d) / setting=40614764 / version=...030 / item=고압 에어 컴프레서(a3d1fff5)
-- close_reason(CloseReason): 1=낙찰,2=가격재협상,3=동가재입찰,4=미참여재소집,5=가격유찰,6=동가유찰,7=미참여유찰,8=거부유찰
-- close_reason(CloseReason): 1=낙찰, 5=가격개찰, 6=동가개찰, 7=미응찰개찰, 8=거부개찰 (개찰=낙찰자 미정 마감, 결렬 아님)
BEGIN;
DELETE FROM negotiation.sessions WHERE quotation_id IN (
@ -29,7 +29,7 @@ DELETE FROM quotation.quotations WHERE qt_id IN (
'aaaa0010-0000-0000-0000-000000000010'
);
-- ── 견적 10건 (close_reason 8종 전부 + 진행중) ────────────────────────────
-- ── 견적 10건 (close_reason 5종: 낙찰+개찰4 + 진행중) ────────────────────────────
INSERT INTO quotation.quotations
(qt_id, user_id, qt_setting_id, version_id, name, number, type, round, status,
start_time, end_time, manager_name, manager_email, manager_contact_number, memo,
@ -53,25 +53,25 @@ VALUES
-- ③ 동가 재입찰(REGEN_EQUAL): 두 곳 390,000 동률 → 재입찰 진행
('aaaa0003-0000-0000-0000-000000000003','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
'③ 동가 재입찰 데모','EST-DEMO-03',2,1,3,
'③ 동가 찰 데모','EST-DEMO-03',2,1,3,
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','동가 발생·다음 라운드 재입찰 케이스',
1, false, null, null, true,
'[{"supplier_id":"9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09","supplier_name":"(주)대한정밀","bid_price":390000},{"supplier_id":"747897ec-adab-4f3e-9076-15803ddf821f","supplier_name":"글로벌테크놀로지","bid_price":390000}]'::jsonb,
3, 400000, 1, now(), now(), false),
6, 400000, 1, now(), now(), false),
-- ④ 거부 유찰(FAIL_REJECT): 협상거부로 마감
('aaaa0004-0000-0000-0000-000000000004','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
'④ 거부 찰 데모','EST-DEMO-04',2,1,3,
'④ 거부 찰 데모','EST-DEMO-04',2,1,3,
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','협상 거부로 유찰 케이스',
1, null, null, null, null, null,
8, 400000, 1, now(), now(), false),
-- ⑤ 미참여 재소집(REGEN_NOSHOW): 전원 미참여 → 다음 라운드 재소집
('aaaa0005-0000-0000-0000-000000000005','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
'⑤ 미참여 재소집 데모','EST-DEMO-05',1,1,3,
'⑤ 미응찰 개찰 데모','EST-DEMO-05',1,1,3,
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','전원 미참여·다음 라운드 재소집 케이스',
1, false, null, null, false, null,
4, 400000, 1, now(), now(), false),
7, 400000, 1, now(), now(), false),
-- ⑥ 진행중: 일부 투찰 완료, 아직 안 닫힘 (close_reason NULL)
('aaaa0006-0000-0000-0000-000000000006','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
@ -82,21 +82,21 @@ VALUES
-- ⑦ 가격 재협상(REGEN_PRICE): 단독 최저가 420,000 > 목표 350,000 → 재협상 진행
('aaaa0007-0000-0000-0000-000000000007','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
'가격 재협상 데모','EST-DEMO-07',4,1,3,
'목표초과 개찰 데모','EST-DEMO-07',4,1,3,
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','목표 초과 → 재협상(더 깎기) 진행 케이스',
1, false, null, null, false, null,
2, 350000, 1, now(), now(), false),
5, 350000, 1, now(), now(), false),
-- ⑧ 가격 유찰(FAIL_PRICE): 단독 최저가 430,000 > 목표 350,000, over_action=유찰(또는 한도소진) → 유찰
('aaaa0008-0000-0000-0000-000000000008','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
'가격 유찰 데모','EST-DEMO-08',4,1,3,
'목표초과 개찰 데모(2)','EST-DEMO-08',4,1,3,
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','목표 초과 → 유찰 케이스',
1, false, null, null, false, null,
5, 350000, 1, now(), now(), false),
-- ⑨ 동가 유찰(FAIL_EQUAL): 동가지만 재입찰 한도 소진/유찰 정책 → 유찰
('aaaa0009-0000-0000-0000-000000000009','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
'⑨ 동가 유찰 데모','EST-DEMO-09',2,2,3,
'⑨ 동가 개찰 데모(2)','EST-DEMO-09',2,2,3,
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','동가지만 재입찰 한도 소진 → 유찰 케이스',
1, false, null, null, true,
'[{"supplier_id":"9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09","supplier_name":"(주)대한정밀","bid_price":405000},{"supplier_id":"69e7057a-5919-48cd-b329-13b833bff994","supplier_name":"서울산업소재(주)","bid_price":405000}]'::jsonb,
@ -104,7 +104,7 @@ VALUES
-- ⑩ 미참여 유찰(FAIL_NOSHOW): 전원 미참여 + 재소집 한도 소진 → 유찰
('aaaa0010-0000-0000-0000-000000000010','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
'⑩ 미참여 유찰 데모','EST-DEMO-10',1,2,3,
'⑩ 미응찰 개찰 데모(2)','EST-DEMO-10',1,2,3,
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','전원 미참여 + 재소집 한도 소진 → 유찰 케이스',
1, false, null, null, false, null,
7, 400000, 1, now(), now(), false);

View File

@ -54,9 +54,6 @@ class QuotationService:
# 기본 전략 버전(card.versions 시드). 견적 생성 시 version_id 미지정이면 이 값으로 채운다.
DEFAULT_VERSION_ID = uuid.UUID("00000000-0000-0000-0000-000000000030")
# 재생성 한도: 한 체인(같은 견적번호)에서 사유(미참여/동가)별 최대 1번까지 재생성(순서 무관, 같은 사유 2번 불가).
MAX_REGEN_PER_CAUSE = 1
# 재생성 라운드의 최소 협상기간(방어적 하한). 원본 협상기간이 비정상적으로 짧으면(또는 0/음수면)
# 새 라운드가 생성 즉시 만료돼 다음 크론 tick(*/5분)에 또 마감되는 연쇄를 막는다.
# 정상 견적(수 시간~수일)은 원본 기간을 그대로 쓰며, 이 하한은 비정상적으로 짧은 경우에만 적용된다.
@ -168,7 +165,6 @@ class QuotationService:
rates = rates or {}
fee = self.INTERNET_AVERAGE_FEE
margin = rates.get("margin") or 0.0
anchoring = rates.get("anchoring") or 0.0
is_new = QuotationType.is_new(quotation.type)
md = quotation.md_price
@ -187,11 +183,11 @@ class QuotationService:
res.selling = int(selling) if selling is not None else None
res.fee = fee
res.margin = margin
res.anchoring_value = anchoring
res.candidates = [TargetCandidate(basis=b, label=self._CANDIDATE_LABELS.get(b, b), value=int(v)) for b, v in cands]
res.chosen_basis = None if is_inherited else chosen_basis
res.target_price = sess.target_price
res.anchoring_price = sess.anchoring_price
res.anchoring_value = (sess.anchoring_value or 0) / 1000 # 세션 ‰ → 비율(main 프론트 '목표가×(1값)' 표시용)
return res
async def list_quotations(self, company_id, owner, search, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList:
@ -298,6 +294,8 @@ class QuotationService:
item_ids=req.item_ids,
supplier_ids=req.supplier_ids,
card_ids=req.card_ids,
mid_action=req.mid_action,
over_action=req.over_action,
)
if res.result.success:
await create_notification(
@ -375,6 +373,8 @@ class QuotationService:
item_ids=item_ids,
supplier_ids=list(supplier_ids),
card_ids=[], # 새 버전 안 만듦(원본 version_id 재사용)
mid_action=original.mid_action, # 낙찰 기준 상속(타입이 REQUOTE 로 바뀌면 빌더가 AWARD 로 재정규화)
over_action=original.over_action,
inherited=inherited, # 직전 라운드 목표가 상속(앵커링가는 현재 rate 로 재계산)
)
@ -384,11 +384,21 @@ class QuotationService:
type_: int, status: int, round_: int, start_time, end_time,
manager_name, manager_email, manager_contact_number, memo, md_price, supplier_type,
item_ids: list, supplier_ids: list, card_ids: list,
mid_action: Optional[int] = None, # 낙찰 기준(견적 단위). 앵커링가<투찰가≤목표가 처리(AWARD/OPEN)
over_action: Optional[int] = None, # 목표가<투찰가 처리(1:1 협상은 항상 OPEN)
inherited: Optional[dict] = None, # 재생성 시 {item_id: target_price} 상속(KTC) — 목표가만. 앵커는 항상 재계산
) -> Res_CreateQuotation:
"""견적 1건 + (상품×공급사) 세션들을 한 트랜잭션으로 생성하는 공통 빌더."""
res = Res_CreateQuotation()
# 낙찰 기준 정규화 — 1:N 경매는 항상 최저가 낙찰(mid=over=AWARD 강제). 1:1 협상은 요청값(미지정=AWARD).
# create/regenerate 양 경로가 이 빌더를 타므로 불변식을 여기 한 곳에서 강제한다(재생성 시 타입 전환도 자동 재정규화).
if QuotationType.is_auction(type_):
mid_action = over_action = PriceGateAction.AWARD.value
else:
mid_action = mid_action or PriceGateAction.AWARD.value
over_action = over_action or PriceGateAction.AWARD.value
# 세션 목표가 입력(상품별 인터넷최저가/매입가/판매가 + 세팅 율). 읽기 트랜잭션에서 먼저 조회.
prices = {}
if item_ids:
@ -455,6 +465,8 @@ class QuotationService:
memo=memo,
md_price=md_price,
supplier_type=supplier_type,
mid_action=mid_action,
over_action=over_action,
)
# 상품 × 공급사 조합마다 세션 1개. md/매입/판매/인터넷 후보가 하나도 없으면 목표가 산정 불가 → 생성 실패.
@ -557,11 +569,11 @@ class QuotationService:
@staticmethod
def _gate_action(bid, target, anchor, mid_action, over_action) -> int:
"""가격게이트 판정 → PriceGateAction 코드.
"""가격게이트 판정 → PriceGateAction 코드(AWARD=낙찰 / OPEN=개찰).
bid 앵커링가 무조건 낙찰(AWARD)
앵커링가 < bid 목표가 mid_action(설정)
목표가 < bid over_action(설정)
target/bid 없으면(설정 불완전 ) AWARD 폴백(하위호환: 기존 '무조건 낙찰')."""
앵커링가 < bid 목표가 mid_action(견적 낙찰 기준)
목표가 < bid over_action(1:1 협상은 항상 OPEN=개찰)
target/bid 없으면(산정 불가 ) AWARD 폴백(최저가 그대로 낙찰)."""
if bid is None or target is None:
return PriceGateAction.AWARD.value
bid = int(bid)
@ -571,9 +583,9 @@ class QuotationService:
return mid_action or PriceGateAction.AWARD.value
return over_action or PriceGateAction.AWARD.value
async def _close(self, qt_uuid, close_reason: int, data: dict = None) -> None:
async def _close(self, qt_uuid, close_reason: int, data: Optional[dict] = None) -> None:
"""마감 공통: status→CLOSED + close_reason 기록 + (있으면)추가데이터 + 미완료(미시작·진행중) 세션→미참여.
close_reason(CloseReason) 재생성 한도 카운팅·사유 구분의 단일 근거.
close_reason(CloseReason) 낙찰/사유 구분의 단일 근거.
preferred_sp_*/equal_bid_* 프론트 표시용으로 함께 채운다(사유 판별은 close_reason 담당)."""
payload = {"status": QuotationStatus.CLOSED.value, "close_reason": close_reason}
if data:
@ -588,28 +600,22 @@ class QuotationService:
],
)
async def _regen_and_notify(self, original, qt_uuid, supplier_ids: list, reason: str, equal=None) -> CloseOutcome:
"""마감 후 다음 라운드 재생성 + 성공 시 REGENERATED 알림. 실패 시 REGEN_FAILED(원본 CLOSED 인데 체인 끊김 → 노출)."""
regen = await self.regenerate_next_round(qt_uuid, supplier_ids)
if not regen.result.success:
LOG.e_no_callstack(
f"[close] 재생성 실패({reason}) qt={qt_uuid} number={original.number} round={original.round} "
f"code={regen.result.code}({regen.result.desc})"
)
return CloseOutcome.REGEN_FAILED
payload = {"qt_name": original.name, "qt_number": original.number, "reason": reason, "next_round": original.round + 1}
if equal is not None:
payload.update({"tied_price": equal["price"], "tied_count": len(equal["suppliers"])})
await create_notification(original.user_id, NotificationType.REGENERATED, payload, ref_qt_id=regen.qt_id)
return CloseOutcome.REGENERATED
async def _open(self, qt_uuid, original, close_reason: int, reason: str, data: Optional[dict] = None) -> CloseOutcome:
"""개찰 마감 — 낙찰자 미정으로 CLOSED + close_reason(OPEN_*) 기록 + 작성자 알림. 자동 재협상/재생성 없음(담당자 수동 처리).
결렬(유찰) 아님. 알림 코드는 유지하되 프론트에서 '개찰' 표기한다."""
await self._close(qt_uuid, close_reason, data)
await create_notification(
original.user_id, NotificationType.FAILURE,
{"qt_name": original.name, "qt_number": original.number, "reason": reason},
ref_qt_id=qt_uuid,
)
return CloseOutcome.OPENED
async def close_and_decide(self, qt_id) -> CloseOutcome:
"""[마감] 견적을 마감하며 결과 판정. 낙찰자는 협상완료 최저가(단독/동가), 그 위에 회사 가격정책(가격게이트)으로 낙찰/재협상/유찰을 가른다.
- 단독 최저가: 가격게이트(앵커 무조건낙찰 / 앵커~목표 mid_action / 목표초과 over_action) 낙찰 / 재협상(한도내) / 유찰
- 동가: 가격 구간 설정이 '유찰' 아니고 한도 남으면 재입찰(tie 해소), 유찰 설정이거나 한도 소진이면 유찰
- 협상거부: 유찰
- 전원 미참여: 재소집(한도내) / 유찰
재생성 한도: regen_limit = 체인(같은 견적번호) 전체 재생성 '' 횟수(사유 무관). 마감 사유는 close_reason 기록(카운팅·유찰사유 구분의 단일 근거).
"""[마감] 견적을 마감하며 결과 판정. 협상완료 단독 최저가가 낙찰 기준(가격게이트)을 통과할 때만 낙찰(AWARDED).
- 단독 최저가: 앵커 항상 낙찰 / 앵커~목표 mid_action / 목표초과 over_action(1:1 협상은 항상 OPEN=개찰).
- (기준 미달·동가·협상거부·전원 미응찰) 결렬(유찰) 아니라 개찰(OPEN_*) 낙찰자 미정으로 마감.
자동 재협상/재생성 없음. 다음 라운드는 담당자가 상세에서 수동 재생성(regenerate_quotation)한다.
공통: 원자적 statusCLOSED 선점, 미시작·진행중 세션미참여."""
qt_uuid = qt_id if isinstance(qt_id, uuid.UUID) else uuid.UUID(str(qt_id))
@ -617,7 +623,7 @@ class QuotationService:
if err_type != ErrorType.SUCCESS or original is None:
return CloseOutcome.CLOSED
# [동시 마감 가드] 원자적으로 status→CLOSED 선점. 실제로 전이한 호출자만 통과(이중 재생성·uq(number,round) 충돌 방지).
# [동시 마감 가드] 원자적으로 status→CLOSED 선점. 실제로 전이한 호출자만 통과.
claim_err, claimed = await DB_SESSION_MNG.execute_lambda_claim(
quotations.DBType(),
lambda s: self.quotation_crud.claim_for_close(s, qt_uuid),
@ -636,29 +642,15 @@ class QuotationService:
has_rejected = any(r.status == SessionStatus.REJECTED.value for r in rows)
winner, equal = self._pick_winner(done)
# 가격게이트 입력: 회사 정책(mid/over/limit) + 세션 목표가/앵커링가(견적당 상품 1개라 세션 공통값)
_e, rates = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.get_setting_rates(s, original.qt_setting_id),
)
rates = rates or {}
mid_action = rates.get("mid_action") or PriceGateAction.AWARD.value
over_action = rates.get("over_action") or PriceGateAction.AWARD.value
limit = rates.get("regen_limit")
limit = limit if limit is not None else self.MAX_REGEN_PER_CAUSE
# 가격게이트 입력: 견적 단위 낙찰 기준(mid/over, 생성 시점 박제) + 세션 목표가/앵커링가(견적당 상품 1개라 세션 공통값)
mid_action = original.mid_action or PriceGateAction.AWARD.value
over_action = original.over_action or PriceGateAction.AWARD.value
target = next((r.target_price for r in rows if r.target_price is not None), None)
anchor = next((r.anchoring_price for r in rows if r.anchoring_price is not None), None)
# 재생성 총 이력(체인, 사유 무관). regen_limit = 체인 전체 재생성 총 한도.
regen_used = await self._chain_regen_count(original.number, original.round)
# 1) 단독 최저가 → 가격게이트로 낙찰/재협상/유찰
# 1) 단독 최저가가 낙찰 기준 통과 → 낙찰. 미달 → 개찰(가격).
if winner is not None:
action = self._gate_action(winner["bid_price"], target, anchor, mid_action, over_action)
if action == PriceGateAction.RENEGO.value and regen_used < limit:
await self._close(qt_uuid, CloseReason.REGEN_PRICE.value, {"preferred_sp_yn": False, "equal_bid_yn": False})
return await self._regen_and_notify(original, qt_uuid, [winner["supplier_id"]], "price")
if action == PriceGateAction.AWARD.value:
await self._close(qt_uuid, CloseReason.AWARDED.value, {
"preferred_sp_yn": True, "preferred_sp_id": winner["supplier_id"],
@ -671,70 +663,20 @@ class QuotationService:
ref_qt_id=qt_uuid,
)
return CloseOutcome.AWARDED
# FAIL 이거나, RENEGO 인데 한도 소진 → 가격 유찰
await self._close(qt_uuid, CloseReason.FAIL_PRICE.value, {"preferred_sp_yn": False, "equal_bid_yn": False})
await create_notification(
original.user_id, NotificationType.FAILURE,
{"qt_name": original.name, "qt_number": original.number, "reason": "price"},
ref_qt_id=qt_uuid,
)
return CloseOutcome.CLOSED
# 개찰(가격) — 낙찰/동가 플래그는 NULL 로 둔다(대시보드 '개찰' 스코프가 preferred/equal 둘 다 NULL 로 집계).
return await self._open(qt_uuid, original, CloseReason.OPEN_PRICE.value, "price")
# 2) 동가 → 그 가격 구간 설정이 '유찰'이 아니고 한도 남으면 재입찰(tie 해소), 유찰 설정이거나 한도 소진이면 유찰.
# (동가는 단독 낙찰 불가 → AWARD 설정이어도 '재입찰로 tie 해소'가 자연스러워 기본은 재입찰=하위호환. 회사가 FAIL 로 두면 유찰.)
# 2) 동가(최저가 동점) → 개찰(동가). 낙찰자 미정. equal_bid_yn 으로 표기(대시보드 '동가' 스코프).
if equal is not None:
action = self._gate_action(equal["price"], target, anchor, mid_action, over_action)
if action != PriceGateAction.FAIL.value and regen_used < limit:
tied_ids = [uuid.UUID(sp["supplier_id"]) for sp in equal["suppliers"]]
await self._close(qt_uuid, CloseReason.REGEN_EQUAL.value,
{"preferred_sp_yn": False, "equal_bid_yn": True, "equal_bid_data": equal})
return await self._regen_and_notify(original, qt_uuid, tied_ids, "equal", equal=equal)
await self._close(qt_uuid, CloseReason.FAIL_EQUAL.value,
{"preferred_sp_yn": False, "equal_bid_yn": True, "equal_bid_data": equal})
await create_notification(
original.user_id, NotificationType.FAILURE,
{"qt_name": original.name, "qt_number": original.number, "reason": "equal"},
ref_qt_id=qt_uuid,
)
return CloseOutcome.CLOSED
return await self._open(qt_uuid, original, CloseReason.OPEN_EQUAL.value, "equal",
{"equal_bid_yn": True, "equal_bid_data": equal})
# 3) 협상거부 있음 → 유찰
# 3) 협상거부 있음 → 개찰(거부).
if has_rejected:
await self._close(qt_uuid, CloseReason.FAIL_REJECT.value)
await create_notification(
original.user_id, NotificationType.FAILURE,
{"qt_name": original.name, "qt_number": original.number, "reason": "rejected"},
ref_qt_id=qt_uuid,
)
return CloseOutcome.CLOSED
return await self._open(qt_uuid, original, CloseReason.OPEN_REJECT.value, "rejected")
# 4) 전원 미참여 → 공급사 전체 재소집(한도 남을 때)
if not done and rows and regen_used < limit:
supplier_ids = list({r.supplier_id for r in rows})
await self._close(qt_uuid, CloseReason.REGEN_NOSHOW.value, {"preferred_sp_yn": False, "equal_bid_yn": False})
return await self._regen_and_notify(original, qt_uuid, supplier_ids, "no_show")
# 5) 그 외 / 한도 도달 → 미참여 유찰
await self._close(qt_uuid, CloseReason.FAIL_NOSHOW.value, {"preferred_sp_yn": False, "equal_bid_yn": False})
await create_notification(
original.user_id, NotificationType.FAILURE,
{"qt_name": original.name, "qt_number": original.number, "reason": "closed"},
ref_qt_id=qt_uuid,
)
return CloseOutcome.CLOSED
async def _chain_regen_count(self, number: str, current_round: int) -> int:
"""체인(같은 견적번호) 이전 라운드들의 재생성 '' 횟수(사유 무관). regen_limit 은 체인 전체 총 한도다.
close_reason REGEN_*(가격/동가/미참여) 센다 낙찰(AWARDED)·유찰(FAIL_*)·미마감(NULL) 한도에 ."""
err_type, reasons = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.list_chain_close_reasons(s, number, current_round),
)
if err_type != ErrorType.SUCCESS:
return 0
regen = {CloseReason.REGEN_PRICE.value, CloseReason.REGEN_EQUAL.value, CloseReason.REGEN_NOSHOW.value}
return sum(1 for c in reasons if c in regen)
# 4) 전원 미응찰 → 개찰(미응찰).
return await self._open(qt_uuid, original, CloseReason.OPEN_NOSHOW.value, "no_show")
async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list) -> Res_CreateQuotation:
"""[프론트] 마감된 견적을 골라 수동으로 다음 라운드를 생성한다.

View File

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

View File

@ -38,14 +38,14 @@ class SupplierService:
return ErrorType.SUPPLIER_NOT_FOUND, None
return ErrorType.SUCCESS, supplier
async def list_suppliers(self, company_id: str, search, priority, pg: PageParams) -> Res_SupplierList:
async def list_suppliers(self, company_id: str, search, pg: PageParams) -> Res_SupplierList:
res = Res_SupplierList(page=pg.page, size=pg.size)
company_uuid = uuid.UUID(company_id)
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
suppliers.DBType(),
DBWRType.DB_READ.value,
lambda s: self.supplier_crud.search(s, company_uuid, search, priority, pg.skip, pg.size),
lambda s: self.supplier_crud.search(s, company_uuid, search, pg.skip, pg.size),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
@ -105,7 +105,7 @@ class SupplierService:
manager_name=req.manager_name,
manager_email=req.manager_email,
manager_contact_number=req.manager_contact_number,
priority=req.priority,
total_revenue=req.total_revenue,
)
err_type = await DB_SESSION_MNG.execute_lambda_run(
[suppliers.DBType()],

View File

@ -1,17 +1,15 @@
"""close_and_decide 동시성·정합성 수정 검증 (코드리뷰 후속).
"""close_and_decide 마감 판정 검증 — 낙찰(AWARDED) 또는 개찰(OPEN_*, 낙찰자 미정 마감).
검증 대상:
- #2 동시 이중 마감 가드: 같은 견적을 동시에 close_and_decide 해도 다음 라운드는 1개만 생성
- #3 차수 매김: 다음 라운드 round = 체인 최신 round + 1
- #4 재생성 사유 집계: 단독낙찰(preferred_sp_yn=True) 이전 라운드를 '미참여'로 오집계하지 않음
- #6 재생성 라운드 최소 협상기간 하한(즉시 재마감 캐스케이드 방지)
- 동시 이중 마감 가드: 같은 견적을 동시에 close_and_decide 해도 실제 마감 전이는 1번만
- 낙찰 기준 게이트: 단독 최저가가 기준 통과면 낙찰, 미달이면 개찰(가격)
- 동가 / 협상거부 / 전원 미응찰 각각 개찰(OPEN_EQUAL / OPEN_REJECT / OPEN_NOSHOW)
용어: 체인 = 같은 견적번호(number) 이어지는 라운드들 / 미참여 = 공급사가 협상에 들어온 마감됨 /
재생성 = 결판 견적의 '다음 라운드' 자동 생성 / 재생성 한도 = 사유(미참여·동가)별로 체인당 1번까지만.
용어: 개찰 = 낙찰자 미정으로 마감(결렬 아님). 자동 재협상/재생성 없음 다음 라운드는 담당자가 수동 재생성.
"""
import asyncio
import uuid
from datetime import datetime, timedelta
from datetime import datetime
import pytest_asyncio
from sqlalchemy import text
@ -30,149 +28,125 @@ async def clean(db_engine):
return db_engine
async def test_concurrent_close_creates_only_one_next_round(clean):
"""검증: 같은 견적(전원 미참여)을 5번 동시에 close_and_decide.
기대결과: 재생성은 1번만(REGENERATED=1), 체인은 [1,2] 이중 재생성/충돌 없음."""
async def test_concurrent_close_single_transition(clean):
"""검증: 전원 미응찰 견적을 5번 동시에 close_and_decide.
기대결과: 실제 마감 전이(OPENED) 1번만·나머지는 no-op(CLOSED), 체인 [1](자동 재생성 없음), close_reason=OPEN_NOSHOW."""
engine = clean
number = "C-CONCURRENT"
qt = await _seed_quotation(engine, number=number, round_=1, status=QuotationStatus.IN_PROGRESS.value)
# 전원 미참여(미시작 세션만) → close_and_decide 가 '다음 라운드 재생성' 경로를 탄다
# 전원 미응찰(미시작 세션만) → 개찰(미응찰) 경로
await _add_session(engine, qt, status=SessionStatus.CREATED.value)
await _add_session(engine, qt, status=SessionStatus.CREATED.value)
service = QuotationService(QuotationCRUD())
outcomes = await asyncio.gather(*[service.close_and_decide(qt) for _ in range(5)])
regenerated = sum(1 for o in outcomes if o == CloseOutcome.REGENERATED)
opened = sum(1 for o in outcomes if o == CloseOutcome.OPENED)
rounds = await _rounds(engine, number)
round_numbers = [r.round for r in rounds]
assert regenerated == 1, f"재생성은 1번만 일어나야 함, 실제 {regenerated} ({outcomes})"
assert round_numbers == [1, 2], f"체인은 [1,2] 여야 함(중복/충돌 없음), 실제 {round_numbers}"
assert opened == 1, f"실제 마감 전이는 1번만이어야 함, 실제 {opened} ({outcomes})"
assert [r.round for r in rounds] == [1], "자동 재생성 없음 — round 2 가 생기면 안 됨"
assert rounds[0].close_reason == CloseReason.OPEN_NOSHOW.value
async def test_next_round_numbering_and_min_duration(clean):
"""검증: 협상기간이 0인 견적을 미참여로 재생성.
기대결과: 체인 [1,2](round=최신+1), 라운드 협상기간 MIN_REGEN_DURATION(즉시 재마감 방지)."""
async def test_single_lowest_meets_target_awarded(clean):
"""검증: 단독 최저가(bid=100)가 목표가(200) 이내 · 목표까지 낙찰(mid=AWARD).
기대결과: AWARDED 협력사로 낙찰(preferred_sp_yn=True), close_reason=AWARDED."""
engine = clean
number = "C-DURATION"
# start==end (협상기간 0) → 하한이 적용되지 않으면 새 라운드도 0 길이가 된다
qt = await _seed_quotation(
engine, number=number, round_=1, status=QuotationStatus.IN_PROGRESS.value,
start_time=PAST, end_time=PAST,
)
await _add_session(engine, qt, status=SessionStatus.CREATED.value)
service = QuotationService(QuotationCRUD())
outcome = await service.close_and_decide(qt)
assert outcome == CloseOutcome.REGENERATED
rounds = await _rounds(engine, number)
assert [r.round for r in rounds] == [1, 2]
nxt = rounds[1]
duration = nxt.end_time - nxt.start_time
assert duration >= QuotationService.MIN_REGEN_DURATION, (
f"재생성 라운드 협상기간({duration})이 최소 하한({QuotationService.MIN_REGEN_DURATION}) 이상이어야 함"
)
async def test_awarded_prior_round_not_counted_as_no_show(clean):
"""검증: round1=단독낙찰 + round2=전원 미참여 인 체인에서 round2 를 마감.
기대결과: REGENERATED, 체인 [1,2,3] 단독낙찰 라운드를 '미참여' 오집계해 재생성을 막지 않는다."""
engine = clean
number = "C-AWARDED-PRIOR"
# round 1: 단독낙찰로 마감(close_reason=AWARDED). 수동 재생성 등으로 체인이 이어진 상황을 가정.
await _seed_quotation(
engine, number=number, round_=1, status=QuotationStatus.CLOSED.value,
preferred_sp_yn=True, equal_bid_yn=False, close_reason=CloseReason.AWARDED.value,
)
# round 2: 전원 미참여 → 미참여 재생성이 일어나야 한다(round 1 은 미참여로 세면 안 됨)
qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.IN_PROGRESS.value)
await _add_session(engine, qt2, status=SessionStatus.CREATED.value)
service = QuotationService(QuotationCRUD())
outcome = await service.close_and_decide(qt2)
rounds = await _rounds(engine, number)
round_numbers = [r.round for r in rounds]
assert outcome == CloseOutcome.REGENERATED, (
f"단독낙찰 이전 라운드는 미참여 예산을 소진하지 않아 round2 가 재생성돼야 함, 실제 {outcome}"
)
assert round_numbers == [1, 2, 3], f"round 3 이 생성돼야 함, 실제 {round_numbers}"
async def test_no_show_prior_round_consumes_budget(clean):
"""검증: round1=미참여 재생성 + round2=전원 미참여 인 체인에서 round2 를 마감.
기대결과: CLOSED, 체인 [1,2] 미참여 재생성 한도(1) 소진돼 재생성 없이 그냥 마감(round3 없음)."""
engine = clean
number = "C-NOSHOW-PRIOR"
# round 1: 미참여 재생성으로 마감(close_reason=REGEN_NOSHOW) → 미참여 예산 1 소진
await _seed_quotation(
engine, number=number, round_=1, status=QuotationStatus.CLOSED.value,
preferred_sp_yn=False, equal_bid_yn=False, close_reason=CloseReason.REGEN_NOSHOW.value,
)
# round 2: 또 전원 미참여 → 한도 도달이라 재생성 없이 그냥 마감
qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.IN_PROGRESS.value)
await _add_session(engine, qt2, status=SessionStatus.CREATED.value)
service = QuotationService(QuotationCRUD())
outcome = await service.close_and_decide(qt2)
rounds = await _rounds(engine, number)
assert outcome == CloseOutcome.CLOSED, f"미참여 예산 소진 → 그냥 마감이어야 함, 실제 {outcome}"
assert [r.round for r in rounds] == [1, 2], "재생성되면 안 됨(round 3 없음)"
async def test_over_target_fail_closes(clean):
"""검증: over_action=유찰(FAIL) 회사에서 단독 최저가가 목표 초과(session target=0 < bid=100)인 견적을 마감.
기대결과: CLOSED + close_reason=FAIL_PRICE 가격에 낙찰 하고 유찰(재생성 없음)."""
engine = clean
setting = await _seed_settings(engine, over_action=PriceGateAction.FAIL.value)
qt = await _seed_quotation(engine, number="C-OVER-FAIL", round_=1,
status=QuotationStatus.IN_PROGRESS.value, qt_setting_id=setting)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100)
qt = await _seed_quotation(engine, number="C-AWARD", round_=1,
status=QuotationStatus.IN_PROGRESS.value,
mid_action=PriceGateAction.AWARD.value, over_action=PriceGateAction.OPEN.value)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, target_price=200)
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
rounds = await _rounds(engine, "C-OVER-FAIL")
assert outcome == CloseOutcome.CLOSED, f"목표초과+유찰정책 → 유찰이어야 함, 실제 {outcome}"
assert [r.round for r in rounds] == [1], "재생성되면 안 됨(round 2 없음)"
assert rounds[0].close_reason == CloseReason.FAIL_PRICE.value, f"close_reason=FAIL_PRICE 여야 함, 실제 {rounds[0].close_reason}"
rounds = await _rounds(engine, "C-AWARD")
assert outcome == CloseOutcome.AWARDED, f"기준 통과 단독 최저가 → 낙찰이어야 함, 실제 {outcome}"
assert rounds[0].close_reason == CloseReason.AWARDED.value
async def test_over_target_renego_regenerates(clean):
"""검증: over_action=재협상(RENEGO) 회사에서 단독 최저가가 목표 초과인 견적을 첫 라운드에 마감.
기대결과: REGENERATED + 체인 [1,2] 가격에 낙찰 하고 다음 라운드로 깎기(1 close_reason=REGEN_PRICE)."""
async def test_over_target_opens(clean):
"""검증: 단독 최저가(bid=200)가 목표가(100) 초과 · 목표초과=개찰(over=OPEN).
기대결과: OPENED + close_reason=OPEN_PRICE 낙찰 하고 개찰(자동 재생성 없이 체인 [1])."""
engine = clean
setting = await _seed_settings(engine, over_action=PriceGateAction.RENEGO.value)
qt = await _seed_quotation(engine, number="C-OVER-RENEGO", round_=1,
status=QuotationStatus.IN_PROGRESS.value, qt_setting_id=setting)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100)
qt = await _seed_quotation(engine, number="C-OVER", round_=1,
status=QuotationStatus.IN_PROGRESS.value,
mid_action=PriceGateAction.AWARD.value, over_action=PriceGateAction.OPEN.value)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=200, target_price=100)
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
rounds = await _rounds(engine, "C-OVER-RENEGO")
assert outcome == CloseOutcome.REGENERATED, f"목표초과+재협상정책 0회차 → 재생성이어야 함, 실제 {outcome}"
assert [r.round for r in rounds] == [1, 2], f"round 2 가 생성돼야 함, 실제 {[r.round for r in rounds]}"
assert rounds[0].close_reason == CloseReason.REGEN_PRICE.value, f"1차 close_reason=REGEN_PRICE 여야 함, 실제 {rounds[0].close_reason}"
rounds = await _rounds(engine, "C-OVER")
assert outcome == CloseOutcome.OPENED, f"목표초과 → 개찰이어야 함, 실제 {outcome}"
assert [r.round for r in rounds] == [1], "자동 재생성 없음(round 2 없음)"
assert rounds[0].close_reason == CloseReason.OPEN_PRICE.value
# ===== 헬퍼 (위 테스트들이 쓰는 도우미. 세션 상태·마감 표식을 SQL 로 직접 세팅) =====
async def test_anchor_only_opens_within_target(clean):
"""검증: '앵커링가까지만 낙찰'(mid=OPEN) 에서 앵커(80)<최저가(120)≤목표(200).
기대결과: OPENED + OPEN_PRICE 앵커 구간은 낙찰 하고 개찰."""
engine = clean
qt = await _seed_quotation(engine, number="C-ANCHOR", round_=1,
status=QuotationStatus.IN_PROGRESS.value,
mid_action=PriceGateAction.OPEN.value, over_action=PriceGateAction.OPEN.value)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=120,
target_price=200, anchoring_price=80)
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
rounds = await _rounds(engine, "C-ANCHOR")
assert outcome == CloseOutcome.OPENED
assert rounds[0].close_reason == CloseReason.OPEN_PRICE.value
async def test_equal_lowest_opens(clean):
"""검증: 최저가 동점(둘 다 100, 목표 200 이내).
기대결과: OPENED + close_reason=OPEN_EQUAL(낙찰자 미정) 자동 재입찰 없음(체인 [1])."""
engine = clean
qt = await _seed_quotation(engine, number="C-EQUAL", round_=1, status=QuotationStatus.IN_PROGRESS.value)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, target_price=200)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, target_price=200)
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
rounds = await _rounds(engine, "C-EQUAL")
assert outcome == CloseOutcome.OPENED, f"동가 → 개찰이어야 함, 실제 {outcome}"
assert [r.round for r in rounds] == [1]
assert rounds[0].close_reason == CloseReason.OPEN_EQUAL.value
async def test_rejected_opens(clean):
"""검증: 완료 투찰 없이 협상거부 세션만 존재.
기대결과: OPENED + close_reason=OPEN_REJECT."""
engine = clean
qt = await _seed_quotation(engine, number="C-REJECT", round_=1, status=QuotationStatus.IN_PROGRESS.value)
await _add_session(engine, qt, status=SessionStatus.REJECTED.value)
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
rounds = await _rounds(engine, "C-REJECT")
assert outcome == CloseOutcome.OPENED, f"협상거부 → 개찰이어야 함, 실제 {outcome}"
assert rounds[0].close_reason == CloseReason.OPEN_REJECT.value
# ===== 헬퍼 (세션 상태·마감 표식을 SQL 로 직접 세팅) =====
async def _seed_quotation(
engine, *, number, round_, status, start_time=PAST, end_time=PAST,
preferred_sp_yn=None, equal_bid_yn=None, close_reason=None, qt_setting_id=None,
mid_action=1, over_action=1,
):
"""견적 1건 시드. number/round_ 로 체인을, close_reason 으로 '이전 라운드가 어떤 사유로 마감/재생성됐는지'를 만든다
(재생성 한도 카운팅은 close_reason REGEN_* 센다). qt_setting_id 마감 가격정책(설정) 연결. preferred_sp_yn/equal_bid_yn 프론트 표시용."""
"""견적 1건 시드. number/round_ 로 체인을, close_reason 으로 이전 라운드 마감 사유를 만든다.
낙찰 기준(mid/over) 견적 행에 직접 박제 close_and_decide 행에서 읽는다(세팅 아님)."""
qt_id = uuid.uuid4()
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO quotations "
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, "
" round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn, close_reason) VALUES "
" round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn, close_reason, "
" mid_action, over_action) VALUES "
"(:qt_id, :user_id, :qt_setting_id, :version_id, :name, :number, :type, :status, "
" :round, 0, :start_time, :end_time, false, :pref, :eq, :creason)"
" :round, 0, :start_time, :end_time, false, :pref, :eq, :creason, "
" :mid, :over)"
),
{
"qt_id": qt_id, "user_id": uuid.uuid4(), "qt_setting_id": qt_setting_id or uuid.uuid4(),
@ -180,51 +154,38 @@ async def _seed_quotation(
"type": QuotationType.REQUOTE.value, "status": status, "round": round_,
"start_time": start_time, "end_time": end_time,
"pref": preferred_sp_yn, "eq": equal_bid_yn, "creason": close_reason,
"mid": mid_action, "over": over_action,
},
)
return qt_id
async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None):
"""세션 1건 시드(공급사 협상 1건)."""
async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None,
target_price=0, anchoring_price=None):
"""세션 1건 시드(공급사 협상 1건). target_price/앵커링가로 가격게이트 입력을 만든다."""
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
" target_price, status, bid_price, end_time) VALUES "
" target_price, anchoring_price, status, bid_price, end_time) VALUES "
"(:session_id, :quotation_id, :item_id, :supplier_id, :qt_number, :qt_round, :qt_type, "
" 0, :status, :bid_price, :end_time)"
" :target_price, :anchor, :status, :bid_price, :end_time)"
),
{
"session_id": uuid.uuid4(), "quotation_id": qt_id, "item_id": uuid.uuid4(),
"supplier_id": supplier_id or uuid.uuid4(), "qt_number": "Q", "qt_round": 1,
"qt_type": QuotationType.REQUOTE.value, "status": status,
"target_price": target_price, "anchor": anchoring_price,
"bid_price": bid_price, "end_time": PAST,
},
)
async def _rounds(engine, number):
"""체인(number)의 (round, status, start_time, end_time, close_reason) 목록 — round 오름차순."""
"""체인(number)의 (round, status, close_reason) 목록 — round 오름차순."""
async with engine.begin() as conn:
return (await conn.execute(
text("SELECT round, status, start_time, end_time, close_reason FROM quotations "
"WHERE number = :n ORDER BY round"),
text("SELECT round, status, close_reason FROM quotations WHERE number = :n ORDER BY round"),
{"n": number},
)).all()
async def _seed_settings(engine, *, mid_action=1, over_action=1, regen_limit=1):
"""quotation_settings 1건 시드(마감 가격정책 지정). 반환: qt_setting_id."""
sid = uuid.uuid4()
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO quotation_settings "
"(qt_setting_id, user_id, target_margin_rate, anchoring_value, card_count, mid_action, over_action, regen_limit) "
"VALUES (:sid, :uid, 0.1, 0.01, 3, :mid, :over, :lim)"
),
{"sid": sid, "uid": uuid.uuid4(), "mid": mid_action, "over": over_action, "lim": regen_limit},
)
return sid

View File

@ -1,15 +1,14 @@
"""견적 마감(close_and_decide) 테스트 — 마감하면 상황별로 결과가 맞게 판정되고, 그 결과가 작성자에게 알림으로 남는지 확인.
핵심은 '재견적(다음 라운드 재생성)이 나오는 경우 vs 안 나오는 경우' 구분이.
마감 결과는 낙찰(AWARDED) 또는 개찰(OPENED, 낙찰자 미정 마감). 개찰은 결렬(유찰) 아니며 자동 재협상/재생성도 .
경우에 (1) 판정이 맞고 (2) 작성자 알림함에 알맞은 알림 1건이 남는지 본다:
· 단독 최저가 낙찰 (SUCCESS) [재견적 X]
· 협상 거부 결렬 (FAILURE, reason=rejected) [재견적 X]
· 동가/미참여 + 한도 남음 재생성 (REGENERATED) [재견적 O]
· 동가/미참여 + 한도 소진 결렬 (FAILURE, reason=closed) [재견적 X]
재생성 한도: 사유(동가·미참여)별로 체인(같은 견적번호)에서 1번까지만.
· 단독 최저가(기준 통과) 낙찰 (SUCCESS)
· 협상 거부 개찰 (알림 FAILURE, reason=rejected 프론트에서 '개찰' 표기)
· 동가 개찰 (알림 FAILURE, reason=equal)
· 전원 미응찰 개찰 (알림 FAILURE, reason=no_show)
공급사의 협상 결과(협상완료/거부/입찰가) 협상 화면에서만 생기는 값이라 API 만든다 SQL 직접 넣는다.
마감 판정 로직 자체를 깊게 파는 test_scheduler·test_close_and_decide_fixes.
마감 판정 로직 자체를 깊게 파는 test_close_and_decide_fixes.
"""
import uuid
from datetime import datetime
@ -17,7 +16,7 @@ from datetime import datetime
import pytest_asyncio
from sqlalchemy import text
from common.enums import CloseOutcome, CloseReason, NotificationType, QuotationStatus, QuotationType, SessionStatus
from common.enums import CloseOutcome, NotificationType, QuotationStatus, QuotationType, SessionStatus
from crud.quotation_crud import QuotationCRUD
from services.quotation_service import QuotationService
@ -32,10 +31,9 @@ async def clean(db_engine):
return db_engine
# ----- 재견적 X (낙찰·거부) -----
async def test_award_notifies_success(clean):
"""검증: 협상완료 세션 2건(입찰 100·200) — 단독 최저가로 마감.
기대결과: 재견적 X, 판정 = 낙찰(AWARDED) + 알림 SUCCESS(winner_price=100=최저가, ref_qt_id= 견적)."""
"""검증: 협상완료 세션 2건(입찰 100·200) — 단독 최저가로 마감(기본 낙찰 기준=최저가 낙찰).
기대결과: 판정 = 낙찰(AWARDED) + 알림 SUCCESS(winner_price=100=최저가, ref_qt_id= 견적)."""
engine = clean
user_id = uuid.uuid4()
winner = uuid.uuid4()
@ -54,9 +52,9 @@ async def test_award_notifies_success(clean):
assert str(ref) == str(qt)
async def test_rejected_notifies_failure(clean):
async def test_rejected_notifies_open(clean):
"""검증: 협상거부 세션만 있는 상태로 마감.
기대결과: 재견적 X, 판정 = 결렬(CLOSED) + 알림 FAILURE(reason=rejected)."""
기대결과: 판정 = 개찰(OPENED) + 알림(reason=rejected). 결렬 아님·자동 재생성 없음."""
engine = clean
user_id = uuid.uuid4()
qt = await _seed_quotation(engine, user_id=user_id, number="N-REJECT")
@ -64,7 +62,7 @@ async def test_rejected_notifies_failure(clean):
outcome = await _service().close_and_decide(qt)
assert outcome == CloseOutcome.CLOSED
assert outcome == CloseOutcome.OPENED
notis = await _notifications(engine, user_id)
assert len(notis) == 1
type_, data, ref = notis[0]
@ -73,10 +71,9 @@ async def test_rejected_notifies_failure(clean):
assert str(ref) == str(qt)
# ----- 재견적 O (동가·미참여, 한도 남음) -----
async def test_equal_bid_regenerates(clean):
"""검증: 협상완료 세션 2건이 '동가'(둘 다 100), 체인에 동가 재생성 이력 없음(한도 남음).
기대결과: 재견적 O, 판정 = 재생성(REGENERATED) + 알림 REGENERATED(reason=equal, tied_price=100, next_round=2)."""
async def test_equal_bid_opens(clean):
"""검증: 협상완료 세션 2건이 '동가'(둘 다 100).
기대결과: 판정 = 개찰(OPENED, 낙찰자 미정) + 알림(reason=equal). 자동 재입찰 없음."""
engine = clean
user_id = uuid.uuid4()
qt = await _seed_quotation(engine, user_id=user_id, number="N-EQUAL")
@ -85,19 +82,17 @@ async def test_equal_bid_regenerates(clean):
outcome = await _service().close_and_decide(qt)
assert outcome == CloseOutcome.REGENERATED
assert outcome == CloseOutcome.OPENED
notis = await _notifications(engine, user_id)
assert len(notis) == 1
type_, data, _ = notis[0]
assert type_ == NotificationType.REGENERATED.value
assert type_ == NotificationType.FAILURE.value
assert data["reason"] == "equal"
assert data["tied_price"] == 100
assert data["next_round"] == 2
async def test_no_show_regenerates(clean):
"""검증: 전원 미참여(미시작 세션만), 체인에 미참여 재생성 이력 없음(한도 남음).
기대결과: 재견적 O, 판정 = 재생성(REGENERATED) + 알림 REGENERATED(reason=no_show, next_round=2)."""
async def test_no_show_opens(clean):
"""검증: 전원 미응찰(미시작 세션만).
기대결과: 판정 = 개찰(OPENED) + 알림(reason=no_show). 자동 재소집 없음."""
engine = clean
user_id = uuid.uuid4()
qt = await _seed_quotation(engine, user_id=user_id, number="N-NOSHOW")
@ -106,89 +101,32 @@ async def test_no_show_regenerates(clean):
outcome = await _service().close_and_decide(qt)
assert outcome == CloseOutcome.REGENERATED
assert outcome == CloseOutcome.OPENED
notis = await _notifications(engine, user_id)
assert len(notis) == 1
type_, data, _ = notis[0]
assert type_ == NotificationType.REGENERATED.value
assert type_ == NotificationType.FAILURE.value
assert data["reason"] == "no_show"
assert data["next_round"] == 2
# ----- 재견적 X (동가·미참여지만 한도 소진 → 결렬) -----
async def test_equal_bid_limit_exhausted_fails(clean):
"""검증: 1차가 이미 '동가'로 재생성된 체인(동가 한도 1 소진)에서, 2차도 또 동가로 마감.
기대결과: 재견적 X 판정 = 결렬(CLOSED) + 알림 FAILURE(reason=closed)."""
engine = clean
user_id = uuid.uuid4()
# 1차: 동가로 재생성 마감(close_reason=REGEN_EQUAL) → 동가 한도 소진
await _seed_quotation(engine, user_id=user_id, number="N-EQUAL-LIMIT", round_=1,
status=QuotationStatus.CLOSED.value, equal_bid_yn=True,
close_reason=CloseReason.REGEN_EQUAL.value)
# 2차: 또 동가
qt2 = await _seed_quotation(engine, user_id=user_id, number="N-EQUAL-LIMIT", round_=2)
await _add_session(engine, qt2, status=SessionStatus.DONE.value, bid_price=100)
await _add_session(engine, qt2, status=SessionStatus.DONE.value, bid_price=100)
outcome = await _service().close_and_decide(qt2)
assert outcome == CloseOutcome.CLOSED # 동가 한도 소진 → 재생성 없이 결렬
notis = await _notifications(engine, user_id)
assert len(notis) == 1
type_, data, ref = notis[0]
assert type_ == NotificationType.FAILURE.value
assert data["reason"] == "equal" # 동가 유찰(FAIL_EQUAL) 사유
assert str(ref) == str(qt2)
async def test_no_show_limit_exhausted_fails(clean):
"""검증: 1차가 이미 '미참여'로 재생성된 체인(미참여 한도 1 소진)에서, 2차도 또 전원 미참여로 마감.
기대결과: 재견적 X 판정 = 결렬(CLOSED) + 알림 FAILURE(reason=closed)."""
engine = clean
user_id = uuid.uuid4()
# 1차: 미참여로 재생성 마감(close_reason=REGEN_NOSHOW) → 미참여 한도 소진
await _seed_quotation(engine, user_id=user_id, number="N-NOSHOW-LIMIT", round_=1,
status=QuotationStatus.CLOSED.value, preferred_sp_yn=False, equal_bid_yn=False,
close_reason=CloseReason.REGEN_NOSHOW.value)
# 2차: 또 전원 미참여
qt2 = await _seed_quotation(engine, user_id=user_id, number="N-NOSHOW-LIMIT", round_=2)
await _add_session(engine, qt2, status=SessionStatus.CREATED.value)
await _add_session(engine, qt2, status=SessionStatus.CREATED.value)
outcome = await _service().close_and_decide(qt2)
assert outcome == CloseOutcome.CLOSED # 미참여 한도 소진 → 재생성 없이 결렬
notis = await _notifications(engine, user_id)
assert len(notis) == 1
type_, data, ref = notis[0]
assert type_ == NotificationType.FAILURE.value
assert data["reason"] == "closed"
assert str(ref) == str(qt2)
# ===== 헬퍼 (위 테스트들이 쓰는 도우미. 세션 입찰값·이전 라운드 표식을 SQL 로 직접 세팅) =====
async def _seed_quotation(
engine, *, user_id, number, round_=1, status=QuotationStatus.IN_PROGRESS.value,
preferred_sp_yn=None, equal_bid_yn=None, close_reason=None,
):
"""견적 1건 시드(작성자=user_id). close_reason 으로 '이전 라운드가 어떤 사유로 재생성됐는지'를 표식한다
(재생성 한도 카운팅은 close_reason REGEN_* 센다). preferred_sp_yn/equal_bid_yn 프론트 표시용."""
# ===== 헬퍼 (세션 입찰값을 SQL 로 직접 세팅) =====
async def _seed_quotation(engine, *, user_id, number, round_=1, status=QuotationStatus.IN_PROGRESS.value):
"""견적 1건 시드(작성자=user_id). 낙찰 기준 mid/over 는 서버 기본(AWARD)=최저가 낙찰."""
qt_id = uuid.uuid4()
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO quotations "
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, "
" round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn, close_reason) VALUES "
" round, iteration, start_time, end_time, deleted) VALUES "
"(:qt_id, :user_id, :qt_setting_id, :version_id, '견적A', :number, :type, :status, "
" :round, 0, :start_time, :end_time, false, :pref, :eq, :creason)"
" :round, 0, :start_time, :end_time, false)"
),
{
"qt_id": qt_id, "user_id": user_id, "qt_setting_id": uuid.uuid4(),
"version_id": uuid.uuid4(), "number": number,
"type": QuotationType.REQUOTE.value, "status": status, "round": round_,
"start_time": PAST, "end_time": PAST,
"pref": preferred_sp_yn, "eq": equal_bid_yn, "creason": close_reason,
},
)
return qt_id

View File

@ -9,7 +9,7 @@ from datetime import datetime
from sqlalchemy import text
from common.enums import QuotationType
from common.enums import PriceGateAction, QuotationType
from crud.quotation_crud import QuotationCRUD
from router.v1.quotation.protocol import Req_CreateQuotation
from services.quotation_service import QuotationService
@ -65,11 +65,67 @@ async def test_create_without_price_fails(db_engine, company_id):
assert rows == []
async def test_auction_forces_lowest_price_award(db_engine, company_id):
"""검증: 1:N 경매(NEW_QUOTE)에 낙찰 기준(OPEN/OPEN)을 실어 생성 요청.
기대결과: 경매는 무조건 최저가 낙찰 견적 행에 mid=over=AWARD 강제 저장(요청값 무시)."""
item = await _seed_item(db_engine, company_id, internet_lowest=100_000)
req = Req_CreateQuotation(
qt_setting_id=uuid.uuid4(),
name="경매정책강제",
type=QuotationType.NEW_QUOTE.value,
end_time=FUTURE,
item_ids=[item],
supplier_ids=[uuid.uuid4(), uuid.uuid4()],
mid_action=PriceGateAction.OPEN.value, # 경매엔 의미 없음 — 서버가 덮어야 함
over_action=PriceGateAction.OPEN.value,
)
res = await _service().create_quotation(str(uuid.uuid4()), req)
assert res.result.success is True
mid, over = await _quotation_policy(db_engine, res.qt_id)
assert mid == PriceGateAction.AWARD.value, f"경매 mid_action 은 AWARD 강제여야 함, 실제 {mid}"
assert over == PriceGateAction.AWARD.value, f"경매 over_action 은 AWARD 강제여야 함, 실제 {over}"
async def test_nego_persists_award_criterion(db_engine, company_id):
"""검증: 1:1 협상(NEW_NEGO)에 낙찰 기준(mid=AWARD/over=OPEN=목표까지 낙찰)을 실어 생성.
기대결과: 요청값이 견적 행에 그대로 박제(협상은 사용자가 낙찰 기준을 정한다. 목표초과=개찰)."""
item = await _seed_item(db_engine, company_id, internet_lowest=100_000)
req = Req_CreateQuotation(
qt_setting_id=uuid.uuid4(),
name="협상정책박제",
type=QuotationType.NEW_NEGO.value,
end_time=FUTURE,
item_ids=[item],
supplier_ids=[uuid.uuid4()],
mid_action=PriceGateAction.AWARD.value,
over_action=PriceGateAction.OPEN.value,
)
res = await _service().create_quotation(str(uuid.uuid4()), req)
assert res.result.success is True
mid, over = await _quotation_policy(db_engine, res.qt_id)
assert (mid, over) == (
PriceGateAction.AWARD.value, PriceGateAction.OPEN.value,
), f"협상 낙찰 기준이 그대로 저장돼야 함, 실제 {(mid, over)}"
# ===== 헬퍼 (위 테스트들이 쓰는 도우미) =====
def _service():
return QuotationService(QuotationCRUD())
async def _quotation_policy(engine, qt_id):
"""생성된 견적의 낙찰 기준 (mid_action, over_action)."""
async with engine.begin() as conn:
return (await conn.execute(
text("SELECT mid_action, over_action FROM quotations WHERE qt_id = :qt"),
{"qt": qt_id},
)).one()
async def _seed_item(engine, company_id, *, internet_lowest):
"""상품 1건 시드(인터넷최저가만). category_type·internet_lowest_price_yn 은 NOT NULL —
ORM default raw INSERT 먹으므로 명시한다(conftest companies.status 같은 이유)."""

View File

@ -6,8 +6,9 @@
*/
/**
* quotations.close_reason (SMALLINT). (REGEN_*) .
preferred_sp_yn/equal_bid_yn 2 4 '목표초과 재협상' .
* quotations.close_reason (SMALLINT). (AWARDED) (OPEN_*) .
=() '낙찰자 미정으로 마감' / ( ).
( REGEN_*(2~4)· . 5~8 OPEN_* .)
*/
export type CloseReason = typeof CloseReason[keyof typeof CloseReason];
@ -15,11 +16,8 @@ export type CloseReason = typeof CloseReason[keyof typeof CloseReason];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const CloseReason = {
AWARDED: 1,
REGEN_PRICE: 2,
REGEN_EQUAL: 3,
REGEN_NOSHOW: 4,
FAIL_PRICE: 5,
FAIL_EQUAL: 6,
FAIL_NOSHOW: 7,
FAIL_REJECT: 8,
OPEN_PRICE: 5,
OPEN_EQUAL: 6,
OPEN_NOSHOW: 7,
OPEN_REJECT: 8,
} as const;

View File

@ -82,7 +82,6 @@ export * from './notificationDataReadAt';
export * from './notificationDataRefQtId';
export * from './notificationDataRefSessionId';
export * from './notificationType';
export * from './priceGateAction';
export * from './quotationCardData';
export * from './quotationCardDataCondition';
export * from './quotationCardDataEditScript';
@ -107,6 +106,8 @@ export * from './quotationDataManagerEmail';
export * from './quotationDataManagerName';
export * from './quotationDataMdPrice';
export * from './quotationDataMemo';
export * from './quotationDataMidAction';
export * from './quotationDataOverAction';
export * from './quotationDataPreferredSpId';
export * from './quotationDataPreferredSpName';
export * from './quotationDataPreferredSpYn';
@ -151,6 +152,8 @@ export * from './reqCreateQuotationManagerEmail';
export * from './reqCreateQuotationManagerName';
export * from './reqCreateQuotationMdPrice';
export * from './reqCreateQuotationMemo';
export * from './reqCreateQuotationMidAction';
export * from './reqCreateQuotationOverAction';
export * from './reqCreateQuotationSetting';
export * from './reqCreateQuotationStartTime';
export * from './reqCreateQuotationSupplierType';
@ -160,7 +163,7 @@ export * from './reqCreateSupplierCode';
export * from './reqCreateSupplierManagerContactNumber';
export * from './reqCreateSupplierManagerEmail';
export * from './reqCreateSupplierManagerName';
export * from './reqCreateSupplierPriority';
export * from './reqCreateSupplierTotalRevenue';
export * from './reqLogin';
export * from './reqRegenerateQuotation';
export * from './reqUpdateCard';
@ -205,11 +208,7 @@ export * from './reqUpdateMeEmail';
export * from './reqUpdateMeName';
export * from './reqUpdateMePassword';
export * from './reqUpdateQuotationSetting';
export * from './reqUpdateQuotationSettingAnchoringValue';
export * from './reqUpdateQuotationSettingCardCount';
export * from './reqUpdateQuotationSettingMidAction';
export * from './reqUpdateQuotationSettingOverAction';
export * from './reqUpdateQuotationSettingRegenLimit';
export * from './reqUpdateQuotationSettingTargetMarginRate';
export * from './reqUpdateSupplier';
export * from './reqUpdateSupplierCode';
@ -217,7 +216,7 @@ export * from './reqUpdateSupplierManagerContactNumber';
export * from './reqUpdateSupplierManagerEmail';
export * from './reqUpdateSupplierManagerName';
export * from './reqUpdateSupplierName';
export * from './reqUpdateSupplierPriority';
export * from './reqUpdateSupplierTotalRevenue';
export * from './resCard';
export * from './resCardCard';
export * from './resCardList';
@ -318,21 +317,21 @@ export * from './resSupplierListMsg';
export * from './resSupplierMsg';
export * from './resSupplierSupplier';
export * from './resTargetBreakdown';
export * from './resTargetBreakdownAnchoringPrice';
export * from './resTargetBreakdownChosenBasis';
export * from './resTargetBreakdownInternetLowest';
export * from './resTargetBreakdownMdPrice';
export * from './resTargetBreakdownMsg';
export * from './resTargetBreakdownPurchase';
export * from './resTargetBreakdownSelling';
export * from './resTargetBreakdownAnchoringPrice';
export * from './sessionData';
export * from './sessionDataAnchoringPrice';
export * from './sessionDataBidAt';
export * from './sessionDataBidPrice';
export * from './sessionDataEmailSentAt';
export * from './sessionDataRejectDeliveryType';
export * from './sessionDataRejectPrice';
export * from './sessionDataRejectReason';
export * from './sessionDataAnchoringPrice';
export * from './sessionStatus';
export * from './supplierData';
export * from './supplierDataCode';
@ -340,7 +339,7 @@ export * from './supplierDataCreatedAt';
export * from './supplierDataManagerContactNumber';
export * from './supplierDataManagerEmail';
export * from './supplierDataManagerName';
export * from './supplierDataPriority';
export * from './supplierDataTotalRevenue';
export * from './supplierDataUpdatedAt';
export * from './supplierType';
export * from './targetCandidate';

View File

@ -10,10 +10,6 @@ export type ListSuppliersParams = {
* //
*/
search?: string | null;
/**
* (HIGH/MEDIUM/LOW)
*/
priority?: string | null;
/**
* @minimum 1
*/

View File

@ -1,20 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
/**
* quotation_settings . '앵커링가<투찰가≤목표가'(mid) / '목표가<투찰가'(over) .
( .)
*/
export type PriceGateAction = typeof PriceGateAction[keyof typeof PriceGateAction];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const PriceGateAction = {
AWARD: 1,
RENEGO: 2,
FAIL: 3,
} as const;

View File

@ -18,6 +18,8 @@ import type { QuotationDataPreferredSpName } from './quotationDataPreferredSpNam
import type { QuotationDataEqualBidYn } from './quotationDataEqualBidYn';
import type { QuotationDataEqualBidData } from './quotationDataEqualBidData';
import type { QuotationDataCloseReason } from './quotationDataCloseReason';
import type { QuotationDataMidAction } from './quotationDataMidAction';
import type { QuotationDataOverAction } from './quotationDataOverAction';
import type { QuotationDataItemId } from './quotationDataItemId';
import type { QuotationDataItemName } from './quotationDataItemName';
import type { QuotationDataCreatorName } from './quotationDataCreatorName';
@ -49,6 +51,8 @@ export interface QuotationData {
equal_bid_yn?: QuotationDataEqualBidYn;
equal_bid_data?: QuotationDataEqualBidData;
close_reason?: QuotationDataCloseReason;
mid_action?: QuotationDataMidAction;
over_action?: QuotationDataOverAction;
participation_count?: number;
item_id?: QuotationDataItemId;
item_name?: QuotationDataItemName;

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export type SupplierDataPriority = string | null;
export type QuotationDataMidAction = number | null;

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export type ReqCreateSupplierPriority = string | null;
export type QuotationDataOverAction = number | null;

View File

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

View File

@ -12,6 +12,8 @@ import type { ReqCreateQuotationManagerContactNumber } from './reqCreateQuotatio
import type { ReqCreateQuotationMemo } from './reqCreateQuotationMemo';
import type { ReqCreateQuotationMdPrice } from './reqCreateQuotationMdPrice';
import type { ReqCreateQuotationSupplierType } from './reqCreateQuotationSupplierType';
import type { ReqCreateQuotationMidAction } from './reqCreateQuotationMidAction';
import type { ReqCreateQuotationOverAction } from './reqCreateQuotationOverAction';
export interface ReqCreateQuotation {
qt_setting_id: string;
@ -31,4 +33,6 @@ export interface ReqCreateQuotation {
item_ids?: string[];
supplier_ids?: string[];
card_ids?: string[];
mid_action?: ReqCreateQuotationMidAction;
over_action?: ReqCreateQuotationOverAction;
}

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export type ReqUpdateQuotationSettingRegenLimit = number | null;
export type ReqCreateQuotationMidAction = number | 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 ReqCreateQuotationOverAction = number | null;

View File

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

View File

@ -8,7 +8,7 @@ import type { ReqCreateSupplierCode } from './reqCreateSupplierCode';
import type { ReqCreateSupplierManagerName } from './reqCreateSupplierManagerName';
import type { ReqCreateSupplierManagerEmail } from './reqCreateSupplierManagerEmail';
import type { ReqCreateSupplierManagerContactNumber } from './reqCreateSupplierManagerContactNumber';
import type { ReqCreateSupplierPriority } from './reqCreateSupplierPriority';
import type { ReqCreateSupplierTotalRevenue } from './reqCreateSupplierTotalRevenue';
export interface ReqCreateSupplier {
name?: string;
@ -16,5 +16,5 @@ export interface ReqCreateSupplier {
manager_name?: ReqCreateSupplierManagerName;
manager_email?: ReqCreateSupplierManagerEmail;
manager_contact_number?: ReqCreateSupplierManagerContactNumber;
priority?: ReqCreateSupplierPriority;
total_revenue?: ReqCreateSupplierTotalRevenue;
}

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 ReqCreateSupplierTotalRevenue = number | null;

View File

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

View File

@ -1,8 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ReqUpdateQuotationSettingAnchoringValue = number | null;

View File

@ -1,9 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { PriceGateAction } from './priceGateAction';
export type ReqUpdateQuotationSettingMidAction = PriceGateAction | null;

View File

@ -1,9 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { PriceGateAction } from './priceGateAction';
export type ReqUpdateQuotationSettingOverAction = PriceGateAction | null;

View File

@ -9,7 +9,7 @@ import type { ReqUpdateSupplierCode } from './reqUpdateSupplierCode';
import type { ReqUpdateSupplierManagerName } from './reqUpdateSupplierManagerName';
import type { ReqUpdateSupplierManagerEmail } from './reqUpdateSupplierManagerEmail';
import type { ReqUpdateSupplierManagerContactNumber } from './reqUpdateSupplierManagerContactNumber';
import type { ReqUpdateSupplierPriority } from './reqUpdateSupplierPriority';
import type { ReqUpdateSupplierTotalRevenue } from './reqUpdateSupplierTotalRevenue';
export interface ReqUpdateSupplier {
name?: ReqUpdateSupplierName;
@ -17,5 +17,5 @@ export interface ReqUpdateSupplier {
manager_name?: ReqUpdateSupplierManagerName;
manager_email?: ReqUpdateSupplierManagerEmail;
manager_contact_number?: ReqUpdateSupplierManagerContactNumber;
priority?: ReqUpdateSupplierPriority;
total_revenue?: ReqUpdateSupplierTotalRevenue;
}

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 ReqUpdateSupplierTotalRevenue = number | null;

View File

@ -8,7 +8,7 @@ import type { SupplierDataCode } from './supplierDataCode';
import type { SupplierDataManagerName } from './supplierDataManagerName';
import type { SupplierDataManagerEmail } from './supplierDataManagerEmail';
import type { SupplierDataManagerContactNumber } from './supplierDataManagerContactNumber';
import type { SupplierDataPriority } from './supplierDataPriority';
import type { SupplierDataTotalRevenue } from './supplierDataTotalRevenue';
import type { SupplierDataCreatedAt } from './supplierDataCreatedAt';
import type { SupplierDataUpdatedAt } from './supplierDataUpdatedAt';
@ -21,7 +21,7 @@ export interface SupplierData {
manager_name?: SupplierDataManagerName;
manager_email?: SupplierDataManagerEmail;
manager_contact_number?: SupplierDataManagerContactNumber;
priority?: SupplierDataPriority;
total_revenue?: SupplierDataTotalRevenue;
created_at?: SupplierDataCreatedAt;
updated_at?: SupplierDataUpdatedAt;
}

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export type ReqUpdateSupplierPriority = string | null;
export type SupplierDataTotalRevenue = number | null;

View File

@ -35,7 +35,7 @@ export function ScopeSection({
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<DeadlineWidget data={s.deadline_soon} onOpen={onOpen} />
<EmailUnsentWidget data={s.email_unsent} onOpen={onOpen} />
<RefWidget title="결렬 (선정자 없이 마감)" icon={Ban} tone="rose" data={s.ruptured} onOpen={onOpen} />
<RefWidget title="개찰 (낙찰자 미정 마감)" icon={Ban} tone="amber" data={s.ruptured} onOpen={onOpen} />
</div>
</div>
);

View File

@ -13,7 +13,7 @@ import { Badge } from '@/components/ui/badge';
import { Typography } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
// 신규 유저용 프로세스 안내. '흐름 설명'(6단계)·'견적 유형'(4종)·'마감 판정'(가격게이트+재생성 한도 결정표) 세 뷰.
// 신규 유저용 프로세스 안내. '흐름 설명'(6단계)·'견적 유형'(4종)·'마감 판정'(가격게이트+개찰 결정표) 세 뷰.
// 읽기 전용 정적 안내(테이블/서버 없음). 첫 방문 자동 노출 + 대시보드 버튼으로 재오픈은 호출부(대시보드)에서 제어.
type Actor = 'user' | 'partner' | 'system';
@ -92,9 +92,9 @@ const STEPS: Step[] = [
actor: '시스템',
actorType: 'system',
icon: Award,
short: '단독최저+정책통과=낙찰 / 목표초과·동가·미참여=재생성 / 결렬',
short: '단독최저+기준통과=낙찰 / 그 외(목표가 초과·동가·미응찰·거부)=개찰',
detail:
'낙찰 후보는 협상완료한 협력사 중 최저가입니다. 다만 최저가라고 바로 낙찰이 아니라, 그 최저가가 단독(동점 아님)이고 회사 가격정책의 가격게이트를 낙찰로 통과해야 낙찰됩니다. 최저가가 목표가 구간을 넘어 정책이 재협상이면 다음 차수로 더 깎고, 동가·전원 미참여도 다음 차수를 재생성합니다(모두 체인 전체 재생성 한도 안에서, 사유 무관 합산·기본 1회). 정책이 결렬이거나 한도를 소진하면, 또는 완료 투찰 없이 거부만 있으면 결렬됩니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.',
'낙찰 후보는 협상완료한 협력사 중 최저가입니다. 다만 최저가라고 바로 낙찰이 아니라, 그 최저가가 단독(동점 아님)이고 견적의 낙찰 기준(가격게이트)을 통과해야 낙찰됩니다. 낙찰 기준을 못 넘기거나(목표가 초과 등), 동가·전원 미응찰·협상거부인 경우는 결렬이 아니라 개찰 — 낙찰자 미정으로 마감됩니다. 개찰은 자동 재협상/재생성 없이 담당자가 상세에서 수동으로 다음 라운드를 생성하거나 처리합니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.',
},
];
@ -104,14 +104,16 @@ const ACTOR_CLASS: Record<Actor, string> = {
system: 'text-muted-foreground',
};
// ----- 견적 마감 판정(close_and_decide 기준): 낙찰 후보=협상완료 최저가 → (1)단독/동가/미완료 (2)가격게이트(mid/over_action) (3)재생성 한도(체인 전체 총·사유무관, regen_limit)로 낙찰/재생성/결렬. 아래 결정표 8행 = close_reason 8종 -----
type Tone = 'win' | 'tie' | 'fail';
// ----- 견적 마감 판정(close_and_decide): 낙찰 후보=협상완료 최저가 → 단독 최저가가 낙찰 기준(가격게이트) 통과면 낙찰, 아니면 개찰.
// 동가·협상거부·전원 미응찰도 결렬이 아니라 개찰(낙찰자 미정 마감). 자동 재협상/재생성 없음 — 다음 라운드는 담당자가 수동 재생성.
// 결정표 = close_reason 5종(낙찰 + 개찰 4: 가격/동가/미응찰/거부). -----
type Tone = 'win' | 'open';
// (1) 가격게이트: 투찰가가 앵커링가/목표가 대비 어느 구간이냐로 회사 정책(mid/over_action) 적용.
// (1) 가격게이트(1:1 협상): 투찰가가 앵커링가/목표가 대비 어느 구간이냐로 견적 낙찰 기준 적용. 미달이면 개찰.
const GATE_ZONES: { range: string; action: string; note: string }[] = [
{ range: '투찰가 ≤ 앵커링가', action: '무조건 낙찰', note: '고정 · 설정 불가' },
{ range: '앵커링가 < 투찰가 ≤ 목표가', action: '견적세팅 mid_action 대로', note: '기본값 = 낙찰' },
{ range: '목표가 < 투찰가', action: '견적세팅 over_action 대로', note: '기본값 = 낙찰' },
{ range: '앵커링가 < 투찰가 ≤ 목표가', action: '낙찰 기준대로 (낙찰 / 개찰)', note: '목표가까지 낙찰이면 낙찰 · 앵커링가까지면 개찰' },
{ range: '목표가 < 투찰가', action: '개찰', note: '항상 개찰(목표가 초과)' },
];
// (2) 완료 양상별 결정. rows 는 위에서부터 순서대로 판정(close_and_decide 분기 순서와 동일).
@ -131,34 +133,30 @@ const DECISION_TREE: DecisionGroup[] = [
group: '단독 최저가',
desc: '협상완료 협력사 중 최저가가 한 곳',
rows: [
{ cond: '가격게이트 = 낙찰', result: '낙찰', tone: 'win' },
{ cond: '가격게이트 = 재협상 · 재생성 한도 남음', result: '재협상 · 더 깎기', tone: 'tie' },
{ cond: '가격게이트 = 결렬, 또는 재협상인데 한도 소진', result: '결렬', tone: 'fail' },
{ cond: '낙찰 기준 통과(앵커 이하 · 또는 목표가 이내·목표가까지 낙찰)', result: '낙찰', tone: 'win' },
{ cond: '낙찰 기준 미달(목표가 초과 등)', result: '개찰 · 낙찰자 미정', tone: 'open' },
],
},
{
group: '동가',
desc: '최저가가 2곳 이상 동일 → 단독 낙찰 불가',
rows: [
{ cond: '가격게이트 ≠ 결렬 · 한도 남음', result: '재입찰 · 동가끼리', tone: 'tie' },
{ cond: '가격게이트 = 결렬, 또는 한도 소진', result: '결렬', tone: 'fail' },
{ cond: '항상', result: '개찰 · 낙찰자 미정', tone: 'open' },
],
},
{
group: '완료한 투찰 없음',
desc: '아무도 협상을 완료(투찰 확정)하지 않음',
rows: [
{ cond: '거부한 협력사가 있음', result: '결렬', tone: 'fail' },
{ cond: '전원 미참여 · 한도 남음', result: '재소집 · 공급사 전체', tone: 'tie' },
{ cond: '전원 미참여 · 한도 소진', result: '결렬', tone: 'fail' },
{ cond: '거부한 협력사가 있음', result: '개찰 · 거부', tone: 'open' },
{ cond: '전원 미응찰', result: '개찰 · 미응찰', tone: 'open' },
],
},
];
const TONE_CHIP: Record<Tone, string> = {
win: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
tie: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
fail: 'bg-destructive/10 text-destructive',
open: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
};
// ----- 견적 유형(4종): 두 축으로 갈림 — 신규/재(목표가 산정 후보) × 협상 1:1 / 견적 1:N(부르는 협력사 수).
@ -434,16 +432,16 @@ export function OnboardingGuideModal({
<Badge variant="outline"></Badge>
</div>
<Typography variant="muted">
. , () · · .
. , () · .
</Typography>
{/* 핵심: 처리 방식은 견적세팅이 정하고, 기본은 낙찰 */}
{/* 핵심: 낙찰 기준은 견적 생성 시 정하고, 미달은 개찰 */}
<div className="rounded-lg border border-primary/40 bg-primary/5 p-3">
<Typography as="p" variant="small" className="font-semibold text-primary">
· ·
(1:1 )
</Typography>
<Typography variant="caption" className="mt-0.5 block leading-relaxed">
(mid_action · over_action) . , .
1:1 / 1. , , . ( ), / . 1:N .
</Typography>
</div>
@ -509,13 +507,13 @@ export function OnboardingGuideModal({
))}
</div>
{/* 3) 재생성 한도 */}
{/* 3) 개찰 처리 */}
<div className="rounded-lg border border-amber-500/40 bg-amber-500/5 p-3">
<Typography as="p" variant="small" className="font-semibold text-amber-700 dark:text-amber-400">
3)
3) ( )
</Typography>
<Typography variant="caption" className="mt-0.5 block leading-relaxed">
· · . (regen_limit) ( ) 1, . .
( ···) . / , . .
</Typography>
</div>
</div>

View File

@ -74,9 +74,9 @@ export const STEPS: Step[] = [
actor: '시스템',
actorType: 'system',
icon: Award,
short: '단독최저=낙찰 / 동가·미참여=재생성 / 결렬',
short: '단독최저+기준통과=낙찰 / 그 외=개찰(낙찰자 미정)',
detail:
'단독 최저가면 그 협력사로 낙찰됩니다. 최저가가 둘 이상 같은 동가이거나 전원 미참여면 다음 차수가 자동 재생성되고, 거절·한도 등으로 낙찰자가 없으면 결렬 처리됩니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.',
'단독 최저가가 견적의 낙찰 기준을 통과하면 그 협력사로 낙찰됩니다. 기준을 못 넘기거나 동가·전원 미응찰·협상거부인 경우는 결렬이 아니라 개찰 — 낙찰자 미정으로 마감되고, 다음 라운드는 담당자가 상세에서 수동으로 만듭니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.',
},
];

View File

@ -17,13 +17,13 @@ type RawRow = {
code: string;
managerName: string;
managerEmail: string;
priority: string;
totalRevenue: string;
};
type ValidatedRow = RawRow & { status: '정상' | '오류'; message: string };
// 업로드 양식 한 줄(예시 행)
type TemplateRow = { name: string; code: string; managerName: string; managerEmail: string; priority: string };
type TemplateRow = { name: string; code: string; managerName: string; managerEmail: string; totalRevenue: string };
type ExcelUploadModalProps = {
open: boolean;
@ -68,7 +68,7 @@ function toSupplierCreate(row: RawRow): SupplierCreate {
manager_name: row.managerName,
manager_email: row.managerEmail,
manager_contact_number: '010-0000-0000',
priority: row.priority,
total_revenue: row.totalRevenue?.trim() ? Number(row.totalRevenue.replace(/[^0-9]/g, '')) : undefined,
};
}
@ -81,9 +81,9 @@ export function downloadPartnerTemplate() {
{ header: '식별코드', value: (r) => r.code },
{ header: '담당자명', value: (r) => r.managerName },
{ header: '담당자이메일', value: (r) => r.managerEmail },
{ header: '우선순위', value: (r) => r.priority },
{ header: '총매출액', value: (r) => r.totalRevenue },
],
[{ name: '예시) (주)한빛정밀', code: 'PART-EXAMPLE-001', managerName: '김철수 과장', managerEmail: 'cs.kim@example.com', priority: 'HIGH' }],
[{ name: '예시) (주)한빛정밀', code: 'PART-EXAMPLE-001', managerName: '김철수 과장', managerEmail: 'cs.kim@example.com', totalRevenue: '5000000000' }],
);
}
@ -123,7 +123,7 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
code: r['식별코드'] ?? '',
managerName: r['담당자명'] ?? '',
managerEmail: r['담당자이메일'] ?? '',
priority: r['우선순위'] ?? 'MEDIUM',
totalRevenue: r['총매출액'] ?? '',
}));
setExcelFile(file.name);
setRows(loaded);

View File

@ -1,4 +1,4 @@
import { useForm, Controller } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Trash2 } from 'lucide-react';
@ -9,8 +9,7 @@ import { Typography } from '@/components/ui/typography';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Sheet } from '@/components/ui/sheet';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { type Partner, priorityOptions } from '../types';
import { type Partner } from '../types';
const schema = z.object({
name: z.string().trim().min(1, '회사명/협력사명을 작성해 주십시오.'),
@ -18,7 +17,7 @@ const schema = z.object({
managerName: z.string().trim().min(1, '담당자명을 입력해 주십시오.'),
managerEmail: z.string().trim().email('정확한 담당자 이메일 형식을 점검해 주십시오.'),
managerPhone: z.string().trim().min(1, '담당자 연락처를 입력해 주십시오.'),
priority: z.string(),
totalRevenue: z.string().trim().optional(), // 총매출액(원)
});
type FormValues = z.infer<typeof schema>;
@ -42,7 +41,7 @@ function buildDefaults(mode: 'create' | 'edit', partner: Partner | null): FormVa
managerName: partner.manager_name || '',
managerEmail: partner.manager_email || '',
managerPhone: partner.manager_contact_number || '',
priority: partner.priority || 'MEDIUM',
totalRevenue: partner.total_revenue != null ? String(partner.total_revenue) : '',
};
}
return {
@ -51,7 +50,7 @@ function buildDefaults(mode: 'create' | 'edit', partner: Partner | null): FormVa
managerName: '',
managerEmail: '',
managerPhone: '010-',
priority: 'MEDIUM',
totalRevenue: '',
};
}
@ -68,7 +67,6 @@ export function PartnerFormSheet({
}: PartnerFormSheetProps) {
const {
register,
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
@ -83,7 +81,7 @@ export function PartnerFormSheet({
manager_name: v.managerName,
manager_email: v.managerEmail,
manager_contact_number: v.managerPhone,
priority: v.priority,
total_revenue: v.totalRevenue?.trim() ? Number(v.totalRevenue.replace(/[^0-9]/g, '')) : undefined,
};
if (mode === 'create') {
@ -138,26 +136,16 @@ export function PartnerFormSheet({
/>
{errors.code && <p className="text-[10px] text-rose-500">{errors.code.message}</p>}
</div>
{/* Priority */}
{/* 총매출액 */}
<div className="space-y-1">
<Typography as="label" variant="label"> </Typography>
<Controller
control={control}
name="priority"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="form-partner-priority" className="w-full">
<SelectValue>
{(value) => priorityOptions.find((o) => o.value === value)?.label ?? ''}
</SelectValue>
</SelectTrigger>
<SelectContent>
{priorityOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
<Typography as="label" variant="label"> (, )</Typography>
<Input
id="form-partner-revenue"
type="number"
min={0}
{...register('totalRevenue')}
className={inputClass}
placeholder="예: 5000000000"
/>
</div>
</div>

View File

@ -1,4 +1,3 @@
import { Badge } from '@/components/ui/badge';
import { DataTable } from '@/components/ui/data-table';
import { TablePagination } from '@/components/ui/table-pagination';
import type { Partner } from '../types';
@ -13,14 +12,6 @@ type PartnerTableProps = {
onPageChange: (page: number) => void;
};
// 우선순위 배지 색상 — HIGH(빨강)/MEDIUM(주황)/그외(회색)
const priorityBadgeClass = (priority?: string | null) =>
priority === 'HIGH'
? 'bg-red-50 text-red-700 dark:bg-rose-950/20 dark:text-rose-400 border border-red-200'
: priority === 'MEDIUM'
? 'bg-amber-50 text-amber-700 dark:bg-amber-950/20 dark:text-amber-400 border border-amber-200'
: 'bg-zinc-100 text-zinc-600 border border-zinc-300';
export function PartnerTable({
data,
onRowClick,
@ -74,16 +65,10 @@ export function PartnerTable({
),
},
{
header: '우선 선정 대상자',
align: 'center',
cell: (part) => (
<Badge
variant="outline"
className={`inline-flex items-center px-2 py-0.5 text-[10px] font-bold rounded-full ${priorityBadgeClass(part.priority)}`}
>
{part.priority}
</Badge>
),
header: '총매출액',
align: 'right',
cellClassName: 'font-mono text-muted-foreground',
cell: (part) => (part.total_revenue != null ? `${Number(part.total_revenue).toLocaleString()}` : '-'),
},
]}
/>

View File

@ -1,11 +1 @@
export type { Partner } from '@/types';
// 우선순위 필터 목록. 'ALL'은 필터 전용(폼에서는 제외).
export const prioritiesList = ['ALL', 'HIGH', 'MEDIUM', 'LOW'];
// 폼 우선순위 선택지(라벨 포함).
export const priorityOptions: { value: string; label: string }[] = [
{ value: 'HIGH', label: 'HIGH (핵심 조달처)' },
{ value: 'MEDIUM', label: 'MEDIUM (일반 벤더)' },
{ value: 'LOW', label: 'LOW (서브 보조처)' },
];

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { X, PlusSquare, ArrowRight, Loader2 } from 'lucide-react';
import { X, PlusSquare, ArrowRight, Loader2, Gavel } from 'lucide-react';
import { useNavigate } from 'react-router';
import { useGetSupplierLastType } from '@/api/generated/quotation/quotation';
import { Button } from '@/components/ui/button';
@ -10,7 +10,16 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
import type { CreateQuotationInput } from '../hooks/useQuotations';
import { QuotationType } from '@/api/generated/model';
import { QUOTATION_TYPE_OPTIONS, supplierTypeOptions, isNewQuotationType } from '../types';
import {
supplierTypeOptions,
is1v1,
toQuotationType,
awardStrategySummary,
PriceGateAction,
DEFAULT_MID_ACTION,
DEFAULT_OVER_ACTION,
type QuotationMode,
} from '../types';
import { supplierTypeLabel } from '@/lib/enumLabels';
import { showToast } from '@/lib/notify';
@ -42,7 +51,9 @@ export function QuotationCreateModal({
}: QuotationCreateModalProps) {
const [step, setStep] = useState(1);
const [title, setTitle] = useState('');
const [type, setType] = useState<number>(QuotationType.REQUOTE);
// 유형은 '진행 방식(협상/경매) × 대상(신규/후속)' 2축으로 받아 제출 직전 4코드로 합성한다.
const [mode, setMode] = useState<QuotationMode>('nego'); // 1:1 협상 / 1:N 경매
const [isNew, setIsNew] = useState(true); // 신규 / 후속(재)
const [productId, setProductId] = useState('');
const [selectedPartnerIds, setSelectedPartnerIds] = useState<string[]>([]);
const [dueDate, setDueDate] = useState(nowKstLocalInput);
@ -51,8 +62,18 @@ export function QuotationCreateModal({
const [memo, setMemo] = useState('');
const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 상품값으로 목표가 산정
const [supplierType, setSupplierType] = useState(''); // 협력사 유형(SupplierType). 전 유형에서 입력 → 견적에 기록
const [midAction, setMidAction] = useState<number>(DEFAULT_MID_ACTION); // 앵커~목표가 구간: 낙찰/개찰 (1:1 전용)
const [overAction, setOverAction] = useState<number>(DEFAULT_OVER_ACTION); // 목표가 초과 구간: 낙찰/개찰 (1:1 전용)
const [submitting, setSubmitting] = useState(false);
const typeOptions = QUOTATION_TYPE_OPTIONS;
const type = toQuotationType(mode, isNew); // 4코드 합성값
const oneToOne = is1v1(type); // 1:1 협상 여부 — 협력사 단일선택·낙찰기준·협상카드 노출을 가른다
const isReType = !isNew;
// 스텝 구성 — 1:1 협상만 협상카드 스텝 추가(작은 화면 과밀 방지). 경매는 3스텝.
const steps = oneToOne
? ['기본 정보', '협력사 초청', '낙찰 기준', '협상카드']
: ['기본 정보', '협력사 초청', '확인·완료'];
const totalSteps = steps.length;
// 협력사 유형은 전 유형에서 입력받되, 재협상(1:1)이면 선택 협력사의 직전 견적 supplier_type 을 조회해 디폴트로 채운다.
const renegoSupplierId = type === QuotationType.RENEGO ? (selectedPartnerIds[0] ?? '') : '';
@ -69,7 +90,6 @@ export function QuotationCreateModal({
const navigate = useNavigate();
// 인터넷최저가·매입가·판매가는 상품 속성 — 모달에선 읽기전용으로만 보여주고, 수정은 상품 상세에서 한다.
const selectedProduct = products.find((p) => p.id === productId);
const isReType = !isNewQuotationType(type);
const internetLowest = selectedProduct?.internet_lowest_price ?? null;
const purchase = selectedProduct?.purchase_price ?? null;
const selling = selectedProduct?.selling_price ?? null;
@ -82,16 +102,25 @@ export function QuotationCreateModal({
if (!open) return null;
const selectMode = (next: QuotationMode) => {
setMode(next);
// 경매→협상 전환 시 다중선택으로 담긴 협력사를 1곳으로 줄인다(1:1 단일선택).
if (next === 'nego') setSelectedPartnerIds((prev) => prev.slice(0, 1));
// 경매는 3스텝뿐 — 협상카드 스텝(4)에 있던 상태면 마지막(3)으로 당긴다.
if (next === 'auction') setStep((s) => Math.min(s, 3));
};
const togglePartner = (id: string) =>
setSelectedPartnerIds((prev) =>
type === QuotationType.RENEGO
? prev.includes(id) ? [] : [id]
: prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id],
oneToOne
? prev.includes(id)
? []
: [id]
: prev.includes(id)
? prev.filter((p) => p !== id)
: [...prev, id],
);
const toggleCard = (id: string) =>
setSelectedCardIds((prev) =>
prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id],
);
setSelectedCardIds((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]));
const handleSubmit = async () => {
if (submitting) return;
@ -102,7 +131,7 @@ export function QuotationCreateModal({
showToast('목표가 산정에 쓸 값이 없습니다 — MD 제시가를 입력하거나, 상품 상세에서 인터넷최저가·매입가를 채워주세요.', 'error');
return; // finally 에서 submitting 해제
}
// 서버가 견적+세션 생성을 끝내고 응답할 때까지 기다린 뒤에 완료(닫기) 처리한다.
// 낙찰 기준은 1:1 협상만 전송(경매는 미전송 → 서버가 mid=over=AWARD 강제). 카드도 1:1 전용.
const ok = await onCreate({
title,
type,
@ -110,10 +139,12 @@ export function QuotationCreateModal({
partnerIds: selectedPartnerIds,
dueDate,
settingId,
cardIds: selectedCardIds,
cardIds: oneToOne ? selectedCardIds : [],
memo,
mdPrice: mdPrice ? Number(mdPrice) : null,
supplierType: supplierType ? Number(supplierType) : null,
midAction: oneToOne ? midAction : undefined,
overAction: oneToOne ? overAction : undefined,
});
if (ok) onClose();
} finally {
@ -138,26 +169,26 @@ export function QuotationCreateModal({
<div className="flex items-center justify-between pb-4 border-b border-border">
<div className="flex items-center gap-2">
<PlusSquare className="text-foreground" size={18} />
<Typography variant="small" className="font-bold"> ( {step}/3)</Typography>
<Typography variant="small" className="font-bold"> ( {step}/{totalSteps})</Typography>
</div>
<button onClick={onClose} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
<X size={18} />
</button>
</div>
{/* Steps indicator */}
{/* Steps indicator — 스텝 수는 유형에 따라 3(경매)/4(협상) */}
<div className="flex items-center justify-between gap-2 py-4 border-b border-border/40 text-muted-foreground">
<button type="button" onClick={() => setStep(1)} className="cursor-pointer hover:opacity-75 transition-opacity">
<Typography as="span" variant="body" className={`font-semibold ${step >= 1 ? 'text-primary' : 'text-muted-foreground'}`}>1. </Typography>
</button>
<ArrowRight size={14} className="shrink-0" />
<button type="button" onClick={() => setStep(2)} className="cursor-pointer hover:opacity-75 transition-opacity">
<Typography as="span" variant="body" className={`font-semibold ${step >= 2 ? 'text-primary' : 'text-muted-foreground'}`}>2. </Typography>
</button>
<ArrowRight size={14} className="shrink-0" />
<button type="button" onClick={() => setStep(3)} className="cursor-pointer hover:opacity-75 transition-opacity">
<Typography as="span" variant="body" className={`font-semibold ${step >= 3 ? 'text-primary' : 'text-muted-foreground'}`}>3. ·</Typography>
</button>
{steps.map((label, i) => {
const n = i + 1;
return (
<div key={label} className="flex items-center gap-2 min-w-0">
{i > 0 && <ArrowRight size={14} className="shrink-0" />}
<button type="button" onClick={() => setStep(n)} className="cursor-pointer hover:opacity-75 transition-opacity truncate">
<Typography as="span" variant="body" className={`font-semibold ${step === n ? 'text-primary' : 'text-muted-foreground'}`}>{n}. {label}</Typography>
</button>
</div>
);
})}
</div>
{/* Step content */}
@ -165,41 +196,41 @@ export function QuotationCreateModal({
{step === 1 && (
<div className="space-y-4">
{/* 견적 유형이 맨 위 — 신규/재 여부가 아래 매입가 필수 여부까지 결정한다 */}
{/* 진행 방식 × 대상 2축 — 협상/경매 갈림이 아래 카드·낙찰기준 노출까지 결정한다 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1">
<Typography as="label" variant="label"> </Typography>
<Select
value={String(type)}
onValueChange={(v) => {
const next = Number(v);
setType(next);
if (next === QuotationType.RENEGO) setSelectedPartnerIds((prev) => prev.slice(0, 1));
}}
>
<SelectTrigger id="wizard-type" className="w-full">
<SelectValue>
{(value) => typeOptions.find((o) => String(o.value) === value)?.label ?? ''}
</SelectValue>
</SelectTrigger>
<SelectContent>
{typeOptions.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Typography as="label" variant="label"></Typography>
<input
id="wizard-date"
type="datetime-local"
className="w-full p-2 bg-background border border-border rounded text-xs"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
<Typography as="label" variant="label"> </Typography>
<Segmented
options={[
{ value: 'nego', label: '1:1 협상', sub: '협상카드·낙찰기준 선택' },
{ value: 'auction', label: '1:N 견적', sub: '최저가 자동 낙찰' },
]}
value={mode}
onChange={(v) => selectMode(v as QuotationMode)}
/>
</div>
<div className="space-y-1">
<Typography as="label" variant="label"></Typography>
<Segmented
options={[
{ value: 'new', label: '신규', sub: '처음 진행하는 견적' },
{ value: 're', label: '후속·재', sub: '이전 견적에 이어 진행' },
]}
value={isNew ? 'new' : 're'}
onChange={(v) => setIsNew(v === 'new')}
/>
</div>
</div>
<div className="space-y-1">
<Typography as="label" variant="label"></Typography>
<input
id="wizard-date"
type="datetime-local"
className="w-full p-2 bg-background border border-border rounded text-xs"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
</div>
<div className="space-y-1">
@ -216,7 +247,7 @@ export function QuotationCreateModal({
<div className="space-y-1">
<Typography as="label" variant="label"></Typography>
<Select value={productId} onValueChange={setProductId}>
<Select value={productId} onValueChange={(v) => setProductId(v ?? '')}>
<SelectTrigger id="wizard-product" className="w-full">
<SelectValue>
{(value) => {
@ -296,7 +327,7 @@ export function QuotationCreateModal({
{step === 2 && (
<div className="space-y-3">
<Typography as="span" variant="label" className="block"> ({type === QuotationType.RENEGO ? '단일선택' : '다중선택'})</Typography>
<Typography as="span" variant="label" className="block"> ({oneToOne ? '단일선택' : '다중선택'})</Typography>
<div className="border border-border rounded overflow-hidden max-h-56 overflow-y-auto divide-y divide-border bg-background">
{partners.map((part) => {
const isChecked = selectedPartnerIds.includes(part.id ?? '');
@ -314,16 +345,9 @@ export function QuotationCreateModal({
/>
<div>
<Typography as="span" variant="small" className="font-semibold block">{part.name}</Typography>
<Typography as="span" variant="small" className="text-muted-foreground">: {part.managerEmail} · : {part.rank}</Typography>
<Typography as="span" variant="small" className="text-muted-foreground">: {part.managerEmail}</Typography>
</div>
</div>
<span
className={`text-[9px] font-mono px-2 py-0.5 rounded ${
part.priority === 'HIGH' ? 'bg-red-50 text-red-700' : 'bg-zinc-100 text-zinc-600'
}`}
>
{part.priority}
</span>
</label>
);
})}
@ -357,13 +381,13 @@ export function QuotationCreateModal({
<div className="space-y-4">
<div className="space-y-1 font-mono text-xs">
<Typography as="label" variant="label"> </Typography>
<Select value={settingId} onValueChange={setSettingId}>
<Select value={settingId} onValueChange={(v) => setSettingId(v ?? '')}>
<SelectTrigger id="wizard-setting-select" className="w-full">
<SelectValue>
{(value) => {
const qs = quotationSettings.find((s) => s.qt_setting_id === value);
return qs
? `[목표 마진: ${qs.target_margin}] ${qs.anchoring_value} (${qs.card_use_count})`
? `[목표 마진: ${qs.target_margin}] 카드 ${qs.card_use_count}`
: '';
}}
</SelectValue>
@ -371,45 +395,42 @@ export function QuotationCreateModal({
<SelectContent>
{quotationSettings.map((qs) => (
<SelectItem key={qs.qt_setting_id} value={qs.qt_setting_id}>
[ : {qs.target_margin}] {qs.anchoring_value} ({qs.card_use_count})
[ : {qs.target_margin}] {qs.card_use_count}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Typography as="span" variant="label" className="block"> </Typography>
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto">
{cards.filter((c) => !c.isWildcard || c.status === 'ACTIVE').map((card) => {
const isChecked = selectedCardIds.includes(card.id);
return (
<div
key={card.id}
onClick={() => toggleCard(card.id)}
className={`p-2.5 rounded border cursor-pointer transition-all flex items-start gap-2 ${
isChecked ? 'bg-primary/5 border-primary font-bold' : 'bg-background border-border hover:bg-muted/10'
}`}
>
<input type="checkbox" checked={isChecked} readOnly className="accent-primary h-3.5 w-3.5 mt-0.5" />
<div>
<div className="flex items-center gap-1.5">
<Typography as="span" variant="small" className="text-muted-foreground font-mono block leading-none">{card.code}</Typography>
<span
className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${
card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'
}`}
>
{card.isWildcard ? '와일드' : '협상'}
</span>
</div>
<Typography as="span" variant="small" className="mt-1 block leading-tight">{card.title}</Typography>
</div>
</div>
);
})}
{oneToOne ? (
<>
{/* 낙찰 기준(1:1 전용) — 스펙트럼 = 선택. 낙찰선을 앵커/목표가 중 택1, 목표가 초과는 항상 개찰. */}
<AwardLinePicker
mid={midAction}
over={overAction}
// 단조성: 초과=낙찰이면 앵커~목표가도 낙찰, 앵커~목표가=개찰이면 초과도 개찰(혼합 조합 방지)
onMid={(v) => {
setMidAction(v);
if (v === PriceGateAction.OPEN) setOverAction(PriceGateAction.OPEN);
}}
onOver={(v) => {
setOverAction(v);
if (v === PriceGateAction.AWARD) setMidAction(PriceGateAction.AWARD);
}}
/>
</>
) : (
/* 경매(1:N) — 낙찰 기준·협상카드 없음. 최저가 자동 낙찰 안내만. */
<div className="rounded border border-border bg-muted/20 p-3 flex items-start gap-2.5">
<Gavel size={18} className="text-primary mt-0.5 shrink-0" />
<div>
<Typography as="span" variant="small" className="font-semibold block"> </Typography>
<Typography as="span" variant="small" className="text-muted-foreground text-[10px] leading-snug">
1:N . · .
</Typography>
</div>
</div>
</div>
)}
<div className="space-y-1">
<Typography as="label" variant="label"> ()</Typography>
@ -426,6 +447,45 @@ export function QuotationCreateModal({
</div>
)}
{/* Step 4 — 협상카드(1:1 협상 전용, 별도 스텝으로 분리해 과밀 방지) */}
{step === 4 && oneToOne && (
<div className="space-y-2">
<Typography as="span" variant="label" className="block"> ()</Typography>
<Typography as="span" variant="small" className="block text-[10px] text-muted-foreground">
1:1 AI .
</Typography>
<div className="grid grid-cols-2 gap-2 max-h-72 overflow-y-auto">
{cards.filter((c) => !c.isWildcard || c.status === 'ACTIVE').map((card) => {
const isChecked = selectedCardIds.includes(card.id);
return (
<div
key={card.id}
onClick={() => toggleCard(card.id)}
className={`p-2.5 rounded border cursor-pointer transition-all flex items-start gap-2 ${
isChecked ? 'bg-primary/5 border-primary font-bold' : 'bg-background border-border hover:bg-muted/10'
}`}
>
<input type="checkbox" checked={isChecked} readOnly className="accent-primary h-3.5 w-3.5 mt-0.5" />
<div>
<div className="flex items-center gap-1.5">
<Typography as="span" variant="small" className="text-muted-foreground font-mono block leading-none">{card.code}</Typography>
<span
className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${
card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'
}`}
>
{card.isWildcard ? '와일드' : '협상'}
</span>
</div>
<Typography as="span" variant="small" className="mt-1 block leading-tight">{card.title}</Typography>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
{/* Footer nav */}
@ -440,7 +500,7 @@ export function QuotationCreateModal({
</Button>
<div className="flex gap-2">
{step < 3 ? (
{step < totalSteps ? (
<Button
type="button"
size="sm"
@ -466,3 +526,117 @@ export function QuotationCreateModal({
</div>
);
}
// ── 헬퍼 컴포넌트 (메인 아래) ──────────────────────────────────────────────
// 세그먼트 컨트롤 — 소수의 명명된 이산 선택(진행 방식·대상)에 라디오보다 명확. 값은 문자열.
function Segmented({
options,
value,
onChange,
}: {
options: { value: string; label: string; sub?: string }[];
value: string;
onChange: (v: string) => void;
}) {
return (
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}>
{options.map((o) => {
const active = o.value === value;
return (
<button
key={o.value}
type="button"
onClick={() => onChange(o.value)}
className={cn(
'rounded border p-2.5 text-left transition-all cursor-pointer',
active ? 'bg-primary/5 border-primary' : 'bg-background border-border hover:bg-muted/20',
)}
>
<Typography as="span" variant="small" className={cn('block font-semibold', active && 'text-primary')}>{o.label}</Typography>
{o.sub && <Typography as="span" variant="small" className="block text-[10px] text-muted-foreground mt-0.5 leading-tight">{o.sub}</Typography>}
</button>
);
})}
</div>
);
}
// 낙찰 기준 컨트롤 — 스펙트럼 선(線) 위 구간을 눌러 낙찰↔개찰 전환. 앵커 이하는 항상 낙찰(고정, 표시만) —
// '앵커~목표가'(mid_action)·'목표가 초과'(over_action) 두 구간만 사용자가 각각 낙찰/개찰로 정한다.
function AwardLinePicker({
mid, over, onMid, onOver,
}: {
mid: number;
over: number;
onMid: (v: number) => void;
onOver: (v: number) => void;
}) {
const A = PriceGateAction.AWARD;
const O = PriceGateAction.OPEN;
const zones = [
{ label: '앵커링가 이하', win: true, locked: true, toggle: undefined },
{ label: '앵커링가~목표가', win: mid === A, locked: false, toggle: () => onMid(mid === A ? O : A) },
{ label: '목표가 초과', win: over === A, locked: false, toggle: () => onOver(over === A ? O : A) },
];
return (
<div className="space-y-1.5 pt-1">
<div className="flex items-center justify-between">
<Typography as="span" variant="label"> </Typography>
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground"> · </Typography>
</div>
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
<span className="font-semibold text-foreground"> </span> / .
</Typography>
{/* 스펙트럼 선: 구간이 곧 선택 버튼 */}
<div className="flex rounded-md overflow-hidden border border-border text-center">
{zones.map((z, i) => {
const body = (
<>
<Typography as="span" variant="small" className="block text-[9px] leading-tight text-muted-foreground">{z.label}</Typography>
<Typography as="span" variant="small" className={cn('block text-[12px] font-bold leading-tight', z.win ? 'text-emerald-700 dark:text-emerald-400' : 'text-zinc-500')}>
{z.win ? '낙찰' : '개찰'}{z.locked ? ' 🔒' : ''}
</Typography>
</>
);
const cls = cn('flex-1 px-1 py-2', i > 0 && 'border-l border-border', z.win ? 'bg-emerald-50 dark:bg-emerald-950/30' : 'bg-muted');
return z.locked ? (
<div key={z.label} className={cls} title="앵커링가 이하는 항상 낙찰(고정)">{body}</div>
) : (
<button key={z.label} type="button" onClick={z.toggle} className={cn(cls, 'cursor-pointer transition-[filter] hover:brightness-95')}>{body}</button>
);
})}
</div>
{/* 경계 마커 — 구간 경계(1/3·2/3)에 ▲ 중앙 정렬(앵커링가·목표가) */}
<div className="relative h-6">
{[
{ left: '33.3333%', label: '앵커링가' },
{ left: '66.6667%', label: '목표가' },
].map((mk) => (
<span
key={mk.label}
className="absolute top-0 flex -translate-x-1/2 flex-col items-center text-[9px] text-muted-foreground"
style={{ left: mk.left }}
>
<span className="leading-none"></span>
<span className="leading-tight whitespace-nowrap">{mk.label}</span>
</span>
))}
</div>
{/* 전략 한 줄 요약(관대/기본/엄격 전략) — 기본 전략(목표가까지 낙찰·초과 개찰)일 때만 경계가 애매하니 '목표가 포함' 부기 */}
{(() => {
const t = awardStrategySummary(mid, over);
const isBasic = mid === PriceGateAction.AWARD && over === PriceGateAction.OPEN;
return (
<Typography as="p" variant="small" className="text-[11px]">
<span className="font-bold text-primary">{t.strategy}</span>
<span className="text-muted-foreground"> · {t.desc}</span>
{isBasic && (
<span className="text-emerald-700 dark:text-emerald-400 font-semibold"> · </span>
)}
</Typography>
);
})()}
</div>
);
}

View File

@ -13,7 +13,7 @@ import {
type QuotationSetting,
type SessionView,
quotationTypeLabel,
priceGateActionLabel,
awardCriterionLabel,
fmtDateTime,
} from '../../types';
@ -89,6 +89,7 @@ export function DrawerHeaderCards({
<InfoField label="견적번호" value={q_number} />
<InfoField label="유형" value={quotationTypeLabel(quotation.type)} />
<InfoField label="차수" value={`${q_round}`} />
<InfoField label="낙찰 기준" value={awardCriterionLabel(quotation.type, quotation.mid_action, quotation.over_action)} valueClassName="font-sans" />
<InfoField label="목표가">
{repSessionId ? (
<button
@ -169,11 +170,7 @@ export function DrawerHeaderCards({
value={selectedSettingObj.target_margin}
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={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>

View File

@ -78,7 +78,7 @@ export function RegenerateModal({ open, partners, sessionStatusBySupplier, defau
<div className="min-w-0">
<Typography as="span" variant="small" className="font-semibold block truncate">{part.name}</Typography>
<Typography as="span" variant="small" className="text-muted-foreground">
: {part.managerEmail} · : {part.rank}
: {part.managerEmail}
</Typography>
</div>
</div>

View File

@ -15,11 +15,10 @@ import {
const won = (n?: number | null) => (n != null ? `${n.toLocaleString()}` : '-');
// 결과 상태별 배지 톤(협상현황 pill 팔레트 재사용).
// 결과 상태별 배지 톤(협상현황 pill 팔레트 재사용). 개찰=주황(낙찰자 미정, 수동 처리 필요).
const OUTCOME_TONE: Record<ChainRoundState, PillTone> = {
awarded: 'emerald',
regenerated: 'amber',
failed: 'blue',
opened: 'amber',
active: 'zinc',
};

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, priceGateActionLabel, PRICE_GATE_ACTION_OPTIONS } from '../types';
import { type QuotationSetting } from '../types';
import type { SettingInput } from '../hooks/useQuotations';
type QuotationSettingsModalProps = {
@ -23,27 +23,17 @@ export function QuotationSettingsModal({
onClose,
}: QuotationSettingsModalProps) {
const [targetMargin, setTargetMargin] = useState('');
const [anchoringValue, setAnchoringValue] = useState('');
const [cardUseCount, setCardUseCount] = useState('');
const [midAction, setMidAction] = useState(1); // PriceGateAction 기본 낙찰
const [overAction, setOverAction] = useState(1);
const [regenLimit, setRegenLimit] = useState('1');
if (!open) return null;
// 세팅은 목표 마진율·카드 사용 횟수만. 낙찰 정책은 견적 생성으로 이관, 앵커링은 칸 rate(v1.2)로 대체.
const handleAdd = (e: React.FormEvent) => {
e.preventDefault();
const ok = onAdd({
targetMargin, anchoringValue, cardUseCount,
midAction, overAction, regenLimit: parseInt(regenLimit, 10),
});
const ok = onAdd({ targetMargin, cardUseCount });
if (ok) {
setTargetMargin('');
setAnchoringValue('');
setCardUseCount('');
setMidAction(1);
setOverAction(1);
setRegenLimit('1');
}
};
@ -70,18 +60,14 @@ export function QuotationSettingsModal({
<TableHeader className="bg-muted text-muted-foreground">
<TableRow>
<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={7} className="p-6 text-center text-muted-foreground">
<TableCell colSpan={3} className="p-6 text-center text-muted-foreground">
. ( )
</TableCell>
</TableRow>
@ -89,11 +75,7 @@ export function QuotationSettingsModal({
{settings.map((qs) => (
<TableRow key={qs.qt_setting_id} className="hover:bg-muted/30">
<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"
@ -119,42 +101,10 @@ export function QuotationSettingsModal({
<Typography as="label" variant="muted" className="text-[10px] font-semibold"> (%)</Typography>
<Input type="number" step="0.1" value={targetMargin} onChange={(e) => setTargetMargin(e.target.value)} placeholder="예: 12" />
</div>
<div className="space-y-1">
<Typography as="label" variant="muted" className="text-[10px] font-semibold"> </Typography>
<Input type="number" step="0.01" value={anchoringValue} onChange={(e) => setAnchoringValue(e.target.value)} placeholder="예: 0.01" />
</div>
<div className="space-y-1">
<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

@ -11,9 +11,10 @@ import {
quotationStatusLabel,
quotationTypeLabel,
chainRoundState,
is1v1,
CHAIN_ROUND_STATE_LABEL,
} from '../types';
import { QuotationType, QuotationStatus } from '@/api/generated/model';
import { QuotationStatus } from '@/api/generated/model';
type QuotationTableProps = {
data: Estimate[];
@ -37,15 +38,13 @@ const statusBadgeClass = (status?: number | null) => {
}
};
// 마감결과 배지 색. 낙찰=초록/재생성=주황/결렬=로즈/진행중=회색(아직 마감 전).
// 마감결과 배지 색. 낙찰=초록 / 개찰(낙찰자 미정, 수동 처리)=주황 / 진행중=회색(아직 마감 전).
const outcomeBadgeClass = (state: ChainRoundState) => {
switch (state) {
case 'awarded':
return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/25 dark:text-emerald-400 border-emerald-300/50';
case 'regenerated':
case 'opened':
return 'bg-amber-50 text-amber-700 dark:bg-amber-950/25 dark:text-amber-400 border-amber-300/50';
case 'failed':
return 'bg-rose-50 text-rose-700 dark:bg-rose-950/25 dark:text-rose-400 border-rose-300/50';
default:
return 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800/40 dark:text-zinc-400 border-zinc-300/40';
}
@ -104,15 +103,12 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo
{
header: '유형',
align: 'center',
// 유형은 고정 속성이라 pill 대신 평문 — 협상(1:1)만 살짝 진하게, 경매(1:N)는 연하게.
cell: (est) => (
<Typography
as="span"
variant="small"
className={`px-2 py-0.5 rounded-full text-[9px] font-bold ${
est.type === QuotationType.RENEGO
? 'bg-neutral-900 text-white dark:bg-zinc-100 dark:text-black'
: 'bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-200'
}`}
className={cn('text-xs', is1v1(est.type) ? 'font-semibold text-foreground' : 'text-muted-foreground')}
>
{quotationTypeLabel(est.type)}
</Typography>

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, type PriceGateAction } from '@/api/generated/model';
import { QuotationStatus } from '@/api/generated/model';
export type CreateQuotationInput = {
title: string;
@ -40,15 +40,14 @@ export type CreateQuotationInput = {
memo: string;
mdPrice?: number | null; // MD 제시가(원). 비우면 미전송 → 서버가 기존 마진식으로 목표가 산정
supplierType?: number | null; // 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록
// 낙찰 기준 — 1:1 협상만 전송(경매는 미전송 → 서버가 mid=over=AWARD 강제). 2전략을 mid/over 로 전개해 담는다(over 항상 OPEN).
midAction?: number; // PriceGateAction (앵커~목표가 처리: 낙찰/개찰)
overAction?: number; // PriceGateAction (목표가 초과 처리: 협상은 항상 개찰)
};
export type SettingInput = {
targetMargin: string;
anchoringValue: string;
cardUseCount: string;
midAction?: number; // PriceGateAction (앵커~목표 마감처리). 미지정=1(낙찰)
overAction?: number; // PriceGateAction (목표초과 마감처리). 미지정=1(낙찰)
regenLimit?: number; // 재생성 최대 횟수(체인 전체, 사유 무관). 미지정=1
};
// 견적 화면 데이터 허브.
@ -124,18 +123,15 @@ export function useQuotations(params: ListQuotationsParams) {
// 백엔드는 target_margin_rate 를 비율(0.12)로 저장하므로 % 입력을 100 으로 나눠 보낸다.
const addSetting = (input: SettingInput): boolean => {
const marginPct = Number(String(input.targetMargin).replace('%', '').trim());
const anchoring = Number(String(input.anchoringValue).trim());
const cardCount = parseInt(String(input.cardUseCount).replace(/[^0-9-]/g, ''), 10);
if (!Number.isFinite(marginPct) || !Number.isFinite(anchoring) || !Number.isInteger(cardCount)) {
showToast('목표 마진율·앵커링 값·카드 사용 횟수를 숫자로 입력해야 합니다.', 'error');
if (!Number.isFinite(marginPct) || !Number.isInteger(cardCount)) {
showToast('목표 마진율·카드 사용 횟수를 숫자로 입력해야 합니다.', 'error');
return false;
}
createSettingMutation.mutate(
// 낙찰 정책은 견적 생성으로 이관, 앵커링은 칸 rate(v1.2) → 세팅은 목표 마진율·카드 사용 횟수만.
{ data: {
target_margin_rate: marginPct / 100, anchoring_value: anchoring, card_count: cardCount,
mid_action: (input.midAction ?? 1) as PriceGateAction,
over_action: (input.overAction ?? 1) as PriceGateAction,
regen_limit: Number.isInteger(input.regenLimit) ? (input.regenLimit as number) : 1,
target_margin_rate: marginPct / 100, card_count: cardCount,
} },
{
onSuccess: () => {
@ -199,6 +195,9 @@ export function useQuotations(params: ListQuotationsParams) {
memo: input.memo.trim() || undefined,
md_price: input.mdPrice && input.mdPrice > 0 ? input.mdPrice : undefined,
supplier_type: input.supplierType ?? undefined,
// 낙찰 기준은 1:1 협상만 전송(모달이 미리 걸러 담음) — 경매면 미전송 → 서버가 AWARD 강제.
mid_action: input.midAction ?? undefined,
over_action: input.overAction ?? undefined,
};
try {

View File

@ -4,12 +4,17 @@ import type { QuotationSettingData } from '@/api/generated/model/quotationSettin
import type { QuotationData } from '@/api/generated/model/quotationData';
import type { SessionData } from '@/api/generated/model/sessionData';
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
import { QuotationType, QuotationStatus, SessionStatus, CardType, CloseReason, PriceGateAction } from '@/api/generated/model';
import { QuotationType, QuotationStatus, SessionStatus, CardType, CloseReason } from '@/api/generated/model';
import { DELIVERY_TYPE_LABEL } from '@/lib/enumLabels';
import type { Product, Partner, NegotiationCard } from '@/types';
export type { Product, Partner, NegotiationCard } from '@/types';
// 낙찰 기준 가격게이트 값 — 백엔드 mid_action/over_action(SMALLINT)에 그대로 저장.
// (백엔드 스키마가 int 라 orval 이 enum 을 안 만들어 → 프론트 로컬 정의.) AWARD=낙찰, OPEN=개찰(낙찰자 미정 마감).
export const PriceGateAction = { AWARD: 1, OPEN: 2 } as const;
export type PriceGateAction = (typeof PriceGateAction)[keyof typeof PriceGateAction];
export type Estimate = Partial<QuotationData> & {
id?: string;
dueDate?: string;
@ -31,30 +36,12 @@ export interface QuotationSetting {
qt_setting_id: string;
user_id: string;
target_margin: string;
anchoring_value: string;
card_use_count: string;
mid_action: PriceGateAction; // 앵커링가<투찰가≤목표가 마감 처리
over_action: PriceGateAction; // 목표가<투찰가 마감 처리
regen_limit: number; // 재생성 최대 횟수(체인 전체, 사유 무관)
created_at: string;
updated_at: string;
deleted: boolean;
}
// 마감 가격정책 처리 라벨(견적세팅 표시·선택).
export const PRICE_GATE_ACTION_LABEL: Record<PriceGateAction, 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 {
@ -68,7 +55,6 @@ export function mapSupplier(sp: SupplierData): Partner {
managerName: sp.manager_name || '',
managerEmail: sp.manager_email || '',
managerPhone: sp.manager_contact_number || '',
rank: sp.priority === 'HIGH' ? 'S' : sp.priority === 'MEDIUM' ? 'A' : 'B',
status: 'ACTIVE',
};
}
@ -80,11 +66,7 @@ export function mapSetting(s: QuotationSettingData): QuotationSetting {
qt_setting_id: s.qt_setting_id,
user_id: s.user_id || '',
target_margin: `${Number.isFinite(ratePct) ? +ratePct.toFixed(2) : 0}%`,
anchoring_value: String(s.anchoring_value ?? ''),
card_use_count: `${s.card_count ?? 0}`,
mid_action: s.mid_action ?? PriceGateAction.AWARD,
over_action: s.over_action ?? PriceGateAction.AWARD,
regen_limit: s.regen_limit ?? 1,
created_at: s.created_at ?? '',
updated_at: s.updated_at ?? '',
deleted: false,
@ -174,36 +156,77 @@ export const QUOTATION_TYPE_OPTIONS = [
export const isNewQuotationType = (t?: number | null): boolean =>
t === QuotationType.NEW_NEGO || t === QuotationType.NEW_QUOTE;
// 1:1 협상(RENEGO/NEW_NEGO) 여부. 협상카드·낙찰기준 노출, 협력사 단일선택이 이 분기에 의존.
// 나머지(REQUOTE/NEW_QUOTE)는 1:N 경매 — 무조건 최저가 낙찰.
export const is1v1 = (t?: number | null): boolean =>
t === QuotationType.RENEGO || t === QuotationType.NEW_NEGO;
// 견적 생성 폼의 2축(진행 방식 × 대상) → 4코드 유형 합성.
export type QuotationMode = 'nego' | 'auction'; // 1:1 협상 / 1:N 경매
export const toQuotationType = (mode: QuotationMode, isNew: boolean): QuotationType =>
mode === 'nego'
? isNew
? QuotationType.NEW_NEGO
: QuotationType.RENEGO
: isNew
? QuotationType.NEW_QUOTE
: QuotationType.REQUOTE;
// ── 낙찰 기준(1:1 협상 전용) ──────────────────────────────────────────────
// 앵커링가 이하는 항상 낙찰(고정·선택 불가, 표시만). 사용자는 '앵커~목표가'(mid_action)와 '목표가 초과'(over_action)
// 두 구간만 각각 낙찰(AWARD)/개찰(OPEN)로 정한다. 개찰 = 낙찰자 미정으로 마감(결렬 아님, 담당자 수동 처리).
export const DEFAULT_MID_ACTION: PriceGateAction = PriceGateAction.AWARD; // 앵커~목표가 기본 낙찰
export const DEFAULT_OVER_ACTION: PriceGateAction = PriceGateAction.OPEN; // 목표가 초과 기본 개찰
// 견적의 낙찰 기준 한 줄 라벨(상세 드로어). 경매(1:N)=최저가 자동 낙찰. 협상=두 구간 조합 요약.
export function awardCriterionLabel(type?: number | null, mid?: number | null, over?: number | null): string {
if (type != null && !is1v1(type)) return '최저가 자동 낙찰';
const m = (mid ?? PriceGateAction.AWARD) === PriceGateAction.AWARD;
const o = (over ?? PriceGateAction.AWARD) === PriceGateAction.AWARD;
if (m && o) return '목표가 초과도 낙찰';
if (m && !o) return '목표가까지 낙찰';
if (!m && !o) return '앵커링가까지 낙찰';
return '목표가 초과만 낙찰'; // 비정상 조합(앵커~목표가 개찰인데 초과 낙찰)
}
// 낙찰 기준(mid, over) 조합 → 한 줄 전략 요약(생성 모달 안내). 낙찰 범위가 넓을수록 관대.
export function awardStrategySummary(mid?: number | null, over?: number | null): { strategy: string; desc: string } {
const m = (mid ?? PriceGateAction.AWARD) === PriceGateAction.AWARD;
const o = (over ?? PriceGateAction.AWARD) === PriceGateAction.AWARD;
if (m && o) return { strategy: '관대 전략', desc: '목표가를 초과해도 최저가면 낙찰' };
if (m && !o) return { strategy: '균형 전략', desc: '목표가 이내면 낙찰 · 초과는 개찰' };
if (!m && !o) return { strategy: '엄격 전략', desc: '앵커링가 이하만 낙찰 · 그 위는 개찰' };
return { strategy: '혼합 전략', desc: '앵커~목표가는 개찰인데 초과만 낙찰 · 권장 안 함' };
}
// 협력사 유형 선택지(견적생성 모달) — 라벨은 lib/enumLabels.ts 의 SUPPLIER_TYPE 단일 출처에서 파생.
export { SUPPLIER_TYPE_OPTIONS as supplierTypeOptions } from '@/lib/enumLabels';
// ── 라운드 체인(같은 견적번호) ───────────────────────────────────────────
// 한 라운드(견적)의 결과를 한 단어로. 낙찰=종료, 동가/마감=후속 라운드 가능, 진행중=아직 안 닫힘.
export type ChainRoundState = 'awarded' | 'regenerated' | 'failed' | 'active';
// 한 라운드(견적)의 결과를 한 단어로. 낙찰=종료 / 개찰=낙찰자 미정 마감(수동 재생성 가능) / 진행중=아직 안 닫힘.
export type ChainRoundState = 'awarded' | 'opened' | 'active';
export const CHAIN_ROUND_STATE_LABEL: Record<ChainRoundState, string> = {
awarded: '낙찰',
regenerated: '재생성',
failed: '결렬',
opened: '개찰',
active: '진행중',
};
const REGEN_CLOSE_REASONS: CloseReason[] = [CloseReason.REGEN_PRICE, CloseReason.REGEN_EQUAL, CloseReason.REGEN_NOSHOW];
const FAIL_CLOSE_REASONS: CloseReason[] = [CloseReason.FAIL_PRICE, CloseReason.FAIL_EQUAL, CloseReason.FAIL_NOSHOW, CloseReason.FAIL_REJECT];
const OPEN_CLOSE_REASONS: CloseReason[] = [
CloseReason.OPEN_PRICE, CloseReason.OPEN_EQUAL, CloseReason.OPEN_NOSHOW, CloseReason.OPEN_REJECT,
];
// 마감결과 한 단어. 서버 close_reason(CloseReason) 을 '단일 근거'로 판정한다.
// close_reason 이 아직 없는 옛 데이터만 preferred_sp_id/equal_bid_yn 플래그로 폴백.
// close_reason 이 아직 없는 옛 데이터만 preferred_sp_id 플래그로 폴백(마감됐는데 낙찰자 없으면 개찰).
// QuotationData(전체)·Estimate(Partial) 둘 다 받도록 필요한 필드만 optional.
export function chainRoundState(
q: { status?: number | null; close_reason?: number | null; preferred_sp_id?: string | null; equal_bid_yn?: boolean | null },
q: { status?: number | null; close_reason?: number | null; preferred_sp_id?: string | null },
): ChainRoundState {
if (q.close_reason != null) {
if (q.close_reason === CloseReason.AWARDED) return 'awarded';
if (REGEN_CLOSE_REASONS.includes(q.close_reason as CloseReason)) return 'regenerated';
if (FAIL_CLOSE_REASONS.includes(q.close_reason as CloseReason)) return 'failed';
if (OPEN_CLOSE_REASONS.includes(q.close_reason as CloseReason)) return 'opened';
}
if (q.preferred_sp_id) return 'awarded';
if (q.equal_bid_yn) return 'regenerated';
if (q.status === QuotationStatus.CLOSED) return 'failed';
if (q.status === QuotationStatus.CLOSED) return 'opened';
return 'active';
}
@ -240,7 +263,7 @@ export type QuotationCardView = {
// DB 저장값이 아니라 이미 로드된 quotation + 세션들로 파생한다(별도 컬럼·엔드포인트 없음).
// · 목표가 = md_price 우선(없으면 세션 목표가)
// · 낙찰가 = 우선협상자 세션의 투찰가(미낙찰이면 현재 최저 투찰가 = 잠정)
// · 절감 = 목표가 낙찰가 (양수 = 목표보다 저렴하게 낙찰)
// · 절감 = 목표가 낙찰가 (양수 = 목표보다 저렴하게 낙찰)
export type QuotationResultView = {
outcome: ChainRoundState;
closeReason: string;
@ -290,16 +313,13 @@ export function buildQuotationResult(q: QuotationData, sessions: SessionView[]):
};
}
// 마감 사유 라벨. 서버 close_reason(CloseReason) 단일 근거로 표기.
// 마감 사유 라벨. 서버 close_reason(CloseReason) 단일 근거로 표기. 개찰 = 낙찰자 미정으로 마감(결렬 아님).
export const CLOSE_REASON_LABEL: Record<CloseReason, string> = {
[CloseReason.AWARDED]: '낙찰',
[CloseReason.REGEN_PRICE]: '목표초과 — 재협상 진행',
[CloseReason.REGEN_EQUAL]: '동가 — 재입찰 진행',
[CloseReason.REGEN_NOSHOW]: '전원 미참여 — 재소집 진행',
[CloseReason.FAIL_PRICE]: '목표초과로 결렬',
[CloseReason.FAIL_EQUAL]: '동가로 결렬',
[CloseReason.FAIL_NOSHOW]: '미참여로 결렬',
[CloseReason.FAIL_REJECT]: '협상 거부로 결렬',
[CloseReason.OPEN_PRICE]: '목표가 초과 — 개찰(낙찰자 미정)',
[CloseReason.OPEN_EQUAL]: '동가 — 개찰(낙찰자 미정)',
[CloseReason.OPEN_NOSHOW]: '전원 미응찰 — 개찰',
[CloseReason.OPEN_REJECT]: '협상 거부 — 개찰',
};
// 마감결과 텍스트(정밀). 마감결과 배지·라벨은 이 값을 쓴다 — close_reason(CloseReason) 을 그대로 표기.
@ -318,10 +338,9 @@ function quotationCloseReason(q: QuotationData, sessions: SessionView[]): string
if (q.status !== QuotationStatus.CLOSED) return '협상 진행중';
if (q.close_reason != null) return CLOSE_REASON_LABEL[q.close_reason as CloseReason] ?? '마감';
if (q.preferred_sp_yn) return '낙찰';
if (q.equal_bid_yn) return '동가 — 다음 라운드 재생성';
if (q.preferred_sp_yn === false && q.equal_bid_yn === false) return '전원 미참여 — 다음 라운드 재생성';
if (sessions.some((s) => s.status === SessionStatus.REJECTED)) return '협상 거부로 마감';
return '마감';
if (q.equal_bid_yn) return '동가 — 개찰(낙찰자 미정)';
if (sessions.some((s) => s.status === SessionStatus.REJECTED)) return '협상 거부 — 개찰';
return '개찰(낙찰자 미정)';
}
// ── 서버 연동 매퍼(negotiation.sessions / chats / 사용 카드) ──────────────

View File

@ -177,9 +177,14 @@ function render(n: NotificationData): { icon: ReactNode; tone: string; event: st
number,
};
case NotificationType.FAILURE:
// 결렬 폐지 → '개찰'(낙찰자 미정으로 마감). reason 으로 사유만 부기.
return {
icon: <XCircle size={18} />, tone: 'text-rose-600', event: '견적 결렬',
line: `${name} — 낙찰 없이 마감`,
icon: <XCircle size={18} />, tone: 'text-amber-600', event: '견적 개찰',
line: `${name} — 낙찰자 미정 (${
({ price: '목표가 초과', equal: '동가', rejected: '협상거부', no_show: '전원 미응찰' } as Record<string, string>)[
String(d.reason)
] ?? '마감'
})`,
number,
};
default:

View File

@ -4,7 +4,6 @@ import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
import { PageContainer } from '@/components/layout/PageContainer';
import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { useServerList } from '@/lib/useServerList';
@ -13,15 +12,13 @@ import type { ListSuppliersParams } from '@/api/generated/model/listSuppliersPar
import { PartnerTable } from '@/features/partners/components/PartnerTable';
import { PartnerFormSheet } from '@/features/partners/components/PartnerFormSheet';
import { ExcelUploadModal, downloadPartnerTemplate } from '@/features/partners/components/ExcelUploadModal';
import { prioritiesList, type Partner } from '@/features/partners/types';
import { type Partner } from '@/features/partners/types';
export default function PartnersPage() {
// 검색/우선순위/페이지 상태(재사용 훅) → 서버 쿼리 파라미터로 변환.
const list = useServerList({ pageSize: 10, initialFilters: { priority: 'ALL' } });
const priorityFilter = list.filters.priority;
// 검색/페이지 상태(재사용 훅) → 서버 쿼리 파라미터로 변환.
const list = useServerList({ pageSize: 10 });
const params: ListSuppliersParams = {
search: list.debouncedSearch || undefined,
priority: priorityFilter !== 'ALL' ? priorityFilter : undefined,
page: list.page,
size: list.pageSize,
};
@ -91,21 +88,6 @@ export default function PartnersPage() {
onClear={list.clearSearch}
placeholder="협력사명, 코드 또는 담당자명으로 추적 검색..."
/>
<Select value={priorityFilter} onValueChange={(v) => list.setFilter('priority', v as string)}>
<SelectTrigger id="partner-priority-filter" className="w-full sm:w-48">
<SelectValue>
{(value) => (value === 'ALL' ? '우선순위 가중치 (전체)' : `우선도: ${value}`)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{prioritiesList.map((prio) => (
<SelectItem key={prio} value={prio}>
{prio === 'ALL' ? '우선순위 가중치 (전체)' : `우선도: ${prio}`}
</SelectItem>
))}
</SelectContent>
</Select>
</PageToolbar>
<PartnerTable

View File

@ -15,7 +15,6 @@ export type Partner = SupplierData & {
managerName?: string;
managerEmail?: string;
managerPhone?: string;
rank?: 'A' | 'B' | 'C' | 'S';
status?: string;
memo?: string;
};

View File

@ -130,7 +130,7 @@ CREATE TABLE IF NOT EXISTS partner.suppliers (
manager_name VARCHAR(50) NULL, -- 담당자명
manager_email VARCHAR(255) NULL, -- 담당자 이메일
manager_contact_number VARCHAR(20) NULL, -- 담당자 연락처
priority VARCHAR(10) NULL, -- 우선순위 (고객사별로 문자열 값일 수 있어 코드(SMALLINT) 대신 VARCHAR 유지)
total_revenue BIGINT NULL, -- 총매출액(원). KTC suppliers.total_revenue 미러
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
@ -247,11 +247,8 @@ CREATE TABLE IF NOT EXISTS quotation.quotation_settings (
qt_setting_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 견적 설정 식별자(PK)
user_id uuid NOT NULL, -- 견적 설정을 생성한 유저 아이디(company.users.user_id)
target_margin_rate NUMERIC(8,6) NOT NULL, -- 목표 마진율 (정수부 2자리 + 소수 6자리, -99.999999~99.999999)
anchoring_value NUMERIC(8,6) NOT NULL DEFAULT 0.01, -- 앵커링 값 (정수부 2자리 + 소수 6자리)
card_count INTEGER NOT NULL DEFAULT 3, -- 한개의 협상 안에서 협상카드 사용 횟수
mid_action SMALLINT NOT NULL DEFAULT 1, -- 마감 가격정책(PriceGateAction): 앵커링가<투찰가≤목표가 처리(1=낙찰/2=재협상/3=유찰)
over_action SMALLINT NOT NULL DEFAULT 1, -- 마감 가격정책(PriceGateAction): 목표가<투찰가 처리(1=낙찰/2=재협상/3=유찰). 투찰가≤앵커링가는 항상 낙찰(설정없음)
regen_limit SMALLINT NOT NULL DEFAULT 1, -- 재생성 최대 횟수(체인 전체 총합, 사유 무관: 목표초과/동가/미참여 합산)
-- 낙찰 정책(mid/over/regen)은 견적 단위로 이관, 앵커링은 칸 rate(anchoring v1.2)로 대체 → 세팅 컬럼 없음
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
@ -281,7 +278,9 @@ CREATE TABLE IF NOT EXISTS quotation.quotations (
preferred_sp_name VARCHAR(20) NULL, -- 선호 공급사명(스냅샷)
equal_bid_yn BOOLEAN NULL, -- 동일가 입찰 발생 여부
equal_bid_data JSONB NULL, -- 동일가 입찰 상세(JSON)
close_reason SMALLINT NULL, -- 마감 사유(CloseReason): 1=낙찰,2=가격재협상,3=동가재입찰,4=미참여재소집,5=가격유찰,6=동가유찰,7=미참여유찰,8=거부유찰. 미마감이면 NULL
close_reason SMALLINT NULL, -- 마감 사유(CloseReason): 1=낙찰, 5=가격개찰, 6=동가개찰, 7=미응찰개찰, 8=거부개찰. 미마감이면 NULL
mid_action SMALLINT NOT NULL DEFAULT 1, -- 낙찰 기준(PriceGateAction 1=낙찰/2=개찰): 앵커링가<투찰가≤목표가 처리. 1:1 협상만 사용자 선택, 1:N 경매는 AWARD 강제
over_action SMALLINT NOT NULL DEFAULT 1, -- 낙찰 기준(PriceGateAction 1=낙찰/2=개찰): 목표가<투찰가 처리(1:1 협상은 항상 개찰). 투찰가≤앵커링가는 항상 낙찰
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부

View File

@ -70,3 +70,35 @@ ALTER TABLE quotation.quotation_settings
ADD COLUMN IF NOT EXISTS mid_action SMALLINT NOT NULL DEFAULT 1, -- 가격정책(PriceGateAction): 앵커링가<투찰가≤목표가 처리
ADD COLUMN IF NOT EXISTS over_action SMALLINT NOT NULL DEFAULT 1, -- 가격정책(PriceGateAction): 목표가<투찰가 처리
ADD COLUMN IF NOT EXISTS regen_limit SMALLINT NOT NULL DEFAULT 1; -- 재생성 최대 횟수(체인 전체 총합, 사유 무관)
-- ─────────────────────────────────────────────────────────────
-- [2026-07-06] 낙찰 기준 견적 단위 이관 + 마감 개편(개찰 모델) — quotations(견적 행)에 낙찰 기준 mid/over 미러.
-- 마감 판정이 견적 행에서 읽는다. 기준 미달/동가/거부/미응찰은 결렬(유찰)이 아니라 개찰(낙찰자 미정 마감).
-- 자동 재협상/재생성 폐지 → regen_limit 제거(설정 템플릿 quotation_settings.regen_limit 은 미사용 잔존).
-- (신규 DB 는 01-schema*.sql 에 반영됨. 기존 행은 DEFAULT 1=AWARD 로 백필.)
ALTER TABLE quotation.quotations
ADD COLUMN IF NOT EXISTS mid_action SMALLINT NOT NULL DEFAULT 1, -- 낙찰 기준(PriceGateAction 1=낙찰/2=개찰): 앵커링가<투찰가≤목표가 처리
ADD COLUMN IF NOT EXISTS over_action SMALLINT NOT NULL DEFAULT 1; -- 낙찰 기준(PriceGateAction 1=낙찰/2=개찰): 목표가<투찰가 처리(1:1 협상은 항상 개찰)
ALTER TABLE quotation.quotations
DROP COLUMN IF EXISTS regen_limit; -- 자동 재생성 폐지로 제거(먼저 추가됐던 dev DB 대비 멱등 DROP)
-- 폐기된 close_reason 코드(구 REGEN_* 2~4) → 개찰(OPEN_*) 로 이관. 미이관 시 QuotationData(CloseReason enum) 검증 실패로 견적 목록 500.
UPDATE quotation.quotations SET close_reason = 5 WHERE close_reason = 2; -- REGEN_PRICE → OPEN_PRICE
UPDATE quotation.quotations SET close_reason = 6 WHERE close_reason = 3; -- REGEN_EQUAL → OPEN_EQUAL
UPDATE quotation.quotations SET close_reason = 7 WHERE close_reason = 4; -- REGEN_NOSHOW → OPEN_NOSHOW
-- quotation_settings 정리 — 낙찰 정책(mid/over/regen)은 견적 단위 이관, 앵커링은 칸 rate(v1.2)로 대체 → 세팅 컬럼 제거.
-- 미제거 시 QuotationSettingData(PriceGateAction enum, 값 3=옛 FAIL) 검증 실패로 견적 세팅 목록 500.
ALTER TABLE quotation.quotation_settings
DROP COLUMN IF EXISTS mid_action,
DROP COLUMN IF EXISTS over_action,
DROP COLUMN IF EXISTS regen_limit,
DROP COLUMN IF EXISTS anchoring_value;
-- ─────────────────────────────────────────────────────────────
-- [2026-07-06] 협력사: 총매출액 추가 + 우선선정(priority) 제거.
-- priority(우선순위 문자열)와 그 파생 등급(rank)은 폐지 — 협력사에서 완전 제거.
ALTER TABLE partner.suppliers
ADD COLUMN IF NOT EXISTS total_revenue BIGINT; -- 총매출액(원, KTC total_revenue 미러)
ALTER TABLE partner.suppliers
DROP COLUMN IF EXISTS priority;