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_name = Column(String(50), nullable=True)
manager_email = Column(String(255), nullable=True) manager_email = Column(String(255), nullable=True)
manager_contact_number = Column(String(20), nullable=True) # ERD 오타(manger) 교정 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): 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) 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) # 설정 소유 유저 user_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 설정 소유 유저
target_margin_rate = Column(Numeric(8, 6), nullable=False) target_margin_rate = Column(Numeric(8, 6), nullable=False) # 목표 마진율(목표가 산정에 사용)
anchoring_value = Column(Numeric(8, 6), nullable=False, default=0.01)
card_count = Column(Integer, nullable=False, default=3) card_count = Column(Integer, nullable=False, default=3)
# 마감 가격정책. 투찰가≤앵커링가는 항상 낙찰(설정없음). 아래 두 구간만 회사가 정한다. 기본 AWARD=현재 '무조건 낙찰' 동작(하위호환). # 낙찰 가격정책(mid/over/regen)은 견적 단위로 이관, 앵커링은 칸 rate(anchoring v1.2)로 대체 → 세팅 컬럼 제거됨.
mid_action = Column(SmallInteger, nullable=False, default=1) # PriceGateAction: 앵커링가<투찰가≤목표가 처리(1=낙찰/2=재협상/3=유찰)
over_action = Column(SmallInteger, nullable=False, default=1) # PriceGateAction: 목표가<투찰가 처리(1=낙찰/2=재협상/3=유찰)
regen_limit = Column(SmallInteger, nullable=False, default=1) # 재생성 최대 횟수(체인 전체 총합, 사유 무관: 목표초과/동가/미참여 합산)
class quotations(MainTableMixin, MAIN_BASE): class quotations(MainTableMixin, MAIN_BASE):
@ -231,6 +227,11 @@ class quotations(MainTableMixin, MAIN_BASE):
equal_bid_data = Column(JSONB, nullable=True) equal_bid_data = Column(JSONB, nullable=True)
close_reason = Column(SmallInteger, nullable=True) # CloseReason 코드. 마감 시 사유 기록(재생성 한도 카운팅·유찰 사유 구분). 미마감이면 NULL 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): class sessions(MainTableMixin, MAIN_BASE):
__tablename__ = "sessions" __tablename__ = "sessions"

View File

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

View File

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

View File

@ -14,7 +14,7 @@ from common.utils.gtime import GTime
# 협력사 CRUD. 모든 조회/변경은 company_id 로 스코프된다(멀티테넌트). # 협력사 CRUD. 모든 조회/변경은 company_id 로 스코프된다(멀티테넌트).
class ISupplierCRUD(ABC): class ISupplierCRUD(ABC):
@abstractmethod @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 pass
@abstractmethod @abstractmethod
@ -44,7 +44,7 @@ class ISupplierCRUD(ABC):
class SupplierCRUD(ISupplierCRUD): class SupplierCRUD(ISupplierCRUD):
async def search( 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]: ) -> Tuple[ErrorType, list, int]:
try: try:
conditions = [suppliers.deleted == False, suppliers.company_id == company_id] # noqa: E712 conditions = [suppliers.deleted == False, suppliers.company_id == company_id] # noqa: E712
@ -56,8 +56,6 @@ class SupplierCRUD(ISupplierCRUD):
suppliers.manager_name.ilike(f"%{search}%"), suppliers.manager_name.ilike(f"%{search}%"),
) )
) )
if priority:
conditions.append(suppliers.priority == priority)
where = and_(*conditions) where = and_(*conditions)
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(suppliers).where(where)) 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개 생성 item_ids: list[uuid.UUID] = [] # 협상 대상 상품. item×supplier 조합마다 세션 1개 생성
supplier_ids: list[uuid.UUID] = [] # 협상 초청 공급사 supplier_ids: list[uuid.UUID] = [] # 협상 초청 공급사
card_ids: list[uuid.UUID] = [] # 선택 협상카드. 버전을 만들어 묶고 quotation.version_id 로 연결 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): class Req_RegenerateQuotation(QuotationProtocol):
@ -63,6 +67,8 @@ class QuotationData(WebPacketProtocol):
equal_bid_yn: Optional[bool] = None equal_bid_yn: Optional[bool] = None
equal_bid_data: Optional[Any] = None equal_bid_data: Optional[Any] = None
close_reason: Optional[CloseReason] = None # 마감 사유(CloseReason). 미마감이면 None close_reason: Optional[CloseReason] = None # 마감 사유(CloseReason). 미마감이면 None
mid_action: Optional[int] = None # 낙찰 기준(견적 단위). 상세 드로어 낙찰기준 표시용
over_action: Optional[int] = None
participation_count: int = 0 # 견적별 참여 협력사 수(세션 distinct supplier). 목록 집계로 채움. participation_count: int = 0 # 견적별 참여 협력사 수(세션 distinct supplier). 목록 집계로 채움.
item_id: Optional[uuid.UUID] = None # 대표 상품 id(세션의 첫 item). 목록 조인으로 채움. item_id: Optional[uuid.UUID] = None # 대표 상품 id(세션의 첫 item). 목록 조인으로 채움.
item_name: Optional[str] = None # 대표 상품명. 목록 조인으로 채움. item_name: Optional[str] = None # 대표 상품명. 목록 조인으로 채움.
@ -199,7 +205,7 @@ class Res_TargetBreakdown(Res_WebPacketProtocol):
selling: Optional[int] = None selling: Optional[int] = None
fee: float = 0.0 fee: float = 0.0
margin: float = 0.0 margin: float = 0.0
anchoring_value: float = 0.0 anchoring_value: float = 0.0 # 앵커링율(비율). 세션 앵커링값(‰)을 /1000 환산 — main 프론트 표시용
candidates: list[TargetCandidate] = [] candidates: list[TargetCandidate] = []
chosen_basis: Optional[str] = None chosen_basis: Optional[str] = None
target_price: int = 0 target_price: int = 0

View File

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

View File

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

View File

@ -24,10 +24,9 @@ async def list_suppliers(
service: SupplierService = Depends(), service: SupplierService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken), user_info: UserInfo = Depends(IsValidAccessToken),
search: str | None = Query(None, description="협력사명/코드/담당자명 검색"), search: str | None = Query(None, description="협력사명/코드/담당자명 검색"),
priority: str | None = Query(None, description="우선순위 필터(HIGH/MEDIUM/LOW)"),
pg: PageParams = Depends(), 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="협력사 등록") @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: def _format_results(results: Counter) -> str:
return ( return (
f"낙찰 {results[CloseOutcome.AWARDED]} / 재생성 {results[CloseOutcome.REGENERATED]} / " f"낙찰 {results[CloseOutcome.AWARDED]} / 개찰 {results[CloseOutcome.OPENED]} / "
f"재생성실패 {results[CloseOutcome.REGEN_FAILED]} / 마감 {results[CloseOutcome.CLOSED]} / 오류 {results['error']}" f"마감 {results[CloseOutcome.CLOSED]} / 오류 {results['error']}"
) )
async def close_expired_quotations() -> int: async def close_expired_quotations() -> int:
"""[잡①] 마감일이 지난 견적을 자동 마감 처리한다. 하루 한 번 실행. """[잡①] 마감일이 지난 견적을 자동 마감 처리한다. 하루 한 번 실행.
대상: 마감 시각이 이미 지났는데 아직 마감되지 않은(삭제되지도 않은) 견적. 대상: 마감 시각이 이미 지났는데 아직 마감되지 않은(삭제되지도 않은) 견적.
처리: 견적마다 close_and_decide 로 결과 판정(낙찰 확정 / 다음 라운드 재생성 / 그냥 마감). 처리: 견적마다 close_and_decide 로 결과 판정(낙찰 확정 / 개찰=낙찰자 미정 마감).
반환: 처리한 견적 수.""" 반환: 처리한 견적 수."""
crud = QuotationCRUD() crud = QuotationCRUD()
service = QuotationService(crud) service = QuotationService(crud)

View File

