Merge branch 'main' into design/frontend
This commit is contained in:
commit
d9191420c1
@ -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"
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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="협력사 등록")
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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:
|
||||||
"""[프론트] 마감된 견적을 골라 수동으로 다음 라운드를 생성한다.
|
"""[프론트] 마감된 견적을 골라 수동으로 다음 라운드를 생성한다.
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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()],
|
||||||
|
|||||||
@ -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
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 와 같은 이유)."""
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -10,10 +10,6 @@ export type ListSuppliersParams = {
|
|||||||
* 협력사명/코드/담당자명 검색
|
* 협력사명/코드/담당자명 검색
|
||||||
*/
|
*/
|
||||||
search?: string | null;
|
search?: string | null;
|
||||||
/**
|
|
||||||
* 우선순위 필터(HIGH/MEDIUM/LOW)
|
|
||||||
*/
|
|
||||||
priority?: string | null;
|
|
||||||
/**
|
/**
|
||||||
* @minimum 1
|
* @minimum 1
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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;
|
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
@ -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;
|
|
||||||
@ -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;
|
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -74,9 +74,9 @@ export const STEPS: Step[] = [
|
|||||||
actor: '시스템',
|
actor: '시스템',
|
||||||
actorType: 'system',
|
actorType: 'system',
|
||||||
icon: Award,
|
icon: Award,
|
||||||
short: '단독최저=낙찰 / 동가·미참여=재생성 / 결렬',
|
short: '단독최저+기준통과=낙찰 / 그 외=개찰(낙찰자 미정)',
|
||||||
detail:
|
detail:
|
||||||
'단독 최저가면 그 협력사로 낙찰됩니다. 최저가가 둘 이상 같은 동가이거나 전원 미참여면 다음 차수가 자동 재생성되고, 거절·한도 등으로 낙찰자가 없으면 결렬 처리됩니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.',
|
'단독 최저가가 견적의 낙찰 기준을 통과하면 그 협력사로 낙찰됩니다. 기준을 못 넘기거나 동가·전원 미응찰·협상거부인 경우는 결렬이 아니라 개찰 — 낙찰자 미정으로 마감되고, 다음 라운드는 담당자가 상세에서 수동으로 만듭니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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 (서브 보조처)' },
|
|
||||||
];
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 / 사용 카드) ──────────────
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 -- 소프트 삭제 여부
|
||||||
@ -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;
|
||||||
Loading…
Reference in New Issue
Block a user