From 7b612824beadfab43bc2d5a1d33288000cb43dbb Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 27 Jul 2026 16:07:21 +0900 Subject: [PATCH] =?UTF-8?q?[refactor]=20negodata:=20=EB=AA=A9=ED=91=9C?= =?UTF-8?q?=EA=B0=80=20=EC=82=B0=EC=A0=95=20=EC=A0=95=EB=A6=AC=20=E2=80=94?= =?UTF-8?q?=20purchase=5Fonly=20=EC=A3=BD=EC=9D=80=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0(=EB=B0=B1+=ED=94=84=EB=A1=A0=ED=8A=B8)=C2=B7?= =?UTF-8?q?=EC=82=B0=EC=A0=95=20=EC=9E=AC=EB=A3=8C=20=EB=A1=9C=EB=8D=94=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=ED=99=94=C2=B7=EC=9E=90=EB=8F=99=EC=9E=AC?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=9E=94=EC=9E=AC=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- negodata/backend/crud/dashboard_crud.py | 2 +- negodata/backend/crud/quotation_crud.py | 40 +-- .../backend/router/v1/quotation/protocol.py | 1 - negodata/backend/scheduler/jobs.py | 8 +- .../backend/services/quotation_service.py | 314 ++++++++++-------- negodata/backend/tests/test_scheduler.py | 4 +- .../components/QuotationCreateModal.tsx | 31 +- .../QuotationDetailSheet/TargetPriceModal.tsx | 33 +- .../front/src/features/settings/catalog.ts | 2 +- 9 files changed, 209 insertions(+), 226 deletions(-) diff --git a/negodata/backend/crud/dashboard_crud.py b/negodata/backend/crud/dashboard_crud.py index 965eb5c..5d43fda 100644 --- a/negodata/backend/crud/dashboard_crud.py +++ b/negodata/backend/crud/dashboard_crud.py @@ -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, diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py index 0dd50c9..a851edb 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -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(소유 회사) 매핑. 앵커링 칸(회사×유형×가격구간) 해석 입력.""" diff --git a/negodata/backend/router/v1/quotation/protocol.py b/negodata/backend/router/v1/quotation/protocol.py index 16225f9..3af9e60 100644 --- a/negodata/backend/router/v1/quotation/protocol.py +++ b/negodata/backend/router/v1/quotation/protocol.py @@ -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 diff --git a/negodata/backend/scheduler/jobs.py b/negodata/backend/scheduler/jobs.py index 08776d9..94e611d 100644 --- a/negodata/backend/scheduler/jobs.py +++ b/negodata/backend/scheduler/jobs.py @@ -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) diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index 971447c..4b63fab 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -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( diff --git a/negodata/backend/tests/test_scheduler.py b/negodata/backend/tests/test_scheduler.py index 4249320..f754446 100644 --- a/negodata/backend/tests/test_scheduler.py +++ b/negodata/backend/tests/test_scheduler.py @@ -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() diff --git a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx index 425c510..15aee39 100644 --- a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx @@ -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({ 상품 상세에서 수정 -
+
{[ - { 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({
{!targetReady && ( - ⚠ MD 제시가도 없고 {purchaseOnly ? '상품에 매입가가' : '상품에 산정할 값이'} 없습니다 — MD가를 입력하거나 위 ‘상품 상세에서 수정’으로 값을 채워야 목표가가 나옵니다. + ⚠ MD 제시가도 없고 상품에 산정할 값이 없습니다 — MD가를 입력하거나 위 ‘상품 상세에서 수정’으로 값을 채워야 목표가가 나옵니다. )} {targetLimitExceeded && ( diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx index 2eb506a..00aec8f 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx @@ -82,28 +82,17 @@ export function TargetPriceModal({
목표가 선정방식 1. MD 입력값 존재 시, 최우선 적용 - {/* 설명 문구는 회사 설정(목표가 모드·숨김 필드)에 따라 실제 후보와 일치하게 바꾼다. */} - {bd.target_price_mode === 'purchase' ? ( - <> - 2. 매입가 × (1−{label('target_margin')}) - - * {label('target_margin')}: {bd.margin} - - - ) : ( - <> - - 2. 다음 중 가장 작은 값 — {[ - !hiddenPrice.includes('internet_lowest_price') && '인터넷최저가×(1−수수료)', - !hiddenPrice.includes('purchase_price') && '매입가', - !hiddenPrice.includes('selling_price') && `판매가×(1−${label('target_margin')})`, - ].filter(Boolean).join(' | ')} - - - * 인터넷 평균 수수료: {bd.fee} · {label('target_margin')}: {bd.margin}{bd.is_new ? ' · 신규견적이라 인터넷최저가만 적용' : ''} - - - )} + {/* 설명 문구는 회사 설정(숨김 필드)에 따라 실제 후보와 일치하게 바꾼다. */} + + 2. 다음 중 가장 작은 값 — {[ + !hiddenPrice.includes('internet_lowest_price') && '인터넷최저가×(1−수수료)', + !hiddenPrice.includes('purchase_price') && '매입가', + !hiddenPrice.includes('selling_price') && `판매가×(1−${label('target_margin')})`, + ].filter(Boolean).join(' | ')} + + + * 인터넷 평균 수수료: {bd.fee} · {label('target_margin')}: {bd.margin}{bd.is_new ? ' · 신규견적이라 인터넷최저가만 적용' : ''} + {bd.is_inherited && ( * 저장된 목표가와 일치하는 현재 후보 없음 — 재생성 상속 또는 산정 후 상품·세팅 변경(아래 후보는 현재값 기준 참고용) diff --git a/negodata/front/src/features/settings/catalog.ts b/negodata/front/src/features/settings/catalog.ts index 13e8bc4..25281d6 100644 --- a/negodata/front/src/features/settings/catalog.ts +++ b/negodata/front/src/features/settings/catalog.ts @@ -18,7 +18,7 @@ export type CompanySettings = { email_header?: string; // 초청 메일 헤더 문구 (기본 NEGODATA) }; labels?: Record; // 카탈로그 키 → 이 회사 용어 (없으면 기본값) - features?: Record; // 회사별 동작 플래그 (예: target_price_mode) + features?: Record; // 회사별 동작 플래그 (현재 사용처 없음 — 새 플래그 추가 시 여기서 읽는다) item_fields?: CustomFieldDef[]; // 상품 커스텀필드 정의 → items.custom supplier_fields?: CustomFieldDef[]; // 협력사 커스텀필드 정의 → suppliers.custom session_fields?: CustomFieldDef[]; // 협상완료 부가정보 정의 → sessions.custom (공급사가 타결 후 입력)