@ -1,7 +1,7 @@
-- 견적상세 UI 케이스별 데모 시드 (dev DB: negosium_db) -- 견적상세 UI 케이스별 데모 시드 (dev DB: negosium_db)
-- 안전: 아래 데모 qt_id / 그 세션만 지우고 다시 넣는다(다른 데이터 무손상). 재실행 가능. -- 안전: 아래 데모 qt_id / 그 세션만 지우고 다시 넣는다(다른 데이터 무손상). 재실행 가능.
-- user=모모나(b874f33d) / setting=40614764 / version=...030 / item=고압 에어 컴프레서(a3d1fff5) -- 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; BEGIN;
DELETE FROM negotiation.sessions WHERE quotation_id IN ( 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' 'aaaa0010-0000-0000-0000-000000000010'
); );
-- ── 견적 10건 (close_reason 8종 전부 + 진행중) ──────────────────────────── -- ── 견적 10건 (close_reason 5종: 낙찰+개찰4 + 진행중) ────────────────────────────
INSERT INTO quotation.quotations INSERT INTO quotation.quotations
(qt_id, user_id, qt_setting_id, version_id, name, number, type, round, status, (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, start_time, end_time, manager_name, manager_email, manager_contact_number, memo,
@ -53,25 +53,25 @@ VALUES
-- ③ 동가 재입찰(REGEN_EQUAL): 두 곳 390,000 동률 → 재입찰 진행 -- ③ 동가 재입찰(REGEN_EQUAL): 두 곳 390,000 동률 → 재입찰 진행
('aaaa0003-0000-0000-0000-000000000003','b874f33d-afc3-4498-a9c2-19bc5ae8faba', ('aaaa0003-0000-0000-0000-000000000003','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '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','동가 발생·다음 라운드 재입찰 케이스', now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','동가 발생·다음 라운드 재입찰 케이스',
1, false, null, null, true, 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, '[{"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): 협상거부로 마감 -- ④ 거부 유찰(FAIL_REJECT): 협상거부로 마감
('aaaa0004-0000-0000-0000-000000000004','b874f33d-afc3-4498-a9c2-19bc5ae8faba', ('aaaa0004-0000-0000-0000-000000000004','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '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','협상 거부로 유찰 케이스', now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','협상 거부로 유찰 케이스',
1, null, null, null, null, null, 1, null, null, null, null, null,
8, 400000, 1, now(), now(), false), 8, 400000, 1, now(), now(), false),
-- ⑤ 미참여 재소집(REGEN_NOSHOW): 전원 미참여 → 다음 라운드 재소집 -- ⑤ 미참여 재소집(REGEN_NOSHOW): 전원 미참여 → 다음 라운드 재소집
('aaaa0005-0000-0000-0000-000000000005','b874f33d-afc3-4498-a9c2-19bc5ae8faba', ('aaaa0005-0000-0000-0000-000000000005','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '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','전원 미참여·다음 라운드 재소집 케이스', now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','전원 미참여·다음 라운드 재소집 케이스',
1, false, null, null, false, null, 1, false, null, null, false, null,
4, 400000, 1, now(), now(), false), 7, 400000, 1, now(), now(), false),
-- ⑥ 진행중: 일부 투찰 완료, 아직 안 닫힘 (close_reason NULL) -- ⑥ 진행중: 일부 투찰 완료, 아직 안 닫힘 (close_reason NULL)
('aaaa0006-0000-0000-0000-000000000006','b874f33d-afc3-4498-a9c2-19bc5ae8faba', ('aaaa0006-0000-0000-0000-000000000006','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
@ -82,21 +82,21 @@ VALUES
-- ⑦ 가격 재협상(REGEN_PRICE): 단독 최저가 420,000 > 목표 350,000 → 재협상 진행 -- ⑦ 가격 재협상(REGEN_PRICE): 단독 최저가 420,000 > 목표 350,000 → 재협상 진행
('aaaa0007-0000-0000-0000-000000000007','b874f33d-afc3-4498-a9c2-19bc5ae8faba', ('aaaa0007-0000-0000-0000-000000000007','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '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','목표 초과 → 재협상(더 깎기) 진행 케이스', now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','목표 초과 → 재협상(더 깎기) 진행 케이스',
1, false, null, null, false, null, 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=유찰(또는 한도소진) → 유찰 -- ⑧ 가격 유찰(FAIL_PRICE): 단독 최저가 430,000 > 목표 350,000, over_action=유찰(또는 한도소진) → 유찰
('aaaa0008-0000-0000-0000-000000000008','b874f33d-afc3-4498-a9c2-19bc5ae8faba', ('aaaa0008-0000-0000-0000-000000000008','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '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','목표 초과 → 유찰 케이스', now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','목표 초과 → 유찰 케이스',
1, false, null, null, false, null, 1, false, null, null, false, null,
5, 350000, 1, now(), now(), false), 5, 350000, 1, now(), now(), false),
-- ⑨ 동가 유찰(FAIL_EQUAL): 동가지만 재입찰 한도 소진/유찰 정책 → 유찰 -- ⑨ 동가 유찰(FAIL_EQUAL): 동가지만 재입찰 한도 소진/유찰 정책 → 유찰
('aaaa0009-0000-0000-0000-000000000009','b874f33d-afc3-4498-a9c2-19bc5ae8faba', ('aaaa0009-0000-0000-0000-000000000009','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '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','동가지만 재입찰 한도 소진 → 유찰 케이스', now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','동가지만 재입찰 한도 소진 → 유찰 케이스',
1, false, null, null, true, 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, '[{"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): 전원 미참여 + 재소집 한도 소진 → 유찰 -- ⑩ 미참여 유찰(FAIL_NOSHOW): 전원 미참여 + 재소집 한도 소진 → 유찰
('aaaa0010-0000-0000-0000-000000000010','b874f33d-afc3-4498-a9c2-19bc5ae8faba', ('aaaa0010-0000-0000-0000-000000000010','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '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','전원 미참여 + 재소집 한도 소진 → 유찰 케이스', now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','전원 미참여 + 재소집 한도 소진 → 유찰 케이스',
1, false, null, null, false, null, 1, false, null, null, false, null,
7, 400000, 1, now(), now(), false); 7, 400000, 1, now(), now(), false);

View File

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

View File

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

View File

@ -38,14 +38,14 @@ class SupplierService:
return ErrorType.SUPPLIER_NOT_FOUND, None return ErrorType.SUPPLIER_NOT_FOUND, None
return ErrorType.SUCCESS, supplier 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) res = Res_SupplierList(page=pg.page, size=pg.size)
company_uuid = uuid.UUID(company_id) company_uuid = uuid.UUID(company_id)
err_type, rows, total = await DB_SESSION_MNG.execute_lambda( err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
suppliers.DBType(), suppliers.DBType(),
DBWRType.DB_READ.value, 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: if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type) res.result.SetResult(err_type)
@ -105,7 +105,7 @@ class SupplierService:
manager_name=req.manager_name, manager_name=req.manager_name,
manager_email=req.manager_email, manager_email=req.manager_email,
manager_contact_number=req.manager_contact_number, manager_contact_number=req.manager_contact_number,
priority=req.priority, total_revenue=req.total_revenue,
) )
err_type = await DB_SESSION_MNG.execute_lambda_run( err_type = await DB_SESSION_MNG.execute_lambda_run(
[suppliers.DBType()], [suppliers.DBType()],

View File

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

View File

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

View File

@ -9,7 +9,7 @@ from datetime import datetime
from sqlalchemy import text from sqlalchemy import text
from common.enums import QuotationType from common.enums import PriceGateAction, QuotationType
from crud.quotation_crud import QuotationCRUD from crud.quotation_crud import QuotationCRUD
from router.v1.quotation.protocol import Req_CreateQuotation from router.v1.quotation.protocol import Req_CreateQuotation
from services.quotation_service import QuotationService from services.quotation_service import QuotationService
@ -65,11 +65,67 @@ async def test_create_without_price_fails(db_engine, company_id):
assert rows == [] 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(): def _service():
return QuotationService(QuotationCRUD()) 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): async def _seed_item(engine, company_id, *, internet_lowest):
"""상품 1건 시드(인터넷최저가만). category_type·internet_lowest_price_yn 은 NOT NULL — """상품 1건 시드(인터넷최저가만). category_type·internet_lowest_price_yn 은 NOT NULL —
ORM default 는 raw INSERT 에 안 먹으므로 명시한다(conftest companies.status 와 같은 이유).""" ORM default 는 raw INSERT 에 안 먹으므로 명시한다(conftest companies.status 와 같은 이유)."""

View File

@ -6,8 +6,9 @@
*/ */
/** /**
* quotations.close_reason 코드값(SMALLINT). 마감 사유 — 재생성 한도 카운팅(REGEN_*)과 유찰 사유 구분에 쓴다. * quotations.close_reason 코드값(SMALLINT). 마감 사유 — 낙찰(AWARDED) 또는 개찰(OPEN_*)로 가른다.
기존 preferred_sp_yn/equal_bid_yn 2플래그로는 4상태만 표현돼 '목표초과 재협상'이 미참여와 충돌하고 유찰 사유가 뭉개짐 → 이 컬럼으로 명시. 개찰=결렬(유찰)이 아니라 '낙찰자 미정으로 마감' — 자동 재협상/재생성 없이 담당자가 수동 처리(수동 재생성 등)한다.
(구 자동재협상 사유 REGEN_*(2~4)·유찰 개념은 폐지. 사유 플래그 값 5~8은 보존해 OPEN_* 로 재명명.)
*/ */
export type CloseReason = typeof CloseReason[keyof typeof CloseReason]; 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 // eslint-disable-next-line @typescript-eslint/no-redeclare
export const CloseReason = { export const CloseReason = {
AWARDED: 1, AWARDED: 1,
REGEN_PRICE: 2, OPEN_PRICE: 5,
REGEN_EQUAL: 3, OPEN_EQUAL: 6,
REGEN_NOSHOW: 4, OPEN_NOSHOW: 7,
FAIL_PRICE: 5, OPEN_REJECT: 8,
FAIL_EQUAL: 6,
FAIL_NOSHOW: 7,
FAIL_REJECT: 8,
} as const; } as const;

View File

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

View File

@ -10,10 +10,6 @@ export type ListSuppliersParams = {
* 협력사명/코드/담당자명 검색 * 협력사명/코드/담당자명 검색
*/ */
search?: string | null; search?: string | null;
/**
* 우선순위 필터(HIGH/MEDIUM/LOW)
*/
priority?: string | null;
/** /**
* @minimum 1 * @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 { QuotationDataEqualBidYn } from './quotationDataEqualBidYn';
import type { QuotationDataEqualBidData } from './quotationDataEqualBidData'; import type { QuotationDataEqualBidData } from './quotationDataEqualBidData';
import type { QuotationDataCloseReason } from './quotationDataCloseReason'; import type { QuotationDataCloseReason } from './quotationDataCloseReason';
import type { QuotationDataMidAction } from './quotationDataMidAction';
import type { QuotationDataOverAction } from './quotationDataOverAction';
import type { QuotationDataItemId } from './quotationDataItemId'; import type { QuotationDataItemId } from './quotationDataItemId';
import type { QuotationDataItemName } from './quotationDataItemName'; import type { QuotationDataItemName } from './quotationDataItemName';
import type { QuotationDataCreatorName } from './quotationDataCreatorName'; import type { QuotationDataCreatorName } from './quotationDataCreatorName';
@ -49,6 +51,8 @@ export interface QuotationData {
equal_bid_yn?: QuotationDataEqualBidYn; equal_bid_yn?: QuotationDataEqualBidYn;
equal_bid_data?: QuotationDataEqualBidData; equal_bid_data?: QuotationDataEqualBidData;
close_reason?: QuotationDataCloseReason; close_reason?: QuotationDataCloseReason;
mid_action?: QuotationDataMidAction;
over_action?: QuotationDataOverAction;
participation_count?: number; participation_count?: number;
item_id?: QuotationDataItemId; item_id?: QuotationDataItemId;
item_name?: QuotationDataItemName; item_name?: QuotationDataItemName;

View File

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

View File

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

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0 * 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 * Negodata Api Server
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
import type { PriceGateAction } from './priceGateAction';
export interface ReqCreateQuotationSetting { export interface ReqCreateQuotationSetting {
target_margin_rate: number; target_margin_rate: number;
anchoring_value?: number;
card_count?: 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 { ReqCreateSupplierManagerName } from './reqCreateSupplierManagerName';
import type { ReqCreateSupplierManagerEmail } from './reqCreateSupplierManagerEmail'; import type { ReqCreateSupplierManagerEmail } from './reqCreateSupplierManagerEmail';
import type { ReqCreateSupplierManagerContactNumber } from './reqCreateSupplierManagerContactNumber'; import type { ReqCreateSupplierManagerContactNumber } from './reqCreateSupplierManagerContactNumber';
import type { ReqCreateSupplierPriority } from './reqCreateSupplierPriority'; import type { ReqCreateSupplierTotalRevenue } from './reqCreateSupplierTotalRevenue';
export interface ReqCreateSupplier { export interface ReqCreateSupplier {
name?: string; name?: string;
@ -16,5 +16,5 @@ export interface ReqCreateSupplier {
manager_name?: ReqCreateSupplierManagerName; manager_name?: ReqCreateSupplierManagerName;
manager_email?: ReqCreateSupplierManagerEmail; manager_email?: ReqCreateSupplierManagerEmail;
manager_contact_number?: ReqCreateSupplierManagerContactNumber; 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 * OpenAPI spec version: 0.1.0
*/ */
import type { ReqUpdateQuotationSettingTargetMarginRate } from './reqUpdateQuotationSettingTargetMarginRate'; import type { ReqUpdateQuotationSettingTargetMarginRate } from './reqUpdateQuotationSettingTargetMarginRate';
import type { ReqUpdateQuotationSettingAnchoringValue } from './reqUpdateQuotationSettingAnchoringValue';
import type { ReqUpdateQuotationSettingCardCount } from './reqUpdateQuotationSettingCardCount'; import type { ReqUpdateQuotationSettingCardCount } from './reqUpdateQuotationSettingCardCount';
import type { ReqUpdateQuotationSettingMidAction } from './reqUpdateQuotationSettingMidAction';
import type { ReqUpdateQuotationSettingOverAction } from './reqUpdateQuotationSettingOverAction';
import type { ReqUpdateQuotationSettingRegenLimit } from './reqUpdateQuotationSettingRegenLimit';
export interface ReqUpdateQuotationSetting { export interface ReqUpdateQuotationSetting {
target_margin_rate?: ReqUpdateQuotationSettingTargetMarginRate; target_margin_rate?: ReqUpdateQuotationSettingTargetMarginRate;
anchoring_value?: ReqUpdateQuotationSettingAnchoringValue;
card_count?: ReqUpdateQuotationSettingCardCount; 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 { ReqUpdateSupplierManagerName } from './reqUpdateSupplierManagerName';
import type { ReqUpdateSupplierManagerEmail } from './reqUpdateSupplierManagerEmail'; import type { ReqUpdateSupplierManagerEmail } from './reqUpdateSupplierManagerEmail';
import type { ReqUpdateSupplierManagerContactNumber } from './reqUpdateSupplierManagerContactNumber'; import type { ReqUpdateSupplierManagerContactNumber } from './reqUpdateSupplierManagerContactNumber';
import type { ReqUpdateSupplierPriority } from './reqUpdateSupplierPriority'; import type { ReqUpdateSupplierTotalRevenue } from './reqUpdateSupplierTotalRevenue';
export interface ReqUpdateSupplier { export interface ReqUpdateSupplier {
name?: ReqUpdateSupplierName; name?: ReqUpdateSupplierName;
@ -17,5 +17,5 @@ export interface ReqUpdateSupplier {
manager_name?: ReqUpdateSupplierManagerName; manager_name?: ReqUpdateSupplierManagerName;
manager_email?: ReqUpdateSupplierManagerEmail; manager_email?: ReqUpdateSupplierManagerEmail;
manager_contact_number?: ReqUpdateSupplierManagerContactNumber; 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 { SupplierDataManagerName } from './supplierDataManagerName';
import type { SupplierDataManagerEmail } from './supplierDataManagerEmail'; import type { SupplierDataManagerEmail } from './supplierDataManagerEmail';
import type { SupplierDataManagerContactNumber } from './supplierDataManagerContactNumber'; import type { SupplierDataManagerContactNumber } from './supplierDataManagerContactNumber';
import type { SupplierDataPriority } from './supplierDataPriority'; import type { SupplierDataTotalRevenue } from './supplierDataTotalRevenue';
import type { SupplierDataCreatedAt } from './supplierDataCreatedAt'; import type { SupplierDataCreatedAt } from './supplierDataCreatedAt';
import type { SupplierDataUpdatedAt } from './supplierDataUpdatedAt'; import type { SupplierDataUpdatedAt } from './supplierDataUpdatedAt';
@ -21,7 +21,7 @@ export interface SupplierData {
manager_name?: SupplierDataManagerName; manager_name?: SupplierDataManagerName;
manager_email?: SupplierDataManagerEmail; manager_email?: SupplierDataManagerEmail;
manager_contact_number?: SupplierDataManagerContactNumber; manager_contact_number?: SupplierDataManagerContactNumber;
priority?: SupplierDataPriority; total_revenue?: SupplierDataTotalRevenue;
created_at?: SupplierDataCreatedAt; created_at?: SupplierDataCreatedAt;
updated_at?: SupplierDataUpdatedAt; updated_at?: SupplierDataUpdatedAt;
} }

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0 * 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"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<DeadlineWidget data={s.deadline_soon} onOpen={onOpen} /> <DeadlineWidget data={s.deadline_soon} onOpen={onOpen} />
<EmailUnsentWidget data={s.email_unsent} 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>
</div> </div>
); );

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
import { Badge } from '@/components/ui/badge';
import { DataTable } from '@/components/ui/data-table'; import { DataTable } from '@/components/ui/data-table';
import { TablePagination } from '@/components/ui/table-pagination'; import { TablePagination } from '@/components/ui/table-pagination';
import type { Partner } from '../types'; import type { Partner } from '../types';
@ -13,14 +12,6 @@ type PartnerTableProps = {
onPageChange: (page: number) => void; 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({ export function PartnerTable({
data, data,
onRowClick, onRowClick,
@ -74,16 +65,10 @@ export function PartnerTable({
), ),
}, },
{ {
header: '우선 선정 대상자', header: '총매출액',
align: 'center', align: 'right',
cell: (part) => ( cellClassName: 'font-mono text-muted-foreground',
<Badge cell: (part) => (part.total_revenue != null ? `₩${Number(part.total_revenue).toLocaleString()}` : '-'),
variant="outline"
className={`inline-flex items-center px-2 py-0.5 text-[10px] font-bold rounded-full ${priorityBadgeClass(part.priority)}`}
>
{part.priority}
</Badge>
),
}, },
]} ]}
/> />

View File

@ -1,11 +1 @@
export type { Partner } from '@/types'; 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 { 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 { useNavigate } from 'react-router';
import { useGetSupplierLastType } from '@/api/generated/quotation/quotation'; import { useGetSupplierLastType } from '@/api/generated/quotation/quotation';
import { Button } from '@/components/ui/button'; 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 { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
import type { CreateQuotationInput } from '../hooks/useQuotations'; import type { CreateQuotationInput } from '../hooks/useQuotations';
import { QuotationType } from '@/api/generated/model'; 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 { supplierTypeLabel } from '@/lib/enumLabels';
import { showToast } from '@/lib/notify'; import { showToast } from '@/lib/notify';
@ -42,7 +51,9 @@ export function QuotationCreateModal({
}: QuotationCreateModalProps) { }: QuotationCreateModalProps) {
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const [title, setTitle] = useState(''); 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 [productId, setProductId] = useState('');
const [selectedPartnerIds, setSelectedPartnerIds] = useState<string[]>([]); const [selectedPartnerIds, setSelectedPartnerIds] = useState<string[]>([]);
const [dueDate, setDueDate] = useState(nowKstLocalInput); const [dueDate, setDueDate] = useState(nowKstLocalInput);
@ -51,8 +62,18 @@ export function QuotationCreateModal({
const [memo, setMemo] = useState(''); const [memo, setMemo] = useState('');
const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 상품값으로 목표가 산정 const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 상품값으로 목표가 산정
const [supplierType, setSupplierType] = useState(''); // 협력사 유형(SupplierType). 전 유형에서 입력 → 견적에 기록 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 [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 을 조회해 디폴트로 채운다. // 협력사 유형은 전 유형에서 입력받되, 재협상(1:1)이면 선택 협력사의 직전 견적 supplier_type 을 조회해 디폴트로 채운다.
const renegoSupplierId = type === QuotationType.RENEGO ? (selectedPartnerIds[0] ?? '') : ''; const renegoSupplierId = type === QuotationType.RENEGO ? (selectedPartnerIds[0] ?? '') : '';
@ -69,7 +90,6 @@ export function QuotationCreateModal({
const navigate = useNavigate(); const navigate = useNavigate();
// 인터넷최저가·매입가·판매가는 상품 속성 — 모달에선 읽기전용으로만 보여주고, 수정은 상품 상세에서 한다. // 인터넷최저가·매입가·판매가는 상품 속성 — 모달에선 읽기전용으로만 보여주고, 수정은 상품 상세에서 한다.
const selectedProduct = products.find((p) => p.id === productId); const selectedProduct = products.find((p) => p.id === productId);
const isReType = !isNewQuotationType(type);
const internetLowest = selectedProduct?.internet_lowest_price ?? null; const internetLowest = selectedProduct?.internet_lowest_price ?? null;
const purchase = selectedProduct?.purchase_price ?? null; const purchase = selectedProduct?.purchase_price ?? null;
const selling = selectedProduct?.selling_price ?? null; const selling = selectedProduct?.selling_price ?? null;
@ -82,16 +102,25 @@ export function QuotationCreateModal({
if (!open) return null; 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) => const togglePartner = (id: string) =>
setSelectedPartnerIds((prev) => setSelectedPartnerIds((prev) =>
type === QuotationType.RENEGO oneToOne
? prev.includes(id) ? [] : [id] ? prev.includes(id)
: prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id], ? []
: [id]
: prev.includes(id)
? prev.filter((p) => p !== id)
: [...prev, id],
); );
const toggleCard = (id: string) => const toggleCard = (id: string) =>
setSelectedCardIds((prev) => setSelectedCardIds((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]));
prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id],
);
const handleSubmit = async () => { const handleSubmit = async () => {
if (submitting) return; if (submitting) return;
@ -102,7 +131,7 @@ export function QuotationCreateModal({
showToast('목표가 산정에 쓸 값이 없습니다 — MD 제시가를 입력하거나, 상품 상세에서 인터넷최저가·매입가를 채워주세요.', 'error'); showToast('목표가 산정에 쓸 값이 없습니다 — MD 제시가를 입력하거나, 상품 상세에서 인터넷최저가·매입가를 채워주세요.', 'error');
return; // finally 에서 submitting 해제 return; // finally 에서 submitting 해제
} }
// 서버가 견적+세션 생성을 끝내고 응답할 때까지 기다린 뒤에 완료(닫기) 처리한다. // 낙찰 기준은 1:1 협상만 전송(경매는 미전송 → 서버가 mid=over=AWARD 강제). 카드도 1:1 전용.
const ok = await onCreate({ const ok = await onCreate({
title, title,
type, type,
@ -110,10 +139,12 @@ export function QuotationCreateModal({
partnerIds: selectedPartnerIds, partnerIds: selectedPartnerIds,
dueDate, dueDate,
settingId, settingId,
cardIds: selectedCardIds, cardIds: oneToOne ? selectedCardIds : [],
memo, memo,
mdPrice: mdPrice ? Number(mdPrice) : null, mdPrice: mdPrice ? Number(mdPrice) : null,
supplierType: supplierType ? Number(supplierType) : null, supplierType: supplierType ? Number(supplierType) : null,
midAction: oneToOne ? midAction : undefined,
overAction: oneToOne ? overAction : undefined,
}); });
if (ok) onClose(); if (ok) onClose();
} finally { } 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 justify-between pb-4 border-b border-border">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<PlusSquare className="text-foreground" size={18} /> <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> </div>
<button onClick={onClose} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer"> <button onClick={onClose} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
<X size={18} /> <X size={18} />
</button> </button>
</div> </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"> <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"> {steps.map((label, i) => {
<Typography as="span" variant="body" className={`font-semibold ${step >= 1 ? 'text-primary' : 'text-muted-foreground'}`}>1. 기본 정보</Typography> const n = i + 1;
</button> return (
<ArrowRight size={14} className="shrink-0" /> <div key={label} className="flex items-center gap-2 min-w-0">
<button type="button" onClick={() => setStep(2)} className="cursor-pointer hover:opacity-75 transition-opacity"> {i > 0 && <ArrowRight size={14} className="shrink-0" />}
<Typography as="span" variant="body" className={`font-semibold ${step >= 2 ? 'text-primary' : 'text-muted-foreground'}`}>2. 협력사 초청</Typography> <button type="button" onClick={() => setStep(n)} className="cursor-pointer hover:opacity-75 transition-opacity truncate">
</button> <Typography as="span" variant="body" className={`font-semibold ${step === n ? 'text-primary' : 'text-muted-foreground'}`}>{n}. {label}</Typography>
<ArrowRight size={14} className="shrink-0" /> </button>
<button type="button" onClick={() => setStep(3)} className="cursor-pointer hover:opacity-75 transition-opacity"> </div>
<Typography as="span" variant="body" className={`font-semibold ${step >= 3 ? 'text-primary' : 'text-muted-foreground'}`}>3. 설정·완료</Typography> );
</button> })}
</div> </div>
{/* Step content */} {/* Step content */}
@ -165,41 +196,41 @@ export function QuotationCreateModal({
{step === 1 && ( {step === 1 && (
<div className="space-y-4"> <div className="space-y-4">
{/* 견적 유형이 맨 위 — 신규/재 여부가 아래 매입가 필수 여부까지 결정한다 */} {/* 진행 방식 × 대상 2축 — 협상/경매 갈림이 아래 카드·낙찰기준 노출까지 결정한다 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1"> <div className="space-y-1">
<Typography as="label" variant="label">견적 유형</Typography> <Typography as="label" variant="label">진행 방식</Typography>
<Select <Segmented
value={String(type)} options={[
onValueChange={(v) => { { value: 'nego', label: '1:1 협상', sub: '협상카드·낙찰기준 선택' },
const next = Number(v); { value: 'auction', label: '1:N 견적', sub: '최저가 자동 낙찰' },
setType(next); ]}
if (next === QuotationType.RENEGO) setSelectedPartnerIds((prev) => prev.slice(0, 1)); value={mode}
}} onChange={(v) => selectMode(v as QuotationMode)}
>
<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)}
/> />
</div> </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>
<div className="space-y-1"> <div className="space-y-1">
@ -216,7 +247,7 @@ export function QuotationCreateModal({
<div className="space-y-1"> <div className="space-y-1">
<Typography as="label" variant="label">상품</Typography> <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"> <SelectTrigger id="wizard-product" className="w-full">
<SelectValue> <SelectValue>
{(value) => { {(value) => {
@ -296,7 +327,7 @@ export function QuotationCreateModal({
{step === 2 && ( {step === 2 && (
<div className="space-y-3"> <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"> <div className="border border-border rounded overflow-hidden max-h-56 overflow-y-auto divide-y divide-border bg-background">
{partners.map((part) => { {partners.map((part) => {
const isChecked = selectedPartnerIds.includes(part.id ?? ''); const isChecked = selectedPartnerIds.includes(part.id ?? '');
@ -314,16 +345,9 @@ export function QuotationCreateModal({
/> />
<div> <div>
<Typography as="span" variant="small" className="font-semibold block">{part.name}</Typography> <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>
</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> </label>
); );
})} })}
@ -357,13 +381,13 @@ export function QuotationCreateModal({
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1 font-mono text-xs"> <div className="space-y-1 font-mono text-xs">
<Typography as="label" variant="label">적용할 견적 세팅 지정</Typography> <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"> <SelectTrigger id="wizard-setting-select" className="w-full">
<SelectValue> <SelectValue>
{(value) => { {(value) => {
const qs = quotationSettings.find((s) => s.qt_setting_id === value); const qs = quotationSettings.find((s) => s.qt_setting_id === value);
return qs return qs
? `[목표 마진: ${qs.target_margin}] ${qs.anchoring_value} (${qs.card_use_count})` ? `[목표 마진: ${qs.target_margin}] 카드 ${qs.card_use_count}`
: ''; : '';
}} }}
</SelectValue> </SelectValue>
@ -371,45 +395,42 @@ export function QuotationCreateModal({
<SelectContent> <SelectContent>
{quotationSettings.map((qs) => ( {quotationSettings.map((qs) => (
<SelectItem key={qs.qt_setting_id} value={qs.qt_setting_id}> <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> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2"> {oneToOne ? (
<Typography as="span" variant="label" className="block">협상카드 및 와일드카드 선택</Typography> <>
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto"> {/* 낙찰 기준(1:1 전용) — 스펙트럼 = 선택. 낙찰선을 앵커/목표가 중 택1, 목표가 초과는 항상 개찰. */}
{cards.filter((c) => !c.isWildcard || c.status === 'ACTIVE').map((card) => { <AwardLinePicker
const isChecked = selectedCardIds.includes(card.id); mid={midAction}
return ( over={overAction}
<div // 단조성: 초과=낙찰이면 앵커~목표가도 낙찰, 앵커~목표가=개찰이면 초과도 개찰(혼합 조합 방지)
key={card.id} onMid={(v) => {
onClick={() => toggleCard(card.id)} setMidAction(v);
className={`p-2.5 rounded border cursor-pointer transition-all flex items-start gap-2 ${ if (v === PriceGateAction.OPEN) setOverAction(PriceGateAction.OPEN);
isChecked ? 'bg-primary/5 border-primary font-bold' : 'bg-background border-border hover:bg-muted/10' }}
}`} onOver={(v) => {
> setOverAction(v);
<input type="checkbox" checked={isChecked} readOnly className="accent-primary h-3.5 w-3.5 mt-0.5" /> if (v === PriceGateAction.AWARD) setMidAction(PriceGateAction.AWARD);
<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 ${ /* 경매(1:N) — 낙찰 기준·협상카드 없음. 최저가 자동 낙찰 안내만. */
card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600' <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>
{card.isWildcard ? '와일드' : '협상'} <Typography as="span" variant="small" className="font-semibold block">최저가 자동 낙찰</Typography>
</span> <Typography as="span" variant="small" className="text-muted-foreground text-[10px] leading-snug">
</div> 1:N 견적은 가장 낮은 투찰가가 자동 낙찰됩니다. 낙찰 기준·협상카드 설정이 없습니다.
<Typography as="span" variant="small" className="mt-1 block leading-tight">{card.title}</Typography> </Typography>
</div> </div>
</div>
);
})}
</div> </div>
</div> )}
<div className="space-y-1"> <div className="space-y-1">
<Typography as="label" variant="label">메모 (선택)</Typography> <Typography as="label" variant="label">메모 (선택)</Typography>
@ -426,6 +447,45 @@ export function QuotationCreateModal({
</div> </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> </div>
{/* Footer nav */} {/* Footer nav */}
@ -440,7 +500,7 @@ export function QuotationCreateModal({
</Button> </Button>
<div className="flex gap-2"> <div className="flex gap-2">
{step < 3 ? ( {step < totalSteps ? (
<Button <Button
type="button" type="button"
size="sm" size="sm"
@ -466,3 +526,117 @@ export function QuotationCreateModal({
</div> </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 QuotationSetting,
type SessionView, type SessionView,
quotationTypeLabel, quotationTypeLabel,
priceGateActionLabel, awardCriterionLabel,
fmtDateTime, fmtDateTime,
} from '../../types'; } from '../../types';
@ -89,6 +89,7 @@ export function DrawerHeaderCards({
<InfoField label="견적번호" value={q_number} /> <InfoField label="견적번호" value={q_number} />
<InfoField label="유형" value={quotationTypeLabel(quotation.type)} /> <InfoField label="유형" value={quotationTypeLabel(quotation.type)} />
<InfoField label="차수" value={`${q_round}차`} /> <InfoField label="차수" value={`${q_round}차`} />
<InfoField label="낙찰 기준" value={awardCriterionLabel(quotation.type, quotation.mid_action, quotation.over_action)} valueClassName="font-sans" />
<InfoField label="목표가"> <InfoField label="목표가">
{repSessionId ? ( {repSessionId ? (
<button <button
@ -169,11 +170,7 @@ export function DrawerHeaderCards({
value={selectedSettingObj.target_margin} value={selectedSettingObj.target_margin}
valueClassName="font-bold text-emerald-600 dark:text-emerald-400 font-sans" valueClassName="font-bold text-emerald-600 dark:text-emerald-400 font-sans"
/> />
<InfoField label="앵커링 설정 값" value={selectedSettingObj.anchoring_value} valueClassName="font-sans" />
<InfoField label="카드 사용 횟수" value={selectedSettingObj.card_use_count} /> <InfoField label="카드 사용 횟수" value={selectedSettingObj.card_use_count} />
<InfoField label="앵커~목표 마감처리" value={priceGateActionLabel(selectedSettingObj.mid_action)} />
<InfoField label="목표초과 마감처리" value={priceGateActionLabel(selectedSettingObj.over_action)} />
<InfoField label="재생성 한도" value={`${selectedSettingObj.regen_limit}회`} />
</div> </div>
) : ( ) : (
<div className="text-muted-foreground py-6 text-center">적용된 견적 세팅이 비어있습니다.</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"> <div className="min-w-0">
<Typography as="span" variant="small" className="font-semibold block truncate">{part.name}</Typography> <Typography as="span" variant="small" className="font-semibold block truncate">{part.name}</Typography>
<Typography as="span" variant="small" className="text-muted-foreground"> <Typography as="span" variant="small" className="text-muted-foreground">
이메일: {part.managerEmail} · 등급: {part.rank} 이메일: {part.managerEmail}
</Typography> </Typography>
</div> </div>
</div> </div>

View File

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

View File

@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button';
import { Typography } from '@/components/ui/typography'; import { Typography } from '@/components/ui/typography';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { type QuotationSetting, priceGateActionLabel, PRICE_GATE_ACTION_OPTIONS } from '../types'; import { type QuotationSetting } from '../types';
import type { SettingInput } from '../hooks/useQuotations'; import type { SettingInput } from '../hooks/useQuotations';
type QuotationSettingsModalProps = { type QuotationSettingsModalProps = {
@ -23,27 +23,17 @@ export function QuotationSettingsModal({
onClose, onClose,
}: QuotationSettingsModalProps) { }: QuotationSettingsModalProps) {
const [targetMargin, setTargetMargin] = useState(''); const [targetMargin, setTargetMargin] = useState('');
const [anchoringValue, setAnchoringValue] = useState('');
const [cardUseCount, setCardUseCount] = useState(''); const [cardUseCount, setCardUseCount] = useState('');
const [midAction, setMidAction] = useState(1); // PriceGateAction 기본 낙찰
const [overAction, setOverAction] = useState(1);
const [regenLimit, setRegenLimit] = useState('1');
if (!open) return null; if (!open) return null;
// 세팅은 목표 마진율·카드 사용 횟수만. 낙찰 정책은 견적 생성으로 이관, 앵커링은 칸 rate(v1.2)로 대체.
const handleAdd = (e: React.FormEvent) => { const handleAdd = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const ok = onAdd({ const ok = onAdd({ targetMargin, cardUseCount });
targetMargin, anchoringValue, cardUseCount,
midAction, overAction, regenLimit: parseInt(regenLimit, 10),
});
if (ok) { if (ok) {
setTargetMargin(''); setTargetMargin('');
setAnchoringValue('');
setCardUseCount(''); setCardUseCount('');
setMidAction(1);
setOverAction(1);
setRegenLimit('1');
} }
}; };
@ -70,18 +60,14 @@ export function QuotationSettingsModal({
<TableHeader className="bg-muted text-muted-foreground"> <TableHeader className="bg-muted text-muted-foreground">
<TableRow> <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">목표초과</TableHead>
<TableHead className="p-2">재생성</TableHead>
<TableHead className="p-2 text-center w-12">삭제</TableHead> <TableHead className="p-2 text-center w-12">삭제</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody className="divide-y divide-border bg-background"> <TableBody className="divide-y divide-border bg-background">
{settings.length === 0 && ( {settings.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={7} className="p-6 text-center text-muted-foreground"> <TableCell colSpan={3} className="p-6 text-center text-muted-foreground">
등록된 견적 세팅이 없습니다. (리스트가 비어 있습니다) 등록된 견적 세팅이 없습니다. (리스트가 비어 있습니다)
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -89,11 +75,7 @@ export function QuotationSettingsModal({
{settings.map((qs) => ( {settings.map((qs) => (
<TableRow key={qs.qt_setting_id} className="hover:bg-muted/30"> <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 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">{qs.card_use_count}</TableCell>
<TableCell className="p-2 text-muted-foreground">{priceGateActionLabel(qs.mid_action)}</TableCell>
<TableCell className="p-2 text-muted-foreground">{priceGateActionLabel(qs.over_action)}</TableCell>
<TableCell className="p-2 text-muted-foreground">{qs.regen_limit}회</TableCell>
<TableCell className="p-2 text-center"> <TableCell className="p-2 text-center">
<button <button
type="button" type="button"
@ -119,42 +101,10 @@ export function QuotationSettingsModal({
<Typography as="label" variant="muted" className="text-[10px] font-semibold">목표 마진율 (%)</Typography> <Typography as="label" variant="muted" className="text-[10px] font-semibold">목표 마진율 (%)</Typography>
<Input type="number" step="0.1" value={targetMargin} onChange={(e) => setTargetMargin(e.target.value)} placeholder="예: 12" /> <Input type="number" step="0.1" value={targetMargin} onChange={(e) => setTargetMargin(e.target.value)} placeholder="예: 12" />
</div> </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"> <div className="space-y-1">
<Typography as="label" variant="muted" className="text-[10px] font-semibold">카드 사용 횟수</Typography> <Typography as="label" variant="muted" className="text-[10px] font-semibold">카드 사용 횟수</Typography>
<Input type="number" step="1" value={cardUseCount} onChange={(e) => setCardUseCount(e.target.value)} placeholder="예: 3" /> <Input type="number" step="1" value={cardUseCount} onChange={(e) => setCardUseCount(e.target.value)} placeholder="예: 3" />
</div> </div>
<div className="space-y-1">
<Typography as="label" variant="muted" className="text-[10px] font-semibold">앵커링가~목표가 마감처리</Typography>
<select
value={midAction}
onChange={(e) => setMidAction(Number(e.target.value))}
className="w-full h-9 rounded-md border border-input bg-background px-3 text-xs"
>
{PRICE_GATE_ACTION_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div className="space-y-1">
<Typography as="label" variant="muted" className="text-[10px] font-semibold">목표가초과 마감처리</Typography>
<select
value={overAction}
onChange={(e) => setOverAction(Number(e.target.value))}
className="w-full h-9 rounded-md border border-input bg-background px-3 text-xs"
>
{PRICE_GATE_ACTION_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div className="space-y-1">
<Typography as="label" variant="muted" className="text-[10px] font-semibold">재생성 최대 횟수 (체인 전체)</Typography>
<Input type="number" step="1" min="0" value={regenLimit} onChange={(e) => setRegenLimit(e.target.value)} placeholder="예: 1" />
</div>
</div> </div>
<div className="flex justify-end pt-2"> <div className="flex justify-end pt-2">

View File

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

View File

@ -27,7 +27,7 @@ import { confirm } from '@/lib/confirm';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import type { Estimate } from '../types'; import type { Estimate } from '../types';
import { mapItem, mapSupplier, mapSetting, mapQuotation } from '../types'; import { mapItem, mapSupplier, mapSetting, mapQuotation } from '../types';
import { QuotationStatus, type PriceGateAction } from '@/api/generated/model'; import { QuotationStatus } from '@/api/generated/model';
export type CreateQuotationInput = { export type CreateQuotationInput = {
title: string; title: string;
@ -40,15 +40,14 @@ export type CreateQuotationInput = {
memo: string; memo: string;
mdPrice?: number | null; // MD 제시가(원). 비우면 미전송 → 서버가 기존 마진식으로 목표가 산정 mdPrice?: number | null; // MD 제시가(원). 비우면 미전송 → 서버가 기존 마진식으로 목표가 산정
supplierType?: number | null; // 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록 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 = { export type SettingInput = {
targetMargin: string; targetMargin: string;
anchoringValue: string;
cardUseCount: 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 으로 나눠 보낸다. // 백엔드는 target_margin_rate 를 비율(0.12)로 저장하므로 % 입력을 100 으로 나눠 보낸다.
const addSetting = (input: SettingInput): boolean => { const addSetting = (input: SettingInput): boolean => {
const marginPct = Number(String(input.targetMargin).replace('%', '').trim()); 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); const cardCount = parseInt(String(input.cardUseCount).replace(/[^0-9-]/g, ''), 10);
if (!Number.isFinite(marginPct) || !Number.isFinite(anchoring) || !Number.isInteger(cardCount)) { if (!Number.isFinite(marginPct) || !Number.isInteger(cardCount)) {
showToast('목표 마진율·앵커링 값·카드 사용 횟수를 숫자로 입력해야 합니다.', 'error'); showToast('목표 마진율·카드 사용 횟수를 숫자로 입력해야 합니다.', 'error');
return false; return false;
} }
createSettingMutation.mutate( createSettingMutation.mutate(
// 낙찰 정책은 견적 생성으로 이관, 앵커링은 칸 rate(v1.2) → 세팅은 목표 마진율·카드 사용 횟수만.
{ data: { { data: {
target_margin_rate: marginPct / 100, anchoring_value: anchoring, card_count: cardCount, target_margin_rate: marginPct / 100, card_count: cardCount,
mid_action: (input.midAction ?? 1) as PriceGateAction,
over_action: (input.overAction ?? 1) as PriceGateAction,
regen_limit: Number.isInteger(input.regenLimit) ? (input.regenLimit as number) : 1,
} }, } },
{ {
onSuccess: () => { onSuccess: () => {
@ -199,6 +195,9 @@ export function useQuotations(params: ListQuotationsParams) {
memo: input.memo.trim() || undefined, memo: input.memo.trim() || undefined,
md_price: input.mdPrice && input.mdPrice > 0 ? input.mdPrice : undefined, md_price: input.mdPrice && input.mdPrice > 0 ? input.mdPrice : undefined,
supplier_type: input.supplierType ?? undefined, supplier_type: input.supplierType ?? undefined,
// 낙찰 기준은 1:1 협상만 전송(모달이 미리 걸러 담음) — 경매면 미전송 → 서버가 AWARD 강제.
mid_action: input.midAction ?? undefined,
over_action: input.overAction ?? undefined,
}; };
try { 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 { QuotationData } from '@/api/generated/model/quotationData';
import type { SessionData } from '@/api/generated/model/sessionData'; import type { SessionData } from '@/api/generated/model/sessionData';
import type { QuotationCardData } from '@/api/generated/model/quotationCardData'; import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
import { QuotationType, QuotationStatus, SessionStatus, CardType, CloseReason, PriceGateAction } from '@/api/generated/model'; import { QuotationType, QuotationStatus, SessionStatus, CardType, CloseReason } from '@/api/generated/model';
import { DELIVERY_TYPE_LABEL } from '@/lib/enumLabels'; import { DELIVERY_TYPE_LABEL } from '@/lib/enumLabels';
import type { Product, Partner, NegotiationCard } from '@/types'; import type { Product, Partner, NegotiationCard } from '@/types';
export 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> & { export type Estimate = Partial<QuotationData> & {
id?: string; id?: string;
dueDate?: string; dueDate?: string;
@ -31,30 +36,12 @@ export interface QuotationSetting {
qt_setting_id: string; qt_setting_id: string;
user_id: string; user_id: string;
target_margin: string; target_margin: string;
anchoring_value: string;
card_use_count: string; card_use_count: string;
mid_action: PriceGateAction; // 앵커링가<투찰가≤목표가 마감 처리
over_action: PriceGateAction; // 목표가<투찰가 마감 처리
regen_limit: number; // 재생성 최대 횟수(체인 전체, 사유 무관)
created_at: string; created_at: string;
updated_at: string; updated_at: string;
deleted: boolean; deleted: boolean;
} }
// 마감 가격정책 처리 라벨(견적세팅 표시·선택).
export const PRICE_GATE_ACTION_LABEL: Record<PriceGateAction, string> = {
[PriceGateAction.AWARD]: '낙찰',
[PriceGateAction.RENEGO]: '재협상',
[PriceGateAction.FAIL]: '결렬',
};
export const priceGateActionLabel = (a?: number | null): string =>
a != null ? PRICE_GATE_ACTION_LABEL[a as PriceGateAction] ?? String(a) : '';
export const PRICE_GATE_ACTION_OPTIONS = [
PriceGateAction.AWARD,
PriceGateAction.RENEGO,
PriceGateAction.FAIL,
].map((value) => ({ value, label: PRICE_GATE_ACTION_LABEL[value] }));
// ── 서버 응답 → UI 모델 매퍼 ───────────────────────────────────────────── // ── 서버 응답 → UI 모델 매퍼 ─────────────────────────────────────────────
export function mapItem(it: ItemData): Product { export function mapItem(it: ItemData): Product {
@ -68,7 +55,6 @@ export function mapSupplier(sp: SupplierData): Partner {
managerName: sp.manager_name || '', managerName: sp.manager_name || '',
managerEmail: sp.manager_email || '', managerEmail: sp.manager_email || '',
managerPhone: sp.manager_contact_number || '', managerPhone: sp.manager_contact_number || '',
rank: sp.priority === 'HIGH' ? 'S' : sp.priority === 'MEDIUM' ? 'A' : 'B',
status: 'ACTIVE', status: 'ACTIVE',
}; };
} }
@ -80,11 +66,7 @@ export function mapSetting(s: QuotationSettingData): QuotationSetting {
qt_setting_id: s.qt_setting_id, qt_setting_id: s.qt_setting_id,
user_id: s.user_id || '', user_id: s.user_id || '',
target_margin: `${Number.isFinite(ratePct) ? +ratePct.toFixed(2) : 0}%`, target_margin: `${Number.isFinite(ratePct) ? +ratePct.toFixed(2) : 0}%`,
anchoring_value: String(s.anchoring_value ?? ''),
card_use_count: `${s.card_count ?? 0}회`, card_use_count: `${s.card_count ?? 0}회`,
mid_action: s.mid_action ?? PriceGateAction.AWARD,
over_action: s.over_action ?? PriceGateAction.AWARD,
regen_limit: s.regen_limit ?? 1,
created_at: s.created_at ?? '', created_at: s.created_at ?? '',
updated_at: s.updated_at ?? '', updated_at: s.updated_at ?? '',
deleted: false, deleted: false,
@ -174,36 +156,77 @@ export const QUOTATION_TYPE_OPTIONS = [
export const isNewQuotationType = (t?: number | null): boolean => export const isNewQuotationType = (t?: number | null): boolean =>
t === QuotationType.NEW_NEGO || t === QuotationType.NEW_QUOTE; 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 단일 출처에서 파생. // 협력사 유형 선택지(견적생성 모달) — 라벨은 lib/enumLabels.ts 의 SUPPLIER_TYPE 단일 출처에서 파생.
export { SUPPLIER_TYPE_OPTIONS as supplierTypeOptions } from '@/lib/enumLabels'; 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> = { export const CHAIN_ROUND_STATE_LABEL: Record<ChainRoundState, string> = {
awarded: '낙찰', awarded: '낙찰',
regenerated: '재생성', opened: '개찰',
failed: '결렬',
active: '진행중', active: '진행중',
}; };
const REGEN_CLOSE_REASONS: CloseReason[] = [CloseReason.REGEN_PRICE, CloseReason.REGEN_EQUAL, CloseReason.REGEN_NOSHOW]; const OPEN_CLOSE_REASONS: CloseReason[] = [
const FAIL_CLOSE_REASONS: CloseReason[] = [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) 을 '단일 근거'로 판정한다. // 마감결과 한 단어. 서버 close_reason(CloseReason) 을 '단일 근거'로 판정한다.
// close_reason 이 아직 없는 옛 데이터만 preferred_sp_id/equal_bid_yn 플래그로 폴백. // close_reason 이 아직 없는 옛 데이터만 preferred_sp_id 플래그로 폴백(마감됐는데 낙찰자 없으면 개찰).
// QuotationData(전체)·Estimate(Partial) 둘 다 받도록 필요한 필드만 optional. // QuotationData(전체)·Estimate(Partial) 둘 다 받도록 필요한 필드만 optional.
export function chainRoundState( 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 { ): ChainRoundState {
if (q.close_reason != null) { if (q.close_reason != null) {
if (q.close_reason === CloseReason.AWARDED) return 'awarded'; if (q.close_reason === CloseReason.AWARDED) return 'awarded';
if (REGEN_CLOSE_REASONS.includes(q.close_reason as CloseReason)) return 'regenerated'; if (OPEN_CLOSE_REASONS.includes(q.close_reason as CloseReason)) return 'opened';
if (FAIL_CLOSE_REASONS.includes(q.close_reason as CloseReason)) return 'failed';
} }
if (q.preferred_sp_id) return 'awarded'; if (q.preferred_sp_id) return 'awarded';
if (q.equal_bid_yn) return 'regenerated'; if (q.status === QuotationStatus.CLOSED) return 'opened';
if (q.status === QuotationStatus.CLOSED) return 'failed';
return 'active'; return 'active';
} }
@ -240,7 +263,7 @@ export type QuotationCardView = {
// DB 저장값이 아니라 이미 로드된 quotation + 세션들로 파생한다(별도 컬럼·엔드포인트 없음). // DB 저장값이 아니라 이미 로드된 quotation + 세션들로 파생한다(별도 컬럼·엔드포인트 없음).
// · 목표가 = md_price 우선(없으면 세션 목표가) // · 목표가 = md_price 우선(없으면 세션 목표가)
// · 낙찰가 = 우선협상자 세션의 투찰가(미낙찰이면 현재 최저 투찰가 = 잠정) // · 낙찰가 = 우선협상자 세션의 투찰가(미낙찰이면 현재 최저 투찰가 = 잠정)
// · 절감 = 목표가 − 낙찰가 (양수 = 목표보다 저렴하게 낙찰) // · 절감 = 목표가 − 낙찰가 (양수 = 목표가보다 저렴하게 낙찰)
export type QuotationResultView = { export type QuotationResultView = {
outcome: ChainRoundState; outcome: ChainRoundState;
closeReason: string; 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> = { export const CLOSE_REASON_LABEL: Record<CloseReason, string> = {
[CloseReason.AWARDED]: '낙찰', [CloseReason.AWARDED]: '낙찰',
[CloseReason.REGEN_PRICE]: '목표초과 — 재협상 진행', [CloseReason.OPEN_PRICE]: '목표가 초과 — 개찰(낙찰자 미정)',
[CloseReason.REGEN_EQUAL]: '동가 — 재입찰 진행', [CloseReason.OPEN_EQUAL]: '동가 — 개찰(낙찰자 미정)',
[CloseReason.REGEN_NOSHOW]: '전원 미참여 — 재소집 진행', [CloseReason.OPEN_NOSHOW]: '전원 미응찰 — 개찰',
[CloseReason.FAIL_PRICE]: '목표초과로 결렬', [CloseReason.OPEN_REJECT]: '협상 거부 — 개찰',
[CloseReason.FAIL_EQUAL]: '동가로 결렬',
[CloseReason.FAIL_NOSHOW]: '미참여로 결렬',
[CloseReason.FAIL_REJECT]: '협상 거부로 결렬',
}; };
// 마감결과 텍스트(정밀). 마감결과 배지·라벨은 이 값을 쓴다 — close_reason(CloseReason) 을 그대로 표기. // 마감결과 텍스트(정밀). 마감결과 배지·라벨은 이 값을 쓴다 — close_reason(CloseReason) 을 그대로 표기.
@ -318,10 +338,9 @@ function quotationCloseReason(q: QuotationData, sessions: SessionView[]): string
if (q.status !== QuotationStatus.CLOSED) return '협상 진행중'; if (q.status !== QuotationStatus.CLOSED) return '협상 진행중';
if (q.close_reason != null) return CLOSE_REASON_LABEL[q.close_reason as CloseReason] ?? '마감'; if (q.close_reason != null) return CLOSE_REASON_LABEL[q.close_reason as CloseReason] ?? '마감';
if (q.preferred_sp_yn) return '낙찰'; if (q.preferred_sp_yn) return '낙찰';
if (q.equal_bid_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 '협상 거부 — 개찰';
if (sessions.some((s) => s.status === SessionStatus.REJECTED)) return '협상 거부로 마감'; return '개찰(낙찰자 미정)';
return '마감';
} }
// ── 서버 연동 매퍼(negotiation.sessions / chats / 사용 카드) ────────────── // ── 서버 연동 매퍼(negotiation.sessions / chats / 사용 카드) ──────────────

View File

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

View File

@ -4,7 +4,6 @@ import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm'; import { confirm } from '@/lib/confirm';
import { PageContainer } from '@/components/layout/PageContainer'; import { PageContainer } from '@/components/layout/PageContainer';
import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar'; import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { useServerList } from '@/lib/useServerList'; 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 { PartnerTable } from '@/features/partners/components/PartnerTable';
import { PartnerFormSheet } from '@/features/partners/components/PartnerFormSheet'; import { PartnerFormSheet } from '@/features/partners/components/PartnerFormSheet';
import { ExcelUploadModal, downloadPartnerTemplate } from '@/features/partners/components/ExcelUploadModal'; 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() { export default function PartnersPage() {
// 검색/우선순위/페이지 상태(재사용 훅) → 서버 쿼리 파라미터로 변환. // 검색/페이지 상태(재사용 훅) → 서버 쿼리 파라미터로 변환.
const list = useServerList({ pageSize: 10, initialFilters: { priority: 'ALL' } }); const list = useServerList({ pageSize: 10 });
const priorityFilter = list.filters.priority;
const params: ListSuppliersParams = { const params: ListSuppliersParams = {
search: list.debouncedSearch || undefined, search: list.debouncedSearch || undefined,
priority: priorityFilter !== 'ALL' ? priorityFilter : undefined,
page: list.page, page: list.page,
size: list.pageSize, size: list.pageSize,
}; };
@ -91,21 +88,6 @@ export default function PartnersPage() {
onClear={list.clearSearch} onClear={list.clearSearch}
placeholder="협력사명, 코드 또는 담당자명으로 추적 검색..." 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> </PageToolbar>
<PartnerTable <PartnerTable

View File

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

View File

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

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 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 over_action SMALLINT NOT NULL DEFAULT 1, -- 가격정책(PriceGateAction): 목표가<투찰가 처리
ADD COLUMN IF NOT EXISTS regen_limit SMALLINT NOT NULL DEFAULT 1; -- 재생성 최대 횟수(체인 전체 총합, 사유 무관) 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;