어느 가격이 "공급사에 지불하는 단가"인지는 회사마다 달라, 컬럼을 합치는 대신 회사가 고르게 했다.
판정은 settings.features.nego_baseline_field 1순위, 미설정 회사는 price 만 숨겼으면 purchase_price 폴백.
판정식 정본은 negodata/backend/common/nego_baseline.py (agent·negosium backend 가 같은 규칙 미러).
- negodata front: 회사 설정에 협상 기준가 라디오(선택지마다 실제 나갈 문장 미리보기 + 학습 경고),
'공급사 포털 안내' 탭 신설(협상 유의사항·헬프데스크 연락처), 용어 카탈로그에 target_price·supplier 추가.
인터넷 최저가는 숨김 대상에서 제외(신규 견적의 유일한 목표가 후보).
상품 등록 기본값에서 개발용 더미 제거(price 1,000,000·PROD-BAT-###·800,000·대한민국·10 EA·14).
- agent: get_item_price → get_item_baseline (기준가·호칭·회사 용어사전을 한 쿼리로),
"기존 공급가 대비" 하드코딩을 회사 용어로 치환 + 받침 기준 조사 자동 보정,
협상 스크립트 4종의 협력사·목표가·배송형태 용어를 {label_*} 토큰화, input_options 도 변수 치환 적용.
- negosium: 협상 화면 기준 단가·배송형태 라벨을 회사 설정 기준으로, 유의사항 본문과 헬프데스크 연락처를
하드코딩에서 회사 설정으로(미등록 시 영역 숨김). 유의사항의 VAT·배송비 문구 삭제(IMK 0803 ⑥).
- negodata backend: LPS 검색 가격 힌트를 items.price 고정에서 기준가 규칙으로.
검증: 기준가 설정 3 × 숨김 4 = 12조합에서 협상 멘트·포털 표시·LPS 힌트가 전부 일치.
agent 테스트 176건 통과. 보고서 negodata/docs/nego-baseline-verification.md
15 KiB
협상 기준가 회사별 선택 — 변경 내역과 검증 보고서
작성 2026-08-05. 대상 = IMK 0803 요청 ⑥(유의사항·헬프데스크)·⑦(공급가–매입가 일원화).
핵심은 협상 기준가(인하율 멘트의 분모이자 RL 가격 수용률의 기준가)를 회사가 고르게 한 것이다.
매입해서 되파는 회사는 공급가(items.price)가, 매입만 하는 회사는 매입가(items.purchase_price)가
실제 지불 단가이므로, 컬럼을 합치는 대신 어느 컬럼을 쓸지 회사 설정으로 지정한다.
1. 판정 규칙
1순위 settings.features.nego_baseline_field ('price' | 'purchase_price')
2순위 price 만 hidden_fields 에 있으면 → purchase_price (설정 화면 이전 회사 안전망)
기본 → price
같은 규칙을 세 앱이 쓴다. 판정식을 바꿀 때는 반드시 세 곳을 같이 고친다.
| 앱 | 위치 |
|---|---|
| negodata backend | common/nego_baseline.py resolve_baseline_field (정본) |
| agent | negotiation/chat/infra/repository/nego_context_crud.py _resolve_baseline |
| negosium backend | services/chat_service.py chat_init 내 인라인 판정 |
2. 회사 유형별 설정 방법
어느 가격이 "우리가 공급사에 지불하는 단가"인지는 회사마다 다르다. 그래서 컬럼을 합치지 않고
회사가 고르게 했다. 설정 위치는 전부 negodata → 회사 설정(/settings, 최고관리자 전용).
유형 A — 매입만 하는 회사 (사서 쓰고, 되팔지 않음)
관리하는 가격이 매입가 하나뿐인 회사.
| 탭 | 설정 |
|---|---|
| 커스텀 필드 → 협상 기준가 | 매입가 선택 |
| 커스텀 필드 → 상품 필드 숨김 | 상품 단가(공급가)·판매가 체크 |
| 용어(라벨) | 필요하면 item.purchase_price 를 자기 용어로(예: 구매단가) |
결과 — 상품 등록·엑셀 양식에 매입가 칸만 남고, 협상 멘트는 "기존 매입가 대비 N% 인하", 인터넷 최저가 검색 힌트도 매입가로 나간다. 목표가는 인터넷최저가·매입가 후보로 산정된다.
유형 B — 매입해서 되파는 회사 (유통·구매대행)
공급사에서 사서(매입가) 고객사에 넘기는(공급가) 회사. 기본값이라 아무것도 안 해도 된다.
| 탭 | 설정 |
|---|---|
| 커스텀 필드 → 협상 기준가 | 상품 단가 선택 (미설정 시 기본값) |
| 커스텀 필드 → 상품 필드 숨김 | 안 씀 |
| 용어(라벨) | 필요하면 item.price 를 자기 용어로(IMK 는 "공급가") |
결과 — 공급가가 협상 출발점, 매입가는 재견적 목표가 후보로 계속 쓰인다.
공통 주의
- 기준가로 고른 필드는 숨기지 말 것. 숨기면 신규 상품 등록 화면에 그 칸이 없어 값이 비고, 인하율 멘트가 통째로 사라진다. 설정 화면이 그 조합을 고르면 경고를 띄운다.
- 인터넷 최저가는 숨길 수 없다. 신규 견적의 유일한 목표가 후보라 숨김 목록에서 제외했다.
- 협상 이력이 쌓인 뒤에는 바꾸지 말 것. 기준가는 RL 가격 수용률의 분모라 학습 상태 인덱스에 들어간다. 바꾸면 Q테이블에 두 기준이 섞이고 되돌려도 복구되지 않는다. 회사 온보딩 때 정한다.
- 용어를 바꾸면 협상 멘트 호칭도 같이 바뀐다. 용어 탭의
item.price/item.purchase_price라벨이 그대로 공급사에게 나가는 문장에 쓰인다(조사는 받침에 맞춰 자동 보정).
현재 IMK 설정
features.nego_baseline_field = "purchase_price" → 매입가 기준 (유형 A)
hidden_fields = [made_in, delivery_fee_yn, selling_price, vat_yn, price]
labels = { "item.price": "공급가", … }
3. 수정한 곳
agent (협상 엔진)
| 파일 | 내용 |
|---|---|
negotiation/chat/infra/repository/nego_context_crud.py |
_ITEMS 에 purchase_price·company_id 추가, _COMPANIES 테이블 신설. get_item_price → get_item_baseline 로 교체 — items⋈companies 한 쿼리로 (기준가, 호칭, 회사 용어사전) 반환. _resolve_baseline 판정 함수 |
negotiation/chat/service/negotiation_context_loader.py |
NegotiationDbContext 에 item_price_label·labels 추가 (세션 시작 시 박제) |
services/chat_service.py |
세션 컨텍스트에 item_price_label·labels 적재. 데모 경로 폴백 _DEFAULT_ITEM_PRICE_LABEL = "상품 단가" |
negotiation/chat/service/chat_engine.py |
discount_phrase 3분기의 "공급가" 하드코딩 → 컨텍스트 호칭. _SCRIPT_LABELS 용어 토큰, _josa/_has_batchim 조사 자동 보정. input_options 도 변수 치환을 타게 수정(안 고쳤으면 {label_delivery_type_1} 토큰이 사용자에게 노출) |
negotiation/chat/service/script_naturalizer.py |
docstring 문구 |
tenants/_base/resources/scripts_renegotiation.json |
"협력사 간"·"협력사 포털"·"협력사 관리 시스템" → {label_supplier} |
tenants/_base/resources/scripts_requote.json |
위 + "신규 공급사를 선정"·"공급사 선정에 반영"·"배송 형태를 선택"·배송 보기 3개 |
tenants/_base/resources/scripts_cards.json |
"목표 매입가" ×2 → {label_target_price}, "다른 협력사들의" → {label_supplier}. 4번 카드의 {discount_rate}% 수치 인용 제거(기준가 없을 때 "인하율 약 0.0%는 의미 있는 진전" 모순 방지) |
tenants/_base/resources/scripts_wildcard.json |
"목표 매입가는" → {label_target_price} |
tests/test_context_loader.py |
더블·단언을 새 시그니처로 |
negosium backend (공급사 포털 API)
| 파일 | 내용 |
|---|---|
services/chat_service.py |
협상 화면 item_price 를 기준가 규칙으로. 요약의 배송형태 라벨을 DeliveryType.label_of() 하드코딩 대신 회사 용어 우선 |
services/auth_service.py |
me()·session_branding() 에 헬프데스크·유의사항 전달 |
router/v1/auth/protocol.py |
Res_Me.guide_notices, Res_SessionBranding.helpdesk |
negodata backend
| 파일 | 내용 |
|---|---|
common/nego_baseline.py |
신규. 기준가 판정 정본 (resolve_baseline_field / resolve_baseline_price) |
services/lps_sync_service.py |
인터넷 최저가 검색의 가격 힌트를 items.price 고정 → 기준가 규칙. 호출부가 설정을 안 넘기면 상품의 소속 회사 설정을 직접 조회 |
negodata front (어드민)
| 파일 | 내용 |
|---|---|
features/settings/catalog.ts |
features.nego_baseline_field 타입·선택지, branding.helpdesk, guide_notices, DEFAULT_GUIDE_NOTICES. 용어 카탈로그에 target_price·supplier 추가. 숨김 가능 목록에서 internet_lowest_price 제외(신규 견적의 유일한 목표가 후보) |
features/settings/SettingsView.tsx |
공급사 포털 안내 탭 신설(협상 유의사항·헬프데스크). 커스텀 필드 탭 최상단에 협상 기준가 라디오(NegoBaselinePicker) — 선택지마다 실제로 나갈 문장 미리보기 + 학습 데이터 경고. LineListEditor. 저장·JSON 병합 경로에 features·guide_notices 반영 |
features/products/components/ProductFormSheet.tsx |
신규 등록 기본값에서 개발용 더미 제거 — code: PROD-BAT-###·price: 1,000,000·minPrice: 800,000·origin: 대한민국·moq: 10 EA·leadTime: 14 → 빈 값/0. 선택형(단위·배송형태·부가세)만 유지 |
negosium front (공급사 포털 화면)
| 파일 | 내용 |
|---|---|
apis/auth/auth.type.ts |
Branding.helpdesk, MeResponse.guide_notices, AuthUser.guideNotices |
features/auth/hooks/usePreLoginBranding.ts |
로그인 전 헬프데스크 수신 |
features/chat/components/menu/Contact.tsx |
하드코딩 연락처 → 회사 설정, 미등록이면 섹션 숨김 |
features/chat/components/popup/GuideContent.tsx |
유의사항 불릿 5개 하드코딩 → 회사 설정(미설정 시 기본 문구). VAT·배송비 불릿 삭제(IMK ⑥ 요청) |
pages/LoginPage.tsx |
하드코딩 연락처 → 회사 설정 |
4. 경우의 수 검증 — 12조합 × 3경로
상품 = 산업용 베어링 6204 (공급가 8,900 / 매입가 7,200), 공급사 제시가 8,000. agent·negosium backend·negodata backend 를 각각 실제로 호출해 측정.
| 기준가 설정 | 숨김 | 포털 표시 | LPS 가격 힌트 | 협상 멘트 |
|---|---|---|---|---|
| 미설정 | 없음 | 8,900 | price=8900 |
기존 상품 단가 대비 약 10.1% 인하 |
| 미설정 | price |
7,200 | purchase_price=7200 |
기존 매입가(7200원)보다 약 11.1% 높은 |
| 미설정 | purchase_price |
8,900 | price=8900 |
기존 상품 단가 대비 약 10.1% 인하 |
| 미설정 | 둘 다 | 8,900 | price=8900 |
기존 상품 단가 대비 약 10.1% 인하 |
=price |
없음 | 8,900 | price=8900 |
기존 상품 단가 대비 약 10.1% 인하 |
=price |
price |
8,900 | price=8900 |
기존 상품 단가 대비 약 10.1% 인하 |
=price |
purchase_price |
8,900 | price=8900 |
기존 상품 단가 대비 약 10.1% 인하 |
=price |
둘 다 | 8,900 | price=8900 |
기존 상품 단가 대비 약 10.1% 인하 |
=purchase |
없음 | 7,200 | purchase_price=7200 |
기존 매입가(7200원)보다 약 11.1% 높은 |
=purchase |
price |
7,200 | purchase_price=7200 |
기존 매입가(7200원)보다 약 11.1% 높은 |
=purchase |
purchase_price |
7,200 | purchase_price=7200 |
기존 매입가(7200원)보다 약 11.1% 높은 |
=purchase |
둘 다 | 7,200 | purchase_price=7200 |
기존 매입가(7200원)보다 약 11.1% 높은 |
12/12 세 경로가 같은 값을 쓴다. 어긋나는 조합 없음.
- 명시 설정이 항상 이긴다 —
=price인데price를 숨겨도 8,900 - 숨김 폴백은
price만 숨겼을 때만 발동 - 둘 다 숨겨도
price로 폴백해 협상이 안 깨진다
용어·조사 검증
| 조건 | 출력 |
|---|---|
용어 미설정 + price |
기존 상품 단가 대비 약 3.2% 인하된 금액입니다 |
item.price="공급가" |
기존 공급가 대비 … |
item.purchase_price="기준매입단가" |
기존 기준매입단가 대비 약 4.2% 인하된 금액입니다 |
item.purchase_price="기준값"(받침) + 제시가=기준가 |
기존 기준값과 동일한 수준의 금액입니다 |
| 기준가 컬럼 NULL | 인하율 문장 생략(0원 대비 계산 안 나감) |
용어를 전부 바꾼 회사(supplier=공급업체, target_price=목표단가)로 전 흐름 실행:
"본 서비스는 아이마켓코리아와 공급업체 간 물품 공급 가격 협상을 위한 것으로…"
"본 안내는 공급업체 포털에 등록된 담당자에게 발송되었습니다."
"아이마켓코리아는 아래 상품에 대해 신규 공급업체를 선정하고 있으며…"
배송형태선택 → options: ['직납', 'IMK물류(배송)', 'IMK물류(집배송)']
목표가 산정 (숨김 축, 기준가 설정과 무관)
인터넷최저가 8,500 / 매입가 7,200 / 판매가 9,800 · 수수료 7.8% · 네고율 12%
| 숨김 | 신규 | 재견적 |
|---|---|---|
| 없음 | 7,840 | 6,340 |
price |
7,840 | 6,340 |
purchase_price |
7,840 | 7,840 (가장 싼 후보가 빠짐) |
| 둘 다 | 7,840 | 7,840 |
price 는 원래 목표가 후보가 아니라 숨겨도 무영향.
포털 API 실측 (GET /v1/negotiation/sessions/{id}/chat/init)
global 계정 로그인 후 4조합 확인 — 위 표의 포털 열이 그 결과. 회사 용어 12개, 배송형태 라벨(직납) 정상 전달.
자동 테스트
agent 176건 전부 통과. (테스트가 learning 스키마를 TRUNCATE 하므로 백업 후 실행·복원)
5. negodata / negosium 영향 — 문제 있는 곳
negodata (어드민)
| 화면 | 설정에 따라 달라지는 것 | 문제 |
|---|---|---|
| 회사 설정 | 협상 기준가 라디오·상품 필드 숨김·용어·포털 안내 탭 | 없음 |
| 상품 목록·등록·엑셀 양식 | 숨긴 필드가 세 곳에서 동시에 빠짐 | 없음 |
| 엑셀 업로드 | 숨긴 열은 파일에 있어도 읽지 않고 무시(양식 생성과 파싱이 같은 컬럼 정의 공유) | 없음 |
| 견적 생성 | 목표가 후보에서 숨긴 가격 제외. 후보가 없으면 프론트가 생성 차단 | 없음 |
| 인터넷 최저가 검색 | 가격 힌트가 기준가 규칙을 따름 | 없음 |
| 통계 | 목표가·낙찰가·앵커가 기반이라 무관 | 없음 |
negosium (공급사 포털·챗)
| 화면 | 설정에 따라 달라지는 것 | 문제 |
|---|---|---|
| 협상 챗 멘트 | "기존 OO 대비 N% 인하" 의 분모와 호칭 | 없음 |
| 협상 화면 상단 | 기준 단가 표시값 | 없음 |
| 협상 카드 멘트 | 회사 용어({label_supplier}·{label_target_price} 등) 치환 |
없음 |
| 배송형태 선택지 | 회사 용어로 치환(IMK: 직납·IMK물류) | 없음 |
| 유의사항 팝업 | 회사 설정 항목, 미설정 시 기본 문구 | 없음 |
| 헬프데스크 | 회사 설정 연락처, 미등록 시 영역 숨김 | 없음 |
| 로그인 화면 | 로그인 전에도 회사 헬프데스크 노출 | 없음 |
안 바뀌는 곳
목표가 산정(기준가 설정과 무관) · 앵커링가 · 앵커링 학습(축 = 회사·협력사유형·가격대) · 통계 전부 · 진행 중인 협상(세션 시작 시 기준가를 박제하므로 라운드 중간에 안 바뀜).
유일하게 되돌릴 수 없는 것
RL 가격 수용률 — 학습 상태 인덱스에 들어가므로, 협상 이력이 쌓인 뒤 기준가를 바꾸면 Q테이블에 두 기준이 섞이고 설정을 되돌려도 복구되지 않는다. 온보딩 때 정한다.
6. 확인하지 않은 것
- 설정 화면 렌더를 눈으로 보지 않았다. tsc·eslint 통과. 다만 관리자가 그 화면에서 저장한 값이
DB 에 정상 반영된 것으로 렌더·저장 경로는 실증됐다(
features·hidden_fields·guide_notices). - negodata backend 테스트 미실행. 컨테이너에 pytest 미설치이고
negosium_test_db가 없어 dev DB 를 truncate 할 위험이 있어 돌리지 않았다. 다만 이번에 바꾼common/nego_baseline.py(신규)·lps_sync_service.py를 호출하는 기존 테스트는 없다(test_scheduler.py가 잡 이름만 확인). global계정 비밀번호를 dev DB 에서1234로 리셋했다.
오해였던 것 (기록)
- 검증 중
learning스키마 행이 늘어 "테스트 협상이 학습을 오염시켰다"고 봤으나, 실제로는 pytest 산출물이었다. 학습 데이터의company_id는 테스트마다 만든 랜덤 UUID 42개이고 IMK 스코프는 0건이다. - "IMK 매입가에 원가가 들어 있어 협상 멘트가 전부 인상으로 나온다"고 봤으나, 테스트 제시가를 매입가보다
높게 넣어서 생긴 착시였다. 매입가 이하를 제시하면 정상적으로 인하율이 나온다:
7,000 → 2.8% 인하·6,500 → 9.7% 인하·8,000 → 11.1% 높은.