[feat] negodata: 타결 상한(done_ceiling) — 세팅 기본율%·견적 override·세션 박제, 견적생성 3단계 타결/낙찰 섹션 분리·재생성 상속
This commit is contained in:
parent
2bf91d64f5
commit
91b8dc77a0
@ -266,6 +266,7 @@ class quotation_settings(MainTableMixin, MAIN_BASE):
|
||||
user_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 설정 소유 유저
|
||||
target_margin_rate = Column(Numeric(8, 6), nullable=False) # 목표 마진율(목표가 산정에 사용)
|
||||
card_count = Column(Integer, nullable=False, default=3)
|
||||
done_ceiling_rate = Column(SmallInteger, nullable=False, server_default=text("50"), default=50) # 협상 완료 상한율(‰). 완료 상한=목표가×(1+값/1000)
|
||||
# 낙찰 가격정책(mid/over/regen)은 견적 단위로 이관, 앵커링은 칸 rate(anchoring v1.2)로 대체 → 세팅 컬럼 제거됨.
|
||||
|
||||
|
||||
@ -303,6 +304,7 @@ class quotations(MainTableMixin, MAIN_BASE):
|
||||
# 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=개찰)
|
||||
done_ceiling_rate = Column(SmallInteger, nullable=True) # 협상 완료 상한율(‰) 견적별 override. NULL 이면 quotation_settings 값
|
||||
|
||||
|
||||
class sessions(MainTableMixin, MAIN_BASE):
|
||||
@ -319,6 +321,7 @@ class sessions(MainTableMixin, MAIN_BASE):
|
||||
qt_type = Column(SmallInteger, nullable=False) # QuotationType 스냅샷
|
||||
target_price = Column(BigInteger, nullable=False) # 목표가(원)
|
||||
anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원) — 생성 시 박제, 이후 수정 금지(앵커링 배치 판정 기준)
|
||||
done_ceiling_price = Column(BigInteger, nullable=True) # 협상 완료 상한가(원) — 생성 시 박제 = 목표가×(1+완료상한율/1000). 봇 종결·마감이 이 이하면 타결
|
||||
anchoring_value = Column(SmallInteger, nullable=True) # 제안 당시 앵커링 값(정수 ‰) 박제 — 위와 동일 규칙. 주의: quotation_settings.anchoring_value(구 float 비율)와 무관. 나머지 앵커링 컬럼(last_offer_price 등)은 backend/배치 소유라 매핑 안 함
|
||||
status = Column(SmallInteger, nullable=False) # SessionStatus 코드
|
||||
bid_price = Column(BigInteger, nullable=True) # 입찰가(원)
|
||||
|
||||
@ -450,21 +450,23 @@ class QuotationCRUD(IQuotationCRUD):
|
||||
return ErrorType.DB_RUN_FAILED, {}
|
||||
|
||||
async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]:
|
||||
"""견적 세팅의 목표 마진율: {margin}. 목표가 산정 입력(인터넷 수수료는 상수).
|
||||
"""견적 세팅의 목표 마진율·협상 완료 상한율: {margin, done_ceiling_rate}.
|
||||
margin·수수료는 목표가 산정 입력, done_ceiling_rate(‰)는 완료 상한 = 목표가×(1+값/1000).
|
||||
(낙찰 정책은 견적 단위 이관, 앵커링은 칸 rate v1.2 → 세팅 컬럼 제거됨.)"""
|
||||
try:
|
||||
query = select(
|
||||
quotation_settings.target_margin_rate,
|
||||
quotation_settings.done_ceiling_rate,
|
||||
).where(quotation_settings.qt_setting_id == qt_setting_id).limit(1)
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, {}
|
||||
if not rows:
|
||||
return ErrorType.SUCCESS, {}
|
||||
# 단일 컬럼 select → execute 가 스칼라 리스트를 돌려준다(Row 아님).
|
||||
margin = rows[0]
|
||||
row = rows[0]
|
||||
return ErrorType.SUCCESS, {
|
||||
"margin": float(margin) if margin is not None else None,
|
||||
"margin": float(row.target_margin_rate) if row.target_margin_rate is not None else None,
|
||||
"done_ceiling_rate": int(row.done_ceiling_rate) if row.done_ceiling_rate is not None else None,
|
||||
}
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
|
||||
@ -33,10 +33,16 @@ class Req_CreateQuotation(QuotationProtocol):
|
||||
# 1:N 경매는 미전송 → 서버가 mid=over=AWARD 강제('무조건 최저가 낙찰').
|
||||
mid_action: Optional[int] = None # PriceGateAction: 앵커링가<투찰가≤목표가 처리
|
||||
over_action: Optional[int] = None # PriceGateAction: 목표가<투찰가 처리
|
||||
done_ceiling_rate: Optional[int] = None # 협상 완료 상한율(‰) 견적 override. None 이면 회사 세팅값
|
||||
|
||||
|
||||
class Req_RegenerateQuotation(QuotationProtocol):
|
||||
supplier_ids: list[uuid.UUID] = [] # 다음 라운드에 부를 공급사(프론트 선택). 상품·기간·번호는 원 견적에서 이어받음
|
||||
supplier_ids: list[uuid.UUID] = [] # 다음 라운드에 부를 공급사(프론트 선택). 상품·번호는 원 견적에서 이어받음
|
||||
# 아래 재지정값은 전부 미전송(None)이면 원 견적/직전 라운드 값을 그대로 승계한다.
|
||||
card_ids: Optional[list[uuid.UUID]] = None # 다음 라운드 협상카드. 빈 리스트면 카드 없는 새 버전
|
||||
target_price: Optional[int] = None # 목표가(원). 이 라운드의 모든 상품에 적용
|
||||
end_time: Optional[datetime] = None # 마감기한. 미전송이면 원 견적과 같은 기간 길이로 생성 시각부터
|
||||
done_ceiling_rate: Optional[int] = None # 타결 상한율(‰). 완료 상한=목표가×(1+값/1000)
|
||||
|
||||
|
||||
class Req_AwardQuotation(QuotationProtocol):
|
||||
@ -71,6 +77,7 @@ class QuotationData(WebPacketProtocol):
|
||||
close_reason: Optional[CloseReason] = None # 마감 사유(CloseReason). 미마감이면 None
|
||||
mid_action: Optional[int] = None # 낙찰 기준(견적 단위). 상세 드로어 낙찰기준 표시용
|
||||
over_action: Optional[int] = None
|
||||
done_ceiling_rate: Optional[int] = None # 타결 상한율(‰) 견적 override. None 이면 견적 세팅값을 따름
|
||||
participation_count: int = 0 # 견적별 참여 협력사 수(세션 distinct supplier). 목록 집계로 채움.
|
||||
item_id: Optional[uuid.UUID] = None # 대표 상품 id(세션의 첫 item). 목록 조인으로 채움.
|
||||
item_name: Optional[str] = None # 대표 상품명. 목록 조인으로 채움.
|
||||
|
||||
@ -72,7 +72,13 @@ async def award_quotation(
|
||||
async def regenerate_quotation(
|
||||
qt_id: UUID, req: Req_RegenerateQuotation, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
|
||||
):
|
||||
return RemoveNoneResponse(await service.regenerate_quotation(str(qt_id), user_info.company_id, req.supplier_ids, user_info.user_id, user_info.role))
|
||||
return RemoveNoneResponse(
|
||||
await service.regenerate_quotation(
|
||||
str(qt_id), user_info.company_id, req.supplier_ids, user_info.user_id, user_info.role,
|
||||
card_ids=req.card_ids, target_price=req.target_price,
|
||||
end_time=req.end_time, done_ceiling_rate=req.done_ceiling_rate,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ----- 견적 상세 (FK로 연결된 하위 데이터 / 일부는 모델 미존재로 스텁) -----
|
||||
|
||||
@ -15,11 +15,13 @@ class QuotationSettingProtocol(WebPacketProtocol):
|
||||
class Req_CreateQuotationSetting(QuotationSettingProtocol):
|
||||
target_margin_rate: float
|
||||
card_count: int = 3
|
||||
done_ceiling_rate: int = 50 # 협상 완료 상한율(‰). 완료 상한=목표가×(1+값/1000)
|
||||
|
||||
|
||||
class Req_UpdateQuotationSetting(QuotationSettingProtocol):
|
||||
target_margin_rate: Optional[float] = None
|
||||
card_count: Optional[int] = None
|
||||
done_ceiling_rate: Optional[int] = None
|
||||
|
||||
|
||||
class QuotationSettingData(WebPacketProtocol):
|
||||
@ -29,6 +31,7 @@ class QuotationSettingData(WebPacketProtocol):
|
||||
user_id: Optional[uuid.UUID] = None
|
||||
target_margin_rate: float
|
||||
card_count: int
|
||||
done_ceiling_rate: int
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
@ -51,9 +51,10 @@ class BuildMixin:
|
||||
md_price=req.md_price,
|
||||
item_ids=req.item_ids,
|
||||
supplier_ids=req.supplier_ids,
|
||||
card_ids=req.card_ids,
|
||||
card_ids=req.card_ids or None, # 미선택이면 새 버전 없이 기본 전략 버전(version_id)을 그대로 쓴다
|
||||
mid_action=req.mid_action,
|
||||
over_action=req.over_action,
|
||||
done_ceiling_rate=req.done_ceiling_rate,
|
||||
)
|
||||
if res.result.success:
|
||||
await create_notification(
|
||||
@ -63,7 +64,11 @@ class BuildMixin:
|
||||
)
|
||||
return res
|
||||
|
||||
async def regenerate_next_round(self, original_qt_id: uuid.UUID, supplier_ids: list, regen_label: Optional[str] = None) -> Res_CreateQuotation:
|
||||
async def regenerate_next_round(
|
||||
self, original_qt_id: uuid.UUID, supplier_ids: list, regen_label: Optional[str] = None, *,
|
||||
card_ids: Optional[list] = None, target_price: Optional[int] = None,
|
||||
end_time=None, done_ceiling_rate: Optional[int] = None,
|
||||
) -> Res_CreateQuotation:
|
||||
"""[재생성] 마감된 견적의 '다음 라운드'를 새로 만든다. 호출 경로는 수동 재생성·재협상 승인뿐.
|
||||
|
||||
플로우:
|
||||
@ -73,6 +78,9 @@ class BuildMixin:
|
||||
|
||||
견적번호(number)를 원본 그대로 이어받아 '같은 번호 = 한 체인'으로 묶는다(parent_id 대체).
|
||||
supplier_ids: 다음 라운드에 부를 공급사(동가면 동가 업체만, 그 외엔 원 견적 공급사 전체).
|
||||
|
||||
card_ids·target_price·end_time·done_ceiling_rate 는 담당자가 이번 라운드에서만 바꾸는 조정값이다.
|
||||
미지정(None)이면 전부 원 견적/직전 라운드 값을 그대로 승계한다(기존 동작).
|
||||
"""
|
||||
res = Res_CreateQuotation()
|
||||
|
||||
@ -88,16 +96,22 @@ class BuildMixin:
|
||||
)
|
||||
item_ids = list({r.item_id for r in rows}) if err_type == ErrorType.SUCCESS else []
|
||||
# 재생성은 목표가를 재계산하지 않고 직전 라운드 세션 값을 그대로 상속(KTC 방식).
|
||||
# 앵커링가는 상속하지 않는다 — 생성 시점의 칸 rate 로 항상 재계산·박제(앵커링 v1.2 인수인계 규칙 1).
|
||||
inherited_target_prices = {r.item_id: r.target_price for r in rows} if err_type == ErrorType.SUCCESS else {}
|
||||
# 담당자가 목표가를 다시 잡았으면(target_price) 그 값이 이번 라운드 전 상품의 목표가가 된다.
|
||||
# 앵커링가는 어느 쪽이든 상속하지 않는다 — 생성 시점의 칸 rate 로 항상 재계산·박제(앵커링 v1.2 인수인계 규칙 1).
|
||||
if target_price is not None:
|
||||
inherited_target_prices = {iid: target_price for iid in item_ids}
|
||||
else:
|
||||
inherited_target_prices = {r.item_id: r.target_price for r in rows} if err_type == ErrorType.SUCCESS else {}
|
||||
|
||||
# 2) 타입 결정: 공급사 1곳 → 재협상 / 여러 곳 → 재견적
|
||||
next_type = QuotationType.RENEGO.value if len(supplier_ids) <= 1 else QuotationType.REQUOTE.value
|
||||
|
||||
# 3) 다음 라운드의 견적 생성
|
||||
now = GTime.UTC()
|
||||
# 원본 협상기간을 이어쓰되, 비정상적으로 짧으면 최소 하한을 적용(즉시 만료→연쇄 재마감 방지).
|
||||
# 마감기한을 다시 잡았으면 그 값, 아니면 원본 협상기간을 이어쓴다.
|
||||
# 이어쓸 때만 최소 하한을 적용한다(원본 기간이 비정상적으로 짧아 즉시 만료→연쇄 재마감 되는 것 방지).
|
||||
duration = max(original.end_time - original.start_time, self.MIN_REGEN_DURATION)
|
||||
next_end_time = end_time or (now + duration)
|
||||
# 다음 차수는 '원본 round+1' 이 아니라 '체인(같은 번호) 최신 round+1'.
|
||||
# 크론 마감과 수동 regenerate_quotation 이 같은 체인을 처리하는 타이밍이 엇갈려도
|
||||
# 항상 체인 끝에 이어붙어 uq_quotations_number(number, round) 충돌을 막는다.
|
||||
@ -122,23 +136,30 @@ class BuildMixin:
|
||||
status=QuotationStatus.CREATED.value,
|
||||
round_=next_round,
|
||||
start_time=now,
|
||||
end_time=now + duration,
|
||||
end_time=next_end_time,
|
||||
manager_name=original.manager_name,
|
||||
manager_email=original.manager_email,
|
||||
manager_contact_number=original.manager_contact_number,
|
||||
memo=original.memo,
|
||||
md_price=original.md_price,
|
||||
md_price=target_price if target_price is not None else original.md_price,
|
||||
item_ids=item_ids,
|
||||
supplier_ids=list(supplier_ids),
|
||||
card_ids=[], # 새 버전 안 만듦(원본 version_id 재사용)
|
||||
# None=원본 version_id 재사용(새 버전 안 만듦), 리스트=이 카드들로 새 버전 생성(빈 리스트면 카드 없는 버전).
|
||||
card_ids=card_ids,
|
||||
mid_action=original.mid_action, # 낙찰 기준 상속(타입이 REQUOTE 로 바뀌면 빌더가 AWARD 로 재정규화)
|
||||
over_action=original.over_action,
|
||||
done_ceiling_rate=done_ceiling_rate if done_ceiling_rate is not None else original.done_ceiling_rate,
|
||||
inherited_target_prices=inherited_target_prices, # 직전 라운드 목표가 상속(앵커링가는 현재 rate 로 재계산)
|
||||
)
|
||||
|
||||
async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list, user_id=None, role=None, regen_label: Optional[str] = None) -> Res_CreateQuotation:
|
||||
async def regenerate_quotation(
|
||||
self, qt_id: str, company_id, supplier_ids: list, user_id=None, role=None, regen_label: Optional[str] = None, *,
|
||||
card_ids: Optional[list] = None, target_price: Optional[int] = None,
|
||||
end_time=None, done_ceiling_rate: Optional[int] = None,
|
||||
) -> Res_CreateQuotation:
|
||||
"""[프론트] 마감된 견적을 골라 수동으로 다음 라운드를 생성한다.
|
||||
상품·기간·견적번호·카드버전은 원 견적에서 이어받는다(regenerate_next_round)."""
|
||||
상품·견적번호는 원 견적에서 이어받고, 카드·목표가·마감기한·타결 상한율은
|
||||
담당자가 모달에서 다시 잡은 값이 있으면 그 값으로 만든다(regenerate_next_round)."""
|
||||
res = Res_CreateQuotation()
|
||||
qt_uuid = uuid.UUID(qt_id)
|
||||
|
||||
@ -171,16 +192,23 @@ class BuildMixin:
|
||||
res.msg = "마지막 차수의 견적에서만 다음 라운드를 생성할 수 있습니다."
|
||||
return res
|
||||
|
||||
return await self.regenerate_next_round(qt_uuid, supplier_ids, regen_label=regen_label)
|
||||
return await self.regenerate_next_round(
|
||||
qt_uuid, supplier_ids, regen_label=regen_label,
|
||||
card_ids=card_ids, target_price=target_price,
|
||||
end_time=end_time, done_ceiling_rate=done_ceiling_rate,
|
||||
)
|
||||
|
||||
async def _build_quotation(
|
||||
self, *,
|
||||
user_id: str, qt_setting_id, version_id, name: str, number: str,
|
||||
type_: int, status: int, round_: int, start_time, end_time,
|
||||
manager_name, manager_email, manager_contact_number, memo, md_price,
|
||||
item_ids: list, supplier_ids: list, card_ids: list,
|
||||
item_ids: list, supplier_ids: list,
|
||||
# None = 넘겨받은 version_id 를 그대로 쓴다(카드 승계). 리스트면 이 카드들로 새 버전을 만든다(빈 리스트=카드 없는 버전).
|
||||
card_ids: Optional[list],
|
||||
mid_action: Optional[int] = None, # 낙찰 기준(견적 단위). 앵커링가<투찰가≤목표가 처리(AWARD/OPEN)
|
||||
over_action: Optional[int] = None, # 목표가<투찰가 처리(1:1 협상은 항상 OPEN)
|
||||
done_ceiling_rate: Optional[int] = None, # 협상 완료 상한율(‰) 견적 override. None 이면 세팅 기본값
|
||||
inherited_target_prices: Optional[dict] = None, # 재생성 시 직전 라운드 목표가 상속(KTC). 목표가만 — 앵커는 항상 재계산
|
||||
) -> Res_CreateQuotation:
|
||||
"""견적 1건 + (상품×공급사) 세션들을 한 트랜잭션으로 생성하는 공통 빌더."""
|
||||
@ -195,13 +223,15 @@ class BuildMixin:
|
||||
over_action = over_action or PriceGateAction.AWARD.value
|
||||
|
||||
# 목표가 계산 재료(가격·비율·회사 설정)를 먼저 모아온다.
|
||||
prices, fee, margin, hidden = await self._load_target_inputs(item_ids, qt_setting_id, user_id)
|
||||
prices, fee, margin, hidden, setting_ceiling_rate = await self._load_target_inputs(item_ids, qt_setting_id, user_id)
|
||||
# 완료 상한율(‰) — 견적 override 우선, 없으면 세팅 기본. 세션에 완료 상한가(원)로 박제한다.
|
||||
effective_ceiling_rate = done_ceiling_rate if done_ceiling_rate is not None else setting_ceiling_rate
|
||||
|
||||
# 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다.
|
||||
# (quotation↔card 는 version → version_nego_cards/version_wild_cards 로 연결.)
|
||||
version_obj = None
|
||||
link_rows = []
|
||||
if card_ids:
|
||||
if card_ids is not None:
|
||||
_err, card_types = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
@ -244,6 +274,7 @@ class BuildMixin:
|
||||
md_price=md_price,
|
||||
mid_action=mid_action,
|
||||
over_action=over_action,
|
||||
done_ceiling_rate=done_ceiling_rate, # 견적 override 원본 저장(None=세팅 따름)
|
||||
)
|
||||
|
||||
# 상품 × 공급사 조합마다 세션 1개. md/매입/판매/인터넷 후보가 하나도 없으면 목표가 산정 불가 → 생성 실패.
|
||||
@ -267,6 +298,11 @@ class BuildMixin:
|
||||
session_objs = []
|
||||
for iid in item_ids:
|
||||
tp = target_prices[iid]
|
||||
# 완료 상한가 = 목표가×(1+상한율/1000), 10원 반올림(앵커가와 동일한 정수 연산). 상한율 없으면 목표가로 폴백.
|
||||
ceiling_price = (
|
||||
int((tp * (1000 + effective_ceiling_rate) + 5000) // 10000) * 10
|
||||
if effective_ceiling_rate is not None else tp
|
||||
)
|
||||
for sid in supplier_ids:
|
||||
value, ap = anchors[(iid, sid)]
|
||||
session_objs.append(
|
||||
@ -281,6 +317,7 @@ class BuildMixin:
|
||||
target_price=tp,
|
||||
anchoring_price=ap, # 박제 — 이후 수정 금지(협상 판정·앵커링 학습 기준값)
|
||||
anchoring_value=value,
|
||||
done_ceiling_price=ceiling_price, # 박제 — 봇 종결·마감이 이 이하면 타결
|
||||
status=SessionStatus.CREATED.value,
|
||||
end_time=quotation.end_time,
|
||||
)
|
||||
|
||||
@ -76,7 +76,7 @@ class PricingMixin:
|
||||
|
||||
async def _load_target_inputs(
|
||||
self, item_ids: list[uuid.UUID], qt_setting_id, user_id
|
||||
) -> tuple[dict, float, float, set]:
|
||||
) -> tuple[dict, float, float, set, int | None]:
|
||||
"""목표가 계산에 필요한 값들을 한 번에 모아온다.
|
||||
|
||||
- prices: 상품마다 (인터넷최저가, 매입가, 판매가) — DB 조회
|
||||
@ -102,6 +102,7 @@ class PricingMixin:
|
||||
rates = rates if _err == ErrorType.SUCCESS else {}
|
||||
fee = self.INTERNET_AVERAGE_FEE # 인터넷가 차감 수수료율(상수)
|
||||
margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율
|
||||
ceiling_rate = rates.get("done_ceiling_rate") # 회사 완료 상한율(‰), 미조회면 None
|
||||
user_uuid = uuid.UUID(user_id) if isinstance(user_id, str) else user_id
|
||||
settings = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
@ -109,7 +110,7 @@ class PricingMixin:
|
||||
lambda s: self.quotation_crud.get_company_settings(s, user_uuid),
|
||||
)
|
||||
hidden = set(settings.get("hidden_fields") or [])
|
||||
return prices, fee, margin, hidden
|
||||
return prices, fee, margin, hidden, ceiling_rate
|
||||
|
||||
def _resolve_target_prices(
|
||||
self, *, qt_id, item_ids: list[uuid.UUID], prices: dict,
|
||||
@ -200,7 +201,7 @@ class PricingMixin:
|
||||
return res
|
||||
|
||||
# 산정 입력(재료)은 생성과 같은 로더를 공유 — 생성값과 표시값이 어긋나지 않는다.
|
||||
prices, fee, margin, hidden = await self._load_target_inputs(
|
||||
prices, fee, margin, hidden, _ceiling_rate = await self._load_target_inputs(
|
||||
[sess.item_id], quotation.qt_setting_id, quotation.user_id
|
||||
)
|
||||
internet, purchase, selling = (prices or {}).get(sess.item_id) or (None, None, None)
|
||||
|
||||
@ -66,6 +66,7 @@ class QuotationSettingService:
|
||||
user_id=uuid.UUID(user_id),
|
||||
target_margin_rate=req.target_margin_rate,
|
||||
card_count=req.card_count,
|
||||
done_ceiling_rate=req.done_ceiling_rate,
|
||||
)
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[quotation_settings.DBType()],
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""앵커링 v1.2 — 견적 생성 시 칸(회사×상품-협력사 공급유형×가격구간) anchoring_value 로 앵커가를 박제하는지 검증.
|
||||
|
||||
이식 명세: schedules/anchoring/docs/인수인계.md §1.
|
||||
- 앵커가 = 목표가 × (1000 − anchoring_value) // 1000 (정수 연산), anchoring_value 동시 박제
|
||||
- 앵커가 = 목표가 × (1000 − anchoring_value), 10원 반올림(calc_anchoring_price), anchoring_value 동시 박제
|
||||
- 조정 이력 없음 / 매핑 유형 미지정 / anchoring 스키마 미적용 → 정적 테이블 시작값(10‰) 폴백,
|
||||
견적 생성은 실패하지 않는다(규칙 6)
|
||||
- 재생성 라운드는 목표가만 상속하고 앵커는 생성 시점 anchoring_value 로 재계산(규칙 1 — 상속 폐지)
|
||||
@ -11,7 +11,7 @@ from datetime import datetime
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from common.anchoring import calc_price_range_index
|
||||
from common.anchoring import calc_anchoring_price, calc_price_range_index
|
||||
from common.enums import QuotationType
|
||||
from crud.quotation_crud import QuotationCRUD
|
||||
from router.v1.quotation.protocol import Req_CreateQuotation
|
||||
@ -32,7 +32,7 @@ async def test_create_without_anchoring_schema_falls_back_to_base_value(db_engin
|
||||
assert res.result.success is True
|
||||
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) # 92,200
|
||||
rows = await _session_anchor_rows(db_engine, res.qt_id)
|
||||
assert rows == {item: (tp, tp * (1000 - BASE_VALUE) // 1000, BASE_VALUE)}
|
||||
assert rows == {item: (tp, calc_anchoring_price(tp, BASE_VALUE), BASE_VALUE)}
|
||||
|
||||
|
||||
async def test_create_uses_latest_adjusted_value_per_cell(db_engine, company_id):
|
||||
@ -49,8 +49,8 @@ async def test_create_uses_latest_adjusted_value_per_cell(db_engine, company_id)
|
||||
|
||||
assert res.result.success is True
|
||||
rows = await _session_anchor_rows(db_engine, res.qt_id)
|
||||
assert rows[item_hit] == (tp_hit, tp_hit * 950 // 1000, 50)
|
||||
assert rows[item_miss] == (tp_miss, tp_miss * 990 // 1000, BASE_VALUE)
|
||||
assert rows[item_hit] == (tp_hit, calc_anchoring_price(tp_hit, 50), 50)
|
||||
assert rows[item_miss] == (tp_miss, calc_anchoring_price(tp_miss, BASE_VALUE), BASE_VALUE)
|
||||
|
||||
|
||||
async def test_supply_type_unset_uses_base_value(db_engine, company_id):
|
||||
@ -65,7 +65,7 @@ async def test_supply_type_unset_uses_base_value(db_engine, company_id):
|
||||
|
||||
assert res.result.success is True
|
||||
rows = await _session_anchor_rows(db_engine, res.qt_id)
|
||||
assert rows == {item: (tp, tp * 990 // 1000, BASE_VALUE)}
|
||||
assert rows == {item: (tp, calc_anchoring_price(tp, BASE_VALUE), BASE_VALUE)}
|
||||
|
||||
|
||||
async def test_regenerate_inherits_target_but_recomputes_anchor(db_engine, company_id):
|
||||
@ -79,14 +79,14 @@ async def test_regenerate_inherits_target_but_recomputes_anchor(db_engine, compa
|
||||
assert res1.result.success is True
|
||||
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE))
|
||||
rows1 = await _session_anchor_rows(db_engine, res1.qt_id)
|
||||
assert rows1 == {item: (tp, tp * 990 // 1000, BASE_VALUE)} # 1라운드는 시작값
|
||||
assert rows1 == {item: (tp, calc_anchoring_price(tp, BASE_VALUE), BASE_VALUE)} # 1라운드는 시작값
|
||||
|
||||
await _seed_adjustment(db_engine, company_id, supplier_type=1, price_range=calc_price_range_index(tp), value_after=50)
|
||||
res2 = await _service().regenerate_next_round(res1.qt_id, [supplier])
|
||||
|
||||
assert res2.result.success is True
|
||||
rows2 = await _session_anchor_rows(db_engine, res2.qt_id)
|
||||
assert rows2 == {item: (tp, tp * 950 // 1000, 50)} # 목표가 상속 + 앵커만 현재 anchoring_value
|
||||
assert rows2 == {item: (tp, calc_anchoring_price(tp, 50), 50)} # 목표가 상속 + 앵커만 현재 anchoring_value
|
||||
|
||||
|
||||
def test_price_range_index_golden_vectors():
|
||||
|
||||
211
negodata/backend/tests/test_quotation_regenerate.py
Normal file
211
negodata/backend/tests/test_quotation_regenerate.py
Normal file
@ -0,0 +1,211 @@
|
||||
"""견적 재생성 조정값 — 담당자가 다음 라운드에서만 바꾼 값(카드·목표가·마감기한·타결 상한율)이 반영되는지 검증.
|
||||
|
||||
기본 계약은 '미전송 = 원 견적/직전 라운드 승계'다(기존 동작). 보내면 그 값으로 라운드가 만들어진다.
|
||||
· card_ids — None=원본 카드 버전 재사용 / 리스트=그 카드들로 새 버전 / []=카드 없는 버전
|
||||
· target_price — 이번 라운드 전 상품의 목표가(세션 target_price + quotations.md_price)
|
||||
· end_time — 마감기한(견적·세션 공통). 미전송이면 원 견적과 같은 협상기간
|
||||
· done_ceiling_rate — 타결 상한율(‰) → 세션 done_ceiling_price 로 박제
|
||||
앵커링가는 어느 경우든 재계산이라 여기선 보지 않는다(test_quotation_anchoring 소관).
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from common.enums import QuotationStatus, QuotationType
|
||||
from crud.quotation_crud import QuotationCRUD
|
||||
from router.v1.quotation.protocol import Req_CreateQuotation
|
||||
from services.quotation import QuotationService
|
||||
|
||||
FUTURE = datetime(2999, 1, 1) # 마감시각 미래 — 생성 직후 크론에 안 잡히게
|
||||
NEXT_DUE = "2999-06-01T00:00:00Z" # 재생성 때 다시 잡는 마감기한(프론트가 보내는 형태 = UTC ISO)
|
||||
TARGET = 100_000 # 1라운드 목표가(= MD 제시가 그대로)
|
||||
|
||||
|
||||
async def test_regenerate_without_overrides_inherits_everything(db_engine, client, auth_headers):
|
||||
"""검증: 조정값 없이 공급사만 보내 재생성.
|
||||
기대결과: 목표가·카드 버전·타결 상한율이 원 견적 그대로 승계되고 차수만 +1."""
|
||||
ctx = await _closed_round1(db_engine, client, auth_headers, "regen_plain", ceiling_rate=50)
|
||||
|
||||
body = await _regenerate(client, ctx, {"supplier_ids": [str(ctx["supplier"])]})
|
||||
|
||||
assert body["result"]["success"] is True
|
||||
q = await _quotation(db_engine, body["qt_id"])
|
||||
assert (q["round"], q["md_price"], q["done_ceiling_rate"]) == (2, TARGET, 50)
|
||||
assert q["version_id"] == ctx["version_id"] # 새 버전 안 만듦 — 원본 카드 버전 재사용
|
||||
s = await _session(db_engine, body["qt_id"])
|
||||
assert s["target_price"] == TARGET
|
||||
assert s["done_ceiling_price"] == 105_000 # 목표가 +5%
|
||||
|
||||
|
||||
async def test_regenerate_applies_target_price_and_ceiling(db_engine, client, auth_headers):
|
||||
"""검증: 목표가 9만원 + 타결 상한율 100‰(=10%)로 재생성.
|
||||
기대결과: 세션 목표가·견적 md_price 가 새 값, 타결 상한가는 새 목표가 기준으로 재계산(99,000)."""
|
||||
ctx = await _closed_round1(db_engine, client, auth_headers, "regen_target", ceiling_rate=50)
|
||||
|
||||
body = await _regenerate(client, ctx, {
|
||||
"supplier_ids": [str(ctx["supplier"])],
|
||||
"target_price": 90_000,
|
||||
"done_ceiling_rate": 100,
|
||||
})
|
||||
|
||||
assert body["result"]["success"] is True
|
||||
q = await _quotation(db_engine, body["qt_id"])
|
||||
assert (q["md_price"], q["done_ceiling_rate"]) == (90_000, 100)
|
||||
s = await _session(db_engine, body["qt_id"])
|
||||
assert (s["target_price"], s["done_ceiling_price"]) == (90_000, 99_000)
|
||||
|
||||
|
||||
async def test_regenerate_applies_end_time(db_engine, client, auth_headers):
|
||||
"""검증: 마감기한(UTC ISO)을 직접 지정해 재생성(원 견적 협상기간 승계 대신).
|
||||
기대결과: 견적·세션 end_time 이 보낸 시각 그대로. 미지정 경로(승계)와 달리 생성시각+기간이 아니다."""
|
||||
ctx = await _closed_round1(db_engine, client, auth_headers, "regen_due", ceiling_rate=50)
|
||||
|
||||
body = await _regenerate(client, ctx, {"supplier_ids": [str(ctx["supplier"])], "end_time": NEXT_DUE})
|
||||
|
||||
assert body["result"]["success"] is True
|
||||
q = await _quotation(db_engine, body["qt_id"])
|
||||
s = await _session(db_engine, body["qt_id"])
|
||||
due = datetime(2999, 6, 1, tzinfo=timezone.utc)
|
||||
assert q["end_time"] == due
|
||||
assert s["end_time"] == due
|
||||
|
||||
|
||||
async def test_regenerate_replaces_cards_with_new_version(db_engine, client, auth_headers):
|
||||
"""검증: 직전 라운드와 다른 카드 1장으로 재생성.
|
||||
기대결과: 원본과 다른 새 버전이 생기고 그 버전엔 보낸 카드만 매핑된다(원본 버전은 그대로 남음)."""
|
||||
ctx = await _closed_round1(db_engine, client, auth_headers, "regen_cards", ceiling_rate=50)
|
||||
new_card = await _seed_nego_card(db_engine)
|
||||
|
||||
body = await _regenerate(client, ctx, {"supplier_ids": [str(ctx["supplier"])], "card_ids": [str(new_card)]})
|
||||
|
||||
assert body["result"]["success"] is True
|
||||
q = await _quotation(db_engine, body["qt_id"])
|
||||
assert q["version_id"] != ctx["version_id"]
|
||||
assert await _version_cards(db_engine, q["version_id"]) == {new_card}
|
||||
assert await _version_cards(db_engine, ctx["version_id"]) == {ctx["card_id"]} # 직전 라운드 카드 이력 보존
|
||||
|
||||
|
||||
async def test_regenerate_with_empty_cards_makes_cardless_version(db_engine, client, auth_headers):
|
||||
"""검증: 카드를 전부 해제(빈 리스트)한 채 재생성.
|
||||
기대결과: 원본 버전을 그대로 물려받지 않고, 카드가 하나도 안 걸린 새 버전으로 생성된다."""
|
||||
ctx = await _closed_round1(db_engine, client, auth_headers, "regen_nocard", ceiling_rate=50)
|
||||
|
||||
body = await _regenerate(client, ctx, {"supplier_ids": [str(ctx["supplier"])], "card_ids": []})
|
||||
|
||||
assert body["result"]["success"] is True
|
||||
q = await _quotation(db_engine, body["qt_id"])
|
||||
assert q["version_id"] != ctx["version_id"]
|
||||
assert await _version_cards(db_engine, q["version_id"]) == set()
|
||||
|
||||
|
||||
# ===== 헬퍼 =====
|
||||
def _service():
|
||||
return QuotationService(QuotationCRUD())
|
||||
|
||||
|
||||
async def _closed_round1(engine, client, auth_headers, login_id, *, ceiling_rate):
|
||||
"""재생성 대상(마감된 1라운드)을 만든다 — 카드 1장·공급사 1곳짜리 1:1 협상 견적.
|
||||
|
||||
생성은 서비스로(견적 생성 API 는 로그인 유저를 작성자로 박으므로 같은 유저로 맞춘다),
|
||||
재생성은 HTTP 로 태워 라우터→서비스 인자 전달까지 함께 본다.
|
||||
"""
|
||||
headers = await auth_headers(login_id)
|
||||
user_id = await _user_id(engine, login_id)
|
||||
item_id = await _seed_item(engine, await _company_of(engine, user_id))
|
||||
card_id = await _seed_nego_card(engine)
|
||||
supplier = uuid.uuid4()
|
||||
|
||||
req = Req_CreateQuotation(
|
||||
qt_setting_id=uuid.uuid4(), # FK 미설정 — 세팅 없으면 율 0(목표가는 md_price 로 확정)
|
||||
name="재생성원본",
|
||||
type=QuotationType.NEW_NEGO.value,
|
||||
end_time=FUTURE,
|
||||
md_price=TARGET,
|
||||
item_ids=[item_id],
|
||||
supplier_ids=[supplier],
|
||||
card_ids=[card_id],
|
||||
done_ceiling_rate=ceiling_rate,
|
||||
)
|
||||
res = await _service().create_quotation(str(user_id), req)
|
||||
assert res.result.success is True
|
||||
# 재생성은 마감 견적에서만 — 크론 마감을 기다리지 않고 상태만 CLOSED 로 돌린다.
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text("UPDATE quotations SET status = :st WHERE qt_id = :qt"),
|
||||
{"st": QuotationStatus.CLOSED.value, "qt": res.qt_id},
|
||||
)
|
||||
original = await _quotation(engine, str(res.qt_id))
|
||||
return {"qt_id": str(res.qt_id), "headers": headers, "supplier": supplier,
|
||||
"card_id": card_id, "version_id": original["version_id"]}
|
||||
|
||||
|
||||
async def _regenerate(client, ctx, payload):
|
||||
r = await client.post(f"/v1/quotation/regenerate/{ctx['qt_id']}", json=payload, headers=ctx["headers"])
|
||||
return r.json()
|
||||
|
||||
|
||||
async def _user_id(engine, login_id):
|
||||
async with engine.begin() as conn:
|
||||
return (await conn.execute(
|
||||
text("SELECT user_id FROM users WHERE id = :id"), {"id": login_id}
|
||||
)).scalar_one()
|
||||
|
||||
|
||||
async def _company_of(engine, user_id):
|
||||
async with engine.begin() as conn:
|
||||
return (await conn.execute(
|
||||
text("SELECT company_id FROM users WHERE user_id = :uid"), {"uid": user_id}
|
||||
)).scalar_one()
|
||||
|
||||
|
||||
async def _seed_item(engine, company_id):
|
||||
"""상품 1건 시드. NOT NULL 컬럼은 명시(ORM default 는 raw INSERT 에 안 먹음)."""
|
||||
item_id = uuid.uuid4()
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text("INSERT INTO items (item_id, company_id, user_id, name, category_type, internet_lowest_price_yn) "
|
||||
"VALUES (:item_id, :company_id, :user_id, '상품', 1, false)"),
|
||||
{"item_id": item_id, "company_id": company_id, "user_id": uuid.uuid4()},
|
||||
)
|
||||
return item_id
|
||||
|
||||
|
||||
async def _seed_nego_card(engine):
|
||||
card_id = uuid.uuid4()
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text("INSERT INTO nego_cards (nego_card_id, user_id, name, number, script, usage_type) "
|
||||
"VALUES (:cid, :uid, '카드', 'N1', '멘트', 1)"),
|
||||
{"cid": card_id, "uid": uuid.uuid4()},
|
||||
)
|
||||
return card_id
|
||||
|
||||
|
||||
async def _quotation(engine, qt_id):
|
||||
async with engine.begin() as conn:
|
||||
row = (await conn.execute(
|
||||
text("SELECT round, version_id, md_price, done_ceiling_rate, end_time "
|
||||
"FROM quotations WHERE qt_id = :qt"),
|
||||
{"qt": uuid.UUID(qt_id)},
|
||||
)).mappings().one()
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def _session(engine, qt_id):
|
||||
"""견적의 세션 1건(상품·공급사 1:1 시드라 단건)."""
|
||||
async with engine.begin() as conn:
|
||||
row = (await conn.execute(
|
||||
text("SELECT target_price, done_ceiling_price, end_time FROM sessions WHERE quotation_id = :qt"),
|
||||
{"qt": uuid.UUID(qt_id)},
|
||||
)).mappings().one()
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def _version_cards(engine, version_id):
|
||||
async with engine.begin() as conn:
|
||||
rows = (await conn.execute(
|
||||
text("SELECT nego_card_id FROM version_nego_cards WHERE version_id = :vid"),
|
||||
{"vid": version_id},
|
||||
)).scalars().all()
|
||||
return set(rows)
|
||||
@ -113,6 +113,7 @@ export * from './quotationData';
|
||||
export * from './quotationDataCloseReason';
|
||||
export * from './quotationDataCreatedAt';
|
||||
export * from './quotationDataCreatorName';
|
||||
export * from './quotationDataDoneCeilingRate';
|
||||
export * from './quotationDataEqualBidData';
|
||||
export * from './quotationDataEqualBidYn';
|
||||
export * from './quotationDataItemId';
|
||||
@ -176,6 +177,7 @@ export * from './reqCreateItemSellingPrice';
|
||||
export * from './reqCreateItemSpec';
|
||||
export * from './reqCreateItemVatYn';
|
||||
export * from './reqCreateQuotation';
|
||||
export * from './reqCreateQuotationDoneCeilingRate';
|
||||
export * from './reqCreateQuotationManagerContactNumber';
|
||||
export * from './reqCreateQuotationManagerEmail';
|
||||
export * from './reqCreateQuotationManagerName';
|
||||
@ -198,6 +200,10 @@ export * from './reqCreateSupplierManagerName';
|
||||
export * from './reqCreateSupplierTotalRevenue';
|
||||
export * from './reqLogin';
|
||||
export * from './reqRegenerateQuotation';
|
||||
export * from './reqRegenerateQuotationCardIds';
|
||||
export * from './reqRegenerateQuotationDoneCeilingRate';
|
||||
export * from './reqRegenerateQuotationEndTime';
|
||||
export * from './reqRegenerateQuotationTargetPrice';
|
||||
export * from './reqRejectRenegotiation';
|
||||
export * from './reqResetSupplierAccountPassword';
|
||||
export * from './reqResetSupplierAccountPasswordPassword';
|
||||
@ -248,6 +254,7 @@ export * from './reqUpdateMeName';
|
||||
export * from './reqUpdateMePassword';
|
||||
export * from './reqUpdateQuotationSetting';
|
||||
export * from './reqUpdateQuotationSettingCardCount';
|
||||
export * from './reqUpdateQuotationSettingDoneCeilingRate';
|
||||
export * from './reqUpdateQuotationSettingTargetMarginRate';
|
||||
export * from './reqUpdateSupplier';
|
||||
export * from './reqUpdateSupplierAccountStatus';
|
||||
|
||||
@ -19,6 +19,7 @@ import type { QuotationDataEqualBidData } from './quotationDataEqualBidData';
|
||||
import type { QuotationDataCloseReason } from './quotationDataCloseReason';
|
||||
import type { QuotationDataMidAction } from './quotationDataMidAction';
|
||||
import type { QuotationDataOverAction } from './quotationDataOverAction';
|
||||
import type { QuotationDataDoneCeilingRate } from './quotationDataDoneCeilingRate';
|
||||
import type { QuotationDataItemId } from './quotationDataItemId';
|
||||
import type { QuotationDataItemName } from './quotationDataItemName';
|
||||
import type { QuotationDataCreatorName } from './quotationDataCreatorName';
|
||||
@ -51,6 +52,7 @@ export interface QuotationData {
|
||||
close_reason?: QuotationDataCloseReason;
|
||||
mid_action?: QuotationDataMidAction;
|
||||
over_action?: QuotationDataOverAction;
|
||||
done_ceiling_rate?: QuotationDataDoneCeilingRate;
|
||||
participation_count?: number;
|
||||
item_id?: QuotationDataItemId;
|
||||
item_name?: QuotationDataItemName;
|
||||
|
||||
@ -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 QuotationDataDoneCeilingRate = number | null;
|
||||
@ -13,6 +13,7 @@ export interface QuotationSettingData {
|
||||
user_id?: QuotationSettingDataUserId;
|
||||
target_margin_rate: number;
|
||||
card_count: number;
|
||||
done_ceiling_rate: number;
|
||||
created_at?: QuotationSettingDataCreatedAt;
|
||||
updated_at?: QuotationSettingDataUpdatedAt;
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ import type { ReqCreateQuotationMemo } from './reqCreateQuotationMemo';
|
||||
import type { ReqCreateQuotationMdPrice } from './reqCreateQuotationMdPrice';
|
||||
import type { ReqCreateQuotationMidAction } from './reqCreateQuotationMidAction';
|
||||
import type { ReqCreateQuotationOverAction } from './reqCreateQuotationOverAction';
|
||||
import type { ReqCreateQuotationDoneCeilingRate } from './reqCreateQuotationDoneCeilingRate';
|
||||
|
||||
export interface ReqCreateQuotation {
|
||||
qt_setting_id: string;
|
||||
@ -33,4 +34,5 @@ export interface ReqCreateQuotation {
|
||||
card_ids?: string[];
|
||||
mid_action?: ReqCreateQuotationMidAction;
|
||||
over_action?: ReqCreateQuotationOverAction;
|
||||
done_ceiling_rate?: ReqCreateQuotationDoneCeilingRate;
|
||||
}
|
||||
|
||||
@ -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 ReqCreateQuotationDoneCeilingRate = number | null;
|
||||
@ -8,4 +8,5 @@
|
||||
export interface ReqCreateQuotationSetting {
|
||||
target_margin_rate: number;
|
||||
card_count?: number;
|
||||
done_ceiling_rate?: number;
|
||||
}
|
||||
|
||||
@ -4,7 +4,15 @@
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ReqRegenerateQuotationCardIds } from './reqRegenerateQuotationCardIds';
|
||||
import type { ReqRegenerateQuotationTargetPrice } from './reqRegenerateQuotationTargetPrice';
|
||||
import type { ReqRegenerateQuotationEndTime } from './reqRegenerateQuotationEndTime';
|
||||
import type { ReqRegenerateQuotationDoneCeilingRate } from './reqRegenerateQuotationDoneCeilingRate';
|
||||
|
||||
export interface ReqRegenerateQuotation {
|
||||
supplier_ids?: string[];
|
||||
card_ids?: ReqRegenerateQuotationCardIds;
|
||||
target_price?: ReqRegenerateQuotationTargetPrice;
|
||||
end_time?: ReqRegenerateQuotationEndTime;
|
||||
done_ceiling_rate?: ReqRegenerateQuotationDoneCeilingRate;
|
||||
}
|
||||
|
||||
@ -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 ReqRegenerateQuotationCardIds = string[] | 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 ReqRegenerateQuotationDoneCeilingRate = 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 ReqRegenerateQuotationEndTime = string | 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 ReqRegenerateQuotationTargetPrice = number | null;
|
||||
@ -6,8 +6,10 @@
|
||||
*/
|
||||
import type { ReqUpdateQuotationSettingTargetMarginRate } from './reqUpdateQuotationSettingTargetMarginRate';
|
||||
import type { ReqUpdateQuotationSettingCardCount } from './reqUpdateQuotationSettingCardCount';
|
||||
import type { ReqUpdateQuotationSettingDoneCeilingRate } from './reqUpdateQuotationSettingDoneCeilingRate';
|
||||
|
||||
export interface ReqUpdateQuotationSetting {
|
||||
target_margin_rate?: ReqUpdateQuotationSettingTargetMarginRate;
|
||||
card_count?: ReqUpdateQuotationSettingCardCount;
|
||||
done_ceiling_rate?: ReqUpdateQuotationSettingDoneCeilingRate;
|
||||
}
|
||||
|
||||
@ -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 ReqUpdateQuotationSettingDoneCeilingRate = number | null;
|
||||
@ -12,6 +12,7 @@ import { Typography, typographyVariants } from '@/components/ui/typography';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Combobox, type ComboOption } from '@/components/ui/combobox';
|
||||
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
|
||||
@ -68,6 +69,8 @@ export function QuotationCreateModal({
|
||||
const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 상품값으로 목표가 산정
|
||||
const [midAction, setMidAction] = useState<number>(DEFAULT_MID_ACTION); // 앵커~목표가 구간: 낙찰/개찰 (1:1 전용)
|
||||
const [overAction, setOverAction] = useState<number>(DEFAULT_OVER_ACTION); // 목표가 초과 구간: 낙찰/개찰 (1:1 전용)
|
||||
const [ceilingPct, setCeilingPct] = useState(''); // 협상 완료 상한율(%) 이 견적 override. 비우면 세팅 기본값
|
||||
const [ceilingTouched, setCeilingTouched] = useState(false); // 상한율을 직접 건드렸는지 — 안 건드렸으면 세팅값 표시
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [mdTouched, setMdTouched] = useState(false); // 담당자가 제시가를 직접 건드렸는지 — 안 건드렸으면 자동 산출값을 채운다
|
||||
// 매입가 네고율 차감 — 이 견적에서만 조정. 안 건드리면 세팅 기본값을 따른다(negoTouched=false). 네고율 값 자체는 세팅값 고정.
|
||||
@ -164,6 +167,8 @@ export function QuotationCreateModal({
|
||||
targetLimitExceeded,
|
||||
estimatedTargetPrice,
|
||||
submitMdPrice,
|
||||
settingCeilingRate,
|
||||
doneCeilingPrice,
|
||||
} = useTargetPrice({
|
||||
quotationSettings,
|
||||
settingId,
|
||||
@ -178,6 +183,7 @@ export function QuotationCreateModal({
|
||||
negoTouched,
|
||||
applyNego,
|
||||
selectedCandidateKey,
|
||||
doneCeilingRateOverride: ceilingTouched && ceilingPct !== '' ? Math.round(Number(ceilingPct) * 10) : null,
|
||||
});
|
||||
|
||||
// ── 카드 선택 게이팅 — 부적합 카드 disabled·자동 선택/해제(useCardGating 이 소유) ──
|
||||
@ -268,6 +274,8 @@ export function QuotationCreateModal({
|
||||
mdPrice: submitMdPrice,
|
||||
midAction: oneToOne ? midAction : undefined,
|
||||
overAction: oneToOne ? overAction : undefined,
|
||||
// 완료 상한율 override — 직접 건드렸을 때만 전송(‰). 비우면 서버가 세팅 기본값 사용.
|
||||
doneCeilingRate: ceilingTouched && ceilingPct !== '' ? Math.round(Number(ceilingPct) * 10) : undefined,
|
||||
});
|
||||
if (ok) onClose();
|
||||
} finally {
|
||||
@ -428,7 +436,7 @@ export function QuotationCreateModal({
|
||||
{(value) => {
|
||||
const qs = quotationSettings.find((s) => s.qt_setting_id === value);
|
||||
return qs
|
||||
? `[${label('target_margin')}: ${qs.target_margin}] 카드 ${qs.card_use_count}`
|
||||
? `[${label('target_margin')}: ${qs.target_margin}] 카드 ${qs.card_use_count} · 타결상한 +${qs.done_ceiling_rate / 10}%`
|
||||
: '';
|
||||
}}
|
||||
</SelectValue>
|
||||
@ -436,46 +444,27 @@ export function QuotationCreateModal({
|
||||
<SelectContent>
|
||||
{quotationSettings.map((qs) => (
|
||||
<SelectItem key={qs.qt_setting_id} value={qs.qt_setting_id}>
|
||||
[{label('target_margin')}: {qs.target_margin}] 카드 {qs.card_use_count}
|
||||
[{label('target_margin')}: {qs.target_margin}] 카드 {qs.card_use_count} · 타결상한 +{qs.done_ceiling_rate / 10}%
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{oneToOne ? (
|
||||
<>
|
||||
{/* 낙찰 기준(1:1 전용) — 스펙트럼 = 선택. 낙찰선을 앵커/목표가 중 택1, 목표가 초과는 항상 개찰. */}
|
||||
<AwardLinePicker
|
||||
mid={midAction}
|
||||
over={overAction}
|
||||
// 단조성: 초과=낙찰이면 앵커~목표가도 낙찰, 앵커~목표가=개찰이면 초과도 개찰(혼합 조합 방지)
|
||||
onMid={(v) => {
|
||||
setMidAction(v);
|
||||
if (v === PriceGateAction.OPEN) setOverAction(PriceGateAction.OPEN);
|
||||
}}
|
||||
onOver={(v) => {
|
||||
setOverAction(v);
|
||||
if (v === PriceGateAction.AWARD) setMidAction(PriceGateAction.AWARD);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
/* 경매(1:N) — 낙찰 기준·협상카드 없음. 최저가 자동 낙찰 안내만. */
|
||||
<div className="rounded border border-border bg-muted/20 p-3 flex items-start gap-2.5">
|
||||
<Gavel size={18} className="text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<Typography as="span" variant="small" className="font-semibold block">최저가 자동 낙찰</Typography>
|
||||
<Typography as="span" variant="small" className="text-muted-foreground text-[10px] leading-snug">
|
||||
1:N 견적은 가장 낮은 투찰가가 자동 낙찰됩니다. 낙찰 기준·협상카드 설정이 없습니다.
|
||||
</Typography>
|
||||
{/* ── 타결 기준 (협상) — 봇이 어느 가격까지 합의하면 타결로 볼지 ── */}
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border bg-muted/30">
|
||||
<span className="grid place-items-center h-5 w-5 rounded-md bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400 shrink-0"><CheckCheck size={12} /></span>
|
||||
<div className="min-w-0">
|
||||
<Typography as="span" variant="small" className="font-bold block leading-tight">타결 기준 <span className="text-muted-foreground font-normal text-[10px]">· 협상</span></Typography>
|
||||
<Typography as="span" variant="small" className="text-muted-foreground text-[10px] block leading-tight">봇이 어느 가격까지 합의하면 타결로 볼지 정합니다</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3 space-y-4">
|
||||
|
||||
{/* 목표가 산정 후보 — 후보 택1로 목표가 결정(기본=최저). 매입가 후보 행의 체크박스로 네고율 차감 여부 조정. 숨김필드는 제외. */}
|
||||
{productId && targetBreakdown.length > 0 && (
|
||||
<div className="rounded border border-border bg-muted/20 p-3 space-y-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography as="span" variant="label">
|
||||
목표가 산정 후보 <span className="text-muted-foreground font-normal">({isReType ? '재' : '신규'})</span>
|
||||
@ -579,6 +568,91 @@ export function QuotationCreateModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 타결 상한 — 목표가 초과 허용폭. 봇이 이 이하로 합의하면 타결, 초과하면 결렬. 비우면 세팅 기본율. */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography as="label" variant="label">타결 상한가</Typography>
|
||||
{/* OFF=세팅 기본율 그대로 · ON=이 견적만 직접 지정 */}
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">이 견적만 조정</Typography>
|
||||
<Switch
|
||||
checked={ceilingTouched}
|
||||
onCheckedChange={(on) => {
|
||||
setCeilingTouched(on);
|
||||
if (on && ceilingPct === '') setCeilingPct(String(settingCeilingRate / 10)); // 켤 때 세팅값에서 출발
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{ceilingTouched ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Typography as="span" variant="small" className="text-[11px] text-muted-foreground">목표가 +</Typography>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.5"
|
||||
min={0}
|
||||
className="h-8 w-20 text-xs text-right"
|
||||
value={ceilingPct}
|
||||
onChange={(e) => setCeilingPct(e.target.value)}
|
||||
/>
|
||||
<Typography as="span" variant="small" className="text-[11px] text-muted-foreground">%</Typography>
|
||||
</div>
|
||||
) : (
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">
|
||||
세팅 기본 <span className="font-semibold text-foreground">목표가 +{settingCeilingRate / 10}%</span> 적용
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
|
||||
{doneCeilingPrice != null ? (
|
||||
<>최종 합의가가 <span className="font-bold text-foreground">₩{doneCeilingPrice.toLocaleString()}</span> 이하면 타결, 초과하면 결렬.</>
|
||||
) : '목표가가 정해지면 타결 상한가가 자동 계산됩니다.'}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 낙찰 기준 (마감) — 타결된 투찰가로 누구를 낙찰시킬지 ── */}
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border bg-muted/30">
|
||||
<span className="grid place-items-center h-5 w-5 rounded-md bg-primary/10 text-primary shrink-0"><Gavel size={12} /></span>
|
||||
<div className="min-w-0">
|
||||
<Typography as="span" variant="small" className="font-bold block leading-tight">낙찰 기준 <span className="text-muted-foreground font-normal text-[10px]">· 마감</span></Typography>
|
||||
<Typography as="span" variant="small" className="text-muted-foreground text-[10px] block leading-tight">타결된 투찰가로 마감 때 누구를 낙찰시킬지 정합니다</Typography>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{oneToOne ? (
|
||||
/* 스펙트럼 = 선택. 낙찰선을 앵커/목표가 중 택1, 목표가 초과는 항상 개찰. */
|
||||
<AwardLinePicker
|
||||
mid={midAction}
|
||||
over={overAction}
|
||||
// 단조성: 초과=낙찰이면 앵커~목표가도 낙찰, 앵커~목표가=개찰이면 초과도 개찰(혼합 조합 방지)
|
||||
onMid={(v) => {
|
||||
setMidAction(v);
|
||||
if (v === PriceGateAction.OPEN) setOverAction(PriceGateAction.OPEN);
|
||||
}}
|
||||
onOver={(v) => {
|
||||
setOverAction(v);
|
||||
if (v === PriceGateAction.AWARD) setMidAction(PriceGateAction.AWARD);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
/* 경매(1:N) — 최저가 자동 낙찰. */
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Gavel size={16} className="text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<Typography as="span" variant="small" className="font-semibold block">최저가 자동 낙찰</Typography>
|
||||
<Typography as="span" variant="small" className="text-muted-foreground text-[10px] leading-snug">
|
||||
1:N 견적은 가장 낮은 투찰가가 자동 낙찰됩니다. 별도 낙찰 기준 설정이 없습니다.
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">협력사 안내 메모 (선택)</Typography>
|
||||
<textarea
|
||||
|
||||
@ -1,42 +1,163 @@
|
||||
import { useState } from 'react';
|
||||
import { X, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { X, RefreshCw, Loader2, CheckCheck, Lightbulb } from 'lucide-react';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { type Partner, sessionStatusLabel } from '../../types';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useListCards } from '@/api/generated/card/card';
|
||||
import { mapCardData } from '@/features/cards/types';
|
||||
import type { QuotationData } from '@/api/generated/model/quotationData';
|
||||
import { CloseReason } from '@/api/generated/model';
|
||||
import { useCardGating } from '../../hooks/useCardGating';
|
||||
import type { RegenerateInput } from '../../hooks/useQuotations';
|
||||
import { SelectedCardTable } from '../QuotationFormParts';
|
||||
import { kstLocalInputAfter, isFutureLocalInput } from '../quotationForm.utils';
|
||||
import { type Partner, type SessionView, type NegotiationCard, sessionStatusLabel } from '../../types';
|
||||
import { ResultSummaryBand } from './ResultSummaryBand';
|
||||
|
||||
// 마감 사유별로 이번 라운드에서 손볼 레버를 짚어준다(안내만 — 선택값은 바꾸지 않는다).
|
||||
const REGEN_HINT: Record<CloseReason, string> = {
|
||||
[CloseReason.AWARDED]: '낙찰로 마감된 건입니다 — 같은 조건으로 한 라운드 더 열면 직전 낙찰과 중복될 수 있습니다.',
|
||||
[CloseReason.OPEN_PRICE]: '목표가를 넘겨 개찰됐습니다 — 목표가·타결 상한을 다시 잡거나, 가격 근거 카드를 교체해 보세요.',
|
||||
[CloseReason.OPEN_EQUAL]: '동가로 개찰됐습니다 — 동가 업체만 다시 부르고, 가격 외 조건(납기·수량) 카드를 넣어 보세요.',
|
||||
[CloseReason.OPEN_NOSHOW]: '전원 미응찰로 개찰됐습니다 — 마감기한을 늘리거나 부를 공급사를 바꿔 보세요.',
|
||||
[CloseReason.OPEN_REJECT]: '협상 거부로 개찰됐습니다 — 거부 사유를 확인하고 카드를 교체해 보세요.',
|
||||
};
|
||||
|
||||
type RegenerateModalProps = {
|
||||
open: boolean;
|
||||
/** 직전 라운드(원 견적) — 결과 요약 밴드·협상기간·상한율 기본값의 근거. */
|
||||
quotation: QuotationData;
|
||||
/** 직전 라운드 세션 — 공급사별 투찰가·상태와 승계 목표가를 읽는다. */
|
||||
sessionViews: SessionView[];
|
||||
/** 현재 견적에 연결된 공급사만. */
|
||||
partners: Partner[];
|
||||
/** 공급사별 협상 단계(세션 상태 코드) — 행에 함께 표시. */
|
||||
sessionStatusBySupplier?: Record<string, number>;
|
||||
/** 회사 협상카드 카탈로그(재선택 후보). */
|
||||
cards: NegotiationCard[];
|
||||
/** 직전 라운드에서 실제 쓴 카드 id — 기본 선택 + '직전 사용' 배지. */
|
||||
previousCardIds: string[];
|
||||
/** 적용 중인 견적 세팅의 타결 상한율(‰) — 견적 override 가 없을 때의 기본값. */
|
||||
settingCeilingRate: number;
|
||||
/** 카드 게이팅용 상품 정보(시장가 인용 카드 판정). */
|
||||
productId: string;
|
||||
internetLowest: number | null;
|
||||
/** 기본 선택 = 원 라운드의 공급사들. */
|
||||
defaultSupplierIds: string[];
|
||||
/** 확정 → 재생성 호출. 성공(true) 시 모달 닫힘. */
|
||||
onConfirm: (supplierIds: string[]) => Promise<boolean> | boolean;
|
||||
onConfirm: (input: RegenerateInput) => Promise<boolean> | boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
// 마감된 견적의 '다음 라운드'를 만들 때 부를 공급사를 고르는 모달.
|
||||
// 상품·견적번호·협상기간·카드는 원 견적에서 이어받으므로 여기선 공급사만 선택한다.
|
||||
export function RegenerateModal({ open, partners, sessionStatusBySupplier, defaultSupplierIds, onConfirm, onClose }: RegenerateModalProps) {
|
||||
// 마감된 견적의 '다음 라운드'를 만드는 모달.
|
||||
// 상품·견적번호·담당자는 원 견적에서 잠긴 채 이어받고, 직전 라운드가 깨진 원인에 해당하는
|
||||
// 레버(공급사·마감기한·목표가·타결 상한·협상카드)만 다시 잡게 한다.
|
||||
export function RegenerateModal({
|
||||
open,
|
||||
quotation,
|
||||
sessionViews,
|
||||
partners,
|
||||
cards,
|
||||
previousCardIds,
|
||||
settingCeilingRate,
|
||||
productId,
|
||||
internetLowest,
|
||||
defaultSupplierIds,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: RegenerateModalProps) {
|
||||
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
|
||||
const inheritedTarget = sessionViews.find((s) => s.target_price > 0)?.target_price ?? null;
|
||||
const inheritedCeilingRate = quotation.done_ceiling_rate ?? settingCeilingRate;
|
||||
|
||||
const [selected, setSelected] = useState<string[]>(defaultSupplierIds);
|
||||
const [dueDate, setDueDate] = useState(() => kstLocalInputAfter(inheritedDurationMs(quotation)));
|
||||
const [targetPrice, setTargetPrice] = useState(inheritedTarget != null ? String(inheritedTarget) : '');
|
||||
const [ceilingTouched, setCeilingTouched] = useState(false); // OFF = 원 견적 상한율 그대로
|
||||
const [ceilingPct, setCeilingPct] = useState('');
|
||||
const [selectedCardIds, setSelectedCardIds] = useState<string[]>(previousCardIds);
|
||||
const [cardDetails, setCardDetails] = useState<Map<string, { code: string; title: string; isWildcard: boolean }>>(
|
||||
() => new Map(cards.filter((c) => previousCardIds.includes(c.id)).map((c) => [c.id, { code: c.code, title: c.title, isWildcard: c.isWildcard }])),
|
||||
);
|
||||
const [cardQ, setCardQ] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const cardSearch = useListCards({ search: cardQ || undefined, size: 30 });
|
||||
const cardRows = cardQ ? (cardSearch.data?.cards ?? []).map(mapCardData) : cards;
|
||||
|
||||
// 공급사 1곳=재협상(1:1), 여러 곳=재견적(1:N) — 백엔드 타입 자동결정과 동일하게 미리 안내.
|
||||
// 협상카드는 1:1 에서만 발동하므로 카드 선택도 이 값으로 가른다.
|
||||
const nextIs1v1 = selected.length <= 1;
|
||||
const targetNum = Number(targetPrice);
|
||||
const targetValid = Number.isFinite(targetNum) && targetNum > 0;
|
||||
const effectiveCeilingRate = ceilingTouched && ceilingPct !== '' ? Math.round(Number(ceilingPct) * 10) : inheritedCeilingRate;
|
||||
// 타결 상한가 = 목표가×(1+율/1000), 10원 반올림(백엔드 박제식과 동일).
|
||||
const doneCeilingPrice = targetValid ? Math.round((targetNum * (1000 + effectiveCeilingRate)) / 1000 / 10) * 10 : null;
|
||||
|
||||
const { blockReason, cardOptions, selectedCardRows } = useCardGating({
|
||||
cardRows,
|
||||
productId,
|
||||
internetLowest,
|
||||
estimatedTargetPrice: targetValid ? targetNum : null,
|
||||
open,
|
||||
selectedCardIds,
|
||||
cardDetails,
|
||||
setSelectedCardIds,
|
||||
setCardDetails,
|
||||
previousCardIds,
|
||||
});
|
||||
if (!open) return null;
|
||||
|
||||
const sessionBySupplier = new Map(sessionViews.map((s) => [s.supplier_id, s]));
|
||||
const previousSet = new Set(previousCardIds);
|
||||
const keptCardCount = selectedCardIds.filter((id) => previousSet.has(id)).length;
|
||||
const cardsChanged =
|
||||
nextIs1v1 && (selectedCardIds.length !== previousSet.size || selectedCardIds.some((id) => !previousSet.has(id)));
|
||||
const targetChanged = targetValid && inheritedTarget != null && targetNum !== inheritedTarget;
|
||||
const targetDeltaPct = targetChanged && inheritedTarget ? ((targetNum - inheritedTarget) / inheritedTarget) * 100 : null;
|
||||
const hint = quotation.close_reason != null ? REGEN_HINT[quotation.close_reason as CloseReason] : null;
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setSelected((prev) => (prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id]));
|
||||
|
||||
// 공급사 1곳=재협상(1:1), 여러 곳=재견적(1:N) — 백엔드 타입 자동결정과 동일하게 미리 안내.
|
||||
const nextTypeLabel = selected.length <= 1 ? '재협상 (1:1)' : '재견적 (1:N)';
|
||||
const toggleCard = (id: string) => {
|
||||
const row = cardRows.find((c) => c.id === id);
|
||||
if (row) setCardDetails((m) => new Map(m).set(id, { code: row.code, title: row.title, isWildcard: row.isWildcard }));
|
||||
setSelectedCardIds((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const selectAllCards = () => {
|
||||
const rows = cardRows.filter((c) => !blockReason(c));
|
||||
setCardDetails((m) => {
|
||||
const next = new Map(m);
|
||||
rows.forEach((r) => next.set(r.id, { code: r.code, title: r.title, isWildcard: r.isWildcard }));
|
||||
return next;
|
||||
});
|
||||
setSelectedCardIds((prev) => [...new Set([...prev, ...rows.map((r) => r.id)])]);
|
||||
};
|
||||
|
||||
const handle = async () => {
|
||||
if (submitting || selected.length === 0) return;
|
||||
if (!isFutureLocalInput(dueDate)) {
|
||||
showToast('마감기한은 현재 시각보다 나중으로 설정해 주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
if (!targetValid) {
|
||||
showToast('목표가를 0보다 큰 값으로 입력해 주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const ok = await onConfirm(selected);
|
||||
const ok = await onConfirm({
|
||||
supplierIds: selected,
|
||||
dueDate,
|
||||
// 승계와 같은 값은 안 보낸다 — 서버가 직전 라운드 값을 그대로 이어쓰게 둔다.
|
||||
targetPrice: targetChanged ? targetNum : null,
|
||||
cardIds: cardsChanged ? selectedCardIds : null,
|
||||
doneCeilingRate: ceilingTouched && ceilingPct !== '' ? Math.round(Number(ceilingPct) * 10) : null,
|
||||
});
|
||||
if (ok) onClose();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@ -45,57 +166,213 @@ export function RegenerateModal({ open, partners, sessionStatusBySupplier, defau
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[55] flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
|
||||
<div className="w-full max-w-xl bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono">
|
||||
<div className="w-full max-w-3xl bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshCw className="text-foreground" size={16} />
|
||||
<Typography variant="small" className="font-bold">다음 견적 재생성</Typography>
|
||||
<Typography variant="small" className="font-bold">
|
||||
다음 견적 재생성 <span className="text-muted-foreground font-normal">· {quotation.round ?? 1}차 → {(quotation.round ?? 1) + 1}차</span>
|
||||
</Typography>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="my-4 space-y-3 text-xs">
|
||||
<Typography as="span" variant="small" className="text-muted-foreground block leading-relaxed">
|
||||
상품 · 견적번호 · 협상기간 · 카드는 이 견적에서 이어받습니다. 다음 견적에 부를 공급사만 고르세요.
|
||||
</Typography>
|
||||
<div className="border border-border rounded overflow-hidden max-h-56 overflow-y-auto divide-y divide-border bg-background">
|
||||
{partners.map((part) => {
|
||||
const isChecked = selected.includes(part.id ?? '');
|
||||
return (
|
||||
<label
|
||||
key={part.id}
|
||||
className="flex items-center justify-between gap-2.5 p-3 hover:bg-muted/30 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => toggle(part.id ?? '')}
|
||||
className="accent-primary h-4 w-4 shrink-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="text-muted-foreground">
|
||||
이메일: {part.managerEmail}
|
||||
</Typography>
|
||||
<div className="my-4 space-y-4 text-xs">
|
||||
{/* ── 직전 라운드 결과 — 무엇을 바꿔야 할지의 근거 ── */}
|
||||
<Section title="직전 라운드 결과" sub={`${quotation.round ?? 1}차`}>
|
||||
<ResultSummaryBand quotation={quotation} sessionViews={sessionViews} />
|
||||
{hint && (
|
||||
<div className="mt-2 flex items-start gap-2 rounded border border-amber-200 bg-amber-50/60 dark:border-amber-900/50 dark:bg-amber-950/20 px-2.5 py-2">
|
||||
<Lightbulb size={13} className="mt-0.5 shrink-0 text-amber-600" />
|
||||
<Typography as="span" variant="small" className="text-[11px] leading-snug">{hint}</Typography>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ── 공급사 ── */}
|
||||
<Section title="다음 라운드에 부를 공급사" sub={`${selected.length}곳 · ${nextIs1v1 ? '재협상 (1:1)' : '재견적 (1:N)'}`}>
|
||||
<div className="border border-border rounded overflow-hidden max-h-56 overflow-y-auto divide-y divide-border bg-background">
|
||||
{partners.map((part) => {
|
||||
const sv = sessionBySupplier.get(part.id ?? '');
|
||||
return (
|
||||
<label
|
||||
key={part.id}
|
||||
className="flex items-center justify-between gap-2.5 p-3 hover:bg-muted/30 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(part.id ?? '')}
|
||||
onChange={() => toggle(part.id ?? '')}
|
||||
className="accent-primary h-4 w-4 shrink-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="text-muted-foreground">
|
||||
{sv?.reject_reason ? `거부 사유: ${sv.reject_reason}` : `이메일: ${part.managerEmail}`}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{sessionStatusBySupplier?.[part.id ?? ''] != null && (
|
||||
<span className="shrink-0 text-[9px] font-mono px-1.5 py-0.5 rounded-full border border-border bg-muted text-muted-foreground">
|
||||
{sessionStatusLabel(sessionStatusBySupplier[part.id ?? ''])}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Typography as="span" variant="small" className="text-[10px] tabular-nums text-muted-foreground">
|
||||
{sv?.bid_price != null ? `직전 투찰 ₩${sv.bid_price.toLocaleString()}` : '미응찰'}
|
||||
</Typography>
|
||||
{sv && (
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded-full border border-border bg-muted text-muted-foreground">
|
||||
{sessionStatusLabel(sv.status)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── 이번 라운드 조건 ── */}
|
||||
<Section title="이번 라운드 조건" sub="비워두면 직전 라운드 값 그대로">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">마감기한</Typography>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
className="text-xs"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
/>
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">
|
||||
기본값 = 원 견적과 같은 협상기간.
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">목표가</Typography>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="text-xs"
|
||||
value={targetPrice}
|
||||
onChange={(e) => setTargetPrice(e.target.value)}
|
||||
placeholder="직전 라운드 목표가"
|
||||
/>
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">
|
||||
{targetDeltaPct != null
|
||||
? `직전 목표가 ₩${inheritedTarget?.toLocaleString()} 대비 ${targetDeltaPct > 0 ? '+' : '−'}${Math.abs(targetDeltaPct).toFixed(1)}%`
|
||||
: '직전 라운드 목표가를 그대로 이어받습니다.'}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 타결 상한 — 목표가 초과 허용폭. 봇이 이 이하로 합의하면 타결. */}
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography as="label" variant="label">타결 상한가</Typography>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">이 라운드만 조정</Typography>
|
||||
<Switch
|
||||
checked={ceilingTouched}
|
||||
onCheckedChange={(on) => {
|
||||
setCeilingTouched(on);
|
||||
if (on && ceilingPct === '') setCeilingPct(String(inheritedCeilingRate / 10)); // 켤 때 원 견적값에서 출발
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>선택: <b className="text-foreground">{selected.length}</b>곳</span>
|
||||
<span>유형: <b className="text-foreground">{nextTypeLabel}</b></span>
|
||||
</div>
|
||||
{ceilingTouched ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Typography as="span" variant="small" className="text-[11px] text-muted-foreground">목표가 +</Typography>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.5"
|
||||
min={0}
|
||||
className="h-8 w-20 text-xs text-right"
|
||||
value={ceilingPct}
|
||||
onChange={(e) => setCeilingPct(e.target.value)}
|
||||
/>
|
||||
<Typography as="span" variant="small" className="text-[11px] text-muted-foreground">%</Typography>
|
||||
</div>
|
||||
) : (
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">
|
||||
원 견적 설정 <span className="font-semibold text-foreground">목표가 +{inheritedCeilingRate / 10}%</span> 유지
|
||||
</Typography>
|
||||
)}
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
|
||||
{doneCeilingPrice != null
|
||||
? <>최종 합의가가 <span className="font-bold text-foreground">₩{doneCeilingPrice.toLocaleString()}</span> 이하면 타결, 초과하면 결렬.</>
|
||||
: '목표가가 정해지면 타결 상한가가 자동 계산됩니다.'}
|
||||
</Typography>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── 협상카드 재선택 (1:1 전용) ── */}
|
||||
<Section
|
||||
title="협상카드"
|
||||
sub={nextIs1v1 ? `${selectedCardIds.length}장 · 직전 유지 ${keptCardCount}/${previousSet.size}` : '1:N 재견적은 카드 미발동'}
|
||||
>
|
||||
{nextIs1v1 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
|
||||
직전 라운드 카드가 기본 선택돼 있습니다 — 같은 멘트를 다시 던지지 않으려면 교체하세요.
|
||||
</Typography>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button type="button" variant="outline" size="sm" className="h-7 px-2.5 text-[11px] gap-1" onClick={selectAllCards}>
|
||||
<CheckCheck size={13} />
|
||||
현재 목록 전체선택
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-[11px] gap-1 text-muted-foreground"
|
||||
onClick={() => setSelectedCardIds([])}
|
||||
disabled={selectedCardIds.length === 0}
|
||||
>
|
||||
<X size={13} />
|
||||
전체해제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Combobox
|
||||
variant="inline"
|
||||
multiple
|
||||
values={selectedCardIds}
|
||||
options={cardOptions}
|
||||
loading={cardSearch.isLoading}
|
||||
onQueryChange={setCardQ}
|
||||
onToggle={(opt) => toggleCard(opt.id)}
|
||||
searchPlaceholder="카드명·번호·스크립트 검색..."
|
||||
emptyText="협상카드가 없습니다"
|
||||
maxListHeight="max-h-60"
|
||||
/>
|
||||
<SelectedCardTable rows={selectedCardRows} onRemove={toggleCard} />
|
||||
</div>
|
||||
) : (
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">
|
||||
공급사를 2곳 이상 고르면 최저가 자동 낙찰(1:N)이라 협상카드가 쓰이지 않습니다. 카드는 원 견적 그대로 둡니다.
|
||||
</Typography>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ── 생성 직전 변경 요약 ── */}
|
||||
<div className="rounded border border-border bg-muted/30 px-3 py-2">
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground block mb-1">
|
||||
{quotation.round ?? 1}차 대비 변경
|
||||
</Typography>
|
||||
<Typography as="p" variant="small" className="text-[11px] leading-snug">
|
||||
{[
|
||||
`공급사 ${defaultSupplierIds.length}곳 → ${selected.length}곳`,
|
||||
targetChanged ? `목표가 ₩${inheritedTarget?.toLocaleString()} → ₩${targetNum.toLocaleString()}` : '목표가 유지',
|
||||
cardsChanged ? `카드 ${previousSet.size}장 → ${selectedCardIds.length}장` : '카드 유지',
|
||||
ceilingTouched && ceilingPct !== '' ? `타결 상한 +${ceilingPct}%` : '타결 상한 유지',
|
||||
].join(' · ')}
|
||||
</Typography>
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground mt-1 leading-snug">
|
||||
상품 · 견적번호 · 담당자는 원 견적에서 그대로 이어받습니다. 생성해도 초청 메일은 자동 발송되지 않습니다.
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -117,3 +394,24 @@ export function RegenerateModal({ open, partners, sessionStatusBySupplier, defau
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 원 견적의 협상기간(ms). 백엔드 승계식과 같은 1시간 하한을 적용한다. */
|
||||
function inheritedDurationMs(quotation: QuotationData): number {
|
||||
const start = new Date(quotation.start_time ?? '').getTime();
|
||||
const end = new Date(quotation.end_time ?? '').getTime();
|
||||
const span = Number.isFinite(start) && Number.isFinite(end) ? end - start : 0;
|
||||
return Math.max(span, 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/** 모달 안의 한 섹션 — 제목줄 + 본문. */
|
||||
function Section({ title, sub, children }: { title: string; sub?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<Typography as="span" variant="label">{title}</Typography>
|
||||
{sub && <Typography as="span" variant="small" className="text-[10px] text-muted-foreground">{sub}</Typography>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -23,11 +23,13 @@ import {
|
||||
mapServerSessionView,
|
||||
mapServerCardView,
|
||||
chainRoundState,
|
||||
type NegotiationCard,
|
||||
} from '../../types';
|
||||
import { QuotationStatus } from '@/api/generated/model';
|
||||
import { DrawerHeaderCards } from './DrawerHeaderCards';
|
||||
import { RoundTimeline } from './RoundTimeline';
|
||||
import { RegenerateModal } from './RegenerateModal';
|
||||
import type { RegenerateInput } from '../../hooks/useQuotations';
|
||||
import { SessionsStatusTab } from './SessionsStatusTab';
|
||||
import { TargetPriceModal } from './TargetPriceModal';
|
||||
import { QuotationCardsTab } from './QuotationCardsTab';
|
||||
@ -42,8 +44,10 @@ type QuotationDetailSheetProps = {
|
||||
onAward: (qtId: string, winnerSupplierId: string, winnerName: string) => Promise<boolean>;
|
||||
/** 라운드 타임라인에서 다른 차수로 전환(같은 견적번호의 다른 견적 상세 열기). */
|
||||
onSwitchRound: (qtId: string) => void;
|
||||
/** 마감된 견적의 다음 라운드를 수동 생성(공급사 선택). 성공 시 새 qt_id 반환. */
|
||||
onRegenerate: (qtId: string, supplierIds: string[]) => Promise<string | null>;
|
||||
/** 마감된 견적의 다음 라운드를 수동 생성(공급사·기한·목표가·카드 재선택). 성공 시 새 qt_id 반환. */
|
||||
onRegenerate: (qtId: string, input: RegenerateInput) => Promise<string | null>;
|
||||
/** 재생성 모달의 협상카드 재선택 후보(회사 카드 카탈로그). */
|
||||
cards: NegotiationCard[];
|
||||
/** 협상 초청 메일 — 견적 단위(미발송 세션 전체) 발송. */
|
||||
onNotify: (qtId: string) => Promise<void>;
|
||||
/** 협상 초청 메일 — 세션(공급사) 단위 재발송. */
|
||||
@ -55,6 +59,7 @@ type QuotationDetailSheetProps = {
|
||||
|
||||
export function QuotationDetailSheet({
|
||||
quotation,
|
||||
cards,
|
||||
onCloseQuotation,
|
||||
onAward,
|
||||
onSwitchRound,
|
||||
@ -98,11 +103,15 @@ export function QuotationDetailSheet({
|
||||
const serverCards = cardsQuery.data?.cards ?? [];
|
||||
// 재생성 모달 기본 선택 = 이 라운드에 부른 공급사들(세션 distinct supplier).
|
||||
const currentSupplierIds = [...new Set(serverSessions.map((s) => s.supplier_id))];
|
||||
// 재생성 모달엔 '현재 견적에 연결된 공급사'만 + 각자의 협상 단계(세션 상태)를 함께 보여준다.
|
||||
// 재생성 모달엔 '현재 견적에 연결된 공급사'만 + 각자의 협상 단계·직전 투찰가를 함께 보여준다.
|
||||
const connectedPartners = partners.filter((p) => currentSupplierIds.includes(p.id ?? ''));
|
||||
const sessionStatusBySupplier: Record<string, number> = Object.fromEntries(
|
||||
serverSessions.map((s) => [s.supplier_id, s.status]),
|
||||
);
|
||||
// 재생성 카드 재선택 기본값 = 이 라운드가 실제로 쓴 카드.
|
||||
const previousCardIds = serverCards
|
||||
.map((c) => c.nego_card_id ?? c.wild_card_id ?? '')
|
||||
.filter((id): id is string => !!id);
|
||||
// 타결 상한율(‰) — 견적 override 가 없으면 적용 중인 견적 세팅값이 기본.
|
||||
const settingCeilingRate =
|
||||
quotationSettings.find((qs) => qs.qt_setting_id === quotation.qt_setting_id)?.done_ceiling_rate ?? 50;
|
||||
|
||||
// 재생성 버튼은 '체인의 마지막 차수(마감됨)'에서만 노출. 체인 로딩 끝난 뒤 판정해 옛 라운드에서 깜빡임 방지.
|
||||
const { rounds: chainRounds, isLoading: chainLoading } = useQuotationChain(quotation.number);
|
||||
@ -351,14 +360,21 @@ export function QuotationDetailSheet({
|
||||
);
|
||||
})()}
|
||||
|
||||
{regenOpen && (
|
||||
{/* 카드·세션이 도착한 뒤에 열어야 '직전 라운드 카드' 기본선택과 결과 요약이 제 값으로 뜬다. */}
|
||||
{regenOpen && !cardsQuery.isLoading && !sessionsQuery.isLoading && (
|
||||
<RegenerateModal
|
||||
open
|
||||
quotation={quotation}
|
||||
sessionViews={sessionViews}
|
||||
partners={connectedPartners}
|
||||
sessionStatusBySupplier={sessionStatusBySupplier}
|
||||
cards={cards}
|
||||
previousCardIds={previousCardIds}
|
||||
settingCeilingRate={settingCeilingRate}
|
||||
productId={itemId}
|
||||
internetLowest={currentItem?.internet_lowest_price ?? null}
|
||||
defaultSupplierIds={currentSupplierIds}
|
||||
onConfirm={async (ids) => {
|
||||
const newId = await onRegenerate(qtId, ids);
|
||||
onConfirm={async (input) => {
|
||||
const newId = await onRegenerate(qtId, input);
|
||||
if (newId) {
|
||||
onSwitchRound(newId); // 새 라운드 상세로 전환
|
||||
return true;
|
||||
|
||||
@ -28,16 +28,18 @@ export function QuotationSettingsModal({
|
||||
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
|
||||
const [targetMargin, setTargetMargin] = useState('');
|
||||
const [cardUseCount, setCardUseCount] = useState('');
|
||||
const [doneCeilingRate, setDoneCeilingRate] = useState('5'); // 협상 완료 상한율(%) 기본 5
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// 세팅은 목표 마진율·카드 사용 횟수만. 낙찰 정책은 견적 생성으로 이관, 앵커링은 칸 rate(v1.2)로 대체.
|
||||
// 세팅은 목표 마진율·카드 사용 횟수·완료 상한율. 낙찰 정책은 견적 생성으로 이관, 앵커링은 칸 rate(v1.2)로 대체.
|
||||
const handleAdd = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const ok = onAdd({ targetMargin, cardUseCount });
|
||||
const ok = onAdd({ targetMargin, cardUseCount, doneCeilingRate });
|
||||
if (ok) {
|
||||
setTargetMargin('');
|
||||
setCardUseCount('');
|
||||
setDoneCeilingRate('5');
|
||||
}
|
||||
};
|
||||
|
||||
@ -65,13 +67,14 @@ export function QuotationSettingsModal({
|
||||
<TableRow>
|
||||
<TableHead className="p-2">{label('target_margin')}</TableHead>
|
||||
<TableHead className="p-2">카드 사용 횟수</TableHead>
|
||||
<TableHead className="p-2">타결 상한율</TableHead>
|
||||
<TableHead className="p-2 text-center w-12">삭제</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="divide-y divide-border bg-background">
|
||||
{settings.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="p-6 text-center text-muted-foreground">
|
||||
<TableCell colSpan={4} className="p-6 text-center text-muted-foreground">
|
||||
등록된 견적 세팅이 없습니다. (리스트가 비어 있습니다)
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -80,6 +83,7 @@ export function QuotationSettingsModal({
|
||||
<TableRow key={qs.qt_setting_id} className="hover:bg-muted/30">
|
||||
<TableCell className="p-2 font-bold text-primary">{qs.target_margin}</TableCell>
|
||||
<TableCell className="p-2 text-muted-foreground">{qs.card_use_count}</TableCell>
|
||||
<TableCell className="p-2 text-muted-foreground">{qs.done_ceiling_rate / 10}%</TableCell>
|
||||
<TableCell className="p-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
@ -109,6 +113,11 @@ export function QuotationSettingsModal({
|
||||
<Typography as="label" variant="muted" className="text-[10px] font-semibold">카드 사용 횟수</Typography>
|
||||
<Input type="number" step="1" value={cardUseCount} onChange={(e) => setCardUseCount(e.target.value)} placeholder="예: 3" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="muted" className="text-[10px] font-semibold">타결 상한율 (%)</Typography>
|
||||
<Input type="number" step="0.5" value={doneCeilingRate} onChange={(e) => setDoneCeilingRate(e.target.value)} placeholder="예: 5" />
|
||||
<Typography as="p" variant="muted" className="text-[9px] leading-tight">목표가를 이 폭까지 넘어도 타결로 인정(목표가×(1+%)). 초과하면 결렬.</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
|
||||
@ -12,7 +12,12 @@ export function nowKstLocalInput(): string {
|
||||
}
|
||||
|
||||
export function defaultDueDateLocalInput(): string {
|
||||
return toKstLocalInput(new Date(Date.now() + 60 * 60 * 1000));
|
||||
return kstLocalInputAfter(60 * 60 * 1000);
|
||||
}
|
||||
|
||||
// 지금부터 ms 뒤 시각. 재생성 모달이 '원 견적과 같은 협상기간'을 마감기한 기본값으로 채울 때 쓴다.
|
||||
export function kstLocalInputAfter(ms: number): string {
|
||||
return toKstLocalInput(new Date(Date.now() + ms));
|
||||
}
|
||||
|
||||
export function isFutureLocalInput(value: string): boolean {
|
||||
|
||||
@ -16,6 +16,8 @@ type UseCardGatingParams = {
|
||||
cardDetails: Map<string, CardDetail>;
|
||||
setSelectedCardIds: Dispatch<SetStateAction<string[]>>;
|
||||
setCardDetails: Dispatch<SetStateAction<Map<string, CardDetail>>>;
|
||||
/** 직전 라운드에서 쓴 카드 id — 목록에 '직전 사용' 배지를 달아 같은 멘트를 또 던지는 걸 눈에 띄게 한다(재생성 전용). */
|
||||
previousCardIds?: string[];
|
||||
};
|
||||
|
||||
// 카드 선택 게이팅 — 협상 멘트에 변수 토큰이 그대로 노출되거나 논리가 모순되는 카드를 선택 단계에서 막는다.
|
||||
@ -30,7 +32,9 @@ export function useCardGating({
|
||||
cardDetails,
|
||||
setSelectedCardIds,
|
||||
setCardDetails,
|
||||
previousCardIds,
|
||||
}: UseCardGatingParams) {
|
||||
const previousSet = new Set(previousCardIds ?? []);
|
||||
// (1) 조건 전략(customer_condition) 미작성 — 저장 시 조건 내용이 있으면 script 에 실제 문구가
|
||||
// 주입되고(slate.serialize), 없으면 {customer_condition} 토큰이 그대로 남는다.
|
||||
const conditionUnfilled = (c: NegotiationCard) =>
|
||||
@ -81,6 +85,11 @@ export function useCardGating({
|
||||
<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>
|
||||
{previousSet.has(card.id) && (
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded leading-none bg-violet-50 text-violet-700">
|
||||
직전 사용
|
||||
</span>
|
||||
)}
|
||||
{reason && (
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded leading-none bg-rose-50 text-rose-600">
|
||||
{reason}
|
||||
|
||||
@ -45,11 +45,22 @@ export type CreateQuotationInput = {
|
||||
// 낙찰 기준 — 1:1 협상만 전송(경매는 미전송 → 서버가 mid=over=AWARD 강제). 2전략을 mid/over 로 전개해 담는다(over 항상 OPEN).
|
||||
midAction?: number; // PriceGateAction (앵커~목표가 처리: 낙찰/개찰)
|
||||
overAction?: number; // PriceGateAction (목표가 초과 처리: 협상은 항상 개찰)
|
||||
doneCeilingRate?: number; // 협상 완료 상한율(‰) 이 견적 override. 미전송이면 세팅 기본값
|
||||
};
|
||||
|
||||
// 재생성 확정값 — 공급사·마감기한은 항상 확정해 보내고, 나머지는 바꾼 것만(null=원 견적 값 승계).
|
||||
export type RegenerateInput = {
|
||||
supplierIds: string[];
|
||||
dueDate: string; // datetime-local 원본값
|
||||
targetPrice: number | null;
|
||||
cardIds: string[] | null;
|
||||
doneCeilingRate: number | null; // ‰
|
||||
};
|
||||
|
||||
export type SettingInput = {
|
||||
targetMargin: string;
|
||||
cardUseCount: string;
|
||||
doneCeilingRate: string; // 협상 완료 상한율(%) 입력값 — 저장 시 ‰(×10)로 변환
|
||||
};
|
||||
|
||||
// 견적 화면 데이터 허브.
|
||||
@ -163,14 +174,20 @@ export function useQuotations(params: ListQuotationsParams) {
|
||||
const addSetting = (input: SettingInput): boolean => {
|
||||
const marginPct = Number(String(input.targetMargin).replace('%', '').trim());
|
||||
const cardCount = parseInt(String(input.cardUseCount).replace(/[^0-9-]/g, ''), 10);
|
||||
const ceilingPct = Number(String(input.doneCeilingRate).replace('%', '').trim());
|
||||
if (!Number.isFinite(marginPct) || !Number.isInteger(cardCount)) {
|
||||
showToast(`${label('target_margin')}·카드 사용 횟수를 숫자로 입력해야 합니다.`, 'error');
|
||||
return false;
|
||||
}
|
||||
if (!Number.isFinite(ceilingPct) || ceilingPct < 0) {
|
||||
showToast('타결 상한율을 0 이상 숫자로 입력해야 합니다.', 'error');
|
||||
return false;
|
||||
}
|
||||
createSettingMutation.mutate(
|
||||
// 낙찰 정책은 견적 생성으로 이관, 앵커링은 칸 rate(v1.2) → 세팅은 목표 마진율·카드 사용 횟수만.
|
||||
// 낙찰 정책은 견적 생성으로 이관, 앵커링은 칸 rate(v1.2) → 세팅은 목표 마진율·카드 사용 횟수·완료 상한율.
|
||||
{ data: {
|
||||
target_margin_rate: marginPct / 100, card_count: cardCount,
|
||||
done_ceiling_rate: Math.round(ceilingPct * 10), // % → ‰
|
||||
} },
|
||||
{
|
||||
onSuccess: () => {
|
||||
@ -236,6 +253,7 @@ export function useQuotations(params: ListQuotationsParams) {
|
||||
// 낙찰 기준은 1:1 협상만 전송(모달이 미리 걸러 담음) — 경매면 미전송 → 서버가 AWARD 강제.
|
||||
mid_action: input.midAction ?? undefined,
|
||||
over_action: input.overAction ?? undefined,
|
||||
done_ceiling_rate: input.doneCeilingRate ?? undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
@ -258,15 +276,25 @@ export function useQuotations(params: ListQuotationsParams) {
|
||||
}
|
||||
};
|
||||
|
||||
// 마감된 견적을 골라 다음 라운드를 수동 생성한다(공급사는 프론트 선택, 상품·번호·기간은 원 견적 승계).
|
||||
// 마감된 견적을 골라 다음 라운드를 수동 생성한다(상품·번호는 원 견적 승계).
|
||||
// 공급사·마감기한은 모달이 항상 확정해 보내고, 목표가·카드·타결 상한율은 바꾼 것만 보낸다(null=승계).
|
||||
// 성공 시 새 라운드 qt_id 반환, 실패/검증오류 시 null.
|
||||
const regenerateQuotation = async (qtId: string, supplierIds: string[]): Promise<string | null> => {
|
||||
if (supplierIds.length === 0) {
|
||||
const regenerateQuotation = async (qtId: string, input: RegenerateInput): Promise<string | null> => {
|
||||
if (input.supplierIds.length === 0) {
|
||||
showToast('다음 견적에 부를 공급사를 한 곳 이상 선택해 주세요.', 'error');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const res = await regenerateQuotationMutation.mutateAsync({ qtId, data: { supplier_ids: supplierIds } });
|
||||
const res = await regenerateQuotationMutation.mutateAsync({
|
||||
qtId,
|
||||
data: {
|
||||
supplier_ids: input.supplierIds,
|
||||
end_time: new Date(input.dueDate).toISOString(),
|
||||
target_price: input.targetPrice ?? undefined,
|
||||
card_ids: input.cardIds ?? undefined,
|
||||
done_ceiling_rate: input.doneCeilingRate ?? undefined,
|
||||
},
|
||||
});
|
||||
const newId = res?.qt_id ?? null;
|
||||
if (!res?.result?.success || !newId) {
|
||||
const reason = res?.msg ?? res?.result?.desc ?? '서버 오류';
|
||||
|
||||
@ -21,6 +21,7 @@ type UseTargetPriceParams = {
|
||||
negoTouched: boolean;
|
||||
applyNego: boolean;
|
||||
selectedCandidateKey: string | null;
|
||||
doneCeilingRateOverride: number | null; // 협상 완료 상한율(‰) 견적 override. null 이면 세팅 기본값
|
||||
};
|
||||
|
||||
// 견적 목표가 산정 — 세팅 네고율·상품 후보(인터넷최저가/매입가/판매가)에서 목표가 후보와 채택값을 파생한다.
|
||||
@ -39,6 +40,7 @@ export function useTargetPrice({
|
||||
negoTouched,
|
||||
applyNego,
|
||||
selectedCandidateKey,
|
||||
doneCeilingRateOverride,
|
||||
}: UseTargetPriceParams) {
|
||||
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
|
||||
const isHidden = useHiddenFields(); // 회사설정으로 감춘 상품 기본필드 — 후보 리스트에서도 제외
|
||||
@ -93,6 +95,13 @@ export function useTargetPrice({
|
||||
: null;
|
||||
const targetLimitExceeded = targetPriceLimit != null && estimatedTargetPrice != null && estimatedTargetPrice > targetPriceLimit;
|
||||
|
||||
// 협상 완료 상한 — 견적 override(‰) 우선, 없으면 세팅 기본. 상한가 = 목표가×(1+율/1000), 10원 반올림(백엔드 박제와 동일).
|
||||
const settingCeilingRate = Number(selectedSetting?.done_ceiling_rate ?? 50);
|
||||
const effectiveCeilingRate = doneCeilingRateOverride ?? settingCeilingRate;
|
||||
const doneCeilingPrice = estimatedTargetPrice != null
|
||||
? Math.round((estimatedTargetPrice * (1000 + effectiveCeilingRate)) / 1000 / 10) * 10
|
||||
: null;
|
||||
|
||||
return {
|
||||
settingMarginPct,
|
||||
negoCandidateKey,
|
||||
@ -106,5 +115,8 @@ export function useTargetPrice({
|
||||
targetLimitExceeded,
|
||||
estimatedTargetPrice,
|
||||
submitMdPrice,
|
||||
settingCeilingRate,
|
||||
effectiveCeilingRate,
|
||||
doneCeilingPrice,
|
||||
};
|
||||
}
|
||||
|
||||
@ -37,6 +37,7 @@ export interface QuotationSetting {
|
||||
user_id: string;
|
||||
target_margin: string;
|
||||
card_use_count: string;
|
||||
done_ceiling_rate: number; // 협상 완료 상한율(‰). 완료 상한=목표가×(1+값/1000). 견적생성 모달이 상한가 계산에 직접 사용
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted: boolean;
|
||||
@ -67,6 +68,7 @@ export function mapSetting(s: QuotationSettingData): QuotationSetting {
|
||||
user_id: s.user_id || '',
|
||||
target_margin: `${Number.isFinite(ratePct) ? +ratePct.toFixed(2) : 0}%`,
|
||||
card_use_count: `${s.card_count ?? 0}회`,
|
||||
done_ceiling_rate: Number(s.done_ceiling_rate ?? 50), // ‰
|
||||
created_at: s.created_at ?? '',
|
||||
updated_at: s.updated_at ?? '',
|
||||
deleted: false,
|
||||
|
||||
@ -240,6 +240,7 @@ export default function QuotationPage() {
|
||||
<QuotationDetailSheet
|
||||
key={activeQuotation.qt_id}
|
||||
quotation={activeQuotation}
|
||||
cards={cards}
|
||||
onCloseQuotation={closeQuotation}
|
||||
onAward={awardQuotation}
|
||||
onSwitchRound={(qtId) => overlay.open('detail', qtId, { replace: true })}
|
||||
|
||||
21
postgres-init/alters/2026-08-03-done-ceiling.sql
Normal file
21
postgres-init/alters/2026-08-03-done-ceiling.sql
Normal file
@ -0,0 +1,21 @@
|
||||
-- 2026-08-03 · 협상 완료 상한(목표가 초과 허용) 컬럼 추가 (기존 DB 보정)
|
||||
-- 요구: 엑셀 4번(EST-495F/AE71) — 목표가 초과라는 이유만으로 결렬되던 협상을, 정한 폭까지는 타결로 인정.
|
||||
-- 완료 상한 = 목표가 × (1 + rate/1000). 회사 기본율은 quotation_settings, 견적별 override 는 quotations,
|
||||
-- 생성 시 확정 금액은 sessions 에 박제(봇 종결·마감이 앵커가처럼 직접 읽는다).
|
||||
-- 정본은 init-data/init.sql(신규 설치). 이 파일은 동일 최종본을 기존 DB 에 반영한다.
|
||||
-- 멱등: ADD COLUMN IF NOT EXISTS — 여러 번 실행해도 안전.
|
||||
-- 적용: psql -h <host> -p <port> -U <user> -d <db> -f postgres-init/alters/2026-08-03-completion-ceiling.sql
|
||||
|
||||
\connect negosium_db
|
||||
|
||||
-- 회사 기본 완료 상한율(‰). 기본 50‰(5%).
|
||||
ALTER TABLE quotation.quotation_settings
|
||||
ADD COLUMN IF NOT EXISTS done_ceiling_rate SMALLINT NOT NULL DEFAULT 50;
|
||||
|
||||
-- 견적별 override(‰). NULL 이면 세팅 기본값을 따른다.
|
||||
ALTER TABLE quotation.quotations
|
||||
ADD COLUMN IF NOT EXISTS done_ceiling_rate SMALLINT NULL;
|
||||
|
||||
-- 생성 시 박제한 완료 상한가(원) = 목표가 × (1+rate/1000), 10원 반올림. 봇 종결·마감 판정 기준.
|
||||
ALTER TABLE negotiation.sessions
|
||||
ADD COLUMN IF NOT EXISTS done_ceiling_price BIGINT NULL;
|
||||
@ -241,6 +241,7 @@ CREATE TABLE IF NOT EXISTS quotation.quotation_settings (
|
||||
user_id uuid NOT NULL, -- 견적 설정을 생성한 유저 아이디(company.users.user_id)
|
||||
target_margin_rate NUMERIC(8,6) NOT NULL, -- 목표 마진율 (정수부 2자리 + 소수 6자리, -99.999999~99.999999)
|
||||
card_count INTEGER NOT NULL DEFAULT 3, -- 한개의 협상 안에서 협상카드 사용 횟수
|
||||
done_ceiling_rate SMALLINT NOT NULL DEFAULT 50, -- 협상 완료 상한율(‰). 완료 상한=목표가×(1+값/1000). 목표가 초과여도 여기까지는 타결
|
||||
-- 낙찰 정책(mid/over/regen)은 견적 단위로 이관, 앵커링은 칸 rate(anchoring v1.2)로 대체 → 세팅 컬럼 없음
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||
@ -273,6 +274,7 @@ CREATE TABLE IF NOT EXISTS quotation.quotations (
|
||||
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 협상은 항상 개찰). 투찰가≤앵커링가는 항상 낙찰
|
||||
done_ceiling_rate SMALLINT NULL, -- 협상 완료 상한율(‰) 견적별 override. NULL 이면 quotation_settings 값 사용
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
||||
@ -291,6 +293,7 @@ CREATE TABLE IF NOT EXISTS negotiation.sessions (
|
||||
qt_type SMALLINT NOT NULL, -- 견적 유형(스냅샷, QuotationType): 1=renego(재협상 1:1), 2=requote(재견적 1:N), 3=new_nego(신규협상 1:1), 4=new_quote(신규견적 1:N)
|
||||
target_price BIGINT NOT NULL, -- 목표가(원)
|
||||
anchoring_price BIGINT NULL, -- 앵커링가(원) — 생성 시 박제(schedules/anchoring 참조)
|
||||
done_ceiling_price BIGINT NULL, -- 협상 완료 상한가(원) — 생성 시 박제 = 목표가×(1+완료상한율/1000). 봇 종결·마감이 이 이하면 타결
|
||||
anchoring_value SMALLINT NULL, -- 제안 당시 앵커링 값(천분율‰) 박제
|
||||
last_offer_price BIGINT NULL, -- 협력사 마지막 제시가(원) — 앵커링 표본 판정의 "가격 흔적"
|
||||
used_by_adjustment_id BIGINT NULL, -- 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id)
|
||||
|
||||
@ -275,6 +275,7 @@ CREATE TABLE IF NOT EXISTS quotation.quotation_settings (
|
||||
user_id uuid NOT NULL, -- 견적 설정을 생성한 유저 아이디(company.users.user_id)
|
||||
target_margin_rate NUMERIC(8,6) NOT NULL, -- 목표 마진율 (정수부 2자리 + 소수 6자리, -99.999999~99.999999)
|
||||
card_count INTEGER NOT NULL DEFAULT 3, -- 한개의 협상 안에서 협상카드 사용 횟수
|
||||
done_ceiling_rate SMALLINT NOT NULL DEFAULT 50, -- 협상 완료 상한율(‰). 완료 상한=목표가×(1+값/1000). 목표가 초과여도 여기까지는 타결
|
||||
-- 낙찰 정책(mid/over/regen)은 견적 단위로 이관, 앵커링은 칸 rate(anchoring v1.2)로 대체 → 세팅 컬럼 없음
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||
@ -307,6 +308,7 @@ CREATE TABLE IF NOT EXISTS quotation.quotations (
|
||||
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 협상은 항상 개찰). 투찰가≤앵커링가는 항상 낙찰
|
||||
done_ceiling_rate SMALLINT NULL, -- 협상 완료 상한율(‰) 견적별 override. NULL 이면 quotation_settings 값 사용
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
||||
@ -325,6 +327,7 @@ CREATE TABLE IF NOT EXISTS negotiation.sessions (
|
||||
qt_type SMALLINT NOT NULL, -- 견적 유형(스냅샷, QuotationType): 1=renego(재협상 1:1), 2=requote(재견적 1:N), 3=new_nego(신규협상 1:1), 4=new_quote(신규견적 1:N)
|
||||
target_price BIGINT NOT NULL, -- 목표가(원)
|
||||
anchoring_price BIGINT NULL, -- 앵커링가(원) — 생성 시 박제(schedules/anchoring 참조)
|
||||
done_ceiling_price BIGINT NULL, -- 협상 완료 상한가(원) — 생성 시 박제 = 목표가×(1+완료상한율/1000). 봇 종결·마감이 이 이하면 타결
|
||||
anchoring_value SMALLINT NULL, -- 제안 당시 앵커링 값(천분율‰) 박제
|
||||
last_offer_price BIGINT NULL, -- 협력사 마지막 제시가(원) — 앵커링 표본 판정의 "가격 흔적"
|
||||
used_by_adjustment_id BIGINT NULL, -- 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user