Compare commits

..

149 Commits

Author SHA1 Message Date
69e4240039 [fix] negodata: 협상카드 성공률에 회사 스코프 누락 — 타사 견적 이력이 섞이던 문제
card_success_map 이 quotations 조인 없이 DB 전체 chats 를 카드별로 집계해,
다른 회사 견적에서 쓰인 이력이 내 카드 목록의 used_count/success_rate 로 잡혔다.
통계 화면 '협상카드 성공률 TOP 5' 와 견적 생성 시 카드 추천 순위가 모두 이 값을 쓴다.

- quotations 조인 + 작성자 회사(users.company_id) 스코프 추가
- 소프트 삭제분 제외(sessions.deleted / quotations.deleted 필터 누락분 보강)
2026-08-13 14:06:34 +09:00
8112394aef [feat] landing: 데모 영상 3종 실화면 재촬영(라이브 협상·견적생성·협상결과, 줌인 연출) + 도입문의·데모요청 폼 mailto 임시연결 2026-08-12 17:28:40 +09:00
1a859b198c [fix] negodata: 직접 낙찰 낙찰가가 계약가 대신 거부가로 표시되던 문제
세션 목록 API(queries.py)가 SessionData 를 필드별로 수동 조립하는데 contract_price 를
빠뜨려, 담당자가 입력한 계약가가 응답에 안 실렸다(항상 null). 그 결과 프론트가 계약가를
못 읽고 제출가(거부가/투찰가)로 폴백해, 14,000,000 으로 직접 낙찰해도 낙찰가에 거부가가
떴다. 프로토콜·ORM·컬럼은 이미 있었으나 이 직렬화 한 줄이 누락돼 있었다.

- queries.py: SessionData 조립에 contract_price=r.contract_price 추가
- ResultSummaryBand: 스펙트럼 낙찰점도 제출가(awardPrice)가 아니라 계약가(contractPrice)로 찍어
  레일 낙찰가와 일치시킴

검증: 세션 API 응답에 contract_price 실제 내려오는 것 확인(화면 아닌 응답 직접). negodata 테스트 통과.
2026-08-12 11:02:10 +09:00
d33648327c [fix] landing: 가이드 협상 탭이 참조하는 negotiation.mp4 복원 — v2 머지로 참조가 되살아남 2026-08-12 10:54:46 +09:00
dce2c42d74 Merge remote-tracking branch 'origin/feat/landing-v2' into feature/landing 2026-08-12 10:53:38 +09:00
0e4ddf0f43 [feat] negodata: 낙찰 방식 구분(자동/직접) + 계약가·사유 저장 위치 재정리
직접 낙찰(담당자 오프라인 계약가)을 AI 자동 낙찰과 통계·화면에서 구분하기 위해
낙찰 방식을 전용 컬럼으로 박고, 계약가·사유를 성격대로 배치했다. 처음엔 계약가·사유를
sessions.custom.offline_award(JSONB) 한 뭉치에 넣었으나 성격이 갈려 정리한다.

DB (postgres-init: init.sql 정본 + alters/2026-08-11-award-type.sql 보정, 멱등)
- quotations.award_type SMALLINT — 낙찰 방식(1=자동/2=직접). 통계 조회·집계 축이라 컬럼
- quotations.custom JSONB — 직접 낙찰 사유·처리자·시각(award={reason,by,at}). 표시·감사용
- sessions.contract_price BIGINT — 직접 낙찰 계약가. bid_price/reject_price 와 같은 협력사
  가격 축이라 세션에. 자동 낙찰은 NULL(투찰가가 곧 계약가)
- 기존 offline_award(JSONB) 데이터를 컬럼·견적 custom 으로 이관 후 키 제거

backend
- 자동 낙찰(close_and_decide)=AUTO, 직접 낙찰(claim_for_award)=MANUAL 로 award_type 기록
- 직접 낙찰: 계약가→세션 contract_price, 사유·처리자·시각→견적 custom.award (한 트랜잭션)
- 통계 계약가 = coalesce(contract_price, bid_price) — JSONB 캐스팅 제거(컬럼끼리, 인덱스·타입 안전)
- QuotationData 에 award_type·custom, SessionData 에 contract_price 노출
- 안 쓰게 된 merge_session_custom 제거

front
- 견적 상세 결과 밴드에 '직접 낙찰' 배지 + 낙찰 사유 표시(견적 custom.award.reason).
  거부·미참여 낙찰은 이미 직접 낙찰을 함의하므로 '— 낙찰' 꼬리를 떼 중복 표기 제거
- 목록 결과 배지에 '낙찰(직접)' 표기 — AI 자동낙찰과 한눈에 구분
- offlineAward()→directAwardPrice()/awardMeta() 로 교체(세션 컬럼·견적 custom 에서 읽음)
- 협상현황 표: 부가정보(회사 필드)와 의견(custom.opinion)을 별도 컬럼으로 분리
- 미응찰 건 레일 4번 칸 라벨 '최저 투찰가'→'결과'(투찰 없을 때)

테스트: negodata 110건 통과. 프론트 tsc+eslint 통과. dev DB 적용·화면 확인.
2026-08-12 10:25:26 +09:00
6eb4dc26f8 [feat] negosium·negodata: 협상 거부 흐름 정리 + 오프라인 계약가 낙찰 + 견적 상세 리모델링
협상 불가 사유를 내면 500 이 나고, 거부 폼은 목록·채팅이 따로 놀았으며,
제출한 내용을 다시 볼 방법이 없었다. 결렬 건은 협력사가 낸 거부가를 계약가로
간주해 낙찰시켜 절감 통계가 음수로 뒤집힐 수 있었고, 견적 상세는 판정 가격이
세 곳에 흩어져 대화 탭에선 아예 보이지 않았다.

agent
- 결렬 종료 로깅 크래시 수정 — _log 를 action_id 기반으로 되돌리고 선택 근거
  (Q·UCB·방문수)는 decision/policy 가 있을 때만 채운다. 종료 행은 카드 선택이
  없고 policy.update 뒤라 값을 넣으면 학습 화면 집계가 오염된다

negosium
- 거부 폼을 목록·채팅 공용 컴포넌트 하나로 통일(사유 3종 + 공급 희망가·의견 선택)
- 거부 사유 열람 — 목록에 '거부 사유 보기'(부가정보 보기와 같은 규격), 채팅
  재진입 시 대화 끝에 거부 내역 카드. 목록·채팅 init 응답에 reject_reason·reject_price 추가
- 자유 입력 거부("협상 포기합니다")가 사유 NULL 로 저장되던 문제 수정 — 폼 마커가
  없으면 원문을 사유로 쓰고, 문장 속 숫자를 희망가로 오인하지 않는다
- koreanNumber 를 전역 lib 으로 이동(공용 폼이 쓴다)

negodata
- 직접 낙찰에 계약가 입력 — 결렬·미응찰 건을 오프라인으로 다시 협상한 결과를
  담당자가 확정해 넣는다. 후보는 초청 협력사 전부(가격 미제출도 포함),
  계약가는 sessions.custom.offline_award 에 근거·작성자·시각과 함께 남긴다
- 통계 계약가 = 담당자 확정가 우선, 없으면 투찰가. 거부가를 계약가로 치던 파생 제거.
  KPI 에 오프라인 반영 건수 추가
- 견적 상세 리모델링 — 가격 레일(앵커링가/투찰현황 · 목표가 · 타결 상한가 · 결과가)을
  시트에 고정해 접힘·탭 전환에도 남기고, 스펙트럼에 타결 판정선과 구간색 추가.
  라벨은 폭을 실측해 두 레인으로 배치(겹침 불가). 상품·마감시각 등 전 행 동일 컬럼 제거,
  협상현황에 부가정보 노출, 1:1 은 협력사·세션상태를 결과 밴드로 올림

테스트: negosium 58 · negodata 110 통과. 프론트 빌드/린트 통과.
2026-08-12 10:25:26 +09:00
d02ba20520 [chore] landing: 미사용 파일 정리 — 히어로 시안 2종(console·glassmorphic)·전용 미디어, gif 재생으로 회귀한 negotiation mp4/jpg, 구 히어로 negotiation_annotated 삭제 2026-08-12 10:17:50 +09:00
ac6366a898 [feat] negosium·negodata: 협상 진행 중 거부 + 결렬 건 최종 제출가로 직접 낙찰
협력사가 단종·품절을 대화 도중 알아채도 봇의 결렬 선언을 기다려야 했고,
거부로 끝난 협상은 가격을 남겨도 낙찰 후보에서 빠져 계약으로 이어지지 않았다.

negosium
- 채팅 액션바에 협상 거부 진입점 — 대화가 끝나지 않고 입력을 기다리는 동안만 노출,
  주 CTA 와 붙지 않게 넓은 화면은 우측 끝 고정·좁은 화면은 wrap
- 거부 팝업은 목록 거부 팝업과 같은 어휘·규격, 대화 중이라 공급 희망 가격·의견을 더 받는다
- /reject 에 reject_price·opinion 추가 — sessions.reject_price 저장, 의견은 custom 병합
- 화면 문구 '거절' → '거부' 통일 (버튼·배지·탭·토스트·안내 팝업)

negodata
- 개찰 견적 직접 낙찰 후보 = 가격을 써낸 세션 — 투찰한 협상완료 + 공급 희망가를 남긴 협상거부
- 계약가 파생 _award_price/awardPrice — coalesce(투찰가, 거부 시 공급 희망가)
- 통계 낙찰 세션 조인도 같은 기준 — 안 고치면 거부가로 낙찰한 건이 절감 집계에서 빠진다
- 세션 상태 탭 라벨 '거절사유/거절가격/거절배송방식' → '거부…'

자동 마감 판정(close_and_decide)은 그대로 — 자동 낙찰은 투찰가만 본다.
2026-08-11 10:34:07 +09:00
f1e924931f [fix] negodata: 협상 학습 화면 정리 — 내부 점수 제거·카드탭 사용 현황 전환·메뉴 개발자 전용
- 관측 사실 기준으로 재구성: 내부 점수 제거, 앵커링 % 표기, 탭 URL 분리
- 카드탭을 성과 지표에서 사용 현황으로 전환, 지표 설명 말풍선·카드 상세 링크
- 사이드바 devOnly + 라우트 가드로 개발자 전용 처리
2026-08-11 08:49:15 +09:00
84090433ab [docs] landing: 히어로 CTA 문구 변경 결정 기록 추가
경영진 보고용 문안을 코드 옆에 남긴다. 최초 지시안(협상 예시 보기 · 직접 체험)을
검토 과정에서 수정한 사유와, 최종안(협상 예시 체험 · 실제 데모 받기)이 지시 의도를
어떻게 유지했는지를 기록한다. 이후 CTA 문구 결정 시 참조할 원칙도 남긴다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:09:07 +09:00
e1e519c20c [fix] negosium/backend·front: 마감된 협상이 '협상 대기'로 보이던 문제 + 종료 협상 결과 보기
- 목록 상태를 파생 계산(_effective_status): 견적 마감/마감시간 경과 + 협상생성(1) → 미참여(4).
  표시·상태필터·기본정렬 그룹에 같이 적용해 탭·KPI 카운트가 배지와 어긋나지 않게 함(DB 쓰기 없음).
  마감 일괄정리 이후 생긴 세션이 '협상 대기'에 남아 눌러도 마감 에러가 나던 헛클릭 제거.
- 미참여·협상거부 세션도 채팅 init 허용 → '결과 보기'로 지난 대화 열람. 대화 재개는 send 가
  협상중만 허용해 읽기 전용. 열 게 없는 건은 목록 has_chat(대화 이력 유무)으로 버튼 자체를 감춤.
- 종료 건은 참여 API 를 타지 않고 채팅으로 직행, 채팅 액션바는 목록 복귀만 남겨 입력 잠금.
- chain_max_round 가 단일컬럼 select 결과를 행 튜플로 읽어 매 목록 호출마다 예외 → 체인 최대차수
  0 → 옛 라운드에도 재협상 요청이 뜨고 통과되던 버그 수정(+ABC 에 중복된 죽은 구현 제거).
2026-08-10 13:57:30 +09:00
e816bfbba4 [feat] negodata: 협상 학습 메뉴 신설 — 카드별 학습 성적 · 앵커링 현황/조정 이력
agent(협상카드 강화학습)와 anchoring(구간별 자동 조정)이 쌓아온 결과를 볼 수 있는 화면이
없어 DB 를 직접 열어야 확인이 됐다. 두 축은 성격이 달라 한 화면에 섞지 않고 탭으로 나눈다.

백엔드(읽기 전용 — 값의 주인은 agent·anchoring):
- crud/learning_crud.py: learning·anchoring 스키마 경량 조회(negodata ORM 미매핑 테이블)
- services/learning_service.py + router/v1/learning: /v1/learning/cards · /anchoring

프론트:
- 사이드바 '협상 학습'(/learning) 추가, 탭 = 협상카드 | 앵커링
- 협상카드: 학습 협상·기록·타결 비율 요약 + 카드별 사용/타결/평균 점수(0 기준 좌우 막대)
- 앵커링: 구간별 현재 인하폭 + 조정 이력(표본·성공률·값 변화)

검증: 로컬 실협상 3건으로 데이터 생성 후 두 탭 실화면 확인, tsc·eslint 통과.
2026-08-10 13:24:32 +09:00
37fa65707f [fix] postgres-init: 카드 시드를 운영 현행에 맞춤 — 중반 카드 앵커가 인용 + 목표가 카드 종결 전용
협상 중반에 목표가를 부르면 이후 올려줄 금액이 없어져 남은 카드가 같은 값만 반복한다.
제안은 앵커가 → 중간가 → 목표가로 수렴해야 하므로 시드를 그 순서에 맞춘다.

- NGC-010 향후 거래 연계 · NGC-011 양보 가치 강조: 제시 금액 {target_price} → {anchor_price}
- tactic: WC-01 종결 전용 추가, WC-04 min_round 2 → 종결 전용, WC-05 종결 전용 + min_round 2,
  WC-02 min_round 2 추가 (WC-03 종전 유지)

init-data.sql · dbeaver/4_cards_reset_dbeaver.sql 동일 반영.
검증: 임시 DB 에 1_init + 4_cards_reset 적용 후 tactic·변수 확인.
2026-08-10 11:41:25 +09:00
86d50c1291 [fix] negodata/front: 윈도우 모달 한글 깨짐 — 컨테이너 font-mono 제거 + 모노 스택 한글 폴백
모달 래퍼·폼에 걸린 font-mono 가 내부 한글까지 모노 스택으로 태웠다. JetBrains Mono·Consolas 에
한글 글리프가 없어 Windows 에서 굴림으로 떨어졌다(맥은 뒤쪽 폰트가 한글을 커버해 정상으로 보임).

- 한글이 들어가는 컨테이너 15곳에서 font-mono 제거 — 숫자 정렬이 필요한 칸은 유지
- --font-mono 스택 끝에 'Noto Sans KR' 추가, index.html 에 구글폰트 링크
  (self-host Pretendard 가 못 뜰 때의 안전망 — 정상 경로는 동일 도메인 Pretendard)
2026-08-10 09:49:26 +09:00
436384fa36 [fix] agent: 중간값 카드가 목표가로 제시되던 문제 — 제안가 산식 일원화·절충 카드 목표가 이상 미발동·적용 대기 와일드카드 차단
원인 두 겹.
1) 절충 계열 가드가 타결 상한만 봐서, 중간가((앵커+목표)/2)처럼 구조상 목표가를 넘지 못하는
   값은 항상 통과했다. 앵커가 목표가에 붙으면 그 값이 곧 목표가라 목표가로 제시된다.
   → 파생 제안값이 목표가 이상이면 미발동(compute_offer). 목표가 카드는 영향 없음.
2) 제안가 확정 시 계산에 쓴 당사 직전가를 남기지 않고 그 자리를 제안가로 덮은 뒤, 멘트 렌더가
   갱신된 값으로 재계산해 "당사 A 와 귀사 B 의 절반은 A" 자기모순이 났다. 직전 커밋은 갱신을
   렌더 뒤로 미뤘으나 와일드카드는 렌더 지점이 더 뒤라 그대로 남아 있었다.
   → Offer/record_offer 로 금액과 재료(당사 직전가·협력사 제시가)를 함께 박제하고, vars_for 는
     재계산 대신 그 기록을 읽는다. 렌더 순서에 의존하지 않으므로 직전 커밋의 순서 조정은 제거.
   → 산식은 tactics.OFFER_VARIABLES 한 곳만 남기고 chat_engine 의 복사본 삭제.

곁들여: 카드 설정에서 '적용 대기'로 꺼둔 와일드카드가 견적에 담겨 있으면 그대로 발동하던 것을
차단(wild_cards.available 조건). 테이블 정의에 빠져 있던 컬럼도 보강.

검증: 단위/E2E 35건, 실협상 8시나리오(멘트 토큰 노출·절반 산술 정합·카드 중복·제안가 역행·
상한 초과 자동 검사) 통과.
2026-08-10 09:45:30 +09:00
a405e00bf9 [fix] agent·negodata/front: 중간값 멘트 모순 수정(갑 포지션 갱신을 치환 뒤로·원값 인용가 반올림 제외·1% 카드 역행 금지) + 계정 전환 쿼리캐시 초기화·ChatTab 카드 렌더 정리 + font-mono 한글 폴백(Windows 한글깨짐) 2026-08-07 15:48:48 +09:00
민헌
7dc925bcd4 docs(lps): 2026-08-07 세션 기록 — IP 로테이션 검수·네이버 UA 규명·결과 상태 정의
다음 세션이 이어받을 수 있게 남긴다.

- docs/2026-08-07-session-notes.md 신규: 세 덩어리(IP 로테이션 결함 6개, 네이버 컨테이너 차단이
  실은 UA 플랫폼 토큰이었다는 규명, '못 봤다'를 '없다'고 말하던 문제의 4단계 해결)와
  **되돌리기 전에 볼 근거**(예산을 IP 에 묶는 이유, 태우지 않는 판정, 임대 없이 크롤 금지,
  UA 는 플랫폼만 바꾸고 버전은 실값, 상태는 하나로 저장하고 표시에서 접기).
- 미해결: 쿠팡 간헐 차단 — IP 에 따라 갈리므로 환경이 아니라 평판. 쿠팡만 gate(해외 IP)를 쓰는 게
  유력한 원인이고, 네이버처럼 kr_host 로 바꾸는 검증이 중단된 상태.
- 다음 할 일 5가지(운영 마이그레이션 → 쿠팡 KR 검증 → 예산 튜닝 → AI 매칭 정확도 → admin 잡목록).
- 주의사항 5가지: **마이그레이션→코드 순서 강제**(역순이면 최저가 조회가 통째로 실패, 재현 확인),
  워커 켜고 pytest 하면 test_job_queue 3개 깨짐, JS 지문 위장은 patchright 가 막아 불가,
  로컬 negodata-front 는 node_modules 가 named volume 이라 재빌드로 의존성이 안 늘어남.
- README 문서 표에 링크 추가.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:02:25 +09:00
민헌
0c6c431f0a Merge branch 'feat/source-state'
'못 봤다'를 '없다'고 말하던 문제를 4단계로 해결한다. 상태를 하나로 정의·저장하고 표시 단계에서
보는 사람에 맞게 접는다 — 운영자(lps-admin)는 7상태 그대로, 실사용자(negodata)는 셋으로.

1) SourceState 7상태 — 어댑터가 이미 알던 '봤다/못 봤다' 구분을 핸들러가 버리지 않게 한다
2) price_history.sources/partial — 그 구분을 담을 자리(by_mall 은 가격 있는 몰만 담는다)
3) lps-admin — 몰별 상태·차단 마커·조치 힌트(blocked=자동회복 vs env_blocked=사람이 고쳐야)
4) negodata — '–'(확인했고 없음)와 '확인 못함'(못 봄)을 가르고, 후자는 재검색 대상으로 남긴다

⚠️ 배포 순서: 마이그레이션 2개(6_lps_2026-08_dbeaver.sql · 2026-08-07-iilp-source-state.sql)를
   **코드보다 먼저** 적용해야 한다. negodata 가 ORM 전체 엔티티를 조회하므로 컬럼이 없으면
   최저가 조회가 통째로 실패한다(재현 확인). 역순(마이그레이션 후 옛 코드)은 안전하다.

실환경 검증: 실제 크롤 결과로 네이버 no_match→'–', 쿠팡 env_blocked→'확인 못함' 확인.
잡 DEAD 0건, 네거티브 캐시 미오염, 포트 소각 8/100 에서 차단.
2026-08-07 14:46:02 +09:00
민헌
67612b477c Merge remote-tracking branch 'origin/main' into feat/source-state 2026-08-07 14:36:41 +09:00
민헌
6e401615eb fix(negodata): '일부 확인 못함'을 결과 열에도 표시 — 몰 칸만 고쳐 놓친 부분
실화면 확인에서 드러났다. 쿠팡 칸은 '확인 못함'으로 잘 나오는데 결과 열이 '변동 없음'이었다.
못 본 몰이 있는데 '변동 없음'이라 하면 사용자는 여전히 "이 가격이 최종이구나"로 읽는다 —
그 몰에 더 싼 값이 있었을 수 있어 이 결과는 최종이 아니다.

docs/result-states.md 3-2절에 이미 정의해 둔 표기인데(| 일부 확인 못함 | partial |)
몰 칸만 구현하고 결과 열을 빠뜨렸다.

- 결과 열: unconfirmed 가 있으면 '일부 확인 못함'(툴팁에 어느 몰인지). '탐색 성공'(더 싸게 찾음)은
  그대로 우선한다 — 값이 실제로 갱신된 건 사실이므로.
- 재검색 대상: 못 본 몰이 있으면 **완료로 치지 않는다**. 가격이 갱신됐어도 다음 '다시 검색'의
  기본 대상으로 남긴다 — 그게 '다시 시도할 이유가 생긴다'의 실제 동작이다.

tsc 오류 없음. 실데이터로 확인(섬유유연제: 네이버 4,820 matched · 쿠팡 blocked/env_blocked).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:29:15 +09:00
11b29b45e3 [fix] negodata·frontend·backend: 회사별 브랜드 색(primary_color) 기능 전면 제거 — 편집 UI 없이 값만 남아 적용되던 구현 정리(포털·메일·사이드바 색 솔루션 고정, 시드·응답 필드 제거) 2026-08-07 13:45:23 +09:00
4ed23c56b8 [feat] negodata: IMK 요청 일괄 반영 — 조회 게이팅·카드 매칭·부가세 통일 모드·기준가 이원 표기·마스킹 해제·이미지 일괄 업로드·초청메일 브랜딩·반응형 정리 2026-08-07 11:57:06 +09:00
민헌
6ddb2c4504 refactor(postgres-init): LPS 2026-08 마이그레이션 6_·7_ 통합 + 주석 스타일 정리
같은 DB(lps_db)의 같은 시기 추가분이 두 파일로 갈려 있어 순서·누락을 신경 써야 했다.
6_ 하나로 합친다(7_ 삭제) — 실행이 한 번이면 '어디까지 돌렸더라'를 기억할 일이 없다.

- 주석을 3_lps_dbeaver.sql 스타일로 통일: 객체 위 한 줄 설명 + 컬럼 인라인 주석 정렬.
  기존 6_·7_ 의 긴 배경 산문은 걷어냈다 — 배경은 lps/docs/result-states.md 가 소스고,
  이 파일은 '무엇을 만드는가'만 답하면 된다.
- 내용은 그대로: proxy_port(+LRU 인덱스), price_history 신뢰 신호 2·배송 3,
  몰별 확인 상태 sources/partial(+부분 인덱스), 적용 확인 SELECT.

검증: 기존 lps_db 재실행(멱등 — NOTICE 만) + 빈 DB 에 3_ → 6_ 신규 설치 후
price_history 신규 7컬럼·테이블 6종 전부 확인. 테스트 DB 는 정리.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:25:22 +09:00
민헌
95086cb3f6 feat(negodata): 4단계 — 사용자 화면에서 '없음'과 '확인 못함'을 구분
마지막 단계. 차단당해 못 본 몰이 화면에서 '–'(없음)로 보여 사용자가 "쿠팡엔 더 싼 게 없구나"로
오해하던 문제를 끝낸다. 안 본 걸 없다고 말하지 않는다.

사용자 화면은 **셋으로 접는다**(lps/docs/result-states.md 3-2절). 할 수 있는 행동이
'쓴다/다시 시도/넘어간다' 뿐이라, 원인이 달라도 다음 행동이 같으면 같은 표기다:
  matched                            → 가격
  no_match · empty                   → '–'        (확인했고 없었다)
  blocked · env_blocked · unavailable → '확인 못함' (못 봤다)
운영자 화면(lps-admin)은 같은 데이터로 7상태를 그대로 본다 — 목적이 진단이라 접지 않는다.

체인 전체를 이었다:
- postgres-init/alters/2026-08-07-iilp-source-state.sql — item_internet_lowest_prices 에
  sources/partial 추가(멱등, **운영 적용 필요**)
- models.py / lps_sync_crud 읽기 계약 / lps_sync_service 미러링 / LowestPriceEntry 프로토콜
- orval 재생성(ORVAL_INPUT 으로 저장 스펙에서 — 서버 없이). 생성 diff 는 새 필드만.
- PriceUpdateModal: 가격이 없는 몰이 '못 본 몰'이면 '–' 대신 '확인 못함'.

partial 은 LPS 가 판단해 내려준 사실을 그대로 쓴다 — 화면이 '어떤 상태가 확인된 것인가'를
다시 판정하면 상태 정의가 LPS 와 negodata 두 곳으로 흩어진다.

E2E 검증(실 DB 2시나리오): by_mall 은 둘 다 naver 뿐인데 쿠팡 칸이 '확인 못함'(차단) / '–'(0건)
으로 갈린다. tsc 오류 없음(기존 xlsx 미설치 오류는 무관). negodata 97 · lps 292 passed.

> 폴더 관례상 negodata 는 인수인계 대상이나, 사용자 요청으로 이번 건도 예외 적용.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:21:56 +09:00
민헌
d1d1ed6ec5 feat(lps-admin): 3단계 — 몰별 확인 상태를 운영 화면에 노출
2단계에서 저장한 sources/partial 을 운영자가 볼 수 있게 한다. 운영자 목적은 **진단**이라
상태를 접지 않는다 — blocked(IP 회전으로 자동 회복)와 env_blocked(사람이 환경·설정을 고쳐야 함)를
뭉뚱그리면 회복될 일에 매달리거나 손봐야 할 설정을 방치하게 된다.

API
- /v1/lps/products, /v1/lps/products/{code}/history 둘 다 sources·partial 을 싣는다.
- 이력은 **시점마다** 싣는다. 최신 상태를 과거 시점의 몰별 표 옆에 붙이면 '그때도 막혔던 것처럼'
  보여 오해를 부른다 — 그래서 ProductItem 이 아니라 PricePoint 에 담았다.

화면
- lib/sourceState.ts: 상태별 라벨·색·설명·confirmed 를 한곳에. 미지의 상태가 와도 화면이 깨지지
  않는다(값 그대로 표시 + '모름' 취급). 색은 전부 @theme 토큰 참조(raw hex 금지).
- 상품 목록: partial 이면 '일부 확인 못함' 배지 + 툴팁에 어느 몰인지.
- 몰별 비교 카드 위: 몰별 상태·수집 건수·실패 사유 원문(툴팁). 가격표에 없는 몰이 **왜** 없는지를
  여기서 답한다 — by_mall 은 가격이 있는 몰만 담으므로 그 답이 여기밖에 없다.

검증: ASGI 직접 호출로 두 엔드포인트 응답 확인(한글 사유 포함), tsc 오류 없음.
테스트 3건 추가(목록 노출 / 시점별 상태가 각각 다르게 / 컬럼 추가 이전 옛 행 호환).
전체 292 passed. 진행 상황은 docs/result-states.md 4절.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:05:31 +09:00
민헌
74db218410 feat(lps): 2단계 — price_history 에 몰별 확인 상태 저장
1단계에서 만든 SourceState 가 job.result 에만 있어 화면까지 못 갔다. by_mall 은 **가격이 있는
몰만** 담으므로, 빠진 몰이 '거기엔 없더라'인지 '거기를 못 봤다'인지 구분할 자리가 없었다.

- price_history.sources (JSONB): 몰별 상태를 그대로 담는다.
  {"naver": {"state": "matched", "count": 40}, "coupang": {"state": "blocked", "error": "..."}}
  열린 스키마라 몰이 늘거나 근거를 덧붙여도 마이그레이션이 필요 없다(by_mall 과 같은 방침).
- price_history.partial (bool): 결과가 완전한가. sources 에서 유도 가능하지만 컬럼으로 둔다 —
  소비자가 '어떤 상태가 확인된 것인가'라는 판단 규칙까지 알아야 하면 **상태 정의가 두 곳으로
  흩어진다**. 판단은 LPS 가 끝내고 소비자(negodata·lps-admin)는 사실 하나만 읽는다.
- 부분 인덱스 ix_price_history_partial — partial=true 행만 담아 작게 유지(운영 점검·알림용).
- _record_history 가 per_source 를 받아 partial 을 계산해 기록한다. 네거티브 캐시 히트 경로는
  sources 없이 남긴다(부분 결과는 애초에 캐시하지 않으므로 항상 확정).
- 마이그레이션: postgres-init/dbeaver/7_lps_source_state_dbeaver.sql (재실행 안전, **운영 적용 필요**)

검증(로컬 실 DB): 쿠팡 차단 vs 쿠팡 0건은 by_mall 이 둘 다 ['naver'] 로 같지만
partial(true/false)·sources.coupang.state(blocked/empty)가 두 경우를 갈라낸다.
JSONB 는 ensure_ascii=False 로 한글 사유가 깨지지 않는 것도 테스트로 고정.

테스트 3건 추가, 전체 289 passed. 진행 상황은 docs/result-states.md 4절.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:57:19 +09:00
민헌
b278f58d9c feat(lps): 1단계 — 몰별 상태 보존, '없음'과 '못 봄'을 가른다
docs/result-states.md 의 1단계. 어댑터는 이미 구분을 알고 있는데 핸들러가 그 정보를 버리고
있었다(per_source[src] = {"error": 문자열}). 그래서 차단당해 못 본 몰이 화면에서 '그 몰엔 없음'
으로 둔갑했다. 새로 알아낼 정보는 없고, 흘리던 걸 잡아두기만 하면 된다.

- common/enums.py: SourceState 7상태 추가(MATCHED/NO_MATCH/EMPTY/BLOCKED/ENV_BLOCKED/
  UNAVAILABLE/SKIPPED). `.confirmed` 프로퍼티로 **'봤다 vs 못 봤다' 경계를 한곳에** 둔다 —
  이 경계가 무너지면 나머지 판단이 전부 틀어지므로 흩어놓지 않는다.
- AdapterError.state: 어댑터가 아는 구분을 실어 보낸다. blocked/fatal 은 '어떻게 대응할까'
  (회전·재시도)를 위한 값이고 state 는 '사용자에게 뭐라 말할까'를 위한 값이라 쓰임이 다르다.
  특히 blocked=False 하나에 결과0건(EMPTY)과 전송실패(UNAVAILABLE)가 섞여 있어 state 없이는
  갈라낼 수 없었다. **기본값은 UNAVAILABLE** — 모르면 '못 봤다'가 안전하다(EMPTY 로 두면
  확인도 안 한 몰을 '없음'으로 단정한다).
- browser_base: raise 지점 5곳에 상태 부여. 핵심 갈림은 0건 종착 한 곳 —
  blocked=False → EMPTY(정말 없다) / blocked=True → BLOCKED(못 봤다).
- _search_round: {"state": ..., "count"|"error": ...} 로 구조화. EMPTY 는 '정상 응답'으로 세어
  (confirmed) 쿠팡에 정말 없을 때 잡이 재시도로 낭비되지 않게 한다.
- _finalize_states: 수집만 된 소스를 AI 판정 뒤 MATCHED/NO_MATCH 로 확정한다. 실패 상태는
  이미 확정이라 덮지 않는다.

검증(9조합 실측): empty → partial=False(확정) / blocked·env_blocked·unavailable → partial=True.
테스트 12건 추가, 전체 286 passed. 진행 상황은 docs/result-states.md 4절에 기록.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:47:28 +09:00
민헌
1e966f7121 docs(lps): 결과 상태 — 운영자와 실사용자를 나눠 접는 원칙 추가
보는 사람이 둘인데 하나로 뭉뚱그리고 있었다.
  lps-admin  운영자 — 목적이 **진단**이다. '왜 그랬나'에 답해야 하므로 7상태를 그대로 보고
             차단 마커·ip_request_no·포트까지 붙인다. 특히 blocked(자동 회복)와
             env_blocked(사람이 고쳐야 함)를 반드시 갈라야 한다 — 개입 여부가 갈린다.
  negodata   실사용자 — 목적이 **행동**이다. 할 수 있는 건 '쓴다/다시 시도/넘어간다' 셋뿐이라,
             원인이 달라도 다음 행동이 같으면 같은 표기로 접는다:
               no_match·empty            → '–'        (둘 다 결론은 '이 몰엔 없다')
               blocked·env_blocked·unavailable → '확인 못함' (행동은 '나중에 다시' 하나뿐)

핵심 원칙: **상태는 하나로 정의·저장하고, 접는 건 표시 단계에서 한다.**
저장을 단순화하면 관리자가 원인을 못 보고, 표시를 상세화하면 사용자가 못 읽는다.
두 화면 모두 price_history 를 읽으므로(admin_service 확인) 데이터는 공유하고 투영만 달리한다.

예외 하나: '일부 확인 못함'(partial)은 사용자에게도 접지 않는다. 이건 행동을 바꾸기 때문이다 —
'이 가격이 최종인가'의 답이 달라진다. 다만 사용자에게 필요한 건 어느 몰이 왜 막혔는지가 아니라
결과가 완전하지 않다는 사실 하나다.

남은 일도 4단계로 갱신(1 플래그 보존 → 2 저장 자리 → 3 admin 표시 · 4 사용자 표시).
3·4 는 같은 데이터를 다르게 접는 것이라 병행 가능.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:11:40 +09:00
77d96cd4a4 [fix] landing: 히어로 CTA 문구 — 클릭 후 일어나는 일에 맞춘다
여기서 직접 체험  →  협상 예시 체험
  데모 요청        →  실제 데모 받기

두 버튼은 성격이 전혀 다른데 문구가 그걸 구분하지 못했다.
1차는 페이지 안에서 바로 조작하는 인터랙티브 데모고, 2차는 이름·이메일을 받는
리드 폼이다. 클릭 직후 경험이 다르므로 문구도 갈라야 한다.

협상 예시 체험
  고정 데이터 시뮬레이션이라 "예시"를 밝힌다. 다만 "보기"로 낮추지는 않았다 —
  도착 섹션이 "직접 경험해 보세요" 이고 역할 카드가 "공급사로 해보기" 라,
  버튼만 구경하는 말이면 클릭 전 기대와 도착 후 요구가 어긋난다.

실제 데모 받기
  누르면 폼이 뜨고 데모는 이메일로 온다. "직접 체험" 이라 쓰면 눌렀을 때 바로
  만질 줄 알았다가 입력을 요구받아 낚인 느낌이 된다. "받기" 는 이메일로 온다는
  사실이 드러나서 폼이 뜨는 게 자연스럽다.

모달 제목도 "데모 요청" → "실제 데모 받기" 로 맞췄다. 버튼이 "받기" 인데 열린 창이
"요청" 이면 전환 순간에 다른 화면처럼 읽힌다.

제출 버튼은 "데모 신청" 그대로 둔다. 같은 원칙을 적용하면 그 클릭으로 실제 일어나는
일은 신청 접수이지 데모 수령이 아니다(수령은 이메일로 나중에).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:47:07 +09:00
민헌
04b697a9eb docs(lps): 검색 결과 상태 정의 — '못 찾음'과 '못 봄'을 가른다
'가격을 못 찾았다' 한 문장에 뜻이 정반대인 상황이 섞여 있다: 그 몰에 정말 없는 것(사실)과
그 몰이 우리를 막아 못 본 것(미확인). 지금 화면은 둘 다 '–' 로 똑같이 보여준다.
구현 전에 용어를 맞추려고 상태를 정의한다.

코드를 확인해 **실제로 구분 가능한 것**만 정의했다:

몰(소스) 단위 7상태 — 경계는 '사실'(no_match/empty) 대 '미확인'(blocked/env_blocked/unavailable).
앞의 둘은 "없다"고 말해도 되고, 뒤의 셋은 말하면 안 된다.

상품 단위 — found / not_found / error + partial 꼬리표. partial 은 독립 상태가 아니라
found·not_found 에 붙는데, 특히 'not_found + partial' 은 결론이 아니다(못 본 몰에 있었을 수 있음).

확인 과정에서 드러난 근본 원인:
- 어댑터는 이미 구분을 **안다** — AdapterError 에 blocked·fatal 이 있고 detect_block 이
  '차단 vs 정상 빈결과'를 판정한다.
- 그런데 **핸들러가 그 플래그를 버린다**: per_source[src] = {"error": f"{...}"} — 문자열만 남는다.
- 게다가 0건도 예외로 온다(return 은 1건 이상일 때만). 즉 empty 와 blocked 가 둘 다
  AdapterError 로 도착하는데 구분 플래그를 버리므로 이후로는 갈라낼 수 없다.

→ 새로 알아낼 정보는 없다. 이미 아는 걸 흘리고 있을 뿐이라, _search_round 한 곳이 출발점이다.
   남은 일을 1(플래그 보존) → 2(price_history 자리) → 3(화면 표기) 순서로 정리했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:46:23 +09:00
민헌
14bcf1481b fix(negodata): 검색 실패를 '변동 없음'으로 표시하던 문구 정정
LPS 가 outcome='error'(모든 소스 차단)로 내려주는 경우가 화면에서 '변동 없음'으로 보였다.
사용자는 '검색은 잘 됐는데 더 싼 게 없구나'로 읽지만, 실제로는 **가격을 확인조차 못 한** 것이다.
뜻이 정반대라 오해가 크다.

원인: success_yn=false 를 전부 notfound 로 묶었고, notfound 와 done 이 같은 문구('변동 없음')를
쓴다. fail_reason 은 이미 API·프론트 타입까지 내려오는데 쓰이지 않고 있었다.

→ fail_reason='error' 면 failed 로 분류한다. 화면은 '탐색 실패'가 되고, 실패 사유가 함께 뜨며,
   재검색 대상(failedIds)에 포함돼 사용자가 다시 시도할 수 있다 — 확인 못 한 건 재시도가 맞다.
   'not_found'(진짜 못 찾음)는 기존대로 '변동 없음'을 유지한다.

LPS 변경 없음(이미 내려오는 값을 쓴 것). ItemState 주석도 실제 의미에 맞게 정정.
tsc: 이 파일 오류 없음(기존 xlsx 미설치 오류는 무관).

> 폴더 관례상 negodata 는 인수인계 대상이나, 이번 건은 사용자 요청으로 예외 적용.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:38:11 +09:00
민헌
152aed9908 fix(lps): 한 소스가 막혀도 잡을 정상 종료 — '탐색 중'에서 멈추던 원인
운영 로그(2026-08-06)에서 드러난 문제. 쿠팡이 막힌 동안 **네이버가 매번 40건을 가져왔는데도**
잡이 전부 DEAD 로 갔다:

  fail 3c0bcaff → DEAD ({'coupang': {'error': 환경 차단}, 'naver': {'count': 40}})

그리고 DEAD 는 price_history 에 행을 남기지 않는다. 이를 폴링하는 negodata 최저가 모달은
받을 결과가 영영 없어 '탐색 중'에서 멈춘다(사용자 화면 확인). 즉 프론트 버그가 아니라
**백엔드가 답을 안 준 것**이다 — 두 증상이 같은 뿌리였다.

원인: 핸들러가 '하나라도 실패했나'(bool)만 보고 무조건 raise 했다. 한 소스가 막혔다고
다른 소스가 멀쩡히 가져온 결과까지 버린 셈이다.

바꾼 것:
- _search_round 가 **성공한 소스 목록**을 돌려준다(bool → list).
- 살아있는 소스가 하나라도 있으면 그 결과로 진행한다. **전부 죽었을 때만** 재시도한다
  ('없음'이라 단정할 수 없는 건 그때뿐이다).
- 결과에 sources_ok/sources_failed/partial 을 실어 커버리지를 드러낸다(부분 결과 로그도 남김).
- 부분 실패 상태의 not_found 는 **네거티브 캐시에 넣지 않는다** — 막힌 소스엔 있었을 수 있는데
  '없음'으로 굳히면 TTL 동안 재검색이 막힌다(사용자가 '다시 검색'을 눌러도 캐시 히트).
- 전부 실패 + 재시도 소진이면 DEAD 대신 outcome='error' 로 **이력을 남기고** 종료한다.
  화면이 무한 대기 대신 결과를 받는 게 중요하다.

negodata 는 변경 없이 받는다(outcome='error' → success_yn=false, fail_reason='error').

테스트 5건 추가·1건 갱신(옛 계약 '한 소스 실패 시 raise' 를 새 계약으로 교체), 전체 274 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:34:36 +09:00
민헌
f483839b11 Merge branch 'fix/naver-container-wtm'
네이버 컨테이너 차단 해결 — 원인은 UA 의 플랫폼 토큰(리눅스)이었다.
네이버가 리눅스 데스크톱 Chrome 을 HTTP 405 로 거부한다: Linux UA 0/3 통과 · Mac UA 3/3 통과
(같은 이미지·같은 KR 프록시, IP 3개씩). 405 는 JS 이전 HTTP 계층이라 WebGL·폰트 등 지문은
원인이 아니었다. 리눅스에서만 UA 플랫폼 토큰을 맥으로 치환하며, Chrome 버전은 실제 값을 유지한다.
쿠팡은 잘 통과하므로 건드리지 않았다.
2026-08-06 15:42:49 +09:00
민헌
8d15fe6bb0 fix(lps): 네이버 컨테이너 차단 해결 — 원인은 UA 의 플랫폼 토큰이었다
컨테이너를 로컬에 재현해 규명했다. '컨테이너가 문제'가 아니라 **UA 가 리눅스라고 말하는 것**이
원인이다. 네이버는 리눅스 데스크톱 Chrome 을 HTTP 405 로 거부한다.

실측(같은 이미지·같은 KR 프록시, 서로 다른 IP 3개씩):
  X11; Linux x86_64        0/3 통과   전부 405 + wtm_captcha (~50KB)
  Macintosh; Intel Mac     3/3 통과   전부 200 · 6건 · ~1.0MB
  안드로이드·아이폰 모바일    0/2       418 '비정상적인 접근'(2.6KB, 회전 무효 하드차단)
프록시 없이 같은 집 IP 로도 호스트 통과 / 컨테이너 차단이 재현돼 IP·게이트웨이는 배제됐다.
맥에서 잘 되던 이유도 이걸로 설명된다.

**405 가 열쇠였다** — JS 가 돌기 전에 HTTP 계층에서 거부당한다. 그래서 그동안 의심하던
WebGL·폰트·plugins 는 애초에 원인이 될 수 없었다(확인차 --enable-unsafe-swiftshader 로
WebGL 을 살려봤지만 405 그대로였다).

조치:
- services/search/user_agent.py: 리눅스에서만 UA 플랫폼 토큰을 맥으로 치환. Chrome 버전은
  `--version` 으로 실제 값을 읽어 유지한다 — 하드코딩하면 컨테이너 Chrome 업데이트 시
  UA 와 엔진이 어긋나 그 불일치가 새 봇 신호가 된다. 조회 실패해도 크롤을 막지 않는다.
- NaverShopAdapter.mac_ua_on_linux = True (쿠팡은 잘 통과하므로 기본 False 그대로 — 멀쩡한 걸
  건드리지 않는다). 맥/윈도우에서는 자동 미적용.
- 실제 어댑터로 컨테이너 검증: '생수' 40건, '스페셜티 원두 1kg' 40건 통과.

부수:
- fingerprint.judge: WebGL 이 **아예 없는** 경우를 OK 로 흘려보내던 판정 버그 수정(컨테이너
  재현 중 발견 — 소프트웨어 렌더링보다 더 튀는 값인데 침묵했다). UA 플랫폼 항목 추가.
- docs/operations.md: '미해결' 절을 원인·수치·조치·확인법으로 교체.
- fingerprint.py: JS 위장이 이 스택에서 불가능하다는 실측 기록 유지(재시도 방지).

테스트 8건 추가(리눅스에서만 보정·실제 버전 유지·조회 실패 폴백·네이버만 opt-in), 전체 270 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:40:14 +09:00
민헌
9380760869 fix(lps): 네이버 컨테이너 차단 — 계측 CDP 토글 + 지문 판정 + 폰트 보강
운영 실측으로 범위가 좁혀졌다: 같은 컨테이너에서 **쿠팡은 60건 정상, 네이버만** 매 IP
첫 요청부터 wtm_captcha. kr_host 도 정상이다(ensure_ports 에 kr.decodo.com, 마커가 해외 IP
하드차단 '비정상적인 접근'(2.6KB)이 아니라 wtm_captcha(43~57KB)). IP·게이트웨이가 아니라
브라우저 지문이 남은 후보다.

**먼저 확인한 것 — 지문 위장은 이 스택에서 불가능하다(중요)**
WebGL 렌더러(컨테이너=SwiftShader)를 실기기 이름으로 덮으려 했으나 주입 경로가 둘 다 막혔다:
  context.add_init_script()  무반응. 단순 마커(window.__M__) 주입조차 undefined 다.
                             patchright 가 무력화한다 — 이 API 는 CDP
                             addScriptToEvaluateOnNewDocument 로 구현되고 그 흔적이 탐지
                             신호라, 걷어내는 게 patchright 의 존재 이유다.
  확장(MV3, world:MAIN, document_start)  로드는 되지만 값이 바뀌지 않았다.
→ 동작하지 않는 코드를 남기지 않는다. 스푸핑은 넣지 않고, 대신 **판정**만 한다.
   재시도 방지를 위해 이 실측을 fingerprint.py docstring 에 남겼다.

**넣은 것**
- services/search/fingerprint.py: 지문 판정(항목·정상여부·관측값·왜 문제인가). 진단이 곧
  '무엇을 고칠까'로 이어지도록 이유를 함께 낸다.
- net_meter 토글: 네이버에 계측용 CDP(Network.enable)를 붙이지 않는다(기본 off).
  네이버는 '요청 가로채기 자체가 탐지 신호'라 리소스 차단을 끈 이력이 있는데 계측 CDP 는
  그대로 붙고 있었다 — 같은 계열이다. [WorkerConfig].naver_net_meter 로 재빌드 없이 A/B
  (config 는 마운트). 끄면 대역폭이 DOM 크기 근사로 떨어질 뿐 크롤엔 영향 없다.
  쿠팡은 잘 통과하므로 기본값을 바꾸지 않는다.
- Dockerfile.worker: fonts-noto-cjk·fonts-liberation 추가(나눔 하나뿐이면 폰트 지문이 부자연스럽다).
- diag_naver.py 가 같은 판정 모듈을 쓰도록 정리.

통과 환경 기준값(맥 실측): 의심 항목 0개 · HTTP 200 · 6건 · 1.26MB.
테스트 6건 추가(통과 지문을 문제로 몰지 않는지·컨테이너 지문을 놓치지 않는지), 전체 262 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:22:01 +09:00
민헌
3bdff74ceb feat(lps): 네이버 WTM 차단 진단 도구 — 컨테이너에서 '네이버가 보는 우리 브라우저'를 덤프
운영(실 x86 서버) 실측으로 문제 범위가 크게 좁아졌다:
  쿠팡   컨테이너에서 **60건 정상**
  네이버 매 IP 첫 요청부터 wtm_captcha(43~57KB)

즉 '컨테이너가 문제'가 아니라 **네이버 WTM 만 이 컨테이너를 걸러낸다**. kr_host 도 정상이다
(ensure_ports 에 kr.decodo.com 이 찍혔고, 마커가 해외 IP 하드차단 '비정상적인 접근'(2.6KB)이
아니라 wtm_captcha 다 = 한국 IP 는 제대로 나가고 있다). IP·게이트웨이가 아니라 지문 쪽이 남았다.

서버는 Docker 전용이라 '워커만 호스트 실행' 우회를 쓸 수 없다. 그래서 추측으로 이것저것
고치는 대신, **고치기 전에 무엇이 다른지 눈으로 보는** 도구를 먼저 만든다.

diag_naver.py — 컨테이너 안에서 실물과 동일한 스택(patchright + 실제 Chrome + locale/timezone +
kr 프록시)으로 띄워:
- 네이버 WTM 이 볼 수 있는 값 덤프(UA·languages·webdriver·plugins·screen/avail·devicePixelRatio·
  WebGL vendor/renderer·한글 폰트·timeZone …)
- 자동화/가상화로 읽히기 쉬운 항목에 판정과 이유를 붙임
- 실제 msearch 요청 → HTTP·카드 등장 여부·파싱 건수·차단 마커, 실패 시 HTML 저장

--no-cdp   바이트 계측 CDP(Network.enable)를 빼고 A/B — 네이버는 '요청 가로채기 자체가 탐지
           신호'라 리소스 차단을 끈 이력이 있는데, 계측용 CDP 는 그대로 붙고 있다
--no-proxy 프록시 없이 시도 — IP 요인과 지문 요인 분리

재빌드 없이 docker cp + docker exec 로 실행한다(사용법은 파일 상단 docstring).

통과 환경 기준값(맥 직결, 참고용):
  glRenderer=ANGLE(Apple M3 Pro) · plugins=5 · 한글폰트 6종 · screen 2560x1080/avail 2560x1050
  → HTTP 200 · 파싱 6건 · html 1.26MB

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:45:52 +09:00
민헌
b403ca21c9 feat(postgres-init): LPS 2026-08 스키마 추가분 dbeaver 파일 — 운영 워커 기동 실패 해소
운영 워커가 기동 중 크래시 루프에 빠졌다:
  UndefinedTableError: relation "proxy_port" does not exist
  (worker_main.py:336 store.ensure_ports → 기동 시 반드시 만지는 테이블)

3_lps_dbeaver.sql 이후 늘어난 스키마가 운영 DB(172.30.1.36/lps_db)에 반영되지 않은 것이다.
모델은 바뀌었지만 이미 만들어진 DB 는 자동으로 따라오지 않는다.

6_lps_2026-08_dbeaver.sql 신규:
- proxy_port 테이블 + LRU 인덱스(ix_proxy_port_pick)
- price_history 신뢰 신호 2컬럼(final_rating, final_review_count)
- price_history 배송 3컬럼(final_shipping_fee/type/label)
- 끝에 검증 SELECT — 워커가 요구하는 테이블 6종의 존재 여부를 한눈에 확인

전부 IF NOT EXISTS 라 재실행 안전하고, 신규 설치(3 → 6)와 기존 DB 양쪽에 같은 파일을 쓴다.
검증: 기존 lps_db 재실행(NOTICE 만, 오류 없음) + 빈 DB 에 3 → 6 순서 적용 후 컬럼이 모델과 일치.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 13:25:46 +09:00
d3e33b10f4 [feat] landing: 최종 CTA 결과 화면을 영상에서 컴포넌트로 — 브랜드 팔레트·중립 화자
negotiation_annotated_3d.mp4(677K)를 NegotiationReplay 컴포넌트로 대체했다.
영상이라 두 가지를 못 고쳤고, 둘 다 픽셀에 구워진 것이 원인이었다.

화자
  "아이마켓 구매 MD" 가 박혀 있었다. POC 고객사 이름이라 다른 MRO 회사에 영업할 때
  쓸 수 없다. 이제 "구매 담당자" 로, 회사명 없이 역할만 쓴다 — 상수라 언제든 바꾼다.

색
  실측하면 말풍선 #5A68CD, 목표가 라벨 #B96175, 와일드카드 배지 #DB840B 로 셋 다
  토큰에 없는 색이었고, 정작 브랜드의 핵심 대비 장치인 민트(#0FFFD6, 상대편 색)는
  한 번도 쓰이지 않았다. 이제 토큰만 쓴다 — 우리 쪽 파랑, 상대편 민트.
  진행 막대도 파랑에서 민트로 차오르며 양쪽이 만나는 지점을 그린다.

부수 효과
  - 다크 무대에 네이티브로 그리니 밝은 영상이 구멍처럼 뜨던 문제가 사라졌다.
    카드로 감싸고 그림자로 띄우던 처리가 더는 필요 없다.
  - 677K + 포스터 85K → 0. 어느 해상도에서도 선명하다(영상은 840x1260 고정이었다).
  - role="img" + aria-label 로 스크린리더가 결론을 읽는다. 영상은 못 읽었다.

숫자는 지어내지 않고 DEMO_ITEMS[0] 을 그대로 쓴다. 위쪽 인터랙티브 데모와 같은
품목·같은 가격이라 둘을 다 본 사람에게 앞뒤가 맞는다.

구현 메모
  - 화면 밖에서는 타이머를 멈춘다(IntersectionObserver). 페이지 최하단이라
    대부분의 시간 동안 보이지 않는데 계속 도는 건 배터리를 쓰는 일이다.
  - prefers-reduced-motion 이면 최종 상태를 정지 표시한다.
  - 낙찰 카드 자리를 h-[74px] 로 항상 확보하고 내용만 켜고 끈다. 나타날 때 아래가
    밀리면 페이지가 주기적으로 흔들린다. 대신 금액은 whitespace-nowrap — 고정 높이라
    한 줄이라도 접히면 잘린다(실제로 "1,080,000" 뒤 "원" 이 접혔다).

검증: 1280x900 카드 319x74 · 375x812 285x74, 슬롯 초과·가로 넘침 없음.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 13:15:46 +09:00
17a698a7af Merge branch 'feature/negodata' 2026-08-06 13:13:11 +09:00
민헌
7b9d895ead fix(lps): 풀 고갈 시 남의 IP 를 빌려 쓰던 문제 — 임대 없으면 크롤하지 않는다
동시 다상품 검색 점검 중 발견. 워커 3개(소유자 6)가 포트 2개/게이트웨이를 두고 경합하는
상황을 실제 코드로 돌리니, 임대를 못 받은 워커가 **남이 쥔 포트를 그대로 집어 같은 IP 로
동시에 요청**했다:

  coupang-w1   사용=70002 임대=70002
  coupang-w2   사용=70002 임대=None    ← 같은 IP 를 둘이 사용

원인은 _port() 의 계산식 폴백이다. 장부 모드에서 acquire 가 None 을 줘도 시간창 계산으로
포트를 하나 골라 돌려줬다. 포트 장부가 존재하는 이유("워커 N개가 같은 IP 에 요청을 몰면 그 IP 가
빨리 탄다" — port_registry.py 도입 배경)를 정면으로 무너뜨리는 경로다. 게다가 하필 **풀이 마른
상태 = IP 가 가장 귀할 때** 발동해, 남은 IP 를 두 배 속도로 태우는 악순환을 만든다.

→ 장부 모드에선 임대한 포트만 쓴다(없으면 None). 못 받으면 AdapterError 로 실패하고 잡이
   백오프 후 재시도한다 — 그 사이 쿨다운이 풀린다. 풀 고갈 자체는 proxy_ports_low 가 이미 운다.
→ playwright_proxy() 도 임대가 없으면 예외. 여기서 None 을 돌려주면 **프록시 없이** 브라우저가
   떠 서버 공인 IP 로 크롤하게 되는데, 그 IP 가 타면 회전으로 복구할 수 없다.

동시성 점검 결과(포트 20개/게이트웨이, 워커 3개):
  정상 24건 동시     성공 24 · 포트 중복 보유 0
  풀 고갈           성공 4/6(2건은 정상적으로 실패) · **같은 IP 공유 0**
  전면 차단          소각이 어댑터당 2개에서 멈춤(게이트웨이당 6/20) · 브레이커 6/6 트립

테스트 3건 추가(고갈 시 None 반환·남의 포트 미사용 / 임대 없는 playwright_proxy 예외 /
검색이 깔끔히 실패), 전체 256 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:01:15 +09:00
3214e4f782 [feat] landing: 가이드 2번 탭 mp4 연결 · 최종 CTA 에 결과 화면 배치
① 이용 가이드 2번 탭 (협력사별 1:1 협상)
video 필드가 빠져 있어 이 탭만 1.7MB GIF(390x780)를 내려받고 있었다. 같은 내용의
mp4 가 저장소에 있는데 참조만 없었다 — 786K, 540x1080 으로 더 가볍고 더 크다.
GifSlot 이 video 를 우선 재생하므로 한 줄로 끝난다.

원본 캡쳐가 좌우로 잘려 문장이 중간에 끊기는 문제는 남아 있다(540x1080 원본 파일
자체에 잘린 채로 들어 있어 CSS 로 못 고친다). 재촬영이 필요하고 주석에 남겼다.

③ 최종 CTA — negotiation_annotated_3d.mp4
이용 가이드에는 넣지 않았다. 그 섹션은 "실제 화면으로 보여드립니다" 라고 약속하는데
이 영상은 재구성 목업이다(실제 UI 는 "아이마켓코리아 (구매 MD)", 영상은 "아이마켓 구매 MD").
1·3번 탭이 실제 캡쳐인데 2번만 목업이면 register 도 어긋난다.
전환을 요청하는 자리에는 그 약속이 없으므로 마케팅 컴포짓이 맞는 자리다.

- 영상 배경이 밝은 회색(#EEF4F5)이라 무대 위에 그냥 놓으면 구멍처럼 뜬다.
  모서리를 깎고 그림자를 깔아 "떠 있는 물체"로 만들면 밝기 차이가 의도로 읽힌다.
- 폰 프레임이 영상에 내장돼 있어 PhoneFrame 으로 감싸지 않는다. 감싸면 이중 프레임이다.
- 2단이 되면서 width md(max-w-4xl) → lg(max-w-5xl), 제목은 lg 부터 한 단계 축소.
  4xl 안에 세로 목업과 카피를 밀어넣으면 양쪽 다 좁아진다.
- preload="none" — 페이지 최하단이라 첫 로드에 677K 를 미리 받을 이유가 없다.

검증: 1280x900 2단 정상, 375x812 카피→영상 중앙 적층·가로 넘침 없음.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:56:59 +09:00
민헌
08afc7d4ad fix(lps): sticky 만료 회전이 저트래픽에서 안 먹던 문제 + prod 템플릿에 kr_host 누락
prod 영향 점검 중 발견. 직전 커밋(377389f)의 sticky 만료 회전은 **브라우저가 열려 있을 때만**
동작했다 — `_ctx is None` 이면 만료 검사를 건너뛰었다. 그런데 negodata 연동은 수동 트리거
전용이라 검색이 드문드문 들어오고, 그때는 유휴 정리(120s)로 브라우저가 닫힌 채 매 검색이
그 경로로 들어온다. 즉 **실사용 패턴에서만 안 먹는** 반쪽 수정이었다(377389f 커밋 메시지·README
의 '고쳤다'는 서술이 부정확했다).

실측(합성 게이트웨이·10포트, sticky 매번 경과):
  연속 검색            IP 6개 순환      ✅
  저트래픽 · 예산 3     IP 2개          예산이 대신 회전시켜 가려져 있었음
  저트래픽 · 예산 0     **IP 1개 고정**  ❌  ([DecodoConfig] 주석의 '0=시간창 회전만'이 거짓)

원인은 시계가 둘이었던 것이다. sticky 만료를 브라우저 기동 시각(_launched_at)으로 쟀는데,
브라우저는 닫혔다 열릴 때마다 시계가 되감긴다. IP 를 쥔 시간과 어긋나는 이 구조가 F1(예산
미발화)과 F4(회전 안 됨)의 공통 원인이었다.
→ 시계를 _session_started_at 하나로 통일하고 _launched_at 을 제거했다. 만료 판정은 브라우저가
   닫혀 있어도 수행한다. 세 시나리오 모두 정상 회전 확인.

**prod 템플릿 kr_host 누락**(기존 문제, 이번 변경과 무관):
config.prod.toml.example 에 kr_host 가 없어 그대로 복사하면 네이버가 국가 무지정 게이트웨이로
떨어진다. 해외 residential IP 는 '비정상적인 접근'(2.6KB) 하드차단이고 회전으로 회복 불가라
네이버 결과가 통째로 0건이 된다. kr_host + naver_ip_request_budget 을 경고 주석과 함께 추가.

테스트 2건 추가(저트래픽 sticky 만료 회전 / IP 를 쥔 시간이 수명 내면 회전 안 함), 전체 253 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:50:08 +09:00
e809129ae9 [feat] landing: 리드 수신 Google Apps Script 추가 (Slack 대체)
사내에서 Slack 을 쓰지 않아 수신처를 Google 로 잡는다. Apps Script 웹 앱이 POST 를
받아 스프레드시트에 적재한다 — 별도 서비스 가입 없이 이미 있는 Google 계정으로 끝나고,
영업이 시트를 그대로 열어 본다.

api/lead.ts 는 고치지 않았다. 이미 LEAD_WEBHOOK_URL 로 JSON 을 POST 하고, Slack 용
text 와 원본 필드를 함께 실어 보내도록 만들어 둬서 수신처만 바꾸면 된다.

스크립트는 docs/ 에 둔다. landing/api/ 안에 두면 Vercel 이 /api 규약에 따라 이 .js 를
서버리스 함수로 빌드하려 들고, Apps Script 전역(SpreadsheetApp 등)을 쓰므로 배포가 깨진다.

스크립트가 처리하는 것
- 헤더 자동 생성·확장. 나중에 필드가 늘어도 스크립트를 다시 안 고쳐도 된다.
- LockService 로 동시 제출이 같은 행에 겹쳐 쓰는 것을 막는다.
- Slack 전용 요약(text)은 시트에서 제외 — 같은 내용이 개별 필드에 이미 있다.
- doGet 은 배포 직후 생존 확인용.

설치 절차는 파일 상단 주석에 있다. 배포 후 Vercel 환경변수 LEAD_WEBHOOK_URL 설정 +
재배포가 필요하다 — 환경변수는 배포 시점에 주입된다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:34:01 +09:00
631a501c5c [chore] landing: 렌더되지 않던 ROI 시뮬레이터 제거
roi-simulator.tsx 는 어디에서도 import 되지 않아 화면에 나오지 않았다.
누락이 아니라 의도적으로 내린 것이다 — 9ccf42c("카피 정직화")에서 <ROISimulator /> 를
빼고 그 자리에 <Comparison />, <Faq /> 를 넣었다.

되살리지 않는 이유는 파일 자체에 있다. 카피는 "아직 실제 도입 실증 사례가 없는
신규 카테고리"라고 스스로 인정하면서 절감액을 제시하고, 계산은 근거 없는 추산이다
(협력사당 6.5시간). 브랜드 보이스 가드레일 통과 기준 3번 — 주장·수치에 출처가 붙어야
하고 출처 없는 주장은 FAIL — 과 정면으로 부딪힌다. 다시 붙이려면 근거부터 확보해야 한다.

range-slider.tsx 도 함께 지운다. 사용처가 roi-simulator 하나뿐이라 같이 죽는다.

되살릴 일이 생기면 git 이력에 그대로 남아 있다.

남은 고아 두 개는 이번 작업과 무관한 기존 항목이라 두었다:
  components/ui/price-sparkline.tsx
  components/ui/slate-renderer.tsx

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:30:03 +09:00
ab77900dac [feat] landing: 본문 브랜드 표기를 네고시움으로 한글화
한글 문장 안에 영문 브랜드명이 섞이면 읽는 흐름이 끊긴다. 본문 카피만 바꾸고,
성격이 다른 자리는 남겼다.

바꾼 곳 (한글 문장 안)
  reinforcement · final-cta · footer 소개문 · how-it-works-demo · comparison 토글 · roi-simulator

남긴 곳과 이유
  logo.tsx alt="NEGOTIUM"    로고는 영문 워드마크 이미지다. alt 만 한글로 바꾸면
                             보이는 것과 읽히는 것이 어긋난다.
  footer © negotium Co., Ltd. 법인 표기. 등기된 이름이라 임의로 한글화할 수 없다.
  console.negotium.ai        실제 도메인.
  og:site_name               브랜드 정식 핸들.
  코드 주석                   내부 문서.

메타 타이틀은 "네고시움(negotium)" 으로 둘 다 담았다. 영문을 빼면 negotium 으로
검색해 들어오던 유입이 끊긴다 — 검색 질의는 사용자가 정하지 우리가 못 정한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:19:30 +09:00
09d24f5022 [fix] landing: 구매자 모드 응수에 목표가 반영 · 무대 선택색 · 플레이 화면 유리 패널
구매 담당자 모드
공급사 모드와 같은 문제가 있었다. simulateBuyerRun 의 에이전트 첫 수가 marketLow 만
읊어서, 목표가를 얼마로 잡든 같은 문장이 나왔다 — 위임한 기준이 반영되는지 확인할
길이 없었다. 이제 목표가를 받았다고 명시하고, 시장 최저가 대비 위치에 따라 태도를
달리한다(근거부터 깔겠다 / 무리 없이 접근하겠다).
여는 말(협력사 정가 제시)은 아직 목표가를 모르는 시점이라 상수로 둔다.

무대 위 드래그 선택색
루트의 selection:text-primary 는 라이트 본문 기준이다. 그 값(#0101F3)을 무대(#070E24)
위에 얹으면 명암비 2.0:1 — app.css 의 --color-primary-on-stage 주석이 경고하는 바로 그
상황이다. 무대 섹션에서 텍스트를 드래그하면 글자가 사라졌다.
.stage-bg / [data-stage-hero] 안에서는 무대용 액센트로 갈아끼운다.

플레이 화면 유리 패널
컨트롤과 대화 로그가 섹션 배경 위에 그냥 얹혀 있어 무대와 같은 색이라 경계가 없었고,
조작 가능한 영역인지 읽히지 않았다. 재질은 무대 위 2차 CTA 와 같게 맞췄다 — 화면 안에
재질이 두 종류면 따로 논다. 테두리는 white/12 로 얕게: 면이지 조작 요소가 아니라
버튼만큼 세우면 카드가 버튼처럼 보인다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:17:16 +09:00
4536a48e40 [fix] landing: 제시가를 바꿔도 에이전트 응수가 그대로이던 문제
공급사 모드에서 슬라이더를 움직여도 첫 응수의 문장과 숫자가 똑같았다.

원인은 두 겹이다.
- 근거 문장이 offer 를 참조하지 않았다. cardsUsed === 0 분기가 marketLow 만 읊었다.
- CONCESSION[0] = 0 이라 부르는 값(ask)도 앵커로 고정이다.
둘이 겹쳐서, 얼마를 제시하든 화면이 전혀 반응하지 않는 것처럼 보였다.

값 고수는 유지한다 — 앵커를 지키는 게 이 에이전트의 성격이고, 제품이 파는 신뢰가
거기서 나온다. 대신 무엇에 대한 응수인지는 매번 달라지게 했다.

  정가 이상   정가 그대로는 검토가 어렵습니다 + 시장 최저가 제시
  최저가 위   제시가가 최저가보다 얼마 높은지 구체 수치로
  최저가 이하 최저가 선까지 내려온 것을 인정 + 기준까지 남은 격차

입력란 숫자에 콤마 누락도 같이 고쳤다. 화면의 다른 금액은 전부 won() 인데 여기만
1018800 으로 나와 같은 값으로 안 읽혔다. 확정 시 콤마를 넣고 파싱할 때 벗긴다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:10:50 +09:00
ea1249429a [feat] landing: 무대 위 2차 CTA 에 유리 재질 — 데모 요청·상담 신청
두 버튼 모두 무대에서 stageGhost 를 쓰므로 변형 하나로 해결된다.

구성은 넷 — 옅은 흰 채움, 뒤를 흐리는 backdrop-blur(12px), 위쪽 1px 안쪽 하이라이트
(유리 모서리에 빛이 걸리는 부분), 아래로 깔리는 부드러운 그림자. 채도를 살짝 올리면
뒤가 흐려지며 빠지는 색기가 돌아온다(saturate 1.5).

테두리는 white/40 에서 더 내리지 않았다. /30 이면 무대 광원부(#16205A) 대비가
2.60:1 로 떨어져 WCAG 1.4.11(UI 경계 3:1)에 미달한다 — 유리 느낌 때문에 버튼이
안 보이던 원래 문제(대비 1.09:1)로 되돌아간다. 투명감은 채움과 blur 로 내고 테두리는 지킨다.

hover 는 흰색 반전이 아니라 채움을 밝힌다. 유리는 뒤가 비치는 게 정체성이라
불투명하게 뒤집으면 그 순간 유리가 아니게 된다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:00:46 +09:00
d2f1a8f6f4 [fix] landing: 히어로 배경이 탁하고 흐리던 문제 — 스크림 축소 + 재인코딩
탁함: 스크림이 원인이었다. stage-deep(#03060F) 을 90/60/95 알파로 전면에 깔아
가장 옅은 지점에서도 영상이 40% 만 남았다. 그 값은 헤드라인 둘째 줄이 회색(/60)이던
시절 그 글자를 살리려고 넣은 것인데, 헤드라인이 흰색이 된 뒤로는 근거가 없어졌다.
게다가 중앙이 가장 옅어서 — 정작 글자가 있는 자리 — 보호 목적으로도 방향이 틀렸다.

흐림: 1280x720 crf 30 은 전면 배경으로 무르다. 1600x900 crf 24 로 재인코딩.
업스케일 1.19배 → 0.96배(다운스케일)로 바뀌어 선명도가 올라간다. 631K → 1.7M.

스크림을 걷었더니 이번엔 리드 문단(17px)이 밝은 입자 위에서 안 읽혔다. 헤드라인은
흰색 74px 이라 버티지만 본문은 못 버틴다. 전면을 덮으면 영상이 탁하고, 다 걷으면
본문이 안 읽힌다 — 둘 다 만족하려면 덮는 범위를 글자가 있는 자리로 좁혀야 한다.

  세로 그라데이션  50 / 투명 / 45   헤더 nav 와 하단 전환만 보호
  중앙 방사형      85 → 55 → 투명   카피 뒤만 국소적으로

색은 color-mix 로 --color-stage-deep 를 참조한다. 하드코딩하면 토큰이 둘이 된다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:57:49 +09:00
be039dd92b [feat] landing: 협상 데모에 에이전트 추천가·직접 입력 추가, 히어로 헤드라인 흰색 통일
데모 — 추천하고, 사용자가 고친다
슬라이더 하나뿐이라 정확한 값을 넣을 수 없었고, 에이전트가 추천하는 자리도 없었다.
"에이전트가 제안하면 사람이 확정한다"가 제품의 관계인데 UI 가 그걸 보여주지 못했다.

- 에이전트 추천가 + 근거 + 원클릭 적용. 값은 지어내지 않고 시뮬레이터가 실제로 쓰는
  수치에서 가져온다 — 구매자는 anchor(reachable = target >= anchor 인 최저선),
  공급사는 marketLow(에이전트가 응수할 때 인용하는 값). 지어낸 숫자를 추천이라 부르면
  사용자가 그대로 넣었을 때 결과가 설명되지 않는다.
- 직접 입력란 추가. 실제 구매 담당자는 "얼마까지"라는 숫자를 이미 들고 온다.
  타이핑 중에는 clamp 하지 않고 blur/Enter 에서 확정한다 — 입력 즉시 범위로 당기면
  "1" 을 치는 순간 튕겨서 손가락과 싸운다.
- 슬라이더·직접입력·추천적용이 commitOffer 한 곳을 거친다. 경로가 셋이면 값이 셋 된다.
- 에이전트 응수 후 offer 만 갱신하고 입력란은 두던 버그 수정. 슬라이더와 숫자가 어긋났다.
- 잠금 조건(role === buyer && turns.length > 0)을 inputLocked 로 묶었다.

히어로 헤드라인
"동시에 협상합니다." 를 흰색으로. 예전 캔버스는 하단 밴드에만 입자가 있어 헤드라인
뒤가 거의 단색이었고 그때는 투명도 60% 도 읽혔다. 배경이 영상으로 바뀌며 밝은 요소가
글자 뒤로 지나가 대비가 무너졌다 — 배경이 움직이는 위에서는 가독성이 조판 장치보다
우선한다. 2톤은 배경이 정적인 최종 CTA 에 남아 있다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:52:29 +09:00
2888ff2c28 [feat] landing: 리드 수집 서버리스 함수 + 상담 신청 폼 실전송 연결
랜딩은 ssr:false 정적 빌드라 서버 라우트가 없다. Vercel /api 디렉터리 함수로 받는다.
클라이언트가 같은 오리진(/api/lead)으로 쏘므로 엔드포인트 환경변수도 CORS 도 없다.

api/lead.ts — 리드를 잃지 않는 것이 유일한 책임
  1) 먼저 구조화 로그를 남긴다. 웹훅이 죽어도 Vercel 로그에 리드가 남는다.
  2) LEAD_WEBHOOK_URL 이 있으면 전달(Slack Incoming Webhook·Zapier·자체 API 모두 POST).
     Slack 용 text 와 원본 필드를 함께 실어 수신처를 바꿔도 이 파일을 안 고친다.
  3) 웹훅 실패해도 200 — 1) 에서 리드를 확보했으니 방문자에게 재입력을 시킬 이유가 없다.
     대신 실패는 로그에 크게 남긴다.
  봇 함정(website) 값이 차 있으면 조용히 200. 400 을 주면 어떤 필드가 함정인지 알려주는 셈이다.

contact.tsx — 가짜 성공 제거
  기존엔 setTimeout 1.2초 뒤 성공 화면만 띄우고 아무 데도 보내지 않았다. 화면은
  "접수되었습니다"인데 영업이 받을 리드는 없었다. 이제 같은 함수로 보내고 서버가
  리드를 확보했을 때만 성공을 띄운다. 실패 시 role="alert" 로 안내.

vercel.json — SPA 리라이트가 /api 를 삼키지 않도록 제외.
  "/(.*)" 그대로 두면 /api/lead 요청이 index.html 로 갔다.

데모 요청 모달 카피
  "이름과 이메일만 남겨주시면, 담당자가 일정을 잡아 연락드립니다"
  → "성함과 이메일만 남겨주시면, 사용해보실 수 있는 Demo를 보내드립니다!"
  성공 화면도 같은 약속으로 맞췄다. 폼은 데모를 보낸다는데 성공 화면이 일정을
  잡는다고 하면 방문자가 무엇을 기다려야 하는지 모른다. 라벨도 성함으로 통일.

⚠️ LEAD_WEBHOOK_URL 미설정 시 리드는 Vercel 로그에만 남는다. 로그는 최후의 그물이지
   CRM 이 아니다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:48:06 +09:00
c53eeefcf6 [feat] landing: 데모 요청 리드 캡처 + 히어로·데모 카피 교체
카피
- 데모 섹션 헤드라인: "직접 협상해 보세요 / 에이전트는 정해진 기준을 벗어나지 않습니다"
  → "사용할수록 진화하는 에이전틱 협상, / 직접 경험해 보세요!"
  예전 카피는 FAQ 2번("에이전트가 우리 기준을 벗어나 합의해 버리면요?")을 헤드라인으로
  끌어올린 것이었다. 아직 생기지도 않은 반론에 가장 비싼 한 줄을 쓰고 있었고,
  부정문이라 "안 하는 일"을 약속했다 — 체험을 권하는 자리에 맞지 않는다.
  방어는 FAQ 에 그대로 남아 있다.
- 히어로 1차 CTA: "직접 협상해 보기" → "여기서 직접 체험"
- 히어로 2차 CTA: "도입 문의" → "데모 요청". 기존엔 #contact-section 으로 가서
  GNB "상담 신청" 과 목적지가 같았다 — 버튼만 둘이고 경로는 하나였다.

리드 캡처
- DemoRequestModal 신규. 이름·이메일 두 칸만 받는다. 필드가 늘수록 이탈이 늘고,
  영업 첫 연락에 필요한 최소가 그 둘이다. 나머지는 통화에서 채운다.
- 모달로 띄우는 이유: 히어로에서 관심이 생긴 자리에서 받아야 한다. 스크롤로
  내려보내면 그 사이에 식는다. 고관여 폼(상담 신청)은 그대로 두고 그 앞단을 만든 것.
- 접근성: Esc 닫기, Tab 을 모달 안에 가둠, 열릴 때 첫 필드 포커스·닫힐 때 복원,
  aria-modal/labelledby, 배경 스크롤 잠금, prefers-reduced-motion 대응.

전송 경로 — 성공을 위조하지 않는다
contact.tsx 는 1.2초 뒤 성공 화면만 띄우고 아무 데도 보내지 않는다("백엔드 미연결").
리드 제너레이션에서 그 방식은 최악이다. 화면은 접수됐다고 하는데 영업이 받을 리드는
없고, 아무도 그 사실을 모른다. 그래서 lib/lead.ts 는 VITE_LEAD_ENDPOINT 가 없으면
not-configured 를 돌려주고, 모달은 그걸 실패로 처리해 상담 신청 경로를 안내한다.

⚠️ VITE_LEAD_ENDPOINT 를 설정하기 전까지 데모 요청은 저장되지 않는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:40:28 +09:00
f214fccc49 [feat] landing: 히어로 배경을 캔버스에서 루프 영상으로 교체
배경이 설명을 지던 구조를 걷어냈다. 예전 캔버스는 negotium 파이프라인을 직접
그렸지만(입자 240개·6단계 서사·무리 라벨), 이제 배경은 배경으로만 쓴다.
설명은 카피와 아래 섹션이 맡는다.

영상: hero_veo31 0~5초를 크로스페이드 루프로. 그냥 자르면 끝에서 처음으로 튀어서
끝 0.8초를 앞 0.8초와 겹쳐 이음새를 없앴다(결과 4.25초, 첫↔끝 프레임 RMS 18.6/255).
1280x720 h264 631K + 포스터 58K. webm 은 뺐다 — VP9 이 1.0M 으로 h264 보다 커서
둘을 다 실을 이유가 없다.

- CLUSTER_ANCHORS 라벨 제거. 좌표 소스가 사라진 라벨은 화면비마다 어긋나기만 하고
  아무것도 설명하지 못한다.
- DataFlowPhase 를 orbit-ring 으로 이관 후 data-flow-canvas.tsx(500줄) 삭제.
  단계 이름을 실제로 쓰는 건 강화학습 섹션의 순환 링뿐이다.
- 영상은 가로 화면에서만 튼다. 16:9 를 세로에 object-cover 로 깔면 가로가 62% 잘리고
  남은 38% 가 1.7배로 확대된다(848x1220 실측) — 입자가 거대한 보케로 뭉개져 배경이
  아니라 노이즈가 된다. 세로에서는 무대 그라데이션만 남긴다. 모바일 대역폭도 아낀다.
- prefers-reduced-motion 도 같은 경로로 영상을 감춘다. 포스터가 뒤를 받친다.
- 스크림은 색 하나(stage-deep)로 알파만 움직인다. 중간 stop 에서 색을 바꿨더니
  그 지점이 가로선으로 읽혔다.
- 밴드 개념이 사라져 히어로 레이아웃을 카피 중앙 정렬로 되돌렸다. 직전 커밋의
  --band-top 기준 높이 계산은 더 이상 필요 없다.

검증: 1440x860 크롭 7%, 375x812 영상 숨김·제목 헤더 여유 152px, 콘솔 에러 없음.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:24:21 +09:00
63717f5eb9 [fix] landing: 무대 위 2차 CTA 가 버튼으로 안 읽힘 — stageGhost 대비 확보
GNB "상담 신청" 이 기본 상태에서 버튼으로 인식되지 않았다. 채움이 없고
테두리는 사실상 안 보여서, 남는 건 흰 글자와 화살표뿐 — 옆 nav 링크와
구분될 근거가 없었다.

테두리를 stage-line(#1E2A52)으로 잡은 게 원인이다. 그 색은 무대 바닥(#070E24)
위 카드용인데, 헤더는 방사형 그라데이션의 광원부(#16205A)에 앉는다. 같은 색
가족이라 하필 헤더 자리에서만 선이 배경에 묻힌다.

  수정 전  테두리 대비 1.09:1  (배경과 사실상 동일)
  수정 후  테두리 대비 3.56:1  (WCAG 1.4.11 UI 경계 3:1 통과)

무대색 토큰 대신 흰색 알파로 잡았다 — 무대 어디에 놓여도 같이 성립한다.
옅은 채움(white/10)도 함께 준다. "누를 수 있는 것"은 테두리보다 면이 더
강하게 전달한다.

히어로 "도입 문의" 도 같은 변형이라 함께 해결된다. 1차 CTA(파란 채움)와의
위계는 그대로다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 09:28:23 +09:00
7f15bc10d1 [fix] landing: 히어로 카피와 입자 밴드 겹침 — 경계를 구조로 강제
버튼 행이 입자 밴드를 파고들었다. 1280x720 에서 34px, 무리 라벨과는 7px.
1440x900 에서만 여유가 있어(45px) 안 보이던 문제다.

원인은 두 값이 서로 다른 기준으로 움직인 것:
  카피 상단 여백  19vh        화면 비율
  카피 높이       351px       고정
  밴드 시작       0.63×높이   화면 비율
카피만 px 고정이라 화면이 짧아지면 밴드가 카피를 덮는다.
0.19H + 351 > 0.63H  →  H < 798px 이면 전부 겹친다.
1280x720, 1366x768 같은 흔한 노트북 높이가 여기 들어간다.

pt-[19vh] 는 위치만 잡을 뿐 경계를 강제하지 못한다. 그래서:
- 카피 영역 높이를 밴드 시작선에 묶었다. 뷰포트와 무관하게 구조적으로 못 겹친다.
- BAND_TOP 을 export 해 캔버스와 카피가 같은 값을 쓴다. 라벨 좌표가 이미 쓰던 방식이다.
- 높이·밴드를 한 식에서 뽑는다. 따로 두면 어긋난다 — 밴드를 63vh 로 두고 높이만
  키웠더니 짧은 화면에서 변수가 실제 밴드보다 위를 가리켰다.
- min-h 바닥 780px. 밴드 위 공간에 헤더(104)+카피(351)+여유(24)=479px 가 들어가려면
  높이 760px 이 하한이라, 정렬만으로는 짧은 화면을 못 구한다. 살짝 스크롤될 뿐이다.
- pt-[104px] 를 md 이상에서도 유지. 빼면 가운데 정렬이 카피를 fixed 헤더 밑으로 민다.

검증(실측, 음수 = 밴드 밖):
  1280x620   +20 → -30,  헤더 여유 +12,  라벨 75px
  1280x720   +34 → -30,  헤더 여유 +12,  라벨 75px
  1440x900   -45 → -68,  헤더 여유 +50,  라벨 122px
  375x812          -57,  헤더 여유 +6

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 09:22:54 +09:00
민헌
7b1bcb6d24 docs(lps): README 에 크롤·매칭·IP 로테이션 실제 방식 추가 + 낡은 서술 정정
"어떻게 동작하나"가 워크플로우 그림 한 장뿐이라, 정작 이 시스템에서 어려운 세 가지
(안티봇 뚫는 법·같은 상품 고르는 법·IP 돌리는 법)를 README 만 봐서는 알 수 없었다.
기존 스타일(표·ASCII·비개발자 설명)을 유지해 '🔬 안을 열어보면' 절을 추가한다.

- 소스별 크롤: 네이버(모바일 msearch·WTM)와 쿠팡(Akamai)의 **정반대 전략**을 표로 대조.
  네이버는 리소스 차단을 끄고(요청 가로채기 자체가 탐지 신호) 한국 IP+ko-KR 로케일이 필수,
  쿠팡은 리소스를 막아 대역폭을 줄인다. 스크롤은 횟수가 아니라 '안 늘어남'이 종료 조건인 이유도.
- 같은 상품 판정: 필터 3단계 + AI 판정, '무시할 차이 / 불일치로 볼 차이' 기준표,
  후보를 10건씩 쪼개야 하는 이유(37건 일괄 → 전멸).
- IP 로테이션: 포트=sticky 세션, 게이트웨이 2개, DB 장부(SKIP LOCKED·LRU),
  포트 3상태(임대/휴식/쿨다운), 회전 계기 4종, **태우지 않는 경우**(구조적 차단·서킷브레이커·
  확신 없는 0건)와 예산을 IP 기준으로 세는 이유.

낡은 서술 정정:
- "네이버 쇼핑 API" → 오픈API 는 2026-07-31 종료, 지금은 둘 다 크롤
- 테이블 5종 → 6종(proxy_port), 알림 10룰 → 11룰(fatal_block)
- 요청 예산 "기본 3회" → 쿠팡 3·네이버 10
- config 설명 "배포는 env 주입" → 실제로는 config.local.toml 마운트, env 는 DB 접속점만
- 폴더 구조에 crud/port_lease·profile_slot 추가

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 17:23:23 +09:00
민헌
377389f495 fix(lps): IP 로테이션 안정성 검수 — 예산이 안 먹던 근본 원인 + 차단 시 풀 소각 차단
크롤·IP 로테이션을 검수하며 찾은 결함을 순서대로 고쳤다. 의심 지점은 모두 실제 코드 경로로
재현해 확인했다(브라우저·네트워크만 mock, 프록시·DB 장부는 실물).

**① 요청 예산이 사실상 발화하지 않았다 (핵심)**
유휴 정리(close_if_idle, 120s)는 브라우저만 닫고 임대는 두는데, 재기동 때마다 _ip_requests 를
0 으로 되돌렸다. 게다가 ensure_port 의 renew 가 임대 만료를 계속 뒤로 민다 — 검색이 유휴
임계보다 뜸하고 임대(10분)보다 잦으면 **한 IP 에 영원히 고정**된다(실측: 6회 검색이 전부 같은
포트·ip_req#1). 연속 검색에서는 정상 동작해 부하 테스트로는 안 잡히고, 수동 트리거처럼
드문드문한 실사용 패턴에서만 깨진다.
파급이 하나 더 있다 — bot_detection.ip_request_no 가 항상 1 로 찍혀, operations.md 가 명시한
'1 위주면 IP 평판 / 2 이상이면 예산 하향' 진단이 통째로 무너진다. 과거 "전량 ip_req#1 이라
IP 평판 문제" 결론은 이 착시일 수 있다(문서에 경고 추가).
→ IP 세션 상태를 브라우저 수명과 분리. **포트가 실제로 바뀔 때만** 리셋한다(_begin_ip_session).
   세션 종료 기록도 포트 변경·최종 close 시점으로 옮겼다(idle 사유 소멸).

**② 환경 차단이면 회복 못 하는데 풀을 계속 태웠다**
쿠팡은 fatal 마커가 없어 컨테이너 차단 같은 '회전 무효' 상황을 구분 못 했다. 실측으로
웜업 6포트 + 잡 1건당 6포트를 30분 쿨다운에 묶어 **잡 16건이면 100포트 고갈**. 실제 장부에도
9분간 11포트 연속 소각 이력이 남아 있다(gate 사용 21 / 소각 14).
→ 서킷브레이커: **서로 다른 IP 가 연속 3개 모두 첫 요청부터** 막히면 IP 문제가 아니라고 판정,
   태우기를 멈추고 fatal 로 알린다(env_block 마커 → 기존 fatal_block 알림이 집계).
   같은 IP 반복 차단·뒤쪽 요청 차단은 세지 않는다. 성공 1회로 자동 해제(타이머 불필요).
   결과: 전면 차단 시 소각이 판정 근거 2개에서 멈춘다(웜업 6→0, 잡 6→0).

**③ 종료가 임대를 반납하지 않았다**
close() 후에도 leased_until(최대 10분)까지 그 IP 를 아무도 못 썼다 — 재시작이 잦을수록 가용
풀이 줄었다. DecodoProxy.release() 추가, close() 에서만 호출(유휴 정리는 웜 쿠키·예산 유지를
위해 그대로 둔다).

**④ 시간창 재기동이 IP 를 안 바꿨다** — 로그만 'IP 회전'이었고 renew 로 같은 포트를 붙잡았다.
sticky 수명이 끝나면 같은 포트라도 IP 가 바뀌므로 명시적으로 놓아준다.

**⑤ '검색결과 없음'을 차단으로 오인해 IP 를 태울 수 있었다**
네이버 무결과 페이지 크기는 실측된 적이 없는데 short_html 폴백이 이를 차단으로 본다.
확신도로 대응을 갈랐다 — 알려진 마커만 태우고/서킷브레이커에 세고, 미지의 짧은 HTML 은
회전·재시도까지만. 판단 근거는 bot_detection 에 계속 쌓이므로 나중에 임계를 실측할 수 있다.

**⑥** available_ports() 가 장부 모드에서 늘 최대값을 반환하는 점을 문서화(관측 경로는 미사용).
세션 마감을 멱등하게 만들어 close() 중복 호출 시 이중 기록 방지.

테스트 14건 추가(전체 251 passed). mock 하니스도 실물을 타도록 고쳤다 — 회전 시 포트가 실제로
바뀌고, 재기동 판단·IP 세션 경계는 실제 코드를 그대로 쓴다(고정 포트 mock 은 이 버그를 못 봤다).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 17:09:01 +09:00
d3bcc61609 [chore] landing: Vercel 배포 설정 추가
랜딩은 ssr:false + prerender 인 SSG 라 정적 산출물만 서빙하면 된다.
프레임워크 자동 감지에 맡기면 React Router 를 SSR 로 잡을 수 있어
framework:null 로 명시하고, outputDirectory 를 build/client 로 고정한다.
SPA 리라이트는 Dockerfile.prod 의 nginx try_files 와 동등하게 맞췄다.

.vercel 은 gitignore. 프로젝트·조직 ID 가 들어가는 로컬 링크 파일이다.

배포 메모:
- 저장소가 Gitea 라 Vercel Git 연동(푸시 시 자동 배포)은 안 된다. CLI 수동 배포.
- 새 프로젝트의 첫 배포는 --prod 없이도 production 으로 승격되니,
  preview 를 원하면 --target=preview 를 명시할 것.
- preview 배포는 Vercel 이 x-robots-tag: noindex 를 자동으로 붙인다.
  색인 차단 목적으로 public/robots.txt 를 건드리지 말 것 — 출시 때 되돌리는 걸 잊는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:36:40 +09:00
55e3ab02aa [fix] landing: 섹션 세로 리듬 통일 — 간격을 SectionHeading 이 소유
디자인 시스템이 색·활자만 소유하고 간격은 각 섹션 파일에 흩어져 있어,
같은 관계에 값이 여러 개 생겼다. 스크롤할 때 섹션마다 호흡이 달라지던 원인.

  eyebrow → title      12/20px      → 16px
  title → description  16/20/32px   → 20px (display 티어 32px)
  머리 → 본문          48~96px 5종  → 56px / 80px(md)
  컨트롤 → 본문        56/64px      → 48px
  리드 → CTA           56/48px      → 48px

- SectionHeading 이 리듬을 소유하고 HEADING_GAP·CONTROL_GAP·CTA_GAP·
  DISPLAY_LEAD_GAP 를 노출. 섹션 파일에서 mb-24 같은 값을 직접 쓰지 않는다.
- negotiation-demo·final-cta 가 Section/SectionHeading 을 우회해 머리를 직접
  조판하고 있었다 — 아이브로우 간격이 이 둘만 20px 이던 원인. 프리미티브로 환원.
- SectionHeading 이 eyebrow 없이도 빈 span 을 렌더해 유령 여백이 생기던 버그 수정.
- descriptionClassName 의 text-[17px] 중복 선언 제거. lead 변형이 이미 갖고 있는
  모바일 축소 단계(text-[16px] sm:text-[17px])를 지우고 있었다.
- reinforcement: text-balance 가 쉼표를 넘어 끊던 헤드라인을 <br /> 로 명시.

reinforcement 만 gap="none" 인데, 머리가 그리드 셀 안이라 마진이 상쇄되지 않아
items-center 정렬을 밀기 때문. 간격은 그리드 래퍼가 진다.

검증: tsc --noEmit 통과, 프로덕션 빌드 통과. DOM 실측으로 375/868/1440px
전 구간에서 머리→본문·eyebrow→title·컨트롤→본문 값 일치 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:22:24 +09:00
a416668256 [feat] negodata/front: 설정 화면 회사/개발자 분리 + 카드 변수 별칭·미리보기 정비 + 네고율 공통화
- 설정 분리: /settings(최고관리자)는 공급사에게 보이는 브랜딩·안내 문구만, /dev/settings(개발자 신설)는
  협상 동작·데이터 구조를 바꾸는 탭(용어·커스텀 필드 등) — 화면에 보이는 것과 동작을 바꾸는 것을 관리 주체로 가름
- 카드 변수: prev_customer_price(당사 직전가) 칩 추가, 시드/구버전 별칭(anchoring_price 등)은
  툴바 미노출·인식만 지원. SlateRenderer 는 평문에 박힌 미주입 토큰도 카탈로그에 있으면 한글 라벨로 표기
- 견적상세 협상대화의 사용 카드 미리보기를 서식 렌더(renderCardScriptPreview)로 — 마커 원문 노출 제거
- 네고율 차감을 매입가 전용 → 매입가·판매가 공통으로(문구·주석 정리)
2026-08-05 16:00:31 +09:00
54f8f7a6a1 [feat] negodata/front·agent: 카드 제시 가격 변수 선택 + 종결 전용 토글 와일드카드 한정
- 카드 상세 "협상 전술"의 제시 가격을 표시 전용 → 셀렉트로: 기본 '자동(멘트의 마지막 가격 변수)',
  가격 변수가 여럿인 카드만 명시 선택이 의미. 선택지는 멘트에 실제 꽂힌 변수로 제한하고
  멘트 수정으로 선택 변수가 사라지면 자동으로 리셋 — 문구≠계산 어긋남 원천 차단.
  명시 선택 시에만 tactic.offer_variable 저장(agent build_card_spec 이 파싱보다 우선 적용, 기존 경로).
- 종결 전용 토글은 와일드카드 폼에만 노출 — 종결 국면이 와일드카드 목록에서만 카드를 뽑으므로
  협상카드에 켜면 어느 경로에서도 발동하지 않는 죽은 카드가 된다. 협상카드는 저장 시 항상 false.
- 퍼즈 하네스 100케이스로 확대(시드 고정) — 100/100 불변식 위반 0(타결 94/결렬 6).
- override 우선순위 단위 테스트 추가(자동=마지막 변수, tactic.offer_variable 지정 시 지정 변수).

검증: agent 179 통과 · front tsc+eslint 통과 · 랜덤 협상 100회 완주 위반 0
2026-08-05 15:58:23 +09:00
민헌
042e2d3012 docs(lps): 2026-08-05 세션 기록 — 변경 배경·결정 근거·미해결·다음 할 일
다음 세션이 이어받을 수 있게 남긴다.
- docs/2026-08-05-session-notes.md 신규: 왜 네이버가 크롤로 갔는지, 이번에 정한 것
  (추천순 유지·순위는 상품가·신뢰 신호·AI 배치 10건)과 그 근거, 미해결(컨테이너 크롤 차단),
  다음 할 일 5가지, 작업 시 주의(테스트가 실DB를 TRUNCATE 한다)
- architecture.md: IP 회전을 DB 장부·게이트웨이 2개·회전무효 차단 구분·AI 배치로 갱신
- database.md: proxy_port 테이블 추가(6종), price_history 의 신뢰·배송 컬럼 설명과
  '미검증 오퍼 비율' 쿼리
2026-08-05 15:51:19 +09:00
민헌
b2b5708a6a fix(lps): AI 매칭이 후보 많으면 통째로 무너지던 문제 — 배치 분할 + 규칙 충돌 제거
A/B 실험(상품 8개)에서 2개가 매칭 0~1건으로 사실상 실패했다. 수집은 40건 정상, 가격도
정확했는데 판정이 못 찾았다. 파고드니 두 가지였다.

**① 일괄 판정 붕괴(핵심)**: 후보 37건을 한 번에 넣으면 gpt-4o-mini 가 전 항목에 같은 점수(70)를
매기고 **전부 불일치**로 답한다. temperature=0 에서 3회 재현. 10건씩 쪼개면 같은 모델·같은
입력으로 12건이 매칭된다. 상품 3종에서 모두 재현(크리넥스 0→12, 다우니 8→23).
→ _BATCH=10 으로 쪼개 병렬 판정 후 전체 index 로 복원. 지연은 배치 1개분, 토큰은 합산.

**② 프롬프트 규칙 충돌**: "포장(개수/박스) 차이는 동일 상품" vs "용량·규격이 다르면 불일치" 가
함께 있어 '30롤 1팩'과 '30롤 2팩'(=60롤)을 어느 쪽으로 볼지 정할 수 없었다. 최저가 관점에선
수량이 다르면 다른 상품이다(60롤 값을 30롤 최저가로 쓰면 왜곡). 종류 혼동도 실측에서
오탐을 만들었다 — '프라이팬'을 찾는데 볶음팬·웍팬이 매칭됐다.
→ 규칙을 '무시할 차이 / 불일치로 볼 차이'로 갈라 재작성(수량·종류 명시).

결과(캐시 후보 재판정):
  크리넥스 30롤   매칭 0 → 5건, 최저가 **not_found → 15,500원**
  다우니 4.1L    매칭 8 → 12건, 최저 21,890 유지
  테팔 프라이팬   매칭 2 → 0건 (볶음팬·웍팬 오탐 제거 — 후보에 진짜 프라이팬이 없었다)

테스트 6건 추가(배치 분할 시 index 매핑·누락 보수처리·토큰 합산·빈 후보·상수 상한·규칙 충돌),
전체 237 passed.
2026-08-05 15:45:32 +09:00
민헌
8574df4c16 feat(lps): 배송 표기 원문 기록 — 순위는 상품가 유지, 배송은 근거로 남긴다
배송 주체가 다르면(쿠팡 로켓 / 판매자로켓 / 네이버 판매자) 배송비 숫자만으로는 비교가
무의미하다. 그래서 **순위는 지금처럼 상품가**로 두되, 배송 정보는 사후 판단이 가능하도록 남긴다.

실측 조사(섬유유연제·생수 2L, 두 소스):
  네이버  배송비무료 / 배송비3,000·3,900·4,500·5,000·8,800·9,000·10,000원 /
          내일배송 8.6.(목) 도착 · 오늘출발 · 빠른배송 / **배송비포함 혜택가 N원**(가격비교 카드)
  쿠팡    내일(목) 도착 보장 · 와우는 무료배송 ∙ 무료반품 ∙ 새벽도착 / 무료배송 ∙ 오늘출발 /
          모레(금) 도착 예정. 뱃지=logo_rocket_filter(로켓)·logo_rocket_merchant(판매자로켓)

- NormalizedProduct.shipping_label: 화면 문구 원문. 같은 '무료배송'이어도 주체·조건
  (와우회원·최소금액·새벽도착)이 다른데 숫자·분류로는 그게 사라진다
- 네이버: 배송비 문구 + 도착 정보를 잇는다. **_prices() 안에서 뽑는다** — 가격 노드에서
  배송비를 decompose 하기 전에 읽어야 해서(나중에 추가했다가 가격비교 카드에서 라벨이 통째로 빔)
- 쿠팡: 배송 문구가 유틸리티 클래스(fw-text-[14px])에 담겨 셀렉터로 못 집는다 → 텍스트 패턴으로
  조각을 모으고, 구분자 없이 붙은 상위 컨테이너("내일(목) 도착무료배송")는 조각 2개 이상을
  품은 것으로 판별해 버린다. selectolax 의 node.css("*") 가 자기 자신을 포함해
  '자손 매칭' 방식은 못 쓴다(실측)
- price_history.final_shipping_fee/type/label 추가(+마이그레이션). fee 는 0=무료,
  NULL=미확인(로켓 조건부) — 둘은 다른 뜻이라 기본값을 두지 않았다

교차 검증(파싱값 vs 카드 원문, 6개 규칙): 네이버 40건·쿠팡 40건 **불일치 0**.
테스트 3건 추가, 전체 231 passed.
2026-08-05 14:40:00 +09:00
c56cf8e3af [feat] agent: 타결선을 목표가 → 타결 상한가로 — 목표가 초과 낙찰 허용(IMK 0803 ②)
목표가를 1원이라도 넘으면 결렬되던 탓에, 기존 단가보다 인하됐는데도 결렬되는 케이스가 있었다
(EST-202607-973E: 기존 17,500 / 목표 16,980 / 최종 17,300). 견적 생성 시 세션에 박제해 두던
done_ceiling_price(목표가×(1+타결상한율), 세팅 기본 +5%)를 협상 엔진이 실제로 읽게 배선했다.

- tactics: settle_ceiling() 신설 — 타결선 판정을 한 곳으로. 박제가 없는 옛 세션·데모는 목표가 폴백
- 카드 제안가 유효조건의 상한도 목표가 → 타결 상한가 (받아줄 수 있는 금액까지는 부를 수 있어야 함)
- _render 가드레일이 목표가 초과 성공을 결렬로 되돌리고 있어 같이 상한 기준으로 교정 —
  타결 판정만 고치면 이 가드에서 다시 뒤집혀, 배선했는데도 결렬로 떨어졌다
- crud/loader/세션 컨텍스트에 done_ceiling_price 적재

검증(목표가 956,580 · 상한 1,004,410): 950,000·1,000,000·1,004,410 타결 / 1,004,500·1,010,000 결렬.
agent 테스트 178건 통과.
2026-08-05 14:10:38 +09:00
민헌
6c7ff5af51 feat(lps): 최저가 오퍼의 신뢰 신호(평점·리뷰) 수집 — 네이버·쿠팡 공통
'가장 싼 값'과 '실제로 살 수 있는 가장 싼 값'은 다르다. 리뷰·평점이 전혀 없는 오퍼는
재고 없는 미끼가격일 수 있고, 그걸 최저가로 보고하면 사용자는 그 가격에 살 수 없다 —
조금 비싼 정답보다 나쁘다. 판단 근거를 수집해 둔다.

- NormalizedProduct.rating / review_count 추가. 두 소스 모두 카드에 노출하는 값만 담아
  교차 비교가 되게 했다. **없으면 None 유지** — '리뷰 0개'와 '리뷰 정보 없음'은 다른 뜻이다
- 네이버: product_grade 의 <strong>평점</strong><em>리뷰수</em>. 텍스트를 통째로 정규식
  돌리면 '평점4.7473' 이 4.74/73 인지 4.7/473 인지 못 가르므로 노드로 분리해 읽는다.
  '1.7만' 같은 축약은 parse_ko_count 로 푼다(그대로 int() 하면 1 이 된다)
- 쿠팡: 별점은 채워진 별 개수가 아니라 컨테이너 aria-label 에, 리뷰 수는 괄호 텍스트에 있다
- price_history.final_rating/final_review_count 추가(+마이그레이션) → "리뷰 0인 최저가가
  몇 %인가"를 SQL 로 물을 수 있다. NULL 과 0 을 구분해야 해서 기본값을 두지 않았다

정렬 점검(사용자 제기): 두 소스 다 정렬 파라미터 없이 **랭킹/추천순**이다(픽스처 가격이
오름차순이 아님으로 확인). 가격순(sort=price_asc)은 차단 없이 동작하고 실측상 더 싼 후보를
찾지만(15,400→10,900), 리뷰·평점 없는 유령상품을 위로 끌어올려 미채택 — 추천순 유지.
신뢰 신호가 쌓이면 "리뷰 N 이상" 가드를 걸고 가격순을 켜는 선택지가 열린다.

e2e: TR-1/TR-2 최저가에 평점 4.89·리뷰 7,314/102,000 이 함께 기록됨. 테스트 5건 추가, 228 passed.
2026-08-05 14:00:12 +09:00
민헌
5c9567919f feat(lps): 네이버 회수율 48→120건(적응형 스크롤) + 크롤 차단을 기동 즉시 알림
**회수율**: 베이스 _wait_ready 는 '고정 3회 스크롤 → 셀렉터 대기' 순서라, 프록시 지연이 있으면
**아직 아무것도 안 그려진 화면을 스크롤**하고 끝났다. 네이버용으로 순서를 뒤집고 종료 조건을
횟수가 아니라 '카드 수가 더 안 늘어남'으로 바꿨다 — 네트워크가 느리든 빠르든 같은 결과가 나온다.
A/B(같은 IP·같은 세션, 3개 쿼리): 14·14·20 = 48건 → **40·40·40 = 120건**(전부 상한 도달).

**크롤 프리플라이트**: 웜업 대상에 naver 를 추가하고, 3회 모두 실패하면 로그가 아니라 **알림**을
쏜다. 컨테이너 워커는 크롤이 막혀도 하트비트가 살아 있어 healthy 로 보이고, 잡이 DEAD 로
쌓일 때까지 아무도 모른다(실측). 성공하면 해소 알림으로 자동 정리된다.
AlertManager 를 main 에서 만들어 웜업·ops 모니터가 쿨다운 상태를 공유한다.

**문서**: operations 에 차단 마커별 대응표(비정상적인 접근=구조적/wtm_captcha=회전)와
'컨테이너 크롤 차단' 절 추가 — 배제한 원인, Rosetta 에뮬 주의(= '이 맥에서만'일 수 있음),
배포 시 확인 순서(warmup 로그 → 호스트 비교 → 워커만 호스트 실행).

테스트 3건 추가(웜업 실패 알림·성공 해소·비크롤 소스 스킵), 전체 223 passed·0 failed.
2026-08-05 13:33:19 +09:00
민헌
b7fc327779 refactor(lps): 사문화된 네이버 오픈API 어댑터 제거 + 낡은 테스트 계약 갱신
**낡은 테스트**: test_handler_skips_record_on_negative_cache_hit 는 '캐시 히트면 이력을
남기지 않는다'를 검증했는데, 그 동작은 실측 버그였다 — 잡은 DONE 인데 price_history 에
새 행이 없어 이를 폴링하는 소비자(negodata 최저가 모달)가 결과를 영영 못 받고 로딩만 돌았다.
코드는 이미 '캐시 히트도 이 잡의 결과이므로 기록한다'로 고쳐져 있었고 테스트만 남아 있었다.
→ 현재 계약(not_found 스냅샷 1건 기록, 가격은 null)을 검증하도록 다시 씀. 전체 220 passed·0 failed.

**오픈API 어댑터 제거**: shop.json 이 2026-07-31 종료돼 404 SE05 만 반환하고, 파이프라인은
naver_shop(크롤)로 옮겨 갔다. 되살릴 수 없는 코드를 남겨두면 다음 사람이 "키를 넣으면 되나"
하고 시간을 쓴다.
- services/search/naver/ (adapter·transform) 삭제
- NaverConfig 모델·로더·설정 섹션 3개 파일에서 제거(죽은 키)
- test_naver_transform 삭제, test_alerts 는 NaverAdapter 대신 스텁 사용
  (검증 대상인 recent_stats/_note_result 는 베이스 SearchAdapter 계약이라 무관)

**문서 정합화**: architecture(네이버 안티봇=WTM, 통과 3조건) · api(배송비가 이제 채워짐,
가격은 즉시판매가·쿠폰가 제외) · operations(kr_host·naver_ip_request_budget) · README 트리.

source 이름 "naver" 는 그대로다 — price_history·by_mall·프론트 계약은 구현 교체와 무관하다.
2026-08-05 13:26:10 +09:00
민헌
18fe18dd39 feat(lps): 회전으로 못 고치는 차단을 따로 구분 — 태우지 않고 즉시 실패 + 설정 알림
네이버 차단은 두 종류인데 지금까지 똑같이 '회전 후 재시도'로 처리했다(실측):
  비정상적인 접근  2.6KB  해외 IP — 게이트웨이 국가가 틀림. IP 를 바꿔도 결과 동일
  wtm_captcha    47~63KB IP 평판·세션 — 회전으로 회복 가능

전자를 회전시키면 100포트를 순서대로 태우기만 하고, 더 나쁘게는 **같은 게이트웨이를 쓰는
쿠팡의 풀까지 30분씩 말린다**(kr_host 를 비우면 네이버가 gate 로 폴백하므로 실제로 일어난다).

- AdapterError.fatal: 재시도해도 안 되는 구조적 실패 표시
- BrowserSearchAdapter.fatal_block_markers: 걸리면 포트를 태우지 않고 회전도 없이 즉시 실패.
  감지 기록(bot_detection)은 남긴다 — 알림이 그걸 센다
- NaverShopAdapter.fatal_block_markers = ("비정상적인 접근",)
- ops 알림 'fatal_block': 해당 마커가 1h 내 1건만 나와도 발화(자연 회복이 없어 방치하면
  그 소스는 계속 0건이다). BotDetectionLog.recent_count_by_marker 추가

라이브 검증: 일부러 해외 게이트웨이로 네이버 검색 → fatal=True 로 즉시 실패,
쿨다운 증가 0(태우지 않음), ERROR 로그에 원인·조치(kr_host 확인) 명시.
테스트 3건 추가(태우지 않음·회전 없음 / 기록은 남김 / 일반 차단은 기존대로), 전체 220 passed.
2026-08-05 13:22:37 +09:00
6f5d69b5cb [feat] agent·negodata: 협상카드 전술 데이터화(tactic JSONB·스크립트 파싱) + 발동규칙 정비
IMK QA 2건(BB9A 카드 중복·8AB0 중간값 오계산)의 근본 원인이 전술 하드코딩(_TACTICS 번호 매칭)이라
전술을 데이터로 옮기고, 발동을 유효성 검사로 바꿨다. 전술 정본 = 카드 스크립트의 마지막 가격 변수(파싱),
문장으로 알 수 없는 운영 규칙(closing·min_round)만 card.*.tactic JSONB. 세션 시작 시 card_specs 스냅샷 박제.

발동 유효성(하나라도 걸리면 그 라운드 미발동 — 클램프 폐지):
목표가 초과 / 협력사 제시가 이상 / 당사 직전 제안 미만(역행 금지, IMK 논의) / 재료 결측 /
필수 변수 결측(시장가 카드 requires — 토큰 노출 방지) / 이미 쓴 카드(played_card_numbers 공용 이력)

- agent: 와일드=비종결·종결=전용 풀 분리(같은 카드 2회 구조적 차단), 발동 시 자기 제안가 기록(절충가 수렴),
  진입 존 프로브(빈 덱 재사용 교착 방지), 낼 카드 전무 시 소진→종결, 무효 금액 카드는 설득 폴백도 금지(playable),
  에디터 anchor_price 별칭 등록, 에러 재렌더 변수 치환
- backend: 카드 사용 기록을 step 휴리스틱→번호 prefix 판정(종결 발동 card:null 누락 해소)
- negodata: 카드 상세 "협상 전술" 섹션(제시 가격 파싱 표시·종결 전용·최소 라운드) + tactic API 배선
- postgres-init: tactic 컬럼·시드(WC-03/05 closing, WC-04 min_round 2), 멱등 alter 로 dev 정본화
  (번호 WC-0x 정규화, WC-01·03·NGC-010 구멘트 교체, WC-05 변수 middle_price 교정)

검증: agent 178 통과 · 시나리오 하네스 14케이스(BB9A·8AB0·역행 실수치 재현) · 랜덤 퍼즈 50협상 불변식 위반 0
(불변식: 카드 중복 금지·종결 카드 자리·타결가≤목표가·표시가=타결가·토큰 잔존 금지·종료 보장)
2026-08-05 11:48:14 +09:00
71ef28110b [feat] negodata·agent·negosium: 협상 기준가 회사별 선택(공급가/매입가) + 공급사 포털 안내 회사설정화
어느 가격이 "공급사에 지불하는 단가"인지는 회사마다 달라, 컬럼을 합치는 대신 회사가 고르게 했다.
판정은 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
2026-08-05 11:47:06 +09:00
민헌
3b345929d4 feat(lps): 포트(IP) 장부를 DB 로 올려 멀티 프로세스에서 안전하게 — 프로필 슬롯도 분리
한 DECODO 계정을 여러 워커 프로세스가 나눠 쓰는 전제로 전환한다. 인메모리 장부는
프로세스마다 따로라 (1) 같은 IP 를 동시에 잡고 (2) 한쪽이 태운 IP 를 다른 쪽이 곧바로
집으며 (3) 재시작하면 쿨다운이 통째로 사라졌다.

proxy_port 테이블 = 단일 진실. 상태는 세 시각으로만 표현한다(leased/rest/cooldown_until).
- acquire: 한 UPDATE 안에서 FOR UPDATE SKIP LOCKED 로 후보를 잠그고 임대까지 끝낸다
  (잡 큐와 같은 방식 — SELECT 후 UPDATE 로 나누면 그 틈에 다른 프로세스가 같은 행을 집는다)
- 회전은 LRU(last_used_at). 프로세스가 몇 개든 '가장 오래 안 쓴 IP'를 집으므로 전체가
  자연히 한 바퀴씩 돈다 → 프로세스별 seed_offset 계산 제거
- 죽은 프로세스 회수: leased_until 만료로 자동 복귀(별도 reaper 불필요)
- 차단·휴식은 전역이라 재시작해도 유지된다

DB 왕복은 비동기라 검색 루프(동기)에서 곧바로 못 한다 → 회전·차단을 pending 에 적어두고
ensure_port(브라우저 재기동 직전, async)에서 한 번에 flush. _close_ctx 에서도 flush 해
종료 시 유실(=태운 IP 를 남이 그대로 집는 상황)을 막는다.

**프로필 슬롯**(services/search/profile_slot): Chrome 은 user_data_dir 당 1 인스턴스다.
예전엔 워커 인덱스로만 갈라서 프로세스 2개면 같은 경로를 잡아 두 번째가 통째로 죽었다
(실측: 잡 3건 중 2건 DEAD, TargetClosedError). 파일 락으로 슬롯을 선점한다 — PID 경로가
아니라 슬롯이라 재시작 시 재사용돼 웜 쿠키(cf_clearance·Akamai)를 버리지 않는다.

검증: 프로세스 2개 동시 acquire 20회 → 중복 배정 0건. 워커 2프로세스 e2e → 잡 3건 모두
DONE(네이버가 삼다수 최저가 획득 8,960 < 13,200). 테스트 14건 추가, 전체 217 passed.
2026-08-05 11:44:54 +09:00
민헌
495e16b951 feat(lps): IP 를 태우지 말고 한계 직전까지 쓰고 쉬게 — 네이버 예산 상향 + 휴식 개념
기존엔 네이버가 쿠팡 기준 예산(3회)을 그대로 썼다. 실측하니 체급이 다르다:
같은 KR IP 로 **12회 연속 검색까지 무차단**(IP 4개 전부 한계 미도달). 3회로 돌리면
불필요하게 4배 자주 회전해 KR 풀만 빨리 소모하고 회전마다 브라우저 재기동(~20s)이 붙는다.
→ [DecodoConfig].naver_ip_request_budget = 10 (실측 12 에 여유). 쿠팡은 3 유지.

그리고 선제 회전에 빠져 있던 조각을 채웠다 — **휴식(rest)**:
예산 도달로 놓은 포트를 곧바로 다른 워커가 집으면 그 IP 의 요청률이 도로 올라가
예산의 의미가 사라진다. release(rest_sec=...) 로 sticky 수명만큼 쉬게 한다.
차단으로 태우는 burn(30분)과는 별개 상태다:
  휴식  탄 게 아님 · 짧음 · 소진 시 가장 먼저 회수
  쿨다운 차단당함 · 김 · 휴식보다 나중에 회수
회전 종류(kind)를 browser_base → DecodoProxy.rotate(kind) 로 전달해 budget 일 때만 휴식을 건다.

라이브 검증(예산 3으로 낮춰 관찰): 6회 검색 = IP 2개만 사용, 3회마다 선제 회전,
놓은 포트는 휴식 1 · 쿨다운 0 · 차단 0. 즉 IP 를 태우지 않고 로테이션만으로 돌아간다.

테스트 6건 추가(휴식 재사용 금지·만료 복귀·burn 우선·회수 우선순위·budget vs block),
전체 202 passed. _MockProxy.rotate 가 kind 를 받도록 갱신.
2026-08-05 11:16:37 +09:00
민헌
f80cece9c1 feat(lps): 프록시 포트(IP 세션) 공용 중재자 — 소스·워커 간 IP 충돌·재사용 방지
한 DECODO 계정으로 성격이 다른 두 풀을 쓰게 됐다:
  쿠팡   gate.decodo.com  국가 무지정(실측 VN·MY·BD·ID·KZ·IN·PH)
  네이버 kr.decodo.com    한국 전용(LG U+·KT·SK브로드밴드)
네이버는 해외 IP 를 즉시 하드차단한다 — 같은 포트 10091 에서 gate=차단(2,641B) /
kr=정상 14건. 같은 포트 번호라도 게이트웨이가 다르면 IP 가 다르다
(port 10061 → gate=103.99.27.55(ID) / kr=121.180.128.2(KR)) → 자원 키는 (host, port).

PortRegistry 가 프로세스 전체의 포트를 중재한다:
- 배타 임대: 한 (host,port) 는 동시에 한 소유자만. 워커 N개가 회전하다 같은 IP 로
  수렴해 한 IP 에 요청이 몰리던 문제를 없앤다(seed_offset 은 시작점만 벌렸다)
- 전역 쿨다운: 누가 태웠든 만료 전까지 아무도 못 집는다(예전엔 쿨다운이 프록시
  인스턴스별이라 다른 소스가 곧바로 재사용했다)
- 리스 만료 = sticky 수명(session_minutes) → 자동 반납 후 새 IP
- 전 포트 소진 시 가장 빨리 풀릴 포트를 회수(멈추는 것보다 낫다)
- snapshot() 을 ops 알림 페이로드에 실어 게이트웨이별 보유/쿨다운/소유자를 남긴다

DecodoProxy 는 registry 주입 시에만 임대 경로를 타고, 미주입이면 기존 동작 그대로다
(단독 사용·기존 테스트 경로 보존).

검증: 워커 2개 동시 실행 e2e — 쿠팡·네이버 병렬 수집, 삼다수에서 네이버가 최저가
획득(naver 8,960 < coupang 13,200). 테스트 14건 추가, 전체 196 passed.
2026-08-05 10:59:49 +09:00
민헌
23c1c48634 feat(lps): 네이버 최저가를 모바일 크롤로 복구(오픈API 종료 대체)
shop.json 이 2026-07-31 종료(404 SE05)되고 NCP API HUB 에도 승계되지 않아
가격을 얻을 공식 경로가 사라졌다 → 쿠팡과 같은 스택(patchright+실제 Chrome)으로 크롤 전환.

경로: msearch.shopping.naver.com (PC 는 405/418 로 막힘). 7/9 스파이크 때 모바일은
로그인 리다이렉트였는데 그 사이 열렸다.

통과 조건 3개 — 하나라도 빠지면 WTM 캡차(실측):
- **한국 IP**: 해외 residential 은 즉시 하드차단(2.6KB) → kr.decodo.com 게이트웨이
  ([DecodoConfig].kr_host, DecodoProxy(host=...) 로 주입. 쿠팡은 기존 월드와이드 유지)
- **ko-KR 로케일/시간대**: KR IP + en-US 조합을 봇으로 본다
  (BrowserSearchAdapter.context_options 훅 추가)
- **리소스 차단 금지**: route 를 걸면 즉시 캡차. image/media/font 만 막아도 동일 →
  '무엇을 막느냐'가 아니라 요청 가로채기 자체가 탐지 신호. 대신 검색당 ~3MB(~$0.009)

파서는 '정확한 상품의 최저가'를 기준으로 취사선택한다:
- 광고/슈퍼적립/브랜드블록 카드 제외(멤버십·쿠폰 조건부 가격)
- 쿠폰할인가를 price 로 쓰지 않음(조건부라 실구매가보다 싸게 잡힘)
- 가격비교('최저 N원') 카드는 유지하고 mall_name="네이버"(옛 lprice 와 같은 의미)
- **배송비 확보** — 옛 오픈API 는 필드 자체가 없어 전 소스 None 이었다
- 가격 함정 3종 회귀 테스트: 단위가격(548원)·가격노드 안의 배송비(3,900원)·정상가/할인율

source 는 "naver" 유지 — price_history.naver_lowest·MALL_BY_SOURCE·프론트 그래프 계약이
구현(API→크롤) 교체와 무관하게 살아야 한다.

테스트 12건 추가(축약 픽스처 + 합성 함정) · 전체 182 passed.
2026-08-05 09:05:38 +09:00
민헌
55c18e6a83 fix(lps): 워커 재시작 시 Xvfb 가 stale lock 으로 죽어 모든 크롤이 실패하던 문제
docker compose start/restart 는 컨테이너 파일시스템을 재사용해 지난 실행의
/tmp/.X99-lock 과 /tmp/.X11-unix/X99 가 남는다. Xvfb 가 이걸 '이미 켜진 디스플레이'로
보고 종료하면 DISPLAY 가 없어 headful Chrome 이 못 뜨고, 워커는 살아있는 채로
launch_persistent_context 가 'Missing X server or $DISPLAY' 로 실패한다.
→ CMD 에서 Xvfb 기동 전에 stale lock/소켓을 제거한다.

재현·검증: restart 2회 연속 Xvfb 정상 기동 확인.
2026-08-04 16:30:38 +09:00
민헌
005ebc3d76 feat(lps): NCP NAVER API HUB 쇼핑 인사이트 클라이언트 추가
네이버가 2026-07-31 검색 오픈API 중 쇼핑·책·전문자료를 종료(유예·대체 없음)해
shop.json 이 404 SE05 를 반환한다. 후속 플랫폼인 NCP NAVER API HUB 를 붙인다.

- NaverApiHubConfig: 게이트웨이 base_url + NCP Client ID/Secret(둘 다 차야 enabled)
- services/naver_hub/client.py: X-NCP-APIGW-API-KEY-ID/KEY 인증, 오류 바디
  3형식(게이트웨이/Search/인사이트)을 NaverApiHubError 로 정규화(auth_failed·retryable)
- services/naver_hub/shopping_insight.py: POST /shopping/v1/categories.
  문서 제약(기간 2017-08-01~, 분야 최대 3개, timeUnit·device·gender·ages)을
  호출 전에 검증하고 카멜케이스 응답을 타입으로 변환
- tests: MockTransport 로 경로·헤더·오류형식 계약 검증 17건 + LPS_LIVE 스모크

주의: 허브에도 쇼핑 '검색'(상품명·가격·판매처)은 없다. 인사이트의 ratio 는
구간 내 최대값 100 기준 상대지표라 최저가 파이프라인 소스로는 쓸 수 없다.
기존 services/search/naver 어댑터는 손대지 않았다(사문화 상태 유지).
2026-08-04 11:23:26 +09:00
91b8dc77a0 [feat] negodata: 타결 상한(done_ceiling) — 세팅 기본율%·견적 override·세션 박제, 견적생성 3단계 타결/낙찰 섹션 분리·재생성 상속 2026-08-04 08:56:03 +09:00
2bf91d64f5 [refactor] negodata/front: 견적등록 모달 훅 분리 + 시장가 모순 카드 차단(10번), 엑셀 업로드 xlsx 파싱·CP949 폴백(7번) 2026-08-03 09:33:06 +09:00
bae0da42d2 [fix] landing: 히어로 세로 리듬 확보 — 간격 확대·헤드라인 74px
답답함의 원인은 가로 비율이 아니라 세로 간격이었다. 헤드라인 80px 에 대해
헤드라인↔서브 28px, 서브↔CTA 40px 는 너무 촘촘했다.

- 간격 확대: mt-7 → mt-11(44px), mt-10 → mt-14(56px).
- 헤드라인 80 → 74px. 글자 실폭이 뷰포트의 40% (레퍼런스 statworx 는 48%).
- 측정 주의사항을 주석에 남김: h1 은 블록이라 getBoundingClientRect 로 재면
  컨테이너 폭(848px)이 나온다. Range 로 텍스트 노드를 재야 실제 글자 폭이다.
  이걸 헷갈려 한 번 과교정(66px, 36%)했다가 되돌렸다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:43:16 +09:00
15369a016d [fix] landing: 히어로 여백 확보 — 순환 링을 강화학습 섹션으로 이동
히어로에 헤드라인·서브·CTA 2개·군집 밴드·순환 링이 다 들어가 세로 예산이 포화라
위를 벌리면 아래가 터지는 상태였다. 링을 옮겨 자리를 만든다.

- 순환 링을 강화학습 섹션으로. "협상할수록 좋아진다"를 말하는 자리라
  루프가 의미상 속하는 곳이기도 하다. 텍스트 좌 / 링 우 2단 배치.
- OrbitRing 에 자체 시계 모드 추가 — progressRef·phase 를 안 주면 스스로 돈다.
  덕분에 히어로 캔버스와 무관한 섹션에서도 단독으로 쓸 수 있다.
- 히어로 상단 여백 14vh → 19vh, 밴드를 하단 전체(0.63/0.30)로 확장.
- 군집 라벨 y 계산 버그 수정. 밴드 지역 좌표(0.5)를 화면 좌표로 착각해
  밴드를 옮길 때마다 라벨이 엉뚱한 곳으로 갔다. 이제 행 수·행간에서
  격자 상단을 실제로 계산해 투영한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:34:42 +09:00
5917942fe3 [fix] landing: 모바일 대응 — 글리프 배율·밴드 리맵·헤더 겹침
- 글리프 크기를 화면 폭에서 파생. 격자 간격은 dx × width 로 폭에 비례하는데
  글리프가 CSS px 고정이라 375px 에서 셀(7.7px)보다 커져(22px) 서로 겹쳐 덩어리가 됐다.
- 좁은 화면(<640px)에서는 밴드를 링 뒤로 내리고 군집 라벨을 접는다.
  모바일은 CTA 가 세로로 쌓여 세로 예산이 부족하고, 375px 에서 3군집을 나란히 두면
  군집당 100px 남짓이라 어차피 판독이 안 된다. 의미는 순환 링이 담는다.
- 히어로 상단 여백을 vh 대신 모바일 고정값으로. 헤더는 고정 높이라
  pt-[7vh](모바일 57px)로는 헤더(약 70px)를 피할 수 없어 헤드라인이 겹쳤다.
- 데스크톱 상단 여백 복원(14vh)과 밴드 재배치(0.575/0.155), 링 290px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:27:02 +09:00
87b896fc2f [fix] landing: 히어로 구도 좌측 쏠림 · 순환 링 정원화 · Playfair self-host
- 군집 좌표를 투영 후 위치에서 역산하도록 변경.
  sx = 0.5 + (x−0.5)·k 라 깊이마다 당겨지는 양이 다르고, 가장 깊은 협력사가
  중앙으로 제일 많이 끌려와 구도가 왼쪽으로 쏠렸다. 이제 화면 19/50/81% 에
  대칭으로 놓인다 — 라벨도 같은 값을 써서 어긋나지 않는다.
- 순환 링을 원근 타원에서 정원으로 교체(orbit-ring). 점선 궤도만 회전하고
  마디·라벨은 고정이다. 눕힌 타원은 깊이는 생기지만 "닫힌 루프"의 완결감이 없다.
- 학습 단계 입자 궤도 제거. 화면을 가로지르는 큰 선이 순환 링과 의미가 겹치고
  시선을 뺏었다. 순환은 링이 맡고 입자는 안쪽으로 흡수만 한다.
- Playfair Display self-host(latin 서브셋 39KB). 구글 폰트 CDN 제거 —
  서드파티 요청이 LCP 를 흔들고 방문자 IP 가 외부로 나간다.
- 히어로 CTA "협상 시연 보기" → "직접 협상해 보기".
  데모는 실제로 동작한다(앵커 이하 낙찰 · 카드 소모 역제안 · 소진 시 개찰 확인).
  수동태 문구는 인터랙티브 기능을 영상처럼 보이게 해 전환을 깎는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:16:28 +09:00
6785c8a6f4 [feat] landing: 랜딩 v2 — 다크 무대 히어로·인터랙티브 협상 데모·디자인 토큰 재정립
레퍼런스(statworx·jitter·infinith) 실측값으로 디자인 시스템을 다시 잡고,
스크롤 재생형 콘솔을 사용자가 직접 협상하는 데모로 교체했다.

디자인 토큰
- 타이포: 굵기·크기·자간을 함께 조정(섹션 제목 44→64px/600→800/자간 -0.042em).
  한글 행간은 1.07 — 라틴 기준 0.95 를 쓰면 네모틀이 꽉 차 줄이 닿는다.
- 모서리: 12가지로 흩어져 있던 값을 card 8px / control 4px / full 셋으로 수렴.
  20~32px 짜리 뭉툭한 카드가 제네릭 SaaS 의 가장 강한 신호였다.
- 다크 무대 토큰 신설(stage/on-stage/counter) + 배경 그라데이션.
- primary-on-stage(#7C8CFF) 추가 — #0101F3 은 무대 배경 대비 2.0:1 로
  WCAG 최소(4.5:1)의 절반도 안 돼 다크 위에서 읽히지 않았다.
- 영문 디스플레이 서체 Playfair Display self-host(latin 서브셋 39KB).

히어로
- 배경 캔버스가 실제 파이프라인을 그린다: 수집→분류→대조→산정→협상→학습.
  분류 단계는 거래 품목(시트)·사용자·협력사로 갈라지고 라벨이 붙는다.
- z 좌표 + 원근 투영. 군집마다 z 평면을 하나씩 줘 격자는 온전한 채 깊이만 생긴다.
- 순환 링(정원, 점선 궤도 회전). 진행도는 ref 로 공유해 60fps 리렌더를 피한다.
- 카피에 진입 애니메이션을 걸지 않는다 — LCP 요소라 opacity:0 으로 시작하면
  하이드레이션 전까지 첫 화면이 빈다.

협상 데모 (신규)
- 역할 선택(공급사/구매 담당자) → 품목·단가 제시 → 턴 협상 → 낙찰/개찰 → 상담 신청.
- 판정 규칙은 실제 엔진과 동일: 앵커링가 이하 낙찰, 협상 카드 3장, 소진 시 개찰.
- 로직은 lib/negotiation-sim.ts 순수 함수로 분리 — 나중에 agent 서비스 연동 시
  컴포넌트를 건드리지 않는다.

카피 감사
- "봇" 21곳 → "에이전트"(로봇 아이콘 3개 포함). 대리인은 조달 실무의 정당한 역할이다.
- "악역은 봇이", "단가를 깎는 악역" 제거 — 협력사를 적으로 규정하는 프레임은
  같은 페이지 FAQ(공급사 만족도 82%)와 모순이고, 동반성장 평가를 받는
  대기업 구매팀 결재 라인에 올릴 수 없다.
- 번역투 최상급(초정밀·압도적·완벽히·최적의)과 데모 대화문 전면 재작성.

버그 수정
- 헤더가 흰 배경 위에 흰 글자 버튼을 그리던 문제. onStage 를 스크롤 수치로 계산해
  하이드레이션 전 스크롤 복원 시 초기값이 굳었다 → IntersectionObserver 로 교체.
- 버튼 transition-all → transition-colors (variant 교체 시 배경 플리커).
- 폼 입력 14px → 16px. iOS 사파리는 16px 미만 입력에 포커스하면 화면을 확대해
  모바일 폼 이탈을 만든다.

정리
- 대체된 히어로 3종·기존 협상 콘솔 삭제(약 760줄).
- 라벨 아이콘·알약 배지·blur(120px) 글로우 블롭 제거.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:08:37 +09:00
3c3f60c7d4 [chore] 루트 업로드 샘플 CSV 4종 제거(upload-samples/ 와 중복) 2026-07-31 10:26:44 +09:00
bd0f39f131 [feat] negodata·agent: 목표가 네고 적용/미적용 산정근거·와일드카드 1% 10원 반올림·표 xl 전환·QA 체크리스트 2026-07-31 10:22:51 +09:00
775984fe48 [fix] negosium: 협상카드 카운터가 10원 단위 반올림 — 앵커·목표가와 표기 통일 2026-07-30 15:20:01 +09:00
5520e3c2b6 [feat] negodata: 견적 삭제(개발자 전용)·연결 데이터 캐스케이드 soft-delete
- 견적관리 체크박스 선택 + 툴바 '선택 삭제'(상품·협력사 패턴), 상세 시트 단건 '삭제'
- 백엔드 soft_delete 를 연결 협상 세션·대화까지 한 트랜잭션 캐스케이드
- 체크박스·선택삭제·상세삭제 모두 개발자(role 3)에게만 노출
2026-07-30 13:20:42 +09:00
2a004734d8 [feat] negodata·negosium: 엑셀 데이터 내보내기·견적상세 협상카드 연동·드로어 뒤로가기
- 엑셀 데이터 내보내기(상품·협력사·협상카드): 현재 검색/페이지 필터를 무시하고 전체를 페이지 순회로 받아(fetchAllForExport, 무언 절삭 방지) 양식과 동일 헤더로 CSV 내보내기(downloadXxxData) + '데이터 다운로드' 메뉴·todayStamp 파일명 → 내려받아 수정 후 재업로드(라운드트립)
- 견적상세 ChatTab: 사용 협상카드 매칭을 chat_id→card_id(카드 PK)로 교정, 카드 사용 메시지 본문 script 중복 제거(카드 박스에만 노출), 카드칩→/cards?detail= 상세 링크, 카드 멘트 실가격 마스킹(target_price 변수 미주입+maskPrices)
- 견적상세 협상카드 탭: 번호·스크립트 미리보기 컬럼 추가(cardScriptPreview 평문 추출)·카드명 상세 링크
- 상세 드로어: 백드롭 좌상단 '뒤로'(navigate(-1)) 버튼 추가(데스크톱), 공용 Sheet·견적상세 동일 적용
- 협상요약(negosium): 배송 리드타임 라벨을 회사설정(labels.lead_time) 연동 → '표준납기' 표기
2026-07-30 11:03:51 +09:00
35d38e0818 [feat] negosium: 협상 리스트 마감기준 정렬 컨트롤
- SortControl(기본순/마감임박순/여유순) 추가, StatusTabs 우측에 배치
- store setDeadline 배선 — 선택값을 서버 정렬(deadlineToOrder)로 전달
2026-07-30 08:31:16 +09:00
82cca894fc [fix] negosium: 'MD → 구매담당자' 표기 통일
- 챗 봇/안내 라벨(ChatMessage·MDInformation·index.css 메뉴 제목)
- negodata 목표가 산정 문구(pricing) · 온보딩 FAQ/용어(OnboardingGuideModal)
- 견적생성 메모 라벨을 '협력사 안내 메모'로 명확화(협상방에서 협력사에게 보여줄 내용)
2026-07-30 08:31:16 +09:00
9716528ce7 [feat] negosium: 협상 챗 헤더 정리 — 상품명 대표 + 견적번호
- 헤더 대표값을 협상 중인 상품명으로(여러 협상건 중 '이 건' 식별), 펄스 점·'가격 협상 채널' 문구 제거
- 견적번호(qt_number)를 백엔드 chat init → 프론트로 배선해 헤더 칩으로 노출(좁은 모바일은 숨김)
- 모바일 상품 드로어 상단에 구매사 로고+견적번호(BuyerBrandStrip) — 데스크톱 사이드바 로고의 모바일 대체
2026-07-30 08:31:16 +09:00
9dca78dc72 [feat] negosium: 협상완료 부가정보 수취·표시·잠금 + VAT 표기 통일
- 완료 요약 카드(투찰/협상 결과)에 부가정보·의견을 항상 구조로 표시, 미입력 필드는 '-'
- 협상 리스트 부가정보를 완전 읽기전용으로 잠금 — 입력은 완료 챗 마무리에서만
- 어드민 협상상세(현황·챗 탭)에 협력사 의견(custom.opinion) 노출
- 협상 화면 VAT 표기를 회사설정(vat_yn) 기준으로 통일 — hidden이면 라벨 생략
2026-07-30 08:31:16 +09:00
671904c762 Merge commit '30f134836b59843d84b60fa8c0923bd81afefb41' into feature/negodata 2026-07-29 09:57:05 +09:00
6f32b466b9 Merge branch 'design/frontend' into feature/negodata 2026-07-29 09:56:59 +09:00
fe9adcaa26 [fix] negosium/front: 포털 모바일 safe-area 여백 정리 — 헤더 노치(safe-t+min-h)·목록 좌우/하단(safe-x/safe-b)·모달 가장자리 여백(p-4), 채팅 스크롤바 트랙 상단 64px 여백 제거 2026-07-29 09:56:11 +09:00
f554202c58 [fix] anchoring: 앵커링가 1원 단위 내림 → 10원 단위 반올림 (자동 제안가 정돈, IMK #11 케이스) 2026-07-29 09:51:26 +09:00
a56589c6d9 [feat] negosium/협상: 협상 종료폼 통일·의견 수취 — rejectRSP/CM를 단일 RejectForm(사유·희망가·의견)으로 통합해 액션바 렌더, 타결 부가정보에 의견 추가(custom.opinion), 결렬 제출값 파싱해 reject_reason/reject_price 컬럼 저장, 상품상세 필드 라벨 회사설정 연동·협상 단가 VAT별도 표기 2026-07-29 09:51:21 +09:00
b33ae05cd6 [feat] negodata: 견적 목표가·앵커 반올림 + 통계 협상카드 효과 — 목표가=매입가×(1−네고율) 자동입력·산정후보를 낙찰기준탭으로 이동·목표가상한 상시표시 제거, 후보가/앵커 생성가 10원 단위 반올림(신규 세션, schedules 앵커와 동일 공식), 산정모달은 구매담당자 제시가 있어도 후보 전부 노출·MD입력가→구매담당자 제시가, 신규상품 등록에 공급사칸 추가, 상품상세/견적생성 라벨 회사설정 연동, 통계 협상카드 효과(유형별 사용빈도+사용직후 평균 제시가 하락)·와일드카드 1%인하 집계, mono 폰트 폴백 2026-07-29 09:51:03 +09:00
30f134836b [fix/feat] 인터넷 최저가(LPS) — 로딩 무한 버그 수정 · 몰별 최저가 노출 · 탐색/상세 UI 재설계 (#10) 2026-07-28 08:25:14 +00:00
민헌
8fa77af866 [chore] lps: 배포 설정 문서화 + 죽은 env 제거 + 알림 문구 정정
배포서버 쿠팡 크롤 조사(2026-07-28)에서 드러난 '설정한 줄 알았는데 아니었던' 것들 정리.
크롤 동작 자체를 바꾸는 변경은 없다.

- Dockerfile.worker: ENV LPS_CHROME_EXECUTABLE 제거.
  읽는 코드가 없는데 값이 박혀 있어 "컨테이너 Chrome 경로가 설정돼 있다"는 오해를 만들었다.
  경로의 유일한 소스는 [WorkerConfig].chrome_executable 이다(설정 시 --no-sandbox 동반).

- config.local.toml.example: profile_dir 경고 추가.
  컨테이너에서 ".profiles" 로 두면 /app/.profiles(컨테이너 레이어)에 쌓여 재생성마다 쿠키가
  날아가고, compose 가 마운트한 lps-profiles 볼륨은 붙어만 있고 아무 일도 하지 않는다.
  로컬·배포서버 양쪽에서 실측(/profiles 는 7/9·7/16 잔재, /app/.profiles 에 9MB 최신).

- config.prod.toml.example 신규: 배포서버 설정 템플릿.
  로컬과 다른 값만 ★ 표시. 헤더에 로드 경로의 함정을 명시했다 —
  docker-compose.prod.yml 이 이 파일을 config.local.toml 자리에 마운트하므로
  APP_ENV=local 인데도 내용은 prod 설정이다(파일명만 보면 오해한다).

- worker_main.py: budget_leak 알림이 "ip_request_budget 하향 검토"를 단정하던 것을 수정.
  차단이 ip_req#1 에 몰리면 새 IP 첫 요청부터 막히는 것이라 예산과 무관하다.
  bot_detection.ip_request_no 분포를 보고 처방을 고르도록 문구를 바꿨다.

⚠️ Dockerfile.worker 가 바뀌었으므로 배포 시 lps-worker 재빌드 필요.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 16:39:14 +09:00
민헌
89652fac1f [refactor] negodata/front: 인터넷 최저가 상세 시트 재설계 — 실구매가 중심 + 근거 검증
협상 근거로 쓰는 화면이라 "얼마에 살 수 있고, 그게 어디서 나온 값인가"를
한 번에 검증할 수 있게 정보 구조를 다시 짰다.

구조: 결론 → 판단 → 근거
  - 히어로: 합계(상품가+배송비) 32px + 단가 대비 판정을 한 블록에.
    면은 브랜드보다 채도를 낮춘 --accent 표면, 값만 브랜드 컬러(화면에 단 하나).
  - 판매처 비교표: 상품가/배송비/합계를 몰별로. 상품가 최저와 실구매가 최저가
    다를 수 있어 둘 다 표시. 결과 없는 몰도 '미발견' 행으로 남겨 빈손임을 드러낸다.
  - 검색어(찾은 상품명) + 복사 버튼 → 판매처에서 다시 찾아볼 때 붙여넣기용.
  - 가격 추이: 상품가 단일 기준. 합계 기준은 배송비를 아는 회차에만 값이 생겨
    (네이버는 배송비 미제공 — 실측 39/39) 기준을 바꾸면 값의 의미와 표본 수가
    동시에 변한다. 기준선(상품 단가) 추가 — 이 선 위면 사는 게 손해.
  - 수집 이력: 미발견도 노출(전체의 30%가 not_found 인데 그동안 숨겨져 있었다).

데이터 정직성
  - 배송비 미상(전체 69%)을 0원으로 뭉개지 않는다 — 무료배송으로 읽히면 안 된다.
    모르면 히어로 라벨이 '합계' → '상품가'로 바뀌고 그 사실을 문구로 밝힌다.
  - 화면 주인공(합계)과 목표가 산정에 쓰이는 값(상품가)이 다르므로 각주로 명시.
  - 금액 옆 배송 주석은 '+3,500'(연산으로 오해) → '배송비 포함(3,500원)'.

접근성·일관성
  - 대비 전수 계산: --accent 면 위에서 muted-fg(4.16)·amber-700(4.28)이 AA 미달이라
    accent-foreground(9.85)·800 계열(6.0~6.5)로 교체. 히어로는 큰 글씨 기준 통과.
  - rose(파괴적 뉘앙스) 제거 → 브랜드 인디고 한 톤. 장식 아이콘 전부 제거.
  - 타이포 7단계를 상수 T 로 고정(임의 크기 금지).
  - 차트 점 3종 모양을 DOT 상수로 추출해 차트와 범례가 공유 — 범례는 차트 밖이라
    --color-price 가 해석되지 않아 색이 어긋났다. 전역 토큰으로 통일.
  - Y축 눈금 1·2·5×10ⁿ, 기준 변경 시 애니메이션 끔, 커스텀 X축 tick 제거
    (recharts 의 가장자리 앵커 보정을 건너뛰어 점과 라벨이 어긋났다).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 16:06:27 +09:00
민헌
5e24741395 [refactor] negodata/front: 최저가 탐색 모달 B2B 재설계 + 완료 판정 버그 수정
완료 판정 버그(로딩이 안 멈추던 원인 중 하나)
  폴링이 crawl_end_time 을 문자열로 비교했다. 서버는 마이크로초 6자리(.755705Z),
  JS toISOString() 은 밀리초 3자리(.755Z)라 자릿수가 달라 사전순이 시간순과 어긋난다
  ('.' < 'Z'). 게다가 클라이언트 시각을 서버 타임스탬프의 기준점으로 써서
  브라우저 시계가 조금만 앞서도 어떤 결과도 기준을 넘지 못했다.
  → 요청 직전 서버가 준 최신 수집 시각을 상품별 기준선으로 읽고 epoch ms 로 비교.

UI 재설계 (소비자 앱풍 → B2B 실무 툴)
  - 상태 3분리: confirm | searching | done. 검색 중에 완료 결론을 섞지 않는다
  - 표가 주인공: [선택 | 상품 | 기존 최저가 | 네이버 | 쿠팡 | 결과], 컴팩트 행, tabular-nums
  - 결과 열은 상태어(탐색 성공 / 변동 없음 / 탐색 실패 / 탐색 중)
  - 진행 카드: 표시등 + mm:ss 경과 + 진행바 + N/M, 셀 단위 스켈레톤으로 진행 위치 표시
  - radius 8/6/4, shadow 최소화, CTA 우측 정렬(풀와이드 금지), 강조는 indigo-800 한 톤
  - 완료 후 자동으로 닫지 않는다(결과 확인). 선택 해제는 닫을 때로 미룸

선택 재검색
  체크박스로 대상을 고르고, 검색이 끝나면 갱신되지 않은 행만 자동 선택된다
  (이미 갱신된 상품에 크롤 비용을 다시 쓰지 않도록). 재검색은 force=true 로 나가
  네거티브 캐시를 우회한다.

접근성 (스킬 기준 + 대비 실측)
  - 보조 텍스트 muted-foreground 4.88:1, indigo-800 9.93:1 (기존 1.92:1 FAIL 해소)
  - 상태는 색 단독이 아니라 텍스트/굵기/sr-only 병행
  - role=status aria-live, 탈출구 3중(X·ESC·오버레이), 버튼 높이 통일

api/generated 는 orval 재생성분. resTargetBreakdown 변경은 이전 백엔드 수정과의
스펙 동기화(이번 작업과 무관하지만 재생성으로 함께 정리됨).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 11:56:00 +09:00
민헌
0cefe315c4 [feat] negodata/backend: 몰별 최저가(by_mall) 노출 + 강제 재검색(force) 연동
몰별 성공/실패 표시
  lps_db.price_history 에는 몰별 정보가 다 있는데(naver_lowest·coupang_lowest·by_mall),
  동기화가 final_source(이긴 몰) 하나만 남기고 나머지를 버려서 API 로는
  "네이버는 어땠는지"를 알 수 없었다. 실제로 네이버는 빈손이고 쿠팡만 성공하는
  케이스가 기본값처럼 나오는 중이라 화면에 드러낼 필요가 있다.
  → price_history.by_mall(JSONB)을 partner.item_internet_lowest_prices 로 그대로
    미러링한다(열린 스키마 — 오픈마켓 폴백이 늘어도 스키마 변경 불필요).
  - crud: 읽기 계약에 by_mall 추가 + SELECT 포함
  - model: item_internet_lowest_prices.by_mall
  - service: 언팩·저장
  - protocol: LowestPriceEntry.by_mall
  - init.sql: 테이블 정의 + 하단 보정 ALTER(기존 DB 반영용)

강제 재검색
  POST /v1/item/{id}/lowest-price?force=true → LPS 네거티브 캐시 우회.
  사용자가 '다시 검색'을 누른 경우에만 true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 11:55:38 +09:00
민헌
ba9d34fd31 [fix] lps: 네거티브 캐시 히트 시 이력 누락 수정 + force 재검색 지원
1) 캐시 히트가 price_history 를 남기지 않던 문제
   not_found 는 24시간 네거티브 캐시에 들어가는데, 캐시에 걸린 조기 반환 경로만
   _record_history 를 호출하지 않았다(다른 모든 경로는 호출).
   그 결과 잡은 완료인데 price_history 에 새 행이 없어, 이를 폴링하는 소비자
   (negodata 최저가 모달)가 결과를 영영 못 받고 로딩만 돌았다.
   → 캐시 히트도 '이 잡의 결과'이므로 이력을 남긴다.

2) force 플래그
   negodata 는 이제 수동 트리거 전용인데 is_negative() 가 job_type 을 보지 않아
   사람이 직접 누른 재검색까지 캐시가 가로막았다. 게다가 캐시 키가 product_code 라
   상품명·모델을 고쳐 재시도해도 동일하게 막힌다.
   → SearchItem.force=true 면 NegativeCache.drop() 으로 기록을 지우고 실제 검색.
   기본 요청은 캐시를 그대로 써서 비용 절감 효과는 유지.

실측: 캐시에 막혀 not_found 만 반복하던 상품이 force 재검색에서 2라운드 만에 found(8,500원).
⚠️ protocol.py 변경은 lps-api 와 lps-worker 를 함께 재빌드해야 반영된다
   (API 만 옛 스키마면 pydantic 이 force 를 조용히 버린다 — 실측으로 확인).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 11:55:23 +09:00
6534d53696 [docs] readme: 업데이트 + 플로우차트 추가 2026-07-28 09:32:40 +09:00
민헌
ca1291708f [fix] lps-worker: compose 에 platform=linux/amd64 명시 (arm64 맥에서 이미지 빌드 실패 해소)
google-chrome-stable(Linux)은 amd64 전용이라 워커 이미지는 본질적으로 amd64다.
arm64 맥에서 platform 없이 빌드하면 arm64 로 시도하다 Chrome 의존성
(libasound2·libxfixes3 등)에서 exit 100 으로 실패하고, 그 결과 예전에 빌드된
구 chromium 이미지로 뜨면서 LPS_CHROME_EXECUTABLE(/opt/google/chrome/chrome)
경로가 없어 쿠팡 웜업이 매번 실패했다.

platform 명시 후 Rosetta 에뮬로 실제 Chrome 150 이 뜨고 쿠팡 Akamai 챌린지를
시도 1회에 통과함을 확인. prod(amd64 서버)에서는 no-op.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 09:16:19 +09:00
a5fa44ee25 [refactor] negodata/backend: QuotationService 파사드 분할 — services/quotation/(pricing·build·closing·queries·invites), 시그니처·임포트 경로 외 동작 무변경 2026-07-27 16:15:19 +09:00
7b612824be [refactor] negodata: 목표가 산정 정리 — purchase_only 죽은코드 제거(백+프론트)·산정 재료 로더 공용화·자동재생성 잔재 주석 정정 2026-07-27 16:07:21 +09:00
bacf6271df [chore] 작업분 일괄: logs.sh 컨테이너 메뉴화 · dbeaver 초기화 SQL · landing 운영 compose · negodata/front eslint 도입 · 업로드 샘플 CSV 2026-07-24 18:45:46 +09:00
9fb4a52c56 [fix] negosium/front: 협상 챗 에러 말풍선 토스트(하단 짤림 수정) + 가격/할인율 입력 자동 포커스 2026-07-24 18:39:49 +09:00
f9d37e08f4 [fix] lps: 워커 이미지 chromium→google-chrome-stable 교체 (쿠팡 Akamai 지문 차단 회피) 2026-07-24 17:12:20 +09:00
b539e073cb Merge commit 'dc8c288f' — lps-admin 신설·LPS 운영기능(ip_session/alerts/admin API)·최저가 출처링크 반영 2026-07-24 15:30:36 +09:00
36afcd68a6 Merge branch 'fix/nego-card-scripts-cleanup' — 협상카드 스크립트 개정·미사용 카드 정리(시드+alter) 2026-07-24 15:17:22 +09:00
e9be649e1f [chore] logs.sh: 운영 로그 조회 스크립트 2026-07-24 14:58:57 +09:00
624a8846b0 [chore] landing: prod Dockerfile(SSG 빌드→nginx) 추가 2026-07-24 14:52:16 +09:00
d580e4d5ea Merge branch 'feature/negodata'
# Conflicts:
#	landing/app/routes/home.tsx
2026-07-24 14:22:31 +09:00
5318ff3263 [chore] landing: 홈 문구 정비 (mobile.mov 제외·ignore) 2026-07-24 14:18:52 +09:00
51d07bb779 [feat] negodata: 재협상 소유권 게이팅·처리담당자·상세 링크·재생성 타이틀·알림 토스트(읽음API)·재협상요청 표기·커스텀필드 select 2026-07-24 14:18:52 +09:00
d7c4e86f30 [feat] 공급사 포털: 협상완료 부가정보(select·종료 동의폼)·상태 라벨 통일·예아니오 Enter=예·목록 결과열·재협상 요청/철회 API·테스트 2026-07-24 14:18:52 +09:00
민헌
dc8c288f68 feat(lps): docker-compose 에 lps-admin 추가 + 환경설정 config.local.toml 단일화
- lps-admin 컨테이너화: Vite 정적 빌드 → nginx, /v1·/healthz·/readyz 를
  lps-api:9600 으로 동일출처 프록시(빌드 타임 API URL 주입 불필요)
- lps-api/worker: APP_ENV=local 고정 + DB_HOST override(도메인 backend·negodata·agent
  와 동일 패턴), config.<env>.toml 마운트 방식 폐기
- server_configs 에 DB 접속 env override(_apply_db_env_override) 복원
- config.dev/prod.toml.example 제거 — 환경 구분 없이 config.local.toml 하나
  (prod 서버도 그 서버의 config.local.toml + docker compose up -d)
- run_docker.sh 환경 선택 제거·lps-admin 포함, README·operations 문서 갱신

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:54:32 +09:00
민헌
d111610555 feat(lps-admin): 관리자 페이지 디자인 개선 및 컴포넌트 통일
- 공용 프리미티브(ui.tsx): PageHeader·Segmented·Button·SearchForm·Legend·
  ScrollBox·ScrollTable·Modal + Card(fill/compact) 옵션
- 차트 공용 설정(lib/chart.ts): 축·그리드·툴팁 스타일 단일화
- 뷰포트 채우기 레이아웃 + content 영역 스크롤(Layout), 버튼 커서 복구
- 표: 헤더 밴드·행 좌우 패딩·고정/채움/auto 높이·sticky 헤더, 배지 줄바꿈 방지
- 작업 큐: 풀폭 표 + 행별 상세/JSON 모달(복사)
- 상품·가격: 가로 배치, Y축 0 기준, 시점 클릭→몰별 비교, 커스텀 툴팁·
  그라데이션 area, 몰별 컴팩트 표(소스 뱃지)
- 크롤 상태: 도넛 중앙 총계·라운드 세그먼트, 막대 그라데이션, 툴팁 통일
- 테스트 검색: 결과 통일(모달·표·소스 뱃지), 진행상태 좌측 이동, 초기화 전
  재검색 차단, 퍼널 가독성(감소량), 배송(로켓배송) 표기, 한 화면 컴팩트
- 사이드바: API 상태 상단 뱃지, 테스트 메뉴 배경색으로 구분

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 08:48:43 +09:00
민헌
e6005b4f94 feat(lps-admin): 테스트 검색을 앰버로 구분 — '실제 비용 드는 액션' 신호
테스트 검색은 다른 페이지(읽기전용 모니터링)와 달리 실제 크롤·프록시
비용이 발생하는 실행 액션이라 시각적으로 구분(협의).

- 네비 '테스트 검색': 앰버 점 마커 + 앰버 텍스트/활성색, 위에 구분선으로
  모니터링 페이지들과 분리(설정과 함께 실행/유틸 그룹).
- 테스트 페이지 '검색 실행' 버튼: primary(indigo) → warn-700(#a36a00).
  흰 텍스트 대비 4.55:1 로 AA 통과(실측). 상태색은 장식이 아니라
  '비용 주의' 의미로만 사용(디자인 규칙 준수).
- whitespace-nowrap 로 라벨 줄바꿈 방지. 모바일 hscroll 0 유지.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:44:59 +09:00
민헌
479cb862b1 feat(lps-admin): 상품·가격 — '몰별 최저가'+'검증 링크' 를 몰별 가격 비교로 통합
두 카드가 사실 같은 데이터(by_mall)를 다르게 그린 것이라 하나로 합침.
상위 N개(3/5/10/전체 선택)를 순위·쇼핑몰·상품명·가격·최저가 대비 델타·
링크 한 줄로 보여주고 가격 비교 막대를 얹어 한눈에 비교 가능.

- MallEntry 타입 필드명 버그 수정(mall/url → mall_name/detail_url/name)
  — 기존 몰별 최저가 차트에 몰 이름이 안 보이던 원인. 이제 표시됨.
- 상위 N 은 프리셋 칩(by_mall 이 몰별 dedup 소집합이라 자유입력보다 적합).
- '검색어'는 항목별로 없어 각 몰의 상품명(title)으로 대체 — 같은 상품
  검증 역할.
- 모바일 가로 스크롤 해소: 상단 네비 overflow-x-auto 가 페이지를 늘리던
  것(루트 w-full 누락)과 master-detail 그리드 자식 min-w-0 누락 동반 수정.
- 실기동 검증: G마켓 56,880/플랜88 59,900 렌더·칩 토글·데스크톱/모바일
  hscroll 0·콘솔 에러 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:57:07 +09:00
민헌
a306ad4cf3 docs(lps-admin): README 에 테스트 검색 페이지 추가
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 09:14:14 +09:00
민헌
c126d56631 feat(lps-admin): 테스트 검색 페이지 — 임의 상품을 실제 파이프라인에 흘려 점검
셀렉터·프록시·AI 매칭 회귀를 UI 에서 즉석 점검. API 는 lean(브라우저
없음)이라 크롤은 워커에만 있으므로, 기존 POST /v1/lps/search 로 잡을
넣고 GET /jobs/{id} 를 2초 폴링해 결과를 시각화(신규 백엔드 0).

- 폼(상품명 필수+모델/규격/제조사/현재가) → 제출 → 진행 스텝퍼
  (대기→크롤·판정중→완료, 1초 티커 경과시간) → 결과.
- 결과: 판정(최저가·소스), 파이프라인 퍼널(수집→필터→이상치→AI→top-N
  단계별 잔존 수), 소스별 수집(+실패 시 error 표시), 검색 원가
  (AI/프록시 분해·전송량), 매칭 상품 표, 원본 JSON.
- product_code=TEST-<시각> 자동 — 프로덕션과 필터 구분(negodata 는
  UUID 만 매핑, 연동 무영향). 자격증명은 서버에만, 결과엔 집계 사용량만.
- 워커 미실행 시 8초 후 '워커 확인' 힌트, DEAD 시 오류 표시.
- 작업 큐에 '테스트만'(TEST- 접두) 필터 칩 추가.
- 실기동 검증: 맥심 커피 검색 → 퍼널 80→2·쿠팡 9.5s·원가 /bin/zsh.002 렌더,
  콘솔 에러 0, 모바일 hscroll 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 09:13:54 +09:00
민헌
3c8d0af2e4 chore: .gitignore 에 .gstack/ 추가 (gstack 로컬 상태 디렉터리)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:59:14 +09:00
민헌
74fce19be9 style(design): FINDING-009 — 비용 빈 상태에 다음 행동 안내 추가
'없습니다'로 끝나던 빈 상태에 '워커를 켜고 검색을 돌리면 채워집니다'
안내를 붙여 사용자가 다음 행동을 알 수 있게.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:58:51 +09:00
민헌
1006ad11ea style(design): FINDING-012 — 접근성 통일: 검색 라벨·행 키보드 선택·이모지 숨김 [subagent]
Settings 는 label 연결이 모범인데 Jobs/Products 검색 인풋만 접근
가능한 이름이 없었고, Products 목록(button)과 달리 Jobs 행(tr onClick)
은 키보드로 선택 불가였다. aria-label 2곳, tr tabIndex+Enter/Space
핸들러+focus-visible, 장식 이모지(⚠✓🎉) aria-hidden 처리.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:58:51 +09:00
민헌
645a0362b0 style(design): FINDING-011 — 타이포 스케일 정수 6단으로 통일 (본문 14px) [subagent]
작은 앱에 0.5px 간격 10종(10.5~22)이 혼재 — 12 vs 12.5 는 위계를
만들지 못하고 half-pixel 은 zoom/서브픽셀에서 반올림이 갈린다.
{11,12,13,14,17,22} 정수 6단으로 정규화(순서 보존 매핑), 본문
13.5→14px(FINDING-004). 빌드·렌더 검증.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:58:14 +09:00
민헌
29e54ad838 style(design): FINDING-007 — 고정 150px 파이의 ResponsiveContainer 제거
recharts 가 '고정 폭·높이엔 ResponsiveContainer 불필요' 경고를 콘솔에
반복 출력(페이지당 4회). PieChart 직접 렌더로 전환.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:54:46 +09:00
민헌
10ea8ddd45 style(design): FINDING-010 — Crawl.tsx 하드코딩 hex 를 @theme 토큰으로 단일화 [subagent]
Crawl 이 dead-600/ink-400/cost-ai 와 동일한 값을 raw hex 로 재복제해
토큰 변경 시 어긋나는 이중 정의 상태였다. 종료사유 팔레트를 상태
토큰(ok/run/warn/dead)+신규 neutral-300/400 으로 치환, ReferenceLine·
막대·폴백 hex 전부 var() 참조로. src 내 raw hex 0 확인.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:53:58 +09:00
민헌
433b973505 style(design): FINDING-003 — ink-400/500 캡션 대비 WCAG AA 통과로 다크닝
ink-400(#9aa1ac)이 11~13px 소형 텍스트(타일 부제·테이블 헤더·각주)에
쓰이는데 대비 2.54:1 로 AA(4.5:1) 미달. ink-400→#6d7480(4.59:1),
ink-500→#5d6470(5.81:1)으로 2단 위계 유지한 채 통과. 실측 재검증.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:52:37 +09:00
민헌
a750c1098a style(design): FINDING-001 — 모바일(<md) 사이드바를 상단 네비로 전환
375px에서 사이드바가 196px을 고정 점유해 콘텐츠가 낱글자로 세로
분해되고 /jobs 에 가로 스크롤(484px)이 생겼다. <md 에서 상단 바
+가로 스크롤 네비(터치 44px, FINDING-006)로 전환, 로고 그라디언트
hex 를 primary-400 토큰으로 승격. 검증: dashboard/jobs 375px
hscroll=false, 스크린샷 확인.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:51:53 +09:00
민헌
5e286d7949 chore(lps-admin): package-lock name 동기화 — 폴더명 변경 후속
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:39:49 +09:00
민헌
992a3246a8 chore(lps-admin): 폴더명 lps-temp-fe → lps-admin
임시 FE 가 정식 관리자 콘솔이 되어 이름을 용도에 맞게 변경.
루트 병렬 유지(lps/ 는 파이썬 백엔드 루트라 중첩 시 비대칭 +
Docker 빌드 컨텍스트 오염). package.json name 동기화.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:07:30 +09:00
민헌
6eb71eb035 feat(lps-temp-fe): React 관리자 페이지 — 대시보드·작업큐·상품가격·크롤·비용
임시 단일 HTML 콘솔을 걷어내고 5페이지 관리자 앱으로 교체(협의:
모니터링+필수 액션, negodata 계열 경량 스택 — Vite+React+TS+Tailwind
+recharts+react-query+react-router).

- 대시보드: /ops 5초 폴링 스탯 타일 8종 + 임계 배너(서버 AlertConfig
  기본값 미러) + 세션 내 누적 큐 추이.
- 작업 큐: 상태 탭·상품 검색·페이지네이션·상세 패널(결과/원가/오류),
  DEAD 재큐 버튼.
- 상품·가격: 가격 추이 3선 + 몰별 최저가 바 + 검증 링크 + 수동 재검색.
  시리즈 색 엔터티 고정 — 네이버 #008a43 은 쿠팡 적색과의 적록색약
  분리(ΔE 14.5)·3:1 대비를 검증해 톤다운한 값.
- 크롤 상태: 종료사유 도넛(의미 기반 상태색)·세션당 요청 수 분포
  +차단 시작 기준선(예산 튜닝 뷰)·차단 추이/이력·세션 테이블.
- 비용: 시간별 원가 스택(AI vs 프록시)·건당 평균·평균 소요(축이 달라
  별도 차트 — 이중축 금지).
- 설정: API 주소·guard 키(localStorage → X-API-Key 자동 첨부).
  서버 설정 변경 UI 없음(toml 단일 소스 원칙).
- dev 는 vite 프록시(:9600)로 CORS 불필요. 5페이지 실렌더 스크린샷 검증.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 21:59:15 +09:00
민헌
61b438d8d0 feat(lps): 관리자 FE 용 API 6종 — 잡 목록/재큐·상품 목록·IP세션/차단/비용 통계
React 관리자 페이지(협의: 모니터링+필수 액션)의 데이터 소스.

- GET /v1/lps/jobs: 최신순 목록+총건수, status/q(상품코드·명) 필터.
  결과에 outcome·최저가·검색원가·오류를 평탄화해 목록에서 바로 보이게.
- POST /v1/lps/jobs/{id}/requeue: DEAD 재큐(attempts 리셋+pg_notify 워커
  깨움). 활성 중복(dedupe)이면 DB_ALREADY_SAME_KEY 로 거절.
- GET /v1/lps/products: 상품별 최신 스냅샷+누적 검색 수(최근 검색순).
- GET /v1/lps/stats/ip-sessions: 종료사유 분포·요청수 히스토그램·차단
  세션 최소 요청수(예산 튜닝 기준선)·최근 세션 50.
- GET /v1/lps/stats/bot: 시간대별 차단 + 최근 감지 목록.
- GET /v1/lps/stats/cost: 시간별 원가(AI/프록시 분해)+평균 소요.
- AdminService/admin_protocol/admin 라우터 신설, guard 일괄 적용.
  설정 변경 UI 는 두지 않음 — toml 단일 소스 원칙.
- 테스트 9건 추가, 전체 154 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 21:42:38 +09:00
민헌
8fb54be830 feat(lps): local/dev/prod 환경별 실행 체계 — 환경 toml 2종 + 대화형 run_docker.sh
다른 개발자가 어느 환경이든 같은 방식으로 띄울 수 있게 한다.
환경 = APP_ENV 가 고르는 config.<env>.toml 하나(구조 동일, 값만 다름).

- config.docker.toml → config.dev.toml 개명, config.prod.toml.example 신설
  (prod 성격 반영: api_keys 필수 표기·debug 로그 금지·웹훅 권장).
- compose 의 lps 서비스 APP_ENV/마운트를 ${APP_ENV:-dev} 로 파라미터화,
  API 포트를 ${LPS_API_BIND:-0.0.0.0} 바인드로 노출 제어.
- run_docker.sh(대화형) 신설: 환경 선택 → 설정 파일 검증(없으면 example
  복사 제안) → 기동/재시작/중지/로그. prod 는 guard 키 비면 경고 후
  확인받고, LPS_API_BIND=127.0.0.1 자동 설정(외부는 리버스프록시 경유).
- 운영 가이드에 환경 개요 표, README 빠른 시작에 Docker 실행 추가.
- 검증: 스크립트 문법·compose(dev/prod) 파싱·APP_ENV=dev 로딩·145 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 21:22:53 +09:00
민헌
c81df5bd88 refactor(lps): 설정을 TOML 단일 소스로 통합 — env/.env 이중 관리 제거
설정이 .env(compose 주입)·config.toml·코드 곳곳의 os.environ 직독 3계층에
흩어져 관리가 어려웠다. TOML 하나로 통합한다(협의 결정).

- 신설 [WorkerConfig](동시성·폴백·프로필·데드라인·유예·Chrome·하트비트),
  [AlertConfig](웹훅·쿨다운·임계 10종). [WebServerConfig].api_keys(guard),
  [DecodoConfig].ip_request_budget/port_cooldown_sec 추가 — 흩어져 있던
  LPS_* env 20여 개를 섹션으로 흡수.
- server_configs 의 env override 계층(DB_*·시크릿·NAVER_KEYS 등) 삭제.
  남는 env 는 APP_ENV(부트스트랩)·PROCESS_COUNT/WORKER_CONCURRENCY(실행
  스크립트 대화형 입력 전용)·LPS_LIVE(테스트 옵트인)뿐.
- Docker: env 주입 → config.docker.toml 마운트 + APP_ENV=docker.
  이미지 무시크릿 유지, 마운트 누락 시 FileNotFoundError 즉시 실패.
  .env.example 삭제, config.docker.toml.example 신설.
- negodata 호출부: guard 키를 env 직독에서 [WebServerConfig].lps_api_key
  (+기존 관례대로 env override)로 이동.
- 실행 스크립트: 프로필·폴백·예산 프롬프트 제거(toml 소스 안내),
  동시성/프로세스 수만 임시 override 로 유지.
- docs 7종·example toml 의 env 표기를 toml 키로 일괄 갱신.
- 전체 145 passed + APP_ENV=docker 로딩·API 기동 스모크 확인.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 21:11:31 +09:00
민헌
993301be8b docs(lps): README·docs 최신화 — IP 선제 회전·알림 10룰·guard·ip_session 반영
이번 기능 3종+알림 확장 이후 문서와 코드의 어긋남을 정리한다.

- README: 주요 기능 표에 선제 회전(예산 3회)·임계 알림·API guard 추가,
  차단 대응 설명을 '막히기 전 교체' 순서로 재서술, 폴더 구조에
  alerts/ip_session 반영, 데이터베이스 문서 링크를 5종으로 수정.
- api.md: /v1/lps/ops 운영 스냅샷 섹션 신설(필드 주석 포함),
  /readyz 문서화, HTTP 401(guard) 상태 코드 추가.
- architecture.md: 구성요소 표에 관측·알림/API guard 행 추가.
- database.md: 제목 '테이블 5종'으로 수정.
- operations.md: 테스트 수 96→145, 로그 읽는 법에 예산 선제 회전·
  포트 쿨다운 로그 2행 추가.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:46:59 +09:00
민헌
ca7f057e41 feat(lps): 알림 룰 4종 추가 — 데드라인·검색원가·포트 고갈·예산 누수
협의로 선정한 조기 신호 4종을 AlertManager 에 추가한다.

- deadline: 최근 1h JobDeadlineExceeded 수 ≥ LPS_ALERT_DEADLINE_1H(5).
  재시도로 살아나면 dead 룰엔 안 잡히는 크롤 행 반복 신호를 별도 집계.
- cost: 최근 1h 완료 잡 검색원가 합 ≥ LPS_ALERT_COST_1H_USD(1.0).
  비용의 87%가 프록시 대역폭 — 리소스차단 풀림·재시도 루프의 조용한
  비용 폭주를 감시. job.result 의 metrics.cost.total_usd JSONB 합산.
- proxy_ports_low: 가용 포트 비율 ≤ LPS_ALERT_PORTS_LOW_PCT(30%).
  쿨다운 격리 누적 — blocks_1h(80건)보다 먼저 우는 대규모 차단 조기
  신호. 워커별 프록시 중 가장 소진된 것 기준(min).
- budget_leak: 최근 6h end_reason=block 세션 ≥ LPS_ALERT_BLOCK_SESSIONS_6H(1).
  요청 예산(3회)을 지켰는데도 차단됨 = 예산 하향 검토 신호.
- deadline_1h·cost_1h_usd 는 queue.ops() 에 편입 → /v1/lps/ops 로도 노출.
  포트·세션 지표는 워커 웹훅 스냅샷에 포함(프록시 상태는 워커에만 있음).
- 테스트 4건 추가(ops 집계 2·포트 스냅샷 2), 전체 145 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:29:08 +09:00
민헌
e7d2c87fbe feat(lps): API guard — LPS_API_KEY 설정 시에만 /v1 에 X-API-Key 검증
외부에서 API 를 함부로 호출(비용 발생 enqueue 등)하지 못하도록 정적 키
guard 를 추가한다. '키의 존재'가 토글 — 개발(local/dev)은 env 를 비워
개방 모드(기동 시 WARN), prod 만 키를 주입한다(협의 결정).

- router/v1/validator/auth.py: X-API-Key 의존성 — secrets.compare_digest
  상수시간 비교, 콤마 구분 복수 키(무중단 키 교체), 매 요청 env 조회
  (재기동 없이 테스트 가능). /v1 라우터 전체에 적용.
- /healthz·/readyz 는 라우터 밖이라 항상 개방(LB 프로브).
- negodata lps_sync_service: LPS_API_KEY env 있으면 헤더 자동 첨부(한 곳).
- compose(lps-api·negodata-backend) LPS_API_KEY 패스스루 + .env.example.
- prod 체크리스트(operations.md): 키 주입 + lps-api 포트 비공개 + 기동
  로그 'API guard ON' 확인. api.md 인증 섹션 추가.
- 라이브 스모크: 무헤더/오키 401 · 정키 2종 200 · healthz 200 확인.
- 테스트 6건 추가, 전체 141 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:45:31 +09:00
민헌
8b9e81777c feat(lps): 알림 확장 — AlertManager 쿨다운·회복, DB풀 포화·소스별 장기실패 룰
기존 ops-monitor 는 임계 초과가 지속되면 30초마다 같은 웹훅을 반복 발송했고
(쿨다운 없음), 해소 여부도 알 수 없었다. 감시 항목도 큐 지표 4종뿐이었다.

- common/alerts.py AlertManager 신설: 룰 키별 상태 관리 — 발화 1회 +
  쿨다운(LPS_ALERT_COOLDOWN_MIN, 기본 30분)마다 리마인드, 해소 시 회복
  알림 1회. sender/clock 주입으로 네트워크·대기 없이 단위 테스트.
- 워커 ops-monitor 를 AlertManager 로 이관(기존 4룰 유지) + 신규 2룰:
  db_pool(풀 포화율 ≥ LPS_ALERT_POOL_PCT 90%) ·
  source_fail:<src>(최근 30분 시도 ≥ LPS_ALERT_SOURCE_FAIL_30M(5) & 성공 0
  — 쿼터 소진·셀렉터 드리프트·전면 차단 신호).
- DBSessionManager.pool_status(): 전 엔진 합산 checked_out/capacity/pct.
- SearchAdapter 에 시간 윈도우 성공/실패 카운터(recent_stats) — 누적
  카운터로는 '최근 30분 성공 0건'을 볼 수 없어 추가. 쿠팡(브라우저)·
  네이버(API) 성공/실패 지점에 배선.
- API 자체 풀 모니터: lifespan 백그라운드 태스크(run_pool_monitor) —
  대량 폴링으로 풀을 고갈시키는 주범이 API 자신일 수 있다.
  /v1/lps/ops 에 pool_checked_out/pool_capacity/pool_pct 노출(스모크 확인).
- 테스트 9건 추가(발화·쿨다운·회복·룰 독립·윈도우 카운터·풀 현황), 전체 135 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:42:12 +09:00
민헌
3c5286aeec feat(lps): IP 선제 로테이션 — 요청 예산·포트 쿨다운·ip_session 관측
쿠팡 크롤 IP 를 '막힐 때까지' 쓰던 방식을 '막히기 전에 교체'로 전환한다.

- 요청 예산(LPS_IP_REQUEST_BUDGET, 기본 3): IP당 요청 수가 예산에 닿으면
  차단 전에 선제 회전. 실측상 5회 부근 차단 이력이 있어 보수적으로 3회.
  선제 교체된 포트는 평판이 깨끗해 로테이션 복귀 시 재사용된다.
- 포트 쿨다운(LPS_PORT_COOLDOWN_SEC, 기본 max(sticky,30분)): 차단 감지·
  전송오류 포트는 격리하고 _port() 가 건너뛴다. 전 포트 쿨다운이면 만료
  임박 포트 사용(가용성 우선). 포트 수는 config 범위에서 동적 산출.
- 차단 재시도 소진 시에도 회전 예약 — 불탄 포트로 다음 검색을 하지 않음.
- ip_session 테이블 신설: 세션마다 요청 수·성공/차단·종료 사유(budget/
  block/proxy_error/window/idle/shutdown)를 기록. bot_detection 과 달리
  무사 종료도 남아 예산 상한 튜닝의 원천 데이터가 된다(쿼리 database.md).
  models.py·migrations·init.sql(lps_db 섹션) 동행 갱신, dev DB 적용 완료.
- 테스트 17건 추가(쿨다운·예산 판정·세션 기록·CRUD), 전체 126 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:35:44 +09:00
민헌
f97e407f7c Merge branch 'main' into feature/negodata-lps 2026-07-13 13:55:36 +09:00
민헌
9ef9bfbca3 feat(negodata/front): 최저가 상세보기 버튼 노출 + 상세 시트 가독성 정리
- 상품 테이블 인터넷 최저가 셀: 값 옆에 차트 아이콘 버튼 상시 노출
  (기존 hover 밑줄만으론 클릭 가능성이 안 보임) — 값 없는 상품도
  버튼으로 진입해 빈 상태 안내를 받는다
- 상세 시트 재구성: 견적 상세 드로어와 같은 SectionCard 문법으로 통일
  · 헤드라인 카드: 상품명 → 대표가(26px rose) + 단가 대비 칩
    (절감=emerald·역전=amber, Trending 아이콘) + 단가·마지막 수집 캡션
  · 최저가 출처: 섹션 타이틀 우측 사이트 배지, 링크는 outline 버튼화
  · 가격 추이: 수집 횟수 캡션, 2회 미만이면 안내 문구(빈 영역 방지)
  · 수집 이력: 배지 폭 고정(w-14)으로 컬럼 정렬, 링크 없는 행 자리 유지

검증: 헤드리스 렌더 확인(테이블 셀·시트 v2 스크린샷), tsc 통과

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:18:09 +09:00
민헌
4a20194dc8 feat(negodata): 인터넷 최저가 출처·링크·가격 추이 UI — 검증 가능한 협상 근거
인터넷 최저가는 견적 목표가·협상 카드(NGC-008 시장가 근거)의 근거값
— "어디서 찾았는지"를 클릭 한 번으로 검증할 수 있어야 한다.

- 스키마: iilp 에 lp_name(찾은 상품명)·lp_url(판매 페이지 링크) 추가
  (alters/2026-07-10-iilp-source-link.sql, init.sql 멱등 반영·dev 적용)
- 동기화: price_history 의 final_source 에 맞는 naver/coupang name·url
  을 함께 반영(기존 행은 NULL 공존)
- API: LowestPriceEntry 에 lp_name/lp_url + orval 재생성
- UI: 상품 테이블 인터넷 최저가 셀 클릭 → 상세 시트
  · 대표가(rose) + 상품 단가 대비 차액(절감=emerald/역전=amber)
  · 출처 카드: 사이트 배지·찾은 상품명·판매 페이지 새탭 링크
  · 가격 추이 라인 그래프(성공 수집 2건부터, recharts+ChartContainer
    라이트/다크 쌍 — 통계 차트와 동일 문법)
  · 최근 수집 이력 리스트(미발견 amber, 행별 외부링크)

검증: 실데이터 e2e(A4 재수집 → lp_name/lp_url 반영 확인) +
헤드리스 브라우저 렌더 확인(시트·그래프·툴팁·링크), tsc·build 통과

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:10:00 +09:00
536 changed files with 36240 additions and 6630 deletions

View File

@ -1,21 +0,0 @@
# docker compose 용 환경변수 템플릿 — 복사해서 사용: cp .env.example .env
# 실제 값(.env)은 커밋하지 않는다(.gitignore). 이미지에는 시크릿이 없으므로(lps 는 example
# config 로 빌드) 아래 값이 없으면 해당 기능이 꺼진 채 뜬다(주석 참고).
# ── LPS DB (미설정 시 postgres/postgres) ──
LPS_DB_USER=postgres
LPS_DB_PASSWORD=postgres
# ── LPS API 스케일 (미설정 시 1 / 40 — 단일 프로세스로도 ~1,100 RPS) ──
LPS_API_PROCESS_COUNT=1 # uvicorn 프로세스 수(=사용 코어 수). 커넥션 풀은 예산에서 자동 역산
LPS_DB_CONNECTION_BUDGET=40 # lps API 커넥션 총예산. 공유 PG=40, 전용 PG(max_conn≈100)=90
# ── LPS 워커 시크릿 ──
OPENAI_API_KEY= # 비면 AI 유사도 판정 OFF
NAVER_KEYS= # "id1:secret1,id2:secret2" — 네이버 쇼핑 오픈API 키(여러 개면 로테이션)
DECODO_HOST= # 예: gate.decodo.com — DECODO 4종이 비면 프록시 미사용(직접 연결)
DECODO_USERNAME=
DECODO_PASSWORD=
DECODO_PORT_START=0 # 예: 10001
DECODO_PORT_END=0 # 예: 10010
DECODO_COST_PER_GB=0 # 요금($/GB) — 검색 원가 계측용(예: 3.0)

4
.gitignore vendored
View File

@ -28,3 +28,7 @@ CLAUDE.md
# 로컬 리서치 노트(크롤링 라이브러리·안티스크래핑 조사) — 추적 안 함, 로컬 참고용 # 로컬 리서치 노트(크롤링 라이브러리·안티스크래핑 조사) — 추적 안 함, 로컬 참고용
/Temp.md /Temp.md
/new.md /new.md
/mobile.mov
.gstack/
.playwright-mcp/

BIN
0729~30_테스트.xlsx Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

190
README.md
View File

@ -1,63 +1,198 @@
# O2O Negosium # O2O Negosium
동일 구조의 두 서비스(**negosium**, **negodata**)가 **하나의 PostgreSQL 인스턴스**를 공유한다. AI 협상 솔루션. 여러 백엔드·프론트·배치가 **하나의 PostgreSQL 인스턴스**를 공유하고,
전부 `docker compose` 하나로 뜬다. (인터넷 최저가 검색 LPS 만 별도 DB `lps_db` 사용.)
## 서비스 소개
구매기업(바이어)이 협력사(공급사)와 벌이는 **가격 협상을 AI 봇이 대신 수행**하는 B2B 협상 자동화 솔루션이다.
바이어가 상품·목표가·기간만 정해 견적을 열면, 각 협력사와의 1:1 협상은 강화학습 기반 에이전트가
**협상 카드로 밀당**하며 진행하고, 마감 시각에 최저 투찰가를 기준으로 자동 **낙찰/개찰**을 판정한다.
크게 세 축 + 부속으로 나뉜다.
| 축 | 구성요소 | 역할 |
|---|---|---|
| **바이어 측** | negodata (backend + front) | 어드민. 상품·협력사 관리, 견적 생성, 마감·낙찰 관리 |
| **공급사 측** | negosium (backend + frontend) | 협력사 포털. 초청받은 협상 챗에 참여해 가격 제시 |
| **협상 엔진** | agent | 실제 AI 협상 봇. 앵커링가·협상 카드로 자동 협상 (강화학습) |
| 부속 | lps · anchoring · landing | 인터넷 최저가(목표가 재료) · 앵커값 자동 조정 배치 · 솔루션 소개 랜딩 |
## 구성 ## 구성
``` ```
o2o-negosium/ o2o-negosium/
├── docker-compose.yml # 두 backend (DB 는 외부) ├── docker-compose.yml # 전체 서비스 (DB 는 compose 밖, config 로 외부 연결)
├── postgres-init/ # DB·테이블 셋업 SQL (대상 DB 에 1회 적용) ├── postgres-init/ # DB·스키마·시드 SQL (대상 DB 에 1회 적용)
├── backend/ # negosium 백엔드 (포트 9300) │
├── negodata/backend/ # negodata 백엔드 (포트 9400) ├── backend/ # negosium 백엔드 — 공급사/협상 API (:9300)
├── agent/ front/ # (예정) ├── frontend/ # negosium 공급사 프론트 (:3300, 프로덕션 빌드 정적 서빙)
└── negodata/front/ # (예정) ├── agent/ # 협상 에이전트 — RL(learning 스키마) (:9500)
│
├── negodata/backend/ # negodata 백엔드 — 바이어/어드민 API (:9400)
├── negodata/front/ # negodata 어드민 프론트 (Vite, :3000)
│
├── landing/ # 솔루션 랜딩페이지 (react-router SSG, :3100)
│
├── lps/ # 인터넷 최저가 검색: lps-api(:9600) + lps-worker(크롤)
├── lps-admin/ # LPS 관리자 UI (nginx → lps-api 프록시, :3400)
│
└── schedules/anchoring/ # 앵커링 값 자동 조정 배치 (포트 없음, 상주 스케줄러 + Redis)
``` ```
두 백엔드는 같은 코드 골격(MVC · 람다 DB · Depends 주입 · JWT 로그인)을 쓴다. negosium·negodata·agent 백엔드는 같은 코드 골격(MVC · 람다 DB · Depends 주입 · JWT 로그인)을 쓴다.
아키텍처/패턴 상세는 각 서버 README 참고: [backend](backend/README.md) · [negodata/backend](negodata/backend/README.md) 아키텍처/패턴 상세는 각 서브 README 참고:
- 백엔드: [backend](backend/README.md) · [negodata/backend](negodata/backend/README.md) · [agent](agent/README.md) · [lps](lps/README.md)
- 프론트: [frontend](frontend/README.md) · [negodata/front](negodata/front/README.md)
- 배치: [schedules/anchoring](schedules/anchoring/README.md) · 관리자 UI: [lps-admin](lps-admin/README.md)
### 서비스 / 포트
| 서비스 | 주소 | 역할 | DB |
|---|---|---|---|
| negosium-backend | http://localhost:9300/docs | 공급사·협상 API | negosium_db |
| negosium-front | http://localhost:3300 | 공급사 프론트 | — |
| agent | http://localhost:9500/docs | 협상 에이전트(RL) | negosium_db (learning) |
| negodata-backend | http://localhost:9400/docs | 바이어·어드민 API | negosium_db |
| negodata-front | http://localhost:3000 | 어드민 프론트 | — |
| landing | http://localhost:3100 | 솔루션 랜딩 | — |
| lps-api | http://localhost:9600/docs | 최저가 검색 접수/조회 | lps_db |
| lps-worker | 포트 없음 | 크롤 워커(헤드풀 Chromium) | lps_db |
| lps-admin | http://localhost:3400 | LPS 관리자 UI | — |
| anchoring | 포트 없음 | 앵커링 조정 배치(격주 토 00:00 KST) | negosium_db (anchoring) |
| anchoring-redis | 127.0.0.1:6380 | anchoring 조회 캐시 | — |
| autoheal | — | unhealthy 컨테이너 자동 재시작 | — |
### DB 는 compose 밖 (config 로 연결) ### DB 는 compose 밖 (config 로 연결)
DB 는 docker-compose 에서 관리하지 않는다. 각 backend 는 `config.<APP_ENV>.toml` 의 접속 정보대로 DB 는 docker-compose 에서 관리하지 않는다. 각 backend 는 `config.<APP_ENV>.toml` 의 접속 정보대로
**외부 PostgreSQL**(호스트 로컬 postgres, 또는 따로 떠 있는 docker postgres)에 연결한다. **외부 PostgreSQL**(호스트 로컬 postgres, 또는 따로 떠 있는 docker postgres)에 연결한다.
한 PostgreSQL 안에 서비스별 database 를 둔다.
한 PostgreSQL 인스턴스 안에 **단일 `negosium_db`** 를 두고 도메인별 **schema** 로 묶는다.
LPS 만 별도 database(`lps_db`) 를 쓴다.
``` ```
PostgreSQL (외부, 5432) PostgreSQL (외부, 5432)
├── negosium_db ← negosium-backend ├── negosium_db ← negosium-backend · negodata-backend · agent · anchoring 공유
└── negodata_db ← negodata-backend │ ├── company / supplier / partner : 회사·유저·협력사·상품
│ ├── card / quotation / negotiation: 협상 카드·견적·협상 세션
│ ├── learning : RL 자산 (agent 소유)
│ └── anchoring : 앵커링 조정 (schedules/anchoring 소유)
└── lps_db ← lps-api · lps-worker
``` ```
- 컨테이너(docker env)에서 호스트 DB 접근: `host.docker.internal:5432` (`config.docker.toml`) - 컨테이너(docker env)에서 호스트 DB 접근: `host.docker.internal:5432` (compose 가 `DB_HOST` 로 override)
- 로컬 실행/테스트(local·test env): `127.0.0.1:5432` (`config.local/test.toml`) - 로컬 실행/테스트(local·test env): `127.0.0.1:5432` (`config.local/test.toml`)
- 계정/database 명은 config 에 맞춘다 (기본 `postgres` / `password`). - 계정/database 명은 config 에 맞춘다 (기본 `postgres` / `password`).
| 서비스 | 서버 | docs | database | ## 핵심 플로우
|---|---|---|---|
| negosium-backend | http://localhost:9300 | /docs | negosium_db | ### 1. 견적 라이프사이클 (전체 개요)
| negodata-backend | http://localhost:9400 | /docs | negodata_db |
바이어가 견적을 열고 → 협력사가 협상에 참여 → 마감 시각에 판정되는 큰 흐름.
견적 유형은 두 축(재/신규 × 협상 1:1 / 견적 1:N)으로 4종.
```mermaid
flowchart TD
A["바이어: 상품·협력사·기간 선택<br/>견적 유형 4종 + 낙찰 기준(mid/over_action) 설정"] --> B["목표가·앵커링가 산정<br/>(MD제시가 → 인터넷최저가/매입가/판매가)"]
B --> C["협력사 초청 (이메일)"]
C --> D{"견적 유형"}
D -->|"협상 1:1 (재협상·신규협상)"| E["AI 봇 밀당 협상<br/>(협상 카드 사용)"]
D -->|"견적 1:N (재견적·신규견적)"| F["정형 흐름<br/>(배송형태·추가할인 확인)"]
E --> G["세션별 투찰가 확정<br/>(협상완료) 또는 실패"]
F --> G
G --> H{"마감 트리거<br/>①마감시각 ②전세션종결 ③수동"}
H --> I["마감 판정<br/>(최저 투찰가 기준)"]
I --> J["낙찰 (승자 1)"]
I --> K["개찰 (낙찰자 미정)"]
```
### 2. 1:1 협상 봇 판정 (agent)
협력사가 가격을 제시할 때마다 봇이 **앵커링가** 기준으로 판정한다.
재제안은 카드를 한 장씩 쓰며 **최대 3번**, 카드 소진·3번 초과에도 앵커 밑으로 못 내리면 실패(투찰 없음).
```mermaid
flowchart TD
P["협력사 가격 제시"] --> Q{"제시가 vs 앵커링가"}
Q -->|"≤ 앵커링가"| R["협상완료 — 투찰 확정"]
Q -->|"앵커 ~ 앵커×1.02"| S["와일드카드: 1% 인하 요청<br/>(세션당 1회)"]
Q -->|"앵커×1.02 초과"| T["협상 카드로 재제안"]
S --> U{"재제안 횟수 ≤ 3?<br/>카드 남음?"}
T --> U
U -->|"예"| P
U -->|"아니오 (소진·3번 초과)"| V["협상 실패 — 낙찰 후보 아님"]
```
### 3. 마감 판정
마감 시 **협상완료 세션의 최저 투찰가**를 본다. 공통 전제: 완료 세션이 없거나(전원 미응찰·협상거부)
동가 최저가 2곳 이상이면 유형과 무관하게 **개찰**. 그 외 단독 최저가일 때만 낙찰 후보가 되며,
이후 판정이 유형별로 갈린다.
#### 3-1. 견적 1:N — 단독 최저면 무조건 낙찰
가격 구간을 보지 않는다. 생성 시 `mid/over_action`이 낙찰(AWARD)로 강제되기 때문.
```mermaid
flowchart TD
M1["마감: 협상완료 세션 최저 투찰가"] --> N1{"완료 세션 있나?"}
N1 -->|"없음 (전원 미응찰·협상거부)"| O1["개찰"]
N1 -->|"동가 최저 2곳+"| O1
N1 -->|"단독 최저"| X1["낙찰 (가격 구간 무관, 무조건)"]
```
#### 3-2. 협상 1:1 — 가격 구간별, 생성 때 정한 값 적용
앵커링가 이하는 무조건 낙찰. 그 위 구간은 **견적 생성 때 미리 정해둔 값**(`mid_action`/`over_action`,
1=낙찰·2=개찰)을 마감 시 그대로 적용한다. 두 필드는 적용 구간만 다를 뿐 동작은 동일.
```mermaid
flowchart TD
M2["마감: 협상완료 세션 최저 투찰가"] --> N2{"완료 세션 있나?"}
N2 -->|"없음 (전원 미응찰·협상거부)"| O2["개찰"]
N2 -->|"동가 최저 2곳+"| O2
N2 -->|"단독 최저"| W2{"투찰가 위치"}
W2 -->|"≤ 앵커링가"| X2["낙찰"]
W2 -->|"앵커 ~ 목표가"| Y2["생성 시 정한 mid_action 적용<br/>(1=낙찰 / 2=개찰)"]
W2 -->|"목표가 초과"| Z2["생성 시 정한 over_action 적용<br/>(1=낙찰 / 2=개찰)"]
```
> 개찰 = 낙찰자 미정 마감(결렬 아님). 개찰 후 수동 처리로 **직접 낙찰 확정**(`/v1/quotation/award`)
> 또는 **재견적 재생성**(`/v1/quotation/regenerate`)이 있다.
> 비즈니스 로직 정본은 [negodata/docs/business-logic.md](negodata/docs/business-logic.md).
## 빠른 시작 ## 빠른 시작
```bash ```bash
# 1) DB 준비 (최초 1회) — 사용할 PostgreSQL 에 스키마 + 시드 적용 # 1) DB 준비 (최초 1회) — 사용할 PostgreSQL 에 스키마 + 시드 적용
psql -h 127.0.0.1 -p 5432 -U postgres -f postgres-init/00-init.sql # 스키마 전체 (negosium_db + 도메인·learning·anchoring schema) # 스키마 DDL (구 01~05 통합, 전부 IF NOT EXISTS 라 재실행 안전)
psql -h 127.0.0.1 -p 5432 -U postgres -f postgres-init/temp-data.sql # 임시 데이터 시드 (admin / admin1234) psql -h 127.0.0.1 -p 5432 -U postgres -d negosium_db -f postgres-init/init-data/init.sql
# 로컬/개발 시드 (admin / admin1234, 회사·유저·협상 카드)
psql -h 127.0.0.1 -p 5432 -U postgres -d negosium_db -f postgres-init/init-data/init-data.sql
# 2) 백엔드 기동 # (DBeaver 로 처음부터 새로 깔 때는 postgres-init/dbeaver/ 의 0~5 순서 스크립트를 쓴다:
docker compose up -d # 두 backend (DB 는 config 대로 외부 연결) # 0 drop&create → 1 스키마 → 2 시드 → 3 lps_db → 4 카드 리셋 → 5 o2o OWNER 유저)
docker compose logs -f
# 2) 전체 기동
docker compose up -d
docker compose logs -f # 컨테이너별 로그는 ./logs.sh 메뉴로도 확인
docker compose down docker compose down
``` ```
> 스키마 변경 보정은 `postgres-init/alters/` 의 날짜별 SQL 을 대상 DB 에 수동 적용한다
> (postgres-init 은 DB 최초 생성 때만 자동 실행되므로, 기존 DB 엔 alter 를 직접 돌려야 새 컬럼이 반영된다).
## 테스트 ## 테스트
```bash ```bash
# config.test.toml 의 PostgreSQL(기본 127.0.0.1:5432) 이 떠 있어야 한다 # config.test.toml 의 PostgreSQL(기본 127.0.0.1:5432) 이 떠 있어야 한다
cd backend # 또는 negodata/backend cd backend # 또는 negodata/backend, agent, lps ...
pip install pytest pytest-asyncio httpx pip install pytest pytest-asyncio httpx
python -m pytest python -m pytest
``` ```
- httpx `ASGITransport` 로 네트워크 없이 앱을 직접 호출하는 e2e (각 5개). - httpx `ASGITransport` 로 네트워크 없이 앱을 직접 호출하는 e2e.
- `DB_SESSION_MNG` 싱글톤의 커넥션 풀이 첫 이벤트 루프에 묶이므로, 모든 테스트가 단일 session 루프를 공유한다(`pytest.ini`). - `DB_SESSION_MNG` 싱글톤의 커넥션 풀이 첫 이벤트 루프에 묶이므로, 모든 테스트가 단일 session 루프를 공유한다(`pytest.ini`).
- ⚠️ 테스트는 `APP_ENV=test` 로 격리한다(테스트 DB). dev DB(`negosium_db`)에 대고 돌리면 데이터가 날아간다.
## 성능 / 벤치마크 ## 성능 / 벤치마크
@ -99,4 +234,9 @@ python -m locust -f loadtest/locustfile.py --host http://localhost:9300 --headle
``` ```
## 기술 스택 ## 기술 스택
FastAPI · SQLAlchemy(async) · asyncpg · PostgreSQL 16 · python-jose(JWT) · bcrypt · uvicorn · Docker Compose - **백엔드**: FastAPI · SQLAlchemy(async) · asyncpg · PostgreSQL 16 · python-jose(JWT) · bcrypt · uvicorn
- **프론트**: React · react-router v7 · Vite · TanStack Query (negosium-front 는 프로덕션 빌드 정적 서빙)
- **에이전트**: 강화학습(Q-learning, learning 스키마) · OpenAI
- **LPS**: 헤드풀 Chromium + Patchright(스텔스 Playwright 포크, 크롤) · autoheal
- **배치**: Redis(anchoring 캐시) · 상주 스케줄러
- **공통**: Docker Compose

View File

@ -1,120 +1,240 @@
"""협상카드 전술 레지스트리 — "멘트 카드 → 전술 카드" 승격 (가격 행동 실행 계층). """협상카드 전술 — "스크립트에 꽂힌 변수가 곧 전술" 계층.
카드 멘트에 이미 설계된 카운터 가격 제시({target_price}·{middle_price} 등)를 시스템 상태로 카드 멘트가 제시하는 가격({target_price}·{middle_price} 등)을 파싱해 시스템 상태로 실행한다:
실행한다: 카드가 카운터가를 제시하면 pending_counter_price 로 적재되고, 협력사가 수락하면 카드가 제안가를 제시하면 pending_counter_price 로 적재되고, 협력사가 수락하면 그 가격으로 타결된다.
그 가격으로 즉시 타결된다(기존 wild_card_1pct 의 offer_1pct 패턴을 일반화).
원칙: 세 계층으로 나뉜다.
- 구매자(갑) 대리이므로 카운터는 항상 min(counter, target_price) 클램프 — 목표가 초과 제시 금지. 1) 스크립트 파싱 — 이 카드가 부를 금액이 무엇인지 (parse_offer_variable)
- 협력사 제시가가 이미 카운터 이하면 카운터가 무의미 → None(HOLD 강등, 순수 설득). 2) 변수 정의 — 그 금액을 지금 쓸 수 있는지 (OFFER_VARIABLES 의 계산식 + 유효조건)
- 미등록 카드번호(테넌트 데모 NGC-B*, 회사 커스텀 COMP-* 등)는 HOLD 폴백 → 기존 동작 그대로. 3) tactic JSONB — 문장으로 알 수 없는 운영 규칙 (min_round·closing)
전술 정본은 이 코드 레지스트리다(v1). negodata 카드 편집은 멘트만 담당하고, 전술을 negodata 유효 조건은 카드가 아니라 '변수'에 붙인다 — 금액이 성립하는지는 계산식의 성질이지 카드의
에서 편집할 필요가 생기면 v2 에서 card.nego_cards 컬럼로 승격해 "DB 우선, 코드 폴백"으로 바꾼다. 성질이 아니다. 새 변수는 OFFER_VARIABLES 에 한 줄 추가하면 코드 분기 없이 끝난다.
""" """
import re
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from typing import Any, Callable, Dict, Optional
from typing import Any, Dict, Optional
# 제안가 변수 — 우리가 새로 부르는 금액. 값은 (계산식, 재료 설명).
# 여기 없는 치환 변수({prev_partner_price}·{internet_lowest_price} 등)는 읽어주기 전용이라
# 제안가가 되지 않는다 — 과거값·외부값을 협력사에게 "수락하라"고 내밀 수 없기 때문.
OFFER_VARIABLES: Dict[str, Callable[[float, float, float, float], Optional[float]]] = {
# (target, anchor, price, prev_customer) -> 제안가 | None(재료 없음)
"target_price": lambda target, anchor, price, prev: target,
"anchoring_price": lambda target, anchor, price, prev: anchor or None,
# negodata 카드 에디터 칩 표기(variables.ts) — DB 시드 표기(anchoring_price)와 같은 값의 별칭.
"anchor_price": lambda target, anchor, price, prev: anchor or None,
"target_mid_price": lambda target, anchor, price, prev: (anchor + target) / 2 if anchor else None,
"middle_price": lambda target, anchor, price, prev: (prev + price) / 2 if prev else None,
}
_TOKEN_RE = re.compile(r"\{([a-z_]+)\}")
# 절충 계열 변수 — 양측 사이/우리 두 값 사이의 중간을 부르는 카드. 목표가 이상이면 미발동한다.
_MID_VARIABLES = ("middle_price", "target_mid_price")
class PriceAction(str, Enum): # 세션 데이터에 따라 값이 없을 수 있는 읽기 전용 변수 → 그 값을 담는 컨텍스트 키.
HOLD = "hold" # 카운터 없음 — 재제안 요구(순수 설득, 기존 동작) # 스크립트가 이런 변수를 인용하면 값이 있을 때만 카드가 나간다 — 없는데 나가면 협력사 채팅에
COUNTER_TARGET = "counter_target" # 목표가 제시 # {internet_lowest_price} 토큰이 원형 노출된다(vars_for 가 미수집이면 키를 안 만드는 것과 짝).
COUNTER_ANCHOR = "counter_anchor" # 앵커가 제시 (예산 상한 프레이밍) # 견적 생성 화면 게이팅(useCardGating)이 1차 방어, 여기가 2차(런타임) 방어다.
COUNTER_TARGET_MID = "counter_target_mid" # (anchor+target)/2 — 시드 {target_mid_price} _CONTEXT_REQUIRED_VARIABLES = {
COUNTER_MID = "counter_mid" # (갑 직전 포지션+협력사 제시가)/2 — 시드 {middle_price} "internet_lowest_price": "internet_lowest_price",
ONE_PCT = "one_pct" # 제시가 1% 인하 (기존 offer_1pct) "internet_min_price": "internet_lowest_price",
@dataclass(frozen=True)
class TacticSpec:
"""카드 1장의 전술 명세.
min_round: 발동 가능 최소 라운드(협력사 가격 입력 횟수 기준).
max_price_ratio: input_price ≤ anchor×ratio 일 때만 발동 (None=무제한).
closing: 종결 국면(라운드 만료·카드 소진) 우선 전술.
"""
price_action: PriceAction = PriceAction.HOLD
min_round: int = 1
max_price_ratio: Optional[float] = None
closing: bool = False
_DEFAULT = TacticSpec() # HOLD — 미등록 카드 폴백
# 카드번호 → 전술. 시드(init-data.sql) 멘트의 가격 변수와 1:1 정합.
# NGC-001~006: 순수 설득(경쟁 압박/승인 핑계/관계/명분/공정성/TCO) — 가격 변수 없음.
# NGC-008: {internet_lowest_price} 인용이나 데이터 소스 미보유 → v1 HOLD (소스 확보 시 승격).
_TACTICS: Dict[str, TacticSpec] = {
"NGC-007": TacticSpec(PriceAction.COUNTER_ANCHOR), # 예산 상한 안내
"NGC-009": TacticSpec(PriceAction.COUNTER_TARGET), # 조건부 가격 조정
"NGC-010": TacticSpec(PriceAction.COUNTER_TARGET), # 향후 거래 연계
"NGC-011": TacticSpec(PriceAction.COUNTER_TARGET), # 양보 가치 강조
"WC-01": TacticSpec(PriceAction.COUNTER_TARGET, min_round=1), # 목표가 선제안
"WC-02": TacticSpec(PriceAction.COUNTER_TARGET_MID), # 역제안가 제시
"WC-03": TacticSpec(PriceAction.COUNTER_TARGET, closing=True), # 최종 통보(최후통첩)
"WC-04": TacticSpec(PriceAction.COUNTER_TARGET, min_round=2), # 단계적 인하 제안
"WC-05": TacticSpec(PriceAction.COUNTER_MID, closing=True), # 중간값 절충(종결)
} }
def tactic_for(card_number: Optional[str]) -> TacticSpec: @dataclass(frozen=True)
"""카드번호의 전술. 미등록/None 은 HOLD(기존 동작).""" class CardSpec:
return _TACTICS.get(str(card_number), _DEFAULT) if card_number else _DEFAULT """카드 1장의 전술. 스크립트 파싱 결과 + tactic JSONB 를 합친 값.
offer_variable: 이 카드가 제시할 금액의 변수명. None 이면 순수 설득 카드(HOLD).
def tactic_available(spec: TacticSpec, context: Dict[str, Any]) -> bool: min_round: 발동 가능 최소 라운드(협력사 가격 입력 횟수 기준).
"""발동 조건 평가 — action space 마스킹용. HOLD(설득)는 언제나 가능.""" closing: 종결 국면 전용 — 라운드 상한·카드 소진 시의 마지막 한 방으로만 쓴다.
if spec.price_action is PriceAction.HOLD: requires: 스크립트가 인용한 세션-의존 변수의 컨텍스트 키 — 값이 없으면 미발동(토큰 노출 방지).
return True
rnd = int(context.get("round") or 0)
if rnd < spec.min_round:
return False
if spec.max_price_ratio is not None:
price = float(context.get("input_price") or 0)
anchor = float(context.get("anchor_price") or 0)
if anchor > 0 and price > anchor * spec.max_price_ratio:
return False
return True
def compute_counter(spec: TacticSpec, context: Dict[str, Any]) -> Optional[int]:
"""전술의 카운터 제시가 계산 (결정론).
- 항상 min(counter, target) 클램프 — 구매자는 목표가 초과로 제시하지 않는다.
- counter ≥ 협력사 제시가(input_price)면 카운터가 무의미(이미 더 싸게 제시받음) → None.
- 필요한 컨텍스트(target/anchor/제시가)가 없으면 None → 호출부가 HOLD 로 강등.
""" """
action = spec.price_action
if action is PriceAction.HOLD: offer_variable: Optional[str] = None
min_round: int = 1
closing: bool = False
requires: tuple = ()
HOLD = CardSpec() # 스펙을 못 찾은 카드(테넌트 데모·회사 커스텀)의 폴백 — 기존 동작(설득만) 유지
def settle_ceiling(context: Dict[str, Any]) -> float:
"""이 협상에서 받아줄 수 있는 최고가 — 타결 판정선이자 카드 제안가의 상한.
견적 생성 시 세션에 박제한 done_ceiling_price(= 목표가 × (1 + 타결상한율)) — 목표가를 조금
넘더라도 기존 단가보다 인하됐으면 타결시키기 위한 값. 박제가 없으면 목표가로 폴백한다.
"""
return float(context.get("done_ceiling_price") or context.get("target_price") or 0)
def parse_offer_variable(script: Optional[str]) -> Optional[str]:
"""스크립트가 제시하는 제안가 변수. 없으면 None(설득 카드).
변수가 여럿이면 마지막에 등장하는 것이 제안가다 — 카드 문장은 배경을 먼저 깔고 실제 제안을
마지막에 하기 때문이다.
"""
found = [m.group(1) for m in _TOKEN_RE.finditer(script or "") if m.group(1) in OFFER_VARIABLES]
return found[-1] if found else None
def build_card_spec(script: Optional[str], tactic: Optional[dict] = None) -> CardSpec:
"""스크립트 + tactic JSONB → CardSpec. tactic 이 비어 있으면 전부 기본값."""
t = tactic or {}
cited = {m.group(1) for m in _TOKEN_RE.finditer(script or "")}
return CardSpec(
# 파싱이 정본 카드 전부를 맞히므로 offer_variable 은 예외 카드용 override 로만 둔다.
offer_variable=t.get("offer_variable") or parse_offer_variable(script),
min_round=int(t.get("min_round") or 1),
closing=bool(t.get("closing")),
requires=tuple(sorted({_CONTEXT_REQUIRED_VARIABLES[v] for v in cited if v in _CONTEXT_REQUIRED_VARIABLES})),
)
def spec_from_context(context: Dict[str, Any], number: Optional[str]) -> CardSpec:
"""세션 컨텍스트에 적재된 카드 스펙(card_specs)에서 꺼낸다. 없으면 HOLD 폴백.
스펙은 협상 시작 시 1회 적재된다(negotiation_context_loader) — 진행 중인 협상은
카드 멘트가 도중에 바뀌어도 시작 시점 전술로 끝까지 간다.
"""
if not number:
return HOLD
raw = (context.get("card_specs") or {}).get(str(number))
if not raw:
return HOLD
return CardSpec(
offer_variable=raw.get("offer_variable"),
min_round=int(raw.get("min_round") or 1),
closing=bool(raw.get("closing")),
requires=tuple(raw.get("requires") or ()),
)
@dataclass(frozen=True)
class Offer:
"""확정된 제안 한 건 — 금액과 그 금액을 만든 재료를 함께 들고 다닌다.
멘트 치환이 재료를 다시 계산하지 않게 하기 위한 것 — 재계산하면 그 사이 갱신된
prev_customer 를 읽어 문장이 자기모순이 된다.
"""
price: int # 협력사에게 제시할 금액(수락 시 타결가)
variable: str # 이 금액을 만든 멘트 변수
prev_customer: int # 계산에 쓴 당사 직전 제안
prev_partner: int # 계산에 쓴 협력사 제시가
def record_offer(context: Dict[str, Any], offer: Offer) -> None:
"""확정 제안을 세션에 기록한다 — 수락 판정용 금액과 멘트 치환용 재료를 한 자리에서 쓴다.
두 키를 항상 함께 써야 표시가와 타결가가 갈라지지 않으므로 기록 지점을 여기 하나로 묶는다.
"""
context["pending_counter_price"] = offer.price
context["pending_offer"] = {
"price": offer.price, "variable": offer.variable,
"prev_customer": offer.prev_customer, "prev_partner": offer.prev_partner,
}
context["prev_customer_price"] = offer.price # 갑의 최신 포지션 — 다음 라운드 계산·역행 금지 기준
def compute_offer_detail(spec: CardSpec, context: Dict[str, Any]) -> Optional[Offer]:
"""카드가 제시할 금액 + 그 계산에 쓴 재료. 쓸 수 없는 상황이면 None."""
price = int(float(context.get("input_price") or 0))
prev_customer = int(float(context.get("prev_customer_price") or context.get("anchor_price") or 0))
value = compute_offer(spec, context)
if value is None:
return None return None
return Offer(price=value, variable=spec.offer_variable or "", prev_customer=prev_customer, prev_partner=price)
def compute_offer(spec: CardSpec, context: Dict[str, Any]) -> Optional[int]:
"""카드가 제시할 금액. 쓸 수 없는 상황이면 None → 호출부가 카드를 건너뛴다.
변수 공통 유효조건 (전부 만족해야 발동):
· 값 ≤ 타결 상한가 — 받아줄 수 없는 금액은 부르지 않는다. 넘으면 깎지 않고 미발동
· 값 < 협력사 제시가 — 이미 더 싸게 받았는데 더 비싼 값을 부를 이유가 없다
· 값 ≥ 당사 직전 제안 — 역행 금지. 제안 시퀀스는 앵커→…→목표가로 단조 수렴해야 한다
"""
variable = spec.offer_variable
if not variable:
return None # 설득 카드 — 제시할 금액 없음
calc = OFFER_VARIABLES.get(variable)
if calc is None:
return None # 미등록 변수(오타·구버전 카드)
target = float(context.get("target_price") or 0) target = float(context.get("target_price") or 0)
anchor = float(context.get("anchor_price") or 0) anchor = float(context.get("anchor_price") or 0)
price = float(context.get("input_price") or 0) price = float(context.get("input_price") or 0)
# 갑의 직전 포지션. 첫 카운터 전에는 앵커가 갑의 포지션이다.
prev_customer = float(context.get("prev_customer_price") or anchor or 0)
if target <= 0 or price <= 0: if target <= 0 or price <= 0:
return None return None # 목표가·제시가 없이는 어떤 변수도 판정 불가
if action is PriceAction.COUNTER_TARGET: value = calc(target, anchor, price, prev_customer)
counter = target if not value or value <= 0:
elif action is PriceAction.COUNTER_ANCHOR: return None # 재료 부족(앵커 미박제·직전 제안 없음)
counter = anchor if value > settle_ceiling(context):
elif action is PriceAction.COUNTER_TARGET_MID: return None # 타결 상한 초과 — 받아줄 수 없는 금액이라 지금 못 쓴다
counter = (anchor + target) / 2 if anchor > 0 else target if variable in _MID_VARIABLES and value >= target:
elif action is PriceAction.COUNTER_MID: # 절충 계열은 목표가 미만일 때만 의미가 있다. 목표가 이상이면 "절반씩 나누자"면서 목표가를
# 갑의 직전 포지션(직전 카운터). 첫 카운터 전에는 앵커가 갑의 포지션이다. # 부르는 꼴이라 미발동 — 목표가 제시는 목표가 카드(최후통첩)가 할 일이다.
prev_customer = float(context.get("prev_customer_price") or anchor or target) return None
counter = (prev_customer + price) / 2 if variable in ("target_price", "anchoring_price", "anchor_price"):
elif action is PriceAction.ONE_PCT: # 원값 인용 변수 — 멘트엔 {target_price} 등 저장 원값이 그대로 나가므로, 반올림하면
counter = price * 0.99 # 표시가≠타결가 미스매치가 난다(목표가 7652 멘트 → 7650 타결). 저장값 그대로 제시.
offer = int(value)
else: else:
return None offer = int(value / 10 + 0.5) * 10 # 파생가(절충·중간) 10원 반올림 — 앵커·목표가 산정과 표기 통일
if offer >= price:
return None # 제시가가 이미 그 값 이하 → 부를 이유 없음
if prev_customer and offer < prev_customer:
return None # 역행 금지 — 한번 부른 금액 아래로 되돌아가지 않는다(같은 금액 재제시는 허용)
return offer
if counter <= 0:
return None def available(spec: CardSpec, context: Dict[str, Any], *, closing_phase: bool = False) -> bool:
counter = min(counter, target) # 목표가 초과 제시 금지 (가드레일) """지금 이 카드를 꺼낼 수 있는지 — 금액과 무관한 조건들.
counter_i = int(round(counter))
if counter_i >= price: · 이미 쓴 카드는 다시 안 나간다(전 카드 공통 규칙 — 협상카드/와일드카드 구분 없음)
return None # 제시가가 이미 카운터 이하 → 카운터 무의미 · 종결 전용 카드는 종결 국면에서만, 종결 국면에선 종결 전용 카드만
return counter_i · min_round 미만이면 아직 이르다
· 스크립트가 인용한 세션-의존 변수(인터넷 최저가 등)가 결측이면 미발동 — 토큰 원형 노출 방지
"""
if spec.closing != closing_phase:
return False
if int(context.get("round") or 0) < spec.min_round:
return False
return all(context.get(key) for key in spec.requires)
def playable(spec: CardSpec, context: Dict[str, Any], *, closing_phase: bool = False) -> bool:
"""이 카드를 지금 실제로 플레이할 수 있는지 — available + (금액 카드는) 제안가 유효까지.
금액을 인용하는 카드(offer_variable 있음)는 그 금액을 못 부르는 상황이면 설득 폴백으로도
내보내지 않는다 — 멘트에 무효한 금액(직전 제안보다 낮은 앵커, 제시가보다 높은 목표가)이
글자로 박혀 나가 역행/모순 서사가 되기 때문. 설득 카드는 금액이 없으니 무관.
"""
if not available(spec, context, closing_phase=closing_phase):
return False
if not spec.offer_variable:
return True
return compute_offer(spec, context) is not None
def is_played(context: Dict[str, Any], number: Optional[str]) -> bool:
"""이 카드를 이 협상에서 이미 썼는지. 와일드 진입·종결·협상카드가 같은 이력을 본다."""
return bool(number) and str(number) in (context.get("played_card_numbers") or [])
def mark_played(context: Dict[str, Any], number: Optional[str]) -> None:
"""카드를 실제로 내보낸 시점에 이력에 남긴다(노출되지 않은 후보는 남기지 않는다)."""
if not number:
return
played = list(context.get("played_card_numbers") or [])
if str(number) not in played:
played.append(str(number))
context["played_card_numbers"] = played

View File

@ -23,6 +23,7 @@ _SESSIONS = table(
"sessions", "sessions",
column("session_id"), column("quotation_id"), column("item_id"), column("supplier_id"), column("session_id"), column("quotation_id"), column("item_id"), column("supplier_id"),
column("qt_type"), column("target_price"), column("anchoring_price"), column("qt_type"), column("target_price"), column("anchoring_price"),
column("done_ceiling_price"), # 타결 상한가 — 견적 생성 시 박제(목표가×(1+타결상한율))
column("qt_setting_id"), column("qt_setting_id"),
column("deleted"), column("deleted"),
schema="negotiation", schema="negotiation",
@ -33,8 +34,32 @@ _QUOTATION_SETTINGS = table(
column("qt_setting_id"), column("card_count"), column("deleted"), column("qt_setting_id"), column("card_count"), column("deleted"),
schema="quotation", schema="quotation",
) )
_ITEMS = table("items", column("item_id"), column("name"), column("price"), _ITEMS = table("items", column("item_id"), column("name"), column("price"), column("purchase_price"),
column("internet_lowest_price"), column("deleted"), schema="partner") column("company_id"), column("internet_lowest_price"), column("deleted"), schema="partner")
# 고객사 설정(companies.settings) — 협상 기준가로 쓸 가격 컬럼을 여기서 정한다.
_COMPANIES = table("companies", column("company_id"), column("settings"), column("deleted"), schema="company")
# 협상 기준가 후보 컬럼. 어느 컬럼을 고르든 공급사 화면 호칭은 '공급가'로 고정한다 —
# 같은 돈을 고객사는 매입가·상품 단가 등으로 부르지만 챗은 공급사가 보는 화면이라
# 공급사 관점 용어 하나만 쓴다. 회사 용어 사전(labels)은 관리자 화면 전용.
_BASELINE_PRICE = "price"
_BASELINE_PURCHASE = "purchase_price"
_SUPPLIER_PRICE_LABEL = "공급가"
def _resolve_baseline(settings: dict) -> str:
"""회사 설정 → 협상 기준가로 쓸 items 컬럼명.
1순위는 관리자가 회사 설정에서 고른 값(features.nego_baseline_field).
미설정 회사는 공급가가 기본이되, 공급가를 화면에서 감췄다면 그 회사는 공급가를 관리하지
않는다는 뜻이므로 매입가로 폴백한다 — 설정 화면이 생기기 전에 만들어진 회사를 위한 안전망."""
chosen = (settings.get("features") or {}).get("nego_baseline_field")
if chosen in (_BASELINE_PRICE, _BASELINE_PURCHASE):
return chosen
hidden = set(settings.get("hidden_fields") or [])
if "price" in hidden and "purchase_price" not in hidden:
return _BASELINE_PURCHASE
return _BASELINE_PRICE
_SUPPLIERS = table("suppliers", column("supplier_id"), column("name"), column("total_revenue"), column("deleted"), schema="partner") _SUPPLIERS = table("suppliers", column("supplier_id"), column("name"), column("total_revenue"), column("deleted"), schema="partner")
_QUOTATIONS = table( _QUOTATIONS = table(
"quotations", "quotations",
@ -53,12 +78,13 @@ _VERSION_WILD_CARDS = table(
) )
_NEGO_CARDS = table( _NEGO_CARDS = table(
"nego_cards", "nego_cards",
column("nego_card_id"), column("number"), column("deleted"), column("nego_card_id"), column("number"), column("script"), column("tactic"), column("deleted"),
schema="card", schema="card",
) )
_WILD_CARDS = table( _WILD_CARDS = table(
"wild_cards", "wild_cards",
column("wild_card_id"), column("number"), column("deleted"), column("wild_card_id"), column("number"), column("script"), column("tactic"), column("deleted"),
column("available"),
schema="card", schema="card",
) )
# 상품↔협력사 매핑 (2026-07-07 신설): supply_type = 이 협력사가 이 상품을 공급하는 방식(SupplierType). # 상품↔협력사 매핑 (2026-07-07 신설): supply_type = 이 협력사가 이 상품을 공급하는 방식(SupplierType).
@ -72,12 +98,16 @@ _SUPPLIER_ITEMS = table(
class INegoContextCRUD(ABC): class INegoContextCRUD(ABC):
@abstractmethod @abstractmethod
async def get_session_row(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[tuple]]: async def get_session_row(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[tuple]]:
"""세션 행 (qt_type, target_price, anchoring_price, item_id, quotation_id, supplier_id). 없으면 None.""" """세션 행 (qt_type, target_price, anchoring_price, done_ceiling_price, item_id, quotation_id, supplier_id). 없으면 None."""
pass pass
@abstractmethod @abstractmethod
async def get_item_price(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]: async def get_item_baseline(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, Tuple[int, str, dict]]:
"""품목 기준가(items.price). 없으면 0.""" """협상 기준가·그 호칭·회사 용어 사전 (가격, 호칭, labels).
어느 컬럼을 기준가로 쓰는지는 회사 설정(features.nego_baseline_field)이 정한다.
labels 는 companies.settings.labels 원본 — 협상 스크립트의 용어 토큰 치환에 쓴다.
값이 없으면 (0, 호칭, {})."""
pass pass
@abstractmethod @abstractmethod
@ -124,8 +154,9 @@ class INegoContextCRUD(ABC):
pass pass
@abstractmethod @abstractmethod
async def get_quotation_card_numbers(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, tuple[list[str], list[str]]]: async def get_quotation_card_numbers(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, tuple[list[tuple], list[tuple]]]:
"""견적 version_id 에 연결된 (일반카드 번호 목록, 와일드카드 번호 목록). 없으면 빈 목록.""" """견적 version_id 에 연결된 (일반카드 행 목록, 와일드카드 행 목록). 없으면 빈 목록.
행 = (number, script, tactic) — 스크립트 파싱 + tactic JSONB 로 카드 전술(CardSpec)을 만든다."""
pass pass
@ -134,6 +165,7 @@ class NegoContextCRUD(INegoContextCRUD):
try: try:
query = ( query = (
select(_SESSIONS.c.qt_type, _SESSIONS.c.target_price, _SESSIONS.c.anchoring_price, select(_SESSIONS.c.qt_type, _SESSIONS.c.target_price, _SESSIONS.c.anchoring_price,
_SESSIONS.c.done_ceiling_price,
_SESSIONS.c.item_id, _SESSIONS.c.quotation_id, _SESSIONS.c.supplier_id) _SESSIONS.c.item_id, _SESSIONS.c.quotation_id, _SESSIONS.c.supplier_id)
.where(_SESSIONS.c.session_id == session_id, _SESSIONS.c.deleted == False) # noqa: E712 .where(_SESSIONS.c.session_id == session_id, _SESSIONS.c.deleted == False) # noqa: E712
.limit(1) .limit(1)
@ -146,20 +178,29 @@ class NegoContextCRUD(INegoContextCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None return ErrorType.DB_RUN_FAILED, None
async def get_item_price(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]: async def get_item_baseline(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, Tuple[int, str, dict]]:
_fallback = (0, _SUPPLIER_PRICE_LABEL, {})
try: try:
# 상품 + 소속 고객사 설정 한 번에. 회사가 없어도(데이터 이상) 상품 행은 나오도록 outer join.
query = ( query = (
select(_ITEMS.c.price) select(_ITEMS.c.price, _ITEMS.c.purchase_price, _COMPANIES.c.settings)
.select_from(_ITEMS.outerjoin(_COMPANIES, _ITEMS.c.company_id == _COMPANIES.c.company_id))
.where(_ITEMS.c.item_id == item_id, _ITEMS.c.deleted == False) # noqa: E712 .where(_ITEMS.c.item_id == item_id, _ITEMS.c.deleted == False) # noqa: E712
.limit(1) .limit(1)
) )
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_item_price failed.", raise_error=False) err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_item_baseline failed.", raise_error=False)
if err_type != ErrorType.SUCCESS or not rows or not rows[0]: if err_type != ErrorType.SUCCESS or not rows:
return err_type, 0 return err_type, _fallback
return ErrorType.SUCCESS, int(rows[0]) # 컬럼이 2개 이상이면 execute 가 행 리스트를 준다(1개일 때만 스칼라 리스트).
price, purchase_price, settings = rows[0]
settings = settings if isinstance(settings, dict) else {}
labels = settings.get("labels") or {}
field = _resolve_baseline(settings)
value = purchase_price if field == _BASELINE_PURCHASE else price
return ErrorType.SUCCESS, (int(value or 0), _SUPPLIER_PRICE_LABEL, labels)
except Exception as ex: except Exception as ex:
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0 return ErrorType.DB_RUN_FAILED, _fallback
async def get_card_count(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[int]]: async def get_card_count(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[int]]:
try: try:
@ -289,7 +330,7 @@ class NegoContextCRUD(INegoContextCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0 return ErrorType.DB_RUN_FAILED, 0
async def get_quotation_card_numbers(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, tuple[list[str], list[str]]]: async def get_quotation_card_numbers(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, tuple[list[tuple], list[tuple]]]:
try: try:
version_q = ( version_q = (
select(_QUOTATIONS.c.version_id) select(_QUOTATIONS.c.version_id)
@ -304,7 +345,7 @@ class NegoContextCRUD(INegoContextCRUD):
version_id = rows[0] version_id = rows[0]
nego_q = ( nego_q = (
select(_NEGO_CARDS.c.number) select(_NEGO_CARDS.c.number, _NEGO_CARDS.c.script, _NEGO_CARDS.c.tactic)
.select_from( .select_from(
_VERSION_NEGO_CARDS.join( _VERSION_NEGO_CARDS.join(
_NEGO_CARDS, _NEGO_CARDS,
@ -323,7 +364,7 @@ class NegoContextCRUD(INegoContextCRUD):
return n_err, ([], []) return n_err, ([], [])
wild_q = ( wild_q = (
select(_WILD_CARDS.c.number) select(_WILD_CARDS.c.number, _WILD_CARDS.c.script, _WILD_CARDS.c.tactic)
.select_from( .select_from(
_VERSION_WILD_CARDS.join( _VERSION_WILD_CARDS.join(
_WILD_CARDS, _WILD_CARDS,
@ -334,6 +375,8 @@ class NegoContextCRUD(INegoContextCRUD):
_VERSION_WILD_CARDS.c.version_id == version_id, _VERSION_WILD_CARDS.c.version_id == version_id,
_VERSION_WILD_CARDS.c.deleted == False, # noqa: E712 _VERSION_WILD_CARDS.c.deleted == False, # noqa: E712
_WILD_CARDS.c.deleted == False, # noqa: E712 _WILD_CARDS.c.deleted == False, # noqa: E712
# 협상 적용 여부(카드 설정 '적용 대기(수동)') — 꺼진 카드는 견적에 담겨 있어도 발동 금지
_WILD_CARDS.c.available == True, # noqa: E712
) )
.order_by(_VERSION_WILD_CARDS.c.created_at) .order_by(_VERSION_WILD_CARDS.c.created_at)
) )
@ -342,8 +385,8 @@ class NegoContextCRUD(INegoContextCRUD):
return w_err, ([], []) return w_err, ([], [])
return ErrorType.SUCCESS, ( return ErrorType.SUCCESS, (
[str(r) for r in n_rows if r is not None], [(str(r[0]), r[1], r[2]) for r in n_rows if r[0] is not None],
[str(r) for r in w_rows if r is not None], [(str(r[0]), r[1], r[2]) for r in w_rows if r[0] is not None],
) )
except Exception as ex: except Exception as ex:
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)

View File

@ -10,10 +10,16 @@ import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from negotiation.cards.domain.tactics import compute_counter, tactic_for from negotiation.cards.domain.tactics import (
OFFER_VARIABLES, Offer, available, compute_offer_detail, is_played, mark_played, playable,
record_offer, settle_ceiling, spec_from_context,
)
from negotiation.chat.service.script_repository import ScriptRepository from negotiation.chat.service.script_repository import ScriptRepository
MAX_ROUNDS = 3 # config 미주입 시 폴백 (규칙 정본은 tenant config negotiation.max_counter_rounds) MAX_ROUNDS = 3 # config 미주입 시 폴백 (규칙 정본은 tenant config negotiation.max_counter_rounds)
# 멘트에 찍히는 파생 가격 — 값이 다른 값에서 계산돼 나오는 것들(원값 인용 target/anchor 는 제외).
_DERIVED_PRICE_VARIABLES = ("target_mid_price", "middle_price")
_PRICE_MODES = ("price",) _PRICE_MODES = ("price",)
_CHOICE_MODES = ("yes_no", "confirm", "delivery_type") _CHOICE_MODES = ("yes_no", "confirm", "delivery_type")
@ -43,6 +49,39 @@ def _parse_price(user_input: Any) -> Optional[float]:
return price if price > 0 else None return price if price > 0 else None
# 협상 스크립트가 쓰는 용어 토큰: {label_*} = 회사 용어(없으면 기본값).
# 값은 negodata 용어 카탈로그(LABEL_CATALOG)의 base 와 같아야 화면·멘트 표기가 갈리지 않는다.
_SCRIPT_LABELS = {
"label_supplier": ("supplier", "협력사"),
"label_target_price": ("target_price", "목표가"),
"label_delivery_type": ("item.delivery_type", "배송 형태"),
"label_delivery_type_1": ("delivery_type.1", "협력사배송"),
"label_delivery_type_2": ("delivery_type.2", "지정택배배송"),
"label_delivery_type_3": ("delivery_type.3", "픽업배송"),
"label_product": ("item.name", "상품명"),
}
# 협상 기준가 호칭 — 공급사 화면 고정 용어. 회사 용어 사전(labels)을 타지 않는다(그건 관리자 화면 전용).
# 실제 값은 loader 가 컨텍스트에 박제하고, DB 컨텍스트가 없는 데모/직접호출 경로만 이 폴백을 쓴다.
_SUPPLIER_PRICE_LABEL = "공급가"
# 조사 자동 보정: 토큰 뒤에 조사가 붙는 자리는 {label_supplier_를} 처럼 대표형을 적는다.
# 회사가 바꾼 용어의 받침을 예측할 수 없어 스크립트에 조사를 고정할 수 없다("협력사를"/"공급업체을").
_JOSA = {"은": ("은", "는"), "는": ("은", "는"), "이": ("이", "가"), "가": ("이", "가"),
"을": ("을", "를"), "를": ("을", "를"), "과": ("과", "와"), "와": ("과", "와")}
def _has_batchim(word: str) -> bool:
last = word[-1] if word else ""
return "가" <= last <= "힣" and (ord(last) - 0xAC00) % 28 != 0
def _josa(word: str, form: str) -> str:
"""단어 + 받침에 맞는 조사. form 은 대표형('를'·'는'·'가'·'와')."""
pair = _JOSA.get(form)
if not pair:
return word
return word + (pair[0] if _has_batchim(word) else pair[1])
@dataclass @dataclass
class ChatSession: class ChatSession:
session_id: str session_id: str
@ -96,8 +135,9 @@ class ChatEngine:
if price is None: if price is None:
return self._error(session, "가격을 숫자로 입력해 주세요.") return self._error(session, "가격을 숫자로 입력해 주세요.")
session.context["input_price"] = price session.context["input_price"] = price
# 새 가격 제시 = 직전 카운터 제안 거절 확정 → 대기 중 카운터 폐기. # 새 가격 제시 = 직전 카운터 제안 거절 확정 → 대기 중 카운터·그 재료 폐기.
session.context.pop("pending_counter_price", None) session.context.pop("pending_counter_price", None)
session.context.pop("pending_offer", None)
session.context["prev_partner_price"] = price session.context["prev_partner_price"] = price
# 협력사 첫 제시가 — 가격 수용률(첫 제시가 대비 양보율) 동적 계산의 기준값. # 협력사 첫 제시가 — 가격 수용률(첫 제시가 대비 양보율) 동적 계산의 기준값.
session.context.setdefault("first_offer_price", price) session.context.setdefault("first_offer_price", price)
@ -115,6 +155,7 @@ class ChatEngine:
# 원 제시가 수락 종결 — 는 카운터를 버리고 기존 input_price 로 타결한다.) # 원 제시가 수락 종결 — 는 카운터를 버리고 기존 input_price 로 타결한다.)
if mode in _CHOICE_MODES and nxt in _SUCCESS_STEPS: if mode in _CHOICE_MODES and nxt in _SUCCESS_STEPS:
pending = session.context.pop("pending_counter_price", None) pending = session.context.pop("pending_counter_price", None)
session.context.pop("pending_offer", None)
if pending and user_input in _ACCEPT_INPUTS: if pending and user_input in _ACCEPT_INPUTS:
session.context["input_price"] = float(pending) session.context["input_price"] = float(pending)
return self._render(session, nxt) return self._render(session, nxt)
@ -169,6 +210,15 @@ class ChatEngine:
and price <= anchor * self.rules.wildcard_entry_ratio) and price <= anchor * self.rules.wildcard_entry_ratio)
) )
) )
# 구간에 들어와도 실제로 낼 카드가 없으면(전부 종결 전용·사용됨·유효조건 미달) 이 조건은
# 불충족으로 두고 다음 조건(우선협상·소진 판정)을 평가한다 — 여기서 매칭돼 버리면
# 카드 소진 판정이 영영 돌지 않아, 빈 덱에서 쓴 카드를 또 꺼내는 무한 협상이 된다.
if ok:
probe = ChatSession(
session_id=session.session_id, tenant_id=session.tenant_id,
company_id=session.company_id, context=dict(ctx),
)
ok = self._pick_wildcard(probe) != "가격협상"
elif cond == "check_is_supplier_type_c": elif cond == "check_is_supplier_type_c":
ok = False # 공급사 유형 미보유 (PoC 단순화) ok = False # 공급사 유형 미보유 (PoC 단순화)
elif cond == "check_price_match": # = 우선협상: 제시가가 앵커가 이하 elif cond == "check_price_match": # = 우선협상: 제시가가 앵커가 이하
@ -181,13 +231,27 @@ class ChatEngine:
# ② 종결 전술까지 소진(closing_played)이면 → 최종 제시가 ≤ target 은 타결, # ② 종결 전술까지 소진(closing_played)이면 → 최종 제시가 ≤ target 은 타결,
# 초과는 결렬(협상실패) — "목표가 초과 타결 금지" 가드레일과 정합. # 초과는 결렬(협상실패) — "목표가 초과 타결 금지" 가드레일과 정합.
counter_rounds = max(0, ctx.get("round", 0) - 1) counter_rounds = max(0, ctx.get("round", 0) - 1)
exhausted = counter_rounds >= self.rules.max_counter_rounds or (cards_total > 0 and cards_used >= cards_total) # 담은 협상카드 중 지금 낼 수 있는 게 하나도 없으면(사용됨·발동조건 미달 — 예:
# 시장가 인용 카드인데 최저가 결측) 장수와 무관하게 소진으로 본다 — 안 그러면
# 선택 마스크가 전부 막힌 채 폴백이 부적합 카드를 억지로 꺼낸다(토큰 노출).
selected = ctx.get("selected_nego_card_numbers") or []
none_playable = bool(selected) and not any(
not is_played(ctx, n) and playable(spec_from_context(ctx, n), ctx)
for n in selected
)
exhausted = (
counter_rounds >= self.rules.max_counter_rounds
or (cards_total > 0 and cards_used >= cards_total)
or none_playable
)
if exhausted: if exhausted:
target = ctx.get("target_price", 0) # 타결선은 목표가가 아니라 타결 상한가(견적 생성 시 박제) — 목표가를 넘어도
# 상한 이내면 타결한다.
ceiling = settle_ceiling(ctx)
if not ctx.get("closing_played"): if not ctx.get("closing_played"):
ctx["force_closing"] = True ctx["force_closing"] = True
return "가격협상" return "가격협상"
return "협상완료" if (target > 0 and price <= target) else c.get("next") return "협상완료" if (ceiling > 0 and price <= ceiling) else c.get("next")
ok = False ok = False
elif cond == "default": elif cond == "default":
ok = True ok = True
@ -197,32 +261,42 @@ class ChatEngine:
def _pick_wildcard(self, session: ChatSession) -> str: def _pick_wildcard(self, session: ChatSession) -> str:
"""앵커가에 아주 근접(≤ anchor×wildcard_1pct_ratio)한 구간에서만 1% 인하 요청(wild_card_1pct)으로 """앵커가에 아주 근접(≤ anchor×wildcard_1pct_ratio)한 구간에서만 1% 인하 요청(wild_card_1pct)으로
앵커가 이하로 유도한다. 그 외 구간은 일반 가격협상(카드 플레이)으로 돌린다. 앵커가 이하로 유도한다. 그 외 구간은 견적에서 선택한 와일드카드의 전술로 카운터하고,
낼 카드가 없으면 일반 가격협상(카드 플레이)으로 돌린다.
과거 여기서 반환하던 '재원부족'(wild_card_budget) 하드코딩 카드는 제거했다 —
견적에서 실제 선택한 와일드카드(중간값 절충·목표가 선제안 등)와 매핑되지 않은 채
'와일드카드를 하나라도 골랐으면' 조건만으로 발동해, 선택하지도 않은 재원부족 멘트가
노출되는 오작동이 있었다.
""" """
ctx = session.context ctx = session.context
price = ctx.get("input_price", 0) price = ctx.get("input_price", 0)
anchor = ctx.get("anchor_price", 0) anchor = ctx.get("anchor_price", 0)
target = ctx.get("target_price", 0)
if anchor > 0 and price <= anchor * self.rules.wildcard_1pct_ratio: if anchor > 0 and price <= anchor * self.rules.wildcard_1pct_ratio:
# 와일드카드는 실제로 노출할 때만 '사용됨'으로 마킹한다 — 가격협상으로 돌아가는 offer_1pct = int(price * 0.99 / 10 + 0.5) * 10 # 1% 인하가 — 10원 반올림(앵커·카운터와 통일)
# 경우에도 마킹하면 이후 라운드에서 정당한 1% 카드까지 억제된다. # 제안가 공통 유효조건(≤목표가 · <제시가 · 직전 당사 제안 이상=역행 금지)은 시스템 1% 카드에도
ctx["wildcard_used"] = True # 동일하게 건다. 기본 앵커 밴드에선 수학적으로 항상 통과하지만 극단 데이터를 방어한다.
ctx["offer_1pct"] = int(round(price * 0.99)) # 1% 인하가 (멘트 변수) prev_customer = ctx.get("prev_customer_price") or 0
ctx["pending_counter_price"] = ctx["offer_1pct"] # 수락 시 이 가격으로 타결 if 0 < offer_1pct < price and (target <= 0 or offer_1pct <= target) and offer_1pct >= prev_customer:
return "wild_card_1pct" # 와일드카드는 실제로 노출할 때만 '사용됨'으로 마킹한다 — 가격협상으로 돌아가는
# 경우에도 마킹하면 이후 라운드에서 정당한 1% 카드까지 억제된다.
ctx["wildcard_used"] = True
ctx["offer_1pct"] = offer_1pct
record_offer(ctx, Offer(price=offer_1pct, variable="offer_1pct",
prev_customer=int(prev_customer or anchor), prev_partner=int(price)))
return "wild_card_1pct"
# 1.02 초과 ~ entry(1.05) 구간: 견적에서 선택한 와일드카드의 전술로 카운터 제시. # 1.02 초과 ~ entry(1.05) 구간: 견적에서 선택한 와일드카드의 전술로 카운터 제시.
# (구현 전에는 이 구간이 일반 가격협상으로 회귀해 선택형 WC 가 영영 발동하지 않던 갭.) # (구현 전에는 이 구간이 일반 가격협상으로 회귀해 선택형 WC 가 영영 발동하지 않던 갭.)
if anchor > 0 and price <= anchor * self.rules.wildcard_entry_ratio: if anchor > 0 and price <= anchor * self.rules.wildcard_entry_ratio:
for number in (ctx.get("selected_wild_card_numbers") or []): for number in (ctx.get("selected_wild_card_numbers") or []):
counter = compute_counter(tactic_for(str(number)), ctx) number = str(number)
if counter is not None: spec = spec_from_context(ctx, number)
# 종결 전용 카드(최종 통보·중간값 절충)는 여기서 안 꺼낸다 — 종결 국면의 마지막 한 방으로 예약.
# 이미 쓴 카드도 제외(같은 멘트 반복 방지).
if not available(spec, ctx) or is_played(ctx, number):
continue
offer = compute_offer_detail(spec, ctx)
if offer is not None:
ctx["wildcard_used"] = True ctx["wildcard_used"] = True
ctx["pending_counter_price"] = counter record_offer(ctx, offer)
ctx["active_wild_card_number"] = str(number) ctx["active_wild_card_number"] = number
mark_played(ctx, number)
return "wild_card_dynamic" return "wild_card_dynamic"
return "가격협상" return "가격협상"
@ -240,6 +314,19 @@ class ChatEngine:
if "anchor_price" in ctx: if "anchor_price" in ctx:
# anchoring_price = DB 시드 기본 카드/sessions 컬럼 표기, anchor_price = 카드 에디터 표기. # anchoring_price = DB 시드 기본 카드/sessions 컬럼 표기, anchor_price = 카드 에디터 표기.
out["anchor"] = out["anchor_price"] = out["anchoring_price"] = int(ctx["anchor_price"]) out["anchor"] = out["anchor_price"] = out["anchoring_price"] = int(ctx["anchor_price"])
# 용어 토큰 — 회사 용어 사전(labels)이 있으면 그 단어, 없으면 카탈로그 기본값.
# 조사가 붙는 자리를 위해 {label_supplier_를} 같은 파생 키도 함께 만든다.
labels = ctx.get("labels") or {}
for token, (label_key, fallback) in _SCRIPT_LABELS.items():
word = labels.get(label_key) or fallback
out[token] = word
for form in ("는", "가", "를", "와"):
out[f"{token}_{form}"] = _josa(word, form)
# 기준가 호칭은 회사 용어가 아니라 공급사 관점 고정 — loader 박제값(없으면 '공급가').
price_word = str(ctx.get("item_price_label") or _SUPPLIER_PRICE_LABEL)
out["label_item_price"] = price_word
for form in ("는", "가", "를", "와"):
out[f"label_item_price_{form}"] = _josa(price_word, form)
# 카드 에디터 카탈로그의 협력사명/상품명(partner_name·product_name) 치환. # 카드 에디터 카탈로그의 협력사명/상품명(partner_name·product_name) 치환.
if ctx.get("partner_name"): if ctx.get("partner_name"):
out["partner_name"] = str(ctx["partner_name"]) out["partner_name"] = str(ctx["partner_name"])
@ -253,43 +340,51 @@ class ChatEngine:
ilp = ctx.get("internet_lowest_price") or 0 ilp = ctx.get("internet_lowest_price") or 0
if ilp > 0: if ilp > 0:
out["internet_lowest_price"] = out["internet_min_price"] = int(ilp) out["internet_lowest_price"] = out["internet_min_price"] = int(ilp)
# 전술 카운터 변수(카드 시드 멘트의 가격 변수) — tactics.compute_counter 산식과 동일 정의. # 전술 카운터 변수(카드 시드 멘트의 가격 변수) — tactics.OFFER_VARIABLES 산식과 동일 정의.
anchor = ctx.get("anchor_price") or 0 anchor = ctx.get("anchor_price") or 0
target = ctx.get("target_price") or 0 target = ctx.get("target_price") or 0
if "input_price" in ctx: # 표시 기준값 — 제안이 확정된 턴이면 그 계산에 쓴 재료(pending_offer)를 쓴다.
out["prev_partner_price"] = int(ctx.get("prev_partner_price") or ctx["input_price"]) # prev_customer_price 는 확정 즉시 새 제안가로 갱신되므로, 그대로 읽으면 멘트가
prev_customer = ctx.get("prev_customer_price") or anchor # "당사 제안과 귀사 제안의 절반이 당사 제안" 같은 자기모순이 된다.
pending_offer = ctx.get("pending_offer") or {}
prev_customer = int(pending_offer.get("prev_customer") or ctx.get("prev_customer_price") or anchor or 0)
partner_price = int(pending_offer.get("prev_partner") or ctx.get("prev_partner_price") or ctx.get("input_price") or 0)
offer_price = int(pending_offer.get("price") or ctx.get("pending_counter_price") or 0)
offer_variable = pending_offer.get("variable") or ""
if prev_customer: if prev_customer:
out["prev_customer_price"] = int(prev_customer) out["prev_customer_price"] = prev_customer
if anchor and target: if partner_price:
out["target_mid_price"] = int(round((anchor + target) / 2)) out["prev_partner_price"] = partner_price
if prev_customer and "input_price" in ctx: if offer_price:
out["middle_price"] = int(round((prev_customer + ctx["input_price"]) / 2)) out["counter_price"] = offer_price
if ctx.get("pending_counter_price"): # 파생 가격(절충가·중간가) — 제안가로 확정된 변수는 그 금액을 그대로 쓴다(멘트에 보이는 금액과
# 카운터 제시 중: 멘트에 보이는 제시가와 수락 시 타결가(pending)를 반드시 일치시킨다. # 수락 시 타결가는 항상 같아야 한다). 나머지는 참고 인용이므로 tactics 산식으로 채운다.
# 절충/중간 변수(middle_price·target_mid_price)는 vars_for 재계산 값이 compute_counter 의 # 산식을 여기 복사해 두면 갱신 시점 차이로 표시가와 제안가가 갈라지므로 정의를 호출만 한다.
# target 클램프·prev_customer 갱신과 어긋나, 멘트엔 1,740,000 이 보이는데 실제로는 # 어느 변수가 제안가인지 모르는 진행 중 세션(구버전 기록)은 종전대로 전부 제안가로 고정한다.
# 1,700,000 으로 타결되던 버그(표시가≠투찰가)가 있었다. pending 은 이 시점 유일한 '제안가'이므로 for name in _DERIVED_PRICE_VARIABLES:
# 세 변수 모두 pending 으로 고정한다(카운터 제시 턴에만 적용 — 비-카운터 렌더는 원 계산값 유지). if offer_price and (name == offer_variable or not offer_variable):
pending_i = int(ctx["pending_counter_price"]) out[name] = offer_price
out["counter_price"] = pending_i continue
out["middle_price"] = pending_i value = OFFER_VARIABLES[name](target, anchor, partner_price, prev_customer)
out["target_mid_price"] = pending_i if value:
# 인하율 = (기존 공급가(상품단가) - 제시가) / 기존 공급가 * 100. 기존가 없으면 미표시(0.0). out[name] = int(value / 10 + 0.5) * 10 # 10원 반올림 — compute_offer 와 동일
# 제시가가 기존가보다 높으면(인상 제시) 음수가 나오는데, "-1.3% 인하된 금액" 같은 # 인하율 = (협상 기준가 - 제시가) / 기준가 * 100. 기준가 없으면 미표시(0.0).
# 제시가가 기준가보다 높으면(인상 제시) 음수가 나오는데, "-1.3% 인하된 금액" 같은
# 모순 표현이 되므로 discount_rate 는 0 미만 금지하고, 인상/동일/인하를 구분한 # 모순 표현이 되므로 discount_rate 는 0 미만 금지하고, 인상/동일/인하를 구분한
# 문구는 discount_phrase 로 별도 제공한다(가격협상_확인 멘트가 사용). # 문구는 discount_phrase 로 별도 제공한다(가격협상_확인 멘트가 사용).
# 기준가 호칭은 공급사 화면 고정 용어('공급가') — loader 가 박제한 값.
base = ctx.get("item_price") or 0 base = ctx.get("item_price") or 0
label = ctx.get("item_price_label") or _SUPPLIER_PRICE_LABEL
if base > 1 and "input_price" in ctx: if base > 1 and "input_price" in ctx:
rate = ((base - ctx["input_price"]) / base) * 100 rate = ((base - ctx["input_price"]) / base) * 100
out["discount_rate"] = f"{max(0.0, rate):.1f}" out["discount_rate"] = f"{max(0.0, rate):.1f}"
if rate >= 0.05: if rate >= 0.05:
out["discount_phrase"] = f"기존 공급가 대비 약 **{rate:.1f}%** 인하된 금액입니다. " out["discount_phrase"] = f"기존 {label} 대비 약 **{rate:.1f}%** 인하된 금액입니다. "
elif rate <= -0.05: elif rate <= -0.05:
out["discount_phrase"] = ( out["discount_phrase"] = (
f"기존 공급가(**{int(base)}원**)보다 약 **{abs(rate):.1f}%** 높은 금액입니다. ") f"기존 {label}(**{int(base)}원**)보다 약 **{abs(rate):.1f}%** 높은 금액입니다. ")
else: else:
out["discount_phrase"] = "기존 공급가와 동일한 수준의 금액입니다. " out["discount_phrase"] = f"기존 {_josa(label, '와')} 동일한 수준의 금액입니다. "
else: else:
out["discount_rate"] = "0.0" out["discount_rate"] = "0.0"
out["discount_phrase"] = "" out["discount_phrase"] = ""
@ -306,13 +401,14 @@ class ChatEngine:
def _render(self, session: ChatSession, step_key: Optional[str]) -> StepView: def _render(self, session: ChatSession, step_key: Optional[str]) -> StepView:
if not step_key or step_key not in self.scripts: if not step_key or step_key not in self.scripts:
return self._error(session, f"다음 단계를 찾을 수 없습니다: {step_key}") return self._error(session, f"다음 단계를 찾을 수 없습니다: {step_key}")
# 가드레일(최후 방어선): 구매자 대리는 목표가 초과로 절대 타결하지 않는다. # 가드레일(최후 방어선): 구매자 대리는 타결 상한가를 넘겨 타결하지 않는다.
# 카운터 클램프·종결 규칙이 정상이면 도달하지 않지만, 스크립트 편집 실수 등으로 # 상한 = 견적 생성 시 박제한 done_ceiling_price(목표가×(1+타결상한율)), 미박제면 목표가.
# 성공 스텝에 초과가로 진입하면 결렬로 강제 전환한다. (재협상 흐름 한정) # 목표가를 조금 넘어도 상한 이내면 타결이 정상이므로 여기서 뒤집지 않는다.
# 상한까지 넘은 경우만 결렬로 강제 전환한다.
if step_key in _SUCCESS_STEPS and self.rq_type == "재협상": if step_key in _SUCCESS_STEPS and self.rq_type == "재협상":
ctx = session.context ctx = session.context
target = ctx.get("target_price") or 0 ceiling = settle_ceiling(ctx)
if target > 0 and ctx.get("input_price", 0) > target: if ceiling > 0 and ctx.get("input_price", 0) > ceiling:
step_key = "협상실패" step_key = "협상실패"
node = self.scripts[step_key] node = self.scripts[step_key]
session.step = step_key session.step = step_key
@ -326,11 +422,14 @@ class ChatEngine:
elif step_key in _FAILURE_STEPS: elif step_key in _FAILURE_STEPS:
session.context["final_outcome"] = "failure" session.context["final_outcome"] = "failure"
outcome = session.context.get("final_outcome") if chat_end else None outcome = session.context.get("final_outcome") if chat_end else None
# 선택지도 스크립트와 같은 변수 치환을 태운다 — 배송형태 보기가 회사 용어({label_delivery_type_1} 등)라
# 치환을 건너뛰면 사용자에게 토큰 원문이 그대로 보인다.
step_vars = self._vars(session)
return StepView( return StepView(
step=step_key, step=step_key,
script=self.repo.format_script(node.get("script", ""), self._vars(session)), script=self.repo.format_script(node.get("script", ""), step_vars),
input_mode=node.get("next_input_mode", "null"), input_mode=node.get("next_input_mode", "null"),
input_options=node.get("input_options", []), input_options=[self.repo.format_script(o, step_vars) for o in node.get("input_options", [])],
chat_end=bool(node.get("chat_end")), chat_end=bool(node.get("chat_end")),
client_step=self.step_map.get(step_key, step_key), client_step=self.step_map.get(step_key, step_key),
needs_card_selection=(step_key == "가격협상"), needs_card_selection=(step_key == "가격협상"),
@ -339,9 +438,13 @@ class ChatEngine:
) )
def _error(self, session: ChatSession, msg: str) -> StepView: def _error(self, session: ChatSession, msg: str) -> StepView:
# 에러 재렌더도 정상 렌더와 같은 변수 치환을 태운다 — 여기만 raw 로 두면
# 가격 오입력 시 옵션 버튼에 {label_*} 토큰이 그대로 노출된다.
node = self.scripts.get(session.step, {}) node = self.scripts.get(session.step, {})
step_vars = self._vars(session)
return StepView( return StepView(
step=session.step, script=node.get("script", ""), step=session.step, script=self.repo.format_script(node.get("script", ""), step_vars),
input_mode=node.get("next_input_mode", "null"), input_options=node.get("input_options", []), input_mode=node.get("next_input_mode", "null"),
input_options=[self.repo.format_script(o, step_vars) for o in node.get("input_options", [])],
chat_end=session.ended, client_step=self.step_map.get(session.step, session.step), error=msg, chat_end=session.ended, client_step=self.step_map.get(session.step, session.step), error=msg,
) )

View File

@ -19,6 +19,7 @@ from typing import Optional
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.enums import DBType, DBWRType, ErrorType from common.enums import DBType, DBWRType, ErrorType
from common.logger import LOG from common.logger import LOG
from negotiation.cards.domain.tactics import build_card_spec
from negotiation.chat.infra.repository.nego_context_crud import INegoContextCRUD, NegoContextCRUD from negotiation.chat.infra.repository.nego_context_crud import INegoContextCRUD, NegoContextCRUD
from negotiation.qtable.domain.model.snapshot import PartnerType from negotiation.qtable.domain.model.snapshot import PartnerType
@ -38,7 +39,10 @@ class NegotiationDbContext:
rq_type: str # 재협상(1:1) | 재견적(1:N) — sessions.qt_type 으로 판별 rq_type: str # 재협상(1:1) | 재견적(1:N) — sessions.qt_type 으로 판별
target_price: int # 목표 매입가(원) — sessions.target_price target_price: int # 목표 매입가(원) — sessions.target_price
anchor_price: int # 앵커링가 — sessions.anchoring_price(생성 시 박제). 없으면 target(무할인 폴백) anchor_price: int # 앵커링가 — sessions.anchoring_price(생성 시 박제). 없으면 target(무할인 폴백)
item_price: int # 기존 공급가(품목 기준가, items.price) — 인하율 멘트용. 없으면 0 done_ceiling_price: int # 타결 상한가 — sessions.done_ceiling_price(생성 시 박제). 없으면 target
item_price: int # 협상 기준가(고객사가 관리하는 가격 — 공급가 또는 매입가) — 인하율 멘트용. 없으면 0
item_price_label: str # 협상 멘트에서 기준가를 부르는 말(회사 용어 설정 → 없으면 카탈로그 기본값)
labels: dict # 회사 용어 사전(companies.settings.labels) — 스크립트 {label_*} 토큰 치환용
internet_lowest_price: int # 인터넷 최저가(items.internet_lowest_price, LPS 대표값) — 카드 {internet_lowest_price} 치환용. 미수집이면 0 internet_lowest_price: int # 인터넷 최저가(items.internet_lowest_price, LPS 대표값) — 카드 {internet_lowest_price} 치환용. 미수집이면 0
partner_name: Optional[str] # 협력사명(suppliers.name) — 카드 {partner_name} 치환용. 없으면 None partner_name: Optional[str] # 협력사명(suppliers.name) — 카드 {partner_name} 치환용. 없으면 None
product_name: Optional[str] # 상품명(items.name) — 카드 {product_name} 치환용. 없으면 None product_name: Optional[str] # 상품명(items.name) — 카드 {product_name} 치환용. 없으면 None
@ -48,6 +52,9 @@ class NegotiationDbContext:
selected_nego_card_numbers: list[str] # 견적 생성 시 선택된 일반 협상카드 번호(card.nego_cards.number) selected_nego_card_numbers: list[str] # 견적 생성 시 선택된 일반 협상카드 번호(card.nego_cards.number)
selected_wild_card_numbers: list[str] # 견적 생성 시 선택된 와일드카드 번호(card.wild_cards.number) selected_wild_card_numbers: list[str] # 견적 생성 시 선택된 와일드카드 번호(card.wild_cards.number)
card_count: Optional[int] # 협상카드 사용 횟수 상한(quotation_settings.card_count). None=상한 미적용 card_count: Optional[int] # 협상카드 사용 횟수 상한(quotation_settings.card_count). None=상한 미적용
# 카드번호 → 전술 {offer_variable, min_round, closing}. 스크립트 파싱 + tactic JSONB 로 시작 시 1회 확정 —
# 진행 중 협상은 카드 멘트가 도중에 바뀌어도 시작 시점 전술로 끝까지 간다(세션 컨텍스트에 박제).
card_specs: dict
class NegotiationContextLoader: class NegotiationContextLoader:
@ -67,8 +74,10 @@ class NegotiationContextLoader:
err, row = await self.crud.get_session_row(s, sid) err, row = await self.crud.get_session_row(s, sid)
if err != ErrorType.SUCCESS or row is None: if err != ErrorType.SUCCESS or row is None:
return None return None
qt_type, target_price, anchoring_price, item_id, quotation_id, supplier_id = row qt_type, target_price, anchoring_price, done_ceiling_price, item_id, quotation_id, supplier_id = row
target = int(target_price or 0) target = int(target_price or 0)
# 타결 상한가: 견적 생성 시 박제(목표가×(1+타결상한율)). 옛 세션은 NULL → 목표가로 폴백.
ceiling = int(done_ceiling_price or 0) or target
# 앵커링가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변. # 앵커링가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변.
# 박제가 없으면(데이터 이상) 무할인 폴백 anchor=target + WARN — 앵커링 v1.2 정책상 # 박제가 없으면(데이터 이상) 무할인 폴백 anchor=target + WARN — 앵커링 v1.2 정책상
@ -85,8 +94,9 @@ class NegotiationContextLoader:
# 매핑이 없거나 미지정이면 None → 호출부 기본값. # 매핑이 없거나 미지정이면 None → 호출부 기본값.
_, supplier_type = await self.crud.get_supply_type(s, supplier_id, item_id) _, supplier_type = await self.crud.get_supply_type(s, supplier_id, item_id)
# 기존 공급가(품목 기준가) — 없으면 0(인하율 멘트 미표시). # 협상 기준가 + 그 호칭 — 어느 컬럼을 쓸지는 고객사 설정(hidden_fields)이 정한다(crud).
_, item_price = await self.crud.get_item_price(s, item_id) # 없으면 0(인하율 멘트 미표시).
_, (item_price, item_price_label, labels) = await self.crud.get_item_baseline(s, item_id)
# 인터넷 최저가(LPS 수집 대표값) — 없으면 0(시장가 인용 카드는 값 있을 때만 치환). # 인터넷 최저가(LPS 수집 대표값) — 없으면 0(시장가 인용 카드는 값 있을 때만 치환).
_, internet_lowest_price = await self.crud.get_item_lowest_price(s, item_id) _, internet_lowest_price = await self.crud.get_item_lowest_price(s, item_id)
@ -106,7 +116,19 @@ class NegotiationContextLoader:
# 견적 생성 모달에서 고른 카드셋. 값이 없으면 운영 DB 기준으로 "선택 카드 없음"이다. # 견적 생성 모달에서 고른 카드셋. 값이 없으면 운영 DB 기준으로 "선택 카드 없음"이다.
# 데모/직접호출 경로(DB context 없음)만 ChatService 에서 기존 기본 카드셋으로 폴백한다. # 데모/직접호출 경로(DB context 없음)만 ChatService 에서 기존 기본 카드셋으로 폴백한다.
_, selected_cards = await self.crud.get_quotation_card_numbers(s, quotation_id) _, selected_cards = await self.crud.get_quotation_card_numbers(s, quotation_id)
selected_nego_cards, selected_wild_cards = selected_cards nego_rows, wild_rows = selected_cards
selected_nego_cards = [number for number, _script, _tactic in nego_rows]
selected_wild_cards = [number for number, _script, _tactic in wild_rows]
# 카드 전술 확정 — "스크립트에 꽂힌 변수가 곧 전술"(제안가 파싱) + tactic JSONB(min_round·closing).
card_specs = {}
for number, script, tactic in [*nego_rows, *wild_rows]:
spec = build_card_spec(script, tactic if isinstance(tactic, dict) else None)
card_specs[number] = {
"offer_variable": spec.offer_variable,
"min_round": spec.min_round,
"closing": spec.closing,
"requires": list(spec.requires), # 세션-의존 변수 결측 시 미발동(available)
}
# 협상카드 사용 횟수 상한(견적 설정). 없으면 None → 상한 미적용(선택 카드 수로만 캡). # 협상카드 사용 횟수 상한(견적 설정). 없으면 None → 상한 미적용(선택 카드 수로만 캡).
_, card_count = await self.crud.get_card_count(s, sid) _, card_count = await self.crud.get_card_count(s, sid)
@ -115,7 +137,10 @@ class NegotiationContextLoader:
rq_type="재협상" if int(qt_type) in _ONE_TO_ONE_QT_TYPES else "재견적", rq_type="재협상" if int(qt_type) in _ONE_TO_ONE_QT_TYPES else "재견적",
target_price=target, target_price=target,
anchor_price=anchor, anchor_price=anchor,
done_ceiling_price=ceiling,
item_price=item_price, item_price=item_price,
item_price_label=item_price_label,
labels=labels,
internet_lowest_price=internet_lowest_price, internet_lowest_price=internet_lowest_price,
partner_name=partner_name, partner_name=partner_name,
product_name=product_name, product_name=product_name,
@ -125,6 +150,7 @@ class NegotiationContextLoader:
selected_nego_card_numbers=selected_nego_cards, selected_nego_card_numbers=selected_nego_cards,
selected_wild_card_numbers=selected_wild_cards, selected_wild_card_numbers=selected_wild_cards,
card_count=card_count, card_count=card_count,
card_specs=card_specs,
) )
try: try:

View File

@ -121,7 +121,7 @@ class ScriptNaturalizer:
def build_situation(context: Dict[str, Any]) -> Dict[str, Any]: def build_situation(context: Dict[str, Any]) -> Dict[str, Any]:
"""세션 컨텍스트 → 정성 상황 라벨 (수치 미노출 — 숫자 환각 차단의 핵심). """세션 컨텍스트 → 정성 상황 라벨 (수치 미노출 — 숫자 환각 차단의 핵심).
가격구간: 제시가 vs 앵커/목표 관계, 라운드: 협상 진행 단계, 인하 진행: 기존 공급가 대비. 가격구간: 제시가 vs 앵커/목표 관계, 라운드: 협상 진행 단계, 인하 진행: 협상 기준가 대비.
""" """
out: Dict[str, Any] = {} out: Dict[str, Any] = {}
rnd = context.get("round") or 0 rnd = context.get("round") or 0

View File

@ -13,7 +13,9 @@ from common.enums import DBType, ErrorType
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.logger import LOG from common.logger import LOG
from config.server_configs import agent_config from config.server_configs import agent_config
from negotiation.cards.domain.tactics import compute_counter, tactic_available, tactic_for from negotiation.cards.domain.tactics import (
Offer, available, compute_offer_detail, is_played, mark_played, playable, record_offer, spec_from_context,
)
from negotiation.chat.service.chat_engine import ( from negotiation.chat.service.chat_engine import (
_CHOICE_MODES, _PRICE_MODES, ChatEngine, ChatSession, StepView, _CHOICE_MODES, _PRICE_MODES, ChatEngine, ChatSession, StepView,
) )
@ -41,6 +43,8 @@ _DEFAULT_REVENUE_AMOUNT = 20_000_000 # 매출액(원) — suppliers.total_reven
_DEFAULT_DISTRIBUTION_CODE = "A" # 유통 코드 — supplier_items.supply_type 미지정 시 폴백 _DEFAULT_DISTRIBUTION_CODE = "A" # 유통 코드 — supplier_items.supply_type 미지정 시 폴백
_DEFAULT_PARTNER_NAME = "귀사" # 협력사명 — suppliers.name 미기재/데모 시 폴백(카드 {partner_name}) _DEFAULT_PARTNER_NAME = "귀사" # 협력사명 — suppliers.name 미기재/데모 시 폴백(카드 {partner_name})
_DEFAULT_PRODUCT_NAME = "본 상품" # 상품명 — items.name 미기재/데모 시 폴백(카드 {product_name}) _DEFAULT_PRODUCT_NAME = "본 상품" # 상품명 — items.name 미기재/데모 시 폴백(카드 {product_name})
_DEFAULT_ITEM_PRICE_LABEL = "공급가" # 협상 기준가 호칭(공급사 화면 고정 용어) — DB 컨텍스트 없는 데모/직접호출 경로 폴백
# (negodata 용어 카탈로그 item.price 의 base 와 같아야 표기가 갈리지 않는다)
class ChatService: class ChatService:
@ -108,12 +112,19 @@ class ChatService:
# 목표가/앵커링가: sessions 행(생성 시 박제된 anchoring_price) → 박제 ‰ → 1% 폴백 (loader). # 목표가/앵커링가: sessions 행(생성 시 박제된 anchoring_price) → 박제 ‰ → 1% 폴백 (loader).
"anchor_price": db_ctx.anchor_price if db_ctx else _DEFAULT_ANCHOR_PRICE, "anchor_price": db_ctx.anchor_price if db_ctx else _DEFAULT_ANCHOR_PRICE,
"target_price": db_ctx.target_price if db_ctx else _DEFAULT_TARGET_PRICE, "target_price": db_ctx.target_price if db_ctx else _DEFAULT_TARGET_PRICE,
# 타결 상한가(sessions.done_ceiling_price 박제) — 타결 판정선이자 카드 제안가 상한.
# 목표가를 조금 넘어도 이 이하면 타결한다. 미박제/데모는 목표가와 같다.
"done_ceiling_price": db_ctx.done_ceiling_price if db_ctx else _DEFAULT_TARGET_PRICE,
# 협력사명/상품명 — 카드 스크립트 {partner_name}·{product_name} 치환용(loader). 없으면 폴백. # 협력사명/상품명 — 카드 스크립트 {partner_name}·{product_name} 치환용(loader). 없으면 폴백.
"partner_name": (db_ctx.partner_name if db_ctx and db_ctx.partner_name else _DEFAULT_PARTNER_NAME), "partner_name": (db_ctx.partner_name if db_ctx and db_ctx.partner_name else _DEFAULT_PARTNER_NAME),
"product_name": (db_ctx.product_name if db_ctx and db_ctx.product_name else _DEFAULT_PRODUCT_NAME), "product_name": (db_ctx.product_name if db_ctx and db_ctx.product_name else _DEFAULT_PRODUCT_NAME),
"round": 0, "round": 0,
# 기존 공급가(품목 기준가) — 가격협상_확인 인하율 산출용. # 협상 기준가(고객사가 관리하는 가격 — 공급가 또는 매입가) — 가격협상_확인 인하율 산출용.
# 호칭은 회사 설정 라벨을 따른다("기존 {label} 대비 …" 멘트).
"item_price": db_ctx.item_price if db_ctx else 0, "item_price": db_ctx.item_price if db_ctx else 0,
"item_price_label": db_ctx.item_price_label if db_ctx else _DEFAULT_ITEM_PRICE_LABEL,
# 회사 용어 사전 — 스크립트의 {label_*} 토큰(협력사·목표가·배송형태 등) 치환용.
"labels": (db_ctx.labels if db_ctx else {}),
# 인터넷 최저가(items.internet_lowest_price, LPS 대표값) — 카드 {internet_lowest_price} 치환용. # 인터넷 최저가(items.internet_lowest_price, LPS 대표값) — 카드 {internet_lowest_price} 치환용.
# 미수집(0)이면 vars_for 가 키를 만들지 않아 원형 유지(허위 시장가 표기 방지). # 미수집(0)이면 vars_for 가 키를 만들지 않아 원형 유지(허위 시장가 표기 방지).
"internet_lowest_price": db_ctx.internet_lowest_price if db_ctx else 0, "internet_lowest_price": db_ctx.internet_lowest_price if db_ctx else 0,
@ -123,6 +134,9 @@ class ChatService:
"db_context_loaded": db_ctx is not None, "db_context_loaded": db_ctx is not None,
"selected_nego_card_numbers": selected_nego_cards, "selected_nego_card_numbers": selected_nego_cards,
"selected_wild_card_numbers": selected_wild_cards, "selected_wild_card_numbers": selected_wild_cards,
# 카드번호 → 전술 {offer_variable, min_round, closing}. 시작 시 1회 박제(loader) —
# 이후 카드 멘트가 바뀌어도 이 협상은 시작 시점 전술로 끝까지 간다.
"card_specs": (db_ctx.card_specs if db_ctx else {}),
"allow_selected_wildcards": True if db_ctx is None else bool(selected_wild_cards), "allow_selected_wildcards": True if db_ctx is None else bool(selected_wild_cards),
}, },
) )
@ -290,20 +304,24 @@ class ChatService:
decision = policy.select(ctx) decision = policy.select(ctx)
session.used_action_ids.add(decision.action_id) session.used_action_ids.add(decision.action_id)
card_id = self._card_id_for_action(engine, session, decision.action_id) card_id = self._card_id_for_action(engine, session, decision.action_id)
# 전술 실행(재설계): 카드의 가격 행동 — 카운터 제시가를 계산해 세션에 적재한다. # 카드번호 공용 이력 — 와일드/종결 경로와 같은 목록을 본다("한 협상 한 카드 1회" 단일 판정).
mark_played(session.context, card_id)
# 전술 실행: 카드가 제시할 금액(스크립트 파싱 결과)을 계산해 세션에 적재한다.
# pending 이 있으면 이 턴은 수락/거절 스텝(가격협상_카운터)으로 전환되고, # pending 이 있으면 이 턴은 수락/거절 스텝(가격협상_카운터)으로 전환되고,
# 협력사가 수락하면 이 가격으로 즉시 타결된다(chat_engine 의 수락 메커니즘). # 협력사가 수락하면 이 가격으로 즉시 타결된다(chat_engine 의 수락 메커니즘).
spec = tactic_for(card_id) # 유효조건(≤목표가 · <제시가) 미달이면 None → 금액 없이 설득 멘트만 나간다(HOLD 강등).
counter = compute_counter(spec, session.context) if tactic_available(spec, session.context) else None spec = spec_from_context(session.context, card_id)
if counter is not None: offer = compute_offer_detail(spec, session.context) if available(spec, session.context) else None
session.context["pending_counter_price"] = counter counter = offer.price if offer else None
session.context["prev_customer_price"] = counter # 갑의 최신 포지션(middle_price 기준) if offer is not None:
record_offer(session.context, offer)
reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap) reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap)
policy.update(Transition(state_index=idx, action_id=decision.action_id, reward=reward.total, done=False)) policy.update(Transition(state_index=idx, action_id=decision.action_id, reward=reward.total, done=False))
await QTablePolicyStore.persist_cell(repo, version_id, policy, idx, decision.action_id) await QTablePolicyStore.persist_cell(repo, version_id, policy, idx, decision.action_id)
session.context["last_state"] = idx session.context["last_state"] = idx
session.context["last_action"] = decision.action_id session.context["last_action"] = decision.action_id
await self._log(repo, session, idx, decision.action_id, card_id, snap, reward, decision.propensity, done=False) await self._log(repo, session, idx, decision.action_id, card_id, snap, reward, done=False,
decision=decision, policy=policy)
res.card_id = card_id res.card_id = card_id
res.policy = policy.name res.policy = policy.name
@ -352,20 +370,31 @@ class ChatService:
규칙층의 강제 결정이므로 RL 선택/학습을 우회한다.""" 규칙층의 강제 결정이므로 RL 선택/학습을 우회한다."""
ctx = session.context ctx = session.context
ctx["closing_played"] = True ctx["closing_played"] = True
# 선택 와일드카드 중 종결 전술 (WC-05/WC-03) — 순서대로 첫 매치. # 선택 와일드카드 중 종결 전용 카드(closing) — 이미 쓴 카드는 건너뛰고(같은 멘트 반복 방지),
closing_number = next( # 제안가 유효조건(≤목표가 · <제시가) 미달 카드도 건너뛴다(예: 절충가가 목표가 초과 → 미발동).
(str(n) for n in (ctx.get("selected_wild_card_numbers") or []) if tactic_for(str(n)).closing), closing_number, closing_offer = None, None
None, for n in (ctx.get("selected_wild_card_numbers") or []):
) n = str(n)
counter = compute_counter(tactic_for(closing_number), ctx) if closing_number else None spec = spec_from_context(ctx, n)
if counter is None: if not available(spec, ctx, closing_phase=True) or is_played(ctx, n):
continue
offer = compute_offer_detail(spec, ctx)
if offer is not None:
closing_number, closing_offer = n, offer
break
if closing_offer is None:
# 폴백 최후통첩: 목표가 제시 (여기 도달 = 제시가 > target 이므로 항상 유효한 카운터). # 폴백 최후통첩: 목표가 제시 (여기 도달 = 제시가 > target 이므로 항상 유효한 카운터).
closing_number = None
target = int(ctx.get("target_price") or 0) target = int(ctx.get("target_price") or 0)
counter = target if 0 < target < ctx.get("input_price", 0) else None price = int(ctx.get("input_price") or 0)
if counter is None: if 0 < target < price:
closing_offer = Offer(price=target, variable="target_price",
prev_customer=int(ctx.get("prev_customer_price") or ctx.get("anchor_price") or 0),
prev_partner=price)
if closing_offer is None:
return # 컨텍스트 이상 — 기존 가격협상 스텝 그대로(재제안 요구) return # 컨텍스트 이상 — 기존 가격협상 스텝 그대로(재제안 요구)
ctx["pending_counter_price"] = counter mark_played(ctx, closing_number) # None(폴백 최후통첩)이면 no-op
ctx["prev_customer_price"] = counter record_offer(ctx, closing_offer)
template = None template = None
if closing_number: if closing_number:
@ -392,7 +421,7 @@ class ChatService:
policy.update(Transition(state_index=last_state, action_id=last_action, reward=reward.total, done=True)) policy.update(Transition(state_index=last_state, action_id=last_action, reward=reward.total, done=True))
await QTablePolicyStore.persist_cell(repo, version_id, policy, last_state, last_action) await QTablePolicyStore.persist_cell(repo, version_id, policy, last_state, last_action)
await self._log(repo, session, last_state, last_action, await self._log(repo, session, last_state, last_action,
self._card_id_for_action(engine, session, last_action), snap, reward, None, done=True) self._card_id_for_action(engine, session, last_action), snap, reward, done=True)
res.updated_q = float(policy.qtable.q[last_state, last_action]) res.updated_q = float(policy.qtable.q[last_state, last_action])
@staticmethod @staticmethod
@ -441,10 +470,12 @@ class ChatService:
@staticmethod @staticmethod
def _tactic_mask(engine: TenantEngine, session: ChatSession) -> Optional[np.ndarray]: def _tactic_mask(engine: TenantEngine, session: ChatSession) -> Optional[np.ndarray]:
"""전술 발동조건(min_round·가격구간)을 만족하는 action 만 True. HOLD(설득)는 항상 True. """지금 플레이 가능한 action 만 True. HOLD(설득)는 발동조건만, 금액 카드는 제안가 유효까지
본다(playable) — 무효 금액(역행·목표가 초과 등)이 멘트 글자로 나가는 것 자체를 막는다.
전부 True 면 None(마스크 불필요).""" 전부 True 면 None(마스크 불필요)."""
ctx = session.context
mask = np.array( mask = np.array(
[tactic_available(tactic_for(engine.mapper.get_card_id(a)), session.context) [playable(spec_from_context(ctx, engine.mapper.get_card_id(a)), ctx)
for a in range(engine.action_space_size)], for a in range(engine.action_space_size)],
dtype=bool, dtype=bool,
) )
@ -469,13 +500,26 @@ class ChatService:
prior[a] = 0.3 * (n - rank) / n prior[a] = 0.3 * (n - rank) / n
return prior if prior.any() else None return prior if prior.any() else None
async def _log(self, repo: LearningRepository, session, state_index, action_id, card_id, snap, reward, propensity, done): async def _log(self, repo: LearningRepository, session, state_index, action_id, card_id, snap, reward, done,
decision=None, policy=None):
data = { data = {
"session_id": session.session_id, "state_index": state_index, "action_id": action_id, "session_id": session.session_id, "state_index": state_index, "action_id": action_id,
"card_id": card_id, "snapshot": snap.to_dict(), "propensity": propensity, "card_id": card_id, "snapshot": snap.to_dict(),
"turn": snap.round_number, "reward": reward.total, "done": done, "turn": snap.round_number, "reward": reward.total, "done": done,
"settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None, "settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None,
} }
if decision is not None and policy is not None:
# 선택 근거(Q값·UCB·방문수)를 그 턴 값 그대로 박제한다 — 사후에 q_values 를 읽으면 이미 갱신된 뒤라
# "그때 왜 이 카드였나"를 복원할 수 없다. negodata 협상 학습 화면이 이 컬럼들을 읽는다.
# 종료 로그(카드 선택 없는 done 행)는 decision 이 없어 NULL — 화면 집계(avg/max)가 무시한다.
data.update({
"propensity": decision.propensity,
"available_actions": decision.available_actions,
"q_value_at_selection": decision.q_value,
"ucb_score_at_selection": decision.ucb_score,
"visit_count_at_selection": int(policy.qtable.visits[state_index, action_id]),
"total_visits_at_selection": int(policy.qtable.state_visits(state_index)),
})
try: try:
await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [lambda s: repo.log_transition(s, data)]) await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [lambda s: repo.log_transition(s, data)])
except Exception as ex: except Exception as ex:

View File

@ -97,10 +97,10 @@ class NegotiationService:
# 7) experience_logs 기록 # 7) experience_logs 기록
if req.log: if req.log:
res.logged = await self._log(engine, session_id, idx, decision, snap, reward) res.logged = await self._log(engine, session_id, idx, decision, snap, reward, policy)
return res return res
async def _log(self, engine, session_id, idx, decision, snap, reward) -> bool: async def _log(self, engine, session_id, idx, decision, snap, reward, policy) -> bool:
repo = LearningRepository(engine.company_id) repo = LearningRepository(engine.company_id)
data = { data = {
"session_id": session_id, "state_index": idx, "action_id": decision.action_id, "session_id": session_id, "state_index": idx, "action_id": decision.action_id,
@ -108,6 +108,8 @@ class NegotiationService:
"turn": snap.round_number, "available_actions": decision.available_actions, "turn": snap.round_number, "available_actions": decision.available_actions,
"reward": reward.total, "done": snap.outcome != NegotiationOutcome.ONGOING, "reward": reward.total, "done": snap.outcome != NegotiationOutcome.ONGOING,
"q_value_at_selection": decision.q_value, "ucb_score_at_selection": decision.ucb_score, "q_value_at_selection": decision.q_value, "ucb_score_at_selection": decision.ucb_score,
"visit_count_at_selection": int(policy.qtable.visits[idx, decision.action_id]),
"total_visits_at_selection": int(policy.qtable.state_visits(idx)),
"settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None, "settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None,
} }
try: try:

View File

@ -1,12 +1,12 @@
{ {
"_comment": "가격협상(카드선택) 턴에 출력할 협상 카드 스크립트. action_id(0~8) → 멘트. 선행 chat_server 의 nego_card_scripts 를 대체하는 중립 기본값(CLEANROOM.md). 실제 운영 시 card.nego_cards.script 로 override(내부 소스만 변경, 흐름 동일). 변수: {target}=목표 매입가, {input_price}=직전 제시가, {anchor}=앵커가, {discount_rate}=기존가 대비 인하율(%).", "_comment": "가격협상(카드선택) 턴에 출력할 협상 카드 스크립트. action_id(0~8) → 멘트. 선행 chat_server 의 nego_card_scripts 를 대체하는 중립 기본값(CLEANROOM.md). 실제 운영 시 card.nego_cards.script 로 override(내부 소스만 변경, 흐름 동일). 변수: {target}=목표가, {input_price}=직전 제시가, {anchor}=앵커가, {discount_rate}=기존가 대비 인하율(%).",
"0": "제안해 주신 **{input_price}원**, 감사합니다. 다만 동일 품목의 시장 거래가를 감안하면 추가 조정 여력이 있어 보입니다. 한 번 더 검토해 가격을 제안해 주시겠어요?", "0": "제안해 주신 **{input_price}원**, 감사합니다. 다만 동일 품목의 시장 거래가를 감안하면 추가 조정 여력이 있어 보입니다. 한 번 더 검토해 가격을 제안해 주시겠어요?",
"1": "적극적으로 협조해 주셔서 감사합니다. 현재 제시가는 목표 매입가(**{target}원**)와는 아직 차이가 있습니다. 조금만 더 좁혀 주시면 우선협상 대상으로 검토하겠습니다.", "1": "적극적으로 협조해 주셔서 감사합니다. 현재 제시가는 {label_target_price}(**{target}원**)와는 아직 차이가 있습니다. 조금만 더 좁혀 주시면 우선협상 대상으로 검토하겠습니다.",
"2": "좋은 제안 감사합니다. 다른 협력사들의 제안 수준을 고려할 때, 현재 금액으로는 경쟁력이 다소 부족합니다. 재검토된 가격을 부탁드립니다.", "2": "좋은 제안 감사합니다. 다른 {label_supplier}들의 제안 수준을 고려할 때, 현재 금액으로는 경쟁력이 다소 부족합니다. 재검토된 가격을 부탁드립니다.",
"3": "협상에 성실히 임해 주셔서 감사합니다. 내부 승인 기준에 맞추려면 앵커가({anchor}원) 수준에 가까운 제안이 필요합니다. 가능하신 범위에서 다시 제안해 주세요.", "3": "협상에 성실히 임해 주셔서 감사합니다. 내부 승인 기준에 맞추려면 앵커가({anchor}원) 수준에 가까운 제안이 필요합니다. 가능하신 범위에서 다시 제안해 주세요.",
"4": "제시해 주신 인하율 약 {discount_rate}%는 의미 있는 진전입니다. 다만 거래를 확정하려면 조금 더 협조가 필요합니다. 한 차례 더 조정해 주시겠어요?", "4": "제시해 주신 조건은 의미 있는 진전입니다. 다만 거래를 확정하려면 조금 더 협조가 필요합니다. 한 차례 더 조정해 주시겠어요?",
"5": "장기적인 협력 관계를 고려해 최대한 반영하고자 합니다. 현재 제시가에서 추가로 조정해 주시면 즉시 검토를 진행하겠습니다. 다시 제안 부탁드립니다.", "5": "장기적인 협력 관계를 고려해 최대한 반영하고자 합니다. 현재 제시가에서 추가로 조정해 주시면 즉시 검토를 진행하겠습니다. 다시 제안 부탁드립니다.",
"6": "검토 결과, 현재 제시가는 우리 기준을 충족하기 직전 단계입니다. 마지막으로 한 번 더 조정된 가격을 제안해 주시면 협상을 마무리할 수 있습니다.", "6": "검토 결과, 현재 제시가는 우리 기준을 충족하기 직전 단계입니다. 마지막으로 한 번 더 조정된 가격을 제안해 주시면 협상을 마무리할 수 있습니다.",
"7": "성의 있는 제안 감사합니다. 다만 물량과 납기 조건을 함께 고려하면 {input_price}원은 다소 높습니다. 목표 매입가({target}원)에 가까운 금액을 제안해 주세요.", "7": "성의 있는 제안 감사합니다. 다만 물량과 납기 조건을 함께 고려하면 {input_price}원은 다소 높습니다. {label_target_price}({target}원)에 가까운 금액을 제안해 주세요.",
"8": "긍정적으로 검토되고 있습니다. 내부 결재를 위해 명분이 조금 더 필요한 상황입니다. 가능하신 선에서 한 번 더 인하된 가격을 제안해 주시겠어요?" "8": "긍정적으로 검토되고 있습니다. 내부 결재를 위해 명분이 조금 더 필요한 상황입니다. 가능하신 선에서 한 번 더 인하된 가격을 제안해 주시겠어요?"
} }

View File

@ -12,7 +12,7 @@
"chat_end": false "chat_end": false
}, },
"서비스안내": { "서비스안내": {
"script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 협력사 간 물품 공급 가격 협상을 위한 것으로, 귀사가 공급 중인 품목의 새로운 가격 협상을 진행합니다. 안내 사항을 확인하신 뒤, 다음 단계로 넘어가려면 [확인]을 눌러 주세요.", "script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 {label_supplier} 간 물품 공급 가격 협상을 위한 것으로, 귀사가 공급 중인 품목의 새로운 가격 협상을 진행합니다. 안내 사항을 확인하신 뒤, 다음 단계로 넘어가려면 [확인]을 눌러 주세요.",
"editor_script_id": "서비스안내", "editor_script_id": "서비스안내",
"next_input_mode": "confirm", "next_input_mode": "confirm",
"input_options": [ "input_options": [
@ -25,7 +25,7 @@
"chat_end": false "chat_end": false
}, },
"담당자확인": { "담당자확인": {
"script": "본 안내는 협력사 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 다시 한 번 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.", "script": "본 안내는 {label_supplier} 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 다시 한 번 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
"editor_script_id": "담당자확인", "editor_script_id": "담당자확인",
"next_input_mode": "yes_no", "next_input_mode": "yes_no",
"input_options": [ "input_options": [
@ -55,7 +55,7 @@
"chat_end": false "chat_end": false
}, },
"정보변경_완료": { "정보변경_완료": {
"script": "[정보변경]을 선택하셨습니다. 협력사 관리 시스템에서 담당자 정보를 변경하신 뒤, 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내에 갱신되지 않으면 참여 의사가 없는 것으로 간주되어 해당 견적 건이 미참여로 처리될 수 있습니다.", "script": "[정보변경]을 선택하셨습니다. {label_supplier} 관리 시스템에서 담당자 정보를 변경하신 뒤, 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내에 갱신되지 않으면 참여 의사가 없는 것으로 간주되어 해당 견적 건이 미참여로 처리될 수 있습니다.",
"editor_script_id": "정보변경_완료", "editor_script_id": "정보변경_완료",
"next_input_mode": "null", "next_input_mode": "null",
"input_options": [], "input_options": [],

View File

@ -10,7 +10,7 @@
"chat_end": false "chat_end": false
}, },
"서비스안내": { "서비스안내": {
"script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 협력사 간 신규 물품 공급 협상을 위한 것으로, 귀사에 새로운 공급 기회를 제공하고자 합니다. 이용 방법 안내를 확인하신 뒤 [확인]을 눌러 주세요.", "script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 {label_supplier} 간 신규 물품 공급 협상을 위한 것으로, 귀사에 새로운 공급 기회를 제공하고자 합니다. 이용 방법 안내를 확인하신 뒤 [확인]을 눌러 주세요.",
"editor_script_id": "서비스안내", "editor_script_id": "서비스안내",
"next_input_mode": "confirm", "next_input_mode": "confirm",
"input_options": ["확인"], "input_options": ["확인"],
@ -19,7 +19,7 @@
"chat_end": false "chat_end": false
}, },
"담당자확인": { "담당자확인": {
"script": "본 안내는 협력사 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.", "script": "본 안내는 {label_supplier} 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
"editor_script_id": "담당자확인", "editor_script_id": "담당자확인",
"next_input_mode": "yes_no", "next_input_mode": "yes_no",
"input_options": ["예", "아니오"], "input_options": ["예", "아니오"],
@ -37,7 +37,7 @@
"chat_end": false "chat_end": false
}, },
"정보변경_완료": { "정보변경_완료": {
"script": "[정보변경]을 선택하셨습니다. 협력사 관리 시스템에서 담당자 정보를 변경하신 뒤 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내 갱신되지 않으면 미참여로 처리될 수 있습니다.", "script": "[정보변경]을 선택하셨습니다. {label_supplier} 관리 시스템에서 담당자 정보를 변경하신 뒤 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내 갱신되지 않으면 미참여로 처리될 수 있습니다.",
"editor_script_id": "정보변경_완료", "editor_script_id": "정보변경_완료",
"next_input_mode": "null", "next_input_mode": "null",
"input_options": [], "input_options": [],
@ -46,7 +46,7 @@
"chat_end": true "chat_end": true
}, },
"협상품목안내": { "협상품목안내": {
"script": "{company_name}는 아래 상품에 대해 신규 공급사를 선정하고 있으며, 귀사를 초대하여 견적을 요청드립니다. 제출하신 견적은 복수 업체와의 비교 평가를 통해 공급사 선정에 반영됩니다. 상품 정보를 확인해 주세요.", "script": "{company_name}는 아래 상품에 대해 신규 {label_supplier_를} 선정하고 있으며, 귀사를 초대하여 견적을 요청드립니다. 제출하신 견적은 복수 업체와의 비교 평가를 통해 {label_supplier} 선정에 반영됩니다. 상품 정보를 확인해 주세요.",
"editor_script_id": "협상품목안내", "editor_script_id": "협상품목안내",
"next_input_mode": "confirm", "next_input_mode": "confirm",
"input_options": ["네, 알겠습니다."], "input_options": ["네, 알겠습니다."],
@ -73,10 +73,10 @@
"chat_end": false "chat_end": false
}, },
"배송형태선택": { "배송형태선택": {
"script": "배송 형태를 선택해 주세요.", "script": "{label_delivery_type_를} 선택해 주세요.",
"editor_script_id": "배송형태선택", "editor_script_id": "배송형태선택",
"next_input_mode": "delivery_type", "next_input_mode": "delivery_type",
"input_options": ["협력사배송", "지정택배배송", "픽업배송"], "input_options": ["{label_delivery_type_1}", "{label_delivery_type_2}", "{label_delivery_type_3}"],
"next_step": { "default": "가격협상_입력" }, "next_step": { "default": "가격협상_입력" },
"type": "text", "type": "text",
"chat_end": false "chat_end": false

View File

@ -15,7 +15,7 @@
"editor_script_id": "wild_card_1pct" "editor_script_id": "wild_card_1pct"
}, },
"wild_card_budget": { "wild_card_budget": {
"script": "솔직히 말씀드리면 현재 내부 예산(재원) 사정상 제안을 그대로 수용하기 어렵습니다. 목표 매입가는 **{target}원**입니다. 이 가격에 맞춰 주신다면 즉시 계약을 진행하고자 합니다. 마지막으로 한 번 더 제안 부탁드립니다.", "script": "솔직히 말씀드리면 현재 내부 예산(재원) 사정상 제안을 그대로 수용하기 어렵습니다. 당사 {label_target_price}는 **{target}원**입니다. 이 가격에 맞춰 주신다면 즉시 계약을 진행하고자 합니다. 마지막으로 한 번 더 제안 부탁드립니다.",
"type": "text", "type": "text",
"chat_end": false, "chat_end": false,
"next_input_mode": "price", "next_input_mode": "price",

View File

@ -0,0 +1,157 @@
"""협상 퍼즈 하네스 — 랜덤 조건·랜덤 협력사 행동으로 N회 완주시키고 불변식 위반을 수집한다.
시드 고정(재현 가능). test_ 접두사 없음 — pytest 수집 대상 아님, 수동 실행 전용:
docker run --rm -v $PWD/agent:/work -w /work -e APP_ENV=local -e DB_HOST=host.docker.internal \
o2o-negosium-agent sh -lc "pip install -q pytest pytest-asyncio httpx; python tests/fuzz_negotiation.py"
케이스마다 검사하는 불변식:
1. 전 턴 success
2. 같은 카드 2회 발동 금지
3. 종결 전용(WC-03·05)은 가격협상_카운터에서만 / 비종결 와일드는 wild_card_dynamic 에서만
4. 타결 시 타결가 ≤ 목표가
5. 카운터/1% 수락으로 타결하면 그 멘트에 타결가 표기
6. 멘트·버튼에 미치환 토큰({xxx}) 잔존 금지
7. 턴 상한(60) 안에 반드시 종료
"""
import asyncio
import random
import re
import sys
import uuid
sys.path.insert(0, "/work")
from router.v1.chat.protocol import Req_Chat # noqa: E402
from services.chat_service import ChatService, reset_sessions # noqa: E402
from tenancy.config_loader import TenantConfigLoader # noqa: E402
from tenancy.registry import TenantEngineRegistry # noqa: E402
from tests.test_card_tactics import _TENANTS_DIR, _cleanup, _seed_quote_session # noqa: E402
N = 100
SEED = 20260805
TARGET = 10_000
NEGO_POOL = ["NGC-001", "NGC-002", "NGC-003", "NGC-004", "NGC-005",
"NGC-007", "NGC-008", "NGC-010", "NGC-011"]
WILD_POOL = ["WC-01", "WC-02", "WC-03", "WC-04", "WC-05"]
CLOSING = {"WC-03", "WC-05"}
TOKEN_RE = re.compile(r"(?<!\{)\{([a-z_0-9]+)\}(?!\})")
class Supplier:
"""랜덤 협력사 — 높은 시작가에서 점진 양보, 카운터는 확률적으로 수락/거절."""
def __init__(self, rng, anchor):
self.rng = rng
self.anchor = anchor
self.price = TARGET * rng.uniform(1.02, 1.30)
self.accept_p = rng.uniform(0.15, 0.5)
def next_price(self):
p = int(self.price)
# 다음 라운드를 위해 양보 — 가끔 앵커 밑까지 다이브(우선협상 유도).
self.price *= self.rng.uniform(0.90, 0.99)
if self.rng.random() < 0.15:
self.price = self.anchor * self.rng.uniform(0.95, 1.04)
return str(max(p, 100))
def choose(self, options):
if "수락" in options:
return "수락" if self.rng.random() < self.accept_p else "다른 가격 제시"
if set(options) >= {"예", "아니오"}:
return "예" if self.rng.random() < max(self.accept_p, 0.5) else "아니오"
return options[0] if options else "확인"
async def run_case(idx, rng):
anchor = int(TARGET * rng.choice([0.99, 0.99, 0.97, 0.95, 1.0]))
nego = rng.sample(NEGO_POOL, rng.randint(1, 5))
wild = rng.sample(WILD_POOL, rng.randint(0, 5))
sup = Supplier(rng, anchor)
reset_sessions()
sid = uuid.uuid4()
qid, ver = await _seed_quote_session(sid, nego, wild_numbers=wild, target=TARGET, anchor=anchor)
violations, fired, settled, outcome, ended = [], [], None, None, False
try:
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
eng = await reg.get_engine(str(uuid.uuid4()))
svc = ChatService()
ui, last_input = None, None
for _turn in range(60):
r = await svc.chat(eng, Req_Chat(session_id=str(sid), user_input=ui))
if r.result.success is not True:
violations.append(f"턴 실패 input={ui} msg={r.msg}")
break
script, opts = r.script or "", list(r.input_options or [])
if TOKEN_RE.search(script):
violations.append(f"미치환 토큰(script): {TOKEN_RE.findall(script)} @ {r.step}")
for o in opts:
if TOKEN_RE.search(o):
violations.append(f"미치환 토큰(option): {o} @ {r.step}")
if r.card_id:
fired.append((r.step, r.card_id))
if r.settled_price is not None:
settled = r.settled_price
# 카운터/1% '수락' 타결이면 마지막 카운터 멘트에 타결가가 보였어야 한다.
if last_input in ("수락",) and str(settled) not in (last_counter or ""):
violations.append(f"표시가≠타결가: {settled} not in counter script")
if r.step in ("가격협상_카운터", "wild_card_dynamic", "wild_card_1pct"):
last_counter = script
if r.chat_end:
outcome, ended = r.outcome, True
break
# 다음 입력 결정
last_input = None
if r.input_mode == "price":
ui = sup.next_price()
elif opts:
ui = sup.choose(opts)
last_input = ui
else:
ui = "확인"
if not ended:
violations.append("60턴 내 미종료")
# 카드 불변식
ids = [c for _, c in fired]
if len(ids) != len(set(ids)):
violations.append(f"카드 중복: {ids}")
for step, c in fired:
if c in CLOSING and step != "가격협상_카운터":
violations.append(f"종결 카드 {c} 가 {step} 에서 발동")
if c.startswith("WC") and c not in CLOSING and step != "wild_card_dynamic":
violations.append(f"비종결 와일드 {c} 가 {step} 에서 발동")
if outcome == "success":
if settled is None:
violations.append("성공인데 settled 없음")
elif settled > TARGET:
violations.append(f"목표가 초과 타결: {settled}")
finally:
await _cleanup(sid, qid, ver)
return {"idx": idx, "anchor": anchor, "nego": nego, "wild": wild,
"fired": fired, "settled": settled, "outcome": outcome, "violations": violations}
async def main():
rng = random.Random(SEED)
results, bad = [], []
for i in range(N):
res = await run_case(i, random.Random(rng.random()))
results.append(res)
if res["violations"]:
bad.append(res)
tag = "OK " if not res["violations"] else "BAD"
print(f"[{tag}] #{i:02d} anchor={res['anchor']} nego={len(res['nego'])} wild={len(res['wild'])} "
f"fired={'→'.join(c for _, c in res['fired']) or '-'} settled={res['settled']} {res['outcome']}")
ok = sum(1 for r in results if not r["violations"])
succ = sum(1 for r in results if r["outcome"] == "success")
print(f"\n===== {ok}/{N} clean · 타결 {succ} / 결렬 {N - succ} =====")
for r in bad:
print(f"\n#{r['idx']} 위반: nego={r['nego']} wild={r['wild']} anchor={r['anchor']}")
for v in r["violations"]:
print(" -", v)
from common.database.db_session_manager import DB_SESSION_MNG
await DB_SESSION_MNG.dispose_all()
sys.exit(0 if not bad else 1)
asyncio.run(main())

View File

@ -53,7 +53,7 @@ async def test_4_4_company_id_auto_onboard():
eng = await _reg().get_engine(COMPANY_ID) eng = await _reg().get_engine(COMPANY_ID)
assert eng.tenant_id == COMPANY_ID assert eng.tenant_id == COMPANY_ID
assert eng.company_id == COMPANY_ID # 학습/세션이 이 company_id 로 격리 assert eng.company_id == COMPANY_ID # 학습/세션이 이 company_id 로 격리
assert eng.action_space_size == 11 # base 기본 카드(162×11 정합) assert eng.action_space_size == 9 # DB 카탈로그 9장(NGC-006·009 소프트삭제)
assert eng.state_space_size == 162 assert eng.state_space_size == 162

View File

@ -91,7 +91,7 @@ async def test_selected_cards_only_are_played(db_engine):
try: try:
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0)) reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
eng = await reg.get_engine(str(_uuid.uuid4())) # 자동 온보딩(_base type:db → 실 DB 카탈로그) eng = await reg.get_engine(str(_uuid.uuid4())) # 자동 온보딩(_base type:db → 실 DB 카탈로그)
assert eng.action_space_size == 11 # 카탈로그 11장(NGC-001~011) assert eng.action_space_size == 9 # 카탈로그 9장(NGC-006·009 소프트삭제)
svc = ChatService() svc = ChatService()
played = [] played = []

View File

@ -1,11 +1,12 @@
"""카드 전술 재설계 검증 — "멘트 카드 → 전술 카드" (가격 행동 실행 계층). """카드 전술 검증 — "스크립트에 꽂힌 변수가 곧 전술" (파싱 + 변수별 유효조건 + tactic JSONB).
① 카운터 산식 결정론 + min(counter, target) 클램프 + 무의미 카운터(HOLD 강등) ① 제안가 파싱(마지막 제안가 변수) + 변수별 계산식 결정론
② 카운터 수락 = 즉시 타결 / 거절 = 재입력 + pending 폐기 ② 변수 공통 유효조건 — 목표가 초과·제시가 이상이면 미발동(클램프 아님 — IMK 8AB0 회귀)
③ 목표가 초과 타결 금지 가드(성공 스텝 진입 차단) ③ 카운터 수락 = 즉시 타결 / 거절 = 재입력 + pending 폐기
④ 선택형 와일드카드(WC-05 중간값 절충) 발동 — 1.02~1.05 구간 갭 해소 ④ 목표가 초과 타결 금지 가드(성공 스텝 진입 차단)
⑤ E2E: 견적 선택 카드(NGC-009 조건부 가격 조정)의 카운터를 수락하면 settled=target ⑤ 와일드 진입 — 종결 전용 카드 예약(중반 미발동) + 카드 이력 공유(중복 발동 차단, IMK BB9A 회귀)
⑥ E2E: 협력사가 target 초과를 고수하면 종결 전술(최후통첩) 후 결렬 — 고객사 이득 가드레일 ⑥ E2E: 견적 선택 카드(NGC-010 목표가 제안)의 카운터를 수락하면 settled=target
⑦ E2E: 협력사가 target 초과를 고수하면 종결 전술(최후통첩) 후 결렬 — 고객사 이득 가드레일
""" """
import os import os
@ -15,7 +16,8 @@ from datetime import datetime, timedelta, timezone
import pytest import pytest
from negotiation.cards.domain.tactics import ( from negotiation.cards.domain.tactics import (
PriceAction, TacticSpec, compute_counter, tactic_available, tactic_for, CardSpec, HOLD, available, build_card_spec, compute_offer,
is_played, mark_played, parse_offer_variable, playable, spec_from_context,
) )
from negotiation.chat.service.chat_engine import ChatEngine, ChatSession from negotiation.chat.service.chat_engine import ChatEngine, ChatSession
from negotiation.chat.service.script_repository import ScriptRepository from negotiation.chat.service.script_repository import ScriptRepository
@ -23,6 +25,12 @@ from tenancy.config_loader import TenantConfigLoader
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants") _TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
# 엔진 단위 테스트용 카드 스펙(로더가 DB 스크립트 파싱으로 만드는 것과 같은 형태).
_SPECS = {
"WC-02": {"offer_variable": "target_mid_price", "min_round": 1, "closing": False},
"WC-05": {"offer_variable": "middle_price", "min_round": 1, "closing": True},
}
def _engine() -> ChatEngine: def _engine() -> ChatEngine:
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea") cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
@ -31,47 +39,134 @@ def _engine() -> ChatEngine:
def _session(step="가격협상_확인", **ctx_over): def _session(step="가격협상_확인", **ctx_over):
ctx = {"input_price": 10300, "anchor_price": 10000, "target_price": 10100, ctx = {"input_price": 10300, "anchor_price": 10000, "target_price": 10100,
"round": 1, "allow_selected_wildcards": False} "round": 1, "allow_selected_wildcards": False, "card_specs": dict(_SPECS)}
ctx.update(ctx_over) ctx.update(ctx_over)
return ChatSession(session_id="00000000-0000-0000-0000-00000000e001", tenant_id="imarketkorea", return ChatSession(session_id="00000000-0000-0000-0000-00000000e001", tenant_id="imarketkorea",
company_id="imarketkorea", step=step, action_space_size=0, context=ctx) company_id="imarketkorea", step=step, action_space_size=0, context=ctx)
# ---- ① 카운터 산식 (결정론 + 가드레일 클램프) -------------------------------- # ---- ① 제안가 파싱 + 계산식 ---------------------------------------------------
def test_counter_formulas_and_clamp(): def test_parse_offer_variable_last_offer_wins():
"""제안가 변수가 여럿이면 마지막 것 — 카드 문장은 배경을 먼저, 제안을 마지막에 한다(WC-04)."""
assert parse_offer_variable("적정가는 {anchoring_price}원이었으나 {target_price}원으로 제안") == "target_price"
assert parse_offer_variable("{target_price}원을 제안 드립니다") == "target_price"
# 읽어주기 변수만 있으면 설득 카드 — 제안가 없음
assert parse_offer_variable("시장가 {internet_lowest_price}원 안팎, 제시가 {prev_partner_price}원") is None
assert parse_offer_variable("가격 변수 없는 설득 멘트") is None
assert parse_offer_variable(None) is None
# WC-05 정본: 읽어주기(직전 제안·제시가) 뒤 절충가 제안
assert parse_offer_variable("당사 제안 {prev_customer_price}원과 귀사 제안 {prev_partner_price}원을 절반씩, {middle_price}원으로") == "middle_price"
# negodata 에디터 칩 표기(anchor_price)도 앵커가 제안으로 인식 — DB 시드 표기(anchoring_price)의 별칭
assert parse_offer_variable("예산 한도는 {anchor_price}원입니다") == "anchor_price"
def test_build_card_spec_merges_script_and_tactic():
spec = build_card_spec("{target_price}원으로 제안", {"min_round": 2, "closing": True})
assert spec == CardSpec(offer_variable="target_price", min_round=2, closing=True)
# tactic 없음 → 기본값. offer_variable override 는 파싱보다 우선.
assert build_card_spec("설득 멘트", None) == HOLD
assert build_card_spec("멘트", {"offer_variable": "anchoring_price"}).offer_variable == "anchoring_price"
def test_offer_formulas():
ctx = {"input_price": 11000, "anchor_price": 9900, "target_price": 10000} ctx = {"input_price": 11000, "anchor_price": 9900, "target_price": 10000}
assert compute_counter(tactic_for("NGC-009"), ctx) == 10000 # COUNTER_TARGET offer = lambda var, c=None: compute_offer(CardSpec(offer_variable=var), c or ctx) # noqa: E731
assert compute_counter(tactic_for("NGC-007"), ctx) == 9900 # COUNTER_ANCHOR assert offer("target_price") == 10000
assert compute_counter(tactic_for("WC-02"), ctx) == 9950 # (anchor+target)/2 assert offer("anchoring_price") == 9900
# COUNTER_MID: 갑 직전 포지션 폴백 = anchor → (9900+11000)/2 = 10450 → target 클램프 assert offer("target_mid_price") == 9950 # (anchor+target)/2
assert compute_counter(tactic_for("WC-05"), ctx) == 10000 # 절충가: 갑 직전 포지션 폴백 = anchor → (8900+9500)/2 = 9200
# 갑 직전 포지션이 있으면 그 기준: (9800+11000)/2 = 10400 → 역시 클램프 10000 assert offer("middle_price", dict(ctx, input_price=9500, anchor_price=8900)) == 9200
assert compute_counter(tactic_for("WC-05"), dict(ctx, prev_customer_price=9800)) == 10000 # 갑 직전 포지션이 있으면 그 기준: (9000+9500)/2 = 9250
# 클램프 미발동 구간: (9900+10050)/2 = 9975 ≤ target assert offer("middle_price", dict(ctx, input_price=9500, prev_customer_price=9000)) == 9250
assert compute_counter(tactic_for("WC-05"), dict(ctx, input_price=10050)) == 9975
def test_counter_meaningless_degrades_to_hold(): # ---- ② 변수 공통 유효조건 — 미발동(클램프 아님) --------------------------------
"""협력사 제시가가 이미 카운터 이하면 카운터가 무의미 → None(순수 설득 유지).""" def test_offer_over_target_does_not_fire_imk_8ab0():
"""IMK 8AB0 회귀: 목표가 9,000 / 제시가 9,500 → 절충가 (8,910+9,500)/2 = 9,205 > 목표가.
구현이 목표가로 깎아 부르면 '중간에서 만나자며 목표가를 부르는' 모순 — 클램프가 아니라 미발동이 정답."""
ctx = {"input_price": 9500, "anchor_price": 8910, "target_price": 9000}
assert compute_offer(CardSpec(offer_variable="middle_price"), ctx) is None
def test_offer_at_or_above_input_price_does_not_fire():
"""협력사 제시가가 이미 제안가 이하면 부를 이유가 없다 → 미발동."""
ctx = {"input_price": 9950, "anchor_price": 9900, "target_price": 10000} ctx = {"input_price": 9950, "anchor_price": 9900, "target_price": 10000}
assert compute_counter(tactic_for("NGC-009"), ctx) is None # target(10000) ≥ 제시가 assert compute_offer(CardSpec(offer_variable="target_price"), ctx) is None # target ≥ 제시가
assert compute_counter(TacticSpec(PriceAction.COUNTER_ANCHOR), dict(ctx, input_price=9900)) is None assert compute_offer(CardSpec(offer_variable="anchoring_price"), dict(ctx, input_price=9900)) is None
def test_unknown_card_falls_back_to_hold(): def test_offer_without_materials_does_not_fire():
"""미등록 카드번호(데모 NGC-B*, 커스텀 COMP-*)는 HOLD — 기존 동작 그대로.""" """재료 결측(목표가·제시가·앵커) — 어떤 변수도 미발동."""
spec = tactic_for("NGC-B003") assert compute_offer(CardSpec(offer_variable="target_price"), {"input_price": 11000}) is None # 목표가 없음
assert spec.price_action is PriceAction.HOLD assert compute_offer(CardSpec(offer_variable="anchoring_price"),
assert compute_counter(spec, {"input_price": 11000, "target_price": 10000}) is None {"input_price": 11000, "target_price": 10000}) is None # 앵커 없음
assert tactic_available(spec, {}) is True assert compute_offer(HOLD, {"input_price": 11000, "target_price": 10000}) is None # 설득 카드
assert compute_offer(CardSpec(offer_variable="없는변수"), {"input_price": 11000, "target_price": 10000}) is None
def test_tactic_availability_conditions(): def test_available_min_round_and_closing_phase():
assert tactic_available(tactic_for("WC-04"), {"round": 1}) is False # min_round=2 spec2 = CardSpec(offer_variable="target_price", min_round=2)
assert tactic_available(tactic_for("WC-04"), {"round": 2}) is True assert available(spec2, {"round": 1}) is False # min_round 미만
assert available(spec2, {"round": 2}) is True
closing = CardSpec(offer_variable="middle_price", closing=True)
assert available(closing, {"round": 1}) is False # 종결 전용 — 중반 미발동(예약)
assert available(closing, {"round": 1}, closing_phase=True) is True
assert available(spec2, {"round": 3}, closing_phase=True) is False # 종결 국면엔 종결 카드만
# ---- ② 카운터 수락/거절 메커니즘 (엔진) --------------------------------------- def test_tactic_offer_variable_overrides_parse():
"""검증: tactic.offer_variable 명시 지정(negodata 셀렉트) — 파싱(마지막 변수) 대신 지정 변수 사용.
기대결과: 멘트 마지막이 target_price 여도 지정한 anchoring_price 가 제안가 변수가 된다."""
script = "적정가는 {anchoring_price}원이었으나 {target_price}원으로 제안 드립니다."
assert build_card_spec(script).offer_variable == "target_price" # 자동: 마지막 변수
spec = build_card_spec(script, {"offer_variable": "anchoring_price"})
assert spec.offer_variable == "anchoring_price" # 명시 지정이 우선
def test_available_requires_context_value():
"""검증: 시장가 인용 카드(NGC-008류)의 requires 게이트 — build_card_spec 이 스크립트에서 잡아내고,
기대결과: 컨텍스트에 인터넷 최저가가 없으면(0/결측) 미발동, 있으면 발동(퍼즈 #3·13·23·40 회귀)."""
spec = build_card_spec("유사 거래는 {internet_lowest_price}원 안팎에서 합의되고 있습니다.")
assert spec.requires == ("internet_lowest_price",)
assert available(spec, {"round": 1}) is False # 결측
assert available(spec, {"round": 1, "internet_lowest_price": 0}) is False # 미수집(0)
assert available(spec, {"round": 1, "internet_lowest_price": 6300}) is True
# 일반 카드는 requires 없음 — 기존 동작 그대로.
assert build_card_spec("귀사와의 협력을 소중히 생각합니다.").requires == ()
def test_offer_monotonic_no_regression():
"""검증: 역행 금지(IMK 논의 — 절충 16,980 후 예산 상한 16,810 제시) 재현.
기대결과: 직전 당사 제안보다 낮은 제안가 카드는 미발동(설득 폴백으로도 안 나감).
직전 제안이 없으면 앵커 제시 허용, 같은 금액 재제시 허용, 더 높은 제안은 정상."""
anchor_card = CardSpec(offer_variable="anchoring_price")
ctx = {"round": 2, "target_price": 17_300, "anchor_price": 16_810, "input_price": 17_500}
assert compute_offer(anchor_card, ctx) == 16_810 # 첫 카운터 전(포지션=앵커): 같은 금액 → 허용
ctx["prev_customer_price"] = 16_980 # 절충 카드가 이미 16,980 을 부른 상태
assert compute_offer(anchor_card, ctx) is None # 앵커 16,810 은 역행 → 미발동
assert playable(anchor_card, ctx) is False # 멘트에 금액이 박히므로 설득 폴백도 금지
assert compute_offer(CardSpec(offer_variable="target_price"), ctx) == 17_300 # 상향 제안은 정상
def test_played_history_is_shared_by_number():
ctx = {}
assert is_played(ctx, "WC-05") is False
mark_played(ctx, "WC-05")
assert is_played(ctx, "WC-05") is True
mark_played(ctx, "WC-05") # 재기록해도 1건 유지
assert ctx["played_card_numbers"] == ["WC-05"]
mark_played(ctx, None) # no-op(폴백 최후통첩)
assert ctx["played_card_numbers"] == ["WC-05"]
def test_spec_from_context_reads_snapshot_and_falls_back_to_hold():
ctx = {"card_specs": dict(_SPECS)}
assert spec_from_context(ctx, "WC-05") == CardSpec(offer_variable="middle_price", min_round=1, closing=True)
assert spec_from_context(ctx, "NGC-B003") == HOLD # 미등록 카드(데모) 폴백
assert spec_from_context({}, "WC-05") == HOLD # 스펙 미적재(구세션·데모) 폴백
# ---- ③ 카운터 수락/거절 메커니즘 (엔진) ---------------------------------------
def test_accept_counter_settles_at_counter_price(): def test_accept_counter_settles_at_counter_price():
eng = _engine() eng = _engine()
s = _session(step="가격협상_카운터", pending_counter_price=10000) s = _session(step="가격협상_카운터", pending_counter_price=10000)
@ -102,7 +197,7 @@ def test_wildcard_1pct_decline_keeps_original_price():
assert s.context["input_price"] == 10000 # 거절 → 카운터 미적용 assert s.context["input_price"] == 10000 # 거절 → 카운터 미적용
# ---- ③ 목표가 초과 타결 금지 가드 -------------------------------------------- # ---- ④ 목표가 초과 타결 금지 가드 --------------------------------------------
def test_success_step_guard_rejects_over_target(): def test_success_step_guard_rejects_over_target():
eng = _engine() eng = _engine()
s = _session(input_price=10800, target_price=10000) s = _session(input_price=10800, target_price=10000)
@ -110,20 +205,46 @@ def test_success_step_guard_rejects_over_target():
assert view.step == "협상실패" # 초과가 성공 진입 → 결렬 강제 assert view.step == "협상실패" # 초과가 성공 진입 → 결렬 강제
# ---- ④ 선택형 와일드카드 발동 (1.02~1.05 구간 갭 해소) ------------------------- # ---- ⑤ 와일드 진입 — 종결 예약 + 중복 차단 (IMK BB9A 회귀) ---------------------
def test_selected_wildcard_fires_in_entry_zone(): def test_selected_wildcard_fires_in_entry_zone_and_records_position():
eng = _engine()
# 10300: 1pct 존(≤10200) 밖, entry 존(≤10500) 안 + 비종결 WC-02 선택
s = _session(input_price=10300, allow_selected_wildcards=True,
selected_wild_card_numbers=["WC-02"])
view = eng.advance(s, "예")
assert view.step == "wild_card_dynamic"
# 제안가 = (anchor 10000 + target 10100)/2 = 10050 ≤ target — 그대로 제시(클램프 없음)
assert s.context["pending_counter_price"] == 10050
assert s.context["prev_customer_price"] == 10050 # 갑 포지션 기록 — "당사 제안" 멘트 정합(BB9A ③)
assert s.context["active_wild_card_number"] == "WC-02"
assert is_played(s.context, "WC-02") # 카드 이력 기록
# 수락 → 그 가격으로 타결
view = eng.advance(s, "수락")
assert view.step == "협상완료" and s.context["input_price"] == 10050
def test_closing_card_is_reserved_never_fires_mid_negotiation():
"""종결 전용 카드(WC-05)는 entry 존이라도 중반에 안 나간다 — 종결 국면의 마지막 한 방으로 예약.
(BB9A 중복의 절반: 중반에 당겨 쓴 카드를 종결에서 또 쓰던 경로 차단.)"""
eng = _engine() eng = _engine()
# 10300: 1pct 존(≤10200) 밖, entry 존(≤10500) 안 + WC-05 선택
s = _session(input_price=10300, allow_selected_wildcards=True, s = _session(input_price=10300, allow_selected_wildcards=True,
selected_wild_card_numbers=["WC-05"]) selected_wild_card_numbers=["WC-05"])
view = eng.advance(s, "예") view = eng.advance(s, "예")
assert view.step == "wild_card_dynamic" assert view.step == "가격협상" # 종결 카드뿐 → 일반 카드 플레이로
# 카운터 = (anchor 10000 + 10300)/2 = 10150 → target(10100) 클램프 assert "active_wild_card_number" not in s.context
assert s.context["pending_counter_price"] == 10100 assert not is_played(s.context, "WC-05") # 안 나갔으니 이력도 없음
assert s.context["active_wild_card_number"] == "WC-05" # 종결 국면에선 발동 가능 + 이력 없음 — 서비스 종결 루프가 이 카드를 쓴다
# 수락 → 그 가격으로 타결 spec = spec_from_context(s.context, "WC-05")
view = eng.advance(s, "수락") assert available(spec, s.context, closing_phase=True) is True
assert view.step == "협상완료" and s.context["input_price"] == 10100
def test_played_wildcard_is_skipped_on_reentry():
"""이미 쓴 카드는 같은 협상에서 다시 안 나간다 — 다음 후보로 넘어간다."""
eng = _engine()
s = _session(input_price=10300, allow_selected_wildcards=True, wildcard_used=False,
selected_wild_card_numbers=["WC-02"], played_card_numbers=["WC-02"])
view = eng.advance(s, "예")
assert view.step == "가격협상" # 유일 후보가 사용됨 → 발동 없음
def test_unselected_wildcard_zone_still_falls_to_nego(): def test_unselected_wildcard_zone_still_falls_to_nego():
@ -186,7 +307,7 @@ def test_vars_for_supplies_tactic_variables():
assert v1["target_mid_price"] == 10100 assert v1["target_mid_price"] == 10100
# ---- ⑤⑥ E2E (실 DB — 견적 선택 카드 + 서비스 레이어) --------------------------- # ---- ⑥⑦ E2E (실 DB — 견적 선택 카드 + 서비스 레이어) ---------------------------
from sqlalchemy import column, delete, insert, select, table # noqa: E402 from sqlalchemy import column, delete, insert, select, table # noqa: E402
from common.database.db_session_manager import DB_SESSION_MNG # noqa: E402 from common.database.db_session_manager import DB_SESSION_MNG # noqa: E402
@ -211,24 +332,30 @@ _T_QUOTATIONS = table(
) )
_T_VNC = table("version_nego_cards", column("vnc_id"), column("version_id"), column("nego_card_id"), schema="card") _T_VNC = table("version_nego_cards", column("vnc_id"), column("version_id"), column("nego_card_id"), schema="card")
_T_NEGO = table("nego_cards", column("nego_card_id"), column("number"), column("deleted"), schema="card") _T_NEGO = table("nego_cards", column("nego_card_id"), column("number"), column("deleted"), schema="card")
_T_VWC = table("version_wild_cards", column("vwc_id"), column("version_id"), column("wild_card_id"), schema="card")
_T_WILD = table("wild_cards", column("wild_card_id"), column("number"), column("deleted"), schema="card")
async def _card_uuid(number: str): async def _card_uuid(number: str, *, wild=False):
tbl, pk = (_T_WILD, _T_WILD.c.wild_card_id) if wild else (_T_NEGO, _T_NEGO.c.nego_card_id)
def _q(s): def _q(s):
return DB_SESSION_MNG.execute( return DB_SESSION_MNG.execute(
s, select(_T_NEGO.c.nego_card_id).where( s, select(pk).where(tbl.c.number == number, tbl.c.deleted == False).limit(1)) # noqa: E712
_T_NEGO.c.number == number, _T_NEGO.c.deleted == False).limit(1)) # noqa: E712
_, rows = await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q) _, rows = await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q)
return rows[0] if rows else None return rows[0] if rows else None
async def _seed_quote_session(sid, selected_numbers, target=10000, anchor=9900): async def _seed_quote_session(sid, selected_numbers, wild_numbers=(), target=10000, anchor=9900):
qid, ver_id, iid, sup = _uuid.uuid4(), _uuid.uuid4(), _uuid.uuid4(), _uuid.uuid4() qid, ver_id, iid, sup = _uuid.uuid4(), _uuid.uuid4(), _uuid.uuid4(), _uuid.uuid4()
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
card_ids = {} card_ids, wild_ids = {}, {}
for n in selected_numbers: for n in selected_numbers:
card_ids[n] = await _card_uuid(n) card_ids[n] = await _card_uuid(n)
assert card_ids[n] is not None, f"카탈로그에 {n} 없음(시드 확인)" assert card_ids[n] is not None, f"카탈로그에 {n} 없음(시드 확인)"
for n in wild_numbers:
wild_ids[n] = await _card_uuid(n, wild=True)
assert wild_ids[n] is not None, f"카탈로그에 {n} 없음(시드 확인)"
def _seed(s_): def _seed(s_):
async def run(s): async def run(s):
@ -243,6 +370,11 @@ async def _seed_quote_session(sid, selected_numbers, target=10000, anchor=9900):
vnc_id=_uuid.uuid4(), version_id=ver_id, nego_card_id=card_ids[n])) vnc_id=_uuid.uuid4(), version_id=ver_id, nego_card_id=card_ids[n]))
if e != ErrorType.SUCCESS: if e != ErrorType.SUCCESS:
return e return e
for n in wild_numbers:
e = await DB_SESSION_MNG.add(s, insert(_T_VWC).values(
vwc_id=_uuid.uuid4(), version_id=ver_id, wild_card_id=wild_ids[n]))
if e != ErrorType.SUCCESS:
return e
return await DB_SESSION_MNG.add(s, insert(_T_SESSIONS).values( return await DB_SESSION_MNG.add(s, insert(_T_SESSIONS).values(
session_id=sid, quotation_id=qid, item_id=iid, supplier_id=sup, session_id=sid, quotation_id=qid, item_id=iid, supplier_id=sup,
qt_number=f"QT-TACTIC-{str(sid)[:8]}", qt_round=1, qt_type=1, qt_number=f"QT-TACTIC-{str(sid)[:8]}", qt_round=1, qt_type=1,
@ -260,17 +392,18 @@ async def _cleanup(sid, qid, ver_id):
[DBType.MAIN.value], [DBType.MAIN.value],
[lambda s: DB_SESSION_MNG.add(s, delete(_T_SESSIONS).where(_T_SESSIONS.c.session_id == sid)), [lambda s: DB_SESSION_MNG.add(s, delete(_T_SESSIONS).where(_T_SESSIONS.c.session_id == sid)),
lambda s: DB_SESSION_MNG.add(s, delete(_T_VNC).where(_T_VNC.c.version_id == ver_id)), lambda s: DB_SESSION_MNG.add(s, delete(_T_VNC).where(_T_VNC.c.version_id == ver_id)),
lambda s: DB_SESSION_MNG.add(s, delete(_T_VWC).where(_T_VWC.c.version_id == ver_id)),
lambda s: DB_SESSION_MNG.add(s, delete(_T_QUOTATIONS).where(_T_QUOTATIONS.c.qt_id == qid))], lambda s: DB_SESSION_MNG.add(s, delete(_T_QUOTATIONS).where(_T_QUOTATIONS.c.qt_id == qid))],
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_e2e_counter_accept_settles_at_target(db_engine): async def test_e2e_counter_accept_settles_at_target(db_engine):
"""견적 선택 카드 NGC-009(조건부 가격 조정 → COUNTER_TARGET)의 카운터를 수락하면 """견적 선택 카드 NGC-010(향후 거래 연계 — 스크립트 {target_price} 파싱 → 목표가 제안)의
합의가 = 목표가(10000) — '수락 즉시 타결' 기획 결정의 E2E 검증.""" 카운터를 수락하면 합의가 = 목표가(10000) — '수락 즉시 타결' 기획 결정의 E2E 검증."""
reset_sessions() reset_sessions()
sid = _uuid.uuid4() sid = _uuid.uuid4()
qid, ver_id = await _seed_quote_session(sid, ["NGC-009"]) qid, ver_id = await _seed_quote_session(sid, ["NGC-010"])
try: try:
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0)) reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
eng = await reg.get_engine(str(_uuid.uuid4())) eng = await reg.get_engine(str(_uuid.uuid4()))
@ -279,9 +412,9 @@ async def test_e2e_counter_accept_settles_at_target(db_engine):
r = None r = None
for ui in [None, "확인", "예", "확인", "11000", "예"]: for ui in [None, "확인", "예", "확인", "11000", "예"]:
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input=ui)) r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input=ui))
# 가격협상 카드 턴 → NGC-009 카운터(target) 제시 스텝 # 가격협상 카드 턴 → NGC-010 카운터(target) 제시 스텝
assert r.step == "가격협상_카운터", f"카운터 스텝 기대, 실제 {r.step}" assert r.step == "가격협상_카운터", f"카운터 스텝 기대, 실제 {r.step}"
assert r.card_id == "NGC-009" assert r.card_id == "NGC-010"
assert r.input_options == ["수락", "다른 가격 제시"] assert r.input_options == ["수락", "다른 가격 제시"]
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="수락")) r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="수락"))
@ -297,7 +430,7 @@ async def test_e2e_over_target_ends_in_failure_after_closing(db_engine):
그래도 거절 → 결렬(협상실패). 목표가 초과로는 절대 타결되지 않는다.""" 그래도 거절 → 결렬(협상실패). 목표가 초과로는 절대 타결되지 않는다."""
reset_sessions() reset_sessions()
sid = _uuid.uuid4() sid = _uuid.uuid4()
qid, ver_id = await _seed_quote_session(sid, ["NGC-003"]) # HOLD 카드 1장 → 빠른 소진 qid, ver_id = await _seed_quote_session(sid, ["NGC-003"]) # 설득 카드 1장 → 빠른 소진
try: try:
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0)) reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
eng = await reg.get_engine(str(_uuid.uuid4())) eng = await reg.get_engine(str(_uuid.uuid4()))
@ -321,3 +454,46 @@ async def test_e2e_over_target_ends_in_failure_after_closing(db_engine):
assert r.settled_price is None # 초과가 타결 없음 assert r.settled_price is None # 초과가 타결 없음
finally: finally:
await _cleanup(sid, qid, ver_id) await _cleanup(sid, qid, ver_id)
@pytest.mark.asyncio
async def test_e2e_bb9a_no_duplicate_wildcard_and_real_middle(db_engine):
"""IMK BB9A 재현 E2E — 와일드카드 2장(WC-02·WC-05) + 설득 카드 1장.
기대 흐름(수정 후):
· 중반 와일드 진입 = 비종결 WC-02 (종결 전용 WC-05 는 예약 — 구현 전엔 WC-05 가 먼저 나갔다)
· 종결 국면 = WC-05, 절충가 = (당사 직전 제안 + 협력사 제시가)/2 실계산 (구현 전엔 목표가로 클램프)
· 같은 카드 2회 발동 없음 + 종결 발동도 card_id 기록
"""
reset_sessions()
sid = _uuid.uuid4()
qid, ver_id = await _seed_quote_session(sid, ["NGC-003"], wild_numbers=["WC-02", "WC-05"])
try:
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
eng = await reg.get_engine(str(_uuid.uuid4()))
svc = ChatService()
session_id = str(sid)
r = None
# 10300: 1pct 존(≤ 9900×1.02=10098) 밖, entry 존(≤ 10395) 안 → 선택형 와일드 발동 구간
for ui in [None, "확인", "예", "확인", "10300", "예"]:
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input=ui))
assert r.step == "wild_card_dynamic"
assert r.card_id == "WC-02" # 종결 전용 WC-05 가 아니라 비종결 카드
# WC-02 제안가 = (anchor 9900 + target 10000)/2 = 9950
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="다른 가격 제시"))
# 10010 재제시 → 설득 카드(NGC-003) 1장 소진
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="10010"))
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="예"))
assert r.step == "가격협상" and r.card_id == "NGC-003"
# 10005 재제시 → 카드 소진 → 종결 국면: WC-05 절충가 = (9950 + 10005)/2 = 9980 (≤ target)
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="10005"))
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="예"))
assert r.step == "가격협상_카운터"
assert r.card_id == "WC-05" # 종결 발동도 카드 기록(구현 전 null)
assert "9980" in r.script # 실제 절충가 — 목표가(10000) 클램프 아님
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="수락"))
assert r.step == "협상완료"
assert r.settled_price == 9980 # 표시가 = 타결가
finally:
await _cleanup(sid, qid, ver_id)

View File

@ -186,11 +186,14 @@ async def test_loader_with_crud_double(db_engine):
class _FakeCRUD(INegoContextCRUD): class _FakeCRUD(INegoContextCRUD):
async def get_session_row(self, cdb, session_id): async def get_session_row(self, cdb, session_id):
# (qt_type, target, anchoring_price, item_id, quotation_id, supplier_id) — 재견적(2)·앵커 미박제 # (qt_type, target, anchoring_price, done_ceiling_price, item_id, quotation_id, supplier_id)
return ErrorType.SUCCESS, (2, 50000, None, uuid.uuid4(), uuid.uuid4(), uuid.uuid4()) # — 재견적(2)·앵커 미박제·타결상한 52,500(목표가 +5%)
return ErrorType.SUCCESS, (2, 50000, None, 52500, uuid.uuid4(), uuid.uuid4(), uuid.uuid4())
async def get_item_price(self, cdb, item_id): async def get_item_baseline(self, cdb, item_id):
return ErrorType.SUCCESS, 7000 # 기준가를 매입가로 고른 회사 + 거래상대 호칭을 '공급업체'로 바꾼 용어 사전.
# 호칭은 crud 가 어떤 회사든 '공급가'(공급사 화면 고정 용어)로 내려준다.
return ErrorType.SUCCESS, (7000, "공급가", {"supplier": "공급업체"})
async def get_item_lowest_price(self, cdb, item_id): async def get_item_lowest_price(self, cdb, item_id):
return ErrorType.SUCCESS, 6300 # 인터넷 최저가(items.internet_lowest_price) return ErrorType.SUCCESS, 6300 # 인터넷 최저가(items.internet_lowest_price)
@ -217,14 +220,22 @@ async def test_loader_with_crud_double(db_engine):
return ErrorType.SUCCESS, 0 # 이력도 없음 → NONE return ErrorType.SUCCESS, 0 # 이력도 없음 → NONE
async def get_quotation_card_numbers(self, cdb, quotation_id): async def get_quotation_card_numbers(self, cdb, quotation_id):
return ErrorType.SUCCESS, (["NGC-003", "NGC-008"], ["WC-02"]) # 견적 선택 카드 # 행 = (number, script, tactic) — 스크립트 파싱 + tactic JSONB 로 card_specs 를 만든다
return ErrorType.SUCCESS, (
[("NGC-003", "설득 멘트(가격 변수 없음)", None),
("NGC-008", "시장가 {internet_lowest_price}원 인용(읽기 전용 변수)", None)],
[("WC-02", "이에 당사는 {target_mid_price}원을 역으로 제안 드립니다.", None)],
)
ctx = await NegotiationContextLoader(crud=_FakeCRUD()).load(str(uuid.uuid4())) ctx = await NegotiationContextLoader(crud=_FakeCRUD()).load(str(uuid.uuid4()))
assert ctx is not None assert ctx is not None
assert ctx.rq_type == "재견적" # qt_type=2(1:N) assert ctx.rq_type == "재견적" # qt_type=2(1:N)
assert ctx.target_price == 50000 assert ctx.target_price == 50000
assert ctx.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target) assert ctx.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target)
assert ctx.done_ceiling_price == 52500 # 타결 상한가 박제값(목표가 +5%)
assert ctx.item_price == 7000 assert ctx.item_price == 7000
assert ctx.item_price_label == "공급가" # 기준가 호칭이 멘트까지 전달되는지
assert ctx.labels == {"supplier": "공급업체"} # 회사 용어 사전이 스크립트 토큰용으로 실리는지
assert ctx.internet_lowest_price == 6300 # 인터넷 최저가 로드 확인 assert ctx.internet_lowest_price == 6300 # 인터넷 최저가 로드 확인
assert ctx.card_count == 3 # 협상카드 사용 횟수 상한 로드 확인 assert ctx.card_count == 3 # 협상카드 사용 횟수 상한 로드 확인
assert ctx.partner_name == "테스트협력사" assert ctx.partner_name == "테스트협력사"
@ -234,6 +245,14 @@ async def test_loader_with_crud_double(db_engine):
assert ctx.partner_type is PartnerType.NONE assert ctx.partner_type is PartnerType.NONE
assert ctx.selected_nego_card_numbers == ["NGC-003", "NGC-008"] assert ctx.selected_nego_card_numbers == ["NGC-003", "NGC-008"]
assert ctx.selected_wild_card_numbers == ["WC-02"] assert ctx.selected_wild_card_numbers == ["WC-02"]
# 카드 전술 확정 — 설득 카드/읽기 전용 변수는 제안가 없음, WC-02 는 스크립트 파싱으로 중간가.
assert ctx.card_specs["NGC-003"]["offer_variable"] is None
assert ctx.card_specs["NGC-008"]["offer_variable"] is None # 인터넷 최저가는 읽어주기 변수 — 제안가 아님
# 시장가 인용 카드는 최저가 결측 세션에서 미발동하도록 requires 로 표시된다(토큰 노출 방지).
assert ctx.card_specs["NGC-008"]["requires"] == ["internet_lowest_price"]
assert ctx.card_specs["WC-02"] == {
"offer_variable": "target_mid_price", "min_round": 1, "closing": False, "requires": [],
}
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@ -49,9 +49,14 @@ def test_wildcard_threshold_is_config_driven():
# 기본(1.02): anchor 10000, 제시 10800 → 임계 밖 → 일반 가격협상 # 기본(1.02): anchor 10000, 제시 10800 → 임계 밖 → 일반 가격협상
view = _engine().advance(_session(10800), "예") view = _engine().advance(_session(10800), "예")
assert view.step == "가격협상" assert view.step == "가격협상"
# 임계를 1.10 으로 완화한 테넌트 → 같은 가격에서 1% 인하 와일드카드 발동 # 임계를 1.10 으로 완화한 테넌트 → 같은 가격에서 1% 인하 와일드카드 발동.
view = _engine(wildcard_1pct_ratio=1.10).advance(_session(10800), "예") # 1%가(10800×0.99=10692)도 제안가 공통 유효조건(≤목표가)을 타므로 목표가를 그 위로 둔다 —
# 기본 target(10100)이면 초과 제시 금지 규칙에 걸려 발동하지 않는 게 새 정답.
view = _engine(wildcard_1pct_ratio=1.10).advance(_session(10800, target_price=11000), "예")
assert view.step == "wild_card_1pct" assert view.step == "wild_card_1pct"
# 목표가가 1%가 아래면(초과 제시 금지) 완화 임계라도 미발동 — 수락해도 결렬되는 모순 제안 차단.
view = _engine(wildcard_1pct_ratio=1.10).advance(_session(10800), "예")
assert view.step == "가격협상"
def test_max_counter_rounds_is_config_driven(): def test_max_counter_rounds_is_config_driven():

View File

@ -0,0 +1,190 @@
"""협상 불변식 시나리오 하네스 — 실서비스 스택(ChatService + 실 DB 카드)으로 13개 협상을 완주시키고,
IMK 가 잡은 두 부류의 사고(같은 카드 반복 · 이상한 금액)가 어떤 흐름에서도 안 나는지 검사한다.
시나리오별 기대 이벤트(카드가 나간 턴의 step·카드·금액)를 정확히 못박고, 공통 불변식을 전 턴에 건다:
· 카드 중복 없음 — 한 협상에서 같은 card_id 2회 발동 금지
· 카드 자리 규칙 — 종결 전용(WC-03·05)은 가격협상_카운터에서만, 비종결 와일드는 wild_card_dynamic 에서만
· 타결가 ≤ 목표가 — 어떤 성공 경로도 목표가 초과로 안 끝남
· 카운터 멘트의 금액 = 수락 시 타결가 (표시가=타결가)
"""
import uuid as _uuid
from dataclasses import dataclass, field
from typing import Optional
import pytest
from router.v1.chat.protocol import Req_Chat
from services.chat_service import ChatService, reset_sessions
from tenancy.config_loader import TenantConfigLoader
from tenancy.registry import TenantEngineRegistry
from tests.test_card_tactics import _TENANTS_DIR, _cleanup, _seed_quote_session
# 종결 전용 와일드카드(DB tactic 시드와 동일) — 자리 규칙 검사용.
_CLOSING_WILDS = {"WC-03", "WC-05"}
_NONCLOSING_WILDS = {"WC-01", "WC-02", "WC-04"}
# 카드가 나갈 수 있는 스텝(이벤트로 수집).
_CARD_STEPS = {"가격협상", "wild_card_dynamic", "wild_card_1pct", "가격협상_카운터"}
_BOILERPLATE = [None, "확인", "예", "확인"]
@dataclass
class Scenario:
name: str
inputs: list # 서두(안내~기존가격제시) 이후의 협력사 입력 시퀀스
# 기대 이벤트: (step, card, offer_substring). card="NGC-*" 는 임의 협상카드(중복만 검사).
events: list
settled: Optional[int] # 기대 타결가(원). None=결렬
nego: list = field(default_factory=lambda: ["NGC-001"])
wild: list = field(default_factory=list)
target: int = 10_000
anchor: int = 9_900
# 밴드(기본 target 10000·anchor 9900): 1% 존 ≤ 10,098 · 진입 존 ≤ 10,395.
SCENARIOS = [
# S01 BB9A 재현 — 중반 비종결 WC-02, 종결 WC-05 실절충가. 같은 카드 2회 없음.
Scenario("S01_bb9a_mid_wc02_close_wc05",
["10300", "예", "다른 가격 제시", "10010", "예", "10005", "예", "수락", "확인"],
[("wild_card_dynamic", "WC-02", "9950"),
("가격협상", "NGC-001", None),
("가격협상_카운터", "WC-05", "9980")],
settled=9980, wild=["WC-02", "WC-05"]),
# S02 8AB0 재현 — 절충가(9,205)가 목표가(9,000) 초과 → WC-05 미발동, 목표가 최후통첩(카드 없음).
Scenario("S02_8ab0_middle_over_target_skips",
["9500", "예", "9500", "예", "다른 가격 제시", "9500", "예", "확인"],
[("가격협상", "NGC-001", None),
("가격협상_카운터", None, "9000")],
settled=None, wild=["WC-05"], target=9_000, anchor=8_910),
# S03 와일드 5장 전부 + 협상카드 2장 — 중반 1장(WC-01)·종결 1장(WC-03)만, 협상카드는 서로 다른 2장.
Scenario("S03_five_wilds_full_run",
["10300", "예", "다른 가격 제시", "10200", "예", "10150", "예", "10100", "예", "수락", "확인"],
[("wild_card_dynamic", "WC-01", "10000"),
("가격협상", "NGC-*", None),
("가격협상", "NGC-*", None),
("가격협상_카운터", "WC-03", "10000")],
settled=10_000, nego=["NGC-001", "NGC-003"],
wild=["WC-01", "WC-02", "WC-03", "WC-04", "WC-05"]),
# S04 종결 전용 와일드만 담김 + 제시가가 진입 존에 머무름 — 소진 판정이 막히지 않고
# 종결로 넘어간다(프로브 픽스 회귀: 픽스 전엔 빈 덱에서 쓴 카드를 또 꺼내는 무한 협상).
Scenario("S04_closing_only_wild_no_deadlock",
["10300", "예", "10250", "예", "수락", "확인"],
[("가격협상", "NGC-001", None),
("가격협상_카운터", None, "10000")], # WC-05 절충 10,075>목표가 → 미발동 → 최후통첩
settled=10_000, wild=["WC-05"]),
# S05 1% 존 — 시스템 1% 카드, 수락 시 표시 금액 그대로 타결.
Scenario("S05_one_pct_zone_accept",
["10050", "예", "예", "확인"],
[("wild_card_1pct", None, "9950")],
settled=9_950),
# S06 앵커 이하 즉시 타결 — 카드 0장.
Scenario("S06_priority_match_no_cards",
["9800", "예", "확인"],
[],
settled=9_800),
# S07 목표가 초과 고수 → 설득 1장 → 최후통첩 → 결렬.
Scenario("S07_hold_high_fails",
["11000", "예", "11000", "예", "다른 가격 제시", "11000", "예", "확인"],
[("가격협상", "NGC-003", None),
("가격협상_카운터", None, "10000")],
settled=None, nego=["NGC-003"]),
# S08 협상카드 카운터(NGC-010 목표가 제안) 수락 — 협상카드도 카운터 스텝을 쓴다.
Scenario("S08_nego_counter_accept",
["11000", "예", "수락", "확인"],
[("가격협상_카운터", "NGC-010", "10000")],
settled=10_000, nego=["NGC-010"]),
# S09 min_round=2 — WC-04 는 1라운드 진입 존에서 안 나가고 2라운드에 나간다.
Scenario("S09_min_round_two_defers_wc04",
["10300", "예", "10200", "예", "수락", "확인"],
[("가격협상", "NGC-001", None),
("wild_card_dynamic", "WC-04", "10000")],
settled=10_000, wild=["WC-04"]),
# S10 종결 체인 폴백 — WC-05 무효(절충 10,175>목표) → 다음 종결 WC-03 발동.
Scenario("S10_closing_chain_falls_to_wc03",
["10500", "예", "10450", "예", "다른 가격 제시", "10450", "예", "확인"],
[("가격협상", "NGC-001", None),
("가격협상_카운터", "WC-03", "10000")],
settled=None, wild=["WC-05", "WC-03"]),
# S11 중반+종결 콤보 — WC-02 중반, 종결은 WC-05 무효 건너뛰고 WC-03. 전 카드 1회씩.
Scenario("S11_mid_and_closing_combo",
["10300", "예", "다른 가격 제시", "10400", "예", "10350", "예", "수락", "확인"],
[("wild_card_dynamic", "WC-02", "9950"),
("가격협상", "NGC-001", None),
("가격협상_카운터", "WC-03", "10000")],
settled=10_000, wild=["WC-02", "WC-05", "WC-03"]),
# S12 라운드 상한 — 협상카드 3장 각 1회(중복 없음) 후 상한 도달 → 최후통첩 → 결렬.
Scenario("S12_round_cap_distinct_nego_cards",
["11000", "예", "11000", "예", "11000", "예", "11000", "예", "다른 가격 제시", "11000", "예", "확인"],
[("가격협상", "NGC-*", None),
("가격협상", "NGC-*", None),
("가격협상", "NGC-*", None),
("가격협상_카운터", None, "10000")],
settled=None, nego=["NGC-001", "NGC-002", "NGC-003", "NGC-004", "NGC-005"]),
# S13 재생성 아님·재료 극단 — 앵커 미박제 세션(anchor=target 폴백)에서도 초과 제시·중복 없음.
Scenario("S13_anchor_equals_target_fallback",
["10300", "예", "10200", "예", "수락", "확인"],
[("가격협상", "NGC-001", None),
("가격협상_카운터", "WC-03", "10000")], # WC-05 절충 (10000+10200)/2=10100>목표 → 스킵
settled=10_000, wild=["WC-05", "WC-03"], anchor=10_000),
# S14 역행 금지(IMK 논의 재현) — 절충 카드(9,950) 뒤에 예산 상한 카드(NGC-007, 앵커 9,900)가
# 선택돼 있어도 발동하지 않는다(설득 폴백으로도 안 나감). 낼 카드가 없어져 종결(목표가 최후통첩)로.
Scenario("S14_no_offer_regression",
["10300", "예", "다른 가격 제시", "10200", "예", "수락", "확인"],
[("wild_card_dynamic", "WC-02", "9950"),
("가격협상_카운터", None, "10000")], # NGC-007 이벤트가 없어야 함(역행 차단)
settled=10_000, nego=["NGC-007"], wild=["WC-02"]),
]
@pytest.mark.asyncio
@pytest.mark.parametrize("sc", SCENARIOS, ids=[s.name for s in SCENARIOS])
async def test_negotiation_invariants(db_engine, sc: Scenario):
reset_sessions()
sid = _uuid.uuid4()
qid, ver_id = await _seed_quote_session(sid, sc.nego, wild_numbers=sc.wild,
target=sc.target, anchor=sc.anchor)
try:
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
eng = await reg.get_engine(str(_uuid.uuid4()))
svc = ChatService()
trace, settled, outcome = [], None, None
for ui in [*_BOILERPLATE, *sc.inputs]:
r = await svc.chat(eng, Req_Chat(session_id=str(sid), user_input=ui))
assert r.result.success is True, f"{sc.name}: 턴 실패 input={ui} msg={r.msg}"
trace.append(r)
if r.settled_price is not None:
settled = r.settled_price
if r.chat_end:
outcome = r.outcome
# ── 기대 이벤트(카드/카운터 턴) 정확 일치 ──
events = [r for r in trace if r.step in _CARD_STEPS]
got = [(r.step, r.card_id) for r in events]
assert len(events) == len(sc.events), f"{sc.name}: 이벤트 수 {got} ≠ 기대 {sc.events}"
for r, (step, card, offer) in zip(events, sc.events):
assert r.step == step, f"{sc.name}: step {r.step} ≠ {step} (전체 {got})"
if card == "NGC-*":
assert r.card_id and r.card_id.startswith("NGC-"), f"{sc.name}: 협상카드 기대, 실제 {r.card_id}"
else:
assert r.card_id == card, f"{sc.name}: card {r.card_id} ≠ {card} (전체 {got})"
if offer is not None:
assert offer in (r.script or ""), f"{sc.name}: 멘트에 금액 {offer} 없음 — {r.script[:80]}"
# ── 공통 불변식 ──
played = [r.card_id for r in events if r.card_id]
assert len(played) == len(set(played)), f"{sc.name}: 카드 중복 발동 {played}"
for r in events:
if r.card_id in _CLOSING_WILDS:
assert r.step == "가격협상_카운터", f"{sc.name}: 종결 카드 {r.card_id}가 중반({r.step})에 발동"
if r.card_id in _NONCLOSING_WILDS:
assert r.step == "wild_card_dynamic", f"{sc.name}: 비종결 와일드 {r.card_id}가 {r.step}에서 발동"
# ── 결말 ──
if sc.settled is None:
assert outcome == "failure" and settled is None, f"{sc.name}: 결렬 기대, settled={settled} outcome={outcome}"
else:
assert outcome == "success", f"{sc.name}: 타결 기대, outcome={outcome}"
assert settled == sc.settled, f"{sc.name}: 타결가 {settled} ≠ 기대 {sc.settled}"
assert settled <= sc.target, f"{sc.name}: 목표가 초과 타결 {settled} > {sc.target}"
finally:
await _cleanup(sid, qid, ver_id)

View File

@ -35,7 +35,7 @@ async def test_two_tenants_distinct_engines():
assert e1.mapper.get_card_id(0) == "NGC-001" assert e1.mapper.get_card_id(0) == "NGC-001"
assert e2.mapper.get_card_id(0) == "NGC-B001" assert e2.mapper.get_card_id(0) == "NGC-B001"
# 차원 # 차원
assert e1.state_space_size == 162 and e1.action_space_size == 11 assert e1.state_space_size == 162 and e1.action_space_size == 9 # 카탈로그 9장(NGC-006·009 소프트삭제)
@pytest.mark.asyncio @pytest.mark.asyncio
@ -69,7 +69,7 @@ async def test_unregistered_company_id_auto_onboards():
reg = _registry() reg = _registry()
# 미등록 company_id(uuid)는 _base 자동 온보딩 → 엔진 생성됨(베이스 9카드, 162 state). # 미등록 company_id(uuid)는 _base 자동 온보딩 → 엔진 생성됨(베이스 9카드, 162 state).
eng = await reg.get_engine("00000000-0000-0000-0000-000000000001") eng = await reg.get_engine("00000000-0000-0000-0000-000000000001")
assert eng.action_space_size == 11 and eng.state_space_size == 162 assert eng.action_space_size == 9 and eng.state_space_size == 162 # DB 카탈로그 9장
assert eng.company_id == "00000000-0000-0000-0000-000000000001" assert eng.company_id == "00000000-0000-0000-0000-000000000001"
assert reg.is_registered("imarketkorea") is True assert reg.is_registered("imarketkorea") is True
# 빈 키만 미등록 → KeyError # 빈 키만 미등록 → KeyError

View File

@ -69,27 +69,27 @@ async def test_cold_start_creates_warmstart_version(db_engine):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_catalog_dim_change_migrates_preserving_learning(db_engine): async def test_catalog_dim_change_migrates_preserving_learning(db_engine):
"""카탈로그 카드 수 변경(9→11) 시 학습 보존 마이그레이션 — 겹치는 셀 복사 + 새 카드 fresh.""" """카탈로그 카드 수 변경(7→9) 시 학습 보존 마이그레이션 — 겹치는 셀 복사 + 새 카드 fresh."""
import uuid as _uuid import uuid as _uuid
cid = str(_uuid.uuid4()) cid = str(_uuid.uuid4())
# 이 회사 활성 버전을 A=9 로 시드 + 셀 (5,2)=0.9 # 이 회사 활성 버전을 A=7 로 시드 + 셀 (5,2)=0.9
repo = LearningRepository(cid) repo = LearningRepository(cid)
vid = await repo.get_or_create_active_version( vid = await repo.get_or_create_active_version(
state_space_size=162, action_space_size=9, learning_rate=0.1, discount_factor=0.95, state_space_size=162, action_space_size=7, learning_rate=0.1, discount_factor=0.95,
scope=2, version_name="old_v9") scope=2, version_name="old_v7")
await repo.upsert_cell(vid, state_index=5, action_id=2, q_value=0.9, count=7) await repo.upsert_cell(vid, state_index=5, action_id=2, q_value=0.9, count=7)
# 엔진(_base type:db → 카탈로그 11장) 로드 → 9≠11 감지 → 마이그레이션 # 엔진(_base type:db → 카탈로그 9장) 로드 → 7≠9 감지 → 마이그레이션
eng = await _reg().get_engine(cid) eng = await _reg().get_engine(cid)
assert eng.action_space_size == 11 assert eng.action_space_size == 9
policy, new_vid, _ = await QTablePolicyStore.load(eng) policy, new_vid, _ = await QTablePolicyStore.load(eng)
assert str(new_vid) != str(vid) # 새 버전 assert str(new_vid) != str(vid) # 새 버전
assert policy.qtable.q[5, 2] == 0.9 # 기존 학습 보존 assert policy.qtable.q[5, 2] == 0.9 # 기존 학습 보존
assert policy.qtable.q[5, 10] == 0.0 # 새 카드(action 10) fresh assert policy.qtable.q[5, 8] == 0.0 # 새 카드(action 8) fresh
assert policy.qtable.visits[5, 2] == 7 # 방문수도 보존 assert policy.qtable.visits[5, 2] == 7 # 방문수도 보존
# 새 버전이 활성 · 차원 11 # 새 버전이 활성 · 차원 11
err, active = await repo.read(lambda s: repo.get_active_version(s)) err, active = await repo.read(lambda s: repo.get_active_version(s))
assert str(active.version_id) == str(new_vid) and active.action_space_size == 11 assert str(active.version_id) == str(new_vid) and active.action_space_size == 9
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@ -42,7 +42,10 @@ def test_requote_structure_preserved():
for key in ["서비스안내", "가격제안", "배송형태선택", "가격협상_확인", "결과안내", "결과제출", "협상종료"]: for key in ["서비스안내", "가격제안", "배송형태선택", "가격협상_확인", "결과안내", "결과제출", "협상종료"]:
assert key in s assert key in s
assert s["배송형태선택"]["next_input_mode"] == "delivery_type" assert s["배송형태선택"]["next_input_mode"] == "delivery_type"
assert s["배송형태선택"]["input_options"] == ["협력사배송", "지정택배배송", "픽업배송"] # 리소스 원본은 회사 용어 토큰({label_*}) — 렌더 시 회사 라벨(없으면 기본값)로 치환된다.
assert s["배송형태선택"]["input_options"] == [
"{label_delivery_type_1}", "{label_delivery_type_2}", "{label_delivery_type_3}",
]
def test_wildcard_present_and_merged(): def test_wildcard_present_and_merged():
@ -170,3 +173,24 @@ async def test_resolve_card_script_prefer_db_for_selected_cards(monkeypatch):
out = await repo.resolve_card_script(1, "2", {"input_price": 10200}, prefer_db=True) out = await repo.resolve_card_script(1, "2", {"input_price": 10200}, prefer_db=True)
assert out == "선택 카드 DB 멘트 **10200원**" assert out == "선택 카드 DB 멘트 **10200원**"
def test_option_label_tokens_rendered():
"""검증: 옵션에 회사 용어 토큰({label_delivery_type_*})이 있는 스텝을 정상 렌더·에러 재렌더로 출력.
기대결과: 두 경로 모두 버튼 문자열이 기본 라벨(협력사배송 등)로 치환되고 토큰이 남지 않는다."""
import os
from negotiation.chat.service.chat_engine import ChatEngine, ChatSession
from tenancy.config_loader import TenantConfigLoader
tenants = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
cfg = TenantConfigLoader(tenants_dir=tenants, cache_ttl_seconds=0).load("_base")
engine = ChatEngine(ScriptRepository(cfg, tenants), rq_type="재견적")
session = ChatSession(session_id="s", tenant_id="_base", company_id="_base")
view = engine.render_step(session, "배송형태선택")
assert view.input_options == ["협력사배송", "지정택배배송", "픽업배송"]
# 에러 재렌더(잘못된 입력 등)도 같은 치환을 타야 한다 — raw 옵션이면 토큰이 버튼에 노출된다.
err_view = engine._error(session, "다시 선택해 주세요.")
assert err_view.input_options == ["협력사배송", "지정택배배송", "픽업배송"]

View File

@ -173,6 +173,7 @@ class quotations(MAIN_BASE):
preferred_sp_yn = Column(Boolean, nullable=True) # 선호 공급사 지정 여부 preferred_sp_yn = Column(Boolean, nullable=True) # 선호 공급사 지정 여부
preferred_sp_id = Column(UUID(as_uuid=True), nullable=True) # 선호 공급사(partner.suppliers.supplier_id) preferred_sp_id = Column(UUID(as_uuid=True), nullable=True) # 선호 공급사(partner.suppliers.supplier_id)
preferred_sp_name = Column(String(20), nullable=True) # 선호 공급사명(스냅샷) preferred_sp_name = Column(String(20), nullable=True) # 선호 공급사명(스냅샷)
close_reason = Column(SmallInteger, nullable=True) # 마감 사유(CloseReason). 재협상 요청 자격 판정에 읽는다
equal_bid_yn = Column(Boolean, nullable=True) # 동일가 입찰 발생 여부 equal_bid_yn = Column(Boolean, nullable=True) # 동일가 입찰 발생 여부
equal_bid_data = Column(JSONB, nullable=True) # 동일가 입찰 상세(JSON) equal_bid_data = Column(JSONB, nullable=True) # 동일가 입찰 상세(JSON)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC) created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
@ -180,6 +181,28 @@ class quotations(MAIN_BASE):
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
class notifications(MAIN_BASE):
# company.notifications (담당자 인박스). 포털은 재협상 요청 알림을 만들기 위해서만 쓴다(조회는 negodata).
# company 스키마 전용 DBType 이 없어 USER 커넥션을 재사용한다(물리 DB 동일).
@staticmethod
def DBType():
return DBType.USER.value
__tablename__ = "notifications"
__table_args__ = {"schema": "company"}
notification_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
user_id = Column(UUID(as_uuid=True), nullable=False) # 수신자(company.users.user_id) = 견적 작성자
type = Column(SmallInteger, nullable=False) # NotificationType
ref_qt_id = Column(UUID(as_uuid=True), nullable=True)
ref_session_id = Column(UUID(as_uuid=True), nullable=True)
data = Column(JSONB, nullable=True) # 렌더 스냅샷(공급사명·사유·희망가 등)
read_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"))
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')"))
deleted = Column(Boolean, nullable=False, server_default=text("false"))
class quotation_settings(MAIN_BASE): class quotation_settings(MAIN_BASE):
# quotation.quotation_settings (견적 설정). 견적 설정 스냅샷 — anchoring_value 는 구(舊) 앵커 산출용으로 채팅 경로에서는 더 이상 사용하지 않음(앵커는 sessions.anchoring_price 박제값). # quotation.quotation_settings (견적 설정). 견적 설정 스냅샷 — anchoring_value 는 구(舊) 앵커 산출용으로 채팅 경로에서는 더 이상 사용하지 않음(앵커는 sessions.anchoring_price 박제값).
@staticmethod @staticmethod

View File

@ -130,6 +130,41 @@ class QuotationStatus(Enum):
CLOSED = 3 # 견적마감 CLOSED = 3 # 견적마감
class CloseReason(Enum):
"""견적 마감 사유. quotation.quotations.close_reason
낙찰(AWARDED) 외 OPEN_* 는 낙찰자 미정으로 마감된 '결렬' 건 — 공급사 재협상 요청 대상."""
AWARDED = 1 # 낙찰
OPEN_PRICE = 5 # 개찰: 낙찰 기준 미달
OPEN_EQUAL = 6 # 개찰: 동가
OPEN_NOSHOW = 7 # 개찰: 전원 미응찰
OPEN_REJECT = 8 # 개찰: 협상거부 존재
# 재협상 요청 가능한 마감 사유(낙찰 건은 제외).
RENEGOTIABLE_CLOSE_REASONS = (
CloseReason.OPEN_PRICE.value,
CloseReason.OPEN_EQUAL.value,
CloseReason.OPEN_NOSHOW.value,
CloseReason.OPEN_REJECT.value,
)
class RenegotiationStatus(Enum):
"""sessions.custom.renegotiation.status — 공급사 재협상 요청 상태(IMK #15)."""
PENDING = 1 # 접수, 담당자 심사 대기
APPROVED = 2 # 승인 — 다음 라운드 생성됨
REJECTED = 3 # 반려
CANCELED = 4 # 공급사 철회
class NotificationType(Enum):
"""company.notifications.type — negodata 담당자 인박스. 포털에서 만드는 건 재협상 요청뿐."""
RENEGO_REQUESTED = 5
class ChatSender(Enum): class ChatSender(Enum):
"""채팅 발신자 코드. negotiation.chats.sender """ """채팅 발신자 코드. negotiation.chats.sender """

View File

@ -1,24 +1,40 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Tuple from typing import Optional, Tuple
from sqlalchemy import case, func, nulls_last, select, update from sqlalchemy import and_, case, cast, func, nulls_last, or_, select, text, update
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import items, quotations, sessions from common.database.model.models import chats, items, quotations, sessions
from common.enums import ErrorType, SessionStatus from common.enums import CloseReason, ErrorType, QuotationStatus, RENEGOTIABLE_CLOSE_REASONS, SessionStatus
from common.logger import LOG from common.logger import LOG
# 협상 세션 CRUD. 목록은 세션(negotiation) ⨝ 상품(partner) ⨝ 견적(quotation) 조인으로 만든다. # 협상 세션 CRUD. 목록은 세션(negotiation) ⨝ 상품(partner) ⨝ 견적(quotation) 조인으로 만든다.
# 마감일(qt_end_time)은 견적(quotation.end_time)이 진실값이다(session.end_time 은 협상 종료 시점 기록용). # 마감일(qt_end_time)은 견적(quotation.end_time)이 진실값이다(session.end_time 은 협상 종료 시점 기록용).
def _effective_status():
"""표시용 세션 상태. 견적이 마감됐거나 마감시간이 지났으면 협상생성(1)은 더 참여할 수 없으므로 미참여(4)로 본다.
참여/채팅진입이 진입 시점에 하는 전이(negotiation_service._load_actionable_session, chat_service.init)와 같은 규칙을
목록에서는 쓰기 없이 파생으로만 맞춘다. 마감 일괄정리 이후에 만들어진 세션도 '협상 대기'로 남지 않는다.
"""
ended = or_(quotations.status == QuotationStatus.CLOSED.value, quotations.end_time < func.now())
return case(
(and_(sessions.status == SessionStatus.CREATED.value, ended), SessionStatus.NOT_PARTICIPATED.value),
else_=sessions.status,
)
class ISessionCRUD(ABC): class ISessionCRUD(ABC):
@abstractmethod @abstractmethod
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit) -> Tuple[ErrorType, list]: async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit, keyword=None, result=None) -> Tuple[ErrorType, list]:
pass pass
@abstractmethod @abstractmethod
async def count_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type) -> Tuple[ErrorType, int]: async def count_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, keyword=None, result=None) -> Tuple[ErrorType, int]:
pass pass
@abstractmethod @abstractmethod
@ -38,27 +54,56 @@ class ISessionCRUD(ABC):
pass pass
@abstractmethod @abstractmethod
async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType: async def update_session_reject(
self, cdb: AsyncSession, session_id, status: int, reject_reason: str, reject_price: Optional[int] = None,
) -> ErrorType:
pass pass
@abstractmethod @abstractmethod
async def update_session_custom(self, cdb: AsyncSession, session_id, supplier_id, custom: dict) -> ErrorType: async def update_session_custom(self, cdb: AsyncSession, session_id, supplier_id, custom: dict) -> ErrorType:
pass pass
@abstractmethod
async def merge_session_custom(self, cdb: AsyncSession, session_id, supplier_id, patch: dict) -> ErrorType:
pass
@abstractmethod
async def chain_max_round(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, int]:
pass
class SessionCRUD(ISessionCRUD): class SessionCRUD(ISessionCRUD):
@staticmethod @staticmethod
def __filters(supplier_id, status, qt_type): def __filters(supplier_id, status, qt_type, keyword=None, result=None):
conds = [sessions.supplier_id == supplier_id, sessions.deleted == False] # noqa: E712 conds = [sessions.supplier_id == supplier_id, sessions.deleted == False] # noqa: E712
if status is not None: if status is not None:
conds.append(sessions.status == status) # 표시 상태로 필터 — 탭/KPI 카운트가 목록 배지와 어긋나지 않게 파생값을 그대로 쓴다.
conds.append(_effective_status() == status)
if qt_type is not None: if qt_type is not None:
conds.append(sessions.qt_type == qt_type) conds.append(sessions.qt_type == qt_type)
# 검색: 견적번호·상품명·상품코드 부분일치(대소문자 무시). items 는 목록/카운트 둘 다 조인돼 있다.
# ILIKE 와일드카드(%,_)는 escape 해 사용자 입력이 패턴으로 새지 않게 한다.
if keyword and keyword.strip():
kw = keyword.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
like = f"%{kw}%"
conds.append(or_(sessions.qt_number.ilike(like), items.name.ilike(like), items.code.ilike(like)))
# 결과(SessionResult) 필터 — _to_result 파생 규칙을 SQL WHERE 로 그대로 복제(집계·필터 일치용).
# 1=낙찰 2=미낙찰 3=결렬(개찰). 전부 견적 마감(CLOSED) 이 전제.
if result in (1, 2, 3):
conds.append(quotations.status == QuotationStatus.CLOSED.value)
if result == 1:
conds.append(quotations.close_reason == CloseReason.AWARDED.value)
conds.append(quotations.preferred_sp_id == sessions.supplier_id)
elif result == 2:
conds.append(quotations.close_reason == CloseReason.AWARDED.value)
conds.append(or_(quotations.preferred_sp_id.is_(None), quotations.preferred_sp_id != sessions.supplier_id))
else:
conds.append(quotations.close_reason.in_(RENEGOTIABLE_CLOSE_REASONS))
return conds return conds
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit) -> Tuple[ErrorType, list]: async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit, keyword=None, result=None) -> Tuple[ErrorType, list]:
try: try:
conds = self.__filters(supplier_id, status, qt_type) conds = self.__filters(supplier_id, status, qt_type, keyword, result)
# 정렬 규칙: # 정렬 규칙:
# - order 를 명시(asc/desc)하면 그룹 구분 없이 전체를 마감 기준 한 줄로 정렬(전체 정렬). # - order 를 명시(asc/desc)하면 그룹 구분 없이 전체를 마감 기준 한 줄로 정렬(전체 정렬).
@ -73,7 +118,7 @@ class SessionCRUD(ISessionCRUD):
else: else:
# 그룹별로 정렬 방향이 달라, case 로 '자기 그룹 행만 end_time' 을 갖는 키를 만들고 # 그룹별로 정렬 방향이 달라, case 로 '자기 그룹 행만 end_time' 을 갖는 키를 만들고
# 반대 그룹은 NULL 로 눌러 간섭을 없앤다. status_rank 가 1차 키라 그룹 경계는 항상 유지. # 반대 그룹은 NULL 로 눌러 간섭을 없앤다. status_rank 가 1차 키라 그룹 경계는 항상 유지.
actionable = sessions.status.in_((SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value)) actionable = _effective_status().in_((SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value))
status_rank = case((actionable, 0), else_=1) status_rank = case((actionable, 0), else_=1)
action_order = case((actionable, quotations.end_time), else_=None).asc() action_order = case((actionable, quotations.end_time), else_=None).asc()
done_order = case((~actionable, quotations.end_time), else_=None).desc() done_order = case((~actionable, quotations.end_time), else_=None).desc()
@ -82,7 +127,7 @@ class SessionCRUD(ISessionCRUD):
query = ( query = (
select( select(
sessions.session_id, sessions.session_id,
sessions.status, _effective_status(), # 마감 후 남은 협상생성은 미참여로 내린다
sessions.qt_type, sessions.qt_type,
sessions.qt_number, sessions.qt_number,
quotations.end_time, # qt_end_time = 견적 마감 시각 quotations.end_time, # qt_end_time = 견적 마감 시각
@ -91,6 +136,16 @@ class SessionCRUD(ISessionCRUD):
items.model_name, items.model_name,
items.manufacturer, items.manufacturer,
sessions.custom, sessions.custom,
quotations.status, # 재협상 요청 자격 판정용(마감 여부)
quotations.close_reason, # 개찰(결렬) 사유
quotations.round,
quotations.preferred_sp_id, # 낙찰자(공급사) — 나와 같으면 낙찰, 다르면 미낙찰
sessions.supplier_id, # 이 세션 소유 공급사(=조회자). 낙찰자와 대조
# 대화 이력 유무 — 종료된 협상의 '결과 보기'(열람) 버튼을 띄울지 판단용. 열 게 없으면 프론트가 감춘다.
select(1).where(chats.session_id == sessions.session_id, chats.deleted == False).exists(), # noqa: E712
# 거부 건이 제출한 사유·희망가 — 목록의 '거부 내역' 열람용(의견은 custom.opinion).
sessions.reject_reason,
sessions.reject_price,
) )
.join(items, items.item_id == sessions.item_id) .join(items, items.item_id == sessions.item_id)
.join(quotations, quotations.qt_id == sessions.quotation_id) .join(quotations, quotations.qt_id == sessions.quotation_id)
@ -107,9 +162,9 @@ class SessionCRUD(ISessionCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [] return ErrorType.DB_RUN_FAILED, []
async def count_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type) -> Tuple[ErrorType, int]: async def count_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, keyword=None, result=None) -> Tuple[ErrorType, int]:
try: try:
conds = self.__filters(supplier_id, status, qt_type) conds = self.__filters(supplier_id, status, qt_type, keyword, result)
query = ( query = (
select(func.count()) select(func.count())
.select_from(sessions) .select_from(sessions)
@ -167,12 +222,45 @@ class SessionCRUD(ISessionCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED return ErrorType.DB_RUN_FAILED
async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType: async def update_session_reject(
self, cdb: AsyncSession, session_id, status: int, reject_reason: str, reject_price: Optional[int] = None,
) -> ErrorType:
try: try:
values = {"status": status, "reject_reason": reject_reason}
# 공급 희망 가격은 선택 입력이라 안 들어올 수 있다 — 그때는 컬럼을 건드리지 않는다.
if reject_price is not None:
values["reject_price"] = reject_price
query = ( query = (
update(sessions) update(sessions)
.where(sessions.session_id == session_id) .where(sessions.session_id == session_id)
.values(status=status, reject_reason=reject_reason) .values(**values)
)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def chain_max_round(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, int]:
# 같은 견적번호(체인)의 최대 차수. 이미 다음 라운드가 있으면 재협상 요청은 의미가 없다.
try:
query = select(func.max(quotations.round)).where(quotations.number == number, quotations.deleted == False) # noqa: E712
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, 0
# 단일 컬럼 select 는 scalars() 로 내려와 rows 가 값 리스트다(행 튜플이 아님).
top = rows[0] if rows else None
return ErrorType.SUCCESS, int(top or 0)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def merge_session_custom(self, cdb: AsyncSession, session_id, supplier_id, patch: dict) -> ErrorType:
# sessions.custom 부분 갱신(기존 키 보존). 부가정보와 재협상 요청이 같은 컬럼을 쓰므로 덮어쓰면 안 된다.
try:
query = (
update(sessions)
.where(sessions.session_id == session_id, sessions.supplier_id == supplier_id)
.values(custom=func.coalesce(sessions.custom, cast(text("'{}'"), JSONB)).op("||")(cast(patch, JSONB)))
) )
return await DB_SESSION_MNG.add(cdb, query) return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex: except Exception as ex:

View File

@ -50,6 +50,7 @@ class Res_Me(Res_WebPacketProtocol):
role: int = Field(0, description="권한 코드 1=user, 2=manager (UserRole)") role: int = Field(0, description="권한 코드 1=user, 2=manager (UserRole)")
branding: dict = Field(default_factory=dict, description="소속 회사 브랜딩(companies.settings.branding). 서비스명/로고/색") branding: dict = Field(default_factory=dict, description="소속 회사 브랜딩(companies.settings.branding). 서비스명/로고/색")
session_fields: list = Field(default_factory=list, description="협상완료 부가정보 필드 정의(companies.settings.session_fields). 공급사가 타결 후 입력") session_fields: list = Field(default_factory=list, description="협상완료 부가정보 필드 정의(companies.settings.session_fields). 공급사가 타결 후 입력")
guide_notices: list = Field(default_factory=list, description="협상 유의사항 항목(companies.settings.guide_notices). 빈 값이면 포털 기본 문구")
class Res_Logout(Res_WebPacketProtocol): class Res_Logout(Res_WebPacketProtocol):
@ -71,4 +72,4 @@ class Res_HidePopup(Res_WebPacketProtocol):
class Res_SessionBranding(Res_WebPacketProtocol): class Res_SessionBranding(Res_WebPacketProtocol):
service_name: str = Field("", description="회사 서비스명(companies.settings.branding.service_name). 미설정 시 빈 값") service_name: str = Field("", description="회사 서비스명(companies.settings.branding.service_name). 미설정 시 빈 값")
logo_url: str = Field("", description="회사 로고 URL") logo_url: str = Field("", description="회사 로고 URL")
primary_color: str = Field("", description="브랜드 색상(hex)") helpdesk: list = Field(default_factory=list, description="헬프데스크 연락처 줄 목록(companies.settings.branding.helpdesk). 한 줄 = 담당자 한 명")

View File

@ -63,6 +63,7 @@ class Res_ChatInit(Res_WebPacketProtocol):
session_id: str = Field("", description="협상 세션 uuid") session_id: str = Field("", description="협상 세션 uuid")
session_status: int = Field(0, description="세션 상태 코드 (SessionStatus: 1=생성 2=진행중 3=완료 4=미참여 5=거부)") session_status: int = Field(0, description="세션 상태 코드 (SessionStatus: 1=생성 2=진행중 3=완료 4=미참여 5=거부)")
quotation_id: str = Field("", description="소속 견적 uuid") quotation_id: str = Field("", description="소속 견적 uuid")
qt_number: str = Field("", description="견적번호(EST-...)")
quotation_end_time: str = Field("", description="견적 마감 시각 (ISO 8601, 타이머용)") quotation_end_time: str = Field("", description="견적 마감 시각 (ISO 8601, 타이머용)")
quotation_memo: str = Field("", description="견적 메모") quotation_memo: str = Field("", description="견적 메모")
item_id: str = Field("", description="상품 uuid") item_id: str = Field("", description="상품 uuid")
@ -78,6 +79,9 @@ class Res_ChatInit(Res_WebPacketProtocol):
item_vat_yn: Optional[bool] = Field(None, description="VAT 포함 여부(미설정 시 null)") item_vat_yn: Optional[bool] = Field(None, description="VAT 포함 여부(미설정 시 null)")
item_delivery_fee_yn: Optional[bool] = Field(None, description="배송비 포함 여부(미설정 시 null)") item_delivery_fee_yn: Optional[bool] = Field(None, description="배송비 포함 여부(미설정 시 null)")
custom: dict = Field(default_factory=dict, description="협상완료 부가정보 기존 입력값(sessions.custom). 재진입 시 폼 프리필용") custom: dict = Field(default_factory=dict, description="협상완료 부가정보 기존 입력값(sessions.custom). 재진입 시 폼 프리필용")
reject_reason: str = Field("", description="협상 거부 시 제출한 사유. 거부 건이 아니면 빈 문자열")
reject_price: Optional[int] = Field(None, description="협상 거부 시 함께 낸 공급 희망 가격(원). 미입력이면 null")
labels: dict = Field(default_factory=dict, description="회사 커스텀 라벨(companies.settings.labels). 상품 상세 필드명(예: lead_time) 치환용. 없으면 프론트 기본값")
# 대화 히스토리(재진입 복원) # 대화 히스토리(재진입 복원)

View File

@ -1,3 +1,5 @@
from typing import Optional
from pydantic import Field from pydantic import Field
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
@ -15,6 +17,13 @@ class ListItem(WebPacketProtocol):
model_name: str = Field("", description="모델명") model_name: str = Field("", description="모델명")
maker_name: str = Field("", description="제조사") maker_name: str = Field("", description="제조사")
custom: dict = Field(default_factory=dict, description="협상완료 부가정보 값(sessions.custom). 미입력이면 빈 dict") custom: dict = Field(default_factory=dict, description="협상완료 부가정보 값(sessions.custom). 미입력이면 빈 dict")
renegotiable: bool = Field(False, description="재협상 요청 가능 여부 — 낙찰 없이 마감(개찰)된 마지막 차수이고 대기 중 요청이 없을 때만 True")
renegotiation_status: int = Field(0, description="현재 재협상 요청 상태(RenegotiationStatus). 요청 이력이 없으면 0")
renegotiation_memo: str = Field("", description="담당자 심사 메모(반려 사유). 없으면 빈 문자열")
result: int = Field(0, description="공급사 관점 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰, 재협상 대상)")
has_chat: bool = Field(False, description="대화 이력 존재 여부 — 종료된 협상(미참여·거부)의 '결과 보기' 노출 판단용")
reject_reason: str = Field("", description="협상 거부 시 제출한 사유. 거부 건이 아니면 빈 문자열")
reject_price: Optional[int] = Field(None, description="협상 거부 시 함께 낸 공급 희망 가격(원). 미입력이면 null")
class Res_SessionList(Res_WebPacketProtocol): class Res_SessionList(Res_WebPacketProtocol):
@ -30,6 +39,8 @@ class Res_Participate(Res_WebPacketProtocol):
class Req_Reject(WebPacketProtocol): class Req_Reject(WebPacketProtocol):
reject_reason: str = Field("", max_length=255, description="거부 사유 (단종/품절 프리셋 라벨 또는 직접 입력)") reject_reason: str = Field("", max_length=255, description="거부 사유 (단종/품절 프리셋 라벨 또는 직접 입력)")
reject_price: Optional[int] = Field(None, description="공급 희망 가격(원). 선택 입력 — 없으면 컬럼 미변경")
opinion: Optional[str] = Field(None, max_length=255, description="추가 의견 — sessions.custom.opinion 에 병합")
class Res_Reject(Res_WebPacketProtocol): class Res_Reject(Res_WebPacketProtocol):
@ -42,3 +53,13 @@ class Req_ExtraInfo(WebPacketProtocol):
class Res_ExtraInfo(Res_WebPacketProtocol): class Res_ExtraInfo(Res_WebPacketProtocol):
session_id: str = Field("", description="부가정보 저장된 세션 uuid") session_id: str = Field("", description="부가정보 저장된 세션 uuid")
class Req_Renegotiation(WebPacketProtocol):
reason: str = Field("", max_length=255, description="재협상 요청 사유(프리셋 라벨 또는 직접 입력)")
desired_price: Optional[int] = Field(None, description="희망 공급가(원). 담당자 판단 근거로만 쓰인다")
class Res_Renegotiation(Res_WebPacketProtocol):
session_id: str = Field("", description="요청이 기록된 세션 uuid")
status: int = Field(0, description="요청 상태(RenegotiationStatus): 1=심사중 2=승인 3=반려 4=철회")

View File

@ -6,7 +6,16 @@ from fastapi.security import HTTPAuthorizationCredentials
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, security from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, security
from services.negotiation_service import NegotiationService from services.negotiation_service import NegotiationService
from .protocol import Req_ExtraInfo, Req_Reject, Res_ExtraInfo, Res_Participate, Res_Reject, Res_SessionList from .protocol import (
Req_ExtraInfo,
Req_Reject,
Req_Renegotiation,
Res_ExtraInfo,
Res_Participate,
Res_Reject,
Res_Renegotiation,
Res_SessionList,
)
router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation"], responses={404: {"description": "Not found"}}) router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation"], responses={404: {"description": "Not found"}})
@ -26,9 +35,11 @@ async def list_sessions(
order: Optional[str] = Query(None, description="마감일 전체 정렬: asc(임박순)/desc(여유순). 미지정 시 기본 그룹 정렬('할 일' 우선 → 종료는 하단·최근순). 지정하면 그룹 없이 전체를 마감 기준으로 정렬."), order: Optional[str] = Query(None, description="마감일 전체 정렬: asc(임박순)/desc(여유순). 미지정 시 기본 그룹 정렬('할 일' 우선 → 종료는 하단·최근순). 지정하면 그룹 없이 전체를 마감 기준으로 정렬."),
page: int = Query(1, ge=1, description="페이지 (1부터)"), page: int = Query(1, ge=1, description="페이지 (1부터)"),
page_size: int = Query(20, ge=1, le=100, description="페이지당 건수 (1~100)"), page_size: int = Query(20, ge=1, le=100, description="페이지당 건수 (1~100)"),
keyword: Optional[str] = Query(None, description="검색어 — 견적번호·상품명·상품코드 부분일치(대소문자 무시)"),
result: Optional[int] = Query(None, description="결과 필터(SessionResult): 1=낙찰 2=미낙찰 3=결렬(개찰). 미지정 시 전체"),
): ):
return RemoveNoneResponse( return RemoveNoneResponse(
await service.list_sessions(user_info, credentials.credentials, status, qt_type, order, page, page_size) await service.list_sessions(user_info, credentials.credentials, status, qt_type, order, page, page_size, keyword, result)
) )
@ -51,7 +62,7 @@ async def participate(
path="/sessions/{session_id}/reject", path="/sessions/{session_id}/reject",
response_model=Res_Reject, response_model=Res_Reject,
summary="협상 거부", summary="협상 거부",
description="세션 참여를 거부한다. 소유(공급사)·세션상태(완료/미참여/거부 불가)·견적마감·마감시간 검증 후 협상거부로 전이하고 사유를 저장.", description="세션 참여를 거부하거나 진행 중인 협상을 거부한다. 소유(공급사)·세션상태(완료/미참여/거부 불가)·견적마감·마감시간 검증 후 협상거부로 전이하고 사유·공급 희망 가격·의견을 저장.",
) )
async def reject( async def reject(
session_id: str = Path(description="대상 협상 세션 uuid"), session_id: str = Path(description="대상 협상 세션 uuid"),
@ -60,7 +71,11 @@ async def reject(
credentials: HTTPAuthorizationCredentials = Depends(security), credentials: HTTPAuthorizationCredentials = Depends(security),
service: NegotiationService = Depends(), service: NegotiationService = Depends(),
): ):
return RemoveNoneResponse(await service.reject(user_info, credentials.credentials, session_id, req.reject_reason)) return RemoveNoneResponse(
await service.reject(
user_info, credentials.credentials, session_id, req.reject_reason, req.reject_price, req.opinion,
)
)
@router.post( @router.post(
@ -77,3 +92,38 @@ async def save_extra_info(
service: NegotiationService = Depends(), service: NegotiationService = Depends(),
): ):
return RemoveNoneResponse(await service.save_extra_info(user_info, credentials.credentials, session_id, req)) return RemoveNoneResponse(await service.save_extra_info(user_info, credentials.credentials, session_id, req))
@router.post(
path="/session/{session_id}/renegotiation",
response_model=Res_Renegotiation,
summary="재협상 요청",
description="낙찰 없이 마감된(개찰) 건에 대해 공급사가 재협상을 요청한다. 담당자 승인 시 다음 라운드가 생성된다. 본인 공급사의 마지막 라운드 세션만 허용.",
)
async def request_renegotiation(
req: Req_Renegotiation,
session_id: str = Path(..., description="협상 세션 uuid"),
user_info: UserInfo = Depends(IsValidAccessToken),
credentials: HTTPAuthorizationCredentials = Depends(security),
service: NegotiationService = Depends(),
):
return RemoveNoneResponse(
await service.request_renegotiation(user_info, credentials.credentials, session_id, req)
)
@router.delete(
path="/session/{session_id}/renegotiation",
response_model=Res_Renegotiation,
summary="재협상 요청 철회",
description="심사 대기(PENDING) 중인 본인 요청을 철회한다.",
)
async def cancel_renegotiation(
session_id: str = Path(..., description="협상 세션 uuid"),
user_info: UserInfo = Depends(IsValidAccessToken),
credentials: HTTPAuthorizationCredentials = Depends(security),
service: NegotiationService = Depends(),
):
return RemoveNoneResponse(
await service.cancel_renegotiation(user_info, credentials.credentials, session_id)
)

View File

@ -260,6 +260,7 @@ class AuthService:
settings = settings or {} settings = settings or {}
res.branding = settings.get("branding") or {} res.branding = settings.get("branding") or {}
res.session_fields = settings.get("session_fields") or [] res.session_fields = settings.get("session_fields") or []
res.guide_notices = settings.get("guide_notices") or []
return res return res
async def session_branding(self, session_id: str) -> Res_SessionBranding: async def session_branding(self, session_id: str) -> Res_SessionBranding:
@ -278,7 +279,7 @@ class AuthService:
branding = branding or {} branding = branding or {}
res.service_name = branding.get("service_name") or "" res.service_name = branding.get("service_name") or ""
res.logo_url = branding.get("logo_url") or "" res.logo_url = branding.get("logo_url") or ""
res.primary_color = branding.get("primary_color") or "" res.helpdesk = branding.get("helpdesk") or []
return res return res
async def popup_status(self, user_info: UserInfo, access_token: str) -> Res_PopupStatus: async def popup_status(self, user_info: UserInfo, access_token: str) -> Res_PopupStatus:

View File

@ -24,6 +24,7 @@ from common.logger import LOG
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
from crud.chat_crud import ChatCRUD, IChatCRUD from crud.chat_crud import ChatCRUD, IChatCRUD
from crud.session_crud import ISessionCRUD, SessionCRUD from crud.session_crud import ISessionCRUD, SessionCRUD
from crud.user_crud import IUserCRUD, UserCRUD
from router.v1.chat.protocol import ChatMessage, ChatSummary, Res_ChatInit, Res_ChatMessages, Res_ChatSend from router.v1.chat.protocol import ChatMessage, ChatSummary, Res_ChatInit, Res_ChatMessages, Res_ChatSend
from services.agent_client import AgentChatContext, IAgentClient, get_agent_client from services.agent_client import AgentChatContext, IAgentClient, get_agent_client
from services.auth_service import AuthService from services.auth_service import AuthService
@ -44,11 +45,13 @@ class ChatService:
auth: AuthService = Depends(AuthService), auth: AuthService = Depends(AuthService),
session_crud: ISessionCRUD = Depends(SessionCRUD), session_crud: ISessionCRUD = Depends(SessionCRUD),
chat_crud: IChatCRUD = Depends(ChatCRUD), chat_crud: IChatCRUD = Depends(ChatCRUD),
user_crud: IUserCRUD = Depends(UserCRUD),
agent: IAgentClient = Depends(get_agent_client), agent: IAgentClient = Depends(get_agent_client),
): ):
self.auth = auth self.auth = auth
self.session_crud = session_crud self.session_crud = session_crud
self.chat_crud = chat_crud self.chat_crud = chat_crud
self.user_crud = user_crud
self.agent = agent self.agent = agent
# ---- 순수 헬퍼/매퍼 (self 불필요, 상단 집약) ---- # ---- 순수 헬퍼/매퍼 (self 불필요, 상단 집약) ----
@ -59,6 +62,35 @@ class ChatService:
digits = "".join(ch for ch in text if ch.isdigit()) digits = "".join(ch for ch in text if ch.isdigit())
return int(digits) if digits else None return int(digits) if digits else None
@staticmethod
def _parse_reject(text: Optional[str]) -> dict:
"""통일 결렬 폼 제출 문자열 파싱 → {offer_price, reason, opinion}.
형식: '공급희망가격-{원}, 합의불가사유-{사유}, 의견-{의견}' (사유는 '기타-{내용}' 가능).
의견은 자유서술이라 콤마 포함 가능 → 맨 뒤 '의견-' 기준으로 먼저 떼어낸다."""
s = text or ""
opinion = None
if ", 의견-" in s:
s, opinion = s.split(", 의견-", 1)
# 폼이 아닌 자유 입력("협상 포기합니다" 등)은 원문이 곧 사유다. 폼 마커가 없으면 가격도 읽지 않는다
# — 문장에 섞인 숫자를 희망가로 오인해 저장하는 것을 막는다.
if "합의불가사유-" not in s and "공급희망가격-" not in s:
return {
"offer_price": None,
"reason": s.strip()[:255] or None,
"opinion": (opinion.strip() or None) if opinion is not None else None,
}
reason = None
if ", 합의불가사유-" in s:
price_part, reason = s.split(", 합의불가사유-", 1)
else:
price_part = s
price_digits = "".join(ch for ch in price_part.replace("공급희망가격-", "") if ch.isdigit())
return {
"offer_price": int(price_digits) if price_digits else None,
"reason": reason or None,
"opinion": (opinion.strip() or None) if opinion is not None else None,
}
@staticmethod @staticmethod
def _in_price_range(price: int, target_price: Optional[int]) -> bool: def _in_price_range(price: int, target_price: Optional[int]) -> bool:
if not target_price: if not target_price:
@ -203,17 +235,15 @@ class ChatService:
) )
sess.status = SessionStatus.NOT_PARTICIPATED.value sess.status = SessionStatus.NOT_PARTICIPATED.value
# 미참여/협상거부 상태는 진입(열람) 불가 (participate/reject 와 동일 규칙). # 미참여/협상거부 세션도 '결과 보기'로 지난 대화를 열람할 수 있다(중간 이탈·거부로 끝난 건).
# 위 마감 변환으로 미참여가 된 세션도 여기서 함께 막힌다. # 대화 재개는 send() 가 협상중(2)만 허용하므로 여기서 막지 않아도 읽기 전용이다.
if sess.status in (SessionStatus.NOT_PARTICIPATED.value, SessionStatus.REJECTED.value):
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
return res
await self._ensure_in_progress(sess, quote) await self._ensure_in_progress(sess, quote)
res.session_id = str(sess.session_id) res.session_id = str(sess.session_id)
res.session_status = sess.status res.session_status = sess.status
res.quotation_id = str(sess.quotation_id) res.quotation_id = str(sess.quotation_id)
res.qt_number = sess.qt_number or ""
res.quotation_end_time = quote.end_time.isoformat(timespec="seconds") if quote.end_time else "" res.quotation_end_time = quote.end_time.isoformat(timespec="seconds") if quote.end_time else ""
res.quotation_memo = quote.memo or "" res.quotation_memo = quote.memo or ""
res.item_id = str(item.item_id) res.item_id = str(item.item_id)
@ -229,6 +259,35 @@ class ChatService:
res.item_vat_yn = item.vat_yn res.item_vat_yn = item.vat_yn
res.item_delivery_fee_yn = item.delivery_fee_yn res.item_delivery_fee_yn = item.delivery_fee_yn
res.custom = sess.custom or {} res.custom = sess.custom or {}
# 거부로 끝난 세션은 대화에 남지 않는 제출 내역(사유·희망가)을 열람용으로 함께 내린다.
res.reject_reason = sess.reject_reason or ""
res.reject_price = sess.reject_price
# 회사 커스텀 라벨(companies.settings.labels) — 상품 상세 필드명 치환용(예: lead_time→표준납기). 실패해도 빈 dict 폴백.
_e, settings = await DB_SESSION_MNG.execute_lambda(
suppliers.DBType(), DBWRType.DB_READ.value,
lambda s: self.user_crud.get_company_settings(s, sess.supplier_id),
)
res.labels = (settings.get("labels") or {}) if _e == ErrorType.SUCCESS and settings else {}
_hidden = (settings.get("hidden_fields") or []) if _e == ErrorType.SUCCESS and settings else []
_features = (settings.get("features") or {}) if _e == ErrorType.SUCCESS and settings else {}
# VAT 표기 — 부가세 전체 통일 회사(features.vat_mode)는 상품 잔존값과 무관하게 'VAT 별도' 고정(False).
# 상품별 관리 회사가 vat_yn 을 숨겼으면(구 방식) 표기 자체를 생략한다(값 null → 프론트 라벨 생략).
if _features.get("vat_mode") == "unified_excluded":
res.item_vat_yn = False
elif "vat_yn" in _hidden:
res.item_vat_yn = None
# 협상 기준가 — 회사 설정에서 고른 가격 컬럼(features.nego_baseline_field).
# agent 의 인하율 멘트(nego_context_crud._resolve_baseline)와 같은 규칙이어야 화면과 멘트가 어긋나지 않는다.
_baseline = _features.get("nego_baseline_field")
if _baseline not in ("price", "purchase_price"):
# 미설정 회사 폴백 — 공급가를 감췄으면 그 회사는 공급가를 관리하지 않는다는 뜻.
_baseline = "purchase_price" if ("price" in _hidden and "purchase_price" not in _hidden) else "price"
if _baseline == "purchase_price":
res.item_price = item.purchase_price or 0
return res return res
async def _ensure_in_progress(self, sess, quote) -> None: async def _ensure_in_progress(self, sess, quote) -> None:
@ -415,24 +474,40 @@ class ChatService:
# 유저 미입력 가격 타결 케이스 — 마지막 유저 제시가와 다를 수 있다). # 유저 미입력 가격 타결 케이스 — 마지막 유저 제시가와 다를 수 있다).
summary = await self._build_summary(sess, quote, item, final_price, turn.settled_price or last_price) summary = await self._build_summary(sess, quote, item, final_price, turn.settled_price or last_price)
# 카드 번호(turn.card_id) → UUID 변환. step 으로 nego/wild 갈라 각 테이블 조회(번호가 겹칠 수 있어 종류로 구분). # 카드 번호(turn.card_id) → UUID 변환. 번호 정본 표기(NGC-/WC- prefix)로 종류를 가르고,
# prefix 없는 구번호는 step 휴리스틱 폴백. 1차 조회가 비면 반대 테이블 재조회 —
# 종결 전술의 와일드카드는 step 이 '가격협상_카운터'(wild 미시작)라 step 만으론 카드가
# 영영 null 로 남았다(사용 카드 통계·화면 누락 원인).
# 카드 사용 로그(chats.card_id/type/used)를 negodata 조인용으로 남긴다. (1% 인하 시스템 카드는 agent 가 card_id 미제공) # 카드 사용 로그(chats.card_id/type/used)를 negodata 조인용으로 남긴다. (1% 인하 시스템 카드는 agent 가 card_id 미제공)
card_uuid = None card_uuid = None
card_type = None card_type = None
if turn.card_id: if turn.card_id:
is_wild = bool(turn.step and turn.step.startswith("wild")) number = str(turn.card_id)
if is_wild: if number.startswith("WC"):
card_uuid = await DB_SESSION_MNG.execute_lambda( wild_first = True
chats.DBType(), DBWRType.DB_READ.value, elif number.startswith("NGC"):
lambda s: self.chat_crud.get_wild_card_id_by_number(s, str(turn.card_id)), wild_first = False
)
card_type = 2
else: else:
card_uuid = await DB_SESSION_MNG.execute_lambda( wild_first = bool(turn.step and turn.step.startswith("wild"))
async def _lookup(wild: bool):
if wild:
found = await DB_SESSION_MNG.execute_lambda(
chats.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.get_wild_card_id_by_number(s, number),
)
return found, 2
found = await DB_SESSION_MNG.execute_lambda(
chats.DBType(), DBWRType.DB_READ.value, chats.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.get_nego_card_id_by_number(s, str(turn.card_id)), lambda s: self.chat_crud.get_nego_card_id_by_number(s, number),
) )
card_type = 1 return found, 1
card_uuid, card_type = await _lookup(wild_first)
if card_uuid is None:
card_uuid, card_type = await _lookup(not wild_first)
if card_uuid is None:
card_type = None
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션. # 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary, card_uuid=card_uuid, card_type=card_type) bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary, card_uuid=card_uuid, card_type=card_type)
@ -455,10 +530,15 @@ class ChatService:
funcs.append(lambda s: self.chat_crud.finalize_session(s, sess.session_id, new_status, bid_price=bid)) funcs.append(lambda s: self.chat_crud.finalize_session(s, sess.session_id, new_status, bid_price=bid))
else: else:
new_status = SessionStatus.REJECTED.value new_status = SessionStatus.REJECTED.value
parsed = self._parse_reject(user_input)
funcs.append(lambda s: self.chat_crud.finalize_session( funcs.append(lambda s: self.chat_crud.finalize_session(
s, sess.session_id, new_status, s, sess.session_id, new_status,
reject_reason=(user_input or None), reject_price=price, reject_reason=parsed["reason"], reject_price=parsed["offer_price"],
)) ))
if parsed["opinion"]:
funcs.append(lambda s, op=parsed["opinion"]: self.session_crud.merge_session_custom(
s, sess.session_id, sess.supplier_id, {"opinion": op},
))
err_type = await DB_SESSION_MNG.execute_lambda_run([chats.DBType()], funcs) err_type = await DB_SESSION_MNG.execute_lambda_run([chats.DBType()], funcs)
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
@ -568,7 +648,17 @@ class ChatService:
# 배송형태: 재견적(CM)의 '배송형태선택' 단계에서 공급사가 고른 라벨. 재협상엔 단계가 없어 None. # 배송형태: 재견적(CM)의 '배송형태선택' 단계에서 공급사가 고른 라벨. 재협상엔 단계가 없어 None.
delivery_label = await self._delivery_choice(sess) if sess.qt_type == 2 else None delivery_label = await self._delivery_choice(sess) if sess.qt_type == 2 else None
# 상품 기본 배송유형(코드→라벨). 선택값이 없으면 표시에 폴백으로 쓸 수 있다. # 상품 기본 배송유형(코드→라벨). 선택값이 없으면 표시에 폴백으로 쓸 수 있다.
item_delivery_label = DeliveryType.label_of(item.delivery_type) if item and item.delivery_type is not None else "" # 회사가 배송유형 보기를 자기 용어로 바꿨으면(settings.labels['delivery_type.N']) 그 단어를 쓴다 —
# 협상 중 공급사가 고른 보기와 요약 표기가 갈리지 않도록.
item_delivery_label = ""
if item and item.delivery_type is not None:
_e2, _settings = await DB_SESSION_MNG.execute_lambda(
suppliers.DBType(), DBWRType.DB_READ.value,
lambda s: self.user_crud.get_company_settings(s, sess.supplier_id),
)
_labels = (_settings.get("labels") or {}) if _e2 == ErrorType.SUCCESS and _settings else {}
item_delivery_label = (_labels.get(f"delivery_type.{item.delivery_type}")
or DeliveryType.label_of(item.delivery_type))
def _iso(dt): def _iso(dt):
if dt is None: if dt is None:

View File

@ -1,15 +1,35 @@
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional
from fastapi import Depends from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import chats, sessions from common.database.model.models import chats, notifications, sessions
from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus from common.enums import (
CloseReason,
DBWRType,
ErrorType,
NotificationType,
QuotationStatus,
RENEGOTIABLE_CLOSE_REASONS,
RenegotiationStatus,
SessionStatus,
)
from common.logger import LOG
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
from crud.chat_crud import ChatCRUD, IChatCRUD from crud.chat_crud import ChatCRUD, IChatCRUD
from crud.session_crud import ISessionCRUD, SessionCRUD from crud.session_crud import ISessionCRUD, SessionCRUD
from router.v1.negotiation.protocol import ListItem, Req_ExtraInfo, Res_ExtraInfo, Res_Participate, Res_Reject, Res_SessionList from router.v1.negotiation.protocol import (
ListItem,
Req_ExtraInfo,
Req_Renegotiation,
Res_ExtraInfo,
Res_Participate,
Res_Reject,
Res_Renegotiation,
Res_SessionList,
)
from services.auth_service import AuthService from services.auth_service import AuthService
@ -32,7 +52,7 @@ class NegotiationService:
self.session_crud = session_crud self.session_crud = session_crud
self.chat_crud = chat_crud self.chat_crud = chat_crud
async def list_sessions(self, user_info: UserInfo, access_token: str, status, qt_type, order: str, page: int, page_size: int) -> Res_SessionList: async def list_sessions(self, user_info: UserInfo, access_token: str, status, qt_type, order: str, page: int, page_size: int, keyword: str = None, result: int = None) -> Res_SessionList:
res = Res_SessionList() res = Res_SessionList()
# 1) 인증 (활성 + 저장된 access 토큰 대조) # 1) 인증 (활성 + 저장된 access 토큰 대조)
@ -48,7 +68,7 @@ class NegotiationService:
err_type, rows = await DB_SESSION_MNG.execute_lambda( err_type, rows = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(), sessions.DBType(),
DBWRType.DB_READ.value, DBWRType.DB_READ.value,
lambda s: self.session_crud.list_by_supplier(s, supplier_id, status, qt_type, order, offset, page_size), lambda s: self.session_crud.list_by_supplier(s, supplier_id, status, qt_type, order, offset, page_size, keyword, result),
) )
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type) res.result.SetResult(err_type)
@ -58,27 +78,22 @@ class NegotiationService:
err_type, total = await DB_SESSION_MNG.execute_lambda( err_type, total = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(), sessions.DBType(),
DBWRType.DB_READ.value, DBWRType.DB_READ.value,
lambda s: self.session_crud.count_by_supplier(s, supplier_id, status, qt_type), lambda s: self.session_crud.count_by_supplier(s, supplier_id, status, qt_type, keyword, result),
) )
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res return res
res.items = [ # 같은 견적번호(체인)의 최대 차수 — 이미 다음 라운드가 있으면 재협상 요청 대상이 아니다.
ListItem( max_rounds: dict = {}
session_id=str(r[0]), for number in {r[3] for r in rows if r[3]}:
session_status=r[1], _e, mx = await DB_SESSION_MNG.execute_lambda(
qt_type=r[2], sessions.DBType(), DBWRType.DB_READ.value,
qt_number=r[3], lambda s, n=number: self.session_crud.chain_max_round(s, n),
qt_end_time=r[4].isoformat(timespec="seconds") if r[4] else "",
item_code=r[5] or "",
item_name=r[6] or "",
model_name=r[7] or "",
maker_name=r[8] or "",
custom=r[9] or {},
) )
for r in rows max_rounds[number] = mx or 0
]
res.items = [self._to_list_item(r, max_rounds) for r in rows]
res.total = total res.total = total
res.page = page res.page = page
res.page_size = page_size res.page_size = page_size
@ -134,6 +149,193 @@ class NegotiationService:
res.session_id = str(session_id) res.session_id = str(session_id)
return res return res
@staticmethod
def _to_list_item(r, max_rounds: dict) -> ListItem:
"""세션 행 → 목록 아이템. 재협상 요청 가능 여부는 서버가 판정해 내려준다(프론트가 규칙을 몰라도 되게)."""
custom = r[9] or {}
renego = custom.get("renegotiation") or {}
status = renego.get("status") or 0
is_last_round = (r[12] or 0) >= max_rounds.get(r[3], 0)
renegotiable = (
r[10] == QuotationStatus.CLOSED.value
and r[11] in RENEGOTIABLE_CLOSE_REASONS
and is_last_round
and status
not in (
RenegotiationStatus.PENDING.value,
RenegotiationStatus.APPROVED.value,
RenegotiationStatus.REJECTED.value,
)
)
return ListItem(
session_id=str(r[0]),
session_status=r[1],
qt_type=r[2],
qt_number=r[3],
qt_end_time=r[4].isoformat(timespec="seconds") if r[4] else "",
item_code=r[5] or "",
item_name=r[6] or "",
model_name=r[7] or "",
maker_name=r[8] or "",
custom=custom,
renegotiable=renegotiable,
renegotiation_status=status,
renegotiation_memo=renego.get("memo") or "",
result=NegotiationService._to_result(r[10], r[11], r[13], r[14]),
has_chat=bool(r[15]),
reject_reason=r[16] or "",
reject_price=r[17],
)
@staticmethod
def _to_result(qt_status, close_reason, winner_id, my_id) -> int:
"""공급사 관점 협상 결과(SessionResult). 견적 마감 전이면 0(미정).
낙찰 건은 낙찰자가 나면 1(낙찰)·아니면 2(미낙찰), 개찰(OPEN_*) 마감은 3(결렬=재협상 대상)."""
if qt_status != QuotationStatus.CLOSED.value:
return 0
if close_reason == CloseReason.AWARDED.value:
return 1 if winner_id is not None and str(winner_id) == str(my_id) else 2
if close_reason in RENEGOTIABLE_CLOSE_REASONS:
return 3
return 0
async def request_renegotiation(
self, user_info: UserInfo, access_token: str, session_id_str: str, req: Req_Renegotiation
) -> Res_Renegotiation:
"""결렬(개찰) 마감 건에 대해 공급사가 재협상을 요청한다(IMK #15).
전용 테이블 없이 sessions.custom.renegotiation 에 기록하고, 견적 작성자에게 알림을 남긴다."""
res = Res_Renegotiation()
err_type, info, sess, quote = await self._load_renegotiable(user_info, access_token, session_id_str)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# 심사 대기·승인·반려 건은 재요청을 막는다(전용 테이블이 없어 유니크 대신 여기서 검증).
# 반려는 담당자가 이미 판단한 결과라 같은 건으로 다시 올릴 수 없다. 철회(CANCELED)만 재요청 허용.
current = (sess.custom or {}).get("renegotiation") or {}
if current.get("status") in (
RenegotiationStatus.PENDING.value,
RenegotiationStatus.APPROVED.value,
RenegotiationStatus.REJECTED.value,
):
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
return res
payload = {
"status": RenegotiationStatus.PENDING.value,
"reason": (req.reason or "").strip(),
"desired_price": req.desired_price,
"requested_at": datetime.now(timezone.utc).isoformat(),
}
err_type = await DB_SESSION_MNG.execute_lambda_run(
[sessions.DBType()],
[lambda s: self.session_crud.merge_session_custom(
s, sess.session_id, uuid.UUID(info.supplier_id), {"renegotiation": payload}
)],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
await self._notify_renegotiation(quote, sess, info, payload)
res.session_id = str(sess.session_id)
res.status = RenegotiationStatus.PENDING.value
return res
async def cancel_renegotiation(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_Renegotiation:
"""공급사가 자기 요청을 철회한다. 심사 대기(PENDING) 중에만 가능."""
res = Res_Renegotiation()
err_type, info, sess, _quote = await self._load_renegotiable(user_info, access_token, session_id_str)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
current = (sess.custom or {}).get("renegotiation") or {}
if current.get("status") != RenegotiationStatus.PENDING.value:
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
return res
patch = {**current, "status": RenegotiationStatus.CANCELED.value}
err_type = await DB_SESSION_MNG.execute_lambda_run(
[sessions.DBType()],
[lambda s: self.session_crud.merge_session_custom(
s, sess.session_id, uuid.UUID(info.supplier_id), {"renegotiation": patch}
)],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.session_id = str(sess.session_id)
res.status = RenegotiationStatus.CANCELED.value
return res
async def _load_renegotiable(self, user_info: UserInfo, access_token: str, session_id_str: str):
"""재협상 요청 자격 검증 — 인증 → 본인 세션 → 결렬(개찰) 마감 → 마지막 라운드.
성공 시 (SUCCESS, info, sess, quote)."""
err_type, info = await self.auth.authenticate(user_info, access_token)
if err_type != ErrorType.SUCCESS:
return err_type, None, None, None
try:
session_id = uuid.UUID(session_id_str)
except (ValueError, TypeError):
return ErrorType.NEGO_NOT_FOUND, None, None, None
err_type, sess = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(), DBWRType.DB_READ.value,
lambda s: self.session_crud.get_session_by_id(s, session_id),
)
if err_type != ErrorType.SUCCESS or sess is None:
return ErrorType.NEGO_NOT_FOUND, None, None, None
if str(sess.supplier_id) != info.supplier_id:
return ErrorType.NEGO_FORBIDDEN, None, None, None
err_type, quote = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(), DBWRType.DB_READ.value,
lambda s: self.session_crud.get_quotation_by_id(s, sess.quotation_id),
)
if err_type != ErrorType.SUCCESS or quote is None:
return ErrorType.NEGO_NOT_FOUND, None, None, None
# 낙찰됐거나 아직 진행 중인 건은 요청 대상이 아니다.
if quote.status != QuotationStatus.CLOSED.value or quote.close_reason not in RENEGOTIABLE_CLOSE_REASONS:
return ErrorType.NEGO_NOT_PARTICIPABLE, None, None, None
# 이미 다음 라운드가 만들어졌으면 요청할 이유가 없다.
_e, max_round = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(), DBWRType.DB_READ.value,
lambda s: self.session_crud.chain_max_round(s, quote.number),
)
if max_round and quote.round < max_round:
return ErrorType.NEGO_NOT_PARTICIPABLE, None, None, None
return ErrorType.SUCCESS, info, sess, quote
async def _notify_renegotiation(self, quote, sess, info, payload: dict) -> None:
"""견적 작성자 인박스에 재협상 요청 알림을 남긴다. 부가 효과라 실패해도 본 흐름을 막지 않는다."""
notif = notifications(
user_id=quote.user_id,
type=NotificationType.RENEGO_REQUESTED.value,
ref_qt_id=quote.qt_id,
ref_session_id=sess.session_id,
data={
"supplier_name": info.supplier_name,
"qt_number": quote.number,
"qt_round": quote.round,
"reason": payload.get("reason"),
"desired_price": payload.get("desired_price"),
},
)
err = await DB_SESSION_MNG.execute_lambda_run(
[notifications.DBType()],
[lambda s: DB_SESSION_MNG.insert(s, notif, raise_error=False)],
)
if err != ErrorType.SUCCESS:
LOG.e_no_callstack(f"[renego] 알림 기록 실패 qt={quote.qt_id} session={sess.session_id}")
async def _is_after_summary(self, session_id) -> bool: async def _is_after_summary(self, session_id) -> bool:
"""마지막 말풍선이 타결 요약(summaryRSP/CM)인지 — 즉 협상이 타결된 뒤인지.""" """마지막 말풍선이 타결 요약(summaryRSP/CM)인지 — 즉 협상이 타결된 뒤인지."""
err_type, (_, _, last_meta) = await DB_SESSION_MNG.execute_lambda( err_type, (_, _, last_meta) = await DB_SESSION_MNG.execute_lambda(
@ -236,7 +438,10 @@ class NegotiationService:
res.session_id = str(sess.session_id) res.session_id = str(sess.session_id)
return res return res
async def reject(self, user_info: UserInfo, access_token: str, session_id_str: str, reject_reason: str) -> Res_Reject: async def reject(
self, user_info: UserInfo, access_token: str, session_id_str: str, reject_reason: str,
reject_price: Optional[int] = None, opinion: Optional[str] = None,
) -> Res_Reject:
res = Res_Reject() res = Res_Reject()
# 거부 사유 필수 # 거부 사유 필수
@ -256,11 +461,19 @@ class NegotiationService:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res return res
# 거부 처리 — 세션을 협상거부로 전이하고 사유 저장 # 거부 처리 — 세션을 협상거부로 전이하고 사유·공급 희망 가격 저장.
err_type = await DB_SESSION_MNG.execute_lambda_run( # 의견은 부가정보와 같은 custom 컬럼이라 병합(덮어쓰기 금지) — 채팅 결렬 폼과 같은 자리.
[sessions.DBType()], funcs = [
[lambda s: self.session_crud.update_session_reject(s, sess.session_id, SessionStatus.REJECTED.value, reason)], lambda s: self.session_crud.update_session_reject(
) s, sess.session_id, SessionStatus.REJECTED.value, reason, reject_price,
)
]
note = (opinion or "").strip()[:255]
if note:
funcs.append(
lambda s: self.session_crud.merge_session_custom(s, sess.session_id, sess.supplier_id, {"opinion": note})
)
err_type = await DB_SESSION_MNG.execute_lambda_run([sessions.DBType()], funcs)
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res return res

View File

@ -220,12 +220,58 @@ async def test_chat_init_returns_meta(client, chat_seed):
assert body["quotation_end_time"] # 타이머용 마감 시각 assert body["quotation_end_time"] # 타이머용 마감 시각
async def test_chat_init_returns_reject_detail(client, chat_seed, db_engine):
"""검증: 협상 거부로 끝난 세션에 재진입('결과 보기')했을 때의 init 응답.
기대결과: 대화에 남지 않는 제출 내역(reject_reason·reject_price)이 실려 열람 카드를 그릴 수 있다."""
token = await _login_token(client)
sid = chat_seed["sids"]["P"]
await client.post(
f"/v1/negotiation/sessions/{sid}/reject",
headers={"Authorization": f"Bearer {token}"},
json={"reject_reason": "단종", "reject_price": 91000, "opinion": "후속 모델로 제안 가능합니다"},
)
body = (await _init(client, token, sid)).json()
assert body["session_status"] == 5
assert body["reject_reason"] == "단종"
assert body["reject_price"] == 91000
assert body["custom"]["opinion"] == "후속 모델로 제안 가능합니다"
async def test_chat_init_forbidden_other_supplier(client, chat_seed): async def test_chat_init_forbidden_other_supplier(client, chat_seed):
token = await _login_token(client) token = await _login_token(client)
body = (await _init(client, token, chat_seed["sids"]["X"])).json() body = (await _init(client, token, chat_seed["sids"]["X"])).json()
assert body["result"]["code"] == 1300 # NEGO_FORBIDDEN assert body["result"]["code"] == 1300 # NEGO_FORBIDDEN
async def test_chat_init_vat_mode_unified_shows_excluded(client, chat_seed, db_engine):
"""검증: 부가세 전체 통일 회사(features.vat_mode=unified_excluded)의 세션 채팅 init.
기대결과: 상품에 vat_yn=true 잔존값이 있어도 item_vat_yn=False — 프론트가 'VAT별도'로 고정 표기."""
import json
company_id = uuid.uuid4()
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO company.companies (company_id, name, status, settings) VALUES (:c, :n, 1, CAST(:s AS JSONB))"),
{"c": company_id, "n": f"{MARK}VAT통일사", "s": json.dumps({"features": {"vat_mode": "unified_excluded"}})},
)
await conn.execute(
text("UPDATE partner.suppliers SET company_id = :c WHERE supplier_id = :sid"),
{"c": company_id, "sid": chat_seed["supplier_id"]},
)
await conn.execute(
text("UPDATE partner.items SET vat_yn = true WHERE item_id = (SELECT item_id FROM negotiation.sessions WHERE session_id = :s)"),
{"s": chat_seed["sids"]["P"]},
)
try:
token = await _login_token(client)
body = (await _init(client, token, chat_seed["sids"]["P"])).json()
assert body["result"]["success"] is True
assert body["item_vat_yn"] is False
finally:
async with db_engine.begin() as conn:
await conn.execute(text("DELETE FROM company.companies WHERE company_id = :c"), {"c": company_id})
# ---- messages (오프닝 seed) ------------------------------------------------- # ---- messages (오프닝 seed) -------------------------------------------------
async def test_messages_seeds_opening(client, chat_seed): async def test_messages_seeds_opening(client, chat_seed):
token = await _login_token(client) token = await _login_token(client)
@ -351,23 +397,28 @@ async def test_send_blocked_when_prev_turn_pending(client, chat_seed, db_engine)
async def test_init_marks_expired_created_as_not_participated(client, chat_seed, db_engine): async def test_init_marks_expired_created_as_not_participated(client, chat_seed, db_engine):
"""검증: 마감시간이 지난 협상생성 세션으로 채팅 진입.
기대결과: DB 상태가 미참여(4)로 정리되고, init 자체는 열람용으로 성공한다."""
token = await _login_token(client) token = await _login_token(client)
sid, qid = chat_seed["sids"]["C"], chat_seed["qids"]["C"] # 협상생성(1) sid, qid = chat_seed["sids"]["C"], chat_seed["qids"]["C"] # 협상생성(1)
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
await conn.execute(text("UPDATE quotation.quotations SET end_time = now() - make_interval(hours => 1) WHERE qt_id = :qid"), {"qid": qid}) await conn.execute(text("UPDATE quotation.quotations SET end_time = now() - make_interval(hours => 1) WHERE qt_id = :qid"), {"qid": qid})
body = (await _init(client, token, sid)).json() body = (await _init(client, token, sid)).json()
# 마감된 협상생성은 DB 상 미참여로 정리되고, 미참여는 진입 불가라 init 은 에러로 막는다. assert body["result"]["success"] is True
assert body["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE assert body["session_status"] == 4
assert await _session_status(db_engine, sid) == 4 # DB 는 미참여로 전이됨 assert await _session_status(db_engine, sid) == 4 # DB 도 미참여로 전이
async def test_init_blocks_rejected_session(client, chat_seed, db_engine): async def test_init_allows_viewing_rejected_session(client, chat_seed, db_engine):
"""검증: 협상거부(5)로 끝난 세션에 '결과 보기'로 재진입.
기대결과: init 성공(열람 허용) — 대화 재개는 send 가 협상중만 허용해 막는다."""
token = await _login_token(client) token = await _login_token(client)
sid = chat_seed["sids"]["P"] sid = chat_seed["sids"]["P"]
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
await conn.execute(text("UPDATE negotiation.sessions SET status = 5 WHERE session_id = :sid"), {"sid": sid}) # 협상거부 await conn.execute(text("UPDATE negotiation.sessions SET status = 5 WHERE session_id = :sid"), {"sid": sid}) # 협상거부
body = (await _init(client, token, sid)).json() body = (await _init(client, token, sid)).json()
assert body["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE — 거부 세션 진입 차단 assert body["result"]["success"] is True and body["session_status"] == 5
assert (await _send(client, token, sid, "네")).json()["result"]["code"] == 1400 # CHAT_NOT_IN_PROGRESS
# ---- 순수 헬퍼 단위 테스트 (DB 불필요, ChatService @staticmethod) ---------- # ---- 순수 헬퍼 단위 테스트 (DB 불필요, ChatService @staticmethod) ----------

View File

@ -16,6 +16,35 @@ TEST_SUPPLIER_NAME = "파이테스트협상공급사"
MARK = "PYTESTNEGO-" # 시드 식별용 prefix (item code / qt number) MARK = "PYTESTNEGO-" # 시드 식별용 prefix (item code / qt number)
async def _seed_case(conn, code, sess_st, qt_type, hrs, quote_st, sup):
"""상품·견적·세션 1세트 시드. 코드/견적번호에 MARK prefix 를 달아 cleanup 이 함께 지운다.
hrs 는 마감(quotation.end_time)까지의 시간 — 음수면 이미 마감시간이 지난 건. 반환: (session_id, qt_id)."""
item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
await conn.execute(
text(
"INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) "
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, :model, '테스트제조사')"
),
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}", "model": f"MODEL-{code}"},
)
await conn.execute(
text(
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time) "
"VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, :tp, :st, now(), now() + make_interval(hours => :hrs))"
),
{"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "tp": qt_type, "st": quote_st, "hrs": hrs},
)
await conn.execute(
text(
"INSERT INTO negotiation.sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, target_price, status, end_time) "
"VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, :qtt, 100000, :st, now())"
),
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "qtt": qt_type, "st": sess_st},
)
return session_id, qt_id
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def nego_seed(db_engine): async def nego_seed(db_engine):
"""공급사 + 유저 + 세션/견적 3건(본인) + 1건(타 공급사) 시드. 세션/견적 id 를 반환.""" """공급사 + 유저 + 세션/견적 3건(본인) + 1건(타 공급사) 시드. 세션/견적 id 를 반환."""
@ -33,6 +62,11 @@ async def nego_seed(db_engine):
sids, qids = {}, {} sids, qids = {}, {}
async def _cleanup(conn): async def _cleanup(conn):
# 대화는 세션보다 먼저 지운다(세션이 사라지면 대상을 못 고른다).
await conn.execute(text(
f"DELETE FROM negotiation.chats WHERE session_id IN "
f"(SELECT session_id FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%')"
))
await conn.execute(text(f"DELETE FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%'")) await conn.execute(text(f"DELETE FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%'"))
await conn.execute(text(f"DELETE FROM quotation.quotations WHERE number LIKE '{MARK}%'")) await conn.execute(text(f"DELETE FROM quotation.quotations WHERE number LIKE '{MARK}%'"))
await conn.execute(text(f"DELETE FROM partner.items WHERE code LIKE '{MARK}%'")) await conn.execute(text(f"DELETE FROM partner.items WHERE code LIKE '{MARK}%'"))
@ -52,31 +86,9 @@ async def nego_seed(db_engine):
), ),
{"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash}, {"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash},
) )
for code, sess_st, qt_type, hrs, quote_st, sup in specs: for spec in specs:
item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() code = spec[0]
sids[code], qids[code] = session_id, qt_id sids[code], qids[code] = await _seed_case(conn, *spec)
await conn.execute(
text(
"INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) "
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, :model, '테스트제조사')"
),
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}", "model": f"MODEL-{code}"},
)
await conn.execute(
text(
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time) "
"VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, :tp, :st, now(), now() + make_interval(hours => :hrs))"
),
{"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "tp": qt_type, "st": quote_st, "hrs": hrs},
)
await conn.execute(
text(
"INSERT INTO negotiation.sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, target_price, status, end_time) "
"VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, :qtt, 100000, :st, now())"
),
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "qtt": qt_type, "st": sess_st},
)
yield {"supplier_id": supplier_id, "sids": sids, "qids": qids} yield {"supplier_id": supplier_id, "sids": sids, "qids": qids}
@ -141,6 +153,63 @@ async def test_list_filter_status(client, nego_seed):
assert body["total"] == 1 and body["items"][0]["item_code"] == f"{MARK}B" assert body["total"] == 1 and body["items"][0]["item_code"] == f"{MARK}B"
async def test_list_shows_closed_quotation_created_session_as_not_participated(client, db_engine, nego_seed):
"""검증: 견적이 마감(3)된 뒤에도 세션이 협상생성(1)으로 남아 있는 건(마감 일괄정리 이후 생성 등).
기대결과: 목록 상태는 미참여(4) — '협상 대기'로 새지 않고, status=1 필터에서도 빠지고 status=4 필터에 잡힌다."""
async with db_engine.begin() as conn:
await _seed_case(conn, "CLOSED1", 1, 2, -1, 3, nego_seed["supplier_id"])
token = await _login_token(client)
listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}CLOSED1")
assert listed["session_status"] == 4
waiting = (await _list(client, token, status=1)).json()
assert waiting["total"] == 1 and {i["item_code"] for i in waiting["items"]} == {f"{MARK}A"}
assert f"{MARK}CLOSED1" in {i["item_code"] for i in (await _list(client, token, status=4)).json()["items"]}
async def test_list_shows_deadline_passed_created_session_as_not_participated(client, db_engine, nego_seed):
"""검증: 견적은 아직 진행중(2)인데 마감시간(end_time)만 지난 협상생성 세션.
기대결과: 미참여(4) — 참여/입장이 막히는 건이라 목록도 같은 상태로 보인다(DB 값은 그대로)."""
async with db_engine.begin() as conn:
session_id, _ = await _seed_case(conn, "OVERDUE", 1, 2, -3, 2, nego_seed["supplier_id"])
token = await _login_token(client)
listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}OVERDUE")
assert listed["session_status"] == 4
assert await _session_status(db_engine, session_id) == 1 # 목록은 파생 표시만, 쓰기는 하지 않는다
async def test_list_marks_stale_round_not_renegotiable(client, db_engine, nego_seed):
"""검증: 개찰(결렬) 마감된 1차 견적에 2차가 이미 생성돼 있는 체인.
기대결과: renegotiable False — 다음 라운드가 있으면 재협상 요청 대상이 아니다(체인 최대 차수 판정)."""
async with db_engine.begin() as conn:
await _seed_case(conn, "CHAIN", 3, 2, -2, 3, nego_seed["supplier_id"])
await conn.execute(text(
f"UPDATE quotation.quotations SET close_reason = 5 WHERE number = '{MARK}CHAIN'"))
# 같은 견적번호의 2차 — 번호가 같아야 체인으로 묶인다.
await conn.execute(text(
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, round, start_time, end_time) "
f"VALUES (gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), '견적 CHAIN 2차', '{MARK}CHAIN', 2, 2, 2, now(), now() + make_interval(hours => 2))"))
token = await _login_token(client)
listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}CHAIN")
assert listed["result"] == 3 and listed["renegotiable"] is False
async def test_list_has_chat_flags_sessions_with_history(client, db_engine, nego_seed):
"""검증: 대화 이력이 있는 세션과 없는 세션의 has_chat.
기대결과: 이력 있는 건만 True — 종료 건의 '결과 보기' 노출이 이 값으로 갈린다."""
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO negotiation.chats (session_id, seq, sender, target_price) VALUES (:sid, 1, 1, 0)"),
{"sid": nego_seed["sids"]["C"]},
)
token = await _login_token(client)
by_code = {i["item_code"]: i["has_chat"] for i in (await _list(client, token)).json()["items"]}
assert by_code[f"{MARK}C"] is True
assert by_code[f"{MARK}A"] is False
async def test_list_filter_qt_type(client, nego_seed): async def test_list_filter_qt_type(client, nego_seed):
token = await _login_token(client) token = await _login_token(client)
body = (await _list(client, token, qt_type=2)).json() body = (await _list(client, token, qt_type=2)).json()
@ -177,6 +246,56 @@ async def test_list_requires_auth(client):
assert (await client.get("/v1/negotiation/sessions")).status_code in (401, 403) assert (await client.get("/v1/negotiation/sessions")).status_code in (401, 403)
# ---- 검색(keyword) ----------------------------------------------------------
async def test_search_by_qt_number_and_item_code(client, nego_seed):
"""검증: 견적번호/상품코드가 같은 값(PYTESTNEGO-B)으로 검색.
기대결과: B 1건만, total 도 1(카운트도 같은 필터 적용)."""
token = await _login_token(client)
body = (await _list(client, token, keyword=f"{MARK}B")).json()
assert body["total"] == 1
assert [i["item_code"] for i in body["items"]] == [f"{MARK}B"]
async def test_search_by_item_name(client, nego_seed):
"""검증: 상품명 일부('상품 A')로 검색.
기대결과: A 1건만."""
token = await _login_token(client)
body = (await _list(client, token, keyword="상품 A")).json()
assert {i["item_code"] for i in body["items"]} == {f"{MARK}A"}
async def test_search_prefix_matches_all_own(client, nego_seed):
"""검증: 공통 prefix(PYTESTNEGO)로 검색.
기대결과: 본인 공급사 3건 전부(타 공급사 X 는 제외 유지)."""
token = await _login_token(client)
body = (await _list(client, token, keyword=MARK.rstrip("-"))).json()
assert body["total"] == 3
async def test_search_case_insensitive(client, nego_seed):
"""검증: 소문자로 검색(pytestnego-c).
기대결과: ILIKE 라 대소문자 무시하고 C 매칭."""
token = await _login_token(client)
body = (await _list(client, token, keyword=f"{MARK}c".lower())).json()
assert {i["item_code"] for i in body["items"]} == {f"{MARK}C"}
async def test_search_no_match_returns_empty(client, nego_seed):
"""검증: 어디에도 없는 검색어.
기대결과: 0건, total 0."""
token = await _login_token(client)
body = (await _list(client, token, keyword="존재하지않는검색어zzz")).json()
assert body["total"] == 0 and body["items"] == []
async def test_search_wildcard_is_escaped(client, nego_seed):
"""검증: ILIKE 와일드카드('%')를 그대로 검색 — 패턴으로 새면 전건 매칭될 위험.
기대결과: escape 되어 리터럴 '%' 로 취급 → 매칭 0건."""
token = await _login_token(client)
body = (await _list(client, token, keyword="%")).json()
assert body["total"] == 0
# ---- 참여 ------------------------------------------------------------------- # ---- 참여 -------------------------------------------------------------------
async def test_participate_success(client, nego_seed, db_engine): async def test_participate_success(client, nego_seed, db_engine):
token = await _login_token(client) token = await _login_token(client)
@ -266,6 +385,65 @@ async def test_reject_success(client, nego_seed, db_engine):
assert status == 5 and reason == "단종 상품입니다" # REJECTED + 사유 저장 assert status == 5 and reason == "단종 상품입니다" # REJECTED + 사유 저장
async def test_reject_with_price_and_opinion(client, nego_seed, db_engine):
# 채팅 내 협상 거부 경로 — 사유 외에 공급 희망 가격과 의견까지 함께 남긴다.
token = await _login_token(client)
sid = nego_seed["sids"]["B"]
r = await client.post(
f"/v1/negotiation/sessions/{sid}/reject",
headers={"Authorization": f"Bearer {token}"},
json={"reject_reason": "품절", "reject_price": 88000, "opinion": "대체품으로 재견적 부탁드립니다"},
)
assert r.json()["result"]["success"] is True
async with db_engine.begin() as conn:
row = (await conn.execute(
text("SELECT status, reject_reason, reject_price, custom FROM negotiation.sessions WHERE session_id = :sid"),
{"sid": sid},
)).first()
assert row.status == 5 and row.reject_reason == "품절"
assert row.reject_price == 88000
assert row.custom["opinion"] == "대체품으로 재견적 부탁드립니다"
async def test_reject_without_price_keeps_null(client, nego_seed, db_engine):
# 목록 거부 경로 — 가격이 없으면 reject_price 를 건드리지 않는다.
token = await _login_token(client)
sid = nego_seed["sids"]["B"]
r = await _reject(client, token, sid, "단종")
assert r.json()["result"]["success"] is True
async with db_engine.begin() as conn:
row = (await conn.execute(
text("SELECT reject_price, custom FROM negotiation.sessions WHERE session_id = :sid"),
{"sid": sid},
)).first()
assert row.reject_price is None and row.custom is None
async def test_list_returns_reject_detail(client, nego_seed):
# 거부 제출 내역은 대화에 남지 않는다 — 목록이 사유·희망가를 실어야 '거부 내역'을 열람할 수 있다.
token = await _login_token(client)
sid = nego_seed["sids"]["B"]
await client.post(
f"/v1/negotiation/sessions/{sid}/reject",
headers={"Authorization": f"Bearer {token}"},
json={"reject_reason": "품절", "reject_price": 77000, "opinion": "재고 확보 후 연락드리겠습니다"},
)
items = (await _list(client, token)).json()["items"]
row = next(i for i in items if i["session_id"] == str(sid))
assert row["reject_reason"] == "품절"
assert row["reject_price"] == 77000
assert row["custom"]["opinion"] == "재고 확보 후 연락드리겠습니다"
async def test_list_reject_detail_empty_for_active(client, nego_seed):
# 거부 건이 아니면 빈 값 — 프론트가 '거부 내역' 버튼 노출을 상태로만 판단하므로 값이 새면 안 된다.
token = await _login_token(client)
items = (await _list(client, token)).json()["items"]
row = next(i for i in items if i["session_id"] == str(nego_seed["sids"]["A"]))
assert row["reject_reason"] == ""
assert row.get("reject_price") is None
async def test_reject_empty_reason(client, nego_seed): async def test_reject_empty_reason(client, nego_seed):
token = await _login_token(client) token = await _login_token(client)
r = await _reject(client, token, nego_seed["sids"]["B"], " ") # 공백만 → 사유 없음 r = await _reject(client, token, nego_seed["sids"]["B"], " ") # 공백만 → 사유 없음
@ -294,3 +472,76 @@ async def test_reject_requires_auth(client, nego_seed):
sid = nego_seed["sids"]["B"] sid = nego_seed["sids"]["B"]
r = await client.post(f"/v1/negotiation/sessions/{sid}/reject", json={"reject_reason": "사유"}) r = await client.post(f"/v1/negotiation/sessions/{sid}/reject", json={"reject_reason": "사유"})
assert r.status_code in (401, 403) assert r.status_code in (401, 403)
# ---- 결과 필터(result) ------------------------------------------------------
# 마감(CLOSED) + 마감사유/낙찰자로 낙찰(1)·미낙찰(2)·결렬(3)을 만들고 result= 로 거른다.
# nego_seed 의 공급사/로그인을 재사용하고, MARK prefix 라 픽스처 teardown 이 함께 정리한다.
async def _seed_result_row(engine, *, supplier_id, code, close_reason, winner_id):
import uuid as _uuid
item_id, qt_id, session_id = _uuid.uuid4(), _uuid.uuid4(), _uuid.uuid4()
async with engine.begin() as conn:
await conn.execute(
text("INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) "
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, 'M', '제조사')"),
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}"},
)
await conn.execute(
text("INSERT INTO quotation.quotations "
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, close_reason, "
" preferred_sp_id, round, start_time, end_time) VALUES "
"(:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, 2, 3, :cr, "
" :win, 1, now() - make_interval(hours => 2), now() - make_interval(hours => 1))"),
{"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "cr": close_reason, "win": winner_id},
)
await conn.execute(
text("INSERT INTO negotiation.sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
" target_price, status, bid_price, end_time) VALUES "
"(:sid, :qid, :iid, :sup, :num, 1, 2, 100000, 3, 95000, now() - make_interval(hours => 1))"),
{"sid": session_id, "qid": qt_id, "iid": item_id, "sup": supplier_id, "num": f"{MARK}{code}"},
)
@pytest_asyncio.fixture
async def result_rows(nego_seed, db_engine):
"""nego_seed 공급사에 낙찰/미낙찰/결렬 각 1건을 추가한다(개찰 5=OPEN_PRICE, 1=AWARDED)."""
sup = nego_seed["supplier_id"]
await _seed_result_row(db_engine, supplier_id=sup, code="RWON", close_reason=1, winner_id=sup) # 낙찰(나)
await _seed_result_row(db_engine, supplier_id=sup, code="RLOST", close_reason=1, winner_id=uuid.uuid4()) # 미낙찰(남)
await _seed_result_row(db_engine, supplier_id=sup, code="ROPEN", close_reason=5, winner_id=None) # 결렬(개찰)
return nego_seed
async def test_result_filter_won(client, result_rows):
"""검증: result=1(낙찰)로 필터. 기대결과: 낙찰 건만, total=1."""
token = await _login_token(client)
body = (await _list(client, token, result=1)).json()
assert body["total"] == 1
assert body["items"][0]["item_code"] == f"{MARK}RWON"
assert body["items"][0]["result"] == 1
async def test_result_filter_lost(client, result_rows):
"""검증: result=2(미낙찰)로 필터. 기대결과: 미낙찰 건만."""
token = await _login_token(client)
body = (await _list(client, token, result=2)).json()
assert {i["item_code"] for i in body["items"]} == {f"{MARK}RLOST"}
assert body["items"][0]["result"] == 2
async def test_result_filter_open(client, result_rows):
"""검증: result=3(결렬)로 필터. 기대결과: 개찰 결렬 건만 + 재협상 대상(renegotiable=True)."""
token = await _login_token(client)
body = (await _list(client, token, result=3)).json()
assert {i["item_code"] for i in body["items"]} == {f"{MARK}ROPEN"}
assert body["items"][0]["result"] == 3
assert body["items"][0]["renegotiable"] is True
async def test_result_filter_composes_with_paging(client, result_rows):
"""검증: 결과 필터가 total(페이징)에 반영. 기대결과: result=1 이면 total=1(전체 목록과 별개)."""
token = await _login_token(client)
all_total = (await _list(client, token)).json()["total"]
won_total = (await _list(client, token, result=1)).json()["total"]
assert won_total == 1 and all_total > won_total

View File

@ -0,0 +1,215 @@
"""공급사 재협상 요청/철회(IMK #15) 포털 e2e — 요청 접수 + 철회.
담당자 심사(승인/반려)는 negodata 백엔드 몫이고, 여기(포털)는 공급사가
sessions.custom.renegotiation 에 요청을 남기고(PENDING) 스스로 철회(CANCELED)하는 절반을 본다:
· 개찰(OPEN_*) 마감 + 본인 마지막 라운드 세션 → 요청 기록(PENDING) + 담당자 알림
· 낙찰(AWARDED) 건 → 요청 거부
· 남의 공급사 세션 → 거부(FORBIDDEN)
· 이미 대기 중인데 재요청 → 거부(중복 방지)
· 대기 중 철회 → CANCELED, 이후 재요청 허용
dev negosium_db 를 그대로 쓰므로(APP_ENV=local) 전용 테스트 행만 시드하고 끝나면 지운다.
"""
import uuid
import bcrypt
import pytest_asyncio
from sqlalchemy import text
from common.enums import CloseReason, QuotationStatus, RenegotiationStatus, SessionStatus
TEST_LOGIN_ID = "pytest_renego_user"
TEST_PW = "pytest1234"
TEST_SUPPLIER_NAME = "파이테스트재협상공급사"
MARK = "PYTESTRENEGO-" # 시드 식별용 prefix (item code / qt number)
@pytest_asyncio.fixture
async def renego_seed(db_engine):
"""공급사 + 로그인유저 + 재협상 후보 세션들을 시드하고 (supplier_id, sids, uids) 반환.
(code, quotation.status, close_reason, 소속 공급사) — 요청 자격은 견적 마감사유·소유로 갈린다.
"""
supplier_id = uuid.uuid4()
other_supplier_id = uuid.uuid4()
pw_hash = bcrypt.hashpw(TEST_PW.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
specs = [
("OPEN", QuotationStatus.CLOSED.value, CloseReason.OPEN_PRICE.value, supplier_id), # 개찰 → 요청 가능
("AWARD", QuotationStatus.CLOSED.value, CloseReason.AWARDED.value, supplier_id), # 낙찰 → 불가
("OTHER", QuotationStatus.CLOSED.value, CloseReason.OPEN_PRICE.value, other_supplier_id), # 남의 공급사
]
sids, uids = {}, {}
async def _cleanup(conn):
await conn.execute(text(f"DELETE FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%'"))
await conn.execute(text(f"DELETE FROM company.notifications WHERE ref_qt_id IN "
f"(SELECT qt_id FROM quotation.quotations WHERE number LIKE '{MARK}%')"))
await conn.execute(text(f"DELETE FROM quotation.quotations WHERE number LIKE '{MARK}%'"))
await conn.execute(text(f"DELETE FROM partner.items WHERE code LIKE '{MARK}%'"))
await conn.execute(text("DELETE FROM supplier.supplier_users WHERE id = :id"), {"id": TEST_LOGIN_ID})
await conn.execute(text("DELETE FROM partner.suppliers WHERE name = :n"), {"n": TEST_SUPPLIER_NAME})
async with db_engine.begin() as conn:
await _cleanup(conn)
await conn.execute(
text("INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name) "
"VALUES (:sid, gen_random_uuid(), gen_random_uuid(), :name)"),
{"sid": supplier_id, "name": TEST_SUPPLIER_NAME},
)
await conn.execute(
text("INSERT INTO supplier.supplier_users (supplier_id, id, password, name, last_accessed_at, status, role) "
"VALUES (:sid, :id, :pw, '협상담당자', now(), 1, 1)"),
{"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash},
)
for code, quote_st, close_reason, sup in specs:
item_id, qt_id, session_id, user_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
sids[code], uids[code] = session_id, user_id
await conn.execute(
text("INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) "
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, :model, '테스트제조사')"),
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}", "model": f"MODEL-{code}"},
)
await conn.execute(
text("INSERT INTO quotation.quotations "
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, close_reason, "
" round, start_time, end_time) VALUES "
"(:qid, :uid, gen_random_uuid(), gen_random_uuid(), :name, :num, 2, :st, :cr, "
" 1, now() - make_interval(hours => 2), now() - make_interval(hours => 1))"),
{"qid": qt_id, "uid": user_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "st": quote_st, "cr": close_reason},
)
await conn.execute(
text("INSERT INTO negotiation.sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
" target_price, status, bid_price, end_time) VALUES "
"(:sesid, :qid, :iid, :sup, :qtn, 1, 2, 100000, :sst, 95000, now() - make_interval(hours => 1))"),
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "sst": SessionStatus.DONE.value},
)
yield {"supplier_id": supplier_id, "sids": sids, "uids": uids}
async with db_engine.begin() as conn:
await _cleanup(conn)
async def _login_token(client):
r = await client.post("/v1/auth/login", json={"id": TEST_LOGIN_ID, "pw": TEST_PW})
return r.json()["access_token"]
async def _request(client, token, session_id, *, reason="가격 재검토", desired_price=90000):
return await client.post(
f"/v1/negotiation/session/{session_id}/renegotiation",
headers={"Authorization": f"Bearer {token}"},
json={"reason": reason, "desired_price": desired_price},
)
async def _cancel(client, token, session_id):
return await client.delete(
f"/v1/negotiation/session/{session_id}/renegotiation",
headers={"Authorization": f"Bearer {token}"},
)
async def _renego(db_engine, session_id):
async with db_engine.begin() as conn:
row = (await conn.execute(
text("SELECT custom FROM negotiation.sessions WHERE session_id = :sid"),
{"sid": session_id},
)).scalar()
return (row or {}).get("renegotiation") or {}
async def _notif_count(db_engine, qt_number):
async with db_engine.begin() as conn:
return (await conn.execute(
text("SELECT count(*) FROM company.notifications WHERE ref_qt_id IN "
"(SELECT qt_id FROM quotation.quotations WHERE number = :num)"),
{"num": qt_number},
)).scalar()
# ---- 요청 -------------------------------------------------------------------
async def test_request_records_pending(client, renego_seed, db_engine):
"""검증: 개찰(OPEN_PRICE) 마감 + 본인 마지막 라운드 세션에 재협상 요청.
기대결과: success + PENDING 기록(사유·희망가 저장) + 담당자 알림 1건."""
token = await _login_token(client)
sid = renego_seed["sids"]["OPEN"]
body = (await _request(client, token, sid, reason="원자재 인상 반영", desired_price=88000)).json()
assert body["result"]["success"] is True
assert body["status"] == RenegotiationStatus.PENDING.value
saved = await _renego(db_engine, sid)
assert saved["status"] == RenegotiationStatus.PENDING.value
assert saved["reason"] == "원자재 인상 반영"
assert saved["desired_price"] == 88000
assert await _notif_count(db_engine, f"{MARK}OPEN") == 1
async def test_request_twice_blocked(client, renego_seed, db_engine):
"""검증: 이미 대기(PENDING) 요청이 있는 세션에 다시 요청.
기대결과: 2번째는 거부(중복 방지) + 상태는 여전히 PENDING 1건."""
token = await _login_token(client)
sid = renego_seed["sids"]["OPEN"]
first = (await _request(client, token, sid)).json()
second = (await _request(client, token, sid)).json()
assert first["result"]["success"] is True
assert second["result"]["success"] is False
assert (await _renego(db_engine, sid))["status"] == RenegotiationStatus.PENDING.value
async def test_request_blocked_on_awarded(client, renego_seed, db_engine):
"""검증: 낙찰(AWARDED)로 마감된 건에 재협상 요청.
기대결과: 거부(낙찰 건은 재협상 불가) + custom.renegotiation 미기록."""
token = await _login_token(client)
sid = renego_seed["sids"]["AWARD"]
body = (await _request(client, token, sid)).json()
assert body["result"]["success"] is False
assert await _renego(db_engine, sid) == {}
async def test_request_forbidden_other_supplier(client, renego_seed, db_engine):
"""검증: 다른 공급사 소유 세션에 재협상 요청.
기대결과: 거부 + custom.renegotiation 미기록(소유 가드)."""
token = await _login_token(client)
sid = renego_seed["sids"]["OTHER"]
body = (await _request(client, token, sid)).json()
assert body["result"]["success"] is False
assert await _renego(db_engine, sid) == {}
# ---- 철회 -------------------------------------------------------------------
async def test_cancel_sets_canceled_and_allows_rerequest(client, renego_seed, db_engine):
"""검증: 대기 중 요청을 철회한 뒤 다시 요청.
기대결과: 철회 시 CANCELED → 재요청 시 다시 PENDING(철회 건은 재요청 허용)."""
token = await _login_token(client)
sid = renego_seed["sids"]["OPEN"]
await _request(client, token, sid)
cancelled = (await _cancel(client, token, sid)).json()
assert cancelled["result"]["success"] is True
assert cancelled["status"] == RenegotiationStatus.CANCELED.value
assert (await _renego(db_engine, sid))["status"] == RenegotiationStatus.CANCELED.value
again = (await _request(client, token, sid)).json()
assert again["result"]["success"] is True
assert (await _renego(db_engine, sid))["status"] == RenegotiationStatus.PENDING.value
async def test_cancel_requires_pending(client, renego_seed, db_engine):
"""검증: 대기 요청이 없는 세션에 철회 시도.
기대결과: 거부(철회할 대기 요청 없음)."""
token = await _login_token(client)
sid = renego_seed["sids"]["OPEN"]
body = (await _cancel(client, token, sid)).json()
assert body["result"]["success"] is False

View File

@ -0,0 +1,51 @@
"""공급사 관점 협상 결과 파생(SessionResult) 단위 테스트.
목록의 result 코드는 견적 마감상태·마감사유·낙찰자로 파생한다(DDL 무변경). 공급사가 이 배지로
'내가 낙찰인지 / 결렬이라 재협상 요청 대상인지'를 구분한다. 결렬(3)만 renegotiable 과 짝을 이룬다.
"""
import uuid
from common.enums import CloseReason, QuotationStatus
from services.negotiation_service import NegotiationService
_R = NegotiationService._to_result
ME = uuid.uuid4()
OTHER = uuid.uuid4()
CLOSED = QuotationStatus.CLOSED.value
def test_result_undecided_before_close():
"""검증: 견적이 아직 마감 전(진행중)이면 결과 미정.
기대결과: 0(미정)."""
assert _R(QuotationStatus.IN_PROGRESS.value, None, None, ME) == 0
def test_result_won_when_winner_is_me():
"""검증: 낙찰(AWARDED) 마감 + 낙찰자가 나.
기대결과: 1(낙찰)."""
assert _R(CLOSED, CloseReason.AWARDED.value, ME, ME) == 1
def test_result_lost_when_winner_is_other():
"""검증: 낙찰 마감이지만 낙찰자가 남.
기대결과: 2(미낙찰)."""
assert _R(CLOSED, CloseReason.AWARDED.value, OTHER, ME) == 2
def test_result_lost_when_awarded_without_winner_id():
"""검증: 낙찰인데 낙찰자 id 가 비어 나와 대조 불가.
기대결과: 2(미낙찰) — 낙찰이라 단정 못 하면 낙찰로 오인시키지 않는다."""
assert _R(CLOSED, CloseReason.AWARDED.value, None, ME) == 2
def test_result_open_is_renegotiable():
"""검증: 개찰(OPEN_*) 4종으로 마감(낙찰자 미정=결렬).
기대결과: 전부 3(결렬) — 재협상 요청 대상."""
for cr in (CloseReason.OPEN_PRICE, CloseReason.OPEN_EQUAL, CloseReason.OPEN_NOSHOW, CloseReason.OPEN_REJECT):
assert _R(CLOSED, cr.value, None, ME) == 3, cr
def test_result_none_when_closed_without_reason():
"""검증: 마감됐지만 close_reason 이 아직 없음(경계).
기대결과: 0(미정) — 낙찰/결렬 어느 쪽도 아님."""
assert _R(CLOSED, None, None, ME) == 0

BIN
dev-settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

View File

@ -0,0 +1,214 @@
services:
# ── 프론트 (React/Vite → 정적 빌드 → nginx) ──────────────────────────────
negodata-front:
build:
context: ./negodata/front
dockerfile: Dockerfile.prod
container_name: negodata-front
ports:
- "${NEGODATA_FRONT_PORT:-30012}:80"
depends_on:
- negodata-backend # nginx 가 시작 시 upstream(negodata-backend) 이름을 해석해야 함
restart: unless-stopped
negosium-front:
build:
context: ./frontend
dockerfile: Dockerfile.prod
container_name: negosium-front
ports:
- "${NEGOSIUM_FRONT_PORT:-30010}:80"
depends_on:
- negosium-backend
restart: unless-stopped
# ── 솔루션 랜딩 (React Router SSG → 정적 빌드 → nginx) ───────────────────
landing:
build:
context: ./landing
dockerfile: Dockerfile.prod
container_name: negosium-landing
ports:
- "${LANDING_PORT:-30013}:80"
restart: unless-stopped
# ── 백엔드 (FastAPI, 기존 Dockerfile) ────────────────────────────────────
negodata-backend:
build: ./negodata/backend
container_name: negodata-backend
environment:
APP_ENV: prod
SCHEDULER_ENABLED: "1" # 견적 자동마감 크론(process_count=1 단일 워커라 중복 없음)
PYTHONUNBUFFERED: "1"
LPS_BASE_URL: http://lps-api:9600 # 최저가 검색요청 enqueue — 같은 compose 망의 lps-api 컨테이너(미설정 시 localhost:9600 → 연결실패)
# LPS 읽기 DB(lps_db)는 config.prod.toml 의 [LpsDBConfig](172.30.1.36/o2o_db_admin) 이 담당 → 별도 env 불필요
DB_HOST: ${DB_HOST} # toml 의 127.0.0.1 을 외부 DB 로 override
DB_PORT: ${DB_PORT:-5432}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME:-negosium_db}
volumes:
- ./negodata/backend/config/config.prod.toml:/app/config/config.prod.toml:ro
# 외부 비노출 — negodata-front 의 nginx 가 /v1 을 내부망으로 프록시.
extra_hosts:
- "host.docker.internal:host-gateway" # DB 가 이 서버 호스트면 DB_HOST=host.docker.internal
restart: unless-stopped
negosium-backend:
build: ./backend
container_name: negosium-backend
environment:
APP_ENV: prod
AGENT_BASE_URL: http://agent:9500 # 내부망으로 agent 호출
PYTHONUNBUFFERED: "1"
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT:-5432}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME:-negosium_db}
volumes:
- ./backend/config/config.prod.toml:/app/config/config.prod.toml:ro
# 외부 비노출 — negosium-front 의 nginx 가 /v1 을 내부망으로 프록시.
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
# ── 협상 에이전트 (Python, 내부 전용) ────────────────────────────────────
agent:
build: ./agent
container_name: negosium-agent
environment:
APP_ENV: prod
PYTHONUNBUFFERED: "1"
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT:-5432}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME:-negosium_db}
volumes:
- ./agent/config/config.prod.toml:/app/config/config.prod.toml:ro
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
# ── 앵커링 값 자동 조정 배치 (negosium_db 공유, 포트 없음 — 상주 스케줄러) ──
# 이미지는 config.{APP_ENV}.toml 을 읽는다(dev/prod 는 파일 없으면 기동 중단). DB/시크릿은
# config.prod.toml(172.30.1.36) 에서 온다 — env(DB_*/REDIS_*) 를 설정하면 파일값을 override.
anchoring:
build: ./schedules/anchoring
container_name: anchoring
environment:
APP_ENV: prod # → config.prod.toml 선택 (local 로 두면 빈 config.local 로 기동 실패)
REDIS_HOST: anchoring-redis # 전용 캐시 컨테이너 사용(config 의 redis host 를 override)
TZ: Asia/Seoul
volumes:
- ./schedules/anchoring/config.prod.toml:/app/config.prod.toml:ro # DB 접속·시크릿은 파일에서(마운트 필수)
depends_on:
- anchoring-redis
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
logging: # 상주 배치 — 장기 운영 디스크 보호
driver: json-file
options:
max-size: "10m"
max-file: "5"
# 앵커링 조회 캐시 (anchoring 전용)
anchoring-redis:
image: redis:7-alpine
container_name: anchoring-redis
ports:
- "127.0.0.1:6380:6379" # 호스트 로컬만 개방 (무인증 Redis). 6380 = 호스트 redis(6379)와 충돌 회피
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
# ── LPS (인터넷 최저가 검색) ──────────────────────────────────
# API(요청 접수) + 워커(크롤, Chromium+Xvfb).
# 이미지는 APP_ENV=local 고정 → config.local.toml 을 읽는다. prod 실값(DB 172.30.1.36 /
# o2o_db_admin / negosium_db + OpenAI·Naver·Decodo 시크릿)은 config.prod.toml 을 그 경로에
# 마운트해 주입한다(다른 prod 서비스와 동일한 파일 기반 방식). DB_* env 는 주지 않는다 —
# 주면 파일값을 빈 값/오타로 덮어써 인증 실패(예: 사용자 o2oadmin)한다.
lps-api:
build:
context: ./lps
dockerfile: Dockerfile
container_name: lps-api
environment:
APP_ENV: local # 이미지 고정값 — 변경 금지(config.prod.toml 을 직접 읽지 않음)
PYTHONUNBUFFERED: "1"
PROCESS_COUNT: ${LPS_API_PROCESS_COUNT:-1}
DB_CONNECTION_BUDGET: ${LPS_DB_CONNECTION_BUDGET:-40} # 전용 PG(max_connections≈100)면 90 근처로 상향
volumes:
- ./lps/config/config.prod.toml:/app/config/config.local.toml:ro # prod DB·시크릿 주입(APP_ENV=local 이 읽는 경로 덮어씀)
ports:
- "9600:9600"
extra_hosts:
- "host.docker.internal:host-gateway"
labels:
autoheal: "true" # HEALTHCHECK 실패 시 autoheal 이 재시작
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
lps-worker:
build:
context: ./lps
dockerfile: Dockerfile.worker # Chromium + Xvfb (headless 는 안티봇에 탐지됨)
container_name: lps-worker
environment:
APP_ENV: local # 이미지 고정값 — 변경 금지
PYTHONUNBUFFERED: "1"
WORKER_CONCURRENCY: "1" # 상품 동시 검색 수(워커별 브라우저 세트, Chrome 4×N)
LPS_PROFILE_DIR: /profiles # Chrome 프로필을 영속 볼륨에 → 재시작해도 cf_clearance 유지
# DB·시크릿(OpenAI/Naver/Decodo)은 아래 config.prod.toml 마운트에서 온다. env override 는 주지 않는다.
volumes:
- ./lps/config/config.prod.toml:/app/config/config.local.toml:ro # prod DB·시크릿 주입(APP_ENV=local 이 읽는 경로 덮어씀)
- lps-profiles:/profiles # Chrome 프로필(쿠키) 영속
extra_hosts:
- "host.docker.internal:host-gateway"
shm_size: "1gb" # Chrome 는 /dev/shm 을 많이 씀 — 부족하면 탭 크래시
stop_grace_period: 75s # graceful 종료 유예 — 기본 10s 면 하던 잡 마무리 전에 SIGKILL
labels:
autoheal: "true" # 하트비트 HEALTHCHECK 실패(행/좀비) 시 autoheal 이 재시작
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# LPS 관리자 UI (정적 React → nginx). /v1·/healthz·/readyz 는 nginx 가 lps-api:9600 으로 프록시(동일 compose 망).
lps-admin:
build:
context: ./lps-admin
dockerfile: Dockerfile
container_name: lps-admin
ports:
- "${LPS_ADMIN_PORT:-30014}:80"
depends_on:
- lps-api
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# HEALTHCHECK 실패 컨테이너 자동 재시작 — autoheal 라벨 붙은 컨테이너(lps-api/lps-worker)를 감시해 재시작.
# docker.sock 마운트 = 도커 제어 권한이므로 신뢰 환경에서만 사용.
autoheal:
image: willfarrell/autoheal:latest
container_name: autoheal
environment:
AUTOHEAL_CONTAINER_LABEL: autoheal
volumes:
- /var/run/docker.sock:/var/run/docker.sock
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
volumes:
lps-profiles:

View File

@ -10,6 +10,8 @@
# 솔루션 랜딩: http://localhost:3100 # 솔루션 랜딩: http://localhost:3100
# agent 서버: http://localhost:9500/docs # agent 서버: http://localhost:9500/docs
# anchoring 배치: 포트 없음 — 상주 스케줄러(격주 토 00:00 KST), docker logs anchoring 으로 확인 # anchoring 배치: 포트 없음 — 상주 스케줄러(격주 토 00:00 KST), docker logs anchoring 으로 확인
# lps API: http://localhost:9600/docs
# lps admin: http://localhost:3400 (nginx → /v1·/healthz 는 lps-api 로 프록시)
# #
# DB 준비(최초 1회): postgres-init 의 SQL 을 대상 DB 에 적용한다. # DB 준비(최초 1회): postgres-init 의 SQL 을 대상 DB 에 적용한다.
# psql -h <host> -p <port> -U <user> -f postgres-init/00-init.sql (스키마 전체: negosium_db + 도메인·learning·anchoring schema) # psql -h <host> -p <port> -U <user> -f postgres-init/00-init.sql (스키마 전체: negosium_db + 도메인·learning·anchoring schema)
@ -42,6 +44,7 @@ services:
# ── LPS(인터넷 최저가) 연동 — 미설정이면 연동 비활성으로 조용히 동작 ── # ── LPS(인터넷 최저가) 연동 — 미설정이면 연동 비활성으로 조용히 동작 ──
LPS_DB_HOST: host.docker.internal # lps_db 읽기전용(수집 배치·조회 API) LPS_DB_HOST: host.docker.internal # lps_db 읽기전용(수집 배치·조회 API)
LPS_BASE_URL: http://host.docker.internal:9600 # 검색요청 enqueue. lps-api 컨테이너 사용 시 http://lps-api:9600 LPS_BASE_URL: http://host.docker.internal:9600 # 검색요청 enqueue. lps-api 컨테이너 사용 시 http://lps-api:9600
# LPS API guard 키는 negodata 의 config.local.toml [WebServerConfig].lps_api_key 로 관리(개발은 빈값=개방)
volumes: volumes:
- ./negodata/backend:/app # 호스트 소스 = 컨테이너 코드. 이게 있어야 수정이 즉시 반영됨 - ./negodata/backend:/app # 호스트 소스 = 컨테이너 코드. 이게 있어야 수정이 즉시 반영됨
ports: ports:
@ -129,10 +132,7 @@ services:
max-size: "10m" max-size: "10m"
max-file: "5" max-file: "5"
# ── LPS (인터넷 최저가 검색) ────────────────────────────────── # LPS API (인터넷 최저가 검색 — 요청 접수/조회, lean). 설정·시크릿은 config.local.toml 하나(미커밋, 마운트).
# API(요청 접수, lean) + 워커(크롤, 헤드풀 Chromium+Xvfb). DB 는 외부(host.docker.internal).
# 이미지엔 시크릿이 없다(example config 로 빌드) — 실값은 아래 env 로 주입.
# 시크릿 값은 리포 루트 .env 파일에 채운다(.env.example 참고, .env 는 미커밋).
lps-api: lps-api:
build: build:
context: ./lps context: ./lps
@ -140,18 +140,12 @@ services:
container_name: lps-api container_name: lps-api
environment: environment:
APP_ENV: local APP_ENV: local
DB_HOST: host.docker.internal # 컨테이너→호스트 DB (example toml 의 127.0.0.1 override) DB_HOST: ${LPS_DB_HOST-host.docker.internal} # 컨테이너→호스트 DB (config.local.toml 의 127.0.0.1 override)
DB_USER: ${LPS_DB_USER:-postgres}
DB_PASSWORD: ${LPS_DB_PASSWORD:-postgres}
PYTHONUNBUFFERED: "1" PYTHONUNBUFFERED: "1"
# 멀티코어: PROCESS_COUNT(uvicorn 워커=코어수)를 올리면 커넥션 풀은 자동 산정된다. volumes:
# (pool+overflow)×2엔진×PROCESS_COUNT ≤ DB_CONNECTION_BUDGET 를 config 가 스스로 보장. - ./lps/config/config.local.toml:/app/config/config.local.toml:ro
# 값은 .env 에서 서버별로 조정(compose 수정 불필요). API 병목은 드묾 — 기본 1이면 충분,
# 부하테스트/대량 폴링 대비 시에만 코어 수만큼 상향(예: 4).
PROCESS_COUNT: ${LPS_API_PROCESS_COUNT:-1}
DB_CONNECTION_BUDGET: ${LPS_DB_CONNECTION_BUDGET:-40} # 전용 PG(max_connections≈100)면 90 근처로 상향
ports: ports:
- "9600:9600" - "${LPS_API_BIND:-0.0.0.0}:9600:9600" # prod 는 LPS_API_BIND=127.0.0.1 로 내부만 개방(리버스프록시 뒤)
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
labels: labels:
@ -161,38 +155,43 @@ services:
driver: json-file driver: json-file
options: { max-size: "10m", max-file: "5" } options: { max-size: "10m", max-file: "5" }
# LPS 워커 (크롤 — 헤드풀 Chromium+Xvfb, headless 는 안티봇에 탐지됨). config.local.toml 공유.
lps-worker: lps-worker:
build: build:
context: ./lps context: ./lps
dockerfile: Dockerfile.worker # Chromium + Xvfb (headless 는 안티봇에 탐지됨) dockerfile: Dockerfile.worker
# google-chrome-stable(Linux)은 amd64 전용 → 이미지 자체가 amd64. arm64 맥에선 명시 없으면
# arm64 로 빌드를 시도하다 Chrome 의존성에서 실패한다(Rosetta 로 에뮬 실행). prod(amd64)에선 무영향.
platform: linux/amd64
container_name: lps-worker container_name: lps-worker
environment: environment:
APP_ENV: local APP_ENV: local
DB_HOST: host.docker.internal DB_HOST: ${LPS_DB_HOST-host.docker.internal} # 컨테이너→호스트 DB (config.local.toml 의 127.0.0.1 override)
DB_USER: ${LPS_DB_USER:-postgres}
DB_PASSWORD: ${LPS_DB_PASSWORD:-postgres}
PYTHONUNBUFFERED: "1" PYTHONUNBUFFERED: "1"
WORKER_CONCURRENCY: "1" # 상품 동시 검색 수(워커별 브라우저 세트, Chrome 4×N)
LPS_PROFILE_DIR: /profiles # Chrome 프로필을 영속 볼륨에 → 재시작해도 cf_clearance 유지(재웜업 회피)
# LPS_FALLBACKS: "gmarket,auction,st11" # 오픈마켓 폴백(기본 OFF — 켜기 전 라이브 스모크로 셀렉터 점검)
# LPS_JOB_DEADLINE_SEC: "300" # 잡 1건 처리 상한(행 방어) — 기본 300s
# ── 시크릿 주입(이미지엔 없음 — 필수). 리포 루트 .env 에 값 채움(.env.example 참고) ──
OPENAI_API_KEY: ${OPENAI_API_KEY:-} # 비면 AI 판정 OFF
NAVER_KEYS: ${NAVER_KEYS:-} # "id1:secret1,id2:secret2" — 비면 네이버 검색 실패
DECODO_HOST: ${DECODO_HOST:-} # DECODO 4종 비면 프록시 미사용(직접 연결)
DECODO_USERNAME: ${DECODO_USERNAME:-}
DECODO_PASSWORD: ${DECODO_PASSWORD:-}
DECODO_PORT_START: ${DECODO_PORT_START:-0}
DECODO_PORT_END: ${DECODO_PORT_END:-0}
DECODO_COST_PER_GB: ${DECODO_COST_PER_GB:-0}
volumes: volumes:
- ./lps/config/config.local.toml:/app/config/config.local.toml:ro
- lps-profiles:/profiles # Chrome 프로필(쿠키) 영속 - lps-profiles:/profiles # Chrome 프로필(쿠키) 영속
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
shm_size: "1gb" # Chrome 는 /dev/shm 을 많이 씀 — 부족하면 탭 크래시 shm_size: "1gb" # Chrome 는 /dev/shm 을 많이 씀 — 부족하면 탭 크래시
stop_grace_period: 75s # graceful 종료 유예(LPS_SHUTDOWN_GRACE_SEC=60 + 정리 여유) — 기본 10s 면 하던 잡 마무리 전에 SIGKILL stop_grace_period: 75s # graceful 종료 유예(worker shutdown_grace_sec=60 + 여유)
labels: labels:
autoheal: "true" # 하트비트 HEALTHCHECK 실패(행/좀비) 시 autoheal 이 재시작 autoheal: "true" # 하트비트 실패(행/좀비) 시 autoheal 재시작
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# LPS 관리자 UI (정적 React → nginx). /v1·/healthz·/readyz 는 nginx 가 lps-api:9600 으로 프록시(앱은 상대경로 호출).
lps-admin:
build:
context: ./lps-admin
dockerfile: Dockerfile
container_name: lps-admin
ports:
- "3400:80"
depends_on:
- lps-api
restart: unless-stopped restart: unless-stopped
logging: logging:
driver: json-file driver: json-file

Binary file not shown.

View File

@ -0,0 +1,157 @@
import AppKit
import CoreGraphics
import Foundation
let output = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "docs/AIO2O-요청사항-반영보고서.pdf"
let W: CGFloat = 595, H: CGFloat = 842, M: CGFloat = 42
let navy = NSColor(calibratedRed: 0.06, green: 0.08, blue: 0.16, alpha: 1)
let ink = NSColor(calibratedRed: 0.11, green: 0.13, blue: 0.18, alpha: 1)
let muted = NSColor(calibratedRed: 0.39, green: 0.43, blue: 0.50, alpha: 1)
let paper = NSColor(calibratedRed: 0.98, green: 0.985, blue: 0.995, alpha: 1)
let line = NSColor(calibratedRed: 0.86, green: 0.88, blue: 0.92, alpha: 1)
let purple = NSColor(calibratedRed: 0.48, green: 0.25, blue: 0.92, alpha: 1)
let green = NSColor(calibratedRed: 0.08, green: 0.60, blue: 0.37, alpha: 1)
let orange = NSColor(calibratedRed: 0.94, green: 0.48, blue: 0.10, alpha: 1)
let red = NSColor(calibratedRed: 0.85, green: 0.24, blue: 0.28, alpha: 1)
let blue = NSColor(calibratedRed: 0.13, green: 0.39, blue: 0.92, alpha: 1)
func pr(_ r: CGRect) -> CGRect { CGRect(x: r.minX, y: H-r.maxY, width: r.width, height: r.height) }
func font(_ s: CGFloat, _ w: NSFont.Weight = .regular) -> NSFont {
NSFont(name: "Apple SD Gothic Neo", size: s) ?? .systemFont(ofSize: s, weight: w)
}
func style(_ s: CGFloat, _ c: NSColor = ink, _ w: NSFont.Weight = .regular,
_ a: NSTextAlignment = .left, _ spacing: CGFloat = 2.5) -> [NSAttributedString.Key:Any] {
let p = NSMutableParagraphStyle(); p.alignment = a; p.lineSpacing = spacing; p.lineBreakMode = .byWordWrapping
return [.font:font(s,w), .foregroundColor:c, .paragraphStyle:p]
}
func text(_ t:String,_ r:CGRect,_ s:CGFloat=10,_ c:NSColor=ink,_ w:NSFont.Weight = .regular,
_ a:NSTextAlignment = .left,_ spacing:CGFloat=2.5) {
NSAttributedString(string:t,attributes:style(s,c,w,a,spacing)).draw(with:pr(r),options:[.usesLineFragmentOrigin,.usesFontLeading])
}
func box(_ r:CGRect,_ fill:NSColor = .white,_ stroke:NSColor? = line,_ radius:CGFloat=10) {
let p=NSBezierPath(roundedRect:pr(r),xRadius:radius,yRadius:radius); fill.setFill(); p.fill()
if let stroke { stroke.setStroke(); p.lineWidth=0.8; p.stroke() }
}
func pill(_ t:String,_ r:CGRect,_ c:NSColor) {
box(r,c.withAlphaComponent(0.12),nil,r.height/2)
text(t,CGRect(x:r.minX,y:r.minY+4,width:r.width,height:r.height-7),8.2,c,.semibold,.center,1)
}
func begin(_ ctx:CGContext,_ page:Int,_ title:String) {
ctx.beginPDFPage(nil); ctx.saveGState(); NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current=NSGraphicsContext(cgContext:ctx,flipped:false)
paper.setFill(); NSBezierPath(rect:pr(CGRect(x:0,y:0,width:W,height:H))).fill()
text(title,CGRect(x:M,y:30,width:420,height:16),7.5,muted,.medium)
text(String(format:"%02d",page),CGRect(x:W-M-30,y:30,width:30,height:16),8,muted,.medium,.right)
let p=NSBezierPath(); p.move(to:CGPoint(x:M,y:31)); p.line(to:CGPoint(x:W-M,y:31))
line.setStroke(); p.lineWidth=0.7; p.stroke()
}
func end(_ ctx:CGContext) {
NSGraphicsContext.restoreGraphicsState(); ctx.restoreGState(); ctx.endPDFPage()
}
func heading(_ n:String,_ t:String,_ sub:String) {
pill(n,CGRect(x:M,y:54,width:34,height:24),purple)
text(t,CGRect(x:86,y:49,width:465,height:30),21,navy,.bold)
text(sub,CGRect(x:M,y:87,width:W-2*M,height:31),9.5,muted,.regular,.left,3)
}
func statusRow(_ no:String,_ title:String,_ body:String,_ status:String,_ c:NSColor,_ y:CGFloat,_ h:CGFloat=82) {
box(CGRect(x:M,y:y,width:W-2*M,height:h),.white,line,9)
pill(no,CGRect(x:M+12,y:y+13,width:28,height:20),c)
text(title,CGRect(x:M+50,y:y+12,width:338,height:19),10.5,navy,.bold)
pill(status,CGRect(x:W-M-102,y:y+12,width:90,height:21),c)
text(body,CGRect(x:M+50,y:y+37,width:W-2*M-64,height:h-44),8.8,ink,.regular,.left,2.4)
}
func metric(_ value:String,_ label:String,_ x:CGFloat,_ c:NSColor) {
box(CGRect(x:x,y:435,width:117,height:96),c.withAlphaComponent(0.08),c.withAlphaComponent(0.3),12)
text(value,CGRect(x:x+8,y:454,width:101,height:32),25,c,.bold,.center)
text(label,CGRect(x:x+8,y:493,width:101,height:20),9,muted,.medium,.center)
}
var media=CGRect(x:0,y:0,width:W,height:H)
guard let consumer=CGDataConsumer(url:URL(fileURLWithPath:output) as CFURL),
let ctx=CGContext(consumer:consumer,mediaBox:&media,nil) else { fatalError("PDF 생성 실패") }
// 1. cover
begin(ctx,1,"AIO2O · 요청사항 반영 보고서")
box(CGRect(x:0,y:0,width:W,height:H),navy,nil,0)
pill("IMPLEMENTATION REVIEW",CGRect(x:M,y:112,width:148,height:25),NSColor(calibratedRed:0.42,green:0.78,blue:1,alpha:1))
text("AIO2O 테스트 및 요청사항\n반영 결과 보고서",CGRect(x:M,y:166,width:510,height:112),34,.white,.bold,.left,7)
text("260727_AIO2O 테스트 및 요청사항.xlsx 기준\n현재 저장소 구현·커밋·검증 캡처 대조",CGRect(x:M,y:310,width:510,height:60),14,NSColor(calibratedWhite:0.78,alpha:1),.regular,.left,7)
box(CGRect(x:M,y:435,width:W-2*M,height:176),NSColor.white.withAlphaComponent(0.07),NSColor.white.withAlphaComponent(0.12),16)
text("결론",CGRect(x:M+22,y:458,width:460,height:25),13,.white,.bold)
text("핵심 업무 흐름은 대부분 구현되었습니다. 견적 목록·상세, 재협상 접수, 목표가 자동계산, 인터넷 최저가, 종료 의견, 결렬폼 통일, VAT 별도 표기, 10원 반올림은 코드 근거가 확인됩니다.\n\n다만 절충안/자동 제안가의 업무 적정성, SG명·유통레벨의 최종 UX, 최저가 VAT 산식은 추가 확인이 필요합니다.",CGRect(x:M+22,y:495,width:W-2*M-44,height:96),11,NSColor(calibratedWhite:0.88,alpha:1),.regular,.left,5)
text("작성일 2026.07.31 | 기준 브랜치 feature/negodata | HEAD 775984fe",CGRect(x:M,y:758,width:W-2*M,height:18),8.5,NSColor(calibratedWhite:0.60,alpha:1))
end(ctx)
// 2. summary
begin(ctx,2,"AIO2O · 요청사항 반영 보고서")
heading("01","종합 요약","엑셀 RAW 시트의 24개 요청을 현재 저장소 상태로 재판정했습니다. 중복 요청은 원 요청 번호를 유지했습니다.")
metric("18","완료·반영",M,green); metric("3","부분 반영",M+130,orange); metric("3","확인 필요",M+260,red); metric("24","전체 항목",M+390,blue)
text("판정 기준",CGRect(x:M,y:566,width:507,height:24),13,navy,.bold)
statusRow("A","완료·반영","사용자 화면과 처리 로직이 모두 확인되거나, 동일 기능을 제공하는 구현 및 검증 캡처가 존재합니다.","18건",green,603,60)
statusRow("B","부분 반영","핵심 기능은 있으나 요청한 명칭·선택값·산식 중 일부가 다르거나 배포/운영 확인이 남았습니다.","3건",orange,675,60)
statusRow("C","확인 필요","코드는 존재하지만 계산 결과의 업무 적정성을 확정할 수 없거나 요청 산식이 명시적으로 확인되지 않습니다.","3건",red,747,60)
end(ctx)
// 3. system 1
begin(ctx,3,"AIO2O · 요청사항 반영 보고서")
heading("02","기본 시스템 반영 내역","견적 생성부터 협력사·최저가·협상 화면까지의 공통 요청입니다.")
statusRow("01","견적관리 목록·상세/히스토리","견적 목록과 상세 드로어가 있으며, 상세의 채팅 탭·협상카드 탭이 세션 데이터를 연결합니다. 견적번호별 진행/완료 상태와 상세 확인 경로가 마련됐습니다.","완료",green,132)
statusRow("02","재협상 접수 및 관리","공급사 포털에서 결렬 건 재협상 요청·철회가 가능하고, 구매자 화면에 재협상 요청 목록·검토 시트·승인/반려 및 알림이 구현됐습니다.","완료",green,226)
statusRow("03","MD 제시가 용어·위치·판매가","‘MD’는 ‘구매담당자’로 통일했고 제시가/산정후보를 ‘3. 낙찰기준’으로 이동했습니다. 판매가 설정은 숨김 처리되어 요청 흐름과 일치합니다.","완료",green,320)
statusRow("04","목표가 자동 산출","매입가 × (1 − 목표 네고율)로 구매담당자 제시가를 자동 입력합니다. 예: 10,000원, 2% → 9,800원. 프론트 자동계산과 백엔드 가격 처리 근거가 있습니다.","완료",green,414)
statusRow("05","공급사/매입가 라벨 일원화","상품 및 견적 화면의 회사별 필드 라벨 설정을 연동해 ‘공급사=매입가’ 표기 정책을 적용할 수 있게 했습니다. 실제 운영 회사 설정값 확인은 필요합니다.","부분",orange,508)
statusRow("06","인터넷 최저가 수집","15%에서 멈추던 Worker/큐 처리 문제를 수정하고, 몰별 결과·진행 상태·이력 화면을 재설계했습니다. 다만 요청 산식 ‘(상품가+배송비)/1.1’의 최종 대표값 적용은 코드에서 확정되지 않습니다.","부분",orange,602)
statusRow("07","신규 상품 공급사 입력","신규 상품 등록 폼에 공급사 선택기를 추가하고 상품–공급사 매핑을 저장하도록 구현했습니다.","완료",green,696)
end(ctx)
// 4. system 2
begin(ctx,4,"AIO2O · 요청사항 반영 보고서")
heading("03","협력사·협상 화면 반영","용어 통일, 종료 단계, 가격 표기와 협상 지표를 중심으로 확인했습니다.")
statusRow("08","협력사 SG명·유통레벨","취급상품 기반 분류와 공급유형 선택/저장은 구현되어 있습니다. 다만 요청한 SG명 콤보와 유통레벨 4종(제조·총판·대리점·일반유통), 취급상품 삭제가 그대로 완성됐는지는 추가 UX 확인이 필요합니다.","부분",orange,132)
statusRow("09","리드타임 → 표준납기","협상 완료 부가정보와 API 설명에 ‘표준납기’가 반영되고 회사 정의 session_fields와 연결됩니다.","완료",green,226)
statusRow("10","협상 단가 VAT 별도","협상 상품정보·요약·목록/상세의 단가 표기를 VAT 별도로 통일했습니다. 검증 캡처도 존재합니다.","완료",green,320)
statusRow("11","협상 성공률 기준 안내","성공률은 공급사 제시가를 앵커가·목표가와 비교한 1~99 지표입니다. 100% 미달이 결렬 조건은 아니며, 실제 종료는 별도 낙찰/개찰 규칙이 결정합니다.","완료(안내)",blue,414)
statusRow("12","협상 종료 추가 의견","타결 부가정보와 결렬 통합폼 모두 ‘기타 의견’을 받으며 sessions.custom.opinion에 저장합니다. 구매자 상세·요약에서 조회되고 완료 후 잠깁니다.","완료",green,508)
statusRow("13","결렬 사유·희망가격 통일","기존 RejectRSP/RejectCM을 단일 RejectForm으로 교체했습니다. 결렬사유·희망가·의견을 한 흐름에서 받고 reject_reason/reject_price에 저장합니다.","완료",green,602)
statusRow("14","카드 사용 횟수 제한","견적 설정의 card_count(기본 3)를 컨텍스트에서 읽어 실제 사용 가능한 카드 수와 종료 조건을 제한하도록 반영했습니다. 운영 시 기존 세션 회귀검증을 권장합니다.","완료",green,696)
end(ctx)
// 5. case-specific
begin(ctx,5,"AIO2O · 요청사항 반영 보고서")
heading("04","견적번호별 이슈 반영","EST-202607-05DE·8945·C9D2 사례에서 제기된 가격/종료 흐름을 대조했습니다.")
statusRow("15","앵커·자동 제안가 10원 반올림","앵커 생성가, 목표가 후보, 협상카드 카운터를 공통으로 10원 단위 반올림합니다. 예: 15,213원 → 15,210원. 관련 커밋과 단위 테스트가 있습니다.","완료",green,132)
statusRow("16","절충안 계산식","협상카드 전술에는 앵커·목표가·직전 제시가를 이용한 중간값 및 목표가 상한 로직이 존재합니다. 다만 ‘절충안’의 기대 공식이 엑셀에 없어 업무적으로 맞는지 확정할 수 없습니다.","확인 필요",red,226)
statusRow("17","05DE/C9D2 결렬 희망가격","견적 유형별로 갈리던 결렬 화면을 단일 폼으로 통합해 희망가격 입력 절차를 동일하게 만들었습니다.","완료",green,320)
statusRow("18","8945 협상 마무리 개편","종료 후 표준납기·MOQ·발주배수·배송유형 등 회사 정의 부가정보를 선택/입력하고, 기타 의견과 함께 최종 요약에 반영합니다. 배송 선택값은 회사 설정에 따라 구성됩니다.","완료",green,414)
statusRow("19","C9D2 자동 제안가 갭","제안가는 카드 전술과 앵커·목표가·직전 제시가의 조합으로 계산되고 10원 반올림됩니다. 17,500원→15,210원의 10.5% 갭이 정책상 적정한지는 목표/앵커 설정을 포함한 별도 검증이 필요합니다.","확인 필요",red,508)
statusRow("20","성공/실패 후 의견 조회","협력사가 입력한 종료 의견은 공급사 요약과 구매자 견적 상세 양쪽에서 확인할 수 있고, 종료 후 읽기 전용으로 잠깁니다.","완료",green,602)
statusRow("21","중복 요청 통합 반영","엑셀 18/22(성공률), 19/25(종료 의견), 5/23(목표가), 14/17/20(결렬폼)은 각각 하나의 공통 구현으로 해소했습니다.","완료",green,696)
end(ctx)
// 6. evidence
begin(ctx,6,"AIO2O · 요청사항 반영 보고서")
heading("05","구현 근거","최근 커밋과 현재 코드에서 확인한 핵심 근거입니다. 커밋 단위로 기능 범위를 추적할 수 있습니다.")
statusRow("A","b33ae05c · 견적 가격/상품/라벨","목표가 자동입력, 산정후보 위치 이동, 앵커·후보 10원 반올림, 신규 상품 공급사 입력, 회사 설정 라벨 연동.","커밋",purple,132,72)
statusRow("B","a56589c6 · 종료폼/의견/VAT","결렬폼 통합, 희망가·사유 저장, 타결/결렬 의견 수취, 상품정보 라벨 연동, 협상 단가 VAT 별도 표기.","커밋",purple,216,72)
statusRow("C","30f13483 · 인터넷 최저가","무한 로딩 버그 수정, Worker 설정 보강, 몰별 최저가·진행/상세 UI 및 이력 저장 개선.","커밋",purple,300,72)
statusRow("D","9dca78dc · 완료 부가정보","완료 부가정보 수취·요약 표시·잠금, 구매자 상세 노출, VAT 표기 통일.","커밋",purple,384,72)
statusRow("E","2a004734 · 견적 상세 연결","견적 상세의 채팅·협상카드 탭 연동과 드로어 탐색 개선.","커밋",purple,468,72)
statusRow("F","775984fe / f554202c · 반올림","협상카드 카운터와 자동 앵커를 10원 단위 반올림으로 통일.","커밋",purple,552,72)
statusRow("G","화면 검증 캡처","목록/상세, 종료 의견, VAT, 완료 요약, 읽기 전용 잠금 등 12개 캡처가 저장소 루트에 남아 있습니다.","캡처",blue,636,72)
text("주의: 본 보고서는 2026-07-31 현재 로컬 저장소의 코드·커밋·캡처를 기준으로 합니다. 운영 배포 여부와 기존 데이터 마이그레이션 상태는 별도 확인 대상입니다.",CGRect(x:M,y:742,width:W-2*M,height:42),8.8,muted,.regular,.left,3)
end(ctx)
// 7. actions
begin(ctx,7,"AIO2O · 요청사항 반영 보고서")
heading("06","남은 확인 및 권고","기능 누락이라기보다 업무 규칙·운영 설정을 확정해야 하는 항목입니다.")
statusRow("1","최저가 VAT 대표값 확정","현재 LPS는 상품가와 배송비를 별도 수집·표시합니다. 대표 최저가를 반드시 (상품가+배송비)/1.1로 저장할지, 화면 표시만 할지 정책을 확정한 뒤 테스트를 추가해야 합니다.","우선순위 높음",red,142,98)
statusRow("2","절충안/자동 제안가 기준 검증","05DE·C9D2의 실제 앵커가·목표가·직전 제시가를 넣어 계산 결과를 재현하고, 허용 최대 인하폭 또는 목표가 클램프 기준을 업무 담당자와 합의하는 것이 좋습니다.","우선순위 높음",red,254,98)
statusRow("3","SG명·유통레벨 UX 확정","현행 취급상품 기반 분류/공급유형을 요청한 SG 콤보와 유통레벨 4종으로 대체할지, 데이터 모델을 유지한 채 라벨만 조정할지 결정이 필요합니다.","우선순위 중간",orange,366,98)
statusRow("4","운영 배포·기존 세션 회귀검증","종료폼, 의견, 카드 횟수 제한은 신규 코드에 반영됐습니다. 운영 컨테이너 재빌드 후 기존 세션과 신규 세션에서 각각 1회 이상 확인해야 합니다.","배포 확인",blue,478,98)
box(CGRect(x:M,y:612,width:W-2*M,height:118),purple.withAlphaComponent(0.08),purple.withAlphaComponent(0.28),12)
text("권장 최종 승인 기준",CGRect(x:M+18,y:630,width:470,height:22),12,purple,.bold)
text("① 운영 배포 버전 확인 ② 대표 견적 3건 시나리오 재실행 ③ 계산식 2건 서면 확정\n④ SG/유통레벨 화면 승인 ⑤ 완료·결렬 의견이 구매자 상세에 저장되는지 확인",CGRect(x:M+18,y:662,width:470,height:50),10,ink,.medium,.left,5)
text("— End of report —",CGRect(x:M,y:760,width:W-2*M,height:20),8,muted,.medium,.center)
end(ctx)
ctx.closePDF()

Binary file not shown.

View File

@ -0,0 +1,790 @@
import AppKit
import CoreGraphics
import Foundation
let outPath = CommandLine.arguments.count > 1
? CommandLine.arguments[1]
: "docs/backend-advanced-concepts-ko.pdf"
let W: CGFloat = 595
let H: CGFloat = 842
let margin: CGFloat = 44
let navy = NSColor(calibratedRed: 0.055, green: 0.086, blue: 0.16, alpha: 1)
let ink = NSColor(calibratedRed: 0.10, green: 0.13, blue: 0.18, alpha: 1)
let muted = NSColor(calibratedRed: 0.37, green: 0.42, blue: 0.50, alpha: 1)
let paper = NSColor(calibratedRed: 0.975, green: 0.98, blue: 0.99, alpha: 1)
let line = NSColor(calibratedRed: 0.86, green: 0.88, blue: 0.92, alpha: 1)
let blue = NSColor(calibratedRed: 0.16, green: 0.39, blue: 0.93, alpha: 1)
let cyan = NSColor(calibratedRed: 0.10, green: 0.69, blue: 0.74, alpha: 1)
let green = NSColor(calibratedRed: 0.10, green: 0.63, blue: 0.39, alpha: 1)
let orange = NSColor(calibratedRed: 0.94, green: 0.47, blue: 0.12, alpha: 1)
let red = NSColor(calibratedRed: 0.88, green: 0.25, blue: 0.28, alpha: 1)
let purple = NSColor(calibratedRed: 0.48, green: 0.32, blue: 0.89, alpha: 1)
func pdfRect(_ r: CGRect) -> CGRect {
CGRect(x: r.minX, y: H - r.maxY, width: r.width, height: r.height)
}
func pdfPoint(_ p: CGPoint) -> CGPoint {
CGPoint(x: p.x, y: H - p.y)
}
func font(_ size: CGFloat, _ weight: NSFont.Weight = .regular) -> NSFont {
NSFont(name: "Apple SD Gothic Neo", size: size)
?? NSFont.systemFont(ofSize: size, weight: weight)
}
func mono(_ size: CGFloat) -> NSFont {
NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
}
func attrs(_ size: CGFloat, color: NSColor = ink, weight: NSFont.Weight = .regular,
align: NSTextAlignment = .left, lineSpacing: CGFloat = 3) -> [NSAttributedString.Key: Any] {
let p = NSMutableParagraphStyle()
p.alignment = align
p.lineSpacing = lineSpacing
p.lineBreakMode = .byWordWrapping
return [.font: font(size, weight), .foregroundColor: color, .paragraphStyle: p]
}
func drawText(_ text: String, _ rect: CGRect, size: CGFloat = 11, color: NSColor = ink,
weight: NSFont.Weight = .regular, align: NSTextAlignment = .left,
lineSpacing: CGFloat = 3) {
NSAttributedString(string: text, attributes: attrs(size, color: color, weight: weight,
align: align, lineSpacing: lineSpacing))
.draw(with: pdfRect(rect), options: [.usesLineFragmentOrigin, .usesFontLeading])
}
func rounded(_ rect: CGRect, radius: CGFloat = 12, fill: NSColor = .white,
stroke: NSColor? = line, width: CGFloat = 1) {
let p = NSBezierPath(roundedRect: pdfRect(rect), xRadius: radius, yRadius: radius)
fill.setFill(); p.fill()
if let stroke { stroke.setStroke(); p.lineWidth = width; p.stroke() }
}
func pill(_ text: String, x: CGFloat, y: CGFloat, w: CGFloat, color: NSColor) {
rounded(CGRect(x: x, y: y, width: w, height: 25), radius: 12.5,
fill: color.withAlphaComponent(0.12), stroke: nil)
drawText(text, CGRect(x: x, y: y + 5, width: w, height: 16), size: 9.5,
color: color, weight: .semibold, align: .center)
}
func arrow(_ from: CGPoint, _ to: CGPoint, color: NSColor = muted) {
let from = pdfPoint(from), to = pdfPoint(to)
let p = NSBezierPath(); p.move(to: from); p.line(to: to)
color.setStroke(); p.lineWidth = 1.8; p.stroke()
let a = atan2(to.y - from.y, to.x - from.x)
let l: CGFloat = 7
let h = NSBezierPath()
h.move(to: to)
h.line(to: CGPoint(x: to.x - l * cos(a - .pi / 6), y: to.y - l * sin(a - .pi / 6)))
h.line(to: CGPoint(x: to.x - l * cos(a + .pi / 6), y: to.y - l * sin(a + .pi / 6)))
h.close(); color.setFill(); h.fill()
}
func node(_ title: String, _ sub: String, rect: CGRect, color: NSColor) {
rounded(rect, radius: 10, fill: color.withAlphaComponent(0.10),
stroke: color.withAlphaComponent(0.55), width: 1.2)
drawText(title, CGRect(x: rect.minX + 8, y: rect.minY + 10, width: rect.width - 16, height: 18),
size: 10.5, color: color, weight: .bold, align: .center)
drawText(sub, CGRect(x: rect.minX + 8, y: rect.minY + 31, width: rect.width - 16, height: rect.height - 36),
size: 8.5, color: muted, align: .center, lineSpacing: 1)
}
func sectionTitle(_ number: String, _ title: String, _ subtitle: String, color: NSColor) {
pill(number, x: margin, y: 48, w: 34, color: color)
drawText(title, CGRect(x: 86, y: 46, width: 450, height: 30), size: 22,
color: navy, weight: .bold)
drawText(subtitle, CGRect(x: margin, y: 82, width: W - 2 * margin, height: 26),
size: 10.5, color: muted)
}
func footer(_ page: Int, _ label: String = "O2O Negosium · Backend Concepts") {
let p = NSBezierPath()
p.move(to: CGPoint(x: margin, y: H - 34)); p.line(to: CGPoint(x: W - margin, y: H - 34))
line.setStroke(); p.lineWidth = 0.7; p.stroke()
drawText(label, CGRect(x: margin, y: H - 28, width: 350, height: 14), size: 7.5, color: muted)
drawText("\(page)", CGRect(x: W - margin - 35, y: H - 28, width: 35, height: 14),
size: 8, color: muted, align: .right)
}
func callout(_ title: String, _ body: String, rect: CGRect, color: NSColor) {
rounded(rect, radius: 12, fill: color.withAlphaComponent(0.08),
stroke: color.withAlphaComponent(0.35))
rounded(CGRect(x: rect.minX, y: rect.minY, width: 5, height: rect.height),
radius: 2.5, fill: color, stroke: nil)
drawText(title, CGRect(x: rect.minX + 16, y: rect.minY + 12,
width: rect.width - 28, height: 20),
size: 11, color: color, weight: .bold)
drawText(body, CGRect(x: rect.minX + 16, y: rect.minY + 37,
width: rect.width - 28, height: rect.height - 45),
size: 9.5, color: ink, lineSpacing: 3)
}
func comparison(_ leftTitle: String, _ left: String, _ rightTitle: String, _ right: String,
y: CGFloat, color: NSColor) {
let gap: CGFloat = 14
let cw = (W - 2 * margin - gap) / 2
callout(leftTitle, left, rect: CGRect(x: margin, y: y, width: cw, height: 126), color: red)
callout(rightTitle, right, rect: CGRect(x: margin + cw + gap, y: y, width: cw, height: 126), color: color)
}
func codeBox(_ title: String, _ path: String, _ code: String, rect: CGRect, accent: NSColor) {
rounded(rect, radius: 10, fill: navy, stroke: nil)
drawText(title, CGRect(x: rect.minX + 14, y: rect.minY + 11,
width: rect.width - 28, height: 17),
size: 10, color: .white, weight: .bold)
drawText(path, CGRect(x: rect.minX + 14, y: rect.minY + 30,
width: rect.width - 28, height: 14),
size: 7.5, color: accent)
let p = NSMutableParagraphStyle(); p.lineSpacing = 2; p.lineBreakMode = .byClipping
NSAttributedString(string: code, attributes: [.font: mono(7.8), .foregroundColor: NSColor(calibratedWhite: 0.88, alpha: 1), .paragraphStyle: p])
.draw(with: pdfRect(CGRect(x: rect.minX + 14, y: rect.minY + 51,
width: rect.width - 28, height: rect.height - 60)),
options: [.usesLineFragmentOrigin])
}
func beginPage(_ ctx: CGContext, page: Int, label: String = "O2O Negosium · Backend Concepts") {
ctx.beginPDFPage(nil)
ctx.saveGState()
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: false)
paper.setFill(); NSBezierPath(rect: pdfRect(CGRect(x: 0, y: 0, width: W, height: H))).fill()
footer(page, label)
}
func endPage(_ ctx: CGContext) {
NSGraphicsContext.restoreGraphicsState()
ctx.restoreGState()
ctx.endPDFPage()
}
var mediaBox = CGRect(x: 0, y: 0, width: W, height: H)
guard let consumer = CGDataConsumer(url: URL(fileURLWithPath: outPath) as CFURL),
let ctx = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
fatalError("PDF context 생성 실패")
}
// 1 — Cover
beginPage(ctx, page: 1, label: "O2O Negosium · Backend Field Guide")
rounded(CGRect(x: 0, y: 0, width: W, height: H), radius: 0, fill: navy, stroke: nil)
for i in 0..<7 {
let x = CGFloat(50 + i * 78)
let c = [blue, cyan, green, orange, purple][i % 5]
rounded(CGRect(x: x, y: 85 + CGFloat((i % 3) * 28), width: 48, height: 48),
radius: 24, fill: c.withAlphaComponent(0.35), stroke: nil)
}
drawText("BACKEND", CGRect(x: margin, y: 190, width: 507, height: 35), size: 15,
color: cyan, weight: .bold)
drawText("어려운 개념 5가지,\n코드로 이해하기", CGRect(x: margin, y: 228, width: 507, height: 118),
size: 36, color: .white, weight: .bold, lineSpacing: 7)
drawText("분산 시스템 · 트랜잭션/동시성 · 멀티테넌시\n캐시 정합성 · 스케줄러/배치",
CGRect(x: margin, y: 370, width: 507, height: 62), size: 15,
color: NSColor(calibratedWhite: 0.80, alpha: 1), lineSpacing: 8)
rounded(CGRect(x: margin, y: 485, width: 507, height: 154), radius: 18,
fill: NSColor.white.withAlphaComponent(0.07),
stroke: NSColor.white.withAlphaComponent(0.15))
drawText("이 문서는 이렇게 읽어요", CGRect(x: 66, y: 510, width: 455, height: 25),
size: 14, color: .white, weight: .bold)
drawText("① 일상 비유로 개념 잡기\n② 실제 서비스 흐름을 그림으로 보기\n③ 프로젝트 코드에서 구현 확인하기\n④ 없을 때 생기는 문제와 비교하기",
CGRect(x: 66, y: 548, width: 455, height: 78), size: 11.5,
color: NSColor(calibratedWhite: 0.88, alpha: 1), lineSpacing: 6)
drawText("Generated from the current repository · 2026-07-29",
CGRect(x: margin, y: 758, width: 507, height: 18), size: 8.5,
color: NSColor(calibratedWhite: 0.62, alpha: 1))
endPage(ctx)
// 2 — Architecture map
beginPage(ctx, page: 2)
drawText("먼저, 서비스 지도를 봅시다", CGRect(x: margin, y: 48, width: 507, height: 34),
size: 24, color: navy, weight: .bold)
drawText("다섯 개념은 따로 노는 것이 아니라, 한 요청이 여러 서비스와 저장소를 지나면서 함께 작동합니다.",
CGRect(x: margin, y: 88, width: 507, height: 32), size: 10.5, color: muted)
node("사용자", "브라우저", rect: CGRect(x: 44, y: 170, width: 90, height: 62), color: purple)
node("Backend", "채팅·공급사", rect: CGRect(x: 184, y: 145, width: 102, height: 72), color: blue)
node("Agent", "AI 협상", rect: CGRect(x: 348, y: 145, width: 102, height: 72), color: orange)
node("Negodata", "견적·관리", rect: CGRect(x: 184, y: 270, width: 102, height: 72), color: cyan)
node("LPS", "최저가 Worker", rect: CGRect(x: 348, y: 270, width: 102, height: 72), color: green)
node("PostgreSQL", "업무 원본", rect: CGRect(x: 184, y: 405, width: 130, height: 72), color: purple)
node("Redis", "앵커링 캐시", rect: CGRect(x: 368, y: 405, width: 100, height: 72), color: red)
arrow(CGPoint(x: 134, y: 200), CGPoint(x: 184, y: 182), color: purple)
arrow(CGPoint(x: 286, y: 180), CGPoint(x: 348, y: 180), color: blue)
arrow(CGPoint(x: 235, y: 217), CGPoint(x: 235, y: 270), color: cyan)
arrow(CGPoint(x: 286, y: 304), CGPoint(x: 348, y: 304), color: green)
arrow(CGPoint(x: 235, y: 342), CGPoint(x: 235, y: 405), color: purple)
arrow(CGPoint(x: 399, y: 342), CGPoint(x: 415, y: 405), color: red)
callout("① 경계가 생기면 ‘분산 시스템’", "서비스 A가 서비스 B를 네트워크로 호출하는 순간, 지연·타임아웃·부분 실패를 다뤄야 합니다.",
rect: CGRect(x: margin, y: 525, width: 246, height: 105), color: blue)
callout("② 여러 실행자가 만나면 ‘동시성’", "사용자 클릭과 스케줄러가 같은 견적을 동시에 마감할 수 있어, DB가 최종 심판 역할을 합니다.",
rect: CGRect(x: 305, y: 525, width: 246, height: 105), color: orange)
callout("③ 빠르게 읽되 원본을 지키면 ‘캐시’", "Redis와 프로세스 메모리는 복사본입니다. PostgreSQL과 설정 파일이 원본입니다.",
rect: CGRect(x: margin, y: 650, width: 246, height: 105), color: red)
callout("④ 회사별 경계를 지키면 ‘멀티테넌시’", "요청 헤더에서 회사 ID를 결정하고, 회사별 설정·엔진을 선택합니다.",
rect: CGRect(x: 305, y: 650, width: 246, height: 105), color: purple)
endPage(ctx)
// 3 — Distributed systems: concept first
beginPage(ctx, page: 3)
sectionTitle("3", "분산 시스템 — 개념부터", "여러 독립 실행 단위가 네트워크를 통해 하나의 업무를 완성하는 시스템", color: blue)
callout("정확한 정의", "프로세스·컨테이너·서버가 각자 메모리와 실행 상태를 가지고, HTTP나 메시지로 통신하는 구조입니다. 한 서비스의 함수 호출과 달리 상대의 상태를 직접 볼 수 없고, 네트워크 응답만으로 결과를 추론해야 합니다.",
rect: CGRect(x: margin, y: 126, width: 507, height: 92), color: blue)
drawText("왜 어려운가: 네트워크에는 네 가지 결과가 있습니다", CGRect(x: margin, y: 244, width: 507, height: 24),
size: 13.5, color: navy, weight: .bold)
let distCases: [(String, String, NSColor)] = [
("성공", "상대가 처리했고 응답도 받음", green),
("명확한 실패", "상대가 오류 응답을 보냄", red),
("연결 실패", "상대에게 요청이 도착하지 않음", orange),
("애매한 타임아웃", "처리는 됐지만 응답만 늦었을 수도 있음", purple),
]
for (i, c) in distCases.enumerated() {
let col = i % 2, row = i / 2
let x = margin + CGFloat(col) * 260
let y = CGFloat(286 + row * 86)
callout(c.0, c.1, rect: CGRect(x: x, y: y, width: 247, height: 70), color: c.2)
}
drawText("대표적인 대응 수단", CGRect(x: margin, y: 475, width: 507, height: 24),
size: 13.5, color: navy, weight: .bold)
callout("Timeout", "얼마나 기다릴지 상한을 둡니다. 짧으면 정상 요청도 실패하고, 길면 자원이 오래 묶입니다.",
rect: CGRect(x: margin, y: 515, width: 159, height: 92), color: blue)
callout("Retry", "일시 실패를 다시 시도합니다. 단, 중복 처리에 안전한 작업에서만 제한적으로 사용합니다.",
rect: CGRect(x: 218, y: 515, width: 159, height: 92), color: orange)
callout("Idempotency", "같은 요청을 여러 번 보내도 결과가 한 번 처리한 것과 같도록 만듭니다.",
rect: CGRect(x: 392, y: 515, width: 159, height: 92), color: purple)
callout("Fallback", "주 서비스가 실패하면 대체 경로·기본값·이전 데이터를 사용합니다. 대체 결과가 업무적으로 허용될 때만 가능합니다.",
rect: CGRect(x: margin, y: 630, width: 247, height: 92), color: green)
callout("Circuit breaker", "실패가 계속되는 서비스를 잠시 호출하지 않아 연쇄 장애를 막습니다. 현재 프로젝트에는 명시적 구현이 없습니다.",
rect: CGRect(x: 304, y: 630, width: 247, height: 92), color: red)
endPage(ctx)
// 4 distributed concept
beginPage(ctx, page: 4)
sectionTitle("3", "분산 시스템과 장애 대응", "한 프로그램이 아니라 여러 서비스가 네트워크로 협력하는 구조", color: blue)
callout("쉬운 비유", "한 식당 안에서 주방과 홀 직원이 말로 협업하는 것이 단일 시스템이라면, 분산 시스템은 서로 다른 건물의 팀이 전화로 협업하는 것입니다. 전화는 늦거나 끊길 수 있고, 상대가 일을 끝냈는데 답만 못 받을 수도 있습니다.",
rect: CGRect(x: margin, y: 130, width: 507, height: 105), color: blue)
drawText("프로젝트의 대표 흐름", CGRect(x: margin, y: 266, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
node("Backend", "사용자 채팅 요청", rect: CGRect(x: 52, y: 315, width: 110, height: 74), color: blue)
node("HTTPX", "timeout 설정", rect: CGRect(x: 242, y: 315, width: 110, height: 74), color: cyan)
node("Agent", "협상 턴 계산", rect: CGRect(x: 432, y: 315, width: 110, height: 74), color: orange)
arrow(CGPoint(x: 162, y: 352), CGPoint(x: 242, y: 352), color: blue)
arrow(CGPoint(x: 352, y: 352), CGPoint(x: 432, y: 352), color: cyan)
drawText("성공", CGRect(x: 394, y: 414, width: 80, height: 18), size: 9, color: green, weight: .bold)
arrow(CGPoint(x: 485, y: 389), CGPoint(x: 485, y: 458), color: green)
node("응답 반영", "채팅 상태 저장", rect: CGRect(x: 430, y: 458, width: 112, height: 65), color: green)
drawText("타임아웃/실패", CGRect(x: 185, y: 414, width: 105, height: 18), size: 9, color: red, weight: .bold)
arrow(CGPoint(x: 297, y: 389), CGPoint(x: 297, y: 458), color: red)
node("안전한 실패", "ok=false 반환", rect: CGRect(x: 241, y: 458, width: 112, height: 65), color: red)
callout("중요한 함정: 타임아웃 ≠ 상대가 아무 일도 안 함", "Agent가 DB 상태를 이미 전진시킨 직후 응답만 늦었을 수 있습니다. 그래서 무조건 재시도하면 같은 턴을 두 번 처리할 위험이 있습니다. 코드가 timed_out을 따로 표시하는 이유입니다.",
rect: CGRect(x: margin, y: 566, width: 507, height: 105), color: orange)
comparison("이 장치가 없으면", "Agent가 느린 순간 Backend 요청도 끝없이 대기합니다. 무작정 재시도하면 협상 step이 두 번 전진할 수 있습니다.",
"현재 방식", "HTTP timeout을 두고 성공/일반 실패/타임아웃을 구분합니다. 호출 경계에서 예외를 응답 객체로 변환합니다.",
y: 695, color: blue)
endPage(ctx)
// 5 distributed code
beginPage(ctx, page: 5)
sectionTitle("3", "분산 시스템 — 실제 코드", "서비스 경계마다 timeout, fallback, best-effort 정책이 다릅니다.", color: blue)
codeBox("Agent 호출: 타임아웃을 별도 상태로 반환",
"backend/services/agent_client.py · lines 81–91",
"""
async with httpx.AsyncClient(
base_url=agent_config.base_url,
timeout=agent_config.timeout_sec,
) as cli:
resp = await cli.post("/v1/chat", json=body, headers=headers)
except httpx.TimeoutException as ex:
# Agent가 이미 처리했을 수 있어 단순 재시도는 위험
return AgentTurn(ok=False, timed_out=True)
except Exception:
return AgentTurn(ok=False)
""",
rect: CGRect(x: margin, y: 130, width: 507, height: 245), accent: cyan)
codeBox("카탈로그 변경 알림: 핵심 업무를 막지 않는 best-effort",
"negodata/backend/services/agent_notify.py · lines 15–26",
"""
try:
async with httpx.AsyncClient(timeout=3.0) as cli:
await cli.post(f"{base}/v1/catalog-refresh-all")
except Exception as ex:
# 알림 실패는 카드 작업을 막지 않는다
LOG.w(f"[agent_notify] 변경 알림 실패(무시): {ex}")
""",
rect: CGRect(x: margin, y: 395, width: 507, height: 160), accent: green)
callout("어떻게 정책을 고르나요?", "결제처럼 반드시 성공해야 하는 작업은 실패를 호출자에게 알려 재처리해야 합니다. 반면 ‘캐시 무효화 알림’처럼 보조적인 작업은 실패해도 핵심 카드 변경을 성공시킬 수 있습니다. 모든 외부 호출을 똑같이 재시도하면 안 됩니다.",
rect: CGRect(x: margin, y: 580, width: 507, height: 105), color: blue)
drawText("기억할 단어", CGRect(x: margin, y: 712, width: 120, height: 20), size: 12,
color: navy, weight: .bold)
pill("timeout", x: 150, y: 707, w: 82, color: blue)
pill("partial failure", x: 242, y: 707, w: 102, color: orange)
pill("best-effort", x: 354, y: 707, w: 92, color: green)
pill("idempotency", x: 456, y: 707, w: 90, color: purple)
endPage(ctx)
// 6 — Transactions and concurrency: concept first
beginPage(ctx, page: 6)
sectionTitle("4", "트랜잭션·동시성 — 개념부터", "데이터의 일관성을 지키는 작업 단위와, 동시에 실행되는 요청을 제어하는 방법", color: orange)
drawText("트랜잭션의 ACID", CGRect(x: margin, y: 126, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
let acid: [(String, String, NSColor)] = [
("A · Atomicity", "전부 성공하거나 전부 취소", orange),
("C · Consistency", "규칙을 만족하는 상태로 이동", green),
("I · Isolation", "동시 작업의 중간 상태를 서로 숨김", purple),
("D · Durability", "COMMIT된 결과는 장애 후에도 보존", blue),
]
for (i, a) in acid.enumerated() {
let x = margin + CGFloat(i % 2) * 260
let y = CGFloat(165 + (i / 2) * 84)
callout(a.0, a.1, rect: CGRect(x: x, y: y, width: 247, height: 68), color: a.2)
}
drawText("동시성 문제는 어떻게 생기나?", CGRect(x: margin, y: 352, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
node("요청 A", "status=OPEN 읽음", rect: CGRect(x: 48, y: 401, width: 116, height: 64), color: blue)
node("요청 B", "status=OPEN 읽음", rect: CGRect(x: 48, y: 500, width: 116, height: 64), color: purple)
node("둘 다 처리", "중복 마감·중복 메일", rect: CGRect(x: 250, y: 449, width: 130, height: 70), color: red)
arrow(CGPoint(x: 164, y: 433), CGPoint(x: 250, y: 471), color: blue)
arrow(CGPoint(x: 164, y: 532), CGPoint(x: 250, y: 497), color: purple)
callout("해결 ① 비관적 잠금", "SELECT ... FOR UPDATE로 먼저 행을 잠급니다. 명확하지만 잠금 대기와 deadlock을 관리해야 합니다.",
rect: CGRect(x: 408, y: 391, width: 143, height: 92), color: orange)
callout("해결 ② 조건부 갱신", "UPDATE ... WHERE status=OPEN 후 rowcount를 확인합니다. 상태 검사와 변경이 원자적으로 일어납니다.",
rect: CGRect(x: 408, y: 497, width: 143, height: 92), color: green)
callout("격리 수준과 잠금은 만능이 아님", "격리를 높이면 안전성은 커지지만 동시 처리량이 줄고 대기·교착 가능성이 커집니다. 업무 규칙에 맞는 최소 범위의 트랜잭션과 조건부 상태 전이가 실용적입니다.",
rect: CGRect(x: margin, y: 624, width: 507, height: 92), color: orange)
callout("COMMIT / ROLLBACK", "COMMIT은 변경 확정, ROLLBACK은 현재 트랜잭션의 미확정 변경 취소입니다. 외부 이메일 발송은 DB rollback으로 되돌릴 수 없다는 점도 중요합니다.",
rect: CGRect(x: margin, y: 720, width: 507, height: 72), color: red)
endPage(ctx)
// 7 transaction concept
beginPage(ctx, page: 7)
sectionTitle("4", "DB 트랜잭션과 동시성 제어", "여러 작업을 하나로 묶고, 동시에 온 요청 중 한 명만 통과시키는 기술", color: orange)
callout("트랜잭션이란?", "은행 이체에서 ‘내 계좌 차감’과 ‘상대 계좌 증가’가 둘 다 성공하거나 둘 다 취소되어야 하듯, 관련 DB 변경을 하나의 작업 단위로 묶는 것입니다. 중간에 실패하면 rollback합니다.",
rect: CGRect(x: margin, y: 130, width: 507, height: 92), color: orange)
drawText("동시 마감 문제", CGRect(x: margin, y: 252, width: 200, height: 24),
size: 14, color: navy, weight: .bold)
node("사용자 클릭", "마감 요청 A", rect: CGRect(x: 50, y: 300, width: 110, height: 66), color: blue)
node("스케줄러", "마감 요청 B", rect: CGRect(x: 50, y: 405, width: 110, height: 66), color: purple)
node("조건부 UPDATE", "status != CLOSED", rect: CGRect(x: 243, y: 350, width: 120, height: 74), color: orange)
node("PostgreSQL", "원자적으로 판정", rect: CGRect(x: 430, y: 350, width: 112, height: 74), color: green)
arrow(CGPoint(x: 160, y: 333), CGPoint(x: 243, y: 376), color: blue)
arrow(CGPoint(x: 160, y: 438), CGPoint(x: 243, y: 399), color: purple)
arrow(CGPoint(x: 363, y: 387), CGPoint(x: 430, y: 387), color: orange)
callout("승자", "영향받은 행 수(rowcount) = 1\n마감 판정 권한 획득",
rect: CGRect(x: 76, y: 518, width: 205, height: 88), color: green)
callout("패자/재요청", "rowcount = 0\n이미 닫혔으므로 추가 처리 중단",
rect: CGRect(x: 314, y: 518, width: 205, height: 88), color: red)
comparison("단순 SELECT 후 UPDATE", "두 요청이 동시에 OPEN을 읽으면 둘 다 마감·낙찰 로직을 실행할 수 있습니다. 이메일도 두 번 발송될 수 있습니다.",
"조건부 UPDATE", "DB가 상태 검사와 변경을 한 문장으로 처리합니다. 먼저 성공한 요청만 rowcount=1을 받습니다.",
y: 640, color: orange)
endPage(ctx)
// 8 transaction code
beginPage(ctx, page: 8)
sectionTitle("4", "트랜잭션·동시성 — 실제 코드", "애플리케이션의 if문보다 DB의 원자적 UPDATE가 강한 최종 방어선입니다.", color: orange)
codeBox("조건부 상태 전이: 마감 권한 선점",
"negodata/backend/crud/quotation_crud.py · lines 482–500",
"""
query = (
update(quotations)
.where(
quotations.qt_id == qt_id,
quotations.status != QuotationStatus.CLOSED.value,
quotations.deleted == False,
)
.values(
status=QuotationStatus.CLOSED.value,
updated_at=GTime.UTC(),
)
)
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
""",
rect: CGRect(x: margin, y: 130, width: 507, height: 235), accent: orange)
codeBox("트랜잭션 실패 시 rollback",
"negodata/backend/common/database/db_session_manager.py · lines 116–127",
"""
try:
await db.commit()
return ErrorType.SUCCESS
except IntegrityError:
await db.rollback()
return ErrorType.DB_ALREADY_SAME_KEY
except Exception:
await db.rollback()
raise
""",
rect: CGRect(x: margin, y: 390, width: 507, height: 175), accent: red)
callout("왜 rowcount를 보나요?", "UPDATE가 에러 없이 실행됐다는 사실만으로는 내가 상태를 바꿨는지 알 수 없습니다. WHERE 조건에 맞는 행이 없으면 SQL은 정상 실행되지만 변경 행은 0개입니다. 그래서 1이면 승자, 0이면 이미 다른 요청이 처리한 것으로 판단합니다.",
rect: CGRect(x: margin, y: 592, width: 507, height: 108), color: orange)
callout("실무 체크", "트랜잭션은 짧게 유지하고, 외부 HTTP·이메일처럼 오래 걸리는 작업을 DB 트랜잭션 안에 오래 붙잡아 두지 않습니다.",
rect: CGRect(x: margin, y: 720, width: 507, height: 65), color: purple)
endPage(ctx)
// 9 — Multitenancy: concept first
beginPage(ctx, page: 9)
sectionTitle("5", "멀티테넌시 — 개념부터", "하나의 애플리케이션을 여러 고객사가 공유하면서 논리적으로 격리하는 설계", color: purple)
callout("Tenant란?", "서비스를 사용하는 독립 고객 단위입니다. 이 프로젝트에서는 주로 ‘회사’가 tenant입니다. 같은 API와 서버를 쓰더라도 회사별 데이터, 설정, 권한, 협상 정책이 섞이면 안 됩니다.",
rect: CGRect(x: margin, y: 126, width: 507, height: 88), color: purple)
drawText("대표적인 데이터 격리 모델", CGRect(x: margin, y: 242, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
callout("DB 분리", "회사마다 별도 DB\n격리 강함 · 운영비 높음",
rect: CGRect(x: margin, y: 282, width: 159, height: 88), color: blue)
callout("Schema 분리", "한 DB 안에서 schema 분리\n중간 수준의 격리와 비용",
rect: CGRect(x: 218, y: 282, width: 159, height: 88), color: cyan)
callout("Row 공유", "같은 테이블 + tenant_id\n효율적 · 쿼리 누락 위험",
rect: CGRect(x: 392, y: 282, width: 159, height: 88), color: orange)
drawText("격리는 DB만의 문제가 아닙니다", CGRect(x: margin, y: 404, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
let tenantAxes: [(String, String)] = [
("식별", "이 요청이 어느 회사 것인지 신뢰할 수 있게 결정"),
("인가", "그 사용자가 해당 회사 자원에 접근 가능한지 확인"),
("데이터", "모든 조회·수정 쿼리에 회사 범위 적용"),
("설정", "회사별 정책·브랜딩·카드 선택"),
("캐시", "캐시 key에 tenant를 포함해 회사 간 충돌 방지"),
("자원", "한 회사의 과부하가 다른 회사에 미치는 영향 제한"),
]
for (i, t) in tenantAxes.enumerated() {
let x = margin + CGFloat(i % 2) * 260
let y = CGFloat(444 + (i / 2) * 74)
rounded(CGRect(x: x, y: y, width: 247, height: 58), radius: 9,
fill: purple.withAlphaComponent(0.06), stroke: purple.withAlphaComponent(0.25))
drawText(t.0, CGRect(x: x + 12, y: y + 10, width: 52, height: 18), size: 10,
color: purple, weight: .bold)
drawText(t.1, CGRect(x: x + 66, y: y + 9, width: 168, height: 38), size: 8.5, color: ink)
}
callout("가장 흔한 사고", "쿼리의 WHERE tenant_id 조건 누락, 공유 캐시 key에 tenant_id 누락, 사용자가 body로 보낸 tenant_id를 그대로 신뢰하는 경우입니다. 그래서 tenant context를 요청 초기에 확정하고 자동 전달하는 구조가 중요합니다.",
rect: CGRect(x: margin, y: 680, width: 507, height: 105), color: red)
endPage(ctx)
// 10 multitenancy concept
beginPage(ctx, page: 10)
sectionTitle("5", "멀티테넌시", "하나의 시스템을 여러 회사가 쓰되, 설정과 데이터의 경계를 지키는 구조", color: purple)
callout("쉬운 비유", "한 오피스 빌딩을 여러 회사가 함께 사용하지만 출입카드가 자기 회사 층만 열어주는 구조입니다. 서버는 공유하되, 요청마다 ‘어느 회사의 요청인지’를 먼저 확정해야 합니다.",
rect: CGRect(x: margin, y: 130, width: 507, height: 92), color: purple)
drawText("요청이 회사별 엔진을 찾는 과정", CGRect(x: margin, y: 252, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
node("HTTP 요청", "X-Tenant-ID", rect: CGRect(x: 45, y: 310, width: 100, height: 68), color: blue)
node("Middleware", "존재·등록 검증", rect: CGRect(x: 195, y: 310, width: 105, height: 68), color: purple)
node("request.state", "tenant_id 보관", rect: CGRect(x: 350, y: 310, width: 105, height: 68), color: cyan)
arrow(CGPoint(x: 145, y: 344), CGPoint(x: 195, y: 344), color: blue)
arrow(CGPoint(x: 300, y: 344), CGPoint(x: 350, y: 344), color: purple)
node("Registry", "회사별 엔진 선택", rect: CGRect(x: 195, y: 445, width: 105, height: 68), color: orange)
node("TenantEngine", "회사별 정책·카드", rect: CGRect(x: 350, y: 445, width: 105, height: 68), color: green)
arrow(CGPoint(x: 402, y: 378), CGPoint(x: 275, y: 445), color: cyan)
arrow(CGPoint(x: 300, y: 479), CGPoint(x: 350, y: 479), color: orange)
callout("보안 핵심", "tenant_id를 요청 body에서 받으면 사용자가 다른 회사 ID를 넣어 위조할 수 있습니다. 이 프로젝트는 헤더/경로에서 결정한 값을 middleware가 request.state에 넣고, 뒤의 코드가 그것만 사용합니다.",
rect: CGRect(x: margin, y: 558, width: 507, height: 105), color: red)
comparison("멀티테넌시 경계가 약하면", "A회사 요청이 B회사 카드·설정·협상 엔진을 사용할 수 있습니다. 이는 단순 버그가 아니라 데이터 유출 사고입니다.",
"현재 방식", "요청 시작점에서 tenant를 검증하고, Registry가 해당 회사의 설정과 엔진을 해석합니다.",
y: 687, color: purple)
endPage(ctx)
// 11 multitenancy code
beginPage(ctx, page: 11)
sectionTitle("5", "멀티테넌시 — 실제 코드", "식별 → 검증 → request.state 전달 → 회사별 엔진 선택의 4단계", color: purple)
codeBox("Middleware: tenant를 요청 경계에서 확정",
"agent/router/middleware/tenant_middleware.py · lines 35–69",
"""
tenant_id = request.headers.get("X-Tenant-ID")
if not tenant_id:
return JSONResponse(status_code=400, ...)
if not tenant_registry.is_registered(tenant_id):
return JSONResponse(status_code=404, ...)
request.state.tenant_id = tenant_id
return await call_next(request)
""",
rect: CGRect(x: margin, y: 130, width: 507, height: 205), accent: purple)
codeBox("Dependency: 검증된 tenant로 엔진 조회",
"agent/router/deps.py · lines 13–21",
"""
async def get_tenant_engine(request: Request) -> TenantEngine:
tenant_id = getattr(request.state, "tenant_id", None)
if not tenant_id:
raise EXCEPTION_TENANT_HEADER_MISSING
return await tenant_registry.get_engine(tenant_id)
""",
rect: CGRect(x: margin, y: 360, width: 507, height: 165), accent: cyan)
codeBox("Registry: 프로세스 메모리에서 회사별 엔진 재사용",
"agent/tenancy/registry.py · lines 85–98",
"""
cached = self._engines.get(tenant_id)
if cached is not None:
return cached
async with self._locks[tenant_id]:
cached = self._engines.get(tenant_id)
if cached is not None:
return cached
engine = await self._build(tenant_id)
self._engines[tenant_id] = engine
return engine
""",
rect: CGRect(x: margin, y: 550, width: 507, height: 205), accent: orange)
endPage(ctx)
// 12 — Cache consistency: concept first
beginPage(ctx, page: 12)
sectionTitle("8", "캐시 정합성 — 개념부터", "비싼 계산·DB·외부 호출의 결과를 가까운 곳에 복사해 재사용하는 기술", color: red)
drawText("기본 용어", CGRect(x: margin, y: 126, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
let cacheTerms: [(String, String, NSColor)] = [
("Hit", "캐시에 값이 있어 원본을 읽지 않음", green),
("Miss", "값이 없어 원본을 읽고 캐시를 채움", blue),
("TTL", "값이 자동 만료될 때까지의 시간", orange),
("Stale", "원본은 바뀌었지만 캐시는 옛 값인 상태", red),
]
for (i, t) in cacheTerms.enumerated() {
let x = margin + CGFloat(i % 2) * 260
let y = CGFloat(165 + (i / 2) * 75)
callout(t.0, t.1, rect: CGRect(x: x, y: y, width: 247, height: 60), color: t.2)
}
drawText("대표적인 읽기·쓰기 패턴", CGRect(x: margin, y: 338, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
callout("Cache-aside", "앱이 캐시를 먼저 조회하고 miss이면 DB를 읽어 캐시에 저장합니다. 단순하고 가장 흔하지만 무효화를 앱이 책임집니다.",
rect: CGRect(x: margin, y: 378, width: 247, height: 92), color: blue)
callout("Write-through", "쓰기 때 캐시와 원본을 함께 갱신합니다. 읽기는 안정적이지만 쓰기 지연과 두 저장소의 부분 실패를 다뤄야 합니다.",
rect: CGRect(x: 304, y: 378, width: 247, height: 92), color: purple)
callout("Write-behind", "캐시에 먼저 쓰고 DB는 나중에 반영합니다. 빠르지만 캐시 장애 시 데이터 유실 위험이 있어 업무 원본에는 신중해야 합니다.",
rect: CGRect(x: margin, y: 486, width: 247, height: 92), color: orange)
callout("Negative cache", "‘결과 없음’도 잠깐 저장합니다. 반복 실패 비용을 줄이지만 너무 긴 TTL은 새로 생긴 데이터를 늦게 발견하게 합니다.",
rect: CGRect(x: 304, y: 486, width: 247, height: 92), color: green)
drawText("캐시에서 자주 생기는 문제", CGRect(x: margin, y: 610, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
callout("Invalidation", "원본 변경 후 어떤 key를 언제 삭제·갱신할지 결정하기 어렵습니다.",
rect: CGRect(x: margin, y: 650, width: 159, height: 82), color: red)
callout("Stampede", "인기 key가 만료되는 순간 많은 요청이 동시에 DB로 몰립니다.",
rect: CGRect(x: 218, y: 650, width: 159, height: 82), color: orange)
callout("Key 설계", "tenant·버전 등이 빠지면 서로 다른 데이터가 같은 key를 공유합니다.",
rect: CGRect(x: 392, y: 650, width: 159, height: 82), color: purple)
endPage(ctx)
// 13 cache concept
beginPage(ctx, page: 13)
sectionTitle("8", "캐시 정합성과 무효화", "빠른 복사본이 원본과 다른 값을 갖지 않도록 관리하는 문제", color: red)
callout("캐시는 복사본", "도서관 검색대의 메모가 캐시이고, 원본 장부가 DB라고 생각하면 쉽습니다. 메모는 빠르지만 오래된 정보일 수 있습니다. 정합성이란 메모와 장부가 의미상 같은 상태를 유지하는 것입니다.",
rect: CGRect(x: margin, y: 130, width: 507, height: 95), color: red)
drawText("앵커링 값의 저장·조회 순서", CGRect(x: margin, y: 252, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
node("1. DB 저장", "조정 이력 COMMIT", rect: CGRect(x: 46, y: 310, width: 118, height: 70), color: purple)
node("2. Redis SET", "최신값 + TTL 7일", rect: CGRect(x: 238, y: 310, width: 118, height: 70), color: red)
node("3. 다음 조회", "Redis 우선", rect: CGRect(x: 430, y: 310, width: 118, height: 70), color: blue)
arrow(CGPoint(x: 164, y: 345), CGPoint(x: 238, y: 345), color: purple)
arrow(CGPoint(x: 356, y: 345), CGPoint(x: 430, y: 345), color: red)
drawText("Redis 실패", CGRect(x: 240, y: 417, width: 110, height: 18), size: 9, color: red, weight: .bold)
arrow(CGPoint(x: 297, y: 380), CGPoint(x: 297, y: 465), color: red)
node("DB Fallback", "업무는 계속", rect: CGRect(x: 238, y: 465, width: 118, height: 70), color: green)
callout("stale 데이터란?", "DB에는 새 값 60이 저장됐는데 Redis SET이 실패해 캐시에 옛 값 55가 남은 상태입니다. 캐시 miss와 달리 값이 존재하므로 더 위험합니다. TTL과 주간 re-SET으로 회복합니다.",
rect: CGRect(x: margin, y: 574, width: 507, height: 95), color: orange)
comparison("캐시만 믿으면", "Redis 장애가 업무 장애가 되고, 오래된 값이 실제 제안가를 왜곡할 수 있습니다. Redis 유실 시 원본도 사라집니다.",
"원본 DB + 파생 캐시", "Redis 장애 시 DB를 읽고, TTL과 reconciliation으로 오래된 복사본을 교정합니다.",
y: 693, color: red)
endPage(ctx)
// 14 cache code
beginPage(ctx, page: 14)
sectionTitle("8", "캐시 정합성 — 실제 코드", "Cache-aside, TTL, DB fallback, reconciliation이 한 세트로 작동합니다.", color: red)
codeBox("Cache-aside: Redis miss → DB → Redis backfill",
"schedules/anchoring/src/anchoring/reader.py · lines 33–42",
"""
cached = await get_value(company_id, supplier_type, price_range)
if cached is not None:
return cached
value = await get_latest_adjusted_value(
db, company_id, supplier_type, price_range
)
if value is None:
value = get_base_anchoring_value(price_range)
await set_value(..., value, nx=True)
return value
""",
rect: CGRect(x: margin, y: 130, width: 507, height: 225), accent: red)
codeBox("Redis 장애는 cache miss로 취급",
"schedules/anchoring/src/anchoring/redis_client.py · lines 69–84",
"""
try:
raw = await _client.get(anchor_key(...))
if raw is None:
return None
return int(raw)
except Exception as ex:
_note_failure("get", anchor_key(...), ex)
return None # 호출측이 DB fallback
""",
rect: CGRect(x: margin, y: 380, width: 507, height: 180), accent: orange)
callout("두 겹의 회복 장치", "TTL 7일은 오래된 키가 영원히 남는 것을 막습니다. 주간 reconciliation은 DB의 최신 조정값을 Redis에 다시 SET하여, DB commit 뒤 Redis 갱신에 실패했던 값도 교정합니다.",
rect: CGRect(x: margin, y: 585, width: 507, height: 100), color: red)
callout("현재 견적 생성 경로의 예외", "Negodata의 실제 견적 생성은 Redis를 사용하지 않고 PostgreSQL의 anchoring.current_values View를 일괄 조회합니다. Redis는 현재 anchoring 배치 내부 캐시입니다.",
rect: CGRect(x: margin, y: 705, width: 507, height: 72), color: cyan)
endPage(ctx)
// 15 — Scheduler and batch: concept first
beginPage(ctx, page: 15)
sectionTitle("9", "스케줄러·배치 — 개념부터", "시간 규칙으로 작업을 시작하고, 많은 데이터를 사용자 요청 밖에서 처리하는 방식", color: green)
callout("둘의 차이", "스케줄러는 ‘언제 실행할지’를 결정합니다. 배치 Job은 ‘무엇을 어떻게 처리할지’를 구현합니다. CronTrigger가 알람시계라면 close_expired_quotations는 알람이 울렸을 때 수행할 실제 업무입니다.",
rect: CGRect(x: margin, y: 126, width: 507, height: 92), color: green)
drawText("Job의 생명주기", CGRect(x: margin, y: 248, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
node("Trigger", "실행 시각 도달", rect: CGRect(x: 45, y: 297, width: 94, height: 66), color: green)
node("Select", "처리 대상 조회", rect: CGRect(x: 181, y: 297, width: 94, height: 66), color: blue)
node("Process", "개별 업무 수행", rect: CGRect(x: 317, y: 297, width: 94, height: 66), color: orange)
node("Checkpoint", "결과·진행점 기록", rect: CGRect(x: 453, y: 297, width: 94, height: 66), color: purple)
arrow(CGPoint(x: 139, y: 330), CGPoint(x: 181, y: 330), color: green)
arrow(CGPoint(x: 275, y: 330), CGPoint(x: 317, y: 330), color: blue)
arrow(CGPoint(x: 411, y: 330), CGPoint(x: 453, y: 330), color: orange)
drawText("운영에서 반드시 결정할 것", CGRect(x: margin, y: 405, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
let jobIssues: [(String, String, NSColor)] = [
("중복 실행", "이전 Job이 안 끝났는데 다음 시각이 오면?", red),
("Misfire", "서버가 꺼져 실행 시각을 놓쳤다면?", orange),
("부분 실패", "100건 중 73번째가 실패하면 어디부터 재개?", purple),
("재시도", "즉시 재시도, 다음 tick, 운영자 재처리 중 무엇?", blue),
("멱등성", "같은 대상을 다시 처리해도 중복 효과가 없는가?", green),
("관측성", "처리량·실패 대상·소요시간을 로그와 지표로 남기는가?", cyan),
]
for (i, j) in jobIssues.enumerated() {
let x = margin + CGFloat(i % 2) * 260
let y = CGFloat(445 + (i / 2) * 73)
callout(j.0, j.1, rect: CGRect(x: x, y: y, width: 247, height: 58), color: j.2)
}
callout("스케줄러만으로 정확성은 보장되지 않음", "max_instances=1은 한 프로세스 안의 중복을 막을 뿐입니다. 서버가 여러 대면 각 서버가 Job을 실행할 수 있으므로 DB 조건부 갱신, 분산 락, 전용 Worker 같은 추가 방어가 필요합니다.",
rect: CGRect(x: margin, y: 680, width: 507, height: 105), color: red)
endPage(ctx)
// 16 scheduler concept
beginPage(ctx, page: 16)
sectionTitle("9", "스케줄러와 배치 안정성", "사용자 요청 없이 정해진 시간마다 반복 업무를 수행하는 백그라운드 실행", color: green)
callout("쉬운 비유", "API가 손님이 주문할 때 움직이는 직원이라면, 스케줄러는 매 5분마다 마감 시간이 지난 주문을 확인하는 당직자입니다. 사람이 요청하지 않아도 시간이 되면 일을 시작합니다.",
rect: CGRect(x: margin, y: 130, width: 507, height: 92), color: green)
drawText("5분 tick의 세 가지 작업", CGRect(x: margin, y: 252, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
node("CronTrigger", "매 5분", rect: CGRect(x: 48, y: 315, width: 105, height: 68), color: green)
node("잡 ①", "기한 지난 견적 마감", rect: CGRect(x: 225, y: 280, width: 135, height: 62), color: orange)
node("잡 ②", "협상 완료 견적 마감", rect: CGRect(x: 225, y: 365, width: 135, height: 62), color: purple)
node("잡 ③", "LPS 결과 증분 반영", rect: CGRect(x: 225, y: 450, width: 135, height: 62), color: cyan)
arrow(CGPoint(x: 153, y: 349), CGPoint(x: 225, y: 311), color: green)
arrow(CGPoint(x: 153, y: 349), CGPoint(x: 225, y: 396), color: green)
arrow(CGPoint(x: 153, y: 349), CGPoint(x: 225, y: 481), color: green)
node("PostgreSQL", "조건부 처리·기록", rect: CGRect(x: 430, y: 365, width: 115, height: 72), color: blue)
arrow(CGPoint(x: 360, y: 311), CGPoint(x: 430, y: 385), color: orange)
arrow(CGPoint(x: 360, y: 396), CGPoint(x: 430, y: 401), color: purple)
arrow(CGPoint(x: 360, y: 481), CGPoint(x: 430, y: 420), color: cyan)
callout("중복 실행 방지 장치", "SCHEDULER_ENABLED=1인 프로세스 하나만 잡을 등록합니다. 각 잡은 max_instances=1이고, 밀린 실행은 coalesce=True로 한 번만 실행합니다. 그래도 다중 서버 가능성을 고려해 DB의 조건부 UPDATE가 마지막 방어선입니다.",
rect: CGRect(x: margin, y: 565, width: 507, height: 115), color: green)
comparison("안정 장치가 없으면", "서버 Worker 수만큼 같은 잡이 실행되고, 같은 견적을 여러 번 마감하거나 알림을 중복 발송할 수 있습니다.",
"현재 방식", "실행 프로세스 제한 + 잡 중복 제한 + DB 동시성 가드를 겹쳐 사용합니다.",
y: 704, color: green)
endPage(ctx)
// 17 scheduler code
beginPage(ctx, page: 17)
sectionTitle("9", "스케줄러·배치 — 실제 코드", "‘언제 실행할지’와 ‘무엇을 안전하게 처리할지’를 분리합니다.", color: green)
codeBox("APScheduler 등록: 5분, 중복 방지, 지연 허용",
"negodata/backend/scheduler/__init__.py · lines 37–69",
"""
_scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
_scheduler.add_job(
jobs.close_expired_quotations,
CronTrigger(minute="*/5"),
id="close_expired_quotations",
coalesce=True, # 밀린 실행은 1번만
misfire_grace_time=600, # 10분 내 지연 실행 허용
max_instances=1, # 같은 잡 동시 실행 금지
)
""",
rect: CGRect(x: margin, y: 130, width: 507, height: 215), accent: green)
codeBox("Job: 대상 조회와 개별 마감 처리를 분리",
"negodata/backend/scheduler/jobs.py · lines 39–61",
"""
err_type, qt_ids = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: crud.list_due_for_close(s, now),
)
if err_type != ErrorType.SUCCESS:
return 0
results = await _close_each(service, qt_ids)
return sum(results.values())
""",
rect: CGRect(x: margin, y: 370, width: 507, height: 190), accent: cyan)
callout("멱등성(idempotency)", "같은 잡을 두 번 실행해도 최종 결과가 한 번 실행한 것과 같도록 만드는 성질입니다. 대상 조회가 중복될 수 있어도 claim_for_close의 조건부 UPDATE가 두 번째 처리를 rowcount=0으로 막습니다.",
rect: CGRect(x: margin, y: 585, width: 507, height: 105), color: purple)
callout("실패한 tick은 어떻게 되나요?", "LPS 동기화는 watermark 기반 증분 처리라 실패한 회차의 데이터가 다음 5분 tick에서 다시 대상이 됩니다. 스케줄러 자체 재시도보다 데이터 설계를 통해 회복합니다.",
rect: CGRect(x: margin, y: 710, width: 507, height: 72), color: green)
endPage(ctx)
// 18 combined scenario
beginPage(ctx, page: 18)
drawText("다섯 기술이 한 장면에서 만나는 순간", CGRect(x: margin, y: 48, width: 507, height: 34),
size: 23, color: navy, weight: .bold)
drawText("예: 협상이 모두 끝난 견적을 스케줄러가 자동 마감하는 동안 담당자가 수동 마감을 클릭했다.",
CGRect(x: margin, y: 88, width: 507, height: 30), size: 10.5, color: muted)
let rows: [(String, String, NSColor)] = [
("1", "멀티테넌시: 요청의 X-Tenant-ID로 어느 회사의 협상 엔진과 데이터인지 결정", purple),
("2", "분산 시스템: Backend가 Agent를 HTTP로 호출할 때 timeout과 부분 실패를 구분", blue),
("3", "스케줄러: 5분 tick이 동일 견적을 마감 대상으로 발견", green),
("4", "동시성 제어: 수동 요청과 스케줄러 중 조건부 UPDATE를 먼저 성공한 쪽만 처리", orange),
("5", "트랜잭션: 관련 상태 변경을 commit하거나, 실패하면 rollback", cyan),
("6", "캐시 정합성: 원본 DB commit 후 파생 캐시를 갱신하고 실패 시 다음 회차에 회복", red),
]
for (i, item) in rows.enumerated() {
let y = CGFloat(145 + i * 91)
rounded(CGRect(x: margin, y: y, width: 507, height: 70), radius: 12,
fill: item.2.withAlphaComponent(0.08), stroke: item.2.withAlphaComponent(0.30))
rounded(CGRect(x: 58, y: y + 15, width: 40, height: 40), radius: 20,
fill: item.2, stroke: nil)
drawText(item.0, CGRect(x: 58, y: y + 24, width: 40, height: 20), size: 13,
color: .white, weight: .bold, align: .center)
drawText(item.1, CGRect(x: 116, y: y + 15, width: 415, height: 42), size: 10.2,
color: ink, weight: .medium, lineSpacing: 3)
if i < rows.count - 1 {
arrow(CGPoint(x: 78, y: y + 70), CGPoint(x: 78, y: y + 90), color: item.2)
}
}
callout("핵심 관점", "어려운 백엔드 기술은 ‘라이브러리 이름’보다 경계와 실패를 다루는 방법입니다. 네트워크 경계, 회사 경계, 트랜잭션 경계, 캐시의 원본 경계를 명확하게 설계하는 것이 핵심입니다.",
rect: CGRect(x: margin, y: 708, width: 507, height: 78), color: navy)
endPage(ctx)
// 19 glossary
beginPage(ctx, page: 19)
drawText("초보자를 위한 한 줄 사전", CGRect(x: margin, y: 48, width: 507, height: 34),
size: 24, color: navy, weight: .bold)
let glossary: [(String, String)] = [
("분산 시스템", "여러 프로세스·서버가 네트워크로 협력하는 시스템"),
("부분 실패", "전체 중 일부 서비스만 실패한 상태"),
("Timeout", "응답을 무한히 기다리지 않고 정해진 시간에 포기하는 제한"),
("Best-effort", "실패해도 핵심 업무는 성공시키는 보조 작업 정책"),
("트랜잭션", "여러 DB 변경을 모두 성공 또는 모두 취소하는 작업 단위"),
("Rollback", "실패했을 때 트랜잭션의 변경을 되돌리는 것"),
("Race condition", "실행 순서에 따라 결과가 달라지는 동시성 문제"),
("원자적 연산", "중간 상태가 보이지 않도록 한 번에 처리되는 연산"),
("멀티테넌시", "한 시스템을 여러 고객사가 격리된 상태로 공유하는 구조"),
("Cache-aside", "캐시를 먼저 보고, miss이면 원본 조회 후 캐시를 채우는 패턴"),
("TTL", "캐시 값이 자동 만료되기까지의 시간"),
("Stale", "원본보다 오래되어 현재와 맞지 않는 캐시 상태"),
("Invalidation", "원본 변경 시 캐시를 삭제하거나 무효화하는 것"),
("Reconciliation", "원본과 복사본을 비교·재적재해 다시 맞추는 작업"),
("Scheduler", "정해진 시간 규칙에 따라 작업을 실행하는 도구"),
("Batch", "사용자 요청과 별개로 데이터 묶음을 주기적으로 처리하는 작업"),
("멱등성", "같은 작업을 반복해도 최종 결과가 달라지지 않는 성질"),
]
for (i, g) in glossary.enumerated() {
let col = i < 9 ? 0 : 1
let row = col == 0 ? i : i - 9
let x = margin + CGFloat(col) * 260
let y = CGFloat(128 + row * 69)
drawText(g.0, CGRect(x: x, y: y, width: 230, height: 19), size: 10.5,
color: [blue, orange, purple, red, green][i % 5], weight: .bold)
drawText(g.1, CGRect(x: x, y: y + 23, width: 230, height: 36), size: 8.8,
color: ink, lineSpacing: 2)
}
callout("추천 복습 순서", "트랜잭션·동시성 → 캐시 정합성 → 스케줄러 → 멀티테넌시 → 분산 시스템 순으로 다시 보면, 작은 DB 작업에서 전체 서비스 구조로 이해가 확장됩니다.",
rect: CGRect(x: margin, y: 718, width: 507, height: 74), color: blue)
endPage(ctx)
ctx.closePDF()
print(outPath)

View File

@ -0,0 +1,108 @@
# 협상 QA 체크리스트 (260727 요청 검증)
> 대상: **negodata 어드민**(:3000) + **negosium 공급사 포털**(:3300). 두 앱을 함께 돌려야 하는 플로우라 루트 `docs/`에 둔다.
> 형식: 케이스별 `~하면 → ~나와야 한다`. 근거는 코드 검증(2026-07-30) 기준. ⚠=현재 알려진 이슈.
> 런타임 주의: negosium front/backend는 이미지 빌드라 코드 수정은 **재빌드해야 반영**된다.
---
## 0. 요청사항 반영 현황 (코드 검증 결과, Excel 상태열 아님)
| # | 요청 | 판정 | 비고 |
|---|------|------|------|
| 2 | 견적관리 리스트+히스토리·상세 | ✅ | /quotation + `?detail=` 시트(협상현황/대화/카드, 라운드 타임라인) |
| 3 | 재협상 리스트·심사 | ✅ | /renegotiation 대기/승인/반려 |
| 4 | MD→구매담당자·판매가 미노출·위치 | ✅ | |
| 5/23 | 목표가=매입가×(1−네고율) 자동 | ✅ | 재/재견적 한정, 네고율=견적세팅 목표마진 |
| 6 | 공급가=매입가 일원화 | ⚙ 설정 | 코드 지원됨(`hideCls`). IMK `hidden_fields`에 `"price"` 추가하면 매입가만 남음 |
| 7 | 최저가 크롤 + VAT산식 | ⚠ 인프라 / 논이슈 | VAT는 설정·아이템별 처리(논이슈). 단 dev의 lps-worker 크래시+수집테이블 0행 → 수집 파이프라인 확인 필요 |
| 8 | 신규 상품 공급사 입력칸 | ✅ | |
| 9 | SG명/유통레벨 콤보·취급상품 삭제 | ❌ 미구현 | §9 참조 |
| 10 | 리드타임→표준납기 | ✅ 수정 | Summary.tsx 하드코딩 라벨 회사설정 반영으로 수정(2026-07-30) |
| 11 | 단가 VAT별도 | ✅ | 회사설정 `item_vat_yn` 동적(IMK는 vat_yn hidden) |
| 12 | 앵커 10원 반올림 | ✅ | `calc_anchoring_price` + 카드 카운터(`compute_counter`) + 와일드카드 1%(2026-07-31 수정) 모두 10원 반올림 |
| 13 | 절충안 계산식 | ✅ | `compute_counter` 점검, 버그 없음 |
| 14/17 | 결렬 희망가 입력란 통일 | ✅ | 단일 RejectForm |
| 15 | 마무리 배송 콤보/기타의견/투찰요약 | ✅ | ExtraInfoBar가 session_fields select 렌더 → 콤보 [직납/IMK배송/IMK집배송] |
| 16 | 제안가 갭·반올림 | ✅ | 갭은 정상(목표가=후보 min×수수료). 봇 제안가(카운터·와일드카드 1%)도 10원 반올림 반영(2026-07-31) |
| 18/22 | 성공률 기준·결렬 무관 | ✅ | 게이지는 참고용, 판정과 무관(§D). 타결=목표가 아래로/봇 카운터 수락, 결렬=카드3+최종제안 소진까지 목표가 위 |
| 19/25 | 종료 의견 단계 | ✅ | 타결 ExtraInfoBar(custom.opinion)+결렬 RejectForm 의견 |
| 20 | 결렬사유 상시노출·필수 | ✅ 검증 | 결렬 세션 9/9 사유 존재. §20 참조 |
| 21 | 카드 3회 초과 | ✅ 정상 | 3회=협상카드 한도. 와일드카드·최종제안은 별도로 붙는 구조라 보이는 카드가 3장을 넘을 수 있음(§21) |
| 24 | 목표가 필드 1→3.낙찰기준 이동 | ✅ | |
---
## A. 견적 생성 (어드민)
- **A1** 재/재견적에서 매입가 10,000·네고율(견적세팅 목표마진) 2% 입력 → 목표가(구매담당자 제시가) 필드에 **9,800 자동 채움**(10원 반올림)
- **A2** 목표가 직접 안 건드리고 생성 → `md_price=null`로 전송, 서버가 후보 최솟값으로 재산정
- **A3** **신규**협상/신규견적 → 매입가 후보 빠지고 인터넷최저가만 사용 → 자동 목표가가 매입가 기반이 아님(A1과 다른 게 정상)
- **A4** 1.기본정보엔 목표가 없어야 / **3.낙찰기준** 스텝에 목표가+산정후보 나와야
- **A5** 판매가 입력칸 없어야 / 라벨 "구매담당자 제시가"
## B. 앵커·제안가
- **B1** 협상 시작 → 세션 앵커가 = 목표가×(1−1%), **끝자리 0** (예 목표 7,650 → 앵커 7,570)
- **B2** 봇 재제안가 전부 **끝자리 0** — 카드 카운터(절충가·목표가 제시)와 **와일드카드 1% 인하가** 포함 (예 제시 15,555 → 1% 인하가 15,400). 1원 단위가 보이면 불량
## C. 협상 진행 (실시간 챗, 공급사 포털)
- **C1** 제시가 **≤ 앵커가** → **즉시 협상완료(투찰 확정)**, 성공률 99
- **C2** 앵커가 < 제시가 ≤ 앵커×1.02 → **와일드카드 1% 인하 요청**(세션 1회), 성공률 99~80
- **C3** 앵커×1.02 초과 → **일반 협상카드로 재제안(최대 3번)**, 성공률 표시(제시가 낮을수록↑)
- **C4** 카드 소진 또는 재제안 3번 후에도 앵커 밑 못 내림 → **협상 실패(결렬, 투찰 없음)**
- **C5** 카드 사용한도 3은 **협상카드에만** 적용. **와일드카드·최종제안은 별도로 붙어** 공급사가 보는 카드가 3장을 넘을 수 있음(정상, §21)
- **C6** 단가는 **VAT별도** 표기(IMK는 VAT 라벨 자체 숨김)
## D. 타결/결렬 판정 & 성공률 게이지
- **판정 규칙**: 유저가 **목표가 아래로 내리거나** 봇이 되받은 제시가(카운터)를 **수락하면 타결**. **카드(3장)+최종제안**까지 다 소진될 때까지 **목표가 위에서 버티면 결렬**.
- **D1** 성공률 게이지는 **참고용일 뿐 판정과 무관** — 제시가 낮을수록 게이지↑(상한 99, **100% 도달 안 함**).
- **D2** 성공률 낮아도 무조건 결렬 아님. 최종 마감(낙찰/개찰)은 별도 기준(§G).
## E. 협상 타결(완료) 마무리
- **E1** 협상완료 → **부가정보 입력 단계 등장** (표준납기·최소주문수량·발주배수·배송유형·의견)
- **E2** 배송유형 → **콤보 [직납/IMK배송/IMK집배송]**로 떠야 (자유입력·다른 라벨이면 불량)
- **E3** 의견(선택) 작성 → `custom.opinion` 저장돼야
- **E4** 입력 완료 → **투찰결과 요약 갱신 후 최종 안내**, 요약에 부가정보·의견 반영
- **E5** 어드민 상세(협상현황/요약)에서 부가정보 **표시+잠금(읽기전용)**
## F. 협상 결렬(실패)
- **F1** 결렬 → **결렬 사유 입력 단계 항상 노출 + 필수**(견적/유형 무관 동일 폼)
- **F2** 사유 프리셋(단가인상/수량/단종/품절)+기타, **기타 선택 시 사유 텍스트 필수**
- **F3** 희망가격 입력 = **단일 폼**(RSP/CM 동일)
- **F4** 의견(선택) 작성 가능
- **F5** 검증: 결렬(협상거부) 세션은 **100% 사유가 기록**돼야 (사유 없는 결렬 세션 = 불량). §20
## G. 마감 · 낙찰/개찰
- **G1** 투찰가 ≤ 앵커가 → **낙찰**
- **G2** 앵커~목표가 → 1:1은 **사용자 지정**(mid_action), 1:N은 낙찰
- **G3** 목표가 초과 → 1:1 **사용자 지정**(over_action, 관례 개찰), 1:N 낙찰
- **G4** 동가(최저 2곳+)·전원 미응찰·협상거부 → **개찰**(낙찰자 미정, 결렬 아님) / 마감사유 라벨 정확히
## H. 재협상 요청·심사 (공급사→어드민)
- **H1** 개찰 마감 + 본인 마지막 라운드 → **재협상 요청 가능**(사유+희망가), 담당자 알림 1건
- **H2** 낙찰건/남의 세션 요청 → **거부**
- **H3** 같은 세션 2회 요청 → 2번째 **거부**; 철회 후 재요청 가능
- **H4** 어드민 /renegotiation 대기 탭 접수 → **승인 시 다음 차수 견적 생성**, 반려 시 사유 필수
---
## 알려진 이슈 상세
### §9. 협력사 SG명/유통레벨 콤보·취급상품 삭제 — 미구현
요청 4가지 모두 현재 구조와 다르다:
1. **분류카테고리 → SG명**: 지금 '분류카테고리'는 **입력 필드가 아니라 취급상품에서 파생 집계**되는 읽기전용 값(`SupplierItemsManager.tsx`). 회사 라벨(labels.category)로 "SG명" 표기만 되지, 직접 고르는 필드가 아님.
2. **선택콤보(취급 SG명)**: 등록된 SG명 중 선택하는 콤보 없음.
3. **유통레벨 콤보(제조/총판/대리점/일반유통)**: 없음. 유사한 건 취급상품별 **공급유형** 콤보인데 값이 다름(유통/제조/총판/없음). '대리점'·'일반유통' 값 자체가 코드에 없음. IMK 설정의 `supplier_fields.distribution_level`은 콤보가 아니라 **텍스트** 커스텀 필드.
4. **취급상품 칸 삭제**: 삭제 안 됨(등록/수정폼·엑셀업로드에 그대로).
- 근본: IMK는 SG명·유통레벨을 **협력사 레벨 속성(콤보)**으로 두고 취급상품 목록은 없애길 원함. 현재는 반대로 **취급상품(supplier_items)에서 카테고리·공급유형을 파생**하는 모델. → 데이터 모델 전환이 필요한 작업.
### §20. 결렬 사유 상시 노출·필수 — 검증 완료
- agent의 모든 실패 경로가 단일 step `"협상실패"`(chat_end)로 수렴 → backend가 rejectRSP/CM로 무조건 매핑 → 프론트 통일 RejectForm(사유 필수). 세션 상태 분기 없음.
- 실데이터: 협상거부(status=5) 9건 **전부 reject_reason 존재**, 사유 없는 결렬 0건.
- 원 버그의 "견적마다 상이"는 *대화 중 결렬*(사유 있음) vs *미참여/마감 자동종료*(대화 자체 없음 → 사유 없음)의 구분이었을 뿐, 결렬 플로우는 일관됨.
### §21. 카드 3회 초과 — 정상 동작(버그 아님)
`card_count=3`은 **협상카드 사용 한도**다. 와일드카드와 최종제안(종결)은 이 한도와 **별개로 각각 붙는** 구조라, 공급사가 보는 카드가 3장을 넘을 수 있다.
1. **와일드카드 1장**(1% 인하/동적 카운터) — 협상카드 카운트와 별개.
2. **최종제안 1장**(중간값 절충/최후통첩) — 카드 소진 후 마지막 국면에 별도로 붙음.
→ 협상카드 3 + 와일드 1 + 최종제안 1 = 최대 5장 노출(예: EST-202607-4DB5).
- 참고(엣지): 세션에 견적세팅(card_count)이 연결 안 되면 한도가 안 걸릴 수 있으니, 설정이 항상 세션에 물리는지만 확인.

View File

@ -57,8 +57,7 @@ export interface RefreshTokenResponse {
export interface Branding { export interface Branding {
service_name?: string service_name?: string
logo_url?: string logo_url?: string
primary_color?: string helpdesk?: string[] // 헬프데스크 연락처 — 한 줄 = 담당자 한 명. 비면 연락처 영역을 렌더하지 않는다
email_header?: string
} }
// 로그인 전(초청 링크 진입) 브랜딩 조회 — GET /v1/auth/session-branding/{session_id}, 인증 불필요 // 로그인 전(초청 링크 진입) 브랜딩 조회 — GET /v1/auth/session-branding/{session_id}, 인증 불필요
@ -66,14 +65,15 @@ export interface SessionBrandingResponse {
result: ApiResult result: ApiResult
service_name: string service_name: string
logo_url: string logo_url: string
primary_color: string helpdesk?: string[]
} }
// 협상완료 부가정보 필드 정의(companies.settings.session_fields) // 협상완료 부가정보 필드 정의(companies.settings.session_fields)
export interface SessionField { export interface SessionField {
key: string key: string
label: string label: string
type: 'text' | 'number' | 'boolean' type: 'text' | 'number' | 'boolean' | 'select'
options?: string[] // type='select' 일 때 고를 보기 목록
} }
export interface MeResponse { export interface MeResponse {
@ -86,6 +86,7 @@ export interface MeResponse {
role: number role: number
branding?: Branding branding?: Branding
session_fields?: SessionField[] session_fields?: SessionField[]
guide_notices?: string[]
} }
// --- 로그아웃 ------------------------------------------------------------- // --- 로그아웃 -------------------------------------------------------------
@ -120,6 +121,8 @@ export interface AuthUser {
role: number role: number
branding: Branding branding: Branding
sessionFields: SessionField[] sessionFields: SessionField[]
/** 협상 유의사항 항목(회사 설정). 비면 포털 기본 문구를 쓴다 */
guideNotices: string[]
} }
export function toAuthUser(res: MeResponse): AuthUser { export function toAuthUser(res: MeResponse): AuthUser {
@ -132,5 +135,6 @@ export function toAuthUser(res: MeResponse): AuthUser {
role: res.role, role: res.role,
branding: res.branding ?? {}, branding: res.branding ?? {},
sessionFields: res.session_fields ?? [], sessionFields: res.session_fields ?? [],
guideNotices: res.guide_notices ?? [],
} }
} }

View File

@ -32,6 +32,7 @@ export interface ChatInitResponse {
session_id: string session_id: string
session_status: number session_status: number
quotation_id: string quotation_id: string
qt_number?: string
quotation_end_time: string quotation_end_time: string
quotation_memo?: string quotation_memo?: string
item_id: string item_id: string
@ -47,6 +48,9 @@ export interface ChatInitResponse {
item_vat_yn?: boolean item_vat_yn?: boolean
item_delivery_fee_yn?: boolean item_delivery_fee_yn?: boolean
custom?: Record<string, unknown> custom?: Record<string, unknown>
labels?: Record<string, string>
reject_reason?: string
reject_price?: number | null
} }
export interface ChatMessagesResponse { export interface ChatMessagesResponse {
@ -89,6 +93,7 @@ export function mapInit(r: ChatInitResponse): ChatInitData {
session_status: r.session_status, session_status: r.session_status,
item_id: r.item_id, item_id: r.item_id,
quotation_id: r.quotation_id, quotation_id: r.quotation_id,
qt_number: r.qt_number ?? '',
item_name: r.item_name, item_name: r.item_name,
item_code: r.item_code ?? '', item_code: r.item_code ?? '',
item_image: r.item_image ?? '', item_image: r.item_image ?? '',
@ -104,5 +109,8 @@ export function mapInit(r: ChatInitResponse): ChatInitData {
item_spec: r.item_spec ?? '', item_spec: r.item_spec ?? '',
quotation_memo: r.quotation_memo ?? '', quotation_memo: r.quotation_memo ?? '',
quotation_end_time: r.quotation_end_time ?? '', quotation_end_time: r.quotation_end_time ?? '',
labels: r.labels ?? {},
reject_reason: r.reject_reason ?? '',
reject_price: r.reject_price ?? null,
} }
} }

View File

@ -2,5 +2,11 @@
export { negotiationApi } from './negotiation.api' export { negotiationApi } from './negotiation.api'
export { negotiationKeys } from './negotiation.keys' export { negotiationKeys } from './negotiation.keys'
export { useSessionListQuery } from './negotiation.queries' export { useSessionListQuery } from './negotiation.queries'
export { useParticipateMutation, useRejectMutation, useSaveExtraInfoMutation } from './negotiation.mutations' export {
useCancelRenegotiationMutation,
useParticipateMutation,
useRejectMutation,
useRequestRenegotiationMutation,
useSaveExtraInfoMutation,
} from './negotiation.mutations'
export * from './negotiation.type' export * from './negotiation.type'

View File

@ -6,6 +6,8 @@ import type {
ParticipateResponse, ParticipateResponse,
RejectRequest, RejectRequest,
RejectResponse, RejectResponse,
RenegotiationRequest,
RenegotiationResponse,
SessionListParams, SessionListParams,
SessionListResponse, SessionListResponse,
} from './negotiation.type' } from './negotiation.type'
@ -34,6 +36,23 @@ export const negotiationApi = {
return res.data return res.data
}, },
/** POST /v1/negotiation/session/{id}/renegotiation — 결렬 건 재협상 요청 */
requestRenegotiation: async (sessionId: string, body: RenegotiationRequest): Promise<RenegotiationResponse> => {
const res = await http.post<RenegotiationResponse>(
`/v1/negotiation/session/${sessionId}/renegotiation`,
body,
)
return res.data
},
/** DELETE /v1/negotiation/session/{id}/renegotiation — 심사 대기 중인 요청 철회 */
cancelRenegotiation: async (sessionId: string): Promise<RenegotiationResponse> => {
const res = await http.delete<RenegotiationResponse>(
`/v1/negotiation/session/${sessionId}/renegotiation`,
)
return res.data
},
/** POST /v1/negotiation/sessions/{id}/extra-info — 협상완료 부가정보 저장 */ /** POST /v1/negotiation/sessions/{id}/extra-info — 협상완료 부가정보 저장 */
saveExtraInfo: async (sessionId: string, body: ExtraInfoRequest): Promise<ExtraInfoResponse> => { saveExtraInfo: async (sessionId: string, body: ExtraInfoRequest): Promise<ExtraInfoResponse> => {
const res = await http.post<ExtraInfoResponse>( const res = await http.post<ExtraInfoResponse>(

View File

@ -2,7 +2,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { negotiationApi } from './negotiation.api' import { negotiationApi } from './negotiation.api'
import { negotiationKeys } from './negotiation.keys' import { negotiationKeys } from './negotiation.keys'
import type { ExtraInfoRequest, RejectRequest } from './negotiation.type' import type { ExtraInfoRequest, RejectRequest, RenegotiationRequest } from './negotiation.type'
/** /**
* 협상 세션 참여: 성공 시 세션 목록 캐시를 무효화해 상태를 갱신한다. * 협상 세션 참여: 성공 시 세션 목록 캐시를 무효화해 상태를 갱신한다.
@ -44,3 +44,24 @@ export function useSaveExtraInfoMutation() {
}, },
}) })
} }
export function useRequestRenegotiationMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ sessionId, request }: { sessionId: string; request: RenegotiationRequest }) =>
negotiationApi.requestRenegotiation(sessionId, request),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
},
})
}
export function useCancelRenegotiationMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ sessionId }: { sessionId: string }) => negotiationApi.cancelRenegotiation(sessionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
},
})
}

View File

@ -43,6 +43,8 @@ export interface SessionListParams {
order?: 'asc' | 'desc' // 생략 시 기본 그룹 정렬('할 일' 우선+임박순, 종료는 하단). 지정 시 그룹 무시하고 전체 마감순(asc=임박/desc=여유) order?: 'asc' | 'desc' // 생략 시 기본 그룹 정렬('할 일' 우선+임박순, 종료는 하단). 지정 시 그룹 무시하고 전체 마감순(asc=임박/desc=여유)
page?: number page?: number
page_size?: number page_size?: number
keyword?: string // 검색어 — 견적번호·상품명·상품코드 부분일치
result?: number // 결과 필터(SessionResult): 1=낙찰 2=미낙찰 3=결렬
} }
export interface SessionListItem { export interface SessionListItem {
@ -56,6 +58,43 @@ export interface SessionListItem {
model_name: string model_name: string
maker_name: string maker_name: string
custom: Record<string, unknown> // 협상완료 부가정보(sessions.custom). 미입력이면 {} custom: Record<string, unknown> // 협상완료 부가정보(sessions.custom). 미입력이면 {}
renegotiable: boolean // 재협상 요청 가능 여부(서버 판정 — 개찰 마감 + 마지막 차수 + 대기 요청 없음)
renegotiation_status: number // 1=심사대기 2=승인 3=반려 4=철회, 이력 없으면 0
renegotiation_memo: string // 담당자 심사 메모(반려 사유)
result: number // 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰)
has_chat: boolean // 대화 이력 존재 여부 — 종료된 협상의 '결과 보기' 노출 판단용
reject_reason: string // 협상 거부 시 제출한 사유. 거부 건이 아니면 ''
reject_price?: number | null // 거부와 함께 낸 공급 희망 가격(원). 미입력이면 null
}
/** 공급사 관점 협상 결과 (sessions 파생) */
export const SessionResult = { NONE: 0, WON: 1, LOST: 2, OPEN: 3 } as const
export const SESSION_RESULT_LABEL: Record<number, string> = {
1: '낙찰',
2: '미낙찰',
3: '결렬',
}
// 재협상 요청(IMK #15)
export interface RenegotiationRequest {
reason: string
desired_price?: number | null
}
export interface RenegotiationResponse {
result: ApiResult
session_id: string
status: number
}
export const RenegoStatus = { NONE: 0, PENDING: 1, APPROVED: 2, REJECTED: 3, CANCELED: 4 } as const
export const RENEGO_STATUS_LABEL: Record<number, string> = {
1: '재협상 심사 중',
2: '재협상 승인됨',
3: '재협상 반려됨',
4: '요청 철회됨',
} }
export interface SessionListResponse { export interface SessionListResponse {
@ -82,10 +121,13 @@ export interface ParticipateResponse {
} }
// --- 거부 (POST /v1/negotiation/sessions/{id}/reject) --------------------- // --- 거부 (POST /v1/negotiation/sessions/{id}/reject) ---------------------
// reject_reason: 프리셋(단종/품절) 라벨 또는 '기타' 직접 입력 텍스트. // 목록의 협상 거부와 채팅 중 협상 거부가 같은 폼(components/RejectPopup)·같은 엔드포인트를 쓴다.
// (백엔드 sessions.reject_reason 컬럼에 대응. 엔드포인트는 백엔드 추가 예정) // reject_reason: 프리셋(단종/품절) 라벨 또는 '기타' 직접 입력 텍스트. 필수.
// reject_price/opinion: 선택 입력 — 빈 값이면 아예 보내지 않는다(opinion 은 sessions.custom 에 병합).
export interface RejectRequest { export interface RejectRequest {
reject_reason: string reject_reason: string
reject_price?: number
opinion?: string
} }
export interface RejectResponse { export interface RejectResponse {

View File

@ -21,7 +21,7 @@ export function Modal({ children, onClose }: ModalProps) {
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-[rgba(0,0,0,0.40)]" className="fixed inset-0 z-50 flex items-center justify-center bg-[rgba(0,0,0,0.40)] p-4"
onClick={handleBackdropClick} onClick={handleBackdropClick}
> >
{children} {children}

View File

@ -0,0 +1,81 @@
import { X } from 'lucide-react'
import { Modal } from '@/components/Modal'
import { numberToKorean } from '@/lib'
export interface RejectDetailPopupProps {
onClose: () => void
/** 어느 건인지 식별용 부제 (견적번호 · 상품명) */
subtitle?: string
/** sessions.reject_reason — 프리셋 라벨 또는 '기타' 직접 입력 텍스트 */
reason: string
/** sessions.reject_price — 미입력이면 null */
price: number | null
/** sessions.custom.opinion */
opinion: string
/** 'VAT포함'/'VAT별도' — 품목 VAT 를 아는 화면에서만 넘긴다 */
vatLabel?: string
}
// 협상 거부 사유 열람 팝업 — 목록의 '협상완료 부가정보(보기)'와 같은 규격(읽기전용 필드 나열).
// 거부는 제출 내역이 대화에 남지 않아 세션 컬럼이 유일한 기록이다.
export function RejectDetailPopup({ onClose, subtitle, reason, price, opinion, vatLabel }: RejectDetailPopupProps) {
const priceText = price
? `${price.toLocaleString()}원 (${numberToKorean(price)}원)${vatLabel ? ` ${vatLabel}` : ''}`
: '미입력'
return (
<Modal onClose={onClose}>
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
<div className="flex items-center justify-between border-b border-border p-5">
<div>
<h3 className="text-base font-bold text-neutral-90">협상 거부 사유 (보기)</h3>
<p className="mt-0.5 text-xs text-neutral-60">
{subtitle}
{subtitle ? ' · ' : ''}제출 후에는 수정할 수 없습니다.
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
>
<X className="size-4" />
</button>
</div>
<div className="space-y-4 p-5">
<Field label="거부 사유" value={reason || '-'} />
<Field label="공급 희망 가격" value={priceText} />
<Field label="의견" value={opinion} rows={2} placeholder="남긴 의견 없음" />
</div>
<div className="flex gap-2 border-t border-border p-5">
<button
type="button"
onClick={onClose}
className="h-11 flex-1 rounded-xl border border-border text-sm font-bold text-neutral-70 hover:bg-neutral-10"
>
닫기
</button>
</div>
</div>
</Modal>
)
}
// 부가정보 팝업의 읽기전용 필드와 같은 모양(라벨 + disabled 입력).
function Field({ label, value, rows, placeholder }: { label: string; value: string; rows?: number; placeholder?: string }) {
const style =
'w-full rounded-xl border border-border bg-neutral-10 px-3 text-sm text-neutral-60 cursor-not-allowed outline-none'
return (
<div className="space-y-1.5">
<label className="block text-sm font-semibold text-neutral-80">{label}</label>
{rows ? (
<textarea value={value} rows={rows} disabled placeholder={placeholder} className={`${style} resize-none py-2`} />
) : (
<input type="text" value={value} disabled placeholder={placeholder} className={`${style} h-11`} />
)}
</div>
)
}

View File

@ -0,0 +1,200 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { Modal } from '@/components/Modal'
import { cn, numberToKorean } from '@/lib'
const MAX_PRICE = 999999999999999
// 거부 사유는 협상을 시작하지 않겠다는 사유(단종/품절/기타)다. 협상해보고 합의가 안 된
// 결렬 폼(단가인상·수량 포함 5종)과는 성격이 달라 목록을 맞추지 않는다.
const REASONS = ['단종', '품절', '기타'] as const
export interface RejectSubmitPayload {
/** 프리셋 라벨 또는 '기타' 직접 입력 텍스트 */
reject_reason: string
reject_price?: number
opinion?: string
}
export interface RejectPopupProps {
onClose: () => void
onSubmit: (payload: RejectSubmitPayload) => void
isPending?: boolean
/** 가격 옆 'VAT 별도' 표기 — 품목 VAT 를 아는 화면(채팅)에서만 켠다 */
isVatExcluded?: boolean
}
// 협상 거부 팝업 — 목록과 채팅 양쪽이 같은 폼·같은 엔드포인트(/reject)를 쓴다.
// 사유는 필수, 공급 희망 가격과 의견은 선택.
export function RejectPopup({ onClose, onSubmit, isPending = false, isVatExcluded = false }: RejectPopupProps) {
const [selectedReason, setSelectedReason] = useState<string | null>(null)
const [customReason, setCustomReason] = useState('')
const [price, setPrice] = useState('')
const [opinion, setOpinion] = useState('')
const [showError, setShowError] = useState(false)
const isEtcOpen = selectedReason === '기타'
const isSubmitDisabled = !selectedReason || (isEtcOpen && !customReason.trim()) || isPending
const handleReasonClick = (reason: string) => {
setShowError(false)
if (selectedReason === reason) {
setSelectedReason(null)
setCustomReason('')
} else {
setSelectedReason(reason)
if (reason !== '기타') setCustomReason('')
}
}
const handlePriceChange = (value: string) => {
const numeric = value.replace(/\D/g, '')
if (numeric && parseInt(numeric) > MAX_PRICE) return
setPrice(numeric)
}
const handleSubmit = () => {
if (isEtcOpen && !customReason.trim()) {
setShowError(true)
return
}
if (isSubmitDisabled || !selectedReason) return
onSubmit({
reject_reason: isEtcOpen ? customReason.trim() : selectedReason,
// 빈 값이면 아예 보내지 않아 컬럼을 건드리지 않는다.
...(price ? { reject_price: parseInt(price) } : {}),
...(opinion.trim() ? { opinion: opinion.trim() } : {}),
})
}
const koreanPrice = price && parseInt(price) > 0 ? `[${numberToKorean(parseInt(price))} 원]` : ''
return (
<Modal onClose={onClose}>
<div className="flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
{/* 헤더 */}
<div className="flex shrink-0 items-center justify-between border-b border-border p-5">
<h3 className="text-base font-bold text-neutral-90">거부 사유를 입력해주세요</h3>
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
>
<X className="size-4" />
</button>
</div>
{/* 본문 */}
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
<div className="grid grid-cols-3 gap-2">
{REASONS.map((reason) => (
<button
key={reason}
type="button"
onClick={() => handleReasonClick(reason)}
disabled={isPending}
className={cn(
'h-11 rounded-xl border text-sm font-bold transition-all active:scale-[0.98] disabled:opacity-50',
selectedReason === reason
? 'border-brand-600 bg-brand-light text-brand-600'
: 'border-border bg-white text-neutral-70 hover:bg-neutral-10',
)}
>
{reason}
</button>
))}
</div>
{isEtcOpen && (
<div className="animate-fade-in">
<textarea
placeholder="사유를 입력하여 주십시오"
value={customReason}
onChange={(e) => {
setCustomReason(e.target.value)
setShowError(false)
}}
rows={3}
disabled={isPending}
className={cn(
'w-full resize-none rounded-xl border bg-white p-3 text-sm text-neutral-90 outline-none transition-all',
'placeholder:text-neutral-50 focus:ring-1',
showError
? 'border-destructive focus:border-destructive focus:ring-destructive'
: 'border-border focus:border-brand-600 focus:ring-brand-600',
)}
/>
{showError && <p className="mt-1.5 text-xs font-medium text-destructive">기타 사유를 입력해주세요</p>}
</div>
)}
{/* 공급 희망 가격 (선택) — 결렬 폼과 같은 라벨·표기 */}
<div className="flex flex-col gap-1.5">
<div className="text-sm font-bold text-neutral-90">
공급 희망 가격 <span className="font-medium text-neutral-60">(선택)</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<input
type="text"
className={cn(
'h-10 max-w-[200px] rounded-xl border px-3 text-right text-sm outline-none transition-all placeholder:text-neutral-50',
isPending
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
)}
value={price ? parseInt(price).toLocaleString() : ''}
onChange={(e) => handlePriceChange(e.target.value)}
placeholder="0"
disabled={isPending}
/>
<span className="whitespace-nowrap text-sm text-neutral-70">원{isVatExcluded && '(VAT 별도)'}</span>
{koreanPrice && <span className="whitespace-nowrap text-sm text-neutral-50">{koreanPrice}</span>}
</div>
</div>
{/* 의견 (선택) — 결렬 폼과 동일 */}
<div className="flex flex-col gap-1.5">
<div className="text-sm font-bold text-neutral-90">
의견 <span className="font-medium text-neutral-60">(선택)</span>
</div>
<textarea
value={opinion}
onChange={(e) => setOpinion(e.target.value)}
rows={2}
maxLength={255}
disabled={isPending}
placeholder="추가로 남길 의견이 있으면 작성해 주세요."
className={cn(
'w-full resize-none rounded-xl border px-3 py-2 text-sm outline-none transition-all placeholder:text-neutral-50',
isPending
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
)}
/>
</div>
</div>
{/* 푸터 */}
<div className="flex shrink-0 gap-2 border-t border-border p-4">
<button
type="button"
onClick={onClose}
disabled={isPending}
className="h-11 flex-1 rounded-xl border border-border bg-white text-sm font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98] disabled:opacity-50"
>
취소
</button>
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitDisabled}
className="h-11 flex-1 rounded-xl bg-brand-600 text-sm font-bold text-white shadow-sm transition-all hover:bg-brand-700 active:scale-[0.98] disabled:opacity-40"
>
거부 처리
</button>
</div>
</div>
</Modal>
)
}

View File

@ -7,3 +7,7 @@ export { Logo } from '@/components/Logo'
export type { LogoProps, LogoVariant } from '@/components/Logo' export type { LogoProps, LogoVariant } from '@/components/Logo'
export { ErrorPage } from '@/components/ErrorPage' export { ErrorPage } from '@/components/ErrorPage'
export type { ErrorPageProps } from '@/components/ErrorPage' export type { ErrorPageProps } from '@/components/ErrorPage'
export { RejectPopup } from '@/components/RejectPopup'
export type { RejectPopupProps, RejectSubmitPayload } from '@/components/RejectPopup'
export { RejectDetailPopup } from '@/components/RejectDetailPopup'
export type { RejectDetailPopupProps } from '@/components/RejectDetailPopup'

View File

@ -43,9 +43,9 @@ export function usePreLoginBranding(): Branding | null {
const next: Branding = { const next: Branding = {
service_name: res.service_name || undefined, service_name: res.service_name || undefined,
logo_url: res.logo_url || undefined, logo_url: res.logo_url || undefined,
primary_color: res.primary_color || undefined, helpdesk: res.helpdesk?.length ? res.helpdesk : undefined,
} }
if (!next.service_name && !next.logo_url) return if (!next.service_name && !next.logo_url && !next.helpdesk) return
setBranding(next) setBranding(next)
writeCached(next) // 다음 진입에 session_id 가 없어도 이 회사로 보이게 한다 writeCached(next) // 다음 진입에 session_id 가 없어도 이 회사로 보이게 한다
}) })

View File

@ -6,23 +6,19 @@ import type { ChatMessage as ChatMessageType } from '@/features/chat/types'
import { renderEmphasis } from '@/features/chat/lib/emphasis' import { renderEmphasis } from '@/features/chat/lib/emphasis'
import { Indicator } from '@/features/chat/components/templates/Indicator' import { Indicator } from '@/features/chat/components/templates/Indicator'
import { Summary } from '@/features/chat/components/templates/Summary' import { Summary } from '@/features/chat/components/templates/Summary'
import { ExtraInfoForm } from '@/features/chat/components/templates/ExtraInfoForm'
import { BidSummary } from '@/features/chat/components/templates/BidSummary' import { BidSummary } from '@/features/chat/components/templates/BidSummary'
import { RejectRSP } from '@/features/chat/components/templates/RejectRSP' import { RejectSummary } from '@/features/chat/components/templates/RejectSummary'
import { RejectCM } from '@/features/chat/components/templates/RejectCM' import { RejectedNotice } from '@/features/chat/components/templates/RejectedNotice'
const AI_LABEL = '아이마켓코리아 (구매 MD)' const AI_LABEL = '아이마켓코리아 (구매담당자)'
export function ChatMessage() { export function ChatMessage() {
const scrollRef = useRef<HTMLDivElement | null>(null) const scrollRef = useRef<HTMLDivElement | null>(null)
return ( return (
<div className="flex-1 w-full min-h-0"> <div className="flex-1 w-full min-h-0">
{/* lg:pt-[64px]: 첫 메시지를 우측 협상절차 카드 상단과 맞추는 값 — 그 카드가 없는 lg 미만에선
스텝바 바로 아래 여백으로만 남아 첫 메시지가 위로 안 붙으므로 뺀다.
스크롤 시 여백도 함께 밀려 올라가도록 스크롤 컨테이너 안쪽에 둔다 */}
<div <div
ref={scrollRef} ref={scrollRef}
className="chat-scroll h-full overflow-y-auto flex flex-col pt-0 lg:pt-[64px] pb-6 px-4 sm:px-6 min-[1180px]:pl-[80px] min-[1180px]:pr-[72px] min-[1350px]:pl-[140px] min-[1350px]:pr-[126px]" className="chat-scroll h-full overflow-y-auto flex flex-col pt-4 pb-6 px-4 sm:px-6 min-[1180px]:pl-[80px] min-[1180px]:pr-[72px] min-[1350px]:pl-[140px] min-[1350px]:pr-[126px]"
> >
<ChatList scrollRef={scrollRef} /> <ChatList scrollRef={scrollRef} />
</div> </div>
@ -48,7 +44,7 @@ function ChatList({ scrollRef }: { scrollRef: RefObject<HTMLDivElement | null> }
scroller.scrollTo({ top: scroller.scrollHeight, behavior }) scroller.scrollTo({ top: scroller.scrollHeight, behavior })
}) })
return () => cancelAnimationFrame(id) return () => cancelAnimationFrame(id)
}, [chats, isLoading]) }, [chats, isLoading, scrollRef])
if (!chats || chats.length === 0) { if (!chats || chats.length === 0) {
return ( return (
@ -63,6 +59,7 @@ function ChatList({ scrollRef }: { scrollRef: RefObject<HTMLDivElement | null> }
{chats.map((message, index) => ( {chats.map((message, index) => (
<MessageItem key={message.chat_id || index} message={message} messages={chats} currentIndex={index} /> <MessageItem key={message.chat_id || index} message={message} messages={chats} currentIndex={index} />
))} ))}
<RejectedNotice />
{isLoading && <TypingBubble />} {isLoading && <TypingBubble />}
</div> </div>
) )
@ -93,11 +90,16 @@ const MessageItem = memo(function MessageItem({
}) { }) {
const isBot = message.sender === 'bot' const isBot = message.sender === 'bot'
// 직전이 reject 폼이면 사용자 답변 말풍선은 숨긴다(폼 자체가 답변을 담고 있음) // 결렬 폼 답변은 제출 문자열(공급희망가격-…, 합의불가사유-…)이라 말풍선 대신 요약 카드로 낸다.
// 폼은 세션 종료와 함께 사라지므로 이 카드가 없으면 결렬 사유가 대화에 안 남는다.
if (!isBot && currentIndex > 0) { if (!isBot && currentIndex > 0) {
const prev = messages[currentIndex - 1] const prev = messages[currentIndex - 1]
if (prev?.bot_chat_type === 'rejectRSP' || prev?.bot_chat_type === 'rejectCM') { if (prev?.bot_chat_type === 'rejectRSP' || prev?.bot_chat_type === 'rejectCM') {
return null return (
<div className="mb-5 animate-fade-in">
<RejectSummary script={message.script || ''} />
</div>
)
} }
} }
@ -124,23 +126,14 @@ const BotMessage = memo(function BotMessage({ message }: { message: ChatMessageT
{showIndicator && message.bot_chat_type === 'indicator' && message.indicator_value != null && ( {showIndicator && message.bot_chat_type === 'indicator' && message.indicator_value != null && (
<Indicator number={message.indicator_value} /> <Indicator number={message.indicator_value} />
)} )}
{message.bot_chat_type === 'summaryRSP' && message.summary && ( {message.bot_chat_type === 'summaryRSP' && message.summary && <Summary data={message.summary} />}
<>
<Summary data={message.summary} />
<ExtraInfoForm />
</>
)}
{message.bot_chat_type === 'summaryCM' && message.summary && ( {message.bot_chat_type === 'summaryCM' && message.summary && (
<BidSummary <BidSummary
itemName={message.summary.item_name} itemName={message.summary.item_name}
itemCode={message.summary.item_code} itemCode={message.summary.item_code}
bidPrice={message.summary.final_price} bidPrice={message.summary.final_price}
deliveryType={message.summary.delivery_type || ''}
isVAT={message.summary.item_isVAT}
/> />
)} )}
{message.bot_chat_type === 'rejectRSP' && <RejectRSP />}
{message.bot_chat_type === 'rejectCM' && <RejectCM />}
</div> </div>
</div> </div>
) )

View File

@ -1,10 +1,33 @@
import { useState } from 'react'
import { SessionStatus } from '@/apis'
import { useChatStore } from '@/features/chat/stores/useChatStore' import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
import { ChatMessage } from '@/features/chat/components/ChatMessage' import { ChatMessage } from '@/features/chat/components/ChatMessage'
import { UserButton } from '@/features/chat/components/UserButton' import { UserButton } from '@/features/chat/components/UserButton'
import { RejectPopup } from '@/features/chat/components/popup/RejectPopup'
import { canRejectNegotiation, GO_TO_LIST_TEXT } from '@/features/chat/lib/userButtonConfig'
import { MobileStepBar } from '@/features/chat/components/MobileStepBar' import { MobileStepBar } from '@/features/chat/components/MobileStepBar'
import type { UserButtonConfig } from '@/features/chat/types'
// 미참여·협상거부로 끝난 세션은 '결과 보기'(열람) 진입이라 입력을 잠근다.
// 마지막 봇 메시지가 입력을 요구하는 상태로 멈춰 있어도(중간 이탈) 서버가 전송을 막으므로 액션바는 복귀 버튼만 둔다.
const READ_ONLY_STATUSES: number[] = [SessionStatus.NOT_PARTICIPATED, SessionStatus.REJECTED]
const READ_ONLY_CONFIG: UserButtonConfig = { type: 'one-black', text: GO_TO_LIST_TEXT }
// 거부 가능한 세션 상태 — 백엔드 /reject 가 허용하는 범위(완료/미참여/거부는 불가)와 같다.
const REJECTABLE_STATUSES: number[] = [SessionStatus.CREATED, SessionStatus.IN_PROGRESS]
export function ChatSection() { export function ChatSection() {
const { userButtonConfig } = useChatStore() const { messages, userButtonConfig } = useChatStore()
const sessionStatus = useChatInitStore((s) => s.session_status)
const [isRejectOpen, setIsRejectOpen] = useState(false)
const isReadOnly = READ_ONLY_STATUSES.includes(sessionStatus)
const config = isReadOnly ? READ_ONLY_CONFIG : userButtonConfig
// 단종·품절처럼 대화로 풀 수 없는 사유는 봇의 결렬 선언을 기다릴 수 없다 — 진행 중에도 빠져나갈 길을 둔다.
const canReject =
!isReadOnly && REJECTABLE_STATUSES.includes(sessionStatus) && canRejectNegotiation(messages, userButtonConfig)
return ( return (
<div className="flex flex-1 flex-col w-full h-full min-h-0 bg-surface"> <div className="flex flex-1 flex-col w-full h-full min-h-0 bg-surface">
<MobileStepBar /> <MobileStepBar />
@ -12,8 +35,9 @@ export function ChatSection() {
{/* 하단 액션 덱 */} {/* 하단 액션 덱 */}
{/* safe-b: 홈 인디케이터에 입력 버튼이 가리지 않도록 하단 안전영역 확보 */} {/* safe-b: 홈 인디케이터에 입력 버튼이 가리지 않도록 하단 안전영역 확보 */}
<div className="shrink-0 safe-b border-t border-border bg-white shadow-[0_-4px_20px_rgba(0,0,0,0.03)]"> <div className="shrink-0 safe-b border-t border-border bg-white shadow-[0_-4px_20px_rgba(0,0,0,0.03)]">
<UserButton {...userButtonConfig} /> <UserButton {...config} onReject={canReject ? () => setIsRejectOpen(true) : undefined} />
</div> </div>
{isRejectOpen && <RejectPopup onClose={() => setIsRejectOpen(false)} />}
</div> </div>
) )
} }

View File

@ -0,0 +1,126 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { useMeQuery, useSaveExtraInfoMutation, getApiErrorMessage } from '@/apis'
import type { SessionField } from '@/apis/auth/auth.type'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
// 타결 요약의 '동의' 단계 하단 액션바 = 부가정보 입력 + 동의를 한 자리에서.
// 다른 입력(가격·배송)과 같은 자리에서 받는다. proceedText = 동의 문구(누르면 저장 후 그 텍스트로 협상 종료).
// 필드 정의(session_fields)가 없으면 폼 없이 동의 버튼만 둔다. 저장 후에도 세션 목록에서 수정할 수 있다.
export function ExtraInfoBar({ proceedText }: { proceedText: string }) {
const sessionId = useChatStore((s) => s.sessionId)
const sendMessage = useChatStore((s) => s.sendMessage)
const { data: user } = useMeQuery()
const fields: SessionField[] = user?.sessionFields ?? []
const save = useSaveExtraInfoMutation()
const existing = useChatInitStore((s) => s.custom) // 기존 입력값(재진입 프리필)
const setInitData = useChatInitStore((s) => s.setInitData)
// 의견은 session_fields 와 무관한 공통 필드 — 타결·결렬 모두 항상 받는다. custom.opinion 에 저장.
const [opinion, setOpinion] = useState<string | null>(null)
const opinionValue = opinion ?? String(existing?.opinion ?? '')
// 사용자가 건드린 값만 state 로 두고, 나머지는 기존값/기본값에서 렌더마다 파생한다(초기화 effect 불필요).
const [overrides, setOverrides] = useState<Record<string, unknown>>({})
const values: Record<string, unknown> = {}
for (const f of fields) values[f.key] = overrides[f.key] ?? existing?.[f.key] ?? (f.type === 'boolean' ? false : '')
const proceed = () => sendMessage(proceedText)
const set = (key: string, value: unknown) => setOverrides((v) => ({ ...v, [key]: value }))
// 저장 성공 후에만 동의(협상 종료)로 넘어간다 — 저장 실패 시 화면 유지.
const handleSaveAndProceed = () => {
if (save.isPending) return
const custom: Record<string, unknown> = {}
for (const f of fields) {
const v = values[f.key]
if (f.type === 'boolean') custom[f.key] = !!v
else if (v !== '' && v != null) custom[f.key] = f.type === 'number' ? Number(v) : v
}
if (opinionValue.trim()) custom.opinion = opinionValue.trim()
if (Object.keys(custom).length === 0) { proceed(); return } // 입력 없음 → 저장 생략하고 종료
save.mutate(
{ sessionId, request: { custom } },
{
onSuccess: () => {
setInitData({ custom: { ...existing, ...custom } }) // 요약 카드가 방금 입력값을 즉시 반영하도록 store 갱신
proceed()
},
onError: (error) => toast.error(getApiErrorMessage(error, '부가정보 저장에 실패했습니다.')),
},
)
}
return (
<div className="w-full">
<p className="mb-3 text-sm font-semibold text-neutral-90">아래 정보를 입력하고 협상을 마무리해 주세요.</p>
{/* overflow-y-auto 는 overflow-x 도 auto 로 만들어(스펙상) 인풋 focus ring 을 좌우로 잘라낸다.
p-1 로 링 여백을 주고 -m-1 로 원래 정렬(버튼과 좌우폭)을 유지한다. */}
<div className="max-h-[36vh] space-y-3 overflow-y-auto p-1 -m-1">
{fields.map((f) => (
<div key={f.key} className="flex items-center gap-3">
<label className="w-28 shrink-0 text-sm font-semibold text-neutral-80">{f.label}</label>
{f.type === 'boolean' ? (
<button
type="button"
role="switch"
aria-checked={!!values[f.key]}
onClick={() => set(f.key, !values[f.key])}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
values[f.key] ? 'bg-brand-600' : 'bg-neutral-30'
}`}
>
<span
className={`inline-block size-5 transform rounded-full bg-white shadow transition-transform ${
values[f.key] ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</button>
) : f.type === 'select' ? (
<select
value={String(values[f.key] ?? '')}
onChange={(e) => set(f.key, e.target.value)}
className="h-10 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600"
>
<option value="">선택</option>
{(f.options ?? []).map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
) : (
<input
type={f.type === 'number' ? 'number' : 'text'}
value={String(values[f.key] ?? '')}
onChange={(e) => set(f.key, e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.nativeEvent.isComposing && handleSaveAndProceed()}
className="h-10 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600"
placeholder={f.label}
/>
)}
</div>
))}
{/* 의견 — 타결·결렬 공통 필드(IMK #18). custom.opinion 저장. */}
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold text-neutral-80">
의견 <span className="font-normal text-neutral-50">(선택)</span>
</label>
<textarea
value={opinionValue}
onChange={(e) => setOpinion(e.target.value)}
rows={2}
maxLength={255}
placeholder="추가로 남길 의견이 있으면 작성해 주세요."
className="w-full resize-none rounded-xl border border-border bg-white px-3 py-2 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600"
/>
</div>
</div>
<button type="button" onClick={handleSaveAndProceed} disabled={save.isPending} className={`${PRIMARY} mt-3 w-full`}>
{save.isPending ? '저장 중…' : proceedText || '저장하고 마무리'}
</button>
</div>
)
}
const PRIMARY =
'h-[46px] min-w-[120px] rounded-xl bg-brand-600 px-6 text-sm font-bold text-white shadow-sm transition-all hover:bg-brand-700 active:scale-[0.98] disabled:opacity-50'

View File

@ -38,14 +38,17 @@ function ItemInfo() {
const { const {
item_code, item_code,
item_price, item_price,
item_vat_yn,
item_model_name, item_model_name,
item_maker_name, item_maker_name,
item_min_order_quantity, item_min_order_quantity,
item_lead_time, item_lead_time,
item_spec, item_spec,
item_name, item_name,
item_vat_yn,
labels,
} = useChatInitStore() } = useChatInitStore()
// 회사 설정 라벨(companies.settings.labels)로 필드명 치환. 미설정 시 기본 라벨.
const fieldLabel = (key: string, fallback: string) => labels?.[key] || fallback
const [tooltip, setTooltip] = useState<TooltipState | null>(null) const [tooltip, setTooltip] = useState<TooltipState | null>(null)
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null) const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
@ -71,7 +74,8 @@ function ItemInfo() {
const formatPrice = (price: number) => price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') const formatPrice = (price: number) => price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
const isNewItem = !item_code && !item_price const isNewItem = !item_code && !item_price
const priceText = isNewItem ? '신규' : `${formatPrice(item_price)}원` const priceText = isNewItem ? '신규' : `${formatPrice(item_price)}원`
const formattedPrice = `${priceText}(${item_vat_yn || 'VAT별도'})` // VAT 표기는 회사 설정 기준(item_vat_yn). vat_yn 미관리(hidden)면 값이 비어 라벨을 생략한다.
const formattedPrice = item_vat_yn ? `${priceText}(${item_vat_yn})` : priceText
const renderRow = (title: string, data: string) => { const renderRow = (title: string, data: string) => {
const displayData = data || '-' const displayData = data || '-'
@ -105,15 +109,16 @@ function ItemInfo() {
<div className="flex flex-1 items-start self-stretch overflow-y-auto overflow-x-hidden px-6 min-h-0"> <div className="flex flex-1 items-start self-stretch overflow-y-auto overflow-x-hidden px-6 min-h-0">
<div className="flex flex-col items-start self-stretch flex-1 gap-2 min-w-0"> <div className="flex flex-col items-start self-stretch flex-1 gap-2 min-w-0">
{renderRow('상품코드', item_code)} {renderRow(fieldLabel('item.code', '상품코드'), item_code)}
{renderRow('단가', formattedPrice)} {/* 값은 회사가 고른 협상 기준가(item_price), 호칭은 공급사 화면 고정 용어 — 회사 용어 사전을 타지 않는다. */}
{renderRow('모델명', item_model_name)} {renderRow('공급가', formattedPrice)}
{renderRow('제조사', item_maker_name)} {renderRow(fieldLabel('item.model_name', '모델명'), item_model_name)}
{renderRow('최소주문수량', item_min_order_quantity)} {renderRow(fieldLabel('item.manufacturer', '제조사'), item_maker_name)}
{renderRow('리드타임', formatLeadTime(item_lead_time))} {renderRow(fieldLabel('item.moq', '최소주문수량'), item_min_order_quantity)}
{renderRow(fieldLabel('lead_time', '리드타임'), formatLeadTime(item_lead_time))}
<div className="flex items-start justify-between gap-3 self-stretch py-2"> <div className="flex items-start justify-between gap-3 self-stretch py-2">
<span className="shrink-0 text-sm text-neutral-60">규격</span> <span className="shrink-0 text-sm text-neutral-60">{fieldLabel('item.spec', '규격')}</span>
<span <span
className="min-w-0 flex-1 overflow-hidden whitespace-normal break-words break-keep text-right text-[13px] font-semibold text-neutral-90 cursor-default" className="min-w-0 flex-1 overflow-hidden whitespace-normal break-words break-keep text-right text-[13px] font-semibold text-neutral-90 cursor-default"
onMouseEnter={(e) => item_spec && showTooltip(e, item_spec, true)} onMouseEnter={(e) => item_spec && showTooltip(e, item_spec, true)}

View File

@ -1,7 +1,7 @@
import { Check } from 'lucide-react' import { Check } from 'lucide-react'
import { cn } from '@/lib' import { cn } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore' import { useChatStore } from '@/features/chat/stores/useChatStore'
import { STEPS } from '@/features/chat/components/menu/NegoStep' import { STEPS } from '@/features/chat/lib/negoSteps'
// 모바일 전용: 협상절차를 채팅 상단에 가로 스텝바로 항상 표시한다. (데스크톱은 우측 패널 사용) // 모바일 전용: 협상절차를 채팅 상단에 가로 스텝바로 항상 표시한다. (데스크톱은 우측 패널 사용)
export function MobileStepBar() { export function MobileStepBar() {

View File

@ -0,0 +1,184 @@
import { useState, useMemo } from 'react'
import { AlertTriangle } from 'lucide-react'
import { cn } from '@/lib'
import { numberToKorean } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
import { SelectRadio, SubmitButton } from '@/features/chat/components/templates/rejectControls'
import { OtherReason } from '@/features/chat/components/templates/OtherReason'
import {
findRestoreScript,
restoreRejectRSP,
extractPart,
REJECT_REASONS,
} from '@/features/chat/lib/rejectForm'
const MAX_PRICE = 999999999999999
// 통일된 협상 결렬 폼 — 최종 제안 단가 + 합의 불가 사유 + 의견.
// rejectRSP / rejectCM 두 유형 모두 이 폼 하나로 받는다(액션바 렌더).
export function RejectForm() {
const isLoading = useChatStore((s) => s.isLoading)
const sendMessage = useChatStore((s) => s.sendMessage)
const messages = useChatStore((s) => s.messages)
const item_vat_yn = useChatInitStore((s) => s.item_vat_yn)
const isVAT = item_vat_yn === 'VAT별도'
// 직전 제출 내역에서 1회 복원 (rejectRSP/rejectCM 어느 유형이든).
const priorScript = useState(
() => findRestoreScript(messages, 'rejectRSP') ?? findRestoreScript(messages, 'rejectCM'),
)[0]
const restored = useState(() => restoreRejectRSP(priorScript))[0]
const [price, setPrice] = useState(restored.price)
const [selectedReason, setSelectedReason] = useState(restored.selectedReason)
const [reason, setReason] = useState(restored.reason)
const [opinion, setOpinion] = useState(() => extractPart(priorScript, '의견-'))
const [otherErrorMessage, setOtherErrorMessage] = useState('')
const [priceErrorMessage, setPriceErrorMessage] = useState('')
const [radioErrorMessage, setRadioErrorMessage] = useState('')
const isSubmitted = useMemo(
() => Boolean(findRestoreScript(messages, 'rejectRSP') ?? findRestoreScript(messages, 'rejectCM')),
[messages],
)
// 사유는 필수(20번), 의견은 선택(18번).
const isValid =
Boolean(price && parseInt(price) > 0) &&
Boolean(selectedReason) &&
(selectedReason !== '기타' || reason.trim() !== '')
const handlePriceChange = (value: string) => {
const numeric = value.replace(/\D/g, '')
if (numeric && parseInt(numeric) > MAX_PRICE) return
setPrice(numeric)
if (numeric) setPriceErrorMessage('')
}
const handleRadioChange = (value: string) => {
setSelectedReason(value)
setRadioErrorMessage('')
if (value !== '기타') {
setReason('')
setOtherErrorMessage('')
}
}
const koreanPrice = (() => {
if (!price) return '영'
const n = parseInt(price)
return Number.isNaN(n) || n === 0 ? '영' : numberToKorean(n)
})()
const handleSubmit = () => {
setPriceErrorMessage(!price || parseInt(price) === 0 ? '가격을 입력해주세요.' : '')
setRadioErrorMessage(!selectedReason ? '사유를 선택해주세요.' : '')
setOtherErrorMessage(selectedReason === '기타' && !reason.trim() ? '기타 사유를 입력해주세요.' : '')
if (isValid && !isLoading && !isSubmitted) {
const reasonPart =
selectedReason === '기타' ? `합의불가사유-기타-${reason.trim()}` : `합의불가사유-${selectedReason}`
const script = `공급희망가격-${price}, ${reasonPart}, 의견-${opinion.trim()}`
sendMessage(script, 'text')
}
}
const isDisabled = isLoading || isSubmitted
return (
<div className="w-full">
<div className="flex flex-col w-full gap-2">
<div className="flex w-full items-center justify-center gap-2 rounded-xl bg-[#FFF1F1] px-4 py-3 text-sm font-bold text-[#E5484D]">
<AlertTriangle className="size-5 shrink-0" />
협상 합의 불가 사유 및 최종 제안 단가 입력
</div>
<div className="max-h-[42vh] space-y-2 overflow-y-auto p-1 -m-1">
{/* 공급 희망 가격 */}
<div className="flex flex-col w-full gap-1">
<div className="flex w-full items-start gap-3">
<div className="w-[100px] shrink-0 pt-2.5 text-sm font-bold text-neutral-90">공급 희망 가격</div>
<div className="flex w-full flex-wrap items-center gap-2 pt-2">
<div className="flex items-center gap-2">
<input
type="text"
className={cn(
'h-10 max-w-[200px] rounded-xl border px-3 text-right text-sm outline-none transition-all placeholder:text-neutral-50',
isDisabled
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
)}
value={price ? parseInt(price).toLocaleString() : ''}
onChange={(e) => handlePriceChange(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.nativeEvent.isComposing && handleSubmit()}
placeholder="0"
disabled={isDisabled}
/>
<div className="whitespace-nowrap text-sm text-neutral-70">원{isVAT && '(VAT 별도)'}</div>
</div>
<div className="whitespace-nowrap text-sm text-neutral-50">[{koreanPrice} 원]</div>
</div>
</div>
{priceErrorMessage && (
<div className="pl-[112px] text-xs font-medium text-negative">{priceErrorMessage}</div>
)}
</div>
{/* 합의 불가 사유 (필수) */}
<div className="flex w-full gap-3">
<div className="w-[100px] shrink-0 pt-2 text-sm font-bold text-neutral-90">합의 불가 사유</div>
<div className="flex w-full flex-col mt-2 gap-2">
{REJECT_REASONS.map((r) => (
<SelectRadio
key={r.value}
text={r.text}
value={r.value}
name="rejectform"
selectedValue={selectedReason}
onChange={handleRadioChange}
errorMessage={radioErrorMessage}
disabled={isDisabled}
/>
))}
<OtherReason
inputValue={reason}
setInputValue={(v) => {
if (selectedReason !== '기타') return
setReason(v)
if (v.trim()) setOtherErrorMessage('')
}}
errorMessage={otherErrorMessage}
selectedValue={selectedReason}
onChange={handleRadioChange}
isError={!!radioErrorMessage}
disabled={isDisabled}
onSubmit={handleSubmit}
/>
</div>
</div>
{/* 의견 (선택) — 타결·결렬 공통 */}
<div className="flex w-full items-start gap-3">
<div className="w-[100px] shrink-0 pt-2 text-sm font-bold text-neutral-90">의견</div>
<textarea
value={opinion}
onChange={(e) => setOpinion(e.target.value)}
rows={2}
maxLength={255}
disabled={isDisabled}
placeholder="추가로 남길 의견이 있으면 작성해 주세요. (선택)"
className={cn(
'mt-2 w-full resize-none rounded-xl border px-3 py-2 text-sm outline-none transition-all placeholder:text-neutral-50',
isDisabled
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
)}
/>
</div>
</div>
<div className="flex w-full items-center justify-center mt-2">
<SubmitButton isValid={isValid} isLoading={isLoading} isSubmitted={isSubmitted} onSubmit={handleSubmit} />
</div>
</div>
</div>
)
}

View File

@ -1,7 +1,10 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { cn } from '@/lib' import { cn } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore' import { useChatStore } from '@/features/chat/stores/useChatStore'
import { Percent, Price } from '@/features/chat/components/userInputs' import { Percent, Price } from '@/features/chat/components/userInputs'
import { ExtraInfoBar } from '@/features/chat/components/ExtraInfoBar'
import { RejectForm } from '@/features/chat/components/RejectForm'
import { GO_TO_LIST_TEXT } from '@/features/chat/lib/userButtonConfig' import { GO_TO_LIST_TEXT } from '@/features/chat/lib/userButtonConfig'
import type { UserButtonConfig } from '@/features/chat/types' import type { UserButtonConfig } from '@/features/chat/types'
@ -14,12 +17,38 @@ const style = {
'flex min-w-[120px] px-[28px] max-[1180px]:min-w-[100px] max-[1180px]:px-[20px] h-[46px] bg-white hover:bg-neutral-10 rounded-xl items-center justify-center text-sm font-bold text-neutral-80 border border-border cursor-pointer whitespace-nowrap transition-all ease-out active:scale-[0.98]', 'flex min-w-[120px] px-[28px] max-[1180px]:min-w-[100px] max-[1180px]:px-[20px] h-[46px] bg-white hover:bg-neutral-10 rounded-xl items-center justify-center text-sm font-bold text-neutral-80 border border-border cursor-pointer whitespace-nowrap transition-all ease-out active:scale-[0.98]',
} }
export function UserButton({ type, text, textList, priceErrorMessage }: UserButtonConfig) { // onReject: 협상 거부 진입점. 넘어오면 버튼 덱 끝에 붙는다(입력 단계는 인풋이 넓어 좁은 화면에서 아랫줄로 wrap).
export function UserButton({ type, text, textList, priceErrorMessage, onReject }: UserButtonConfig & { onReject?: () => void }) {
if (type === '') return null if (type === '') return null
// 부가정보 입력은 폼이라 가운데정렬 덱이 아니라 전체폭으로 편다. text = 저장 후 보낼 동의 문구.
if (type === 'extra-info') {
return (
<div className="w-full px-6 py-4 max-[1180px]:px-4">
<ExtraInfoBar proceedText={text || ''} />
</div>
)
}
// 결렬 통일 폼 — 부가정보와 같은 자리(액션바)에서 전체폭으로.
if (type === 'reject') {
return (
<div className="w-full px-6 py-4 max-[1180px]:px-4">
<RejectForm />
</div>
)
}
// 입력 단계는 에러 말풍선이 위로 삐져나가야 해서 overflow 클리핑 제외 (버튼 덱만 가로 스크롤 허용)
const isInputStep = type === 'percent' || type === 'price'
return ( return (
<div className="flex w-full justify-center overflow-x-auto px-6 py-4 max-[1180px]:px-4"> <div
<div className="flex justify-center"> className={cn(
'relative flex w-full flex-wrap items-center justify-center gap-3 px-6 py-4 max-[1180px]:px-4',
!isInputStep && 'overflow-x-auto',
)}
>
<div className="flex items-center justify-center gap-3">
{type === 'one-black' && <OneBlack text={text || '확인'} />} {type === 'one-black' && <OneBlack text={text || '확인'} />}
{type === 'one-gray' && <OneGray text={text || '확인'} />} {type === 'one-gray' && <OneGray text={text || '확인'} />}
{type === 'black-white' && <BlackWhite textList={[textList?.[0] || '예', textList?.[1] || '아니오']} />} {type === 'black-white' && <BlackWhite textList={[textList?.[0] || '예', textList?.[1] || '아니오']} />}
@ -30,6 +59,19 @@ export function UserButton({ type, text, textList, priceErrorMessage }: UserButt
{type === 'price' && <Price priceErrorMessage={priceErrorMessage} />} {type === 'price' && <Price priceErrorMessage={priceErrorMessage} />}
{type === 'loading' && <LoadingDots />} {type === 'loading' && <LoadingDots />}
</div> </div>
{/* 오클릭 방지: 주 CTA 와 붙이지 않는다. 넓은 화면은 우측 끝 고정(가운데 CTA 는 그대로),
좁은 화면은 인풋 폭 때문에 같은 줄이 안 나오므로 wrap 되어 아랫줄로 내려간다. */}
{onReject && (
<button
className={cn(
style.white,
'min-[1180px]:absolute min-[1180px]:right-8 min-[1180px]:top-1/2 min-[1180px]:-translate-y-1/2',
)}
onClick={onReject}
>
협상 거부
</button>
)}
</div> </div>
) )
} }
@ -82,6 +124,18 @@ function ThreeBlack({ textList }: { textList: [string, string, string] }) {
function BlackWhite({ textList }: { textList: [string, string] }) { function BlackWhite({ textList }: { textList: [string, string] }) {
const sendMessage = useChatStore((s) => s.sendMessage) const sendMessage = useChatStore((s) => s.sendMessage)
// Enter = 첫 버튼(예). 입력창이 없는 선택 단계라 전역 Enter 를 잡아도 안전하다.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.isComposing) {
e.preventDefault()
sendMessage(textList[0])
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [sendMessage, textList])
return ( return (
<div className="flex gap-3"> <div className="flex gap-3">
<button className={style.black} onClick={() => sendMessage(textList[0])}> <button className={style.black} onClick={() => sendMessage(textList[0])}>

View File

@ -1,13 +1,21 @@
import { useMeQuery } from '@/apis'
// 헬프데스크 — 연락처. (안내 팝업 진입은 상단 '유의사항 및 이용방법' 섹션으로 일원화) // 헬프데스크 — 연락처. (안내 팝업 진입은 상단 '유의사항 및 이용방법' 섹션으로 일원화)
// 연락처는 회사 설정(companies.settings.branding.helpdesk)에서 온다. 미등록이면 섹션 자체를 숨긴다.
export function Contact() { export function Contact() {
const { data: user } = useMeQuery()
const helpdesk = user?.branding?.helpdesk ?? []
if (helpdesk.length === 0) return null
return ( return (
<section className="w-full pt-4"> <section className="w-full pt-4">
<div className="border-b border-border pb-2"> <div className="border-b border-border pb-2">
<span className="text-[11px] font-bold tracking-wide text-neutral-60">헬프데스크</span> <span className="text-[11px] font-bold tracking-wide text-neutral-60">헬프데스크</span>
</div> </div>
<div className="flex flex-col gap-1 pt-3 text-sm text-neutral-60"> <div className="flex flex-col gap-1 pt-3 text-sm text-neutral-60">
<span>010-0000-0000</span> {helpdesk.map((line) => (
<span>o2odev@o2o.kr</span> <span key={line}>{line}</span>
))}
</div> </div>
</section> </section>
) )

View File

@ -29,7 +29,7 @@ export function MDInformation({ isOpen, setIsOpen }: Props) {
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className="flex w-full items-center justify-between border-b border-border pb-2" className="flex w-full items-center justify-between border-b border-border pb-2"
> >
<span className="text-[11px] font-bold tracking-wide text-neutral-60">MD 안내사항</span> <span className="text-[11px] font-bold tracking-wide text-neutral-60">구매담당자 안내사항</span>
<ChevronDown <ChevronDown
size={16} size={16}
className={cn('text-neutral-50 transition-transform duration-300', !isOpen && '-rotate-90')} className={cn('text-neutral-50 transition-transform duration-300', !isOpen && '-rotate-90')}

View File

@ -1,20 +1,13 @@
import { Check, ChevronDown } from 'lucide-react' import { Check, ChevronDown } from 'lucide-react'
import { cn } from '@/lib' import { cn } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore' import { useChatStore } from '@/features/chat/stores/useChatStore'
import { STEPS } from '@/features/chat/lib/negoSteps'
interface Props { interface Props {
isOpen: boolean isOpen: boolean
setIsOpen: (isOpen: boolean) => void setIsOpen: (isOpen: boolean) => void
} }
export const STEPS = [
{ name: '서비스안내', desc: '협상 방식과 유의사항을 확인합니다.' },
{ name: '담당자확인', desc: '협상 담당자 본인 여부를 확인합니다.' },
{ name: '협상품목안내', desc: '대상 품목과 기준 단가를 확인합니다.' },
{ name: '가격협상', desc: '공급 단가를 제안하고 조율합니다.' },
{ name: '협상종료', desc: '최종 합의 후 결과를 확인합니다.' },
]
export function NegoStep({ isOpen, setIsOpen }: Props) { export function NegoStep({ isOpen, setIsOpen }: Props) {
const chats = useChatStore((s) => s.messages) const chats = useChatStore((s) => s.messages)
const currentStep = chats[chats.length - 1]?.display_step || '서비스안내' const currentStep = chats[chats.length - 1]?.display_step || '서비스안내'

View File

@ -1,10 +1,21 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore' import { useMeQuery } from '@/apis'
// 협상 유의사항 및 서비스 이용 방법 안내 — 팝업 2종(자동 안내/메뉴 가이드)이 공유하는 본문. // 협상 유의사항 및 서비스 이용 방법 안내 — 팝업 2종(자동 안내/메뉴 가이드)이 공유하는 본문.
// VAT/배송비 문구는 채팅 init 메타(useChatInitStore)를 읽어 상품별로 동적 표시한다. // 항목은 회사 설정(companies.settings.guide_notices)에서 오고, 비어 있으면 아래 기본 문구를 쓴다.
const bodyStyle = 'text-sm font-normal leading-relaxed text-neutral-70 break-keep' const bodyStyle = 'text-sm font-normal leading-relaxed text-neutral-70 break-keep'
// negodata 회사 설정의 "기본 문구 불러오기" 값과 동일해야 한다
// (원본: negodata/front/src/features/settings/catalog.ts DEFAULT_GUIDE_NOTICES).
// VAT·배송비 조건은 상품마다 달라 기본 문구에서 뺐다 — 필요한 회사가 항목으로 직접 넣는다.
const DEFAULT_NOTICES = [
'협상 개시는 협상 참여 버튼을 클릭하는 순간부터 시작됩니다.',
'부여된 협상 시간에 응찰하지 않는 경우, 협상 참여의사가 없는 것으로 간주하여 재견적으로 진행될 수 있습니다.',
'본 협상 결과에 대해서는 협상자와 협상대상자 간의 비밀 유지 조건으로 진행되고, 협상에서 얻어진 결과나 내용에 대해서는 당사자를 제외하고 제 3자에 공유할 수 없으며, 비밀 유지를 전제로 진행됩니다.',
'협상이 종결되면 특별한 사유 없이 취소 변경이 불가하니, 신중하게 협상에 참여해 주시기 바랍니다.',
'안내된 사항 외 부분은 기존 견적 프로세스와 동일한 부분 유의 바랍니다.',
]
function Bullet({ children }: { children: ReactNode }) { function Bullet({ children }: { children: ReactNode }) {
return ( return (
<div className="flex items-start gap-2.5 py-1.5"> <div className="flex items-start gap-2.5 py-1.5">
@ -15,10 +26,8 @@ function Bullet({ children }: { children: ReactNode }) {
} }
export function GuideContent() { export function GuideContent() {
const { item_vat_yn, item_delivery_fee_yn } = useChatInitStore() const { data: user } = useMeQuery()
// init 미로드/값 없음 → 보수적 기본값 (KT-NEGOWIZ 와 동일한 폴백 규칙) const notices = user?.guideNotices?.length ? user.guideNotices : DEFAULT_NOTICES
const vat = item_vat_yn || 'VAT별도'
const deliveryFee = item_delivery_fee_yn || '배송비별도'
return ( return (
<div className="flex w-full flex-col"> <div className="flex w-full flex-col">
@ -31,59 +40,30 @@ export function GuideContent() {
</div> </div>
<div className="flex flex-col divide-y divide-border/60 rounded-xl border border-border px-4 py-1"> <div className="flex flex-col divide-y divide-border/60 rounded-xl border border-border px-4 py-1">
<Bullet> {notices.map((notice) => (
협상 개시는&nbsp; <Bullet key={notice}>{notice}</Bullet>
<span className="font-semibold text-neutral-90"> ))}
Negosium 시스템의 협상 참여 버튼을 클릭하는 순간부터 시작
</span>
됩니다.
</Bullet>
<Bullet>
부여된 협상 시간에&nbsp;
<span className="font-semibold text-neutral-90">
응찰하지 않는 경우, 협상 참여의사가 없는 것으로 간주하여 재견적으로 진행
</span>
될 수 있습니다.
</Bullet>
<Bullet>
협상에 입력되는 모든 가격은&nbsp;
<span className="font-semibold text-negative">
{vat} 및 {deliveryFee}
</span>
&nbsp;기준이며,&nbsp;
<span className="font-semibold text-neutral-90">
할인을 요청하는 경우 기존 공급가격에 할인율이 적용된 가격으로 환산
</span>
되어 제시됩니다.
</Bullet>
<Bullet>
본 협상 결과에 대해서는 협상자와 협상대상자 간의 비밀 유지 조건으로 진행되고, 협상에서 얻어진 결과나 내용에
대해서는 당사자를 제외하고 제 3자에 공유할 수 없으며, 비밀 유지를 전제로 진행됩니다.
</Bullet>
<Bullet>
협상이 종결되면 특별한 사유 없이 취소 변경이 불가하니, 신중하게 협상에 참여해 주시기 바랍니다.
</Bullet>
<Bullet>안내된 사항 외 부분은 기존 견적 프로세스와 동일한 부분 유의 바랍니다.</Bullet>
</div> </div>
</div> </div>
) )
} }
// 하단 문의 안내 박스 // 하단 문의 안내 박스 — 연락처는 회사 설정(branding.helpdesk). 미등록이면 박스를 통째로 숨긴다.
export function GuideContactBox() { export function GuideContactBox() {
const { data: user } = useMeQuery()
const helpdesk = user?.branding?.helpdesk ?? []
if (helpdesk.length === 0) return null
return ( return (
<div className="flex w-full flex-col items-center gap-1.5 rounded-xl bg-neutral-10 p-4"> <div className="flex w-full flex-col items-center gap-1.5 rounded-xl bg-neutral-10 p-4">
<div className={`${bodyStyle} text-center`}> <div className={`${bodyStyle} text-center`}>
기타 협상 과정에서 궁금하거나 문의하실 사항은 아래 연락처로 상담 부탁드립니다. 기타 협상 과정에서 궁금하거나 문의하실 사항은 아래 연락처로 상담 부탁드립니다.
</div> </div>
<div className="break-keep text-center text-sm font-bold text-neutral-90"> {helpdesk.map((line) => (
헬프데스크 010-0000-0000 · o2odev@o2o.kr <div key={line} className="break-keep text-center text-sm font-bold text-neutral-90">
</div> {line}
</div>
))}
</div> </div>
) )
} }

View File

@ -0,0 +1,35 @@
import { useNavigate } from 'react-router'
import { RejectPopup as RejectPopupForm } from '@/components'
import { getApiErrorMessage, useRejectMutation } from '@/apis'
import { toast } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
// 협상 진행 중 거부 — 폼은 목록 거부와 공용이고, 여기서는 대화 세션에 붙여 제출·이탈만 처리한다.
export function RejectPopup({ onClose }: { onClose: () => void }) {
const navigate = useNavigate()
const reject = useRejectMutation()
const sessionId = useChatStore((s) => s.sessionId)
const itemVatYn = useChatInitStore((s) => s.item_vat_yn)
return (
<RejectPopupForm
onClose={onClose}
isPending={reject.isPending}
isVatExcluded={itemVatYn === 'VAT별도'}
onSubmit={(request) =>
reject.mutate(
{ sessionId, request },
{
onSuccess: () => {
onClose()
toast.warning('협상 거부가 완료되었습니다.')
navigate('/list')
},
onError: (error) => toast.error(getApiErrorMessage(error, '거부 처리에 실패했습니다.')),
},
)
}
/>
)
}

View File

@ -1,17 +1,40 @@
import { CheckCircle2 } from 'lucide-react' import { CheckCircle2 } from 'lucide-react'
import { numberToKorean } from '@/features/chat/lib/koreanNumber' import { numberToKorean } from '@/lib'
import { useMeQuery } from '@/apis'
import type { SessionField } from '@/apis/auth/auth.type'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
interface BidSummaryProps { interface BidSummaryProps {
itemName: string itemName: string
itemCode: string itemCode: string
bidPrice: number bidPrice: number
deliveryType: string }
isVAT: boolean
// 부가정보 값 표시 포맷(불리언→예/아니오, 숫자→천단위, 그 외→문자열)
function formatFieldValue(type: SessionField['type'], value: unknown): string {
if (type === 'boolean') return value ? '예' : '아니오'
if (type === 'number') return Number(value).toLocaleString()
return String(value)
} }
// 투찰 결과 요약 카드 (보고서식 final-summary) // 투찰 결과 요약 카드 (보고서식 final-summary)
export function BidSummary({ itemName, itemCode, bidPrice, deliveryType, isVAT }: BidSummaryProps) { // 공급사가 마무리에서 입력한 부가정보(회사설정 session_fields + 의견)가 있으면 함께 표시한다(입력완료 시 요약 갱신).
export function BidSummary({ itemName, itemCode, bidPrice }: BidSummaryProps) {
// VAT 표기는 회사 설정 기준(item_vat_yn). vat_yn 미관리(hidden)면 비어 라벨을 생략한다.
const item_vat_yn = useChatInitStore((s) => s.item_vat_yn)
const custom = useChatInitStore((s) => s.custom)
const { data: user } = useMeQuery()
const fields: SessionField[] = user?.sessionFields ?? []
const priceStr = (bidPrice || 0).toLocaleString() const priceStr = (bidPrice || 0).toLocaleString()
// 부가정보(회사설정 session_fields) — 완료 요약엔 구조를 항상 노출(미입력 값은 '-')
const extraRows = fields.map((f) => {
const v = custom?.[f.key]
const has = v !== undefined && v !== null && v !== ''
return { key: f.key, label: f.label, value: has ? formatFieldValue(f.type, v) : '-' }
})
const opinion = custom?.opinion ? String(custom.opinion) : ''
return ( return (
<div className="w-full rounded-2xl border-2 border-brand-light bg-white p-5 shadow-md"> <div className="w-full rounded-2xl border-2 border-brand-light bg-white p-5 shadow-md">
<div className="mb-4 flex items-center gap-2"> <div className="mb-4 flex items-center gap-2">
@ -27,10 +50,14 @@ export function BidSummary({ itemName, itemCode, bidPrice, deliveryType, isVAT }
<span className="min-w-0 flex-1 break-keep text-right text-sm"> <span className="min-w-0 flex-1 break-keep text-right text-sm">
<span className="font-bold text-[#E5484D]">{priceStr}원</span> <span className="font-bold text-[#E5484D]">{priceStr}원</span>
<span className="text-neutral-70"> ({numberToKorean(bidPrice || 0)}원)</span> <span className="text-neutral-70"> ({numberToKorean(bidPrice || 0)}원)</span>
<span className="text-neutral-70"> {isVAT ? 'VAT포함' : 'VAT별도'}</span> {item_vat_yn ? <span className="text-neutral-70"> {item_vat_yn}</span> : null}
</span> </span>
</div> </div>
<Row label="배송형태" value={deliveryType || '-'} /> {extraRows.map((r) => (
<Row key={r.key} label={r.label} value={r.value} />
))}
{/* 의견은 타결·결렬 공통 기본 필드 — 값이 없어도 항상 표시 */}
<Row label="의견" value={opinion || '-'} />
</div> </div>
</div> </div>
) )

View File

@ -1,109 +0,0 @@
import { useEffect, useState } from 'react'
import { CheckCircle2 } from 'lucide-react'
import { toast } from 'sonner'
import { useMeQuery, useSaveExtraInfoMutation, getApiErrorMessage } from '@/apis'
import type { SessionField } from '@/apis/auth/auth.type'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
// 협상 타결(Summary) 직후 부가정보 입력 폼. 필드 정의(session_fields)는 회사 설정(/me)에서 온다.
// 저장하면 sessions.custom 에 기록되고 negodata 견적상세에 표시된다. 정의가 없으면 렌더하지 않는다.
export function ExtraInfoForm() {
const sessionId = useChatStore((s) => s.sessionId)
const { data: user } = useMeQuery()
const fields: SessionField[] = user?.sessionFields ?? []
const save = useSaveExtraInfoMutation()
const existing = useChatInitStore((s) => s.custom) // 기존 입력값(재진입 프리필)
const [values, setValues] = useState<Record<string, unknown>>({})
const [inited, setInited] = useState(false)
const [saved, setSaved] = useState(false)
// fields(회사 설정)와 기존값(sessions.custom)이 준비된 첫 시점에 프리필 — 이후 사용자 편집은 보존.
useEffect(() => {
if (inited || fields.length === 0) return
const init: Record<string, unknown> = {}
for (const f of fields) init[f.key] = existing?.[f.key] ?? (f.type === 'boolean' ? false : '')
setValues(init)
setInited(true)
}, [inited, fields, existing])
if (fields.length === 0) return null
const set = (key: string, value: unknown) => setValues((v) => ({ ...v, [key]: value }))
const handleSave = () => {
const custom: Record<string, unknown> = {}
for (const f of fields) {
const v = values[f.key]
if (f.type === 'boolean') custom[f.key] = !!v
else if (v !== '' && v != null) custom[f.key] = f.type === 'number' ? Number(v) : v
}
save.mutate(
{ sessionId, request: { custom } },
{
onSuccess: () => {
setSaved(true)
toast.success('부가정보가 저장되었습니다.')
},
onError: (error) => toast.error(getApiErrorMessage(error, '부가정보 저장에 실패했습니다.')),
},
)
}
return (
<div className="w-full rounded-2xl border border-border bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<CheckCircle2 className="size-5 text-brand-600" />
<h3 className="text-sm font-bold text-neutral-90">부가정보 입력</h3>
</div>
<p className="text-sm leading-relaxed text-neutral-70 break-keep">
협상이 완료되었습니다. 아래 정보를 입력해 주세요. (저장 후에도 세션 목록에서 수정할 수 있습니다.)
</p>
<div className="mt-4 space-y-4">
{fields.map((f) => (
<div key={f.key} className="space-y-1.5">
<label className="block text-sm font-semibold text-neutral-80">{f.label}</label>
{f.type === 'boolean' ? (
<button
type="button"
role="switch"
aria-checked={!!values[f.key]}
onClick={() => set(f.key, !values[f.key])}
disabled={saved}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50 ${
values[f.key] ? 'bg-brand-600' : 'bg-neutral-30'
}`}
>
<span
className={`inline-block size-5 transform rounded-full bg-white shadow transition-transform ${
values[f.key] ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</button>
) : (
<input
type={f.type === 'number' ? 'number' : 'text'}
value={String(values[f.key] ?? '')}
onChange={(e) => set(f.key, e.target.value)}
disabled={saved}
className="h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600 disabled:bg-neutral-10"
placeholder={f.label}
/>
)}
</div>
))}
</div>
<button
type="button"
onClick={handleSave}
disabled={save.isPending || saved}
className="mt-4 h-11 w-full rounded-xl bg-brand-600 text-sm font-bold text-white transition-colors hover:bg-brand-700 disabled:opacity-50"
>
{saved ? '저장 완료' : save.isPending ? '저장 중…' : '부가정보 저장'}
</button>
</div>
)
}

View File

@ -10,6 +10,7 @@ export function OtherReason({
onChange, onChange,
isError, isError,
disabled, disabled,
onSubmit,
}: { }: {
inputValue: string inputValue: string
setInputValue: (value: string) => void setInputValue: (value: string) => void
@ -18,6 +19,7 @@ export function OtherReason({
onChange: (value: string) => void onChange: (value: string) => void
isError: boolean isError: boolean
disabled?: boolean disabled?: boolean
onSubmit?: () => void
}) { }) {
const isChecked = selectedValue === '기타' const isChecked = selectedValue === '기타'
const ref = useRef<HTMLTextAreaElement>(null) const ref = useRef<HTMLTextAreaElement>(null)
@ -73,6 +75,13 @@ export function OtherReason({
)} )}
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
// Enter=제출, Shift+Enter=줄바꿈. 한글 조합 중 Enter 는 무시.
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && onSubmit) {
e.preventDefault()
onSubmit()
}
}}
placeholder="제시한 가격을 수용할 수 없는 이유를 작성해주세요." placeholder="제시한 가격을 수용할 수 없는 이유를 작성해주세요."
disabled={isTextareaDisabled} disabled={isTextareaDisabled}
rows={1} rows={1}

View File

@ -1,119 +0,0 @@
import { useState, useMemo } from 'react'
import { AlertTriangle } from 'lucide-react'
import { cn } from '@/lib'
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
import { findRestoreScript, extractPart } from '@/features/chat/lib/rejectForm'
import { SelectRadio, SubmitButton } from '@/features/chat/components/templates/rejectControls'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
const MAX_PRICE = 999999999999999
// 재견적 실패 시: 최종 공급 희망 가격 + 배송 형태 입력 폼
export function RejectCM() {
const isLoading = useChatStore((s) => s.isLoading)
const sendMessage = useChatStore((s) => s.sendMessage)
const messages = useChatStore((s) => s.messages)
const item_vat_yn = useChatInitStore((s) => s.item_vat_yn)
const isVAT = item_vat_yn === 'VAT별도'
// 폼 값은 이전 제출 내역에서 1회만 복원 (setter 미사용 = lazy 초기화 전용)
const restored = useState(() => findRestoreScript(messages, 'rejectCM'))[0]
const [price, setPrice] = useState(() => extractPart(restored, '공급희망가격-'))
const [selectedReason, setSelectedReason] = useState(() => extractPart(restored, '배송형태-'))
const [priceErrorMessage, setPriceErrorMessage] = useState('')
const [radioErrorMessage, setRadioErrorMessage] = useState('')
// 제출 완료 여부는 messages 변화에 반응해야 함 (제출 후 갱신 반영)
const isSubmitted = useMemo(() => Boolean(findRestoreScript(messages, 'rejectCM')), [messages])
const isValid = Boolean(price && parseInt(price) > 0) && Boolean(selectedReason)
const handlePriceChange = (value: string) => {
const numeric = value.replace(/\D/g, '')
if (numeric && parseInt(numeric) > MAX_PRICE) return
setPrice(numeric)
if (numeric) setPriceErrorMessage('')
}
const koreanPrice = (() => {
if (!price) return '영'
const n = parseInt(price)
return Number.isNaN(n) || n === 0 ? '영' : numberToKorean(n)
})()
const handleSubmit = () => {
setPriceErrorMessage(!price || parseInt(price) === 0 ? '가격을 입력해주세요.' : '')
setRadioErrorMessage(!selectedReason ? '배송 형태를 선택해주세요.' : '')
if (isValid && !isLoading && !isSubmitted) {
sendMessage(`공급희망가격-${price}, 배송형태-${selectedReason}`, 'text')
}
}
const isDisabled = isLoading || isSubmitted
return (
<div className="flex flex-col w-full p-8 bg-white rounded-2xl border border-border shadow-sm gap-2 transition-all duration-300">
<div className="flex w-full items-center justify-center gap-2 rounded-xl bg-[#FFF1F1] px-4 py-3 text-sm font-bold text-[#E5484D]">
<AlertTriangle className="size-5 shrink-0" />
최종 공급 희망 가격 입력
</div>
<div className="py-2 text-sm leading-relaxed text-neutral-70 break-keep">
최종 공급 희망 가격과 배송 형태를 입력하여 주시기 바랍니다.
<br />
가격 검토를 통해 경쟁력 있는 가격일 경우 다시 협상에 참여할 기회가 주어지오니 신중한 가격 제안 부탁드립니다.
</div>
<div className="flex flex-col w-full gap-1">
<div className="flex w-full items-start gap-3">
<div className="w-[100px] shrink-0 pt-2.5 text-sm font-bold text-neutral-90">공급 희망 가격</div>
<div className="flex w-full flex-wrap items-center gap-2 pt-2">
<div className="flex items-center gap-2">
<input
type="text"
className={cn(
'h-10 max-w-[200px] rounded-xl border px-3 text-right text-sm outline-none transition-all placeholder:text-neutral-50',
isDisabled
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
)}
value={price ? parseInt(price).toLocaleString() : ''}
onChange={(e) => handlePriceChange(e.target.value)}
placeholder="0"
disabled={isDisabled}
/>
<div className="whitespace-nowrap text-sm text-neutral-70">원{isVAT && '(VAT 별도)'}</div>
</div>
<div className="whitespace-nowrap text-sm text-neutral-50">[{koreanPrice} 원]</div>
</div>
</div>
{priceErrorMessage && <div className="pl-[112px] text-xs font-medium text-negative">{priceErrorMessage}</div>}
</div>
<div className="flex w-full gap-3">
<div className="w-[100px] shrink-0 pt-2 text-sm font-bold text-neutral-90">배송 형태</div>
<div className="flex w-full flex-wrap items-center gap-x-6 gap-y-2">
{['협력사배송', '지정택배배송', '픽업배송'].map((opt) => (
<SelectRadio
key={opt}
text={opt === '협력사배송' ? '협력사 배송' : opt}
value={opt}
name="rejectcm"
selectedValue={selectedReason}
onChange={(v) => {
setSelectedReason(v)
setRadioErrorMessage('')
}}
errorMessage={radioErrorMessage}
disabled={isDisabled}
/>
))}
</div>
</div>
<div className="flex w-full items-center justify-center mt-4">
<SubmitButton isValid={isValid} isLoading={isLoading} isSubmitted={isSubmitted} onSubmit={handleSubmit} />
</div>
</div>
)
}

View File

@ -1,159 +0,0 @@
import { useState, useMemo } from 'react'
import { AlertTriangle } from 'lucide-react'
import { cn } from '@/lib'
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
import { SelectRadio, SubmitButton } from '@/features/chat/components/templates/rejectControls'
import { OtherReason } from '@/features/chat/components/templates/OtherReason'
import { findRestoreScript, restoreRejectRSP } from '@/features/chat/lib/rejectForm'
const MAX_PRICE = 999999999999999
const REASONS = [
{ value: '단가인상', text: "'원재료 가격 상승' 또는 '제조사 가격 인상'으로 요청한 공급가격을 맞출 수 없습니다." },
{ value: '수량', text: '주문 수량이 적어, 소량 생산 시 발생하는 제조비용으로 맞출 수 없습니다.' },
{ value: '단종', text: '현재 단종된 제품으로 물량 수급이 원활하지 않아 가격을 맞출 수 없습니다.' },
{ value: '품절', text: '해당 상품이 품절되어 납품할 수 없습니다.' },
]
// 협상 합의 불가 시: 최종 제안 단가 + 합의 불가 사유 입력 폼
export function RejectRSP() {
const isLoading = useChatStore((s) => s.isLoading)
const sendMessage = useChatStore((s) => s.sendMessage)
const messages = useChatStore((s) => s.messages)
const item_vat_yn = useChatInitStore((s) => s.item_vat_yn)
const isVAT = item_vat_yn === 'VAT별도'
// 폼 값은 이전 제출 내역에서 1회만 복원 (setter 미사용 = lazy 초기화 전용)
const restored = useState(() => restoreRejectRSP(findRestoreScript(messages, 'rejectRSP')))[0]
const [price, setPrice] = useState(restored.price)
const [selectedReason, setSelectedReason] = useState(restored.selectedReason)
const [reason, setReason] = useState(restored.reason)
const [otherErrorMessage, setOtherErrorMessage] = useState('')
const [priceErrorMessage, setPriceErrorMessage] = useState('')
const [radioErrorMessage, setRadioErrorMessage] = useState('')
const isSubmitted = useMemo(
() => Boolean(findRestoreScript(messages, 'rejectRSP')),
[messages],
)
const isValid =
Boolean(price && parseInt(price) > 0) &&
Boolean(selectedReason) &&
(selectedReason !== '기타' || reason.trim() !== '')
const handlePriceChange = (value: string) => {
const numeric = value.replace(/\D/g, '')
if (numeric && parseInt(numeric) > MAX_PRICE) return
setPrice(numeric)
if (numeric) setPriceErrorMessage('')
}
const handleRadioChange = (value: string) => {
setSelectedReason(value)
setRadioErrorMessage('')
if (value !== '기타') {
setReason('')
setOtherErrorMessage('')
}
}
const koreanPrice = (() => {
if (!price) return '영'
const n = parseInt(price)
return Number.isNaN(n) || n === 0 ? '영' : numberToKorean(n)
})()
const handleSubmit = () => {
setPriceErrorMessage(!price || parseInt(price) === 0 ? '가격을 입력해주세요.' : '')
setRadioErrorMessage(!selectedReason ? '사유를 선택해주세요.' : '')
setOtherErrorMessage(selectedReason === '기타' && !reason.trim() ? '기타 사유를 입력해주세요.' : '')
if (isValid && !isLoading && !isSubmitted) {
const script =
selectedReason === '기타'
? `공급희망가격-${price}, 합의불가사유-기타-${reason.trim()}`
: `공급희망가격-${price}, 합의불가사유-${selectedReason}`
sendMessage(script, 'text')
}
}
const isDisabled = isLoading || isSubmitted
return (
<div className="flex flex-col w-full p-8 bg-white rounded-2xl border border-border shadow-sm gap-2 transition-all duration-300">
<div className="flex w-full items-center justify-center gap-2 rounded-xl bg-[#FFF1F1] px-4 py-3 text-sm font-bold text-[#E5484D]">
<AlertTriangle className="size-5 shrink-0" />
협상 합의 불가 사유 및 최종 제안 단가 입력
</div>
<div className="py-2 text-sm leading-relaxed text-neutral-70 break-keep">
협상이 합의에 도달하지 못하였습니다. 최종 희망하는 가격을 입력해주시기 바랍니다.
<br />
추가로 제시한 가격을 수용할 수 없는 이유를 선택하여 주시기 바라며, 목록에 없는 경우 기타 항목 선택 후 간단한 사유를
기재하여 주시기 바랍니다.
</div>
<div className="flex flex-col w-full gap-1">
<div className="flex w-full items-start gap-3">
<div className="w-[100px] shrink-0 pt-2.5 text-sm font-bold text-neutral-90">공급 희망 가격</div>
<div className="flex w-full flex-wrap items-center gap-2 pt-2">
<div className="flex items-center gap-2">
<input
type="text"
className={cn(
'h-10 max-w-[200px] rounded-xl border px-3 text-right text-sm outline-none transition-all placeholder:text-neutral-50',
isDisabled
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
)}
value={price ? parseInt(price).toLocaleString() : ''}
onChange={(e) => handlePriceChange(e.target.value)}
placeholder="0"
disabled={isDisabled}
/>
<div className="whitespace-nowrap text-sm text-neutral-70">원{isVAT && '(VAT 별도)'}</div>
</div>
<div className="whitespace-nowrap text-sm text-neutral-50">[{koreanPrice} 원]</div>
</div>
</div>
{priceErrorMessage && <div className="pl-[112px] text-xs font-medium text-negative">{priceErrorMessage}</div>}
</div>
<div className="flex w-full gap-3">
<div className="w-[100px] shrink-0 pt-2 text-sm font-bold text-neutral-90">합의 불가 사유</div>
<div className="flex w-full flex-col mt-2 gap-2">
{REASONS.map((r) => (
<SelectRadio
key={r.value}
text={r.text}
value={r.value}
name="rejectrsp"
selectedValue={selectedReason}
onChange={handleRadioChange}
errorMessage={radioErrorMessage}
disabled={isDisabled}
/>
))}
<OtherReason
inputValue={reason}
setInputValue={(v) => {
if (selectedReason !== '기타') return
setReason(v)
if (v.trim()) setOtherErrorMessage('')
}}
errorMessage={otherErrorMessage}
selectedValue={selectedReason}
onChange={handleRadioChange}
isError={!!radioErrorMessage}
disabled={isDisabled}
/>
</div>
</div>
<div className="flex w-full items-center justify-center mt-6">
<SubmitButton isValid={isValid} isLoading={isLoading} isSubmitted={isSubmitted} onSubmit={handleSubmit} />
</div>
</div>
)
}

View File

@ -0,0 +1,52 @@
import { AlertTriangle } from 'lucide-react'
import { numberToKorean } from '@/lib'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
import { parseRejectSubmission } from '@/features/chat/lib/rejectForm'
// 결렬 제출 요약 — 타결의 BidSummary 와 대칭.
export function RejectSummary({ script }: { script: string }) {
// VAT 표기는 회사 설정 기준(item_vat_yn). 미관리(hidden)면 비어 라벨을 생략한다.
const item_vat_yn = useChatInitStore((s) => s.item_vat_yn)
const { price, reasonLabel, reasonDetail, opinion, extras } = parseRejectSubmission(script)
return (
<div className="w-full rounded-2xl border-2 border-[#FFD9D9] bg-white p-5 shadow-md">
<div className="mb-4 flex items-center gap-2">
<AlertTriangle className="size-5 text-[#E5484D]" />
<h3 className="text-sm font-bold text-neutral-90">협상 결렬 사유</h3>
</div>
<div className="divide-y divide-border/60 rounded-xl border border-border">
<div className="flex items-start justify-between gap-3 px-4 py-3">
<span className="shrink-0 text-sm text-neutral-60">공급 희망 가격</span>
<span className="min-w-0 flex-1 break-keep text-right text-sm">
<span className="font-bold text-[#E5484D]">{price.toLocaleString()}원</span>
<span className="text-neutral-70"> ({numberToKorean(price)}원)</span>
{item_vat_yn ? <span className="text-neutral-70"> {item_vat_yn}</span> : null}
</span>
</div>
<div className="flex items-start justify-between gap-3 px-4 py-3">
<span className="shrink-0 text-sm text-neutral-60">합의 불가 사유</span>
<span className="min-w-0 flex-1 break-keep text-right">
<span className="text-sm font-semibold text-neutral-90">{reasonLabel || '-'}</span>
{reasonDetail && <span className="mt-1 block text-xs text-neutral-70">{reasonDetail}</span>}
</span>
</div>
{extras.map((e) => (
<Row key={e.label} label={e.label} value={e.value} />
))}
{/* 의견은 타결·결렬 공통 기본 필드 — 값이 없어도 항상 표시 */}
<Row label="의견" value={opinion || '-'} />
</div>
</div>
)
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-3 px-4 py-3">
<span className="shrink-0 text-sm text-neutral-60">{label}</span>
<span className="min-w-0 flex-1 break-keep text-right text-sm font-semibold text-neutral-90">{value}</span>
</div>
)
}

View File

@ -0,0 +1,56 @@
import { AlertTriangle } from 'lucide-react'
import { SessionStatus } from '@/apis'
import { numberToKorean } from '@/lib'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
// 협상 거부로 끝난 세션의 제출 내역 — 결렬의 RejectSummary 와 같은 카드 규격.
// 거부는 팝업으로 내 대화에 남지 않아 대화 끝에 따로 붙인다.
export function RejectedNotice() {
const sessionStatus = useChatInitStore((s) => s.session_status)
const reason = useChatInitStore((s) => s.reject_reason)
const price = useChatInitStore((s) => s.reject_price)
const custom = useChatInitStore((s) => s.custom)
const item_vat_yn = useChatInitStore((s) => s.item_vat_yn)
if (sessionStatus !== SessionStatus.REJECTED || !reason) return null
const opinion = String(custom?.opinion ?? '')
return (
<div className="mb-6 w-full rounded-2xl border-2 border-[#FFD9D9] bg-white p-5 shadow-md">
<div className="mb-4 flex items-center gap-2">
<AlertTriangle className="size-5 text-[#E5484D]" />
<h3 className="text-sm font-bold text-neutral-90">협상 거부 사유</h3>
</div>
<div className="divide-y divide-border/60 rounded-xl border border-border">
<div className="flex items-start justify-between gap-3 px-4 py-3">
<span className="shrink-0 text-sm text-neutral-60">공급 희망 가격</span>
<span className="min-w-0 flex-1 break-keep text-right text-sm">
{price ? (
<>
<span className="font-bold text-[#E5484D]">{price.toLocaleString()}원</span>
<span className="text-neutral-70"> ({numberToKorean(price)}원)</span>
{item_vat_yn ? <span className="text-neutral-70"> {item_vat_yn}</span> : null}
</>
) : (
<span className="font-semibold text-neutral-90">미입력</span>
)}
</span>
</div>
<Row label="거부 사유" value={reason} />
{/* 의견은 타결·결렬·거부 공통 기본 필드 — 값이 없어도 항상 표시 */}
<Row label="의견" value={opinion || '-'} />
</div>
</div>
)
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-3 px-4 py-3">
<span className="shrink-0 text-sm text-neutral-60">{label}</span>
<span className="min-w-0 flex-1 break-keep text-right text-sm font-semibold text-neutral-90">{value}</span>
</div>
)
}

View File

@ -1,11 +1,20 @@
import { CheckCircle2 } from 'lucide-react' import { CheckCircle2 } from 'lucide-react'
import { numberToKorean } from '@/features/chat/lib/koreanNumber' import { numberToKorean } from '@/lib'
import { formatLeadTime } from '@/features/chat/lib/format' import { formatLeadTime } from '@/features/chat/lib/format'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
import type { ChatSummary } from '@/features/chat/types' import type { ChatSummary } from '@/features/chat/types'
// 협상 결과 요약 카드 // 협상 결과 요약 카드
export function Summary({ data }: { data: ChatSummary }) { export function Summary({ data }: { data: ChatSummary }) {
const priceStr = (data.final_price || 0).toLocaleString() const priceStr = (data.final_price || 0).toLocaleString()
// VAT 표기는 회사 설정 기준(item_vat_yn). vat_yn 미관리(hidden)면 비어 라벨을 생략한다.
const item_vat_yn = useChatInitStore((s) => s.item_vat_yn)
// 공급사가 마무리에서 남긴 의견(있을 때만 표시)
const custom = useChatInitStore((s) => s.custom)
const opinion = custom?.opinion ? String(custom.opinion) : ''
// 납기 라벨은 회사 설정(labels.lead_time) 기준 — IMK 등은 '표준납기'. 미설정 시 기본 '배송 리드타임'.
const labels = useChatInitStore((s) => s.labels)
const leadTimeLabel = labels?.['lead_time'] || '배송 리드타임'
return ( return (
<div className="w-full rounded-2xl border border-border bg-white p-5 shadow-sm"> <div className="w-full rounded-2xl border border-border bg-white p-5 shadow-sm">
@ -37,22 +46,24 @@ export function Summary({ data }: { data: ChatSummary }) {
<Row label="제품 규격" value={data.item_spec || '-'} /> <Row label="제품 규격" value={data.item_spec || '-'} />
<Row label="최소 주문" value={data.item_moq || '-'} /> <Row label="최소 주문" value={data.item_moq || '-'} />
<Row label="배송 형태" value={data.item_delivery_type || '-'} /> <Row label="배송 형태" value={data.item_delivery_type || '-'} />
<Row label="배송 리드타임" value={formatLeadTime(data.item_lead_time) || '-'} /> <Row label={leadTimeLabel} value={formatLeadTime(data.item_lead_time) || '-'} />
<div className="flex items-start justify-between gap-3 px-4 py-3"> <div className="flex items-start justify-between gap-3 px-4 py-3">
<span className="shrink-0 text-sm text-neutral-60">최종 협의 가격</span> <span className="shrink-0 text-sm text-neutral-60">최종 협의 가격</span>
<span className="min-w-0 flex-1 break-keep text-right text-sm"> <span className="min-w-0 flex-1 break-keep text-right text-sm">
<span className="font-bold text-[#E5484D]">{priceStr}원</span> <span className="font-bold text-[#E5484D]">{priceStr}원</span>
<span className="text-neutral-70"> ({numberToKorean(data.final_price || 0)}원)</span> <span className="text-neutral-70"> ({numberToKorean(data.final_price || 0)}원)</span>
<span className="text-neutral-70"> {data.item_isVAT ? 'VAT포함' : 'VAT별도'}</span> {item_vat_yn ? <span className="text-neutral-70"> {item_vat_yn}</span> : null}
</span> </span>
</div> </div>
{/* 의견은 타결·결렬 공통 기본 필드 — 값이 없어도 항상 표시 */}
<Row label="의견" value={opinion || '-'} />
</div> </div>
{/* 계약 / 담당 MD */} {/* 계약 / 구매담당자 */}
<div className="mt-3 space-y-1 text-xs text-neutral-70"> <div className="mt-3 space-y-1 text-xs text-neutral-70">
<p>공급 계약 기간: 협상 완료일로부터 1년 ({addOneYear(data.nego_end_date)})까지</p> <p>공급 계약 기간: 협상 완료일로부터 1년 ({addOneYear(data.nego_end_date)})까지</p>
<p> <p>
담당 MD: {data.md_name || '-'} ({data.md_phone_number || '-'}) {data.md_email || '-'} 구매담당자: {data.md_name || '-'} ({data.md_phone_number || '-'}) {data.md_email || '-'}
</p> </p>
</div> </div>

View File

@ -1,4 +1,4 @@
import { useState, useRef, useId } from 'react' import { useState, useRef, useId, useEffect } from 'react'
import { useChatStore } from '@/features/chat/stores/useChatStore' import { useChatStore } from '@/features/chat/stores/useChatStore'
// 단위(원/%) 오버레이 + 전송 버튼이 붙은 값 입력 (Percent / Price 공용) // 단위(원/%) 오버레이 + 전송 버튼이 붙은 값 입력 (Percent / Price 공용)
@ -22,8 +22,22 @@ function InputWithUnit({
ariaLabel: string ariaLabel: string
}) { }) {
const errorId = useId() const errorId = useId()
// 입력 단계(percent/price)로 바뀌어 마운트될 때마다 바로 타이핑 가능하게
useEffect(() => {
inputRef.current?.focus()
}, [inputRef])
return ( return (
<div className="relative flex flex-col gap-3"> <div className="relative">
{/* 레이아웃 밖(absolute)에 띄워 에러가 떠도 input 위치가 안 변한다 */}
{error && (
<div
id={errorId}
role="alert"
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 whitespace-nowrap rounded-full bg-white border border-border shadow-md px-4 py-1.5 text-sm font-semibold text-negative"
>
{error}
</div>
)}
<div className="relative flex items-center min-w-[220px] sm:min-w-[360px] min-[1180px]:min-w-[420px] min-[1350px]:min-w-[480px]"> <div className="relative flex items-center min-w-[220px] sm:min-w-[360px] min-[1180px]:min-w-[420px] min-[1350px]:min-w-[480px]">
<input <input
ref={inputRef} ref={inputRef}
@ -51,11 +65,6 @@ function InputWithUnit({
전송 전송
</button> </button>
</div> </div>
{error && (
<div id={errorId} role="alert" className="absolute top-[62px] left-1/2 -translate-x-1/2 text-sm font-semibold text-negative w-full text-center">
{error}
</div>
)}
</div> </div>
) )
} }

View File

@ -24,8 +24,9 @@ const TERMINAL_CODES = new Set<number>([
ErrorCode.NEGO_NOT_FOUND, ErrorCode.NEGO_NOT_FOUND,
]) ])
// 진입 로드(init/messages) 시 '잘못된 접근'으로 볼 코드 → 권한 없음 / 없는(또는 남의) 세션 / // 진입 로드(init/messages) 시 '잘못된 접근'으로 볼 코드 → 권한 없음 / 없는(또는 남의) 세션 / 진입 불가 상태.
// 진입 불가 상태(미참여·거부). 토스트 안내 후 목록으로 복귀시킨다. (인증≠인가 — 로그아웃하지 않는다) // 토스트 안내 후 목록으로 복귀시킨다. (인증≠인가 — 로그아웃하지 않는다)
// 종료된 세션(완료·미참여·거부)은 열람 진입이 허용되므로 여기 걸리지 않는다 — 입력만 잠긴다(ChatSection).
const INVALID_ACCESS_CODES = new Set<number>([ const INVALID_ACCESS_CODES = new Set<number>([
ErrorCode.NEGO_FORBIDDEN, ErrorCode.NEGO_FORBIDDEN,
ErrorCode.NEGO_NOT_FOUND, ErrorCode.NEGO_NOT_FOUND,
@ -115,8 +116,8 @@ export function useChatController(sessionId: string) {
if (data.message) s.appendMessage(mapMessage(data.message)) if (data.message) s.appendMessage(mapMessage(data.message))
s.setIsLoading(false) s.setIsLoading(false)
// 협상 종료 전이(완료/거부 등): 목록 캐시만 무효화해 /list 복귀 시 최신 상태를 보장한다. // 협상 종료 전이(완료/거부 등): 목록 캐시만 무효화해 /list 복귀 시 최신 상태를 보장한다.
// init 은 '진입 메타'라 화면에서 재조회하지 않는다 — 재조회하면 미참여/거부 진입 게이트(1301)에 // init 은 '진입 메타'라 화면에서 재조회하지 않는다 — 방금 종료한 화면의 입력이 열람 모드로
// 걸려 방금 정상 종료한 사용자가 튕겨난다. 재진입 시 최신 status 는 언마운트의 removeQueries 가 보장한다. // 갈아치워지지 않게 한다. 재진입 시 최신 status 는 언마운트의 removeQueries 가 보장한다.
if (data.session_status !== SessionStatus.IN_PROGRESS || data.message?.chat_end) { if (data.session_status !== SessionStatus.IN_PROGRESS || data.message?.chat_end) {
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() }) queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
} }

View File

@ -0,0 +1,8 @@
// 협상 절차 5단계 — NegoStep(사이드) · MobileStepBar(모바일 상단)가 공유.
export const STEPS = [
{ name: '서비스안내', desc: '협상 방식과 유의사항을 확인합니다.' },
{ name: '담당자확인', desc: '협상 담당자 본인 여부를 확인합니다.' },
{ name: '협상품목안내', desc: '대상 품목과 기준 단가를 확인합니다.' },
{ name: '가격협상', desc: '공급 단가를 제안하고 조율합니다.' },
{ name: '협상종료', desc: '최종 합의 후 결과를 확인합니다.' },
]

View File

@ -1,5 +1,13 @@
import type { ChatMessage } from '@/features/chat/types' import type { ChatMessage } from '@/features/chat/types'
// value = sessions.reject_reason 에 저장되는 값
export const REJECT_REASONS = [
{ value: '단가인상', text: "'원재료 가격 상승' 또는 '제조사 가격 인상'으로 요청한 공급가격을 맞출 수 없습니다." },
{ value: '수량', text: '주문 수량이 적어, 소량 생산 시 발생하는 제조비용으로 맞출 수 없습니다.' },
{ value: '단종', text: '현재 단종된 제품으로 물량 수급이 원활하지 않아 가격을 맞출 수 없습니다.' },
{ value: '품절', text: '해당 상품이 품절되어 납품할 수 없습니다.' },
] as const
// reject 폼 직전 사용자 답변(script)을 찾아 복원용으로 반환 // reject 폼 직전 사용자 답변(script)을 찾아 복원용으로 반환
export function findRestoreScript(messages: ChatMessage[], type: 'rejectCM' | 'rejectRSP'): string | null { export function findRestoreScript(messages: ChatMessage[], type: 'rejectCM' | 'rejectRSP'): string | null {
const reversed = [...messages].reverse() const reversed = [...messages].reverse()
@ -22,3 +30,28 @@ export function restoreRejectRSP(script: string | null) {
if (reasonRaw.startsWith('기타-')) return { price, selectedReason: '기타', reason: reasonRaw.replace('기타-', '') } if (reasonRaw.startsWith('기타-')) return { price, selectedReason: '기타', reason: reasonRaw.replace('기타-', '') }
return { price, selectedReason: reasonRaw, reason: '' } return { price, selectedReason: reasonRaw, reason: '' }
} }
// 제출 script → 표시값
export function parseRejectSubmission(script: string | null) {
const { price, selectedReason, reason } = restoreRejectRSP(script)
const preset = REJECT_REASONS.find((r) => r.value === selectedReason)
return {
price: price ? parseInt(price) : 0,
reasonLabel: selectedReason,
reasonDetail: selectedReason === '기타' ? reason : (preset?.text ?? ''),
opinion: extractPart(script, '의견-'),
extras: extraParts(script),
}
}
// 통합 폼 이전 제출분의 '배송형태-' 등. 사유 뒤는 자유서술이 섞여 앞 구간만 훑는다.
function extraParts(script: string | null): { label: string; value: string }[] {
const head = (script ?? '').split(', 의견-')[0].split(', 합의불가사유-')[0]
return head
.split(', ')
.filter((p) => p && !p.startsWith('공급희망가격-'))
.map((p) => {
const i = p.indexOf('-')
return i > 0 ? { label: p.slice(0, i), value: p.slice(i + 1) } : { label: '항목', value: p }
})
}

View File

@ -2,6 +2,17 @@ import type { ChatMessage, UserButtonConfig } from '@/features/chat/types'
export const GO_TO_LIST_TEXT = '상품 목록으로 가기' export const GO_TO_LIST_TEXT = '상품 목록으로 가기'
// 협상 거부 진입점을 숨기는 액션바 상태.
// reject=봇이 이미 결렬을 선언(그 폼이 최종가를 받는다) / extra-info=타결 후 동의 단계 / loading=응답 대기.
const NO_REJECT_TYPES = ['', 'loading', 'reject', 'extra-info']
// 채팅 안에서 협상을 거부할 수 있는 상태인지 — 대화가 끝나지 않고 협력사 입력을 기다리는 동안만.
export function canRejectNegotiation(messages: ChatMessage[], config: UserButtonConfig): boolean {
if (!messages || messages.length === 0) return false
if (messages[messages.length - 1].chat_end) return false
return !NO_REJECT_TYPES.includes(config.type)
}
// 마지막 봇 메시지의 next_input_mode 로 하단 입력 UI 구성을 결정한다. // 마지막 봇 메시지의 next_input_mode 로 하단 입력 UI 구성을 결정한다.
export function deriveUserButtonConfig( export function deriveUserButtonConfig(
messages: ChatMessage[], messages: ChatMessage[],
@ -16,7 +27,14 @@ export function deriveUserButtonConfig(
const options = last.next_input_type const options = last.next_input_type
if (last.chat_end) return { type: 'one-black', text: GO_TO_LIST_TEXT } if (last.chat_end) return { type: 'one-black', text: GO_TO_LIST_TEXT }
if (mode === 'confirm') return { type: 'one-black', text: options?.[0] || '' } // 결렬 폼(rejectRSP/rejectCM)은 통일된 RejectForm 을 액션바에서 받는다(메시지 카드 아님).
if (last.bot_chat_type === 'rejectRSP' || last.bot_chat_type === 'rejectCM') return { type: 'reject' }
// 타결 요약이 뜬 뒤의 '동의' 단계 = 부가정보 입력 + 동의를 한 자리(액션바)에서 받는다.
// (요약 이후 confirm 단계에만 적용 — 재견적의 투찰확정/정보수정 같은 선택 단계는 그대로 둔다.)
const dealt = messages.some((m) => m.bot_chat_type === 'summaryRSP' || m.bot_chat_type === 'summaryCM')
if (mode === 'confirm') {
return dealt ? { type: 'extra-info', text: options?.[0] || '' } : { type: 'one-black', text: options?.[0] || '' }
}
if (mode === 'yes_no') return { type: 'black-white', textList: options || [] } if (mode === 'yes_no') return { type: 'black-white', textList: options || [] }
if (mode === 'percent') return { type: 'percent' } if (mode === 'percent') return { type: 'percent' }
if (mode === 'price') return { type: 'price', priceErrorMessage: priceErrorMessage || undefined } if (mode === 'price') return { type: 'price', priceErrorMessage: priceErrorMessage || undefined }

View File

@ -16,6 +16,7 @@ const initialState: ChatInitData = {
session_status: 0, session_status: 0,
item_id: '', item_id: '',
quotation_id: '', quotation_id: '',
qt_number: '',
item_name: '', item_name: '',
item_code: '', item_code: '',
item_image: '', item_image: '',
@ -30,6 +31,9 @@ const initialState: ChatInitData = {
quotation_memo: '', quotation_memo: '',
quotation_end_time: '', quotation_end_time: '',
custom: {}, custom: {},
labels: {},
reject_reason: '',
reject_price: null,
} }
export const useChatInitStore = create<ChatInitStore>((set) => ({ export const useChatInitStore = create<ChatInitStore>((set) => ({

View File

@ -53,6 +53,8 @@ export type UserButtonType =
| 'percent' | 'percent'
| 'three-black' | 'three-black'
| 'price' | 'price'
| 'extra-info'
| 'reject'
| 'loading' | 'loading'
| '' | ''
@ -68,6 +70,7 @@ export type ChatInitData = {
session_status: number // SessionStatus 코드 (진입 시점 기준) session_status: number // SessionStatus 코드 (진입 시점 기준)
item_id: string item_id: string
quotation_id: string quotation_id: string
qt_number: string
item_name: string item_name: string
item_code: string item_code: string
item_image: string item_image: string
@ -82,4 +85,7 @@ export type ChatInitData = {
quotation_memo: string quotation_memo: string
quotation_end_time: string quotation_end_time: string
custom: Record<string, unknown> // 협상완료 부가정보 기존 입력값(프리필용) custom: Record<string, unknown> // 협상완료 부가정보 기존 입력값(프리필용)
labels: Record<string, string> // 회사 커스텀 라벨(companies.settings.labels). 상품 상세 필드명 치환용
reject_reason: string // 협상 거부로 끝난 세션의 제출 사유(의견은 custom.opinion)
reject_price: number | null // 거부와 함께 낸 공급 희망 가격(원)
} }

View File

@ -22,6 +22,9 @@ export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProp
for (const f of fields) init[f.key] = target.custom?.[f.key] ?? (f.type === 'boolean' ? false : '') for (const f of fields) init[f.key] = target.custom?.[f.key] ?? (f.type === 'boolean' ? false : '')
return init return init
}) })
const [opinion, setOpinion] = useState<string>(() => String(target.custom?.opinion ?? ''))
// 부가정보는 협상 완료(동의) 시점에 확정 — 리스트에서는 항상 읽기전용(보기). 입력은 완료 챗에서만.
const readOnly = true
const set = (key: string, value: unknown) => setValues((v) => ({ ...v, [key]: value })) const set = (key: string, value: unknown) => setValues((v) => ({ ...v, [key]: value }))
@ -33,6 +36,7 @@ export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProp
if (f.type === 'boolean') out[f.key] = !!v if (f.type === 'boolean') out[f.key] = !!v
else if (v !== '' && v != null) out[f.key] = f.type === 'number' ? Number(v) : v else if (v !== '' && v != null) out[f.key] = f.type === 'number' ? Number(v) : v
} }
if (opinion.trim()) out.opinion = opinion.trim()
onSubmit(out) onSubmit(out)
onClose() onClose()
} }
@ -42,8 +46,11 @@ export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProp
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in"> <div className="w-full max-w-md overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
<div className="flex items-center justify-between border-b border-border p-5"> <div className="flex items-center justify-between border-b border-border p-5">
<div> <div>
<h3 className="text-base font-bold text-neutral-90">협상완료 부가정보</h3> <h3 className="text-base font-bold text-neutral-90">협상완료 부가정보{readOnly ? ' (보기)' : ''}</h3>
<p className="mt-0.5 text-xs text-neutral-60">{target.qt_number} · {target.item_name}</p> <p className="mt-0.5 text-xs text-neutral-60">
{target.qt_number} · {target.item_name}
{readOnly ? ' · 제출 후에는 수정할 수 없습니다.' : ''}
</p>
</div> </div>
<button <button
type="button" type="button"
@ -56,10 +63,7 @@ export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProp
</div> </div>
<div className="space-y-4 p-5"> <div className="space-y-4 p-5">
{fields.length === 0 ? ( {fields.map((f) => (
<p className="py-6 text-center text-sm text-neutral-60">입력할 부가정보 항목이 없습니다.</p>
) : (
fields.map((f) => (
<div key={f.key} className="space-y-1.5"> <div key={f.key} className="space-y-1.5">
<label className="block text-sm font-semibold text-neutral-80">{f.label}</label> <label className="block text-sm font-semibold text-neutral-80">{f.label}</label>
{f.type === 'boolean' ? ( {f.type === 'boolean' ? (
@ -67,8 +71,9 @@ export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProp
type="button" type="button"
role="switch" role="switch"
aria-checked={!!values[f.key]} aria-checked={!!values[f.key]}
disabled={readOnly}
onClick={() => set(f.key, !values[f.key])} onClick={() => set(f.key, !values[f.key])}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${
values[f.key] ? 'bg-brand-600' : 'bg-neutral-30' values[f.key] ? 'bg-brand-600' : 'bg-neutral-30'
}`} }`}
> >
@ -78,36 +83,73 @@ export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProp
}`} }`}
/> />
</button> </button>
) : f.type === 'select' ? (
<select
value={String(values[f.key] ?? '')}
onChange={(e) => set(f.key, e.target.value)}
disabled={readOnly}
className="h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600 disabled:cursor-not-allowed disabled:bg-neutral-10 disabled:text-neutral-60"
>
<option value="">선택</option>
{(f.options ?? []).map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
) : ( ) : (
<input <input
type={f.type === 'number' ? 'number' : 'text'} type={f.type === 'number' ? 'number' : 'text'}
value={String(values[f.key] ?? '')} value={String(values[f.key] ?? '')}
onChange={(e) => set(f.key, e.target.value)} onChange={(e) => set(f.key, e.target.value)}
className="h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600" disabled={readOnly}
className="h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600 disabled:cursor-not-allowed disabled:bg-neutral-10 disabled:text-neutral-60"
placeholder={f.label} placeholder={f.label}
/> />
)} )}
</div> </div>
)) ))}
)} <div className="space-y-1.5">
<label className="block text-sm font-semibold text-neutral-80">
의견 <span className="font-normal text-neutral-50">(선택)</span>
</label>
<textarea
value={opinion}
onChange={(e) => setOpinion(e.target.value)}
rows={2}
maxLength={255}
disabled={readOnly}
placeholder="추가로 남길 의견"
className="w-full resize-none rounded-xl border border-border bg-white px-3 py-2 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600 disabled:cursor-not-allowed disabled:bg-neutral-10 disabled:text-neutral-60"
/>
</div>
</div> </div>
<div className="flex gap-2 border-t border-border p-5"> <div className="flex gap-2 border-t border-border p-5">
<button {readOnly ? (
type="button" <button
onClick={onClose} type="button"
className="h-11 flex-1 rounded-xl border border-border text-sm font-bold text-neutral-70 hover:bg-neutral-10" onClick={onClose}
> className="h-11 flex-1 rounded-xl border border-border text-sm font-bold text-neutral-70 hover:bg-neutral-10"
취소 >
</button> 닫기
<button </button>
type="button" ) : (
onClick={handleSubmit} <>
disabled={fields.length === 0} <button
className="h-11 flex-1 rounded-xl bg-brand-600 text-sm font-bold text-white hover:bg-brand-700 disabled:opacity-40" type="button"
> onClick={onClose}
저장 className="h-11 flex-1 rounded-xl border border-border text-sm font-bold text-neutral-70 hover:bg-neutral-10"
</button> >
취소
</button>
<button
type="button"
onClick={handleSubmit}
className="h-11 flex-1 rounded-xl bg-brand-600 text-sm font-bold text-white hover:bg-brand-700 disabled:opacity-40"
>
저장
</button>
</>
)}
</div> </div>
</div> </div>
</Modal> </Modal>

View File

@ -0,0 +1,92 @@
import { X } from 'lucide-react'
import { Modal } from '@/components'
import { cn, interactive } from '@/lib'
// 공급사 포털 목록 화면 이용안내. 상태(진행)·결과(마감)·재협상 3파트로 최근 추가된 용어를 설명한다.
// 헤더의 '이용안내' 버튼으로 여는 수동형 팝업. X·배경 클릭·ESC 로 닫는다.
export function GuidePopup({ onClose }: { onClose: () => void }) {
return (
<Modal onClose={onClose}>
<div className="m-4 flex max-h-[90vh] w-full max-w-[560px] flex-col overflow-y-auto rounded-2xl border border-border bg-white p-6 shadow-xl animate-scale-in sm:p-8">
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-extrabold text-neutral-90">이용안내</h2>
<p className="mt-1 text-[13px] text-neutral-60">협상 목록의 상태·결과·재협상을 안내합니다.</p>
</div>
<button
type="button"
aria-label="닫기"
onClick={onClose}
className={cn('flex size-9 shrink-0 items-center justify-center rounded-full bg-neutral-10 text-neutral-70 hover:bg-neutral-20', interactive)}
>
<X size={18} />
</button>
</div>
<div className="flex flex-col gap-6">
<Section title="진행 상태" desc="협상 세션이 지금 어느 단계인지 — 내가 무엇을 할지 알려줍니다.">
<Row badge="협상 대기" tone="wait" text="초청받은 협상. 입장해서 가격 협상을 시작하세요." />
<Row badge="협상 중" tone="prog" text="가격을 조율하는 중입니다. 이어서 진행하세요." />
<Row badge="협상 완료" tone="done" text="가격 제출을 마쳤습니다. 최종 결과는 견적 마감 후 아래 '결과'로 표시됩니다." />
<Row badge="협상 미참여" tone="none" text="기한 내 참여하지 않아 종료된 건입니다." />
<Row badge="협상 거부" tone="reject" text="내가 거부한 건입니다." />
</Section>
<Section title="협상 결과" desc="견적이 마감된 뒤 정해지는 낙찰 결과입니다. 마감 전에는 표시되지 않습니다.">
<Row badge="낙찰" tone="win" text="내가 낙찰되어 계약 대상이 된 건입니다." />
<Row badge="미낙찰" tone="lost" text="다른 공급사가 낙찰되어 이번 계약에서는 제외된 건입니다." />
<Row badge="결렬" tone="open" text="낙찰자 없이 마감된 건입니다. 조건이 바뀌었다면 재협상을 요청할 수 있습니다." />
</Section>
<Section title="재협상 요청" desc="결렬(낙찰자 미정 마감) 건에 한해 가능합니다.">
<p className="text-[13px] leading-relaxed text-neutral-70">
결렬 건의 <b className="font-bold text-neutral-90">재협상 요청</b> 버튼으로 사유·희망가를 담아 요청하면,
구매 담당자 검토 후 <b className="font-bold text-neutral-90">승인 시 다음 차수 협상</b>이 새로 열립니다.
요청 상태(심사 중·승인·반려)는 목록에서 확인할 수 있습니다.
</p>
</Section>
</div>
<button
type="button"
onClick={onClose}
className="mt-7 h-11 w-full rounded-xl bg-brand-600 text-sm font-bold text-white transition-colors hover:bg-brand-700"
>
확인했어요
</button>
</div>
</Modal>
)
}
function Section({ title, desc, children }: { title: string; desc: string; children: React.ReactNode }) {
return (
<section>
<h3 className="text-sm font-extrabold text-neutral-90">{title}</h3>
<p className="mb-3 mt-0.5 text-xs text-neutral-60">{desc}</p>
<div className="flex flex-col gap-2.5">{children}</div>
</section>
)
}
const TONE: Record<string, string> = {
wait: 'bg-brand-light text-brand-700',
prog: 'bg-[#FFF3E5] text-[#F5A623]',
done: 'bg-[#EAFDF3] text-success',
none: 'bg-neutral-20 text-neutral-60',
reject: 'bg-[#FFEBEB] text-[#FF4D4F]',
win: 'bg-[#EAFDF3] text-success',
lost: 'bg-neutral-20 text-neutral-60',
open: 'bg-[#FFF3E5] text-[#F5A623]',
}
function Row({ badge, tone, text }: { badge: string; tone: string; text: string }) {
return (
<div className="flex items-start gap-2.5">
<span className={cn('mt-0.5 inline-flex shrink-0 items-center rounded-full px-2.5 py-1 text-xs font-bold', TONE[tone])}>
{badge}
</span>
<p className="text-[13px] leading-relaxed text-neutral-70">{text}</p>
</div>
)
}

View File

@ -1,54 +1,85 @@
import { AlertTriangle, CheckCircle2, FileText, RefreshCw } from 'lucide-react' import type { ReactNode } from 'react'
import { cn } from '@/lib' import { cn } from '@/lib'
import { useSessionCounts, type KpiCount } from '@/features/list/hooks/useSessionCounts' import { useSessionCounts, type KpiCount } from '@/features/list/hooks/useSessionCounts'
// key → 아이콘/색 스타일 (콕핏 카드) // 목업(claude artifact) 그대로: 진행(내가 할 일=상태축) / 결과(마감 후=결과축) 두 묶음.
const STYLE: Record<string, { icon: typeof FileText; tile: string; iconColor: string; spin?: boolean }> = { // 모든 카드 동일 크기·높이 — 강조는 색·CTA 로만. 낙찰=초록, 결렬=amber(재협상 가능), 미낙찰=회색.
created: { icon: FileText, tile: 'bg-brand-light', iconColor: 'text-brand-600' },
progress: { icon: RefreshCw, tile: 'bg-sky-50', iconColor: 'text-sky-500', spin: true },
done: { icon: CheckCircle2, tile: 'bg-emerald-50', iconColor: 'text-success' },
rejected: { icon: AlertTriangle, tile: 'bg-red-50', iconColor: 'text-[#FF4D4F]' },
}
interface KpiCardsProps { interface KpiCardsProps {
selectedStatus: string | null selectedStatus: string | null
onSelect: (status: string) => void selectedResult: number | null
onSelectStatus: (status: string) => void
onSelectResult: (code: number) => void
} }
export function KpiCards({ selectedStatus, onSelect }: KpiCardsProps) { // key → 배지 점 색 / 숫자 색 / 카드 배경·테두리 / CTA.
const counts = useSessionCounts() const STYLE: Record<string, { dot?: string; num: string; card?: string; cta?: string; ctaText?: string }> = {
created: { num: 'text-brand-600' },
progress: { num: 'text-neutral-90' },
won: { dot: 'bg-success', num: 'text-success', card: 'border-success/40 bg-gradient-to-b from-emerald-50 to-white', cta: 'text-success', ctaText: '★ 계약 대상' },
open: { dot: 'bg-warning', num: 'text-warning', card: 'border-warning/40', cta: 'text-warning', ctaText: '▲ 재협상 요청 가능' },
lost: { dot: 'bg-neutral-50', num: 'text-neutral-70' },
}
export function KpiCards({ selectedStatus, selectedResult, onSelectStatus, onSelectResult }: KpiCardsProps) {
const { progress, result } = useSessionCounts()
const isActive = (c: KpiCount) =>
c.kind === 'status' ? selectedStatus === c.statusLabel : selectedResult === c.resultCode
const onClick = (c: KpiCount) =>
c.kind === 'status' ? onSelectStatus(c.statusLabel!) : onSelectResult(c.resultCode!)
return ( return (
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4"> <div className="grid gap-4 lg:grid-cols-[2fr_3fr]">
{counts.map((c) => ( <Cluster label="진행 · 내가 할 일" dot="bg-brand-600" cols="grid-cols-2">
<Card key={c.key} data={c} active={selectedStatus === c.status} onClick={() => onSelect(c.status)} /> {progress.map((c) => (
))} <Card key={c.key} data={c} active={isActive(c)} onClick={() => onClick(c)} />
))}
</Cluster>
<Cluster label="결과 · 마감 후" dot="bg-success" cols="grid-cols-3">
{result.map((c) => (
<Card key={c.key} data={c} active={isActive(c)} onClick={() => onClick(c)} />
))}
</Cluster>
</div>
)
}
function Cluster({ label, dot, cols, children }: { label: string; dot: string; cols: string; children: ReactNode }) {
return (
<div>
<div className="mb-2 flex items-center gap-1.5 px-0.5 text-[11px] font-extrabold uppercase tracking-wider text-neutral-60">
<span className={cn('size-1.5 rounded-full', dot)} />
{label}
</div>
<div className={cn('grid gap-2.5', cols)}>{children}</div>
</div> </div>
) )
} }
function Card({ data, active, onClick }: { data: KpiCount; active: boolean; onClick: () => void }) { function Card({ data, active, onClick }: { data: KpiCount; active: boolean; onClick: () => void }) {
const s = STYLE[data.key] const s = STYLE[data.key] ?? STYLE.created
const Icon = s.icon const ctaText = s.ctaText ?? ' ' // 빈 카드도 CTA 줄 높이를 확보(nbsp) → 5개 카드 높이 완전 동일
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className={cn( className={cn(
'flex items-center gap-3.5 rounded-2xl border bg-white p-4 text-left transition-all sm:p-5', 'flex h-full flex-col rounded-2xl border bg-white p-4 text-left shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md active:scale-[0.99]',
'hover:shadow-md active:scale-[0.99]', s.card ?? 'border-border',
active ? 'border-brand-600 ring-2 ring-brand-600/10 shadow-sm' : 'border-border shadow-sm', // 오프셋 링이라 카드 배경색(낙찰 초록·결렬 amber)과 무관하게 선택이 또렷하게 보인다.
active && 'ring-2 ring-brand-600 ring-offset-2 ring-offset-surface',
)} )}
> >
<div className={cn('flex size-10 shrink-0 items-center justify-center rounded-xl', s.tile)}> <p className="mb-2 flex items-center gap-1.5 whitespace-nowrap text-xs font-bold text-neutral-70">
<Icon className={cn('size-5', s.iconColor, s.spin && 'animate-spin-slow')} /> {s.dot && <span className={cn('size-1.5 rounded-full', s.dot)} />}
</div> {data.label}
<div className="min-w-0"> </p>
<p className="truncate text-[11px] font-bold text-neutral-60">{data.label}</p> <p className={cn('text-2xl font-extrabold tabular-nums', s.num)}>
<p className="text-lg font-extrabold tabular-nums text-neutral-90"> {data.isLoading ? '–' : data.count.toLocaleString()}
{data.isLoading ? '–' : data.count.toLocaleString()} <span className="ml-0.5 text-xs font-bold text-neutral-60">건</span>
<span className="ml-0.5 text-xs font-bold text-neutral-60">건</span> </p>
</p> {/* CTA 는 좁은 카드(모바일 3열)에서 nowrap 이면 넘치므로 단어 단위로만 줄바꿈 */}
</div> <p className={cn('mt-1.5 break-keep text-[11px] font-extrabold', s.cta)}>{ctaText}</p>
</button> </button>
) )
} }

View File

@ -1,124 +0,0 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { Modal } from '@/components'
import { cn } from '@/lib'
const REASONS = ['단종', '품절', '기타'] as const
export interface RejectPopupProps {
onClose: () => void
/** 최종 거부 사유 (프리셋 라벨 또는 기타 입력 텍스트) */
onSubmit: (reason: string) => void
}
// 거부 사유 입력 팝업 (단종/품절/기타).
export function RejectPopup({ onClose, onSubmit }: RejectPopupProps) {
const [selectedReason, setSelectedReason] = useState<string | null>(null)
const [customReason, setCustomReason] = useState('')
const [showError, setShowError] = useState(false)
const isEtcOpen = selectedReason === '기타'
const isSubmitDisabled = !selectedReason || (isEtcOpen && !customReason.trim())
const handleReasonClick = (reason: string) => {
setShowError(false)
if (selectedReason === reason) {
setSelectedReason(null)
setCustomReason('')
} else {
setSelectedReason(reason)
if (reason !== '기타') setCustomReason('')
}
}
const handleSubmit = () => {
if (isEtcOpen && !customReason.trim()) {
setShowError(true)
return
}
if (isSubmitDisabled || !selectedReason) return
onSubmit(isEtcOpen ? customReason.trim() : selectedReason)
onClose()
}
return (
<Modal onClose={onClose}>
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
{/* 헤더 */}
<div className="flex items-center justify-between border-b border-border p-5">
<h3 className="text-base font-bold text-neutral-90">거부 사유를 입력해주세요</h3>
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
>
<X className="size-4" />
</button>
</div>
{/* 본문 */}
<div className="space-y-4 p-5">
<div className="grid grid-cols-3 gap-2">
{REASONS.map((reason) => (
<button
key={reason}
type="button"
onClick={() => handleReasonClick(reason)}
className={cn(
'h-11 rounded-xl border text-sm font-bold transition-all active:scale-[0.98]',
selectedReason === reason
? 'border-brand-600 bg-brand-light text-brand-600'
: 'border-border bg-white text-neutral-70 hover:bg-neutral-10',
)}
>
{reason}
</button>
))}
</div>
{isEtcOpen && (
<div className="animate-fade-in">
<textarea
placeholder="사유를 입력하여 주십시오"
value={customReason}
onChange={(e) => {
setCustomReason(e.target.value)
setShowError(false)
}}
rows={3}
className={cn(
'w-full resize-none rounded-xl border bg-white p-3 text-sm text-neutral-90 outline-none transition-all',
'placeholder:text-neutral-50 focus:ring-1',
showError
? 'border-destructive focus:border-destructive focus:ring-destructive'
: 'border-border focus:border-brand-600 focus:ring-brand-600',
)}
/>
{showError && <p className="mt-1.5 text-xs font-medium text-destructive">기타 사유를 입력해주세요</p>}
</div>
)}
</div>
{/* 푸터 */}
<div className="flex gap-2 border-t border-border p-4">
<button
type="button"
onClick={onClose}
className="h-11 flex-1 rounded-xl border border-border bg-white text-sm font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]"
>
취소
</button>
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitDisabled}
className="h-11 flex-1 rounded-xl bg-brand-600 text-sm font-bold text-white shadow-sm transition-all hover:bg-brand-700 active:scale-[0.98] disabled:opacity-40"
>
거부 처리
</button>
</div>
</div>
</Modal>
)
}

View File

@ -0,0 +1,54 @@
import { X } from 'lucide-react'
import { Modal } from '@/components'
import { RENEGO_STATUS_LABEL } from '@/apis/negotiation/negotiation.type'
import type { ListItem } from '../types'
export interface RenegotiationMemoPopupProps {
target: ListItem
onClose: () => void
}
// 재협상 심사 결과의 담당자 메모 열람 팝업. 목록에는 상태 배지만 두고 메모 전문은 여기서 보여준다.
export function RenegotiationMemoPopup({ target, onClose }: RenegotiationMemoPopupProps) {
return (
<Modal onClose={onClose}>
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
<div className="flex items-center justify-between border-b border-border p-5">
<div>
<h3 className="text-base font-bold text-neutral-90">
{RENEGO_STATUS_LABEL[target.renegotiationStatus] ?? '재협상 심사 결과'}
</h3>
<p className="mt-0.5 text-xs text-neutral-60">
{target.qt_number} · {target.item_name}
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
>
<X className="size-4" />
</button>
</div>
<div className="p-5">
<p className="text-sm font-semibold text-neutral-80">담당자 메모</p>
<p className="mt-1.5 max-h-64 overflow-y-auto whitespace-pre-wrap break-keep rounded-xl bg-neutral-10 p-3 text-sm leading-relaxed text-neutral-80">
{target.renegotiationMemo}
</p>
</div>
<div className="border-t border-border p-5">
<button
type="button"
onClick={onClose}
className="w-full rounded-xl bg-brand-600 py-3 text-sm font-bold text-white shadow-sm transition-all hover:bg-brand-700 active:scale-[0.98]"
>
확인
</button>
</div>
</div>
</Modal>
)
}

View File

@ -0,0 +1,122 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { Modal } from '@/components'
import type { ListItem } from '../types'
export interface RenegotiationPopupProps {
target: ListItem
onClose: () => void
onSubmit: (reason: string, desiredPrice: number | null) => void
}
// 재협상 요청 팝업. 결렬(낙찰자 미정) 건에만 열리며, 담당자 승인 시 다음 차수가 생성된다.
// 사유는 자주 쓰는 것을 프리셋으로 두고, 직접 입력도 허용한다.
const PRESETS = ['가격 조건 재검토', '재고·납기 확보', '단가 정정', '수량 조건 변경'] as const
export function RenegotiationPopup({ target, onClose, onSubmit }: RenegotiationPopupProps) {
const [preset, setPreset] = useState<string>(PRESETS[0])
const [custom, setCustom] = useState('')
const [price, setPrice] = useState('')
const isDirect = preset === '직접 입력'
const reason = isDirect ? custom.trim() : preset
const canSubmit = reason.length > 0
const handleSubmit = () => {
if (!canSubmit) return
const parsed = Number(price.replace(/[^0-9]/g, ''))
onSubmit(reason, parsed > 0 ? parsed : null)
onClose()
}
return (
<Modal onClose={onClose}>
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
<div className="flex items-center justify-between border-b border-border p-5">
<div>
<h3 className="text-base font-bold text-neutral-90">재협상 요청</h3>
<p className="mt-0.5 text-xs text-neutral-60">
{target.qt_number} · {target.item_name}
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
>
<X className="size-4" />
</button>
</div>
<div className="space-y-4 p-5">
<p className="rounded-xl bg-neutral-10 p-3 text-xs leading-relaxed text-neutral-70">
이 건은 낙찰자 없이 마감되었습니다. 요청하면 구매 담당자가 검토 후 승인 시
<b> 다시 협상할 기회</b>가 열립니다. 승인·반려 결과는 목록에서 확인할 수 있습니다.
</p>
<div className="space-y-1.5">
<label className="block text-sm font-semibold text-neutral-80">요청 사유</label>
<div className="flex flex-wrap gap-1.5">
{[...PRESETS, '직접 입력'].map((p) => (
<button
key={p}
type="button"
onClick={() => setPreset(p)}
className={`rounded-lg border px-3 py-1.5 text-xs font-bold transition-all active:scale-[0.98] ${
preset === p
? 'border-brand-600 bg-brand-50 text-brand-700'
: 'border-border text-neutral-70 hover:bg-neutral-10'
}`}
>
{p}
</button>
))}
</div>
{isDirect && (
<input
value={custom}
onChange={(e) => setCustom(e.target.value)}
maxLength={255}
placeholder="요청 사유를 입력해 주세요"
className="mt-1.5 h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600"
/>
)}
</div>
<div className="space-y-1.5">
<label className="block text-sm font-semibold text-neutral-80">
희망 공급가 <span className="font-medium text-neutral-60">(선택)</span>
</label>
<input
inputMode="numeric"
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder="예: 14,500,000"
className="h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600"
/>
<p className="text-xs text-neutral-60">담당자 검토 참고용입니다. 실제 가격은 재협상에서 다시 조율합니다.</p>
</div>
</div>
<div className="flex gap-2 border-t border-border p-5">
<button
type="button"
onClick={onClose}
className="flex-1 rounded-xl border border-border py-3 text-sm font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]"
>
취소
</button>
<button
type="button"
onClick={handleSubmit}
disabled={!canSubmit}
className="flex-[2] rounded-xl bg-brand-600 py-3 text-sm font-bold text-white shadow-sm transition-all hover:bg-brand-700 active:scale-[0.98] disabled:opacity-50"
>
요청 보내기
</button>
</div>
</div>
</Modal>
)
}

View File

@ -0,0 +1,37 @@
import { cn } from '@/lib'
// 마감 기준 정렬. value(null=기본 그룹정렬)는 store.selectedDeadline 로 그대로 전달되고,
// adapter.deadlineToOrder 가 '남은 시간 적은 순'=asc(임박), 그 외=desc(여유) 로 매핑한다.
const SORT_OPTIONS: { label: string; value: string | null }[] = [
{ label: '기본순', value: null },
{ label: '마감임박순', value: '남은 시간 적은 순' },
{ label: '여유순', value: '남은 시간 긴 순' },
]
interface SortControlProps {
selected: string | null
onSelect: (value: string | null) => void
}
export function SortControl({ selected, onSelect }: SortControlProps) {
return (
<div className="flex items-center gap-0.5 rounded-xl border border-border bg-neutral-10 p-0.5">
{SORT_OPTIONS.map((o) => {
const active = selected === o.value
return (
<button
key={o.label}
type="button"
onClick={() => onSelect(o.value)}
className={cn(
'whitespace-nowrap rounded-[10px] px-2.5 py-[6px] text-xs font-bold transition-all active:scale-[0.98]',
active ? 'bg-white text-brand-700 shadow-sm' : 'text-neutral-60 hover:text-neutral-80',
)}
>
{o.label}
</button>
)
})}
</div>
)
}

View File

@ -18,7 +18,7 @@ interface StatusTabsProps {
export function StatusTabs({ selected, onSelect }: StatusTabsProps) { export function StatusTabs({ selected, onSelect }: StatusTabsProps) {
return ( return (
<div className="flex flex-wrap items-center gap-1 rounded-xl bg-neutral-20 p-1"> <div className="flex flex-wrap items-center gap-1">
{TABS.map((t) => { {TABS.map((t) => {
const active = selected === t.value const active = selected === t.value
return ( return (
@ -27,8 +27,8 @@ export function StatusTabs({ selected, onSelect }: StatusTabsProps) {
type="button" type="button"
onClick={() => onSelect(t.value)} onClick={() => onSelect(t.value)}
className={cn( className={cn(
'rounded-lg px-3.5 py-1.5 text-[13px] font-bold transition-all active:scale-[0.98]', 'whitespace-nowrap rounded-[10px] px-3 py-[7px] text-[13px] font-bold transition-all active:scale-[0.98]',
active ? 'bg-white text-neutral-90 shadow-sm' : 'text-neutral-60 hover:text-neutral-80', active ? 'bg-brand-light text-brand-700' : 'text-neutral-60 hover:text-neutral-80',
)} )}
> >
{t.label} {t.label}

View File

@ -1,7 +1,8 @@
import { Loader2 } from 'lucide-react' import { Loader2, MessageSquareText } from 'lucide-react'
import { cn, formatKstDateTime } from '@/lib' import { cn, formatKstDateTime } from '@/lib'
import { RENEGO_STATUS_LABEL } from '@/apis/negotiation/negotiation.type'
import type { ListItem } from '@/features/list/types' import type { ListItem } from '@/features/list/types'
import { statusMeta } from '@/features/list/lib/status' import { statusMeta, canEnterChat, isEndedStatus, RESULT_META } from '@/features/list/lib/status'
interface WorkspaceCardsProps { interface WorkspaceCardsProps {
items: ListItem[] items: ListItem[]
@ -10,10 +11,13 @@ interface WorkspaceCardsProps {
onEnter: (item: ListItem) => void onEnter: (item: ListItem) => void
onReject: (item: ListItem) => void onReject: (item: ListItem) => void
onExtraInfo: (item: ListItem) => void onExtraInfo: (item: ListItem) => void
onRenegotiate: (item: ListItem) => void
onMemo: (item: ListItem) => void
onRejectDetail: (item: ListItem) => void
} }
// 모바일(lg 미만) 협상 목록: 테이블 대신 카드 스택. // 모바일(lg 미만) 협상 목록: 테이블 대신 카드 스택.
export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, onExtraInfo }: WorkspaceCardsProps) { export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo, onRejectDetail }: WorkspaceCardsProps) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-16">
@ -28,7 +32,7 @@ export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, on
return ( return (
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{items.map((item) => ( {items.map((item) => (
<Card key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} /> <Card key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} onRejectDetail={onRejectDetail} />
))} ))}
</div> </div>
) )
@ -40,19 +44,29 @@ function Card({
onEnter, onEnter,
onReject, onReject,
onExtraInfo, onExtraInfo,
onRenegotiate,
onMemo,
onRejectDetail,
}: { }: {
item: ListItem item: ListItem
busy: boolean busy: boolean
onEnter: (item: ListItem) => void onEnter: (item: ListItem) => void
onReject: (item: ListItem) => void onReject: (item: ListItem) => void
onExtraInfo: (item: ListItem) => void onExtraInfo: (item: ListItem) => void
onRenegotiate: (item: ListItem) => void
onMemo: (item: ListItem) => void
onRejectDetail: (item: ListItem) => void
}) { }) {
const meta = statusMeta(item.session_status) const meta = statusMeta(item.session_status)
const canEnter = !['미참여', '협상거부'].includes(item.session_status) const ended = isEndedStatus(item.session_status)
const canEnter = canEnterChat(item.session_status, item.hasChat)
const canReject = ['협상생성', '협상중'].includes(item.session_status) const canReject = ['협상생성', '협상중'].includes(item.session_status)
// 거부 건은 제출 내역이 대화에 남지 않는다 — 세션에 적힌 사유를 여기서만 다시 볼 수 있다.
const isRejected = item.session_status === '협상거부'
const isDone = item.session_status === '협상완료' const isDone = item.session_status === '협상완료'
const enterLabel = isDone ? '결과 보기' : '협상 입장' const enterLabel = ended ? '결과 보기' : '협상 입장'
const hasExtra = item.custom && Object.keys(item.custom).length > 0 // 재협상: 요청 가능하면 버튼, 이미 요청했으면 진행 상태를 보여준다.
const renegoLabel = RENEGO_STATUS_LABEL[item.renegotiationStatus] ?? ''
return ( return (
<div className="p-4"> <div className="p-4">
@ -67,10 +81,17 @@ function Card({
{item.model_name && <span> · {item.model_name}</span>} {item.model_name && <span> · {item.model_name}</span>}
</p> </p>
</div> </div>
<span className={cn('inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-xs font-bold', meta.badge)}> <div className="flex shrink-0 items-center gap-1.5">
<span className={cn('size-1.5 rounded-full', meta.dot)} /> {RESULT_META[item.result] && (
{meta.display} <span className={cn('inline-flex items-center whitespace-nowrap rounded-full px-2 py-1 text-xs font-bold', RESULT_META[item.result].badge)}>
</span> {RESULT_META[item.result].label}
</span>
)}
<span className={cn('inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-bold', meta.badge)}>
<span className={cn('size-1.5 rounded-full', meta.dot)} />
{meta.display}
</span>
</div>
</div> </div>
<div className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-neutral-60"> <div className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-neutral-60">
@ -78,15 +99,52 @@ function Card({
<span>마감 {formatKstDateTime(item.qt_end_time)}</span> <span>마감 {formatKstDateTime(item.qt_end_time)}</span>
</div> </div>
{(canEnter || canReject || isDone) && ( {renegoLabel &&
(item.renegotiationMemo ? (
<button
type="button"
onClick={() => onMemo(item)}
className="mt-2.5 flex w-full items-center justify-between rounded-xl bg-neutral-10 px-3 py-2 transition-all hover:bg-neutral-20 active:scale-[0.99]"
>
<span className="whitespace-nowrap text-xs font-bold text-neutral-80">{renegoLabel}</span>
<span className="flex items-center gap-1 whitespace-nowrap text-[11px] text-neutral-60">
담당자 메모
<MessageSquareText className="size-3.5 text-neutral-50" />
</span>
</button>
) : (
<div className="mt-2.5 rounded-xl bg-neutral-10 px-3 py-2">
<p className="text-xs font-bold text-neutral-80">{renegoLabel}</p>
</div>
))}
{(canEnter || canReject || isDone || isRejected || item.renegotiable) && (
<div className="mt-3 flex gap-2"> <div className="mt-3 flex gap-2">
{isRejected && (
<button
type="button"
onClick={() => onRejectDetail(item)}
className="flex-1 rounded-lg border border-brand-600/40 py-2 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
>
거부 사유 보기
</button>
)}
{item.renegotiable && (
<button
type="button"
onClick={() => onRenegotiate(item)}
className="flex-1 rounded-lg border border-brand-600/40 py-2 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
>
재협상 요청
</button>
)}
{isDone && ( {isDone && (
<button <button
type="button" type="button"
onClick={() => onExtraInfo(item)} onClick={() => onExtraInfo(item)}
className="flex-1 rounded-lg border border-brand-600/40 py-2 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]" className="flex-1 rounded-lg border border-brand-600/40 py-2 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
> >
{hasExtra ? '부가정보 수정' : '부가정보 입력'} 부가정보 보기
</button> </button>
)} )}
{canReject && ( {canReject && (
@ -95,7 +153,7 @@ function Card({
onClick={() => onReject(item)} onClick={() => onReject(item)}
className="flex-1 rounded-lg border border-border py-2 text-xs font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]" className="flex-1 rounded-lg border border-border py-2 text-xs font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]"
> >
거절 협상 거부
</button> </button>
)} )}
{canEnter && ( {canEnter && (

View File

@ -1,8 +1,9 @@
import { type ReactNode } from 'react' import { type ReactNode } from 'react'
import { Loader2 } from 'lucide-react' import { Loader2, MessageSquareText } from 'lucide-react'
import { cn, formatKstDateTime } from '@/lib' import { cn, formatKstDateTime } from '@/lib'
import { RENEGO_STATUS_LABEL } from '@/apis/negotiation/negotiation.type'
import type { ListItem } from '@/features/list/types' import type { ListItem } from '@/features/list/types'
import { statusMeta } from '@/features/list/lib/status' import { statusMeta, canEnterChat, isEndedStatus, RESULT_META } from '@/features/list/lib/status'
interface WorkspaceTableProps { interface WorkspaceTableProps {
items: ListItem[] items: ListItem[]
@ -11,22 +12,27 @@ interface WorkspaceTableProps {
onEnter: (item: ListItem) => void onEnter: (item: ListItem) => void
onReject: (item: ListItem) => void onReject: (item: ListItem) => void
onExtraInfo: (item: ListItem) => void onExtraInfo: (item: ListItem) => void
onRenegotiate: (item: ListItem) => void
onMemo: (item: ListItem) => void
onRejectDetail: (item: ListItem) => void
} }
const HEAD = 'px-5 py-3.5 text-left text-[11px] font-bold uppercase tracking-wider text-neutral-60 whitespace-nowrap' // th 기본 정렬은 center — 정렬은 베이스에 넣지 않고 컬럼마다 명시한다(cn 이 tailwind-merge 가 아니라 충돌 시 승자가 불명확).
const HEAD = 'px-5 py-3.5 text-[11px] font-bold uppercase tracking-wider text-neutral-60 whitespace-nowrap'
const CELL = 'px-5 py-4 align-middle text-sm text-neutral-80' const CELL = 'px-5 py-4 align-middle text-sm text-neutral-80'
export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, onExtraInfo }: WorkspaceTableProps) { export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo, onRejectDetail }: WorkspaceTableProps) {
return ( return (
<div className="w-full overflow-x-auto"> <div className="w-full overflow-x-auto">
<table className="w-full min-w-[900px] border-collapse"> <table className="w-full min-w-[900px] border-collapse">
<thead> <thead>
<tr className="border-b border-border bg-table-header"> <tr className="border-b border-border bg-table-header">
<th className={HEAD}>견적번호</th> <th className={cn(HEAD, 'text-left')}>견적번호</th>
<th className={HEAD}>상품</th> <th className={cn(HEAD, 'text-left')}>상품</th>
<th className={HEAD}>제조사</th> <th className={cn(HEAD, 'text-left')}>제조사</th>
<th className={cn(HEAD, 'text-center')}>상태</th> <th className={cn(HEAD, 'text-right')}>상태</th>
<th className={HEAD}>마감일</th> <th className={cn(HEAD, 'text-center')}>결과</th>
<th className={cn(HEAD, 'text-left')}>마감일</th>
<th className={cn(HEAD, 'text-right')}>액션</th> <th className={cn(HEAD, 'text-right')}>액션</th>
</tr> </tr>
</thead> </thead>
@ -41,7 +47,7 @@ export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, on
</StateRow> </StateRow>
) : ( ) : (
items.map((item) => ( items.map((item) => (
<Row key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} /> <Row key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} onRejectDetail={onRejectDetail} />
)) ))
)} )}
</tbody> </tbody>
@ -56,19 +62,27 @@ function Row({
onEnter, onEnter,
onReject, onReject,
onExtraInfo, onExtraInfo,
onRenegotiate,
onMemo,
onRejectDetail,
}: { }: {
item: ListItem item: ListItem
busy: boolean busy: boolean
onEnter: (item: ListItem) => void onEnter: (item: ListItem) => void
onReject: (item: ListItem) => void onReject: (item: ListItem) => void
onExtraInfo: (item: ListItem) => void onExtraInfo: (item: ListItem) => void
onRenegotiate: (item: ListItem) => void
onMemo: (item: ListItem) => void
onRejectDetail: (item: ListItem) => void
}) { }) {
const meta = statusMeta(item.session_status) const meta = statusMeta(item.session_status)
const canEnter = !['미참여', '협상거부'].includes(item.session_status) const ended = isEndedStatus(item.session_status)
const canEnter = canEnterChat(item.session_status, item.hasChat)
const canReject = ['협상생성', '협상중'].includes(item.session_status) const canReject = ['협상생성', '협상중'].includes(item.session_status)
// 거부 건은 제출 내역이 대화에 남지 않는다 — 세션에 적힌 사유를 여기서만 다시 볼 수 있다.
const isRejected = item.session_status === '협상거부'
const isDone = item.session_status === '협상완료' const isDone = item.session_status === '협상완료'
const enterLabel = isDone ? '결과 보기' : '협상 입장' const enterLabel = ended ? '결과 보기' : '협상 입장'
const hasExtra = item.custom && Object.keys(item.custom).length > 0
return ( return (
<tr className="border-b border-border/60 transition-colors last:border-0 hover:bg-table-hover/70"> <tr className="border-b border-border/60 transition-colors last:border-0 hover:bg-table-hover/70">
@ -83,22 +97,66 @@ function Row({
</p> </p>
</td> </td>
<td className={cn(CELL, 'whitespace-nowrap text-neutral-70')}>{item.maker_name || '-'}</td> <td className={cn(CELL, 'whitespace-nowrap text-neutral-70')}>{item.maker_name || '-'}</td>
<td className={cn(CELL, 'text-center')}> <td className={cn(CELL, 'text-right')}>
<span className={cn('inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-bold', meta.badge)}> <span className={cn('inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-bold', meta.badge)}>
<span className={cn('size-1.5 rounded-full', meta.dot)} /> <span className={cn('size-1.5 rounded-full', meta.dot)} />
{meta.display} {meta.display}
</span> </span>
</td> </td>
<td className={cn(CELL, 'text-center')}>
{RESULT_META[item.result] ? (
<span className={cn('inline-flex items-center whitespace-nowrap rounded-full px-2 py-1 text-xs font-bold', RESULT_META[item.result].badge)}>
{RESULT_META[item.result].label}
</span>
) : (
<span className="text-xs text-neutral-40">–</span>
)}
</td>
<td className={cn(CELL, 'whitespace-nowrap text-neutral-70')}>{formatKstDateTime(item.qt_end_time)}</td> <td className={cn(CELL, 'whitespace-nowrap text-neutral-70')}>{formatKstDateTime(item.qt_end_time)}</td>
<td className={CELL}> <td className={CELL}>
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
{/* 재협상: 요청 가능하면 버튼, 요청 이력이 있으면 상태 텍스트(담당자 메모 있으면 클릭 시 팝업) */}
{item.renegotiable ? (
<button
type="button"
onClick={() => onRenegotiate(item)}
className="rounded-lg border border-brand-600/40 px-3 py-1.5 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
>
재협상 요청
</button>
) : (
RENEGO_STATUS_LABEL[item.renegotiationStatus] &&
(item.renegotiationMemo ? (
<button
type="button"
onClick={() => onMemo(item)}
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-lg bg-neutral-10 px-2.5 py-1.5 text-xs font-bold text-neutral-70 transition-all hover:bg-neutral-20 active:scale-[0.98]"
>
{RENEGO_STATUS_LABEL[item.renegotiationStatus]}
<MessageSquareText className="size-3.5 text-neutral-50" />
</button>
) : (
<span className="whitespace-nowrap rounded-lg bg-neutral-10 px-2.5 py-1.5 text-xs font-bold text-neutral-70">
{RENEGO_STATUS_LABEL[item.renegotiationStatus]}
</span>
))
)}
{isDone && ( {isDone && (
<button <button
type="button" type="button"
onClick={() => onExtraInfo(item)} onClick={() => onExtraInfo(item)}
className="rounded-lg border border-brand-600/40 px-3 py-1.5 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]" className="rounded-lg border border-brand-600/40 px-3 py-1.5 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
> >
{hasExtra ? '부가정보 수정' : '부가정보 입력'} 부가정보 보기
</button>
)}
{isRejected && (
<button
type="button"
onClick={() => onRejectDetail(item)}
className="rounded-lg border border-brand-600/40 px-3 py-1.5 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
>
거부 사유 보기
</button> </button>
)} )}
{canReject && ( {canReject && (
@ -107,7 +165,7 @@ function Row({
onClick={() => onReject(item)} onClick={() => onReject(item)}
className="rounded-lg border border-border px-3 py-1.5 text-xs font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]" className="rounded-lg border border-border px-3 py-1.5 text-xs font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]"
> >
거절 협상 거부
</button> </button>
)} )}
{canEnter ? ( {canEnter ? (
@ -120,9 +178,7 @@ function Row({
{busy && <Loader2 className="size-3 animate-spin" />} {busy && <Loader2 className="size-3 animate-spin" />}
{enterLabel} {enterLabel}
</button> </button>
) : ( ) : null}
!canReject && <span className="text-xs text-neutral-50">–</span>
)}
</div> </div>
</td> </td>
</tr> </tr>
@ -132,7 +188,7 @@ function Row({
function StateRow({ children }: { children: ReactNode }) { function StateRow({ children }: { children: ReactNode }) {
return ( return (
<tr> <tr>
<td colSpan={6} className="py-20"> <td colSpan={7} className="py-20">
<div className="flex items-center justify-center">{children}</div> <div className="flex items-center justify-center">{children}</div>
</td> </td>
</tr> </tr>

View File

@ -1,18 +1,30 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { Search } from 'lucide-react' import { HelpCircle, Search } from 'lucide-react'
import { getApiErrorMessage, useMeQuery, useParticipateMutation, useRejectMutation, useSaveExtraInfoMutation } from '@/apis' import {
getApiErrorMessage,
useMeQuery,
useParticipateMutation,
useRejectMutation,
useRequestRenegotiationMutation,
useSaveExtraInfoMutation,
} from '@/apis'
import { RejectDetailPopup, RejectPopup, type RejectSubmitPayload } from '@/components'
import { cn, toast } from '@/lib' import { cn, toast } from '@/lib'
import { useList } from '@/features/list/hooks/useList' import { useList } from '@/features/list/hooks/useList'
import { useListStore } from '@/features/list/stores/useListStore' import { useListStore } from '@/features/list/stores/useListStore'
import { KpiCards } from '@/features/list/components/KpiCards' import { KpiCards } from '@/features/list/components/KpiCards'
import { StatusTabs } from '@/features/list/components/StatusTabs' import { StatusTabs } from '@/features/list/components/StatusTabs'
import { SortControl } from '@/features/list/components/SortControl'
import { WorkspaceTable } from '@/features/list/components/WorkspaceTable' import { WorkspaceTable } from '@/features/list/components/WorkspaceTable'
import { WorkspaceCards } from '@/features/list/components/WorkspaceCards' import { WorkspaceCards } from '@/features/list/components/WorkspaceCards'
import { Pagination } from '@/features/list/components/Pagination' import { Pagination } from '@/features/list/components/Pagination'
import { RejectPopup } from '@/features/list/components/RejectPopup'
import { ExtraInfoPopup } from '@/features/list/components/ExtraInfoPopup' import { ExtraInfoPopup } from '@/features/list/components/ExtraInfoPopup'
import { RenegotiationPopup } from '@/features/list/components/RenegotiationPopup'
import { RenegotiationMemoPopup } from '@/features/list/components/RenegotiationMemoPopup'
import { GuidePopup } from '@/features/list/components/GuidePopup'
import { isEndedStatus } from '@/features/list/lib/status'
import type { ListItem } from '@/features/list/types' import type { ListItem } from '@/features/list/types'
// 상태별 거부 불가 안내 // 상태별 거부 불가 안내
@ -25,25 +37,46 @@ const REJECT_BLOCKED: Record<string, string> = {
export function ListWorkspace() { export function ListWorkspace() {
const navigate = useNavigate() const navigate = useNavigate()
const { data: user } = useMeQuery() const { data: user } = useMeQuery()
// 리스트는 서버 페이징. (검색은 백엔드 미지원 → 프론트 검색은 비워 둠) // 리스트는 서버 페이징 + 서버 검색(keyword: 견적번호·상품명·상품코드 부분일치).
const { items, isLoading, totalPages, currentPage, setCurrentPage } = useList() const { items, isLoading, totalPages, currentPage, setCurrentPage } = useList()
const participate = useParticipateMutation() const participate = useParticipateMutation()
const reject = useRejectMutation() const reject = useRejectMutation()
const saveExtra = useSaveExtraInfoMutation() const saveExtra = useSaveExtraInfoMutation()
const requestRenego = useRequestRenegotiationMutation()
const { selectedStatus, setStatus } = useListStore( const { selectedStatus, selectedResult, selectedDeadline, setStatus, toggleResult, setDeadline, setKeyword } =
useShallow((s) => ({ useListStore(
selectedStatus: s.selectedStatus, useShallow((s) => ({
setStatus: s.setStatus, selectedStatus: s.selectedStatus,
})), selectedResult: s.selectedResult,
) selectedDeadline: s.selectedDeadline,
setStatus: s.setStatus,
toggleResult: s.toggleResult,
setDeadline: s.setDeadline,
setKeyword: s.setKeyword,
})),
)
// 입력은 즉시 반영하되, 서버 요청은 300ms 디바운스(타이핑마다 호출 방지). 변경 시 store 가 페이지를 1로 되돌린다.
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
useEffect(() => {
const id = setTimeout(() => setKeyword(search), 300)
return () => clearTimeout(id)
}, [search, setKeyword])
const [enteringId, setEnteringId] = useState<string | null>(null) const [enteringId, setEnteringId] = useState<string | null>(null)
const [rejectTarget, setRejectTarget] = useState<ListItem | null>(null) const [rejectTarget, setRejectTarget] = useState<ListItem | null>(null)
const [extraTarget, setExtraTarget] = useState<ListItem | null>(null) const [extraTarget, setExtraTarget] = useState<ListItem | null>(null)
const [renegoTarget, setRenegoTarget] = useState<ListItem | null>(null)
const [memoTarget, setMemoTarget] = useState<ListItem | null>(null)
const [rejectDetailTarget, setRejectDetailTarget] = useState<ListItem | null>(null)
const [guideOpen, setGuideOpen] = useState(false)
const handleEnter = (item: ListItem) => { const handleEnter = (item: ListItem) => {
// 종료 건(완료·미참여·거부)은 열람 전용 — 참여 API 는 미참여/거부를 막으므로 바로 채팅으로 보낸다.
if (isEndedStatus(item.session_status)) {
navigate(`/chat?session_id=${item.session_id}`)
return
}
setEnteringId(item.session_id) setEnteringId(item.session_id)
participate.mutate(item.session_id, { participate.mutate(item.session_id, {
onSuccess: () => navigate(`/chat?session_id=${item.session_id}`), onSuccess: () => navigate(`/chat?session_id=${item.session_id}`),
@ -63,13 +96,16 @@ export function ListWorkspace() {
setRejectTarget(item) setRejectTarget(item)
} }
const handleRejectSubmit = (reason: string) => { const handleRejectSubmit = (request: RejectSubmitPayload) => {
if (!rejectTarget) return if (!rejectTarget) return
reject.mutate( reject.mutate(
{ sessionId: rejectTarget.session_id, request: { reject_reason: reason } }, { sessionId: rejectTarget.session_id, request },
{ {
onSuccess: () => toast.warning('참여 거절이 완료되었습니다.'), onSuccess: () => {
onError: (error) => toast.error(getApiErrorMessage(error, '거절 처리에 실패했습니다.')), setRejectTarget(null)
toast.warning('협상 거부가 완료되었습니다.')
},
onError: (error) => toast.error(getApiErrorMessage(error, '거부 처리에 실패했습니다.')),
}, },
) )
} }
@ -85,28 +121,53 @@ export function ListWorkspace() {
) )
} }
const handleRenegoSubmit = (reason: string, desiredPrice: number | null) => {
if (!renegoTarget) return
requestRenego.mutate(
{ sessionId: renegoTarget.session_id, request: { reason, desired_price: desiredPrice } },
{
onSuccess: () => toast.success('재협상을 요청했습니다. 담당자 검토 후 결과가 목록에 표시됩니다.'),
onError: (error) => toast.error(getApiErrorMessage(error, '재협상 요청에 실패했습니다.')),
},
)
}
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* 환영 배너 */} {/* 헤더 */}
<div className="flex flex-wrap items-center justify-between gap-2 rounded-2xl border border-border bg-white px-5 py-4 shadow-[0_2px_12px_rgba(0,0,0,0.02)]"> <div className="flex flex-wrap items-baseline justify-between gap-2">
<div> <div>
<p className="text-[15px] font-bold text-neutral-90"> <h1 className="text-xl font-extrabold tracking-tight text-neutral-90">협상 워크스페이스</h1>
{user?.supplierName ? `${user.supplierName} 담당자님, 환영합니다.` : '공급 파트너님, 환영합니다.'} <p className="mt-0.5 text-[13px] text-neutral-60">
</p> {user?.supplierName ? `${user.supplierName} (공급 파트너)` : '공급 파트너'} · 진행/결과를 한눈에
<p className="mt-0.5 text-xs text-neutral-60">
진행 중인 협상 건을 확인하고 가격 협상에 참여해 주세요.
</p> </p>
</div> </div>
<div className="flex items-center gap-2.5">
<button
type="button"
onClick={() => setGuideOpen(true)}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-white px-3 py-1.5 text-[13px] font-bold text-neutral-70 transition-colors hover:bg-neutral-10"
>
<HelpCircle className="size-4 text-neutral-50" />
이용안내
</button>
</div>
</div> </div>
{/* KPI 콕핏 */} {/* KPI 콕핏 — 진행/결과 두 묶음 */}
<KpiCards selectedStatus={selectedStatus} onSelect={(s) => setStatus(selectedStatus === s ? null : s)} /> <KpiCards
selectedStatus={selectedStatus}
selectedResult={selectedResult}
onSelectStatus={(s) => setStatus(selectedStatus === s ? null : s)}
onSelectResult={toggleResult}
/>
{/* 워크스페이스 카드 */} {/* 워크스페이스 카드 */}
<div className="overflow-hidden rounded-2xl border border-border bg-white shadow-sm"> <div className="overflow-hidden rounded-2xl border border-border bg-white shadow-sm">
<div className="flex flex-col gap-3 border-b border-border p-4 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 border-b border-border p-4 lg:flex-row lg:items-center lg:justify-between">
<StatusTabs selected={selectedStatus} onSelect={setStatus} /> <StatusTabs selected={selectedStatus} onSelect={setStatus} />
<div className="flex w-full items-center gap-2 lg:w-auto"> <div className="flex w-full items-center gap-2 lg:w-auto">
<SortControl selected={selectedDeadline} onSelect={setDeadline} />
<div className="relative flex-1 lg:flex-none"> <div className="relative flex-1 lg:flex-none">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-neutral-50" /> <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-neutral-50" />
<input <input
@ -131,6 +192,9 @@ export function ListWorkspace() {
onEnter={handleEnter} onEnter={handleEnter}
onReject={handleReject} onReject={handleReject}
onExtraInfo={setExtraTarget} onExtraInfo={setExtraTarget}
onRenegotiate={setRenegoTarget}
onMemo={setMemoTarget}
onRejectDetail={setRejectDetailTarget}
/> />
</div> </div>
<div className="lg:hidden"> <div className="lg:hidden">
@ -141,6 +205,9 @@ export function ListWorkspace() {
onEnter={handleEnter} onEnter={handleEnter}
onReject={handleReject} onReject={handleReject}
onExtraInfo={setExtraTarget} onExtraInfo={setExtraTarget}
onRenegotiate={setRenegoTarget}
onMemo={setMemoTarget}
onRejectDetail={setRejectDetailTarget}
/> />
</div> </div>
@ -149,13 +216,41 @@ export function ListWorkspace() {
</div> </div>
</div> </div>
{guideOpen && <GuidePopup onClose={() => setGuideOpen(false)} />}
{rejectTarget && ( {rejectTarget && (
<RejectPopup onClose={() => setRejectTarget(null)} onSubmit={handleRejectSubmit} /> <RejectPopup
onClose={() => setRejectTarget(null)}
onSubmit={handleRejectSubmit}
isPending={reject.isPending}
/>
)} )}
{extraTarget && ( {extraTarget && (
<ExtraInfoPopup target={extraTarget} onClose={() => setExtraTarget(null)} onSubmit={handleExtraSubmit} /> <ExtraInfoPopup target={extraTarget} onClose={() => setExtraTarget(null)} onSubmit={handleExtraSubmit} />
)} )}
{renegoTarget && (
<RenegotiationPopup
target={renegoTarget}
onClose={() => setRenegoTarget(null)}
onSubmit={handleRenegoSubmit}
/>
)}
{rejectDetailTarget && (
<RejectDetailPopup
onClose={() => setRejectDetailTarget(null)}
subtitle={`${rejectDetailTarget.qt_number} · ${rejectDetailTarget.item_name}`}
reason={rejectDetailTarget.rejectReason}
price={rejectDetailTarget.rejectPrice}
opinion={String(rejectDetailTarget.custom?.opinion ?? '')}
/>
)}
{memoTarget && (
<RenegotiationMemoPopup target={memoTarget} onClose={() => setMemoTarget(null)} />
)}
</div> </div>
) )
} }

View File

@ -7,11 +7,13 @@ const PAGE_SIZE = 20
// 필터/정렬/페이지는 서버에 위임하고, 응답(정수 코드)을 화면용 라벨로 변환한다. // 필터/정렬/페이지는 서버에 위임하고, 응답(정수 코드)을 화면용 라벨로 변환한다.
export function useList() { export function useList() {
const { selectedStatus, selectedDeadline, currentPage, setCurrentPage } = useListStore() const { selectedStatus, selectedDeadline, selectedResult, keyword, currentPage, setCurrentPage } = useListStore()
const query = useSessionListQuery({ const query = useSessionListQuery({
status: selectedStatus ? statusLabelToCode(selectedStatus) : undefined, status: selectedStatus ? statusLabelToCode(selectedStatus) : undefined,
order: selectedDeadline ? deadlineToOrder(selectedDeadline) : undefined, order: selectedDeadline ? deadlineToOrder(selectedDeadline) : undefined,
result: selectedResult ?? undefined,
keyword: keyword.trim() || undefined,
page: currentPage, page: currentPage,
page_size: PAGE_SIZE, page_size: PAGE_SIZE,
}) })

View File

@ -1,35 +1,63 @@
import { useQueries } from '@tanstack/react-query' import { useQueries } from '@tanstack/react-query'
import { negotiationApi, negotiationKeys, SessionStatus } from '@/apis' import { negotiationApi, negotiationKeys, SessionStatus, SessionResult } from '@/apis'
// KPI 콕핏에 노출할 상태별 집계. 각 상태를 page_size=1 로 조회해 total 만 읽는다(목록과 캐시 공유). // KPI 콕핏 집계. 진행(상태축)·결과(결과축) 두 묶음. 각 항목을 page_size=1 로 조회해 total 만 읽는다(목록과 캐시 공유).
// 클릭 시 걸 필터가 상태냐 결과냐가 다르므로 kind 로 구분해 컨테이너가 올바른 setter 로 보낸다.
export interface KpiCount { export interface KpiCount {
key: string key: string
label: string label: string
status: string // 클릭 시 필터에 넣을 원본 상태 라벨 kind: 'status' | 'result'
code: number statusLabel?: string // kind=status: 필터에 넣을 원본 상태 라벨
resultCode?: number // kind=result: 필터에 넣을 SessionResult 코드
count: number count: number
isLoading: boolean isLoading: boolean
} }
const KPI = [ // 진행 묶음(내가 할 일) — 세션 상태축.
{ key: 'created', label: '협상 대기', status: '협상생성', code: SessionStatus.CREATED }, const PROGRESS = [
{ key: 'progress', label: '조율 진행중', status: '협상중', code: SessionStatus.IN_PROGRESS }, { key: 'created', label: '협상 대기', statusLabel: '협상생성', code: SessionStatus.CREATED },
{ key: 'done', label: '타결 완료', status: '협상완료', code: SessionStatus.DONE }, { key: 'progress', label: '협상 중', statusLabel: '협상중', code: SessionStatus.IN_PROGRESS },
{ key: 'rejected', label: '거부/반려', status: '협상거부', code: SessionStatus.REJECTED },
] as const ] as const
export function useSessionCounts(): KpiCount[] { // 결과 묶음(마감 후) — 결과축. 낙찰=주강조, 결렬=재협상 가능(액션).
const results = useQueries({ const RESULT = [
queries: KPI.map((k) => ({ { key: 'won', label: '낙찰', code: SessionResult.WON },
{ key: 'open', label: '결렬', code: SessionResult.OPEN },
{ key: 'lost', label: '미낙찰', code: SessionResult.LOST },
] as const
export function useSessionCounts(): { progress: KpiCount[]; result: KpiCount[] } {
const statusQ = useQueries({
queries: PROGRESS.map((k) => ({
queryKey: negotiationKeys.sessionList({ status: k.code, page: 1, page_size: 1 }), queryKey: negotiationKeys.sessionList({ status: k.code, page: 1, page_size: 1 }),
queryFn: () => negotiationApi.getSessions({ status: k.code, page: 1, page_size: 1 }), queryFn: () => negotiationApi.getSessions({ status: k.code, page: 1, page_size: 1 }),
staleTime: 60 * 1000, staleTime: 60 * 1000,
})), })),
}) })
const resultQ = useQueries({
queries: RESULT.map((k) => ({
queryKey: negotiationKeys.sessionList({ result: k.code, page: 1, page_size: 1 }),
queryFn: () => negotiationApi.getSessions({ result: k.code, page: 1, page_size: 1 }),
staleTime: 60 * 1000,
})),
})
return KPI.map((k, i) => ({ return {
...k, progress: PROGRESS.map((k, i) => ({
count: results[i].data?.total ?? 0, key: k.key,
isLoading: results[i].isLoading, label: k.label,
})) kind: 'status',
statusLabel: k.statusLabel,
count: statusQ[i].data?.total ?? 0,
isLoading: statusQ[i].isLoading,
})),
result: RESULT.map((k, i) => ({
key: k.key,
label: k.label,
kind: 'result',
resultCode: k.code,
count: resultQ[i].data?.total ?? 0,
isLoading: resultQ[i].isLoading,
})),
}
} }

Some files were not shown because too many files have changed in this diff Show More