[refactor] negodata: 목표가 산정 정리 — purchase_only 죽은코드 제거(백+프론트)·산정 재료 로더 공용화·자동재생성 잔재 주석 정정
This commit is contained in:
parent
bacf6271df
commit
7b612824be
@ -145,7 +145,7 @@ class DashboardCRUD(IDashboardCRUD):
|
||||
|
||||
async def ruptured(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]:
|
||||
try:
|
||||
# 결렬 = 마감됐는데 단독낙찰(preferred_sp_yn)도 동가(equal_bid_yn)도 아님 → 둘 다 NULL(거부/한도로 그냥 마감).
|
||||
# 결렬 = 마감됐는데 낙찰(preferred_sp_yn)도 동가(equal_bid_yn)도 아님 → 둘 다 NULL(가격미달·거부·미응찰 개찰).
|
||||
where = and_(
|
||||
*_company_scope(company_id, owner),
|
||||
quotations.status == QuotationStatus.CLOSED.value,
|
||||
|
||||
@ -51,6 +51,10 @@ class IQuotationCRUD(ABC):
|
||||
async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_company_settings(self, cdb: AsyncSession, user_id) -> dict:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def add_rows(self, cdb: AsyncSession, obj_list: list) -> ErrorType:
|
||||
pass
|
||||
@ -384,11 +388,9 @@ class QuotationCRUD(IQuotationCRUD):
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, {}
|
||||
|
||||
async def get_target_price_mode(self, cdb: AsyncSession, user_id):
|
||||
# 견적 소유 유저 → 회사 설정에서 목표가 산정에 영향을 주는 두 가지를 함께 읽는다.
|
||||
# features.target_price_mode: 'purchase' 면 매입가만 후보(IMK #10)
|
||||
# hidden_fields: 화면에서 감춘 가격 필드는 후보에서도 뺀다(감춘 값이 목표가를 정하면 설명이 안 된다)
|
||||
# 반환: (mode, hidden_fields set). 미설정이면 (None, set()) → 기존 로직 그대로.
|
||||
async def get_company_settings(self, cdb: AsyncSession, user_id):
|
||||
# 이 유저가 속한 회사의 settings(JSONB) 전체를 돌려준다. 회사가 없거나 조회에 실패하면 빈 dict.
|
||||
# 어떤 키를 어떻게 해석할지(목표가 정책·메일 브랜딩 등)는 호출하는 쪽 몫이다.
|
||||
try:
|
||||
query = (
|
||||
select(companies.settings)
|
||||
@ -399,33 +401,11 @@ class QuotationCRUD(IQuotationCRUD):
|
||||
)
|
||||
err, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err != ErrorType.SUCCESS or not rows:
|
||||
return None, set()
|
||||
settings = rows[0] or {}
|
||||
mode = (settings.get("features") or {}).get("target_price_mode")
|
||||
hidden = set(settings.get("hidden_fields") or [])
|
||||
return mode, hidden
|
||||
return {}
|
||||
return rows[0] or {}
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return None, set()
|
||||
|
||||
async def get_email_header(self, cdb: AsyncSession, user_id):
|
||||
# 견적 소유 유저 → 회사 → companies.settings.branding.email_header. 초청 메일 헤더 브랜딩용. 없으면 None.
|
||||
try:
|
||||
query = (
|
||||
select(companies.settings)
|
||||
.select_from(users)
|
||||
.join(companies, companies.company_id == users.company_id)
|
||||
.where(users.user_id == user_id, companies.deleted == False) # noqa: E712
|
||||
.limit(1)
|
||||
)
|
||||
err, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err != ErrorType.SUCCESS or not rows:
|
||||
return None
|
||||
settings = rows[0] or {}
|
||||
return (settings.get("branding") or {}).get("email_header")
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return None
|
||||
return {}
|
||||
|
||||
async def get_item_companies(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]:
|
||||
"""item_id -> company_id(소유 회사) 매핑. 앵커링 칸(회사×유형×가격구간) 해석 입력."""
|
||||
|
||||
@ -205,7 +205,6 @@ class Res_TargetBreakdown(Res_WebPacketProtocol):
|
||||
margin: float = 0.0
|
||||
anchoring_value: float = 0.0 # 앵커링율(비율). 세션 앵커링값(‰)을 /1000 환산 — main 프론트 표시용
|
||||
candidates: list[TargetCandidate] = []
|
||||
target_price_mode: Optional[str] = None # 회사 설정 목표가 산정 모드('purchase' = 매입가만). 화면 설명 문구용
|
||||
hidden_price_fields: list[str] = [] # 회사 설정으로 감춰 후보에서 뺀 가격 필드
|
||||
chosen_basis: Optional[str] = None
|
||||
target_price: int = 0
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""스케줄 잡 로직(what). '언제 도느냐'(scheduler/__init__.py)와 분리된, 잡이 실제로 하는 일.
|
||||
|
||||
두 잡 모두 '대상 견적을 골라' → 견적마다 QuotationService.close_and_decide 를 호출한다.
|
||||
마감 + 결과 판정(낙찰 확정 / 다음 라운드 재생성 / 그냥 마감)은 전부 도메인(close_and_decide)이 책임지고,
|
||||
마감 + 결과 판정(낙찰 확정 / 그 외 개찰)은 전부 도메인(close_and_decide)이 책임지고,
|
||||
여기 잡은 '어떤 견적을 고르냐(대상 선정)'와 '언제 도느냐'만 담당한다.
|
||||
"""
|
||||
from collections import Counter
|
||||
@ -37,7 +37,7 @@ def _format_results(results: Counter) -> str:
|
||||
|
||||
|
||||
async def close_expired_quotations() -> int:
|
||||
"""[잡①] 마감일이 지난 견적을 자동 마감 처리한다. 하루 한 번 실행.
|
||||
"""[잡①] 마감일이 지난 견적을 자동 마감 처리한다. 5분마다 실행(scheduler/__init__.py).
|
||||
대상: 마감 시각이 이미 지났는데 아직 마감되지 않은(삭제되지도 않은) 견적.
|
||||
처리: 견적마다 close_and_decide 로 결과 판정(낙찰 확정 / 개찰=낙찰자 미정 마감).
|
||||
반환: 처리한 견적 수."""
|
||||
@ -61,8 +61,8 @@ async def close_expired_quotations() -> int:
|
||||
|
||||
async def close_negotiated_quotations() -> int:
|
||||
"""[잡②] 모든 세션의 협상이 끝난 견적은 마감일을 기다리지 않고 바로 마감한다(견적 타입 무관).
|
||||
대상: 아직 마감되지 않았고, 진행중·미시작 세션이 하나도 없는(= 모두 종결된) 견적. 한 시간마다 실행.
|
||||
처리: 견적마다 close_and_decide 로 결과 판정(낙찰 확정 / 다음 라운드 재생성 / 그냥 마감).
|
||||
대상: 아직 마감되지 않았고, 진행중·미시작 세션이 하나도 없는(= 모두 종결된) 견적. 5분마다 실행.
|
||||
처리: 견적마다 close_and_decide 로 결과 판정(낙찰 확정 / 개찰=낙찰자 미정 마감).
|
||||
반환: 처리한 견적 수."""
|
||||
crud = QuotationCRUD()
|
||||
service = QuotationService(crud)
|
||||
|
||||
@ -63,9 +63,16 @@ class QuotationService:
|
||||
# 인터넷 평균 수수료율(상수). 시장 평균값이라 견적/세팅별로 두지 않고 고정. 목표가=인터넷최저가×(1−값).
|
||||
INTERNET_AVERAGE_FEE = 0.078
|
||||
|
||||
# TODO 목표마진율(quotation_settings.target_margin_rate)을 세팅에서 상수로 강등 검토 (2026-07-27)
|
||||
# KTC 원본은 하드코딩 상수 0.065 (task_get_rq_price.py). 쓰임새도 판매가 후보 하나뿐.
|
||||
# 강등 시 딸려가는 것: quotation_settings 컬럼(날짜 SQL 파일)·세팅 화면·산정내역 표기·테스트.
|
||||
|
||||
# 목표가 후보 basis 코드 ↔ 표시 라벨(산정내역 응답에서 프론트가 그대로 표기).
|
||||
_CANDIDATE_LABELS = {"md": "MD 입력가", "internet": "인터넷 최저가", "purchase": "매입가", "selling": "판매가"}
|
||||
|
||||
# 가격 소스별로, 회사 설정 hidden_fields 에서 쓰는 필드 이름. 숨긴 가격은 목표가 후보에서도 뺀다.
|
||||
_SOURCE_HIDDEN_FIELD = {"internet": "internet_lowest_price", "purchase": "purchase_price", "selling": "selling_price"}
|
||||
|
||||
def __init__(self, quotation_crud: IQuotationCRUD = Depends(QuotationCRUD)):
|
||||
self.quotation_crud = quotation_crud
|
||||
|
||||
@ -76,59 +83,41 @@ class QuotationService:
|
||||
return f"{base}/chat?session_id={session_id}"
|
||||
|
||||
@staticmethod
|
||||
def _candidates(md_price=None, internet_lowest=None, purchase=None, selling=None, fee=0.0, margin=0.0, is_new=False, mode=None, hidden=None):
|
||||
"""목표가 후보 [(basis, value_float)] 목록(빈 값/0 은 제외). md 있으면 md 단독.
|
||||
값은 float(인터넷=가격×(1−수수료), 판매가=가격×(1−마진))이며 채택 시 int() 절삭한다.
|
||||
_calc_target_price(생성)와 get_target_breakdown(표시)가 공유하는 단일 산정 로직.
|
||||
def _candidates(md_price=None, internet_lowest=None, purchase=None, selling=None, fee=0.0, margin=0.0, is_new=False, hidden=None) -> list[tuple[str, float]]:
|
||||
"""목표가 후보 [(소스, 후보가)] 목록. 생성(_calc_target_price)과 산정내역 화면이 공용.
|
||||
|
||||
mode='purchase' (회사 설정 features.target_price_mode) 면 매입가만 후보로 쓴다 —
|
||||
인터넷최저가·판매가는 신규/재 구분 없이 제외하고 매입가 × (1 − 네고율) 하나로 잡는다(IMK #10).
|
||||
hidden (회사 설정 hidden_fields) 에 든 가격 필드는 후보에서 뺀다 — 화면에서 감춘 값이
|
||||
목표가를 결정하면 담당자가 산정 근거를 확인할 수 없기 때문."""
|
||||
md_price 있으면 그 값 하나만. 없으면:
|
||||
신규(is_new): 인터넷최저가 × (1−fee)
|
||||
재: 인터넷최저가 × (1−fee) · 매입가 · 판매가 × (1−margin)
|
||||
값이 없거나 hidden(회사가 숨긴 필드)에 든 소스는 제외."""
|
||||
if md_price:
|
||||
return [("md", float(int(md_price)))]
|
||||
# (소스, 가격, 차감율) 표 — 조건에 맞는 소스만 남겨 후보가 = 가격 × (1−차감율)
|
||||
if is_new:
|
||||
table = [("internet", internet_lowest, fee)]
|
||||
else:
|
||||
table = [("internet", internet_lowest, fee), ("purchase", purchase, 0.0), ("selling", selling, margin)]
|
||||
hidden = hidden or set()
|
||||
if "internet_lowest_price" in hidden:
|
||||
internet_lowest = None
|
||||
if "purchase_price" in hidden:
|
||||
purchase = None
|
||||
if "selling_price" in hidden:
|
||||
selling = None
|
||||
if mode == "purchase":
|
||||
return [("purchase", int(purchase) * (1 - (margin or 0.0)))] if purchase else []
|
||||
out = []
|
||||
if internet_lowest:
|
||||
out.append(("internet", int(internet_lowest) * (1 - (fee or 0.0))))
|
||||
if not is_new: # 재(협상·견적)만 매입가·판매가를 후보에 추가. 신규는 인터넷최저가만.
|
||||
if purchase:
|
||||
out.append(("purchase", float(int(purchase))))
|
||||
if selling:
|
||||
out.append(("selling", int(selling) * (1 - (margin or 0.0))))
|
||||
return out
|
||||
return [
|
||||
(basis, int(price) * (1 - (rate or 0.0)))
|
||||
for basis, price, rate in table
|
||||
if price and QuotationService._SOURCE_HIDDEN_FIELD[basis] not in hidden
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _calc_target_price(md_price=None, internet_lowest=None, purchase=None, selling=None, fee=0.0, margin=0.0, is_new=False, mode=None, hidden=None) -> int:
|
||||
"""세션 목표가 (KTC 신규/재 분리 로직, 회사 데이터 풍부도에 graceful 적응)
|
||||
① md_price 있으면 → 그대로
|
||||
② 없으면:
|
||||
· 신규(NEW_NEGO/NEW_QUOTE) → 인터넷최저가 × (1 − fee) [인터넷최저가만]
|
||||
· 재(RENEGO/REQUOTE) → 유효 후보 중 min:
|
||||
- 인터넷최저가 × (1 − fee) ← fee=quotation_settings.internet_average_fee
|
||||
- 매입가 (그대로)
|
||||
- 판매가 × (1 − margin) ← margin=quotation_settings.target_margin_rate
|
||||
③ 후보 0개 → 견적 생성 불가(ValueError).
|
||||
mode='purchase' 면 ②를 무시하고 매입가 × (1 − 네고율) 하나만 후보로 쓴다(IMK #10)."""
|
||||
def _calc_target_price(md_price=None, internet_lowest=None, purchase=None, selling=None, fee=0.0, margin=0.0, is_new=False, hidden=None) -> int:
|
||||
"""세션에 박을 목표가를 확정한다: MD 입력가가 있으면 그 값 그대로, 없으면 후보 중 가장 싼 값.
|
||||
|
||||
차감율(fee/margin)이 1 이상이면 목표가가 0이나 음수가 되므로 설정 오류로 막는다.
|
||||
후보를 하나도 못 만들면 ValueError — 이 견적은 생성 자체가 불가능하다."""
|
||||
if not md_price:
|
||||
# 율은 비율(0~1 미만)이어야 한다. 1 이상이면 (1−율)≤0 → 목표가가 0/음수가 되므로 설정 오류로 막는다.
|
||||
if not 0.0 <= (fee or 0.0) < 1.0:
|
||||
raise ValueError(f"인터넷 수수료율은 0 이상 1 미만이어야 합니다: fee={fee}")
|
||||
if (mode == "purchase" or not is_new) and not 0.0 <= (margin or 0.0) < 1.0:
|
||||
if not is_new and not 0.0 <= (margin or 0.0) < 1.0:
|
||||
raise ValueError(f"목표 마진율은 0 이상 1 미만이어야 합니다: margin={margin}")
|
||||
cands = QuotationService._candidates(md_price, internet_lowest, purchase, selling, fee, margin, is_new, mode, hidden)
|
||||
cands = QuotationService._candidates(md_price, internet_lowest, purchase, selling, fee, margin, is_new, hidden)
|
||||
if not cands:
|
||||
if mode == "purchase":
|
||||
raise ValueError("타겟 가격 계산 불가: md_price·매입가 모두 없음")
|
||||
raise ValueError("타겟 가격 계산 불가: md_price·인터넷최저가" + ("" if is_new else "·매입가·판매가") + " 모두 없음")
|
||||
raise ValueError("목표가 계산 불가: MD 입력가·인터넷최저가" + ("" if is_new else "·매입가·판매가") + " 모두 없음")
|
||||
return int(min(v for _, v in cands))
|
||||
|
||||
@staticmethod
|
||||
@ -150,11 +139,10 @@ class QuotationService:
|
||||
return ErrorType.SUCCESS, quotation
|
||||
|
||||
async def get_target_breakdown(self, session_id: str, company_id=None) -> Res_TargetBreakdown:
|
||||
"""세션 목표가 산정내역(후보·채택). 저장된 target_price/anchoring 은 그대로 표기하고,
|
||||
후보값은 생성과 동일한 _candidates 로직으로 계산해 내려준다(프론트 재계산 제거 → 항상 일치).
|
||||
채택 표시는 저장 목표가와 값이 일치하는 후보로 판정한다 — 산정 이후 다른 후보(상품 가격)가
|
||||
변해 현재 최소값이 바뀌어도 출처 후보의 체크는 유지된다. 일치 후보가 없으면(재생성 상속,
|
||||
채택 후보 자체가 변경) is_inherited=True 로 채택 표시를 비운다."""
|
||||
"""목표가 모달의 산정내역 응답.
|
||||
|
||||
저장된 목표가·앵커링가는 그대로 내려주고, 후보 목록은 _candidates 로 다시 계산한다.
|
||||
채택 체크 = 저장 목표가와 값이 같은 후보. 없으면(재생성 상속 등) is_inherited=True."""
|
||||
res = Res_TargetBreakdown()
|
||||
err_type, got = await DB_SESSION_MNG.execute_lambda(
|
||||
sessions.DBType(),
|
||||
@ -166,33 +154,19 @@ class QuotationService:
|
||||
return res
|
||||
sess = got[0]
|
||||
err_type, quotation = await self._fetch(sess.quotation_id, company_id)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
if err_type != ErrorType.SUCCESS or quotation is None:
|
||||
res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND)
|
||||
return res
|
||||
|
||||
_e, prices = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.get_item_prices(s, [sess.item_id]),
|
||||
# 산정 입력(재료)은 생성과 같은 로더를 공유 — 생성값과 표시값이 어긋나지 않는다.
|
||||
prices, fee, margin, hidden = 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)
|
||||
_e, rates = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.get_setting_rates(s, quotation.qt_setting_id),
|
||||
)
|
||||
rates = rates or {}
|
||||
fee = self.INTERNET_AVERAGE_FEE
|
||||
margin = rates.get("margin") or 0.0
|
||||
is_new = QuotationType.is_new(quotation.type)
|
||||
md = quotation.md_price
|
||||
mode, hidden = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.get_target_price_mode(s, quotation.user_id),
|
||||
)
|
||||
|
||||
cands = self._candidates(md, internet, purchase, selling, fee, margin, is_new, mode, hidden)
|
||||
cands = self._candidates(md, internet, purchase, selling, fee, margin, is_new, hidden)
|
||||
chosen_basis = next((b for b, v in cands if int(v) == sess.target_price), None)
|
||||
is_inherited = chosen_basis is None
|
||||
|
||||
@ -205,7 +179,6 @@ class QuotationService:
|
||||
res.fee = fee
|
||||
res.margin = margin
|
||||
res.candidates = [TargetCandidate(basis=b, label=self._CANDIDATE_LABELS.get(b, b), value=int(v)) for b, v in cands]
|
||||
res.target_price_mode = mode
|
||||
res.hidden_price_fields = sorted(hidden & {"internet_lowest_price", "purchase_price", "selling_price"})
|
||||
res.chosen_basis = chosen_basis
|
||||
res.target_price = sess.target_price
|
||||
@ -312,7 +285,7 @@ class QuotationService:
|
||||
return res
|
||||
|
||||
async def regenerate_next_round(self, original_qt_id: uuid.UUID, supplier_ids: list, regen_label: Optional[str] = None) -> Res_CreateQuotation:
|
||||
"""[마감 후속] 결판 안 난 견적의 '다음 라운드'를 새로 만든다.
|
||||
"""[재생성] 마감된 견적의 '다음 라운드'를 새로 만든다. 호출 경로는 수동 재생성·재협상 승인뿐.
|
||||
|
||||
플로우:
|
||||
1) 원 견적 + 세션을 조회해 대상 상품(item)을 복원
|
||||
@ -337,7 +310,7 @@ class QuotationService:
|
||||
item_ids = list({r.item_id for r in rows}) if err_type == ErrorType.SUCCESS else []
|
||||
# 재생성은 목표가를 재계산하지 않고 직전 라운드 세션 값을 그대로 상속(KTC 방식).
|
||||
# 앵커링가는 상속하지 않는다 — 생성 시점의 칸 rate 로 항상 재계산·박제(앵커링 v1.2 인수인계 규칙 1).
|
||||
inherited = {r.item_id: r.target_price for r in rows} if err_type == ErrorType.SUCCESS 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
|
||||
@ -381,31 +354,21 @@ class QuotationService:
|
||||
card_ids=[], # 새 버전 안 만듦(원본 version_id 재사용)
|
||||
mid_action=original.mid_action, # 낙찰 기준 상속(타입이 REQUOTE 로 바뀌면 빌더가 AWARD 로 재정규화)
|
||||
over_action=original.over_action,
|
||||
inherited=inherited, # 직전 라운드 목표가 상속(앵커링가는 현재 rate 로 재계산)
|
||||
inherited_target_prices=inherited_target_prices, # 직전 라운드 목표가 상속(앵커링가는 현재 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,
|
||||
mid_action: Optional[int] = None, # 낙찰 기준(견적 단위). 앵커링가<투찰가≤목표가 처리(AWARD/OPEN)
|
||||
over_action: Optional[int] = None, # 목표가<투찰가 처리(1:1 협상은 항상 OPEN)
|
||||
inherited: Optional[dict] = None, # 재생성 시 {item_id: target_price} 상속(KTC) — 목표가만. 앵커는 항상 재계산
|
||||
) -> Res_CreateQuotation:
|
||||
"""견적 1건 + (상품×공급사) 세션들을 한 트랜잭션으로 생성하는 공통 빌더."""
|
||||
res = Res_CreateQuotation()
|
||||
async def _load_target_inputs(
|
||||
self, item_ids: list[uuid.UUID], qt_setting_id, user_id
|
||||
) -> tuple[dict, float, float, set]:
|
||||
"""목표가 계산에 필요한 값들을 한 번에 모아온다.
|
||||
|
||||
# 낙찰 기준 정규화 — 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: 상품마다 (인터넷최저가, 매입가, 판매가) — DB 조회
|
||||
- margin: 판매가에서 깎을 목표마진율 — 견적 세팅(quotation_settings) 조회
|
||||
- fee: 인터넷최저가에서 깎을 수수료율 — DB 아님, 고정 상수 INTERNET_AVERAGE_FEE
|
||||
- hidden: 회사 설정(companies.settings)의 숨김 가격 필드 — 숨긴 가격은 목표가 후보에서도 뺀다
|
||||
|
||||
# 세션 목표가 입력(상품별 인터넷최저가/매입가/판매가 + 세팅 율). 읽기 트랜잭션에서 먼저 조회.
|
||||
견적 생성(_build_quotation)과 산정내역 화면(get_target_breakdown)이 똑같이 이 함수를 쓴다 —
|
||||
그래야 만들 때 계산한 목표가와 화면에 보여주는 근거가 어긋나지 않는다."""
|
||||
prices = {}
|
||||
if item_ids:
|
||||
_err, prices = await DB_SESSION_MNG.execute_lambda(
|
||||
@ -422,12 +385,110 @@ class QuotationService:
|
||||
rates = rates if _err == ErrorType.SUCCESS else {}
|
||||
fee = self.INTERNET_AVERAGE_FEE # 인터넷가 차감 수수료율(상수)
|
||||
margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율
|
||||
# 앵커링가는 quotation_settings.anchoring_value(구 float 비율)를 더 이상 쓰지 않는다(앵커링 v1.2) —
|
||||
# 칸(회사×상품-협력사 공급유형×가격구간)별 조정 anchoring_value(정수 ‰)로 계산한다. 아래 세션 생성부 ②.
|
||||
user_uuid = uuid.UUID(user_id) if isinstance(user_id, str) else user_id
|
||||
settings = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.get_company_settings(s, user_uuid),
|
||||
)
|
||||
hidden = set(settings.get("hidden_fields") or [])
|
||||
return prices, fee, margin, hidden
|
||||
|
||||
def _resolve_target_prices(
|
||||
self, *, qt_id, item_ids: list[uuid.UUID], prices: dict,
|
||||
md_price, fee, margin, is_new: bool, hidden, inherited_target_prices: Optional[dict],
|
||||
) -> dict[uuid.UUID, int]:
|
||||
"""상품마다 목표가를 정한다.
|
||||
|
||||
재생성 라운드는 직전 라운드의 목표가를 그대로 물려받는다(KTC 규칙).
|
||||
그 외에는 인터넷최저가·매입가·판매가로 만든 후보 중 가장 싼 값을 목표가로 쓴다.
|
||||
후보를 하나도 못 만드는 상품이 있으면 ValueError — 호출한 쪽이 견적 생성 실패로 처리한다."""
|
||||
target_prices = {}
|
||||
for iid in item_ids:
|
||||
if inherited_target_prices and iid in inherited_target_prices:
|
||||
target_prices[iid] = inherited_target_prices[iid]
|
||||
continue
|
||||
internet, purchase, selling = prices.get(iid) or (None, None, None)
|
||||
try:
|
||||
target_prices[iid] = self._calc_target_price(md_price, internet, purchase, selling, fee, margin, is_new=is_new, hidden=hidden)
|
||||
except ValueError as ex:
|
||||
LOG.w(
|
||||
f"[목표가 산정불가] qt_id={qt_id} item={iid} is_new={is_new} "
|
||||
f"md={md_price} internet={internet} purchase={purchase} selling={selling} :: {ex}"
|
||||
)
|
||||
raise
|
||||
return target_prices
|
||||
|
||||
async def _resolve_anchors(
|
||||
self, item_ids: list[uuid.UUID], supplier_ids: list[uuid.UUID], target_prices: dict[uuid.UUID, int]
|
||||
) -> dict[tuple[uuid.UUID, uuid.UUID], tuple[int, int]]:
|
||||
"""(상품×공급사) 조합마다 앵커링가를 계산한다(앵커링 v1.2, 인수인계.md §1.3).
|
||||
앵커링 값은 사용자 입력이 아니라, 칸(상품의 회사 × 공급유형 × 가격구간)마다
|
||||
배치(schedules/anchoring)가 조정해 둔 현재값을 읽어 쓴다.
|
||||
칸 값을 못 찾으면(공급유형 미지정·조정 이력 없음·조회 실패) 기본 시작값 테이블로 폴백 —
|
||||
반환: {(item_id, supplier_id): (anchoring_value ‰, anchoring_price 원)}"""
|
||||
_err, item_companies = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.get_item_companies(s, item_ids),
|
||||
)
|
||||
item_companies = item_companies if _err == ErrorType.SUCCESS else {}
|
||||
_err, supply_types = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.get_supply_types(s, item_ids, supplier_ids),
|
||||
)
|
||||
supply_types = supply_types if _err == ErrorType.SUCCESS else {}
|
||||
value_map = {}
|
||||
sampleable_supply_types = sorted({t for t in supply_types.values() if t in SAMPLEABLE_SUPPLIER_TYPES})
|
||||
if sampleable_supply_types and item_companies:
|
||||
value_map = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: fetch_current_values(s, list(set(item_companies.values())), sampleable_supply_types),
|
||||
)
|
||||
|
||||
anchors = {}
|
||||
for iid in item_ids:
|
||||
tp = target_prices[iid]
|
||||
price_range = calc_price_range_index(tp)
|
||||
company = item_companies.get(iid)
|
||||
for sid in supplier_ids:
|
||||
supply_type = supply_types.get((iid, sid))
|
||||
value = value_map.get((company, supply_type, price_range)) if company is not None else None
|
||||
if value is None:
|
||||
value = get_base_anchoring_value(price_range)
|
||||
ap = calc_anchoring_price(tp, value) # 목표가×(1000−value)//1000 — float 곱셈 금지(1원 내림 정확성)
|
||||
anchors[(iid, sid)] = (value, ap)
|
||||
return anchors
|
||||
|
||||
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,
|
||||
mid_action: Optional[int] = None, # 낙찰 기준(견적 단위). 앵커링가<투찰가≤목표가 처리(AWARD/OPEN)
|
||||
over_action: Optional[int] = None, # 목표가<투찰가 처리(1:1 협상은 항상 OPEN)
|
||||
inherited_target_prices: Optional[dict] = None, # 재생성 시 직전 라운드 목표가 상속(KTC). 목표가만 — 앵커는 항상 재계산
|
||||
) -> Res_CreateQuotation:
|
||||
"""견적 1건 + (상품×공급사) 세션들을 한 트랜잭션으로 생성하는 공통 빌더."""
|
||||
res = Res_CreateQuotation()
|
||||
|
||||
# 낙찰 기준 정규화 — 1:N 경매는 항상 최저가 낙찰(mid=over=AWARD 강제). 1:1 협상은 요청값(미지정=AWARD).
|
||||
# create/regenerate 양 경로가 이 빌더를 타므로 불변식을 여기 한 곳에서 강제한다(재생성 시 타입 전환도 자동 재정규화).
|
||||
if QuotationType.is_auction(type_):
|
||||
mid_action = over_action = PriceGateAction.AWARD.value
|
||||
else:
|
||||
mid_action = mid_action or PriceGateAction.AWARD.value
|
||||
over_action = over_action or PriceGateAction.AWARD.value
|
||||
|
||||
# 목표가 계산 재료(가격·비율·회사 설정)를 먼저 모아온다.
|
||||
prices, fee, margin, hidden = await self._load_target_inputs(item_ids, qt_setting_id, user_id)
|
||||
|
||||
# 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다.
|
||||
# (quotation↔card 는 version → version_nego_cards/version_wild_cards 로 연결.)
|
||||
version_obj = None
|
||||
version_obj = None
|
||||
link_rows = []
|
||||
if card_ids:
|
||||
_err, card_types = await DB_SESSION_MNG.execute_lambda(
|
||||
@ -478,65 +539,25 @@ class QuotationService:
|
||||
# 신규(NEW_NEGO/NEW_QUOTE)는 인터넷최저가만, 재(RENEGO/REQUOTE)는 매입가·판매가까지 후보(KTC 신규/재 분리).
|
||||
is_new = QuotationType.is_new(type_)
|
||||
|
||||
# 회사별 목표가 산정 모드(features.target_price_mode). 'purchase' 면 매입가 × 네고율만 후보(IMK #10).
|
||||
mode, hidden = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.get_target_price_mode(s, uuid.UUID(user_id)),
|
||||
)
|
||||
|
||||
# ① 목표가 산정 — 재생성(inherited)은 직전 라운드 값 그대로 상속(KTC), 그 외엔 후보 min.
|
||||
target_prices = {}
|
||||
# 상품별 목표가 결정
|
||||
try:
|
||||
for iid in item_ids:
|
||||
if inherited and iid in inherited:
|
||||
target_prices[iid] = inherited[iid]
|
||||
else:
|
||||
internet, purchase, selling = prices.get(iid) or (None, None, None)
|
||||
target_prices[iid] = self._calc_target_price(md_price, internet, purchase, selling, fee, margin, is_new=is_new, mode=mode, hidden=hidden)
|
||||
except ValueError as ex:
|
||||
LOG.w(
|
||||
f"[목표가 산정불가] qt_id={qt_id} item={iid} is_new={is_new} "
|
||||
f"md={md_price} internet={internet} purchase={purchase} selling={selling} :: {ex}"
|
||||
target_prices = self._resolve_target_prices(
|
||||
qt_id=qt_id, item_ids=item_ids, prices=prices,
|
||||
md_price=md_price, fee=fee, margin=margin,
|
||||
is_new=is_new, hidden=hidden, inherited_target_prices=inherited_target_prices,
|
||||
)
|
||||
except ValueError:
|
||||
res.result.SetResult(ErrorType.QUOTATION_TARGET_PRICE_UNAVAILABLE)
|
||||
return res
|
||||
|
||||
# ② 앵커가 산출 — 칸(items.company_id × supplier_items.supply_type × 목표가 구간) anchoring_value 조회 후
|
||||
# 정수 연산으로 박제(앵커링 v1.2, 인수인계.md §1.3). 매핑 미지정/조정 이력 없음/조회 실패는
|
||||
# 정적 테이블 시작값 폴백 — 값 조회 때문에 견적 생성이 실패하지 않는다(규칙 6).
|
||||
_err, item_companies = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.get_item_companies(s, item_ids),
|
||||
)
|
||||
item_companies = item_companies if _err == ErrorType.SUCCESS else {}
|
||||
_err, supply_types = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.get_supply_types(s, item_ids, supplier_ids),
|
||||
)
|
||||
supply_types = supply_types if _err == ErrorType.SUCCESS else {}
|
||||
value_map = {}
|
||||
sampleable_supply_types = sorted({t for t in supply_types.values() if t in SAMPLEABLE_SUPPLIER_TYPES})
|
||||
if sampleable_supply_types and item_companies:
|
||||
value_map = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: fetch_current_values(s, list(set(item_companies.values())), sampleable_supply_types),
|
||||
)
|
||||
# ② 앵커가 산출 — 칸별 anchoring_value 조회 후 정수 연산으로 박제(상세는 _resolve_anchors).
|
||||
anchors = await self._resolve_anchors(item_ids, supplier_ids, target_prices)
|
||||
|
||||
session_objs = []
|
||||
for iid in item_ids:
|
||||
tp = target_prices[iid]
|
||||
price_range = calc_price_range_index(tp)
|
||||
company = item_companies.get(iid)
|
||||
for sid in supplier_ids:
|
||||
supply_type = supply_types.get((iid, sid))
|
||||
value = value_map.get((company, supply_type, price_range)) if company is not None else None
|
||||
if value is None:
|
||||
value = get_base_anchoring_value(price_range)
|
||||
ap = calc_anchoring_price(tp, value) # 목표가×(1000−value)//1000 — float 곱셈 금지(1원 내림 정확성)
|
||||
value, ap = anchors[(iid, sid)]
|
||||
session_objs.append(
|
||||
sessions(
|
||||
session_id=uuid.uuid4(),
|
||||
@ -738,7 +759,7 @@ class QuotationService:
|
||||
|
||||
async def stop_quotation(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_Quotation:
|
||||
"""[프론트] 수동 견적마감. 크론과 똑같은 마감 판정(close_and_decide)을 탄다
|
||||
(단독낙찰 확정 / 동가·미참여면 다음 라운드 재생성 / 거부·한도면 그냥 마감)."""
|
||||
(낙찰 확정 / 그 외 전부 개찰 — 낙찰자 미정 마감. 재생성은 별도 수동 API)."""
|
||||
res = Res_Quotation()
|
||||
qt_uuid = uuid.UUID(qt_id)
|
||||
|
||||
@ -995,10 +1016,11 @@ class QuotationService:
|
||||
"""targets [(session, supplier_name, email)] 에 초청 메일 발송. res.sent/failed 를 채우고
|
||||
성공한 session_id 목록을 반환. ACS/SMTP 미설정이면 첫 발송에서 중단(EMAIL_NOT_CONFIGURED)."""
|
||||
# 회사 브랜딩(초청 메일 헤더) 한 번 조회 — 견적당 동일.
|
||||
email_header = await DB_SESSION_MNG.execute_lambda(
|
||||
settings = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.get_email_header(s, quotation.user_id),
|
||||
lambda s: self.quotation_crud.get_company_settings(s, quotation.user_id),
|
||||
)
|
||||
email_header = (settings.get("branding") or {}).get("email_header")
|
||||
sent_ids = []
|
||||
for sess, sp_name, email in targets:
|
||||
subject, html, text = build_invite_email(
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
마감을 자동으로 돌리는 크론 잡이 2개 있다(scheduler/jobs.py):
|
||||
· 잡① close_expired_quotations : 마감시각(end_time)이 지났는데 아직 안 닫힌 견적을 닫는다.
|
||||
· 잡② close_negotiated_quotations : 참여 세션이 전부 끝난(협상 종료) 견적을 닫는다.
|
||||
두 잡 모두, 고른 견적마다 close_and_decide() 를 불러 결과(낙찰 / 다음 라운드 재생성 / 그냥 마감)를 정한다.
|
||||
두 잡 모두, 고른 견적마다 close_and_decide() 를 불러 결과(낙찰 / 그 외 개찰)를 정한다.
|
||||
|
||||
이 파일은 그 두 잡이 (1) 마감할 견적을 올바로 고르는지, (2) 마감 결과가 맞는지 확인한다.
|
||||
잡에는 HTTP 엔드포인트가 없어 scheduler.jobs 함수를 직접 부른다(앱과 같은 DB 연결을 써서 mock 불필요).
|
||||
@ -82,7 +82,7 @@ async def test_close_negotiated_picks_when_all_sessions_ended(clean):
|
||||
|
||||
async def test_award_single_lowest(clean):
|
||||
"""검증: 두 공급사가 각각 100·200 으로 협상완료(DONE)한, 마감시각 지난 견적을 잡①로 마감.
|
||||
기대결과: 마감(CLOSED)되고, 더 싼 100 공급사가 단독 낙찰(낙찰 있음 + 낙찰자=그 공급사)."""
|
||||
기대결과: 마감(CLOSED)되고, 더 싼 100 공급사가 낙찰(낙찰 있음 + 낙찰자=그 공급사)."""
|
||||
engine = clean
|
||||
qt = await _add_quotation(engine, end_time=PAST)
|
||||
winner = uuid.uuid4()
|
||||
|
||||
@ -7,7 +7,7 @@ import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
||||
import { useListCards } from '@/api/generated/card/card';
|
||||
import { mapCardData } from '@/features/cards/types';
|
||||
import { CONDITION_VARIABLE, CONDITION_LABEL } from '@/features/cards/editor/variables';
|
||||
import { useLabels, useCompanySettings } from '@/features/settings/useCompanySettings';
|
||||
import { useLabels } from '@/features/settings/useCompanySettings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Typography, typographyVariants } from '@/components/ui/typography';
|
||||
import { cn } from '@/lib/utils';
|
||||
@ -253,14 +253,9 @@ export function QuotationCreateModal({
|
||||
const d = cardDetails.get(id);
|
||||
return { id, code: d?.code ?? '', title: d?.title ?? id, isWildcard: d?.isWildcard ?? false };
|
||||
});
|
||||
// 목표가 산정 모드(회사 설정). 'purchase' 면 매입가 × (1 − 네고율) 하나만 후보 — 백엔드 _candidates 와 같은 규칙.
|
||||
const { settings: companySettings } = useCompanySettings();
|
||||
const purchaseOnly = (companySettings.features as { target_price_mode?: string } | undefined)?.target_price_mode === 'purchase';
|
||||
// 상품에 산정 후보가 있는지(인터넷=공통, 매입·판매=재 한정). 없으면 MD가가 유일한 후보 → 필수가 된다.
|
||||
const mdNum = Number(mdPrice) || 0;
|
||||
const hasItemCandidate = purchaseOnly
|
||||
? purchase != null
|
||||
: internetLowest != null || (isReType && (purchase != null || selling != null));
|
||||
const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null));
|
||||
const mdRequired = !!productId && !hasItemCandidate;
|
||||
// 목표가 산정 가능 여부: MD가가 있으면 무조건 OK. 없으면 상품 후보 중 하나라도 있어야.
|
||||
const targetReady = mdNum > 0 || hasItemCandidate;
|
||||
@ -268,13 +263,11 @@ export function QuotationCreateModal({
|
||||
const margin = parsePercent(selectedSetting?.target_margin);
|
||||
const targetCandidates = mdNum > 0
|
||||
? [mdNum]
|
||||
: purchaseOnly
|
||||
? [purchase == null ? null : purchase * (1 - margin)].filter((v): v is number => v != null && v > 0)
|
||||
: [
|
||||
internetLowest != null ? internetLowest * (1 - INTERNET_AVERAGE_FEE) : null,
|
||||
!isReType || purchase == null ? null : purchase,
|
||||
!isReType || selling == null ? null : selling * (1 - margin),
|
||||
].filter((v): v is number => v != null && v > 0);
|
||||
: [
|
||||
internetLowest != null ? internetLowest * (1 - INTERNET_AVERAGE_FEE) : null,
|
||||
!isReType || purchase == null ? null : purchase,
|
||||
!isReType || selling == null ? null : selling * (1 - margin),
|
||||
].filter((v): v is number => v != null && v > 0);
|
||||
const estimatedTargetPrice = targetCandidates.length ? Math.trunc(Math.min(...targetCandidates)) : null;
|
||||
const targetPriceLimit = unitPrice != null && unitPrice > 0
|
||||
? unitPrice * TARGET_PRICE_UNIT_LIMIT_MULTIPLIER
|
||||
@ -499,11 +492,11 @@ export function QuotationCreateModal({
|
||||
상품 상세에서 수정
|
||||
</button>
|
||||
</div>
|
||||
<div className={`grid ${purchaseOnly ? 'grid-cols-1' : isReType ? 'grid-cols-3' : 'grid-cols-1'} gap-2`}>
|
||||
<div className={`grid ${isReType ? 'grid-cols-3' : 'grid-cols-1'} gap-2`}>
|
||||
{[
|
||||
{ label: '인터넷 최저가', value: internetLowest, show: !purchaseOnly },
|
||||
{ label: '매입가', value: purchase, show: purchaseOnly || isReType },
|
||||
{ label: '판매가', value: selling, show: !purchaseOnly && isReType },
|
||||
{ label: '인터넷 최저가', value: internetLowest, show: true },
|
||||
{ label: '매입가', value: purchase, show: isReType },
|
||||
{ label: '판매가', value: selling, show: isReType },
|
||||
]
|
||||
.filter((r) => r.show)
|
||||
.map((r) => (
|
||||
@ -530,7 +523,7 @@ export function QuotationCreateModal({
|
||||
</div>
|
||||
{!targetReady && (
|
||||
<Typography as="p" variant="small" className="text-rose-600 leading-snug">
|
||||
⚠ MD 제시가도 없고 {purchaseOnly ? '상품에 매입가가' : '상품에 산정할 값이'} 없습니다 — MD가를 입력하거나 위 ‘상품 상세에서 수정’으로 값을 채워야 목표가가 나옵니다.
|
||||
⚠ MD 제시가도 없고 상품에 산정할 값이 없습니다 — MD가를 입력하거나 위 ‘상품 상세에서 수정’으로 값을 채워야 목표가가 나옵니다.
|
||||
</Typography>
|
||||
)}
|
||||
{targetLimitExceeded && (
|
||||
|
||||
@ -82,28 +82,17 @@ export function TargetPriceModal({
|
||||
<div className="mt-4 bg-muted/40 border border-border rounded p-3 space-y-1">
|
||||
<Typography as="p" variant="small" className="font-bold text-foreground text-[11px]">목표가 선정방식</Typography>
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">1. MD 입력값 존재 시, 최우선 적용</Typography>
|
||||
{/* 설명 문구는 회사 설정(목표가 모드·숨김 필드)에 따라 실제 후보와 일치하게 바꾼다. */}
|
||||
{bd.target_price_mode === 'purchase' ? (
|
||||
<>
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">2. 매입가 × (1−{label('target_margin')})</Typography>
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">
|
||||
* {label('target_margin')}: {bd.margin}
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">
|
||||
2. 다음 중 가장 작은 값 — {[
|
||||
!hiddenPrice.includes('internet_lowest_price') && '인터넷최저가×(1−수수료)',
|
||||
!hiddenPrice.includes('purchase_price') && '매입가',
|
||||
!hiddenPrice.includes('selling_price') && `판매가×(1−${label('target_margin')})`,
|
||||
].filter(Boolean).join(' | ')}
|
||||
</Typography>
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">
|
||||
* 인터넷 평균 수수료: {bd.fee} · {label('target_margin')}: {bd.margin}{bd.is_new ? ' · 신규견적이라 인터넷최저가만 적용' : ''}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
{/* 설명 문구는 회사 설정(숨김 필드)에 따라 실제 후보와 일치하게 바꾼다. */}
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">
|
||||
2. 다음 중 가장 작은 값 — {[
|
||||
!hiddenPrice.includes('internet_lowest_price') && '인터넷최저가×(1−수수료)',
|
||||
!hiddenPrice.includes('purchase_price') && '매입가',
|
||||
!hiddenPrice.includes('selling_price') && `판매가×(1−${label('target_margin')})`,
|
||||
].filter(Boolean).join(' | ')}
|
||||
</Typography>
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">
|
||||
* 인터넷 평균 수수료: {bd.fee} · {label('target_margin')}: {bd.margin}{bd.is_new ? ' · 신규견적이라 인터넷최저가만 적용' : ''}
|
||||
</Typography>
|
||||
{bd.is_inherited && (
|
||||
<Typography as="p" variant="small" className="text-[10px] text-amber-600">
|
||||
* 저장된 목표가와 일치하는 현재 후보 없음 — 재생성 상속 또는 산정 후 상품·세팅 변경(아래 후보는 현재값 기준 참고용)
|
||||
|
||||
@ -18,7 +18,7 @@ export type CompanySettings = {
|
||||
email_header?: string; // 초청 메일 헤더 문구 (기본 NEGODATA)
|
||||
};
|
||||
labels?: Record<string, string>; // 카탈로그 키 → 이 회사 용어 (없으면 기본값)
|
||||
features?: Record<string, unknown>; // 회사별 동작 플래그 (예: target_price_mode)
|
||||
features?: Record<string, unknown>; // 회사별 동작 플래그 (현재 사용처 없음 — 새 플래그 추가 시 여기서 읽는다)
|
||||
item_fields?: CustomFieldDef[]; // 상품 커스텀필드 정의 → items.custom
|
||||
supplier_fields?: CustomFieldDef[]; // 협력사 커스텀필드 정의 → suppliers.custom
|
||||
session_fields?: CustomFieldDef[]; // 협상완료 부가정보 정의 → sessions.custom (공급사가 타결 후 입력)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user