# 협상 기준가 회사별 선택 — 변경 내역과 검증 보고서 작성 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% 높은`.