[refactor] negodata: 목표가 산정 정리 — purchase_only 죽은코드 제거(백+프론트)·산정 재료 로더 공용화·자동재생성 잔재 주석 정정

This commit is contained in:
Mina Choi 2026-07-27 16:07:21 +09:00
parent bacf6271df
commit 7b612824be
9 changed files with 209 additions and 226 deletions

View File

@ -145,7 +145,7 @@ class DashboardCRUD(IDashboardCRUD):
async def ruptured(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]: async def ruptured(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]:
try: try:
# 결렬 = 마감됐는데 단독낙찰(preferred_sp_yn)도 동가(equal_bid_yn)도 아님 → 둘 다 NULL(거부/한도로 그냥 마감). # 결렬 = 마감됐는데 낙찰(preferred_sp_yn)도 동가(equal_bid_yn)도 아님 → 둘 다 NULL(가격미달·거부·미응찰 개찰).
where = and_( where = and_(
*_company_scope(company_id, owner), *_company_scope(company_id, owner),
quotations.status == QuotationStatus.CLOSED.value, quotations.status == QuotationStatus.CLOSED.value,

View File

@ -51,6 +51,10 @@ class IQuotationCRUD(ABC):
async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]: async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]:
pass pass
@abstractmethod
async def get_company_settings(self, cdb: AsyncSession, user_id) -> dict:
pass
@abstractmethod @abstractmethod
async def add_rows(self, cdb: AsyncSession, obj_list: list) -> ErrorType: async def add_rows(self, cdb: AsyncSession, obj_list: list) -> ErrorType:
pass pass
@ -384,11 +388,9 @@ class QuotationCRUD(IQuotationCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, {} return ErrorType.DB_RUN_FAILED, {}
async def get_target_price_mode(self, cdb: AsyncSession, user_id): async def get_company_settings(self, cdb: AsyncSession, user_id):
# 견적 소유 유저 → 회사 설정에서 목표가 산정에 영향을 주는 두 가지를 함께 읽는다. # 이 유저가 속한 회사의 settings(JSONB) 전체를 돌려준다. 회사가 없거나 조회에 실패하면 빈 dict.
# features.target_price_mode: 'purchase' 면 매입가만 후보(IMK #10) # 어떤 키를 어떻게 해석할지(목표가 정책·메일 브랜딩 등)는 호출하는 쪽 몫이다.
# hidden_fields: 화면에서 감춘 가격 필드는 후보에서도 뺀다(감춘 값이 목표가를 정하면 설명이 안 된다)
# 반환: (mode, hidden_fields set). 미설정이면 (None, set()) → 기존 로직 그대로.
try: try:
query = ( query = (
select(companies.settings) select(companies.settings)
@ -399,33 +401,11 @@ class QuotationCRUD(IQuotationCRUD):
) )
err, rows = await DB_SESSION_MNG.execute(cdb, query) err, rows = await DB_SESSION_MNG.execute(cdb, query)
if err != ErrorType.SUCCESS or not rows: if err != ErrorType.SUCCESS or not rows:
return None, set() return {}
settings = rows[0] or {} return rows[0] or {}
mode = (settings.get("features") or {}).get("target_price_mode")
hidden = set(settings.get("hidden_fields") or [])
return mode, hidden
except Exception as ex: except Exception as ex:
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return None, set() return {}
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
async def get_item_companies(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]: async def get_item_companies(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]:
"""item_id -> company_id(소유 회사) 매핑. 앵커링 칸(회사×유형×가격구간) 해석 입력.""" """item_id -> company_id(소유 회사) 매핑. 앵커링 칸(회사×유형×가격구간) 해석 입력."""

View File

@ -205,7 +205,6 @@ class Res_TargetBreakdown(Res_WebPacketProtocol):
margin: float = 0.0 margin: float = 0.0
anchoring_value: float = 0.0 # 앵커링율(비율). 세션 앵커링값(‰)을 /1000 환산 — main 프론트 표시용 anchoring_value: float = 0.0 # 앵커링율(비율). 세션 앵커링값(‰)을 /1000 환산 — main 프론트 표시용
candidates: list[TargetCandidate] = [] candidates: list[TargetCandidate] = []
target_price_mode: Optional[str] = None # 회사 설정 목표가 산정 모드('purchase' = 매입가만). 화면 설명 문구용
hidden_price_fields: list[str] = [] # 회사 설정으로 감춰 후보에서 뺀 가격 필드 hidden_price_fields: list[str] = [] # 회사 설정으로 감춰 후보에서 뺀 가격 필드
chosen_basis: Optional[str] = None chosen_basis: Optional[str] = None
target_price: int = 0 target_price: int = 0

View File

@ -1,7 +1,7 @@
"""스케줄 잡 로직(what). '언제 도느냐'(scheduler/__init__.py)와 분리된, 잡이 실제로 하는 일. """스케줄 잡 로직(what). '언제 도느냐'(scheduler/__init__.py)와 분리된, 잡이 실제로 하는 일.
두 잡 모두 '대상 견적을 골라' → 견적마다 QuotationService.close_and_decide 를 호출한다. 두 잡 모두 '대상 견적을 골라' → 견적마다 QuotationService.close_and_decide 를 호출한다.
마감 + 결과 판정(낙찰 확정 / 다음 라운드 재생성 / 그냥 마감)은 전부 도메인(close_and_decide)이 책임지고, 마감 + 결과 판정(낙찰 확정 / 그 외 개찰)은 전부 도메인(close_and_decide)이 책임지고,
여기 잡은 '어떤 견적을 고르냐(대상 선정)'와 '언제 도느냐'만 담당한다. 여기 잡은 '어떤 견적을 고르냐(대상 선정)'와 '언제 도느냐'만 담당한다.
""" """
from collections import Counter from collections import Counter
@ -37,7 +37,7 @@ def _format_results(results: Counter) -> str:
async def close_expired_quotations() -> int: async def close_expired_quotations() -> int:
"""[잡①] 마감일이 지난 견적을 자동 마감 처리한다. 하루 한 번 실행. """[잡①] 마감일이 지난 견적을 자동 마감 처리한다. 5분마다 실행(scheduler/__init__.py).
대상: 마감 시각이 이미 지났는데 아직 마감되지 않은(삭제되지도 않은) 견적. 대상: 마감 시각이 이미 지났는데 아직 마감되지 않은(삭제되지도 않은) 견적.
처리: 견적마다 close_and_decide 로 결과 판정(낙찰 확정 / 개찰=낙찰자 미정 마감). 처리: 견적마다 close_and_decide 로 결과 판정(낙찰 확정 / 개찰=낙찰자 미정 마감).
반환: 처리한 견적 수.""" 반환: 처리한 견적 수."""
@ -61,8 +61,8 @@ async def close_expired_quotations() -> int:
async def close_negotiated_quotations() -> int: async def close_negotiated_quotations() -> int:
"""[잡②] 모든 세션의 협상이 끝난 견적은 마감일을 기다리지 않고 바로 마감한다(견적 타입 무관). """[잡②] 모든 세션의 협상이 끝난 견적은 마감일을 기다리지 않고 바로 마감한다(견적 타입 무관).
대상: 아직 마감되지 않았고, 진행중·미시작 세션이 하나도 없는(= 모두 종결된) 견적. 한 시간마다 실행. 대상: 아직 마감되지 않았고, 진행중·미시작 세션이 하나도 없는(= 모두 종결된) 견적. 5분마다 실행.
처리: 견적마다 close_and_decide 로 결과 판정(낙찰 확정 / 다음 라운드 재생성 / 그냥 마감). 처리: 견적마다 close_and_decide 로 결과 판정(낙찰 확정 / 개찰=낙찰자 미정 마감).
반환: 처리한 견적 수.""" 반환: 처리한 견적 수."""
crud = QuotationCRUD() crud = QuotationCRUD()
service = QuotationService(crud) service = QuotationService(crud)

View File

@ -63,9 +63,16 @@ class QuotationService:
# 인터넷 평균 수수료율(상수). 시장 평균값이라 견적/세팅별로 두지 않고 고정. 목표가=인터넷최저가×(1−값). # 인터넷 평균 수수료율(상수). 시장 평균값이라 견적/세팅별로 두지 않고 고정. 목표가=인터넷최저가×(1−값).
INTERNET_AVERAGE_FEE = 0.078 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 코드 ↔ 표시 라벨(산정내역 응답에서 프론트가 그대로 표기). # 목표가 후보 basis 코드 ↔ 표시 라벨(산정내역 응답에서 프론트가 그대로 표기).
_CANDIDATE_LABELS = {"md": "MD 입력가", "internet": "인터넷 최저가", "purchase": "매입가", "selling": "판매가"} _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)): def __init__(self, quotation_crud: IQuotationCRUD = Depends(QuotationCRUD)):
self.quotation_crud = quotation_crud self.quotation_crud = quotation_crud
@ -76,59 +83,41 @@ class QuotationService:
return f"{base}/chat?session_id={session_id}" return f"{base}/chat?session_id={session_id}"
@staticmethod @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): 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]]:
"""목표가 후보 [(basis, value_float)] 목록(빈 값/0 은 제외). md 있으면 md 단독. """목표가 후보 [(소스, 후보가)] 목록. 생성(_calc_target_price)과 산정내역 화면이 공용.
값은 float(인터넷=가격×(1−수수료), 판매가=가격×(1−마진))이며 채택 시 int() 절삭한다.
_calc_target_price(생성)와 get_target_breakdown(표시)가 공유하는 단일 산정 로직.
mode='purchase' (회사 설정 features.target_price_mode) 면 매입가만 후보로 쓴다 — md_price 있으면 그 값 하나만. 없으면:
인터넷최저가·판매가는 신규/재 구분 없이 제외하고 매입가 × (1 − 네고율) 하나로 잡는다(IMK #10). 신규(is_new): 인터넷최저가 × (1−fee)
hidden (회사 설정 hidden_fields) 에 든 가격 필드는 후보에서 뺀다 — 화면에서 감춘 값이 재: 인터넷최저가 × (1−fee) · 매입가 · 판매가 × (1−margin)
목표가를 결정하면 담당자가 산정 근거를 확인할 수 없기 때문.""" 값이 없거나 hidden(회사가 숨긴 필드)에 든 소스는 제외."""
if md_price: if md_price:
return [("md", float(int(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() hidden = hidden or set()
if "internet_lowest_price" in hidden: return [
internet_lowest = None (basis, int(price) * (1 - (rate or 0.0)))
if "purchase_price" in hidden: for basis, price, rate in table
purchase = None if price and QuotationService._SOURCE_HIDDEN_FIELD[basis] not in hidden
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
@staticmethod @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: 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:
"""세션 목표가 (KTC 신규/재 분리 로직, 회사 데이터 풍부도에 graceful 적응) """세션에 박을 목표가를 확정한다: MD 입력가가 있으면 그 값 그대로, 없으면 후보 중 가장 싼 값.
① md_price 있으면 → 그대로
② 없으면: 차감율(fee/margin)이 1 이상이면 목표가가 0이나 음수가 되므로 설정 오류로 막는다.
· 신규(NEW_NEGO/NEW_QUOTE) → 인터넷최저가 × (1 − fee) [인터넷최저가만] 후보를 하나도 못 만들면 ValueError — 이 견적은 생성 자체가 불가능하다."""
· 재(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)."""
if not md_price: if not md_price:
# 율은 비율(0~1 미만)이어야 한다. 1 이상이면 (1−율)≤0 → 목표가가 0/음수가 되므로 설정 오류로 막는다.
if not 0.0 <= (fee or 0.0) < 1.0: if not 0.0 <= (fee or 0.0) < 1.0:
raise ValueError(f"인터넷 수수료율은 0 이상 1 미만이어야 합니다: fee={fee}") 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}") 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 not cands:
if mode == "purchase": raise ValueError("목표가 계산 불가: MD 입력가·인터넷최저가" + ("" if is_new else "·매입가·판매가") + " 모두 없음")
raise ValueError("타겟 가격 계산 불가: md_price·매입가 모두 없음")
raise ValueError("타겟 가격 계산 불가: md_price·인터넷최저가" + ("" if is_new else "·매입가·판매가") + " 모두 없음")
return int(min(v for _, v in cands)) return int(min(v for _, v in cands))
@staticmethod @staticmethod
@ -150,11 +139,10 @@ class QuotationService:
return ErrorType.SUCCESS, quotation return ErrorType.SUCCESS, quotation
async def get_target_breakdown(self, session_id: str, company_id=None) -> Res_TargetBreakdown: async def get_target_breakdown(self, session_id: str, company_id=None) -> Res_TargetBreakdown:
"""세션 목표가 산정내역(후보·채택). 저장된 target_price/anchoring 은 그대로 표기하고, """목표가 모달의 산정내역 응답.
후보값은 생성과 동일한 _candidates 로직으로 계산해 내려준다(프론트 재계산 제거 → 항상 일치).
채택 표시는 저장 목표가와 값이 일치하는 후보로 판정한다 — 산정 이후 다른 후보(상품 가격)가 저장된 목표가·앵커링가는 그대로 내려주고, 후보 목록은 _candidates 로 다시 계산한다.
변해 현재 최소값이 바뀌어도 출처 후보의 체크는 유지된다. 일치 후보가 없으면(재생성 상속, 채택 체크 = 저장 목표가와 값이 같은 후보. 없으면(재생성 상속 등) is_inherited=True."""
채택 후보 자체가 변경) is_inherited=True 로 채택 표시를 비운다."""
res = Res_TargetBreakdown() res = Res_TargetBreakdown()
err_type, got = await DB_SESSION_MNG.execute_lambda( err_type, got = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(), sessions.DBType(),
@ -166,33 +154,19 @@ class QuotationService:
return res return res
sess = got[0] sess = got[0]
err_type, quotation = await self._fetch(sess.quotation_id, company_id) err_type, quotation = await self._fetch(sess.quotation_id, company_id)
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS or quotation is None:
res.result.SetResult(err_type) res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND)
return res return res
_e, prices = await DB_SESSION_MNG.execute_lambda( # 산정 입력(재료)은 생성과 같은 로더를 공유 — 생성값과 표시값이 어긋나지 않는다.
quotations.DBType(), prices, fee, margin, hidden = await self._load_target_inputs(
DBWRType.DB_READ.value, [sess.item_id], quotation.qt_setting_id, quotation.user_id
lambda s: self.quotation_crud.get_item_prices(s, [sess.item_id]),
) )
internet, purchase, selling = (prices or {}).get(sess.item_id) or (None, None, None) 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) is_new = QuotationType.is_new(quotation.type)
md = quotation.md_price 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) chosen_basis = next((b for b, v in cands if int(v) == sess.target_price), None)
is_inherited = chosen_basis is None is_inherited = chosen_basis is None
@ -205,7 +179,6 @@ class QuotationService:
res.fee = fee res.fee = fee
res.margin = margin res.margin = margin
res.candidates = [TargetCandidate(basis=b, label=self._CANDIDATE_LABELS.get(b, b), value=int(v)) for b, v in cands] res.candidates = [TargetCandidate(basis=b, label=self._CANDIDATE_LABELS.get(b, b), value=int(v)) for b, v in cands]
res.target_price_mode = mode
res.hidden_price_fields = sorted(hidden & {"internet_lowest_price", "purchase_price", "selling_price"}) res.hidden_price_fields = sorted(hidden & {"internet_lowest_price", "purchase_price", "selling_price"})
res.chosen_basis = chosen_basis res.chosen_basis = chosen_basis
res.target_price = sess.target_price res.target_price = sess.target_price
@ -312,7 +285,7 @@ class QuotationService:
return res 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) -> Res_CreateQuotation:
"""[마감 후속] 결판 안 난 견적의 '다음 라운드'를 새로 만든다. """[재생성] 마감된 견적의 '다음 라운드'를 새로 만든다. 호출 경로는 수동 재생성·재협상 승인뿐.
플로우: 플로우:
1) 원 견적 + 세션을 조회해 대상 상품(item)을 복원 1) 원 견적 + 세션을 조회해 대상 상품(item)을 복원
@ -337,7 +310,7 @@ class QuotationService:
item_ids = list({r.item_id for r in rows}) if err_type == ErrorType.SUCCESS else [] item_ids = list({r.item_id for r in rows}) if err_type == ErrorType.SUCCESS else []
# 재생성은 목표가를 재계산하지 않고 직전 라운드 세션 값을 그대로 상속(KTC 방식). # 재생성은 목표가를 재계산하지 않고 직전 라운드 세션 값을 그대로 상속(KTC 방식).
# 앵커링가는 상속하지 않는다 — 생성 시점의 칸 rate 로 항상 재계산·박제(앵커링 v1.2 인수인계 규칙 1). # 앵커링가는 상속하지 않는다 — 생성 시점의 칸 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곳 → 재협상 / 여러 곳 → 재견적 # 2) 타입 결정: 공급사 1곳 → 재협상 / 여러 곳 → 재견적
next_type = QuotationType.RENEGO.value if len(supplier_ids) <= 1 else QuotationType.REQUOTE.value next_type = QuotationType.RENEGO.value if len(supplier_ids) <= 1 else QuotationType.REQUOTE.value
@ -381,31 +354,21 @@ class QuotationService:
card_ids=[], # 새 버전 안 만듦(원본 version_id 재사용) card_ids=[], # 새 버전 안 만듦(원본 version_id 재사용)
mid_action=original.mid_action, # 낙찰 기준 상속(타입이 REQUOTE 로 바뀌면 빌더가 AWARD 로 재정규화) mid_action=original.mid_action, # 낙찰 기준 상속(타입이 REQUOTE 로 바뀌면 빌더가 AWARD 로 재정규화)
over_action=original.over_action, over_action=original.over_action,
inherited=inherited, # 직전 라운드 목표가 상속(앵커링가는 현재 rate 로 재계산) inherited_target_prices=inherited_target_prices, # 직전 라운드 목표가 상속(앵커링가는 현재 rate 로 재계산)
) )
async def _build_quotation( async def _load_target_inputs(
self, *, self, item_ids: list[uuid.UUID], qt_setting_id, user_id
user_id: str, qt_setting_id, version_id, name: str, number: str, ) -> tuple[dict, float, float, set]:
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()
# 낙찰 기준 정규화 — 1:N 경매는 항상 최저가 낙찰(mid=over=AWARD 강제). 1:1 협상은 요청값(미지정=AWARD). - prices: 상품마다 (인터넷최저가, 매입가, 판매가) — DB 조회
# create/regenerate 양 경로가 이 빌더를 타므로 불변식을 여기 한 곳에서 강제한다(재생성 시 타입 전환도 자동 재정규화). - margin: 판매가에서 깎을 목표마진율 — 견적 세팅(quotation_settings) 조회
if QuotationType.is_auction(type_): - fee: 인터넷최저가에서 깎을 수수료율 — DB 아님, 고정 상수 INTERNET_AVERAGE_FEE
mid_action = over_action = PriceGateAction.AWARD.value - hidden: 회사 설정(companies.settings)의 숨김 가격 필드 — 숨긴 가격은 목표가 후보에서도 뺀다
else:
mid_action = mid_action or PriceGateAction.AWARD.value
over_action = over_action or PriceGateAction.AWARD.value
# 세션 목표가 입력(상품별 인터넷최저가/매입가/판매가 + 세팅 율). 읽기 트랜잭션에서 먼저 조회. 견적 생성(_build_quotation)과 산정내역 화면(get_target_breakdown)이 똑같이 이 함수를 쓴다 —
그래야 만들 때 계산한 목표가와 화면에 보여주는 근거가 어긋나지 않는다."""
prices = {} prices = {}
if item_ids: if item_ids:
_err, prices = await DB_SESSION_MNG.execute_lambda( _err, prices = await DB_SESSION_MNG.execute_lambda(
@ -422,12 +385,110 @@ class QuotationService:
rates = rates if _err == ErrorType.SUCCESS else {} rates = rates if _err == ErrorType.SUCCESS else {}
fee = self.INTERNET_AVERAGE_FEE # 인터넷가 차감 수수료율(상수) fee = self.INTERNET_AVERAGE_FEE # 인터넷가 차감 수수료율(상수)
margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율 margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율
# 앵커링가는 quotation_settings.anchoring_value(구 float 비율)를 더 이상 쓰지 않는다(앵커링 v1.2) — user_uuid = uuid.UUID(user_id) if isinstance(user_id, str) else user_id
# 칸(회사×상품-협력사 공급유형×가격구간)별 조정 anchoring_value(정수 ‰)로 계산한다. 아래 세션 생성부 ②. 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.version_id 로 연결한다.
# (quotation↔card 는 version → version_nego_cards/version_wild_cards 로 연결.) # (quotation↔card 는 version → version_nego_cards/version_wild_cards 로 연결.)
version_obj = None version_obj = None
link_rows = [] link_rows = []
if card_ids: if card_ids:
_err, card_types = await DB_SESSION_MNG.execute_lambda( _err, card_types = await DB_SESSION_MNG.execute_lambda(
@ -478,65 +539,25 @@ class QuotationService:
# 신규(NEW_NEGO/NEW_QUOTE)는 인터넷최저가만, 재(RENEGO/REQUOTE)는 매입가·판매가까지 후보(KTC 신규/재 분리). # 신규(NEW_NEGO/NEW_QUOTE)는 인터넷최저가만, 재(RENEGO/REQUOTE)는 매입가·판매가까지 후보(KTC 신규/재 분리).
is_new = QuotationType.is_new(type_) 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: try:
for iid in item_ids: target_prices = self._resolve_target_prices(
if inherited and iid in inherited: qt_id=qt_id, item_ids=item_ids, prices=prices,
target_prices[iid] = inherited[iid] md_price=md_price, fee=fee, margin=margin,
else: is_new=is_new, hidden=hidden, inherited_target_prices=inherited_target_prices,
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}"
) )
except ValueError:
res.result.SetResult(ErrorType.QUOTATION_TARGET_PRICE_UNAVAILABLE) res.result.SetResult(ErrorType.QUOTATION_TARGET_PRICE_UNAVAILABLE)
return res return res
# ② 앵커가 산출 — 칸(items.company_id × supplier_items.supply_type × 목표가 구간) anchoring_value 조회 후 # ② 앵커가 산출 — 칸별 anchoring_value 조회 후 정수 연산으로 박제(상세는 _resolve_anchors).
# 정수 연산으로 박제(앵커링 v1.2, 인수인계.md §1.3). 매핑 미지정/조정 이력 없음/조회 실패는 anchors = await self._resolve_anchors(item_ids, supplier_ids, target_prices)
# 정적 테이블 시작값 폴백 — 값 조회 때문에 견적 생성이 실패하지 않는다(규칙 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),
)
session_objs = [] session_objs = []
for iid in item_ids: for iid in item_ids:
tp = target_prices[iid] tp = target_prices[iid]
price_range = calc_price_range_index(tp)
company = item_companies.get(iid)
for sid in supplier_ids: for sid in supplier_ids:
supply_type = supply_types.get((iid, sid)) value, ap = anchors[(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원 내림 정확성)
session_objs.append( session_objs.append(
sessions( sessions(
session_id=uuid.uuid4(), 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: async def stop_quotation(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_Quotation:
"""[프론트] 수동 견적마감. 크론과 똑같은 마감 판정(close_and_decide)을 탄다 """[프론트] 수동 견적마감. 크론과 똑같은 마감 판정(close_and_decide)을 탄다
(단독낙찰 확정 / 동가·미참여면 다음 라운드 재생성 / 거부·한도면 그냥 마감).""" (낙찰 확정 / 그 외 전부 개찰 — 낙찰자 미정 마감. 재생성은 별도 수동 API)."""
res = Res_Quotation() res = Res_Quotation()
qt_uuid = uuid.UUID(qt_id) qt_uuid = uuid.UUID(qt_id)
@ -995,10 +1016,11 @@ class QuotationService:
"""targets [(session, supplier_name, email)] 에 초청 메일 발송. res.sent/failed 를 채우고 """targets [(session, supplier_name, email)] 에 초청 메일 발송. res.sent/failed 를 채우고
성공한 session_id 목록을 반환. ACS/SMTP 미설정이면 첫 발송에서 중단(EMAIL_NOT_CONFIGURED).""" 성공한 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, 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 = [] sent_ids = []
for sess, sp_name, email in targets: for sess, sp_name, email in targets:
subject, html, text = build_invite_email( subject, html, text = build_invite_email(

View File

@ -5,7 +5,7 @@
마감을 자동으로 돌리는 크론 잡이 2개 있다(scheduler/jobs.py): 마감을 자동으로 돌리는 크론 잡이 2개 있다(scheduler/jobs.py):
· 잡① close_expired_quotations : 마감시각(end_time)이 지났는데 아직 안 닫힌 견적을 닫는다. · 잡① close_expired_quotations : 마감시각(end_time)이 지났는데 아직 안 닫힌 견적을 닫는다.
· 잡② close_negotiated_quotations : 참여 세션이 전부 끝난(협상 종료) 견적을 닫는다. · 잡② close_negotiated_quotations : 참여 세션이 전부 끝난(협상 종료) 견적을 닫는다.
두 잡 모두, 고른 견적마다 close_and_decide() 를 불러 결과(낙찰 / 다음 라운드 재생성 / 그냥 마감)를 정한다. 두 잡 모두, 고른 견적마다 close_and_decide() 를 불러 결과(낙찰 / 그 외 개찰)를 정한다.
이 파일은 그 두 잡이 (1) 마감할 견적을 올바로 고르는지, (2) 마감 결과가 맞는지 확인한다. 이 파일은 그 두 잡이 (1) 마감할 견적을 올바로 고르는지, (2) 마감 결과가 맞는지 확인한다.
잡에는 HTTP 엔드포인트가 없어 scheduler.jobs 함수를 직접 부른다(앱과 같은 DB 연결을 써서 mock 불필요). 잡에는 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): async def test_award_single_lowest(clean):
"""검증: 두 공급사가 각각 100·200 으로 협상완료(DONE)한, 마감시각 지난 견적을 잡①로 마감. """검증: 두 공급사가 각각 100·200 으로 협상완료(DONE)한, 마감시각 지난 견적을 잡①로 마감.
기대결과: 마감(CLOSED)되고, 더 싼 100 공급사가 단독 낙찰(낙찰 있음 + 낙찰자=그 공급사).""" 기대결과: 마감(CLOSED)되고, 더 싼 100 공급사가 낙찰(낙찰 있음 + 낙찰자=그 공급사)."""
engine = clean engine = clean
qt = await _add_quotation(engine, end_time=PAST) qt = await _add_quotation(engine, end_time=PAST)
winner = uuid.uuid4() winner = uuid.uuid4()

View File

@ -7,7 +7,7 @@ import { useListSuppliers } from '@/api/generated/supplier/supplier';
import { useListCards } from '@/api/generated/card/card'; import { useListCards } from '@/api/generated/card/card';
import { mapCardData } from '@/features/cards/types'; import { mapCardData } from '@/features/cards/types';
import { CONDITION_VARIABLE, CONDITION_LABEL } from '@/features/cards/editor/variables'; 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 { Button } from '@/components/ui/button';
import { Typography, typographyVariants } from '@/components/ui/typography'; import { Typography, typographyVariants } from '@/components/ui/typography';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -253,14 +253,9 @@ export function QuotationCreateModal({
const d = cardDetails.get(id); const d = cardDetails.get(id);
return { id, code: d?.code ?? '', title: d?.title ?? id, isWildcard: d?.isWildcard ?? false }; 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가가 유일한 후보 → 필수가 된다. // 상품에 산정 후보가 있는지(인터넷=공통, 매입·판매=재 한정). 없으면 MD가가 유일한 후보 → 필수가 된다.
const mdNum = Number(mdPrice) || 0; const mdNum = Number(mdPrice) || 0;
const hasItemCandidate = purchaseOnly const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null));
? purchase != null
: internetLowest != null || (isReType && (purchase != null || selling != null));
const mdRequired = !!productId && !hasItemCandidate; const mdRequired = !!productId && !hasItemCandidate;
// 목표가 산정 가능 여부: MD가가 있으면 무조건 OK. 없으면 상품 후보 중 하나라도 있어야. // 목표가 산정 가능 여부: MD가가 있으면 무조건 OK. 없으면 상품 후보 중 하나라도 있어야.
const targetReady = mdNum > 0 || hasItemCandidate; const targetReady = mdNum > 0 || hasItemCandidate;
@ -268,13 +263,11 @@ export function QuotationCreateModal({
const margin = parsePercent(selectedSetting?.target_margin); const margin = parsePercent(selectedSetting?.target_margin);
const targetCandidates = mdNum > 0 const targetCandidates = mdNum > 0
? [mdNum] ? [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,
internetLowest != null ? internetLowest * (1 - INTERNET_AVERAGE_FEE) : null, !isReType || selling == null ? null : selling * (1 - margin),
!isReType || purchase == null ? null : purchase, ].filter((v): v is number => v != null && v > 0);
!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 estimatedTargetPrice = targetCandidates.length ? Math.trunc(Math.min(...targetCandidates)) : null;
const targetPriceLimit = unitPrice != null && unitPrice > 0 const targetPriceLimit = unitPrice != null && unitPrice > 0
? unitPrice * TARGET_PRICE_UNIT_LIMIT_MULTIPLIER ? unitPrice * TARGET_PRICE_UNIT_LIMIT_MULTIPLIER
@ -499,11 +492,11 @@ export function QuotationCreateModal({
상품 상세에서 수정 상품 상세에서 수정
</button> </button>
</div> </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: internetLowest, show: true },
{ label: '매입가', value: purchase, show: purchaseOnly || isReType }, { label: '매입가', value: purchase, show: isReType },
{ label: '판매가', value: selling, show: !purchaseOnly && isReType }, { label: '판매가', value: selling, show: isReType },
] ]
.filter((r) => r.show) .filter((r) => r.show)
.map((r) => ( .map((r) => (
@ -530,7 +523,7 @@ export function QuotationCreateModal({
</div> </div>
{!targetReady && ( {!targetReady && (
<Typography as="p" variant="small" className="text-rose-600 leading-snug"> <Typography as="p" variant="small" className="text-rose-600 leading-snug">
⚠ MD 제시가도 없고 {purchaseOnly ? '상품에 매입가가' : '상품에 산정할 값이'} 없습니다 — MD가를 입력하거나 위 ‘상품 상세에서 수정’으로 값을 채워야 목표가가 나옵니다. ⚠ MD 제시가도 없고 상품에 산정할 값이 없습니다 — MD가를 입력하거나 위 ‘상품 상세에서 수정’으로 값을 채워야 목표가가 나옵니다.
</Typography> </Typography>
)} )}
{targetLimitExceeded && ( {targetLimitExceeded && (

View File

@ -82,28 +82,17 @@ export function TargetPriceModal({
<div className="mt-4 bg-muted/40 border border-border rounded p-3 space-y-1"> <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="font-bold text-foreground text-[11px]">목표가 선정방식</Typography>
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">1. MD 입력값 존재 시, 최우선 적용</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. 다음 중 가장 작은 값 — {[
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">2. 매입가 × (1−{label('target_margin')})</Typography> !hiddenPrice.includes('internet_lowest_price') && '인터넷최저가×(1−수수료)',
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground"> !hiddenPrice.includes('purchase_price') && '매입가',
* {label('target_margin')}: {bd.margin} !hiddenPrice.includes('selling_price') && `판매가×(1−${label('target_margin')})`,
</Typography> ].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 as="p" variant="small" className="text-[11px] text-muted-foreground"> </Typography>
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 && ( {bd.is_inherited && (
<Typography as="p" variant="small" className="text-[10px] text-amber-600"> <Typography as="p" variant="small" className="text-[10px] text-amber-600">
* 저장된 목표가와 일치하는 현재 후보 없음 — 재생성 상속 또는 산정 후 상품·세팅 변경(아래 후보는 현재값 기준 참고용) * 저장된 목표가와 일치하는 현재 후보 없음 — 재생성 상속 또는 산정 후 상품·세팅 변경(아래 후보는 현재값 기준 참고용)

View File

@ -18,7 +18,7 @@ export type CompanySettings = {
email_header?: string; // 초청 메일 헤더 문구 (기본 NEGODATA) email_header?: string; // 초청 메일 헤더 문구 (기본 NEGODATA)
}; };
labels?: Record<string, string>; // 카탈로그 키 → 이 회사 용어 (없으면 기본값) labels?: Record<string, string>; // 카탈로그 키 → 이 회사 용어 (없으면 기본값)
features?: Record<string, unknown>; // 회사별 동작 플래그 (예: target_price_mode) features?: Record<string, unknown>; // 회사별 동작 플래그 (현재 사용처 없음 — 새 플래그 추가 시 여기서 읽는다)
item_fields?: CustomFieldDef[]; // 상품 커스텀필드 정의 → items.custom item_fields?: CustomFieldDef[]; // 상품 커스텀필드 정의 → items.custom
supplier_fields?: CustomFieldDef[]; // 협력사 커스텀필드 정의 → suppliers.custom supplier_fields?: CustomFieldDef[]; // 협력사 커스텀필드 정의 → suppliers.custom
session_fields?: CustomFieldDef[]; // 협상완료 부가정보 정의 → sessions.custom (공급사가 타결 후 입력) session_fields?: CustomFieldDef[]; // 협상완료 부가정보 정의 → sessions.custom (공급사가 타결 후 입력)