[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]:
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,

View File

@ -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(소유 회사) 매핑. 앵커링 칸(회사×유형×가격구간) 해석 입력."""

View File

@ -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

View File

@ -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)

View File

@ -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(

View File

@ -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()

View File

@ -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 && (

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">
<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">
* 저장된 목표가와 일치하는 현재 후보 없음 — 재생성 상속 또는 산정 후 상품·세팅 변경(아래 후보는 현재값 기준 참고용)

View File

@ -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 (공급사가 타결 후 입력)