[chore] 작업분 일괄: logs.sh 컨테이너 메뉴화 · dbeaver 초기화 SQL · landing 운영 compose · negodata/front eslint 도입 · 업로드 샘플 CSV

This commit is contained in:
Mina Choi 2026-07-24 18:45:46 +09:00
parent 9fb4a52c56
commit bacf6271df
20 changed files with 2429 additions and 31 deletions

16
IMK_상품_업로드.csv Normal file
View File

@ -0,0 +1,16 @@
상품명,상품코드,모델번호,카테고리,규격,제조사,공급사,원산지,상품 단가,최저한도,매입가,판매가,이미지URL,최소주문수량,리드타임(일),단위,배송형태,부가세포함(Y/N),배송비포함(Y/N)
27형 QHD 모니터,IMK-MN-2701,VX2718-Q,사무기기,27인치 / QHD 2560x1440,비전텍,글로벌테크놀로지,한국,298000,279000,265000,329000,https://images.unsplash.com/photo-1527443224154-c4a3942d3acf?w=640&q=80,1,7,EA,협력사배송,N,Y
27형 올인원 PC,IMK-PC-2810,AIO-2740,사무기기,27인치 / i5 / 16GB / 512GB,비전텍,글로벌테크놀로지,한국,1190000,1120000,1050000,1290000,https://images.unsplash.com/photo-1547082299-de196ea013d6?w=640&q=80,1,10,EA,협력사배송,N,Y
무선 슬림 키보드,IMK-KB-3312,SK-110W,사무기기,텐키리스 / 2.4GHz,커넥타,글로벌테크놀로지,중국,32000,28500,24900,39000,https://images.unsplash.com/photo-1587829741301-dc798b83add3?w=640&q=80,10,3,EA,지정택배배송,Y,Y
무선 무소음 마우스,IMK-MS-3305,WM-540G,사무기기,2.4GHz / 무소음 클릭,커넥타,글로벌테크놀로지,중국,18500,16900,14800,22000,https://images.unsplash.com/photo-1527864550417-7fd91fc51a46?w=640&q=80,10,3,EA,지정택배배송,Y,Y
노이즈캔슬링 헤드셋,IMK-HS-3520,NC-800,사무기기,블루투스 5.3 / ANC,사운드코어,글로벌테크놀로지,중국,128000,115000,102000,149000,https://images.unsplash.com/photo-1608043152269-423dbba4e7e1?w=640&q=80,5,5,EA,지정택배배송,Y,Y
블루투스 회의용 스피커,IMK-SP-3618,BS-330,사무기기,20W / IPX5,사운드코어,글로벌테크놀로지,중국,89000,79000,69500,105000,https://images.unsplash.com/photo-1585771724684-38269d6639fd?w=640&q=80,5,5,EA,지정택배배송,Y,Y
인스턴트 카메라,IMK-CM-3901,IC-500W,사무기기,즉석인화 / 홍보·행사용,포토랩,글로벌테크놀로지,일본,145000,132000,118000,169000,https://images.unsplash.com/photo-1526170375885-4d8ecf77b99f?w=640&q=80,2,7,EA,지정택배배송,Y,N
패브릭 라운지 체어,IMK-CH-1102,LC-220BE,사무가구,W600 x D640 x H780,퍼니코,대한정밀,한국,187000,172000,159000,209000,https://images.unsplash.com/photo-1580480055273-228ff5388ef8?w=640&q=80,2,14,EA,협력사배송,N,N
LED 데스크 스탠드,IMK-LT-1230,DL-410G,사무가구,3단 밝기 / 각도조절,루멘랩,대한정밀,한국,46000,41000,36500,55000,https://images.unsplash.com/photo-1507473885765-e6ed057f782c?w=640&q=80,5,7,EA,협력사배송,Y,Y
데스크 미니 화분,IMK-PL-1305,GP-100M,사무가구,다육 / 지름 10cm 화기 포함,그린데스크,대한정밀,한국,12500,10900,9200,15000,https://images.unsplash.com/photo-1485955900006-10f4d324d411?w=640&q=80,10,5,EA,협력사배송,Y,N
스페셜티 원두 1kg,IMK-CF-7701,RB-1000,탕비용품,아라비카 100% / 홀빈,빈스터,서울산업소재,한국,29800,26500,23000,35000,https://images.unsplash.com/photo-1511920170033-f8396924c348?w=640&q=80,5,4,BAG,지정택배배송,Y,Y
스테인리스 텀블러 500ml,IMK-TB-7802,ST-500KH,탕비용품,진공 2중 / 500ml,웜쿨,서울산업소재,한국,16800,14900,12800,21000,https://images.unsplash.com/photo-1602143407151-7111542de6e8?w=640&q=80,20,5,EA,지정택배배송,Y,Y
핸드메이드 솝바 세트,IMK-SB-7910,HS-3SET,생활용품,천연비누 3입,클린어스,서울산업소재,한국,13500,11900,9800,16500,https://images.unsplash.com/photo-1584305574647-0cc949a2bb9f?w=640&q=80,10,5,SET,지정택배배송,Y,Y
스프링 노트 A5 10권,IMK-NT-8011,SN-A5-10,사무용품,A5 / 80매 / 10권 팩,페이퍼웍스,서울산업소재,한국,21000,18500,15900,25000,https://images.unsplash.com/photo-1531346878377-a5be20888e57?w=640&q=80,5,3,PACK,픽업배송,Y,Y
젤 잉크 볼펜 12자루,IMK-PN-8105,GP-05BK,사무용품,0.5mm / 흑색 / 12입,페이퍼웍스,서울산업소재,한국,8400,7200,5900,10500,https://images.unsplash.com/photo-1583485088034-697b5bc54ccd?w=640&q=80,10,3,BOX,픽업배송,Y,Y
1 상품명 상품코드 모델번호 카테고리 규격 제조사 공급사 원산지 상품 단가 최저한도 매입가 판매가 이미지URL 최소주문수량 리드타임(일) 단위 배송형태 부가세포함(Y/N) 배송비포함(Y/N)
2 27형 QHD 모니터 IMK-MN-2701 VX2718-Q 사무기기 27인치 / QHD 2560x1440 비전텍 글로벌테크놀로지 한국 298000 279000 265000 329000 https://images.unsplash.com/photo-1527443224154-c4a3942d3acf?w=640&q=80 1 7 EA 협력사배송 N Y
3 27형 올인원 PC IMK-PC-2810 AIO-2740 사무기기 27인치 / i5 / 16GB / 512GB 비전텍 글로벌테크놀로지 한국 1190000 1120000 1050000 1290000 https://images.unsplash.com/photo-1547082299-de196ea013d6?w=640&q=80 1 10 EA 협력사배송 N Y
4 무선 슬림 키보드 IMK-KB-3312 SK-110W 사무기기 텐키리스 / 2.4GHz 커넥타 글로벌테크놀로지 중국 32000 28500 24900 39000 https://images.unsplash.com/photo-1587829741301-dc798b83add3?w=640&q=80 10 3 EA 지정택배배송 Y Y
5 무선 무소음 마우스 IMK-MS-3305 WM-540G 사무기기 2.4GHz / 무소음 클릭 커넥타 글로벌테크놀로지 중국 18500 16900 14800 22000 https://images.unsplash.com/photo-1527864550417-7fd91fc51a46?w=640&q=80 10 3 EA 지정택배배송 Y Y
6 노이즈캔슬링 헤드셋 IMK-HS-3520 NC-800 사무기기 블루투스 5.3 / ANC 사운드코어 글로벌테크놀로지 중국 128000 115000 102000 149000 https://images.unsplash.com/photo-1608043152269-423dbba4e7e1?w=640&q=80 5 5 EA 지정택배배송 Y Y
7 블루투스 회의용 스피커 IMK-SP-3618 BS-330 사무기기 20W / IPX5 사운드코어 글로벌테크놀로지 중국 89000 79000 69500 105000 https://images.unsplash.com/photo-1585771724684-38269d6639fd?w=640&q=80 5 5 EA 지정택배배송 Y Y
8 인스턴트 카메라 IMK-CM-3901 IC-500W 사무기기 즉석인화 / 홍보·행사용 포토랩 글로벌테크놀로지 일본 145000 132000 118000 169000 https://images.unsplash.com/photo-1526170375885-4d8ecf77b99f?w=640&q=80 2 7 EA 지정택배배송 Y N
9 패브릭 라운지 체어 IMK-CH-1102 LC-220BE 사무가구 W600 x D640 x H780 퍼니코 대한정밀 한국 187000 172000 159000 209000 https://images.unsplash.com/photo-1580480055273-228ff5388ef8?w=640&q=80 2 14 EA 협력사배송 N N
10 LED 데스크 스탠드 IMK-LT-1230 DL-410G 사무가구 3단 밝기 / 각도조절 루멘랩 대한정밀 한국 46000 41000 36500 55000 https://images.unsplash.com/photo-1507473885765-e6ed057f782c?w=640&q=80 5 7 EA 협력사배송 Y Y
11 데스크 미니 화분 IMK-PL-1305 GP-100M 사무가구 다육 / 지름 10cm 화기 포함 그린데스크 대한정밀 한국 12500 10900 9200 15000 https://images.unsplash.com/photo-1485955900006-10f4d324d411?w=640&q=80 10 5 EA 협력사배송 Y N
12 스페셜티 원두 1kg IMK-CF-7701 RB-1000 탕비용품 아라비카 100% / 홀빈 빈스터 서울산업소재 한국 29800 26500 23000 35000 https://images.unsplash.com/photo-1511920170033-f8396924c348?w=640&q=80 5 4 BAG 지정택배배송 Y Y
13 스테인리스 텀블러 500ml IMK-TB-7802 ST-500KH 탕비용품 진공 2중 / 500ml 웜쿨 서울산업소재 한국 16800 14900 12800 21000 https://images.unsplash.com/photo-1602143407151-7111542de6e8?w=640&q=80 20 5 EA 지정택배배송 Y Y
14 핸드메이드 솝바 세트 IMK-SB-7910 HS-3SET 생활용품 천연비누 3입 클린어스 서울산업소재 한국 13500 11900 9800 16500 https://images.unsplash.com/photo-1584305574647-0cc949a2bb9f?w=640&q=80 10 5 SET 지정택배배송 Y Y
15 스프링 노트 A5 10권 IMK-NT-8011 SN-A5-10 사무용품 A5 / 80매 / 10권 팩 페이퍼웍스 서울산업소재 한국 21000 18500 15900 25000 https://images.unsplash.com/photo-1531346878377-a5be20888e57?w=640&q=80 5 3 PACK 픽업배송 Y Y
16 젤 잉크 볼펜 12자루 IMK-PN-8105 GP-05BK 사무용품 0.5mm / 흑색 / 12입 페이퍼웍스 서울산업소재 한국 8400 7200 5900 10500 https://images.unsplash.com/photo-1583485088034-697b5bc54ccd?w=640&q=80 10 3 BOX 픽업배송 Y Y

View File

@ -0,0 +1,4 @@
협력사명,식별코드,담당자명,담당자이메일,총매출액,취급상품
글로벌테크놀로지,global,김지훈 팀장,jh.kim@globaltech.co.kr,12000000000,"27형 QHD 모니터, 27형 올인원 PC, 무선 슬림 키보드, 무선 무소음 마우스, 노이즈캔슬링 헤드셋, 블루투스 회의용 스피커, 인스턴트 카메라"
대한정밀,daehan,박서연 과장,sy.park@daehanpm.co.kr,8500000000,"패브릭 라운지 체어, LED 데스크 스탠드, 데스크 미니 화분"
서울산업소재,seoul,이민호 대리,mh.lee@seoulmat.co.kr,5200000000,"스페셜티 원두 1kg, 스테인리스 텀블러 500ml, 핸드메이드 솝바 세트, 스프링 노트 A5 10권, 젤 잉크 볼펜 12자루"
1 협력사명 식별코드 담당자명 담당자이메일 총매출액 취급상품
2 글로벌테크놀로지 global 김지훈 팀장 jh.kim@globaltech.co.kr 12000000000 27형 QHD 모니터, 27형 올인원 PC, 무선 슬림 키보드, 무선 무소음 마우스, 노이즈캔슬링 헤드셋, 블루투스 회의용 스피커, 인스턴트 카메라
3 대한정밀 daehan 박서연 과장 sy.park@daehanpm.co.kr 8500000000 패브릭 라운지 체어, LED 데스크 스탠드, 데스크 미니 화분
4 서울산업소재 seoul 이민호 대리 mh.lee@seoulmat.co.kr 5200000000 스페셜티 원두 1kg, 스테인리스 텀블러 500ml, 핸드메이드 솝바 세트, 스프링 노트 A5 10권, 젤 잉크 볼펜 12자루

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:

77
logs.sh
View File

@ -1,18 +1,69 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 운영 로그 보기 — docker-compose.prod.yml 기준. 서비스명은 compose 의 아무 서비스나. # 운영 로그 보기 — 실행하면 실행중인 컨테이너 번호 메뉴, 0=전체
# ./logs.sh → 전체 서비스 실시간 # ./logs.sh → 메뉴에서 선택
# ./logs.sh negodata-backend → 그 서비스만 실시간 # ./logs.sh 3 → 메뉴 안 띄우고 3번 바로
# ./logs.sh negosium-backend 200 → 마지막 200줄만 (실시간 아님) # ./logs.sh lps-api → 이름으로 바로
set -e set -e
cd "$(dirname "$0")"
SVC="$1" TAIL=100
LINES="$2"
if [ -z "$SVC" ]; then # 우리 프로젝트 컨테이너만 (negosium-db는 compose 라벨이 없어 이름으로 포함)
docker compose -f docker-compose.prod.yml logs -f --tail 100 containers=()
elif [ -z "$LINES" ]; then while IFS= read -r name; do
docker compose -f docker-compose.prod.yml logs -f --tail 100 "$SVC" containers+=("$name")
else done < <(
docker compose -f docker-compose.prod.yml logs --tail "$LINES" "$SVC" {
docker ps --filter label=com.docker.compose.project=o2o-negosium --format '{{.Names}}'
docker ps --filter name='^negosium-db$' --format '{{.Names}}'
} | sort -u
)
if [ ${#containers[@]} -eq 0 ]; then
echo "실행 중인 컨테이너가 없습니다."
exit 1
fi
follow_all() {
# docker logs -f 는 컨테이너 하나씩만 되므로 백그라운드로 전부 띄우고 prefix 붙임
trap 'kill $(jobs -p) 2>/dev/null; exit 0' INT TERM
local colors=(31 32 33 34 35 36 91 92 93 94 95 96)
local i=0
for c in "${containers[@]}"; do
local col=${colors[$((i % ${#colors[@]}))]}
docker logs -f --tail "$TAIL" "$c" 2>&1 | sed -u "s/^/$(printf '\033[%sm[%s]\033[0m ' "$col" "$c")/" &
i=$((i + 1))
done
wait
}
follow_one() {
echo "=== $1 (tail $TAIL, Ctrl+C 종료) ==="
exec docker logs -f --tail "$TAIL" "$1"
}
pick="$1"
if [ -z "$pick" ]; then
echo " 0) 전체 (모든 컨테이너)"
for idx in "${!containers[@]}"; do
printf "%2d) %s\n" "$((idx + 1))" "${containers[$idx]}"
done
printf "번호 선택: "
read -r pick
if [ -z "$pick" ]; then
exit 0
fi
fi
if [ "$pick" = "0" ]; then
follow_all
elif [[ "$pick" =~ ^[0-9]+$ ]]; then
sel=$((pick - 1))
if [ "$sel" -lt 0 ] || [ "$sel" -ge ${#containers[@]} ]; then
echo "잘못된 번호: $pick"
exit 1
fi
follow_one "${containers[$sel]}"
else
follow_one "$pick"
fi fi

View File

@ -0,0 +1,32 @@
// 최소 게이트 — 전체 스타일 린트가 아니라, tsc 가 못 잡는 런타임 크래시 버그만 막는다.
// 도입 계기: QuotationDetailSheet 에서 로컬 const canManage 가 동명 import 를 가려
// zustand 셀렉터가 TDZ 참조 → 프로덕션 크래시(Cannot access 'k' before initialization).
// 그 케이스는 순환 타입 추론으로 tsc --noEmit 도 통과했다.
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
export default tseslint.config({
files: ['src/**/*.{ts,tsx}'],
ignores: ['src/api/generated/**'],
languageOptions: {
parser: tseslint.parser,
},
plugins: {
'@typescript-eslint': tseslint.plugin,
'react-hooks': reactHooks,
},
rules: {
// 훅 호출 순서 위반은 런타임 크래시라 error, deps 누락은 기존 코드가 많아 warn.
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
// 선언보다 위에서 변수를 쓰는 것(TDZ) 차단. 함수/타입 호이스팅은 안전하므로 허용.
'no-use-before-define': 'off',
'@typescript-eslint/no-use-before-define': [
'error',
{ functions: false, classes: false, typedefs: false, enums: false, variables: true },
],
// 상위 스코프(import 포함) 이름을 가리는 재선언 차단.
'no-shadow': 'off',
'@typescript-eslint/no-shadow': 'error',
},
});

File diff suppressed because it is too large Load Diff

View File

@ -5,10 +5,10 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --port=3000 --host=0.0.0.0", "dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build", "build": "tsc --noEmit && eslint src && vite build",
"preview": "vite preview", "preview": "vite preview",
"clean": "rm -rf dist server.js", "clean": "rm -rf dist server.js",
"lint": "tsc --noEmit", "lint": "tsc --noEmit && eslint src",
"orval": "orval --config ./orval.config.ts" "orval": "orval --config ./orval.config.ts"
}, },
"dependencies": { "dependencies": {
@ -50,10 +50,13 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"eslint": "^10.7.0",
"eslint-plugin-react-hooks": "^7.1.1",
"orval": "^7.3.0", "orval": "^7.3.0",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "~5.8.2", "typescript": "~5.8.2",
"typescript-eslint": "^8.65.0",
"vite": "^6.2.3" "vite": "^6.2.3"
} }
} }

View File

@ -60,7 +60,7 @@ function ChartContainer({
); );
} }
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { function ChartStyle({ id, config }: { id: string; config: ChartConfig }) {
const colorConfig = Object.entries(config).filter(([, c]) => c.theme || c.color); const colorConfig = Object.entries(config).filter(([, c]) => c.theme || c.color);
if (!colorConfig.length) return null; if (!colorConfig.length) return null;
@ -85,11 +85,11 @@ ${colorConfig
}} }}
/> />
); );
}; }
const ChartTooltip = RechartsPrimitive.Tooltip; const ChartTooltip = RechartsPrimitive.Tooltip;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type TooltipContentProps = { type TooltipContentProps = {
active?: boolean; active?: boolean;
payload?: any[]; payload?: any[];
@ -214,7 +214,7 @@ const ChartLegend = RechartsPrimitive.Legend;
type LegendContentProps = { type LegendContentProps = {
className?: string; className?: string;
hideIcon?: boolean; hideIcon?: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload?: any[]; payload?: any[];
verticalAlign?: 'top' | 'bottom' | 'middle'; verticalAlign?: 'top' | 'bottom' | 'middle';
nameKey?: string; nameKey?: string;
@ -251,7 +251,7 @@ function ChartLegendContent({
} }
// payload 항목에서 config 매칭 키를 찾는다(shadcn 원본 로직). // payload 항목에서 config 매칭 키를 찾는다(shadcn 원본 로직).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getPayloadConfigFromPayload(config: ChartConfig, payload: any, key: string) { function getPayloadConfigFromPayload(config: ChartConfig, payload: any, key: string) {
if (typeof payload !== 'object' || payload === null) return undefined; if (typeof payload !== 'object' || payload === null) return undefined;

View File

@ -9,7 +9,7 @@ type PhoneInputProps = Omit<React.ComponentProps<typeof Input>, 'value' | 'onCha
onChange?: (value: string) => void; // 숫자만으로 방출 onChange?: (value: string) => void; // 숫자만으로 방출
}; };
export const PhoneInput = forwardRef<HTMLInputElement, PhoneInputProps>(function PhoneInput( export const PhoneInput = forwardRef<HTMLInputElement, PhoneInputProps>(function PhoneInputInner(
{ value = '', onChange, ...props }, { value = '', onChange, ...props },
ref, ref,
) { ) {

View File

@ -123,4 +123,6 @@ function MarkButton({
); );
} }
const Divider = () => <span className="mx-1 h-4 w-px bg-border" />; function Divider() {
return <span className="mx-1 h-4 w-px bg-border" />;
}

View File

@ -384,13 +384,13 @@ export function QuotationCreateModal({
{/* Steps indicator — 스텝 수는 유형에 따라 3(경매)/4(협상) */} {/* Steps indicator — 스텝 수는 유형에 따라 3(경매)/4(협상) */}
<div className="shrink-0 flex items-center justify-between gap-2 py-4 border-b border-border/40 text-muted-foreground"> <div className="shrink-0 flex items-center justify-between gap-2 py-4 border-b border-border/40 text-muted-foreground">
{steps.map((label, i) => { {steps.map((stepLabel, i) => {
const n = i + 1; const n = i + 1;
return ( return (
<div key={label} className="flex items-center gap-2 min-w-0"> <div key={stepLabel} className="flex items-center gap-2 min-w-0">
{i > 0 && <ArrowRight size={14} className="shrink-0" />} {i > 0 && <ArrowRight size={14} className="shrink-0" />}
<button type="button" onClick={() => setStep(n)} className="cursor-pointer hover:opacity-75 transition-opacity truncate"> <button type="button" onClick={() => setStep(n)} className="cursor-pointer hover:opacity-75 transition-opacity truncate">
<Typography as="span" variant="body" className={`font-semibold ${step === n ? 'text-primary' : 'text-muted-foreground'}`}>{n}. {label}</Typography> <Typography as="span" variant="body" className={`font-semibold ${step === n ? 'text-primary' : 'text-muted-foreground'}`}>{n}. {stepLabel}</Typography>
</button> </button>
</div> </div>
); );

View File

@ -78,10 +78,10 @@ export function QuotationDetailSheet({
// 프론트 1차 차단이며, 실제 보안은 백엔드가 동일 스코프로 강제해야 함(버튼 숨김만으론 우회 가능). // 프론트 1차 차단이며, 실제 보안은 백엔드가 동일 스코프로 강제해야 함(버튼 숨김만으론 우회 가능).
const myUserId = useAuthStore((s) => s.user?.userId); const myUserId = useAuthStore((s) => s.user?.userId);
const isSuperAdmin = useAuthStore((s) => canManage(s.user?.role)); const isSuperAdmin = useAuthStore((s) => canManage(s.user?.role));
const canManage = !!myUserId && (quotation.user_id === myUserId || isSuperAdmin); const canMutate = !!myUserId && (quotation.user_id === myUserId || isSuperAdmin);
const canNotify = canManage; // 초청 메일 발송/재발송 const canNotify = canMutate; // 초청 메일 발송/재발송
// 직접 낙찰 = 개찰(낙찰자 미정 마감) 견적에서만. 후보(투찰한 협상완료 협력사) 유무는 표에서 판정. // 직접 낙찰 = 개찰(낙찰자 미정 마감) 견적에서만. 후보(투찰한 협상완료 협력사) 유무는 표에서 판정.
const canAward = canManage && chainRoundState(quotation) === 'opened'; const canAward = canMutate && chainRoundState(quotation) === 'opened';
// 협상 세션·사용 카드는 견적 단위, 채팅은 선택 세션 단위로 서버에서 읽는다. // 협상 세션·사용 카드는 견적 단위, 채팅은 선택 세션 단위로 서버에서 읽는다.
// 세션은 협상 진행으로 계속 바뀌므로 탭 복귀 시 재조회한다. 카드는 생성 후 불변이라 끄둔다. // 세션은 협상 진행으로 계속 바뀌므로 탭 복귀 시 재조회한다. 카드는 생성 후 불변이라 끄둔다.
const sessionsQuery = useGetQuotationSessions(qtId, { const sessionsQuery = useGetQuotationSessions(qtId, {
@ -102,7 +102,7 @@ export function QuotationDetailSheet({
const { rounds: chainRounds, isLoading: chainLoading } = useQuotationChain(quotation.number); const { rounds: chainRounds, isLoading: chainLoading } = useQuotationChain(quotation.number);
const maxRound = chainRounds.length ? Math.max(...chainRounds.map((r) => r.round)) : (quotation.round ?? 1); const maxRound = chainRounds.length ? Math.max(...chainRounds.map((r) => r.round)) : (quotation.round ?? 1);
const isLatestRound = (quotation.round ?? 1) >= maxRound; const isLatestRound = (quotation.round ?? 1) >= maxRound;
const canRegenerate = canManage && !chainLoading && quotation.status === QuotationStatus.CLOSED && isLatestRound; const canRegenerate = canMutate && !chainLoading && quotation.status === QuotationStatus.CLOSED && isLatestRound;
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null); const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const effectiveSessionId = selectedSessionId ?? serverSessions[0]?.session_id ?? null; const effectiveSessionId = selectedSessionId ?? serverSessions[0]?.session_id ?? null;
@ -183,13 +183,13 @@ export function QuotationDetailSheet({
{/* 마감 버튼은 항상 노출하되, 본인/최고관리자가 아니거나 마감 가능한 상태가 아니면 비활성화한다. */} {/* 마감 버튼은 항상 노출하되, 본인/최고관리자가 아니거나 마감 가능한 상태가 아니면 비활성화한다. */}
{(() => { {(() => {
const alreadyClosed = quotation.status === QuotationStatus.CLOSED; const alreadyClosed = quotation.status === QuotationStatus.CLOSED;
const canClose = canManage && !alreadyClosed; const canClose = canMutate && !alreadyClosed;
return ( return (
<button <button
onClick={() => onCloseQuotation(quotation.qt_id ?? '', q_name)} onClick={() => onCloseQuotation(quotation.qt_id ?? '', q_name)}
disabled={!canClose} disabled={!canClose}
title={ title={
!canManage !canMutate
? '본인이 생성한 견적만 마감할 수 있습니다.' ? '본인이 생성한 견적만 마감할 수 있습니다.'
: alreadyClosed : alreadyClosed
? '이미 마감된 견적입니다.' ? '이미 마감된 견적입니다.'

19
o2o_상품_업로드.csv Normal file
View File

@ -0,0 +1,19 @@
상품명,상품코드,모델번호,카테고리,규격,제조사,공급사,원산지,상품 단가,최저한도,매입가,판매가,이미지URL,최소주문수량,리드타임(일),단위,배송형태,부가세포함(Y/N),배송비포함(Y/N)
A4 복사용지 2500매,O2O-PP-1001,CP-A4-2500,사무용품,A4 / 80g / 500매 x 5권,페이퍼밀,한빛오피스,한국,23500,21000,18500,28000,https://images.unsplash.com/photo-1586074299757-dc655f18518c?w=640&q=80,5,3,BOX,협력사배송,Y,Y
스프링 노트 A5 10권,O2O-NT-1002,SN-A5-10,사무용품,A5 / 80매 / 10권 팩,페이퍼웍스,한빛오피스,한국,21000,18500,15900,25000,https://images.unsplash.com/photo-1531346878377-a5be20888e57?w=640&q=80,5,3,PACK,픽업배송,Y,Y
젤 잉크 볼펜 12자루,O2O-PN-1003,GP-05BK,사무용품,0.5mm / 흑색 / 12입,페이퍼웍스,한빛오피스,한국,8400,7200,5900,10500,https://images.unsplash.com/photo-1583485088034-697b5bc54ccd?w=640&q=80,10,3,BOX,픽업배송,Y,Y
데스크 정리함,O2O-OG-1004,DO-320W,사무용품,3단 서랍 / 메쉬 스틸,오피스원,한빛오피스,중국,15800,13900,11500,19000,https://images.unsplash.com/photo-1497032628192-86f99bcd76bc?w=640&q=80,5,5,EA,지정택배배송,Y,Y
클리어 파일 20매,O2O-FL-1005,CF-A4-20,사무용품,A4 / 20포켓,페이퍼웍스,한빛오피스,한국,3200,2800,2200,4000,https://images.unsplash.com/photo-1544816155-12df9643f363?w=640&q=80,20,3,EA,픽업배송,Y,Y
27형 QHD 모니터,O2O-MN-2001,VX2718-Q,사무기기,27인치 / QHD 2560x1440,비전텍,누리테크,한국,298000,279000,265000,329000,https://images.unsplash.com/photo-1527443224154-c4a3942d3acf?w=640&q=80,1,7,EA,협력사배송,N,Y
무선 슬림 키보드,O2O-KB-2002,SK-110W,사무기기,텐키리스 / 2.4GHz,커넥타,누리테크,중국,32000,28500,24900,39000,https://images.unsplash.com/photo-1587829741301-dc798b83add3?w=640&q=80,10,3,EA,지정택배배송,Y,Y
무선 무소음 마우스,O2O-MS-2003,WM-540G,사무기기,2.4GHz / 무소음 클릭,커넥타,누리테크,중국,18500,16900,14800,22000,https://images.unsplash.com/photo-1527864550417-7fd91fc51a46?w=640&q=80,10,3,EA,지정택배배송,Y,Y
USB-C 도킹스테이션,O2O-DK-2004,DS-900C,사무기기,11-in-1 / HDMI 4K / PD 100W,커넥타,누리테크,중국,68000,61000,54000,79000,https://images.unsplash.com/photo-1625842268584-8f3296236761?w=640&q=80,5,5,EA,지정택배배송,Y,Y
노이즈캔슬링 헤드셋,O2O-HS-2005,NC-800,사무기기,블루투스 5.3 / ANC,사운드코어,누리테크,중국,128000,115000,102000,149000,https://images.unsplash.com/photo-1608043152269-423dbba4e7e1?w=640&q=80,5,5,EA,지정택배배송,Y,Y
스테인리스 텀블러 500ml,O2O-TB-3001,ST-500KH,탕비용품,진공 2중 / 500ml,웜쿨,그린리빙,한국,16800,14900,12800,21000,https://images.unsplash.com/photo-1602143407151-7111542de6e8?w=640&q=80,20,5,EA,지정택배배송,Y,Y
친환경 종이컵 1000개,O2O-CU-3002,EC-1000,탕비용품,6.5oz / FSC 인증,에코팩,그린리빙,한국,28000,25000,21500,33000,https://images.unsplash.com/photo-1577937927133-66ef06acdf18?w=640&q=80,5,4,BOX,협력사배송,Y,Y
핸드워시 리필 3입,O2O-HW-3003,HW-500R3,생활용품,500ml x 3입,클린어스,그린리빙,한국,14500,12800,10500,17500,https://images.unsplash.com/photo-1584305574647-0cc949a2bb9f?w=640&q=80,10,5,SET,지정택배배송,Y,Y
데스크 미니 화분,O2O-PL-3004,GP-100M,생활용품,다육 / 지름 10cm 화기 포함,그린데스크,그린리빙,한국,12500,10900,9200,15000,https://images.unsplash.com/photo-1485955900006-10f4d324d411?w=640&q=80,10,5,EA,협력사배송,Y,N
KF94 방역마스크 30개,O2O-MK-4001,KF94-30W,위생용품,KF94 / 대형 / 30매입,세이프가드,세종위생용품,한국,19500,17500,14900,24000,https://images.unsplash.com/photo-1605845328644-c92a5b3b3e0b?w=640&q=80,10,4,BOX,지정택배배송,Y,Y
손소독제 500ml 10개,O2O-SN-4002,HS-500G10,위생용품,겔타입 / 에탄올 62% / 500ml x 10,클린어스,세종위생용품,한국,42000,37500,32000,49000,https://images.unsplash.com/photo-1584483766114-2cea6facdf57?w=640&q=80,3,4,BOX,협력사배송,Y,Y
물티슈 100매 20팩,O2O-WT-4003,WT-100C20,위생용품,100매 캡형 x 20팩,퓨어랩,세종위생용품,한국,26000,23000,19500,31000,https://images.unsplash.com/photo-1605713288610-00c1c630ca1e?w=640&q=80,5,4,BOX,협력사배송,Y,Y
라텍스 장갑 100매,O2O-GL-4004,LG-100M,위생용품,파우더프리 / M / 100매입,세이프가드,세종위생용품,말레이시아,9800,8600,7200,12000,https://images.unsplash.com/photo-1584634731339-252c581abfc5?w=640&q=80,10,5,BOX,지정택배배송,Y,Y
1 상품명 상품코드 모델번호 카테고리 규격 제조사 공급사 원산지 상품 단가 최저한도 매입가 판매가 이미지URL 최소주문수량 리드타임(일) 단위 배송형태 부가세포함(Y/N) 배송비포함(Y/N)
2 A4 복사용지 2500매 O2O-PP-1001 CP-A4-2500 사무용품 A4 / 80g / 500매 x 5권 페이퍼밀 한빛오피스 한국 23500 21000 18500 28000 https://images.unsplash.com/photo-1586074299757-dc655f18518c?w=640&q=80 5 3 BOX 협력사배송 Y Y
3 스프링 노트 A5 10권 O2O-NT-1002 SN-A5-10 사무용품 A5 / 80매 / 10권 팩 페이퍼웍스 한빛오피스 한국 21000 18500 15900 25000 https://images.unsplash.com/photo-1531346878377-a5be20888e57?w=640&q=80 5 3 PACK 픽업배송 Y Y
4 젤 잉크 볼펜 12자루 O2O-PN-1003 GP-05BK 사무용품 0.5mm / 흑색 / 12입 페이퍼웍스 한빛오피스 한국 8400 7200 5900 10500 https://images.unsplash.com/photo-1583485088034-697b5bc54ccd?w=640&q=80 10 3 BOX 픽업배송 Y Y
5 데스크 정리함 O2O-OG-1004 DO-320W 사무용품 3단 서랍 / 메쉬 스틸 오피스원 한빛오피스 중국 15800 13900 11500 19000 https://images.unsplash.com/photo-1497032628192-86f99bcd76bc?w=640&q=80 5 5 EA 지정택배배송 Y Y
6 클리어 파일 20매 O2O-FL-1005 CF-A4-20 사무용품 A4 / 20포켓 페이퍼웍스 한빛오피스 한국 3200 2800 2200 4000 https://images.unsplash.com/photo-1544816155-12df9643f363?w=640&q=80 20 3 EA 픽업배송 Y Y
7 27형 QHD 모니터 O2O-MN-2001 VX2718-Q 사무기기 27인치 / QHD 2560x1440 비전텍 누리테크 한국 298000 279000 265000 329000 https://images.unsplash.com/photo-1527443224154-c4a3942d3acf?w=640&q=80 1 7 EA 협력사배송 N Y
8 무선 슬림 키보드 O2O-KB-2002 SK-110W 사무기기 텐키리스 / 2.4GHz 커넥타 누리테크 중국 32000 28500 24900 39000 https://images.unsplash.com/photo-1587829741301-dc798b83add3?w=640&q=80 10 3 EA 지정택배배송 Y Y
9 무선 무소음 마우스 O2O-MS-2003 WM-540G 사무기기 2.4GHz / 무소음 클릭 커넥타 누리테크 중국 18500 16900 14800 22000 https://images.unsplash.com/photo-1527864550417-7fd91fc51a46?w=640&q=80 10 3 EA 지정택배배송 Y Y
10 USB-C 도킹스테이션 O2O-DK-2004 DS-900C 사무기기 11-in-1 / HDMI 4K / PD 100W 커넥타 누리테크 중국 68000 61000 54000 79000 https://images.unsplash.com/photo-1625842268584-8f3296236761?w=640&q=80 5 5 EA 지정택배배송 Y Y
11 노이즈캔슬링 헤드셋 O2O-HS-2005 NC-800 사무기기 블루투스 5.3 / ANC 사운드코어 누리테크 중국 128000 115000 102000 149000 https://images.unsplash.com/photo-1608043152269-423dbba4e7e1?w=640&q=80 5 5 EA 지정택배배송 Y Y
12 스테인리스 텀블러 500ml O2O-TB-3001 ST-500KH 탕비용품 진공 2중 / 500ml 웜쿨 그린리빙 한국 16800 14900 12800 21000 https://images.unsplash.com/photo-1602143407151-7111542de6e8?w=640&q=80 20 5 EA 지정택배배송 Y Y
13 친환경 종이컵 1000개 O2O-CU-3002 EC-1000 탕비용품 6.5oz / FSC 인증 에코팩 그린리빙 한국 28000 25000 21500 33000 https://images.unsplash.com/photo-1577937927133-66ef06acdf18?w=640&q=80 5 4 BOX 협력사배송 Y Y
14 핸드워시 리필 3입 O2O-HW-3003 HW-500R3 생활용품 500ml x 3입 클린어스 그린리빙 한국 14500 12800 10500 17500 https://images.unsplash.com/photo-1584305574647-0cc949a2bb9f?w=640&q=80 10 5 SET 지정택배배송 Y Y
15 데스크 미니 화분 O2O-PL-3004 GP-100M 생활용품 다육 / 지름 10cm 화기 포함 그린데스크 그린리빙 한국 12500 10900 9200 15000 https://images.unsplash.com/photo-1485955900006-10f4d324d411?w=640&q=80 10 5 EA 협력사배송 Y N
16 KF94 방역마스크 30개 O2O-MK-4001 KF94-30W 위생용품 KF94 / 대형 / 30매입 세이프가드 세종위생용품 한국 19500 17500 14900 24000 https://images.unsplash.com/photo-1605845328644-c92a5b3b3e0b?w=640&q=80 10 4 BOX 지정택배배송 Y Y
17 손소독제 500ml 10개 O2O-SN-4002 HS-500G10 위생용품 겔타입 / 에탄올 62% / 500ml x 10 클린어스 세종위생용품 한국 42000 37500 32000 49000 https://images.unsplash.com/photo-1584483766114-2cea6facdf57?w=640&q=80 3 4 BOX 협력사배송 Y Y
18 물티슈 100매 20팩 O2O-WT-4003 WT-100C20 위생용품 100매 캡형 x 20팩 퓨어랩 세종위생용품 한국 26000 23000 19500 31000 https://images.unsplash.com/photo-1605713288610-00c1c630ca1e?w=640&q=80 5 4 BOX 협력사배송 Y Y
19 라텍스 장갑 100매 O2O-GL-4004 LG-100M 위생용품 파우더프리 / M / 100매입 세이프가드 세종위생용품 말레이시아 9800 8600 7200 12000 https://images.unsplash.com/photo-1584634731339-252c581abfc5?w=640&q=80 10 5 BOX 지정택배배송 Y Y

View File

@ -0,0 +1,5 @@
협력사명,식별코드,담당자명,담당자이메일,총매출액,취급상품
한빛오피스,hanbit,정우성 팀장,ws.jung@hanbitoffice.co.kr,9800000000,"A4 복사용지 2500매, 스프링 노트 A5 10권, 젤 잉크 볼펜 12자루, 데스크 정리함, 클리어 파일 20매"
누리테크,nuri,최은지 과장,ej.choi@nuritech.co.kr,15000000000,"27형 QHD 모니터, 무선 슬림 키보드, 무선 무소음 마우스, USB-C 도킹스테이션, 노이즈캔슬링 헤드셋"
그린리빙,green,한상민 대리,sm.han@greenliving.co.kr,4300000000,"스테인리스 텀블러 500ml, 친환경 종이컵 1000개, 핸드워시 리필 3입, 데스크 미니 화분"
세종위생용품,sejong,오하늘 팀장,hn.oh@sejonghygiene.co.kr,6700000000,"KF94 방역마스크 30개, 손소독제 500ml 10개, 물티슈 100매 20팩, 라텍스 장갑 100매"
1 협력사명 식별코드 담당자명 담당자이메일 총매출액 취급상품
2 한빛오피스 hanbit 정우성 팀장 ws.jung@hanbitoffice.co.kr 9800000000 A4 복사용지 2500매, 스프링 노트 A5 10권, 젤 잉크 볼펜 12자루, 데스크 정리함, 클리어 파일 20매
3 누리테크 nuri 최은지 과장 ej.choi@nuritech.co.kr 15000000000 27형 QHD 모니터, 무선 슬림 키보드, 무선 무소음 마우스, USB-C 도킹스테이션, 노이즈캔슬링 헤드셋
4 그린리빙 green 한상민 대리 sm.han@greenliving.co.kr 4300000000 스테인리스 텀블러 500ml, 친환경 종이컵 1000개, 핸드워시 리필 3입, 데스크 미니 화분
5 세종위생용품 sejong 오하늘 팀장 hn.oh@sejonghygiene.co.kr 6700000000 KF94 방역마스크 30개, 손소독제 500ml 10개, 물티슈 100매 20팩, 라텍스 장갑 100매

View File

@ -0,0 +1,8 @@
-- 0단계: DB 삭제 후 재생성 — 반드시 "postgres" DB에 연결한 상태로 실행 (negosium_db 에 연결한 채로는 자기 자신을 못 지움)
-- WITH (FORCE): 백엔드 등이 물고 있는 접속을 끊고 지운다 (PostgreSQL 13+)
DROP DATABASE IF EXISTS negosium_db WITH (FORCE);
DROP DATABASE IF EXISTS lps_db WITH (FORCE);
CREATE DATABASE negosium_db;
CREATE DATABASE lps_db;

View File

@ -0,0 +1,652 @@
-- 모든 세션에서 시각을 UTC 로 다룬다 (DEFAULT now() 가 UTC 기준으로 저장·조회됨).
ALTER DATABASE negosium_db SET timezone TO 'UTC';
SET timezone TO 'UTC';
-- uuid 기본값(gen_random_uuid) 사용을 위한 확장 (public 스키마에 설치)
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ============================================================
-- 스키마 (도메인별 네임스페이스)
-- ============================================================
CREATE SCHEMA IF NOT EXISTS company;
CREATE SCHEMA IF NOT EXISTS supplier;
CREATE SCHEMA IF NOT EXISTS partner;
CREATE SCHEMA IF NOT EXISTS card;
CREATE SCHEMA IF NOT EXISTS quotation;
CREATE SCHEMA IF NOT EXISTS negotiation;
-- ============================================================
-- company : 회사 / 내부 유저 / 인증 토큰
-- ============================================================
CREATE TABLE IF NOT EXISTS company.companies (
company_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 회사 식별자(PK)
name VARCHAR(100) NOT NULL, -- 회사명
business_number VARCHAR(30) NULL, -- 사업자등록번호
code INTEGER NULL, -- 회사코드 (내부 인덱스용)
representative_name VARCHAR(50) NULL, -- 대표자명
email VARCHAR(255) NULL, -- 대표 이메일
contact_number VARCHAR(20) NULL, -- 대표 연락처
website_url VARCHAR(255) NULL, -- 홈페이지 URL
industry SMALLINT NULL, -- 업종 ( 필요한 만큼 숫자에 매핑하여 사용 )
status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active(활성), 2=inactive(비활성)
settings JSONB NULL, -- 회사별 커스터마이징: branding(CI)/labels(용어)/features(동작)/item_fields·supplier_fields(커스텀필드 정의)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
CREATE TABLE IF NOT EXISTS company.users (
user_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 유저 식별자(PK)
company_id uuid NOT NULL, -- 소속 회사(company.companies.company_id)
id VARCHAR(20) NOT NULL, -- 로그인 ID
password VARCHAR(255) NOT NULL, -- 해시된 비밀번호이어야 함
name VARCHAR(50) NULL, -- 이름
email VARCHAR(255) NULL, -- 이메일
contact_number VARCHAR(20) NULL, -- 연락처
last_accessed_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 마지막 접속 시각
status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active(활성), 2=inactive(비활성)
role SMALLINT NOT NULL DEFAULT 1, -- 권한(UserRole): 1=user(일반), 2=owner(최고관리자)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
CREATE TABLE IF NOT EXISTS company.user_tokens (
user_tokens_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 토큰 식별자(PK)
user_id uuid NOT NULL, -- 소유 유저(company.users.user_id)
type SMALLINT NOT NULL, -- 토큰 종류 (코드, 앱 enum 매핑)
token JSONB NOT NULL, -- 토큰 본문(JSON)
issued_at TIMESTAMPTZ NOT NULL, -- 발급 시각
expired_at TIMESTAMPTZ NOT NULL, -- 만료 시각
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
-- ============================================================
-- supplier : 공급사 유저 / 인증 토큰
-- ============================================================
CREATE TABLE IF NOT EXISTS supplier.supplier_users (
su_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 공급사 유저 식별자(PK)
supplier_id uuid NOT NULL, -- 소속 공급사(partner.suppliers.supplier_id)
id VARCHAR(20) NOT NULL, -- 로그인 ID
password VARCHAR(255) NOT NULL, -- 해시된 비밀번호이어야 함
name VARCHAR(50) NULL, -- 이름
email VARCHAR(255) NULL, -- 이메일
contact_number VARCHAR(20) NULL, -- 연락처
last_accessed_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 마지막 접속 시각
status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active(활성), 2=inactive(비활성)
role SMALLINT NOT NULL DEFAULT 1, -- 권한: 1=user(유저), 2=manager(매니저)
hide_service_info BOOLEAN NOT NULL DEFAULT FALSE, -- 서비스 안내 팝업(협상 유의사항) "안내 보지 않기" 여부
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
CREATE TABLE IF NOT EXISTS supplier.supplier_user_tokens (
sut_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 토큰 식별자(PK)
su_id uuid NOT NULL, -- 소유 공급사 유저(supplier.supplier_users.su_id)
type SMALLINT NOT NULL, -- 토큰 종류 (코드, 앱 enum 매핑)
token JSONB NOT NULL, -- 토큰 본문(JSON)
issued_at TIMESTAMPTZ NOT NULL, -- 발급 시각
expired_at TIMESTAMPTZ NOT NULL, -- 만료 시각
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
-- ============================================================
-- partner : 공급사 / 상품 / 인터넷 최저가
-- ============================================================
CREATE TABLE IF NOT EXISTS partner.suppliers (
supplier_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 공급사 식별자(PK)
company_id uuid NOT NULL, -- 소속 회사(company.companies.company_id)
user_id uuid NOT NULL, -- 등록 유저(company.users.user_id)
name VARCHAR(100) NOT NULL, -- 공급사명
code VARCHAR(20) NULL, -- 공급사 코드
manager_name VARCHAR(50) NULL, -- 담당자명
manager_email VARCHAR(255) NULL, -- 담당자 이메일
manager_contact_number VARCHAR(20) NULL, -- 담당자 연락처
total_revenue BIGINT NULL, -- 총매출액(원). KTC suppliers.total_revenue 미러
custom JSONB NULL, -- 회사 커스텀필드 값 {key: value} (정의는 companies.settings.supplier_fields)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
CREATE TABLE IF NOT EXISTS partner.items (
item_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 상품 식별자(PK)
company_id uuid NOT NULL, -- 소속 회사(company.companies.company_id)
user_id uuid NOT NULL, -- 등록 유저(company.users.user_id)
name VARCHAR(100) NOT NULL, -- 상품명
code VARCHAR(30) NULL, -- 상품 코드
price BIGINT NULL, -- 가격(원)
category VARCHAR(255) NULL, -- 카테고리
image_url VARCHAR(255) NULL, -- 이미지 URL
model_name VARCHAR(100) NULL, -- 모델명
spec VARCHAR(255) NULL, -- 규격
moq VARCHAR(50) NULL, -- 상품 주문시, 최소 주문 수량
lead_time SMALLINT NULL, -- 상품 주문 완료 후, 배송 도착까지의 시간
manufacturer VARCHAR(50) NULL, -- 제조사
made_in VARCHAR(100) NULL, -- 원산지
quantity_unit VARCHAR(50) NULL, -- 상품 취급 단위 라벨(자유입력): EA/BOX/SET/ROLL ... (ORM String 기준)
delivery_type SMALLINT NULL, -- 배송 유형(DeliveryType): 1=supplier(협력사배송), 2=courier(지정택배배송), 3=pickup(픽업배송)
vat_yn BOOLEAN NULL, -- 부가세 포함 여부
delivery_fee_yn BOOLEAN NULL, -- 배송비 포함 여부
internet_lowest_price_yn BOOLEAN NOT NULL DEFAULT FALSE, -- 최저가 솔루션의 원자성을 보존하기 위한 보조 컬럼
internet_lowest_price BIGINT NULL, -- 인터넷 최저가
purchase_price BIGINT NULL, -- 매입가
selling_price BIGINT NULL, -- 판매가
category_type INTEGER NOT NULL DEFAULT 1, -- 자동으로 늘어나는 숫자 ( 카테고리 찾을때 유용한 컬럼)
custom JSONB NULL, -- 회사 커스텀필드 값 {key: value} (정의는 companies.settings.item_fields)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
CREATE TABLE IF NOT EXISTS partner.item_internet_lowest_prices (
lp_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 최저가 레코드 식별자(PK)
item_id uuid NOT NULL, -- 대상 상품(partner.items.item_id)
lp_price BIGINT NULL, -- 크롤링한 최저가(원)
website SMALLINT NOT NULL, -- 크롤링 대상 사이트 (코드, 앱 enum 매핑)
success_yn BOOLEAN NOT NULL, -- 크롤링 성공 여부
fail_reason VARCHAR(100) NULL, -- 실패 사유
ai_model SMALLINT NULL, -- 사용한 AI 모델 (코드, 앱 enum 매핑)
crawl_duration_ms INTEGER NULL, -- 크롤링 소요 시간(ms)
crawl_end_time TIMESTAMPTZ NOT NULL, -- 크롤링 종료 시각
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
CREATE TABLE IF NOT EXISTS partner.supplier_items (
supplier_item_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 매핑 식별자(PK)
supplier_id uuid NOT NULL, -- 협력사(partner.suppliers.supplier_id)
item_id uuid NOT NULL, -- 상품(partner.items.item_id)
supply_type SMALLINT NOT NULL DEFAULT 0, -- 공급 유형(SupplierType): 0=none(없음), 1=distribution(유통), 2=manufacture(제조), 3=sole_agency(총판)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
-- ============================================================
-- card : 협상 전략 (버전 / 협상카드 / 와일드카드 / 매핑)
-- ============================================================
CREATE TABLE IF NOT EXISTS card.versions (
version_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 버전 식별자(PK)
user_id uuid NOT NULL, -- 버전을 생성한 유저 아이디(company.users.user_id)
code INTEGER NOT NULL DEFAULT 0, -- 빠른 버전 조회를 위한 컬럼
name VARCHAR(10) NOT NULL, -- 버전명
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
CREATE TABLE IF NOT EXISTS card.nego_cards (
nego_card_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 협상 카드 식별자(PK)
user_id uuid NULL, -- 협상 카드는 기본으로 o2o에서 설정할 수 도 있기 때문에 null 가능
name VARCHAR(20) NULL, -- 카드명
number VARCHAR(10) NULL, -- 식별번호
script TEXT NULL, -- 협상 스크립트
edit_script JSONB NULL, -- 편집된 스크립트(JSON)
usage_type SMALLINT NOT NULL DEFAULT 1, -- 카드 적용 견적 구분(CardUsageType): 1=common(공통), 2=new(신규견적전용), 3=reuse(재견적전용)
tone SMALLINT NULL, -- 카드 톤(CardTone): 1=강경, 2=정중, 3=우호, 4=중립, 5=단호
strategy_type SMALLINT NULL, -- 전략 유형(CardStrategyType): 1=경쟁, 2=수용, 3=고수, 4=협력, 5=선점, 6=종결
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
CREATE TABLE IF NOT EXISTS card.wild_cards (
wild_card_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 와일드 카드 식별자(PK)
user_id uuid NULL, -- 와일드 카드는 기본으로 o2o에서 설정할 수 도 있기 때문에 null 가능
name VARCHAR(20) NULL, -- 카드명
number VARCHAR(10) NULL, -- 식별번호
script TEXT NULL, -- 협상 스크립트
edit_script JSONB NULL, -- 편집된 스크립트(JSON)
usage_type SMALLINT NOT NULL DEFAULT 1, -- 카드 적용 견적 구분(CardUsageType): 1=common(공통), 2=new(신규견적전용), 3=reuse(재견적전용)
tone SMALLINT NULL, -- 카드 톤(CardTone): 1=강경, 2=정중, 3=우호, 4=중립, 5=단호
strategy_type SMALLINT NULL, -- 전략 유형(CardStrategyType): 1=경쟁, 2=수용, 3=고수, 4=협력, 5=선점, 6=종결
condition VARCHAR(255) NULL, -- 커스터마이징 협상 카드이기 때문에 상세 조건을 기재해야 함
available BOOLEAN NOT NULL DEFAULT FALSE, -- 와일드 카드는 수동으로 코드에 추가해야 하기 때문에 컬럼 추가
memo VARCHAR(255) NULL, -- 사용 조건 이외에 자유롭게 적을 수 있는 메모
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
CREATE TABLE IF NOT EXISTS card.version_nego_cards (
vnc_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 매핑 식별자(PK)
version_id uuid NOT NULL, -- 버전(card.versions.version_id)
nego_card_id uuid NOT NULL, -- 협상 카드(card.nego_cards.nego_card_id)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
CREATE TABLE IF NOT EXISTS card.version_wild_cards (
vwc_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 매핑 식별자(PK)
version_id uuid NOT NULL, -- 버전(card.versions.version_id)
wild_card_id uuid NOT NULL, -- 와일드 카드(card.wild_cards.wild_card_id)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
-- ============================================================
-- quotation : 견적설정 / 견적
-- ============================================================
CREATE TABLE IF NOT EXISTS quotation.quotation_settings (
qt_setting_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 견적 설정 식별자(PK)
user_id uuid NOT NULL, -- 견적 설정을 생성한 유저 아이디(company.users.user_id)
target_margin_rate NUMERIC(8,6) NOT NULL, -- 목표 마진율 (정수부 2자리 + 소수 6자리, -99.999999~99.999999)
card_count INTEGER NOT NULL DEFAULT 3, -- 한개의 협상 안에서 협상카드 사용 횟수
-- 낙찰 정책(mid/over/regen)은 견적 단위로 이관, 앵커링은 칸 rate(anchoring v1.2)로 대체 → 세팅 컬럼 없음
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
CREATE TABLE IF NOT EXISTS quotation.quotations (
qt_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 견적 식별자(PK)
user_id uuid NOT NULL, -- 생성 유저(company.users.user_id)
qt_setting_id uuid NOT NULL, -- 견적 설정(quotation.quotation_settings.qt_setting_id)
version_id uuid NOT NULL, -- 버전(card.versions.version_id)
name VARCHAR(50) NOT NULL, -- 견적명
number VARCHAR(30) NOT NULL, -- 견적번호
type SMALLINT NOT NULL, -- 견적 유형(QuotationType): 1=renego(재협상 1:1), 2=requote(재견적 1:N), 3=new_nego(신규협상 1:1), 4=new_quote(신규견적 1:N)
round INTEGER NOT NULL DEFAULT 1, -- 같은 견적 번호로 재견적 진행 시, 해당 숫자가 증가
status SMALLINT NOT NULL, -- 진행 상태(QuotationStatus): 1=created(생성), 2=in_progress(진행중), 3=closed(마감)
start_time TIMESTAMPTZ NOT NULL, -- 견적 시작 시각
end_time TIMESTAMPTZ NOT NULL, -- 견적 종료 시각
manager_name VARCHAR(50) NULL, -- 담당자명
manager_email VARCHAR(255) NULL, -- 담당자 이메일
manager_contact_number VARCHAR(20) NULL, -- 담당자 연락처
memo VARCHAR(100) NULL, -- 메모
md_price BIGINT NULL, -- MD 제시가(원). 목표가 산정 최우선값 (견적생성 모달 입력)
iteration INTEGER NOT NULL DEFAULT 0, -- 반복 횟수
preferred_sp_yn BOOLEAN NULL, -- 선호 공급사 지정 여부
preferred_sp_id uuid NULL, -- 선호 공급사(partner.suppliers.supplier_id)
preferred_sp_name VARCHAR(20) NULL, -- 선호 공급사명(스냅샷)
equal_bid_yn BOOLEAN NULL, -- 동일가 입찰 발생 여부
equal_bid_data JSONB NULL, -- 동일가 입찰 상세(JSON)
close_reason SMALLINT NULL, -- 마감 사유(CloseReason): 1=낙찰, 5=가격개찰, 6=동가개찰, 7=미응찰개찰, 8=거부개찰. 미마감이면 NULL
mid_action SMALLINT NOT NULL DEFAULT 1, -- 낙찰 기준(PriceGateAction 1=낙찰/2=개찰): 앵커링가<투찰가≤목표가 처리. 1:1 협상만 사용자 선택, 1:N 경매는 AWARD 강제
over_action SMALLINT NOT NULL DEFAULT 1, -- 낙찰 기준(PriceGateAction 1=낙찰/2=개찰): 목표가<투찰가 처리(1:1 협상은 항상 개찰). 투찰가≤앵커링가는 항상 낙찰
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
-- ============================================================
-- negotiation : 협상 세션 / 채팅 / 결과
-- ============================================================
CREATE TABLE IF NOT EXISTS negotiation.sessions (
session_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 협상 세션 식별자(PK)
quotation_id uuid NOT NULL, -- 소속 견적(quotation.quotations.qt_id)
item_id uuid NOT NULL, -- 대상 상품(partner.items.item_id)
supplier_id uuid NOT NULL, -- 대상 공급사(partner.suppliers.supplier_id)
qt_number VARCHAR(30) NOT NULL, -- 견적번호(스냅샷)
qt_round INTEGER NOT NULL, -- 견적 라운드(스냅샷)
qt_type SMALLINT NOT NULL, -- 견적 유형(스냅샷, QuotationType): 1=renego(재협상 1:1), 2=requote(재견적 1:N), 3=new_nego(신규협상 1:1), 4=new_quote(신규견적 1:N)
target_price BIGINT NOT NULL, -- 목표가(원)
anchoring_price BIGINT NULL, -- 앵커링가(원) — 생성 시 박제(schedules/anchoring 참조)
anchoring_value SMALLINT NULL, -- 제안 당시 앵커링 값(천분율‰) 박제
last_offer_price BIGINT NULL, -- 협력사 마지막 제시가(원) — 앵커링 표본 판정의 "가격 흔적"
used_by_adjustment_id BIGINT NULL, -- 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id)
status SMALLINT NOT NULL, -- 진행 상태(SessionStatus): 1=created(생성), 2=in_progress(진행중), 3=done(완료), 4=not_participated(미참여), 5=rejected(거부)
bid_price BIGINT NULL, -- 입찰가(원)
bid_at TIMESTAMPTZ NULL, -- 입찰 시각
end_time TIMESTAMPTZ NOT NULL, -- 세션 종료 시각
reject_reason VARCHAR(255) NULL, -- 거절 사유
reject_price BIGINT NULL, -- 거절 시 제시가(원)
reject_delivery_type SMALLINT NULL, -- 거절 시 배송 유형(DeliveryType): 1=supplier(협력사배송), 2=courier(지정택배배송), 3=pickup(픽업배송)
email_sent_at TIMESTAMPTZ NULL, -- 협상 초청 메일 발송 시각(NULL=미발송). 수동 발송 버튼이 채움
custom JSONB NULL, -- 협상완료 부가정보 값 {key: value} (정의는 companies.settings.session_fields: 표준납기/MOQ/발주배수/배송유형)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
CREATE TABLE IF NOT EXISTS negotiation.chats (
chat_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 채팅 식별자(PK)
session_id uuid NOT NULL, -- 소속 세션(negotiation.sessions.session_id), session 1 : N chats
card_id uuid NULL, -- 사용된 카드(card.nego_cards/card.wild_cards)
seq INTEGER NOT NULL DEFAULT 1, -- 세션 내 메시지 순번
sender SMALLINT NOT NULL, -- 발신자 구분(ChatSender): 1=bot(봇), 2=user(유저)
target_price BIGINT NOT NULL, -- 제시 목표가(원)
card_used_yn BOOLEAN NULL, -- 카드 사용 여부
indicator_value NUMERIC(8,6) NULL, -- 소수점 까지 반환할 수도 있음 (정수부 2자리 + 소수 6자리, -99.999999~99.999999)
card_type SMALLINT NULL, -- 카드 유형(CardType): 1=nego(협상카드), 2=wild(와일드카드)
meta JSONB NULL, -- 말풍선 표현 데이터(script/step/client_step/input_mode/input_options/chat_end). 구조화 컬럼(price/card/indicator) 외 가변 UI 필드만 보관.
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
-- TODO: 결과 스키마 미확정 — 컬럼·관계(session/quotation 연결 등) 추후 정의 (임시 테이블)
CREATE TABLE IF NOT EXISTS negotiation.results (
result_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 결과 식별자(PK)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
-- ============================================================
-- company : 알림(인박스) — 협상 이벤트를 견적 작성자(유저)에게 통지
-- ============================================================
CREATE TABLE IF NOT EXISTS company.notifications (
notification_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 알림 식별자(PK)
user_id uuid NOT NULL, -- 수신자(company.users.user_id) = 견적 작성자
type SMALLINT NOT NULL, -- 알림 유형(NotificationType): 1=success(낙찰), 2=regenerated(재생성), 3=failure(결렬), 4=created(견적생성)
ref_qt_id uuid NULL, -- 관련 견적(quotation.quotations.qt_id), 클릭 시 이동
ref_session_id uuid NULL, -- 관련 세션(negotiation.sessions.session_id), 공급사 알림만
data JSONB NULL, -- 렌더 스냅샷(qt_name/qt_number + 유형별 필드). 발생 시점 값 보관(텍스트 불변).
read_at TIMESTAMPTZ NULL, -- 읽은 시각(NULL=안읽음). 행동 필요(검토) 판정 겸용
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
-- ============================================================
-- 인덱스 (FK 를 걸지 않으므로 조인 컬럼 인덱스를 명시적으로 생성)
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_users_company_id ON company.users (company_id);
CREATE INDEX IF NOT EXISTS idx_user_tokens_user_id ON company.user_tokens (user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON company.notifications (user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON company.notifications (user_id, created_at) WHERE deleted = FALSE AND read_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_notifications_ref_qt_id ON company.notifications (ref_qt_id);
CREATE INDEX IF NOT EXISTS idx_supplier_users_supplier_id ON supplier.supplier_users (supplier_id);
CREATE INDEX IF NOT EXISTS idx_sut_su_id ON supplier.supplier_user_tokens (su_id);
CREATE INDEX IF NOT EXISTS idx_suppliers_company_id ON partner.suppliers (company_id);
CREATE INDEX IF NOT EXISTS idx_suppliers_user_id ON partner.suppliers (user_id);
CREATE INDEX IF NOT EXISTS idx_items_company_id ON partner.items (company_id);
CREATE INDEX IF NOT EXISTS idx_items_user_id ON partner.items (user_id);
CREATE INDEX IF NOT EXISTS idx_iilp_item_id ON partner.item_internet_lowest_prices (item_id);
CREATE INDEX IF NOT EXISTS idx_supplier_items_supplier_id ON partner.supplier_items (supplier_id);
CREATE INDEX IF NOT EXISTS idx_supplier_items_item_id ON partner.supplier_items (item_id);
CREATE INDEX IF NOT EXISTS idx_versions_user_id ON card.versions (user_id);
CREATE INDEX IF NOT EXISTS idx_nego_cards_user_id ON card.nego_cards (user_id);
CREATE INDEX IF NOT EXISTS idx_wild_cards_user_id ON card.wild_cards (user_id);
CREATE INDEX IF NOT EXISTS idx_vnc_version_id ON card.version_nego_cards (version_id);
CREATE INDEX IF NOT EXISTS idx_vnc_nego_card_id ON card.version_nego_cards (nego_card_id);
CREATE INDEX IF NOT EXISTS idx_vwc_version_id ON card.version_wild_cards (version_id);
CREATE INDEX IF NOT EXISTS idx_vwc_wild_card_id ON card.version_wild_cards (wild_card_id);
CREATE INDEX IF NOT EXISTS idx_quotation_settings_user_id ON quotation.quotation_settings (user_id);
CREATE INDEX IF NOT EXISTS idx_quotations_user_id ON quotation.quotations (user_id);
CREATE INDEX IF NOT EXISTS idx_quotations_qt_setting_id ON quotation.quotations (qt_setting_id);
CREATE INDEX IF NOT EXISTS idx_quotations_version_id ON quotation.quotations (version_id);
CREATE INDEX IF NOT EXISTS idx_sessions_quotation_id ON negotiation.sessions (quotation_id);
CREATE INDEX IF NOT EXISTS idx_sessions_item_id ON negotiation.sessions (item_id);
CREATE INDEX IF NOT EXISTS idx_sessions_supplier_id ON negotiation.sessions (supplier_id);
CREATE INDEX IF NOT EXISTS idx_chats_card_id ON negotiation.chats (card_id);
-- 자연키 / 1:1 유니크 (소프트 삭제 고려한 부분 유니크 인덱스)
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_id ON company.users (id) WHERE deleted = FALSE;
CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_users_id ON supplier.supplier_users (id) WHERE deleted = FALSE;
CREATE UNIQUE INDEX IF NOT EXISTS uq_companies_biz_number ON company.companies (business_number) WHERE deleted = FALSE AND business_number IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_quotations_number ON quotation.quotations (number, round) WHERE deleted = FALSE;
CREATE UNIQUE INDEX IF NOT EXISTS uq_chats_session_seq ON negotiation.chats (session_id, seq) WHERE deleted = FALSE; -- 세션 내 메시지 순번 유니크 (session 1:N, session_id 조회도 이 인덱스로 커버)
CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_items ON partner.supplier_items (supplier_id, item_id) WHERE deleted = FALSE; -- (협력사,상품) 매핑 중복 방지(소프트 삭제분은 재등록 허용)
-- ============================================================
-- 추가 인덱스 (예상 조회 패턴 대비)
-- - 자주 deleted=FALSE 로 필터하므로 부분 인덱스로 둔다.
-- - 실제 쿼리 패턴이 확정되면 불필요한 것은 제거할 것.
-- ============================================================
-- 코드 / 번호 단건 조회
CREATE INDEX IF NOT EXISTS idx_companies_code ON company.companies (code) WHERE deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_suppliers_code ON partner.suppliers (code) WHERE deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_items_code ON partner.items (code) WHERE deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_items_category_type ON partner.items (category_type) WHERE deleted = FALSE; -- 카테고리 조회용
CREATE INDEX IF NOT EXISTS idx_versions_code ON card.versions (code) WHERE deleted = FALSE; -- 빠른 버전 조회
CREATE INDEX IF NOT EXISTS idx_nego_cards_number ON card.nego_cards (number) WHERE deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_wild_cards_number ON card.wild_cards (number) WHERE deleted = FALSE;
-- 상태 필터 (목록 화면 등)
CREATE INDEX IF NOT EXISTS idx_quotations_status ON quotation.quotations (status) WHERE deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_sessions_status ON negotiation.sessions (status) WHERE deleted = FALSE;
-- 자주 함께 거는 조건 (복합)
CREATE INDEX IF NOT EXISTS idx_quotations_user_status ON quotation.quotations (user_id, status) WHERE deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_sessions_quotation_status ON negotiation.sessions (quotation_id, status) WHERE deleted = FALSE;
-- 시간 기반 조회 / 마감 임박 정렬
CREATE INDEX IF NOT EXISTS idx_quotations_end_time ON quotation.quotations (end_time) WHERE deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_sessions_end_time ON negotiation.sessions (end_time) WHERE deleted = FALSE;
-- 상품별 최신 크롤링 최저가 조회
CREATE INDEX IF NOT EXISTS idx_iilp_item_crawl_time ON partner.item_internet_lowest_prices (item_id, crawl_end_time DESC) WHERE deleted = FALSE;
-- [2026-07-10] LPS 동기화 중복 방지 — 같은 수집분(item_id, crawl_end_time)은 1행만(경합 시 진 쪽 tx 실패)
CREATE UNIQUE INDEX IF NOT EXISTS uq_iilp_item_crawl_time ON partner.item_internet_lowest_prices (item_id, crawl_end_time) WHERE deleted = FALSE;
-- ============================================================
-- learning : 협상 에이전트(agent) RL 학습 자산 (Q-Table / 경험로그) — agent 소유, backend 미사용
-- ============================================================
-- 멀티테넌트 논리 격리: 모든 테이블에 company_id 컬럼. company.companies.company_id(uuid)를
-- 문자열로 보관하되, 공유 베이스 정책은 예약어 '_base' 를 쓴다(uuid/sentinel 혼용 → VARCHAR).
-- 모든 유니크/인덱스는 company_id 선두 복합으로 둔다(테넌트 간 충돌 방지 + 스코프 조회).
CREATE SCHEMA IF NOT EXISTS learning;
-- ------------------------------------------------------------
-- Q-Table 버전 (학습 스냅샷의 헤더)
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS learning.q_table_versions (
version_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
company_id VARCHAR(64) NOT NULL, -- 테넌트 키(company uuid 문자열 또는 '_base')
version_name VARCHAR(50) NOT NULL, -- 버전명 (예: v000_warmstart_from_base)
scope SMALLINT NOT NULL DEFAULT 2, -- 1=base, 2=tenant
base_version_id uuid NULL, -- warm-start 출처 추적(베이스 버전)
state_space_size INTEGER NOT NULL, -- 차원 정합성 체크용
action_space_size INTEGER NOT NULL,
learning_rate NUMERIC(6,4) NOT NULL DEFAULT 0.1000,
discount_factor NUMERIC(6,4) NOT NULL DEFAULT 0.9500,
epochs INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT FALSE, -- 활성 버전 포인터(테넌트당 1개)
action_cards JSONB NULL, -- 카탈로그 스냅샷: 카드번호 목록(index=action_id). 카드번호 기반 마이그레이션용
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- version_name 은 테넌트 스코프에서만 유니크 (계획서 C: UniqueConstraint(tenant, version_name))
CREATE UNIQUE INDEX IF NOT EXISTS uq_qtv_company_version
ON learning.q_table_versions (company_id, version_name);
-- 테넌트별 활성 버전은 최대 1개 (부분 유니크)
CREATE UNIQUE INDEX IF NOT EXISTS uq_qtv_company_active
ON learning.q_table_versions (company_id) WHERE is_active AND NOT deleted;
-- ------------------------------------------------------------
-- Q 값 (state_index, action_id) -> q_value
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS learning.q_values (
id BIGSERIAL PRIMARY KEY,
company_id VARCHAR(64) NOT NULL,
version_id uuid NOT NULL,
state_index INTEGER NOT NULL,
action_id INTEGER NOT NULL,
q_value DOUBLE PRECISION NOT NULL DEFAULT 0.0
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_qval_company_version_sa
ON learning.q_values (company_id, version_id, state_index, action_id);
CREATE INDEX IF NOT EXISTS idx_qval_company_version_state
ON learning.q_values (company_id, version_id, state_index);
-- ------------------------------------------------------------
-- 방문 횟수 (UCB 탐색용)
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS learning.visit_counts (
id BIGSERIAL PRIMARY KEY,
company_id VARCHAR(64) NOT NULL,
version_id uuid NOT NULL,
state_index INTEGER NOT NULL,
action_id INTEGER NOT NULL,
count BIGINT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_visit_company_version_sa
ON learning.visit_counts (company_id, version_id, state_index, action_id);
CREATE INDEX IF NOT EXISTS idx_visit_company_version_state
ON learning.visit_counts (company_id, version_id, state_index);
-- ------------------------------------------------------------
-- 경험 로그 (transition). OPE/오프라인RL 의 데이터 소스.
-- propensity / turn / available_actions / settled_price 는 신규 로깅(소급 불가, 계획서 H0).
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS learning.experience_logs (
id BIGSERIAL PRIMARY KEY,
company_id VARCHAR(64) NOT NULL,
transition_id uuid NOT NULL DEFAULT gen_random_uuid(),
session_id uuid NULL, -- negotiation.sessions.session_id 연결
state_index INTEGER NOT NULL,
action_id INTEGER NOT NULL,
card_id VARCHAR(40) NULL, -- 사용된 카드(테넌트 카탈로그)
q_value_at_selection DOUBLE PRECISION NULL,
reward DOUBLE PRECISION NULL, -- 보상 산출 후 update
next_state_index INTEGER NULL,
done BOOLEAN NOT NULL DEFAULT FALSE,
snapshot JSONB NULL, -- NegotiationSnapshot 전체(연속 feature)
propensity DOUBLE PRECISION NULL, -- 행동정책 선택확률 (OPE 필수)
turn INTEGER NULL, -- 협상 라운드(iteration)
available_actions JSONB NULL, -- 선택 시점 가용 액션(마스킹)
settled_price BIGINT NULL, -- 타결가(원)
visit_count_at_selection BIGINT NULL,
total_visits_at_selection BIGINT NULL,
ucb_score_at_selection DOUBLE PRECISION NULL,
is_new_quote BOOLEAN NOT NULL DEFAULT FALSE, -- 학습 격리(신규견적은 UCB 비활성)
is_invalidated BOOLEAN NOT NULL DEFAULT FALSE,
invalidated_reason VARCHAR(255) NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_exp_company_transition
ON learning.experience_logs (company_id, transition_id);
CREATE INDEX IF NOT EXISTS idx_exp_company_session
ON learning.experience_logs (company_id, session_id);
CREATE INDEX IF NOT EXISTS idx_exp_company_state_action
ON learning.experience_logs (company_id, state_index, action_id);
-- ------------------------------------------------------------
-- 테넌트별 action_id -> card 매핑 (계획서 C: tenant_action_cards)
-- PoC 는 카드 매핑 고정. P6 에서 동기화 소스로 사용.
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS learning.tenant_action_cards (
id BIGSERIAL PRIMARY KEY,
company_id VARCHAR(64) NOT NULL,
action_id INTEGER NOT NULL,
card_id VARCHAR(40) NOT NULL, -- card.nego_cards.number 등 테넌트 카탈로그 식별자
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_tac_company_action
ON learning.tenant_action_cards (company_id, action_id) WHERE NOT deleted;
-- ------------------------------------------------------------
-- 대화 세션 상태 (P8-A: /chat 진행 상태 영속화 — 재시작/멀티워커 안전)
-- 채팅 '로그'(메시지)가 아니라 진행 '상태'(현재 step·맥락·사용카드·라운드)다.
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS learning.chat_sessions (
session_id uuid PRIMARY KEY,
company_id VARCHAR(64) NOT NULL,
tenant_id VARCHAR(64) NOT NULL,
rq_type VARCHAR(10) NOT NULL DEFAULT '재협상',
step VARCHAR(40) NOT NULL DEFAULT '시작', -- 현재 대기 중인 step
context JSONB NOT NULL DEFAULT '{}'::jsonb, -- 앵커/목표가·라운드·last_state 등
used_action_ids JSONB NOT NULL DEFAULT '[]'::jsonb, -- 사용한 카드(중복방지/소진 판정)
action_space_size INTEGER NOT NULL DEFAULT 0,
ended BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_chat_sessions_company ON learning.chat_sessions (company_id);
-- ============================================================
-- anchoring : 앵커링 값 자동 조정 배치 자산 — schedules/anchoring 소유, backend 는 소비만
-- ============================================================
-- 규범 문서: schedules/anchoring/docs/개발용.md §6.
-- 구 이름(rate_adjustments 등)의 기존 DB 는 schedules/anchoring/migrations/20260706_rename_anchoring.sql 적용.
CREATE SCHEMA IF NOT EXISTS anchoring;
-- 앵커링 값 조정 이력. append-only — UPDATE/DELETE 금지, updated_at/deleted 의도적 생략.
CREATE TABLE IF NOT EXISTS anchoring.adjustments (
adjustment_id BIGSERIAL PRIMARY KEY,
company_id uuid NOT NULL, -- 테넌트(partner.items.company_id 유래)
supplier_type SMALLINT NOT NULL, -- 1=유통(δ20) 2=제조(δ10) 3=총판(δ15)
price_range_index INTEGER NOT NULL, -- 가격구간 0..45 자릿수 사다리 (앱 보장)
sample_count INTEGER NOT NULL, -- 유효 표본 수 n (>=10, 앱 보장)
success_count INTEGER NOT NULL, -- n 중 성공(BID_SUCCESS) 건수
anchoring_value_before SMALLINT NOT NULL, -- 직전 값(‰) (이력 없었으면 정적 테이블 시작값)
anchoring_value_after SMALLINT NOT NULL, -- 조정 후 값(‰), clamp [10,200] 앱 보장
used_session_ids JSONB NOT NULL, -- 소비한 세션 uuid 배열(창 박제 — 재현성·감사)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 현재 값 조회 최적화: 칸별 최신 조정
CREATE INDEX IF NOT EXISTS idx_adjustments_cell
ON anchoring.adjustments (company_id, supplier_type, price_range_index, adjustment_id DESC);
-- 배치 스캔 최적화: 미처리 "재협상" 세션만 (부분 인덱스).
-- qt_type=1 을 술어에 포함해야 함 — 빼면 배치가 마킹하지 않는 비재협상 세션이
-- 영구 잔류해 인덱스가 전체 세션 수에 비례해 성장한다(의도는 이월 풀만 담는 소형 인덱스).
CREATE INDEX IF NOT EXISTS idx_sessions_anchoring_pending
ON negotiation.sessions (status)
WHERE used_by_adjustment_id IS NULL AND deleted = false AND qt_type = 1;
-- ── 조회용 뷰 (파생 — 상태 없음, 진실 원천은 adjustments) ──────────
-- 회사별 앵커링 값 변경 이력 리스트업: "언제, 어떤 칸이, 몇 건 중 몇 건 성공으로, 몇 ‰에서 몇 ‰로"
CREATE OR REPLACE VIEW anchoring.value_history AS
SELECT adjustment_id,
company_id,
supplier_type, -- 1유통/2제조/3총판
price_range_index, -- 0..45 자릿수 사다리
anchoring_value_before, -- 이전 값(‰)
anchoring_value_after, -- 새 값(‰)
anchoring_value_after - anchoring_value_before AS value_change,
sample_count,
success_count,
round(success_count::numeric / sample_count, 3) AS success_rate,
created_at
FROM anchoring.adjustments;
-- 칸별 현재값: 칸의 최신 조정 행. 여기 없는 칸의 현재값 = 정적 테이블 시작값(10‰)
CREATE OR REPLACE VIEW anchoring.current_values AS
SELECT DISTINCT ON (company_id, supplier_type, price_range_index)
company_id,
supplier_type,
price_range_index,
anchoring_value_after AS anchoring_value,
adjustment_id AS last_adjustment_id,
created_at AS last_adjusted_at
FROM anchoring.adjustments
ORDER BY company_id, supplier_type, price_range_index, adjustment_id DESC;
-- ============================================================
-- 기존 DB 보정(ALTER) — 재실행 시 기존 DB 를 최신 스키마로 맞춘다
-- ============================================================
-- 위 CREATE TABLE IF NOT EXISTS 는 기존 테이블을 바꾸지 못하므로, 컬럼 추가/타입 변경은
-- 멱등 ALTER 로 보정한다. 신규 DB 에는 전부 no-op.
-- 기준선(2026-07-07 main 스키마)까지의 보정은 이 섹션에 있고, 그보다 오래된 DB 는 git 이력의 04-alter*.sql 을 먼저 적용.
-- 기준선 이후의 새 스키마 변경은 위 테이블 정의를 갱신하고, 보정 ALTER 는 alters/ 아래 별도 파일로 만들어 적용한다.
ALTER TABLE quotation.quotations DROP COLUMN IF EXISTS supplier_type;
-- [2026-07-08] Q-table 버전에 카탈로그 스냅샷(카드번호 기반 마이그레이션용).
ALTER TABLE learning.q_table_versions ADD COLUMN IF NOT EXISTS action_cards JSONB NULL;
-- last_accessed_at: 기준선 스키마에 DEFAULT now() 누락 → 앱 유저 생성(ORM server_default 의존) 시 NULL NOT NULL 위반.
UPDATE company.users SET last_accessed_at = now() WHERE last_accessed_at IS NULL;
ALTER TABLE company.users ALTER COLUMN last_accessed_at SET DEFAULT now();
UPDATE supplier.supplier_users SET last_accessed_at = now() WHERE last_accessed_at IS NULL;
ALTER TABLE supplier.supplier_users ALTER COLUMN last_accessed_at SET DEFAULT now();
-- [2026-07-22] 회사별 커스터마이징(브랜딩/용어 라벨/커스텀필드) — IMK 요청 대응
ALTER TABLE company.companies ADD COLUMN IF NOT EXISTS settings JSONB NULL;
ALTER TABLE partner.items ADD COLUMN IF NOT EXISTS custom JSONB NULL;
ALTER TABLE partner.suppliers ADD COLUMN IF NOT EXISTS custom JSONB NULL;
-- [2026-07-22] 협상완료 부가정보(표준납기/MOQ/발주배수/배송유형) — 회사 정의(session_fields) 값 저장(#5)
ALTER TABLE negotiation.sessions ADD COLUMN IF NOT EXISTS custom JSONB NULL;

View File

@ -0,0 +1,217 @@
-- 임시 데이터(로컬/개발 시드) — 스키마는 00-init.sql, 데이터는 이 파일.
-- 채우는 것: company.companies + company.users(negodata 로그인용) + 협상 카드(card.nego_cards/wild_cards).
-- 전체/공용 시드가 아니다. 재실행 안전(WHERE NOT EXISTS) — 운영 DB 에는 적용하지 않는다.
-- admin 계정은 negodata 프론트 로그인 폼 기본값(admin / admin1234)에 대응한다.
-- 적용(도커 postgres): docker exec -i negosium-db psql -U postgres -d negosium_db < postgres-init/temp-data.sql
-- created_at/updated_at/deleted 은 server_default 로 자동 채워져 INSERT 에 안 넣는다.
-- 회사 1개 (고정 UUID — users.company_id FK 가 참조). status 1=active.
INSERT INTO company.companies (company_id, name, business_number, representative_name, email, contact_number, website_url, status)
SELECT 'a35152d2-db61-4760-9f1e-beb9736d957f', '아이마켓코리아', '220-88-21724', '홍길동',
'admin@imarketkorea.com', '02-3708-5000', 'https://www.imarketkorea.com', 1
WHERE NOT EXISTS (
SELECT 1 FROM company.companies WHERE company_id = 'a35152d2-db61-4760-9f1e-beb9736d957f'
);
-- admin 유저. password 는 'admin1234' 의 bcrypt 해시(백엔드 GetHashedPW 와 동일 알고리즘, checkpw 로 검증됨).
-- role 2=manager (UserRole.MANAGER; ADMIN 코드는 enum 에 없어 최상위인 MANAGER 사용). status 1=active.
INSERT INTO company.users (company_id, id, password, name, email, contact_number, last_accessed_at, status, role)
SELECT 'a35152d2-db61-4760-9f1e-beb9736d957f',
'admin',
'$2b$12$E.y.XVR.MxmzOMd2satUDuVhvQA4kRsNJu7lNbZA4YZf/UWVcslSy',
'관리자', 'admin@imarketkorea.com', '02-3708-5000', now(), 1, 2
WHERE NOT EXISTS (
SELECT 1 FROM company.users WHERE id = 'admin' AND deleted = FALSE
);
-- ============================================================
-- 협상 카드 시드 (실 적용 카드) — nego_cards 일반 11장 / wild_cards 와일드 5장
-- user_id NULL = o2o 기본 제공 카드. 기본 카드가 한 장도 없을 때만 통째로 삽입(재실행 안전).
-- tone(CardTone): 1=강경 2=정중 3=우호 4=중립 5=단호
-- strategy_type(CardStrategyType): 1=경쟁 2=수용 3=고수 4=협력 5=선점 6=종결
--
-- [사용 변수 목록] (총 9개) * 주체: customer=고객사(발화자,"당사") / partner=협력사(응답자,"귀사")
-- 기본가(DB 존재):
-- {target_price} 목표가격(고객사 지향가) 접미: 원(VAT별도)
-- {anchoring_price} 앵커링가격(고객사 하한) 접미: 원(VAT별도)
-- {internet_lowest_price} 인터넷 최저가 접미: 원(VAT별도)
-- 세션값(협상 중 직전 제시가):
-- {prev_customer_price} 고객사 직전 제시가 접미: 원
-- {prev_partner_price} 협력사 직전 제시가 접미: 원
-- 계산가(기본가·세션값 조합):
-- {target_mid_price} 역제안가 = (anchoring_price + target_price) / 2
-- {middle_price} 절충가 = (prev_customer_price + prev_partner_price) / 2
-- 조건/근거:
-- {customer_condition} 고객사 교환·요구 조건
-- {customer_reference} 고객사 가격 산정 근거
-- ============================================================
-- 일반 카드 (nego_cards)
INSERT INTO card.nego_cards
(user_id, name, number, script, edit_script, usage_type, tone, strategy_type)
SELECT * FROM (VALUES
-- 1. 타사 검토 언급 — tone 1(강경) · strategy 1(경쟁)
(NULL::uuid, '타사 검토 언급', 'NGC-001',
'귀사의 품질과 협력 의지를 높이 평가하고 있어 우선적으로 협의를 진행하고 있습니다.
, .
, .',
'[{"type": "paragraph", "children": [{"text": "귀사의 품질과 협력 의지를 높이 평가하고 있어 우선적으로 협의를 진행하고 있습니다."}]}, {"type": "paragraph", "children": [{"text": "다만 당사는 현재 본 건에 대해 "}, {"text": "복수의 공급 가능 업체와 병행하여 검토를 진행", "bold": true}, {"text": "하고 있으며, 최종 선정을 위해서는 제시해 주신 조건이 타 대안 대비 분명한 경쟁력을 갖추어야 합니다."}]}, {"type": "paragraph", "children": [{"text": "귀사께서 충분히 경쟁력 있는 제안을 주실 수 있으리라 기대하며, 재검토하신 조건을 회신해 주시기 바랍니다."}]}]'::jsonb,
1, 1, 1),
-- 2. 내부 승인 필요 — tone 2(정중) · strategy 1(경쟁)
(NULL::uuid, '내부 승인 필요', 'NGC-002',
'제안해 주신 조건은 잘 검토하였습니다.
, . , .
, .',
'[{"type": "paragraph", "children": [{"text": "제안해 주신 조건은 잘 검토하였습니다."}]}, {"type": "paragraph", "children": [{"text": "다만 해당 수준의 가격은 담당자 선에서 단독으로 확정하기 어려우며, "}, {"text": "내부 상위 결재 절차를 거쳐야 하는 사안", "bold": true}, {"text": "입니다. 솔직히 말씀드리면, 현재 조건으로는 내부 승인을 받기가 쉽지 않을 것으로 예상됩니다."}]}, {"type": "paragraph", "children": [{"text": "당사가 무리 없이 승인을 진행할 수 있는 수준으로 한 번 더 조정해 주신다면, 신속하게 절차를 밟아 협상을 매듭짓겠습니다."}]}]'::jsonb,
1, 2, 1),
-- 3. 장기 관계 강조 — tone 3(우호) · strategy 2(수용)
(NULL::uuid, '장기 관계 강조', 'NGC-003',
'귀사와의 협력을 진심으로 소중하게 생각하고 있습니다.
, . .
, . .',
'[{"type": "paragraph", "children": [{"text": "귀사와의 협력을 진심으로 소중하게 생각하고 있습니다."}]}, {"type": "paragraph", "children": [{"text": "이번 거래가 단발성으로 끝나지 않고, "}, {"text": "앞으로 오랜 기간 함께할 관계의 시작", "bold": true}, {"text": "이 되기를 바랍니다. 그런 의미에서 양사가 모두 만족할 수 있는 합리적인 지점을 함께 찾고자 합니다."}]}, {"type": "paragraph", "children": [{"text": "귀사께서 조금만 더 협조해 주신다면, 당사 역시 장기적인 관점에서 최선을 다해 화답하겠습니다. 좋은 합의를 기대합니다."}]}]'::jsonb,
1, 3, 2),
-- 4. 명분 제공 — tone 2(정중) · strategy 2(수용)
(NULL::uuid, '명분 제공', 'NGC-004',
'그동안 귀사께서 보여주신 협상 태도와 전문성을 높이 평가하고 있습니다.
, . .
.',
'[{"type": "paragraph", "children": [{"text": "그동안 귀사께서 보여주신 협상 태도와 전문성을 높이 평가하고 있습니다."}]}, {"type": "paragraph", "children": [{"text": "이번 조정은 귀사가 일방적으로 양보하는 것이 아니라, "}, {"text": "양사가 장기적 협력을 위해 함께 내린 전략적 결정", "bold": true}, {"text": "으로 이해해 주시면 감사하겠습니다. 당사 역시 이번 합의를 귀사와의 신뢰를 보여주는 기회로 삼겠습니다."}]}, {"type": "paragraph", "children": [{"text": "서로의 입장을 존중하는 선에서 원만하게 마무리할 수 있기를 바랍니다."}]}]'::jsonb,
1, 2, 2),
-- 5. 공정 합의 제안 — tone 4(중립) · strategy 2(수용)
(NULL::uuid, '공정 합의 제안', 'NGC-005',
'당사가 바라는 것은 어느 한쪽에 치우친 거래가 아니라, 양사 모두가 공정하다고 느낄 수 있는 합의입니다.
, 믿. , .
.',
'[{"type": "paragraph", "children": [{"text": "당사가 바라는 것은 어느 한쪽에 치우친 거래가 아니라, "}, {"text": "양사 모두가 공정하다고 느낄 수 있는 합의", "bold": true}, {"text": "입니다."}]}, {"type": "paragraph", "children": [{"text": "당사는 합리적인 근거 위에서 성실하게 조건을 제시해 왔으며, 귀사 역시 같은 자세로 임해 주시리라 믿습니다. 서로가 공정함을 기준으로 한 걸음씩 다가선다면, 양사 모두 납득할 수 있는 결론에 이를 수 있습니다."}]}, {"type": "paragraph", "children": [{"text": "공정한 합의를 향해 귀사의 전향적인 검토를 부탁드립니다."}]}]'::jsonb,
1, 4, 2),
-- 6. 총비용 가치 설명 — tone 4(중립) · strategy 3(고수)
(NULL::uuid, '총비용 가치 설명', 'NGC-006',
'제안 드린 가격에 대해 부담을 느끼시는 점 충분히 이해합니다.
, . , .
.',
'[{"type": "paragraph", "children": [{"text": "제안 드린 가격에 대해 부담을 느끼시는 점 충분히 이해합니다."}]}, {"type": "paragraph", "children": [{"text": "다만 당사의 제안 가격은 단순한 단가가 아니라 "}, {"text": "안정적인 품질과 납기 준수, 사후 지원까지 포함한 총비용 관점", "bold": true}, {"text": "에서 산정된 것입니다. 초기 단가만을 기준으로 비교할 경우, 운영 과정에서 발생할 수 있는 추가 비용이나 리스크가 충분히 반영되지 않을 수 있습니다."}]}, {"type": "paragraph", "children": [{"text": "가격 자체보다 귀사께서 얻으실 전체적인 가치를 함께 고려해 주시기를 부탁드립니다."}]}]'::jsonb,
1, 4, 3),
-- 7. 예산 상한 안내 — tone 2(정중) · strategy 3(고수)
(NULL::uuid, '예산 상한 안내', 'NGC-007',
'귀사와의 합의를 진심으로 바라고 있습니다.
{anchoring_price}(VAT별도) . .
.',
'[{"type": "paragraph", "children": [{"text": "귀사와의 합의를 진심으로 바라고 있습니다."}]}, {"type": "paragraph", "children": [{"text": "다만 당사 내부 예산 정책상 본 건에 책정 가능한 금액은 "}, {"type": "variable", "name": "anchoring_price", "label": "앵커링가격(고객사 하한)", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": "이 한계입니다.", "bold": true}, {"text": " 이는 개인의 재량을 넘어선 내부 기준에 해당하여 조정이 어려운 부분이니 너른 양해를 부탁드립니다."}]}, {"type": "paragraph", "children": [{"text": "해당 범위 내에서 귀사가 수용 가능한 지점을 함께 찾을 수 있기를 바랍니다."}]}]'::jsonb,
1, 2, 3),
-- 8. 시장가 근거 제시 — tone 4(중립) · strategy 3(고수)
(NULL::uuid, '시장가 근거 제시', 'NGC-008',
'당사의 제안 가격이 시장에서 동떨어진 수준이 아님을 함께 확인해 보고자 합니다.
, {internet_lowest_price}(VAT별도) . .
, .',
'[{"type": "paragraph", "children": [{"text": "당사의 제안 가격이 시장에서 동떨어진 수준이 아님을 함께 확인해 보고자 합니다."}]}, {"type": "paragraph", "children": [{"text": "인터넷 시장 조사 결과, 동종 업계의 유사 거래에서도 본 건과 비슷한 사양은 "}, {"type": "variable", "name": "internet_lowest_price", "label": "인터넷 최저가", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": " 안팎에서 합의되고 있습니다. 당사의 제안은 이러한 "}, {"text": "시장의 일반적인 수준을 충실히 반영", "bold": true}, {"text": "한 것입니다."}]}, {"type": "paragraph", "children": [{"text": "특정 업체만의 기준이 아니라 업계 전반의 관행에 근거한 가격인 만큼, 합리적으로 검토해 주시기를 부탁드립니다."}]}]'::jsonb,
1, 4, 3),
-- 9. 조건부 가격 조정 — tone 4(중립) · strategy 4(협력)
(NULL::uuid, '조건부 가격 조정', 'NGC-009',
'귀사의 입장을 고려하여 당사가 한 걸음 더 나아가고자 합니다.
{customer_condition} , {target_price}(VAT별도) .
, . .',
'[{"type": "paragraph", "children": [{"text": "귀사의 입장을 고려하여 당사가 한 걸음 더 나아가고자 합니다."}]}, {"type": "paragraph", "children": [{"text": "귀사께서 "}, {"type": "variable", "name": "customer_condition", "label": "고객사 교환·요구 조건", "children": [{"text": ""}], "style": {"bold": true}}, {"text": "에 동의해 주신다면, 당사는 가격을 "}, {"type": "variable", "name": "target_price", "label": "목표가격(고객사 지향가)", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": "까지 조정하겠습니다.", "bold": true}]}, {"type": "paragraph", "children": [{"text": "당사가 양보하는 만큼 귀사께서도 의미 있는 조건으로 화답해 주신다면, 양사 모두 만족할 수 있는 합의가 될 것입니다. 위 전제에 동의하시는지 확인 부탁드립니다."}]}]'::jsonb,
1, 4, 4),
-- 10. 향후 거래 연계 — tone 4(중립) · strategy 4(협력)
(NULL::uuid, '향후 거래 연계', 'NGC-010',
'이번 거래의 가격을 {target_price}원(VAT별도)으로 조정하는 대신, {customer_condition}을 함께 검토해 주실 것을 제안 드립니다.
, . , .',
'[{"type": "paragraph", "children": [{"text": "이번 거래의 가격을 "}, {"type": "variable", "name": "target_price", "label": "목표가격(고객사 지향가)", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": "으로 조정하는 대신, "}, {"type": "variable", "name": "customer_condition", "label": "고객사 교환·요구 조건", "children": [{"text": ""}], "style": {"bold": true}}, {"text": "을 함께 검토해 주실 것을 제안 드립니다."}]}, {"type": "paragraph", "children": [{"text": "당장의 단가 한 건만 보기보다 향후 이어질 거래까지 함께 고려한다면, 양사 모두에게 더 큰 가치를 만들 수 있습니다. 이번 합의를 장기적 관계의 출발점으로 삼아, 서로에게 이익이 되는 구조를 함께 설계하기를 바랍니다."}]}]'::jsonb,
1, 4, 4),
-- 11. 양보 가치 강조 — tone 4(중립) · strategy 4(협력)
(NULL::uuid, '양보 가치 강조', 'NGC-011',
'이번 조정은 당사에 결코 작은 일이 아님을 먼저 말씀드리고 싶습니다.
{target_price}(VAT별도) , . .
, .',
'[{"type": "paragraph", "children": [{"text": "이번 조정은 당사에 결코 작은 일이 아님을 먼저 말씀드리고 싶습니다."}]}, {"type": "paragraph", "children": [{"type": "variable", "name": "target_price", "label": "목표가격(고객사 지향가)", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": "으로의 조정은 당사 수익 구조상 상당한 부담을 감수한 결정", "bold": true}, {"text": "이며, 내부적으로도 쉽지 않은 승인 과정을 거쳤습니다. 이는 곧 귀사께서 그만큼 실질적인 혜택을 얻으시게 된다는 의미이기도 합니다."}]}, {"type": "paragraph", "children": [{"text": "당사가 감수한 만큼, 귀사께서도 이에 상응하는 조건으로 화답해 주시기를 정중히 요청 드립니다."}]}]'::jsonb,
1, 4, 4)
) AS v(user_id, name, number, script, edit_script, usage_type, tone, strategy_type)
WHERE NOT EXISTS (SELECT 1 FROM card.nego_cards WHERE user_id IS NULL AND deleted = FALSE);
-- 와일드 카드 (wild_cards) - condition NULL, available TRUE
INSERT INTO card.wild_cards
(user_id, name, number, script, edit_script, usage_type, condition, available, memo, tone, strategy_type)
SELECT * FROM (VALUES
-- 1. 목표가 선제안 — tone 5(단호) · strategy 5(선점)
(NULL::uuid, '목표가 선제안', 'WC-01',
'안녕하십니까. 금번 협상에 참여해 주셔서 감사합니다.
{customer_reference}() , {target_price}(VAT별도) .
, . .',
'[{"type": "paragraph", "children": [{"text": "안녕하십니까. 금번 협상에 참여해 주셔서 감사합니다."}]}, {"type": "paragraph", "children": [{"text": "당사는 "}, {"type": "variable", "name": "customer_reference", "label": "고객사 가격 산정 근거", "children": [{"text": ""}], "style": {"bold": true}}, {"text": "을(를) 종합적으로 검토하여 합리적인 목표 가격을 산정하였으며, 이에 "}, {"type": "variable", "name": "target_price", "label": "목표가격(고객사 지향가)", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": "을 제안 드립니다.", "bold": true}]}, {"type": "paragraph", "children": [{"text": "본 제안은 명확한 산정 기준에 근거한 것으로, 귀사께서도 이를 바탕으로 건설적인 협의가 가능할 것으로 기대합니다. 검토 후 의견 주시기 바랍니다."}]}]'::jsonb,
1, NULL::varchar, TRUE, NULL::varchar, 5, 5),
-- 2. 역제안가 제시 — tone 5(단호) · strategy 5(선점)
(NULL::uuid, '역제안가 제시', 'WC-02',
'제안해 주신 {prev_partner_price}원은 당사가 검토한 기준 대비 다소 높은 수준으로 판단됩니다.
{target_mid_price}(VAT별도) . , .
, .',
'[{"type": "paragraph", "children": [{"text": "제안해 주신 "}, {"type": "variable", "name": "prev_partner_price", "label": "협력사 직전 제시가", "children": [{"text": ""}], "suffix": "원", "style": {"bold": true, "color": "red"}}, {"text": "은 당사가 검토한 기준 대비 다소 높은 수준으로 판단됩니다."}]}, {"type": "paragraph", "children": [{"text": "이에 당사는 "}, {"type": "variable", "name": "target_mid_price", "label": "역제안가(앵커·목표 중간)", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": "을 역으로 제안 드립니다.", "bold": true}, {"text": " 양측 제안 사이에서 합리적인 접점을 찾되, 그 기준은 명확한 산정 근거에 두는 것이 바람직하다고 봅니다."}]}, {"type": "paragraph", "children": [{"text": "귀사의 제안 근거도 함께 공유해 주시면, 보다 빠르게 합의 가능한 구간을 좁혀갈 수 있겠습니다."}]}]'::jsonb,
1, NULL::varchar, TRUE, NULL::varchar, 5, 5),
-- 3. 최종 통보 — tone 1(강경) · strategy 1(경쟁)
(NULL::uuid, '최종 통보', 'WC-03',
'합리적인 기준에 근거하여 목표 가격을 제안 드렸으나, 귀사의 기존 제안 가격으로는 긍정적인 합의가 어려울 것으로 예상됩니다.
, .
. .',
'[{"type": "paragraph", "children": [{"text": "합리적인 기준에 근거하여 목표 가격을 제안 드렸으나, 귀사의 기존 제안 가격으로는 긍정적인 합의가 어려울 것으로 예상됩니다."}]}, {"type": "paragraph", "children": [{"text": "이번 협상이 결렬되는 경우 "}, {"text": "우선 협상권을 보장하기 어려우며, 다른 공급 업체를 선정하기 위한 검토가 진행될 수 있습니다.", "bold": true}]}, {"type": "paragraph", "children": [{"text": "귀사와 앞으로 보다 많은 협력 기회를 만들어 나가기를 희망합니다. 다시 한번 고민하신 후 제안 가격을 입력해 주시기 바랍니다."}]}]'::jsonb,
1, NULL::varchar, TRUE, NULL::varchar, 1, 1),
-- 4. 단계적 인하 제안 — tone 1(강경) · strategy 1(경쟁)
(NULL::uuid, '단계적 인하 제안', 'WC-04',
'당초 당사가 검토한 적정가는 {anchoring_price}원(VAT별도) 수준이었습니다. 다만 귀사의 입장과 시장 상황을 함께 고려하여, 당사가 한발 물러서고자 합니다.
{target_price}(VAT별도) . , .',
'[{"type": "paragraph", "children": [{"text": "당초 당사가 검토한 적정가는 "}, {"type": "variable", "name": "anchoring_price", "label": "앵커링가격(고객사 하한)", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": " 수준이었습니다. 다만 귀사의 입장과 시장 상황을 함께 고려하여, 당사가 한발 물러서고자 합니다."}]}, {"type": "paragraph", "children": [{"text": "이에 "}, {"type": "variable", "name": "target_price", "label": "목표가격(고객사 지향가)", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": "으로 조정하여 제안 드립니다.", "bold": true}, {"text": " 이는 당초 기준 대비 당사가 상당 부분 양보한 금액이니, 귀사께서도 이 점을 감안하여 긍정적으로 검토해 주시기를 부탁드립니다."}]}]'::jsonb,
1, NULL::varchar, TRUE, NULL::varchar, 1, 1),
-- 5. 중간값 절충 — tone 4(중립) · strategy 6(종결)
(NULL::uuid, '중간값 절충', 'WC-05',
'긴 협의 끝에 양측의 입장이 상당히 가까워졌습니다.
{prev_customer_price} {prev_partner_price} , {middle_price}(VAT별도) .
. .',
'[{"type": "paragraph", "children": [{"text": "긴 협의 끝에 양측의 입장이 상당히 가까워졌습니다."}]}, {"type": "paragraph", "children": [{"text": "현재 당사 제안 "}, {"type": "variable", "name": "prev_customer_price", "label": "고객사 직전 제시가", "children": [{"text": ""}], "suffix": "원", "style": {"bold": true, "color": "red"}}, {"text": "과 귀사 제안 "}, {"type": "variable", "name": "prev_partner_price", "label": "협력사 직전 제시가", "children": [{"text": ""}], "suffix": "원", "style": {"bold": true, "color": "red"}}, {"text": " 사이의 차이를 양사가 절반씩 나누어, "}, {"type": "variable", "name": "middle_price", "label": "절충가(양측 중간)", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": "으로 마무리할 것을 제안 드립니다.", "bold": true}]}, {"type": "paragraph", "children": [{"text": "어느 한쪽이 일방적으로 양보하기보다 균형 있게 합의하는 것이 앞으로의 협력 관계를 위해서도 바람직하다고 생각합니다. 동의하신다면 본 금액으로 최종 확정하겠습니다."}]}]'::jsonb,
1, NULL::varchar, TRUE, NULL::varchar, 4, 6)
) AS v(user_id, name, number, script, edit_script, usage_type, condition, available, memo, tone, strategy_type)
WHERE NOT EXISTS (SELECT 1 FROM card.wild_cards WHERE user_id IS NULL AND deleted = FALSE);

View File

@ -0,0 +1,86 @@
ALTER DATABASE lps_db SET timezone TO 'UTC';
SET timezone TO 'UTC';
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid()
-- 작업 큐(PostgreSQL 을 큐로: 원자적 CAS claim + lease 소유권 + dead-letter)
CREATE TABLE IF NOT EXISTS job (
job_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
job_type SMALLINT NOT NULL, -- JobType
status SMALLINT NOT NULL DEFAULT 1, -- JobStatus (1=PENDING)
priority SMALLINT NOT NULL DEFAULT 100, -- 낮을수록 우선
payload JSONB NOT NULL DEFAULT '{}'::jsonb, -- 잡 입력
result JSONB NULL, -- 잡 출력(완료 시)
dedupe_key VARCHAR(200) NULL, -- 활성 중복 방지 키(부분 유니크)
attempts SMALLINT NOT NULL DEFAULT 0, -- 시도 횟수(claim 시 +1)
max_attempts SMALLINT NOT NULL DEFAULT 3,
run_after TIMESTAMPTZ NOT NULL DEFAULT now(), -- 이 시각 이후에만 claim(백오프)
lease_until TIMESTAMPTZ NULL, -- 소유권 임대 만료(reaper 회수 기준)
worker_id VARCHAR(80) NULL, -- 현재 점유 워커
run_started_at TIMESTAMPTZ NULL, -- RUNNING 진입 시각
last_error TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -- onupdate 는 앱(ORM)에서 갱신
);
CREATE INDEX IF NOT EXISTS ix_job_claim ON job (status, run_after, priority, created_at);
CREATE INDEX IF NOT EXISTS ix_job_lease ON job (status, lease_until);
-- 활성 중복 방지: 같은 dedupe_key 는 PENDING/RUNNING(1,2) 중 하나만 존재 가능
CREATE UNIQUE INDEX IF NOT EXISTS uq_job_dedupe_active
ON job (dedupe_key) WHERE status IN (1, 2) AND dedupe_key IS NOT NULL;
-- 네거티브 캐시 — '검색해도 없더라'를 TTL 동안 기억(재검색 낭비 방지)
CREATE TABLE IF NOT EXISTS search_negative (
key VARCHAR(300) PRIMARY KEY, -- 보통 product_code(없으면 query)
until TIMESTAMPTZ NOT NULL, -- 이 시각까지 not_found 로 간주
reason VARCHAR(200) NULL, -- 종료 사유 메모(관측)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 상품별 최저가 스냅샷(트리거 기반) — 시계열 그래프용
CREATE TABLE IF NOT EXISTS price_history (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
product_code VARCHAR(100) NOT NULL, -- 상품 식별(조회 키)
job_id uuid NULL, -- 검색 잡 연결(추적)
triggered_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- X축(검색 실행 시각)
outcome VARCHAR(20) NOT NULL, -- found / not_found
matched_count INTEGER NULL, -- AI 매칭 건수
naver_lowest INTEGER NULL,
naver_name VARCHAR(300) NULL,
naver_url TEXT NULL,
coupang_lowest INTEGER NULL,
coupang_name VARCHAR(300) NULL,
coupang_url TEXT NULL,
final_lowest INTEGER NULL, -- 전체 최저가(Y축 핵심)
final_source VARCHAR(20) NULL, -- 최종 최저가 소스
by_mall JSONB NULL, -- 몰별 최저가 스냅샷(열린 스키마)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_price_history_product ON price_history (product_code, triggered_at);
-- IP(프록시 포트) 세션 종료 이력 — 요청 예산(LPS_IP_REQUEST_BUDGET) 상한 튜닝의 원천 데이터
CREATE TABLE IF NOT EXISTS ip_session (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
source VARCHAR(20) NOT NULL, -- coupang 등
proxy_port INTEGER NULL, -- 사용 포트(=IP 세션), 프록시 미사용이면 NULL
requests INTEGER NOT NULL, -- 이 IP 로 보낸 요청 수
ok_count INTEGER NOT NULL DEFAULT 0, -- 성공 검색 수
blocked_count INTEGER NOT NULL DEFAULT 0, -- 차단 감지 수
elapsed_sec INTEGER NULL, -- 세션 지속 시간(초)
end_reason VARCHAR(20) NOT NULL, -- budget/block/proxy_error/window/idle/shutdown/rotate
created_at TIMESTAMPTZ NOT NULL DEFAULT now() -- 세션 종료 시각
);
CREATE INDEX IF NOT EXISTS ix_ip_session_source ON ip_session (source, created_at);
-- 봇 감지 이력 — IP·요청순번·차단방식 축적(패턴 분석)
CREATE TABLE IF NOT EXISTS bot_detection (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
source VARCHAR(20) NOT NULL, -- coupang 등
query VARCHAR(300) NULL, -- 감지 당시 검색어
ip_request_no INTEGER NULL, -- 현재 IP로 몇 번째 요청이었나
proxy_port INTEGER NULL, -- 사용 중이던 프록시 포트(=IP 세션)
elapsed_sec INTEGER NULL, -- 브라우저 실행 후 경과(초)
marker VARCHAR(120) NULL, -- 감지 근거(차단 페이지 마커)
headless BOOLEAN NULL,
html_len INTEGER NULL, -- 응답 길이(차단 페이지는 작음)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

View File

@ -0,0 +1,171 @@
-- 기본 제공 카드 리셋 — negosium_db 에 연결해서 실행
-- 기본카드(user_id NULL)를 지우고 IMK 수정본(엑셀 260716 반영)을 다시 넣는다. 회사 개인 카드는 건드리지 않음.
DELETE FROM card.nego_cards WHERE user_id IS NULL;
DELETE FROM card.wild_cards WHERE user_id IS NULL;
-- 협상 카드 시드 (실 적용 카드) — nego_cards 일반 11장 / wild_cards 와일드 5장
-- user_id NULL = o2o 기본 제공 카드. 기본 카드가 한 장도 없을 때만 통째로 삽입(재실행 안전).
-- tone(CardTone): 1=강경 2=정중 3=우호 4=중립 5=단호
-- strategy_type(CardStrategyType): 1=경쟁 2=수용 3=고수 4=협력 5=선점 6=종결
--
-- [사용 변수 목록] (총 9개) * 주체: customer=고객사(발화자,"당사") / partner=협력사(응답자,"귀사")
-- 기본가(DB 존재):
-- {target_price} 목표가격(고객사 지향가) 접미: 원(VAT별도)
-- {anchoring_price} 앵커링가격(고객사 하한) 접미: 원(VAT별도)
-- {internet_lowest_price} 인터넷 최저가 접미: 원(VAT별도)
-- 세션값(협상 중 직전 제시가):
-- {prev_customer_price} 고객사 직전 제시가 접미: 원
-- {prev_partner_price} 협력사 직전 제시가 접미: 원
-- 계산가(기본가·세션값 조합):
-- {target_mid_price} 역제안가 = (anchoring_price + target_price) / 2
-- {middle_price} 절충가 = (prev_customer_price + prev_partner_price) / 2
-- 조건/근거:
-- {customer_condition} 고객사 교환·요구 조건 (조건 전략 내용이 있을 때만 카드 선택 가능 — 미작성 시 토큰 잔존)
-- ============================================================
-- 일반 카드 (nego_cards)
INSERT INTO card.nego_cards
(user_id, name, number, script, edit_script, usage_type, tone, strategy_type)
SELECT * FROM (VALUES
-- 1. 타사 검토 언급 — tone 1(강경) · strategy 1(경쟁)
(NULL::uuid, '타사 검토 언급', 'NGC-001',
'귀사의 품질과 협력 의지를 높이 평가하고 있어 우선적으로 협의를 진행하고 있습니다.
, .
, .',
'[{"type": "paragraph", "children": [{"text": "귀사의 품질과 협력 의지를 높이 평가하고 있어 우선적으로 협의를 진행하고 있습니다."}]}, {"type": "paragraph", "children": [{"text": "다만 당사는 현재 본 건에 대해 "}, {"text": "복수의 공급 가능 업체와 병행하여 검토를 진행", "bold": true}, {"text": "하고 있으며, 최종 선정을 위해서는 제시해 주신 조건이 타 대안 대비 분명한 경쟁력을 갖추어야 합니다."}]}, {"type": "paragraph", "children": [{"text": "귀사께서 충분히 경쟁력 있는 제안을 주실 수 있으리라 기대하며, 재검토하신 조건을 회신해 주시기 바랍니다."}]}]'::jsonb,
1, 1, 1),
-- 2. 내부 승인 필요 — tone 2(정중) · strategy 1(경쟁)
(NULL::uuid, '내부 승인 필요', 'NGC-002',
'제안해 주신 조건은 잘 검토하였습니다.
, . , .
, .',
'[{"type": "paragraph", "children": [{"text": "제안해 주신 조건은 잘 검토하였습니다."}]}, {"type": "paragraph", "children": [{"text": "다만 해당 수준의 가격은 담당자 선에서 단독으로 확정하기 어려우며, "}, {"text": "내부 상위 결재 절차를 거쳐야 하는 사안", "bold": true}, {"text": "입니다. 솔직히 말씀드리면, 현재 조건으로는 내부 승인을 받기가 쉽지 않을 것으로 예상됩니다."}]}, {"type": "paragraph", "children": [{"text": "당사가 무리 없이 승인을 진행할 수 있는 수준으로 한 번 더 조정해 주신다면, 신속하게 절차를 밟아 협상을 매듭짓겠습니다."}]}]'::jsonb,
1, 2, 1),
-- 3. 장기 관계 강조 — tone 3(우호) · strategy 2(수용)
(NULL::uuid, '장기 관계 강조', 'NGC-003',
'귀사와의 협력을 진심으로 소중하게 생각하고 있습니다.
, . .
, . .',
'[{"type": "paragraph", "children": [{"text": "귀사와의 협력을 진심으로 소중하게 생각하고 있습니다."}]}, {"type": "paragraph", "children": [{"text": "이번 거래가 단발성으로 끝나지 않고, "}, {"text": "앞으로도 오랜 기간 협력하는 관계", "bold": true}, {"text": "가 되기를 바랍니다. 그런 의미에서 양사가 모두 만족할 수 있는 합리적인 지점을 함께 찾고자 합니다."}]}, {"type": "paragraph", "children": [{"text": "귀사께서 조금만 더 협조해 주신다면, 당사 역시 장기적인 관점에서 최선을 다해 화답하겠습니다. 좋은 합의를 기대합니다."}]}]'::jsonb,
1, 3, 2),
-- 4. 명분 제공 — tone 2(정중) · strategy 2(수용)
(NULL::uuid, '명분 제공', 'NGC-004',
'그동안 귀사께서 제공해주신 전문성과 경쟁력에 감사드립니다.
, . .
.',
'[{"type": "paragraph", "children": [{"text": "그동안 귀사께서 제공해주신 전문성과 경쟁력에 감사드립니다."}]}, {"type": "paragraph", "children": [{"text": "이번 합의는 귀사가 일방적으로 양보하는 것이 아니라, "}, {"text": "양사가 장기적 협력을 위해 함께 내린 전략적 결정", "bold": true}, {"text": "으로 이해해 주시면 감사하겠습니다. 당사 역시 이번 합의를 귀사와의 신뢰를 보여주는 기회로 삼겠습니다."}]}, {"type": "paragraph", "children": [{"text": "서로의 입장을 존중하는 선에서 원만하게 마무리할 수 있기를 바랍니다."}]}]'::jsonb,
1, 2, 2),
-- 5. 공정 합의 제안 — tone 4(중립) · strategy 2(수용)
(NULL::uuid, '공정 합의 제안', 'NGC-005',
'당사가 바라는 것은 어느 한쪽에 치우친 거래가 아니라, 양사 모두가 공정하다고 느낄 수 있는 합의입니다.
, 믿. , .
.',
'[{"type": "paragraph", "children": [{"text": "당사가 바라는 것은 어느 한쪽에 치우친 거래가 아니라, "}, {"text": "양사 모두가 공정하다고 느낄 수 있는 합의", "bold": true}, {"text": "입니다."}]}, {"type": "paragraph", "children": [{"text": "당사는 합리적인 근거 위에서 성실하게 조건을 제시해 왔으며, 귀사 역시 같은 자세로 임해 주시리라 믿습니다. 서로가 공정함을 기준으로 한 걸음씩 다가선다면, 양사 모두 납득할 수 있는 결론에 이를 수 있습니다."}]}, {"type": "paragraph", "children": [{"text": "공정한 합의를 향해 귀사의 전향적인 검토를 부탁드립니다."}]}]'::jsonb,
1, 4, 2),
-- 7. 예산 상한 안내 — tone 2(정중) · strategy 3(고수)
(NULL::uuid, '예산 상한 안내', 'NGC-007',
'귀사와의 합의를 진심으로 바라고 있습니다.
{anchoring_price}(VAT별도) . .
.',
'[{"type": "paragraph", "children": [{"text": "귀사와의 합의를 진심으로 바라고 있습니다."}]}, {"type": "paragraph", "children": [{"text": "다만 당사 내부 사정상 본 건에 희망 가능한 금액은 "}, {"type": "variable", "name": "anchoring_price", "label": "앵커링가격(고객사 하한)", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": "이 한계입니다.", "bold": true}, {"text": " 이는 담당자 개인의 재량을 넘어선것으로 너른 양해를 부탁드립니다."}]}, {"type": "paragraph", "children": [{"text": "해당 범위 내에서 귀사가 수용 가능한 지점을 함께 찾을 수 있기를 바랍니다."}]}]'::jsonb,
1, 2, 3),
-- 8. 시장가 근거 제시 — tone 4(중립) · strategy 3(고수)
(NULL::uuid, '시장가 근거 제시', 'NGC-008',
'인터넷 시장 조사 결과, 동종 업계의 유사 거래에서도 본 건과 비슷한 사양은 {internet_lowest_price}원(VAT별도) 안팎에서 거래되고 있습니다.
, .',
'[{"type": "paragraph", "children": [{"text": "인터넷 시장 조사 결과, 동종 업계의 유사 거래에서도 본 건과 비슷한 사양은 "}, {"type": "variable", "name": "internet_lowest_price", "label": "인터넷 최저가", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": " 안팎에서 거래되고 있습니다."}]}, {"type": "paragraph", "children": [{"text": "특정 업체만의 기준이 아니라 업계 전반의 관행에 근거한 가격인 만큼, 합리적으로 검토해 주시기를 부탁드립니다."}]}]'::jsonb,
1, 4, 3),
-- 10. 향후 거래 연계 — tone 4(중립) · strategy 4(협력)
(NULL::uuid, '향후 거래 연계', 'NGC-010',
'이번 거래의 가격을 {target_price}원(VAT별도)으로 조정하는 대신, {customer_condition}을 함께 검토해 주실 것을 제안 드립니다.
, . , .',
'[{"type": "paragraph", "children": [{"text": "이번 거래의 가격을 "}, {"type": "variable", "name": "target_price", "label": "목표가격(고객사 지향가)", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": "으로 조정하는 대신, "}, {"type": "variable", "name": "customer_condition", "label": "고객사 교환·요구 조건", "children": [{"text": ""}], "style": {"bold": true}}, {"text": "을 함께 검토해 주실 것을 제안 드립니다."}]}, {"type": "paragraph", "children": [{"text": "당장의 단가 한 건만 보기보다 향후 이어질 거래까지 함께 고려한다면, 양사 모두에게 더 큰 가치를 만들 수 있습니다. 이번 합의를 장기적 관계의 출발점으로 삼아, 서로에게 이익이 되는 구조를 함께 설계하기를 바랍니다."}]}]'::jsonb,
1, 4, 4),
-- 11. 양보 가치 강조 — tone 4(중립) · strategy 4(협력)
(NULL::uuid, '양보 가치 강조', 'NGC-011',
'이번 조정은 당사에 결코 작은 일이 아님을 먼저 말씀드리고 싶습니다.
{target_price}(VAT별도) , . .
, .',
'[{"type": "paragraph", "children": [{"text": "이번 조정은 당사에 결코 작은 일이 아님을 먼저 말씀드리고 싶습니다."}]}, {"type": "paragraph", "children": [{"type": "variable", "name": "target_price", "label": "목표가격(고객사 지향가)", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": "으로의 조정은 당사 수익 구조상 상당한 부담을 감수한 결정", "bold": true}, {"text": "이며, 내부적으로도 쉽지 않은 승인 과정을 거쳤습니다. 이는 곧 귀사께서 그만큼 실질적인 혜택을 얻으시게 된다는 의미이기도 합니다."}]}, {"type": "paragraph", "children": [{"text": "당사가 감수한 만큼, 귀사께서도 이에 상응하는 조건으로 화답해 주시기를 정중히 요청 드립니다."}]}]'::jsonb,
1, 4, 4)
) AS v(user_id, name, number, script, edit_script, usage_type, tone, strategy_type)
WHERE NOT EXISTS (SELECT 1 FROM card.nego_cards WHERE user_id IS NULL AND deleted = FALSE);
-- 와일드 카드 (wild_cards) - condition NULL, available TRUE
INSERT INTO card.wild_cards
(user_id, name, number, script, edit_script, usage_type, condition, available, memo, tone, strategy_type)
SELECT * FROM (VALUES
-- 1. 목표가 선제안 — tone 5(단호) · strategy 5(선점)
(NULL::uuid, '목표가 선제안', 'WC-01',
'안녕하십니까. 금번 협상에 참여해 주셔서 감사합니다.
, {target_price}(VAT별도) .
, . .',
'[{"type": "paragraph", "children": [{"text": "안녕하십니까. 금번 협상에 참여해 주셔서 감사합니다."}]}, {"type": "paragraph", "children": [{"text": "당사는 "}, {"text": "시장 상황과 거래 조건", "bold": true}, {"text": "을 종합적으로 검토하여 합리적인 목표 가격을 산정하였으며, 이에 "}, {"type": "variable", "name": "target_price", "label": "목표가격(고객사 지향가)", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": "을 제안 드립니다.", "bold": true}]}, {"type": "paragraph", "children": [{"text": "본 제안은 명확한 산정 기준에 근거한 것으로, 귀사께서도 이를 바탕으로 건설적인 협의가 가능할 것으로 기대합니다. 검토 후 의견 주시기 바랍니다."}]}]'::jsonb,
1, NULL::varchar, TRUE, NULL::varchar, 5, 5),
-- 2. 역제안가 제시 — tone 5(단호) · strategy 5(선점)
(NULL::uuid, '역제안가 제시', 'WC-02',
'제안해 주신 {prev_partner_price}원은 당사가 검토한 기준 대비 다소 높은 수준으로 판단됩니다.
{target_mid_price}(VAT별도) . , .
, .',
'[{"type": "paragraph", "children": [{"text": "제안해 주신 "}, {"type": "variable", "name": "prev_partner_price", "label": "협력사 직전 제시가", "children": [{"text": ""}], "suffix": "원", "style": {"bold": true, "color": "red"}}, {"text": "은 당사가 검토한 기준 대비 다소 높은 수준으로 판단됩니다."}]}, {"type": "paragraph", "children": [{"text": "이에 당사는 "}, {"type": "variable", "name": "target_mid_price", "label": "역제안가(앵커·목표 중간)", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": "을 역으로 제안 드립니다.", "bold": true}, {"text": " 양측 제안 사이에서 합리적인 접점을 찾되, 그 기준은 명확한 산정 근거에 두는 것이 바람직하다고 봅니다."}]}, {"type": "paragraph", "children": [{"text": "귀사의 제안 근거도 함께 공유해 주시면, 보다 빠르게 합의 가능한 구간을 좁혀갈 수 있겠습니다."}]}]'::jsonb,
1, NULL::varchar, TRUE, NULL::varchar, 5, 5),
-- 3. 최종 통보 — tone 1(강경) · strategy 1(경쟁)
(NULL::uuid, '최종 통보', 'WC-03',
'합리적인 기준에 근거하여 당사의 최종 목표 가격 {target_price}원(VAT별도)을 제안 드립니다. 귀사의 기존 제안 가격으로는 긍정적인 합의가 어려울 것으로 예상됩니다.
, .
, .',
'[{"type": "paragraph", "children": [{"text": "합리적인 기준에 근거하여 당사의 최종 목표 가격 "}, {"type": "variable", "name": "target_price", "label": "목표가격(고객사 지향가)", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": "을 제안 드립니다. 귀사의 기존 제안 가격으로는 긍정적인 합의가 어려울 것으로 예상됩니다."}]}, {"type": "paragraph", "children": [{"text": "이번 협상이 결렬되는 경우 "}, {"text": "우선 협상권을 보장하기 어려우며, 다른 공급 업체를 선정하기 위한 검토가 진행될 수 있습니다.", "bold": true}]}, {"type": "paragraph", "children": [{"text": "본 금액을 수락하시면 해당 가격으로 최종 확정되며, 재검토가 필요하시면 다른 제안 가격을 입력해 주시기 바랍니다."}]}]'::jsonb,
1, NULL::varchar, TRUE, NULL::varchar, 1, 1),
-- 4. 단계적 인하 제안 — tone 1(강경) · strategy 1(경쟁)
(NULL::uuid, '단계적 인하 제안', 'WC-04',
'당초 당사가 검토한 적정가는 {anchoring_price}원(VAT별도) 수준이었습니다. 다만 귀사의 입장과 시장 상황을 함께 고려하여, 당사가 한발 물러서고자 합니다.
{target_price}(VAT별도) . , .',
'[{"type": "paragraph", "children": [{"text": "당초 당사가 검토한 적정가는 "}, {"type": "variable", "name": "anchoring_price", "label": "앵커링가격(고객사 하한)", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": " 수준이었습니다. 다만 귀사의 입장과 시장 상황을 함께 고려하여, 당사가 한발 물러서고자 합니다."}]}, {"type": "paragraph", "children": [{"text": "이에 "}, {"type": "variable", "name": "target_price", "label": "목표가격(고객사 지향가)", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": "으로 조정하여 제안 드립니다.", "bold": true}, {"text": " 이는 당초 기준 대비 당사가 상당 부분 양보한 금액이니, 귀사께서도 이 점을 감안하여 긍정적으로 검토해 주시기를 부탁드립니다."}]}]'::jsonb,
1, NULL::varchar, TRUE, NULL::varchar, 1, 1),
-- 5. 중간값 절충 — tone 4(중립) · strategy 6(종결)
(NULL::uuid, '중간값 절충', 'WC-05',
'긴 협의 끝에 양측의 입장이 상당히 가까워졌습니다.
{prev_customer_price} {prev_partner_price} , {middle_price}(VAT별도) .
. .',
'[{"type": "paragraph", "children": [{"text": "긴 협의 끝에 양측의 입장이 상당히 가까워졌습니다."}]}, {"type": "paragraph", "children": [{"text": "현재 당사 제안 "}, {"type": "variable", "name": "prev_customer_price", "label": "고객사 직전 제시가", "children": [{"text": ""}], "suffix": "원", "style": {"bold": true, "color": "red"}}, {"text": "과 귀사 제안 "}, {"type": "variable", "name": "prev_partner_price", "label": "협력사 직전 제시가", "children": [{"text": ""}], "suffix": "원", "style": {"bold": true, "color": "red"}}, {"text": " 사이의 차이를 양사가 절반씩 나누어, "}, {"type": "variable", "name": "middle_price", "label": "절충가(양측 중간)", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": "으로 마무리할 것을 제안 드립니다.", "bold": true}]}, {"type": "paragraph", "children": [{"text": "어느 한쪽이 일방적으로 양보하기보다 균형 있게 합의하는 것이 앞으로의 협력 관계를 위해서도 바람직하다고 생각합니다. 동의하신다면 본 금액으로 최종 확정하겠습니다."}]}]'::jsonb,
1, NULL::varchar, TRUE, NULL::varchar, 4, 6)
) AS v(user_id, name, number, script, edit_script, usage_type, condition, available, memo, tone, strategy_type)
WHERE NOT EXISTS (SELECT 1 FROM card.wild_cards WHERE user_id IS NULL AND deleted = FALSE);

View File

@ -0,0 +1,19 @@
-- o2o 회사 + OWNER 유저 시드 — negosium_db 에 연결해서 실행. 재실행 안전(WHERE NOT EXISTS).
-- 로그인: id=o2o / pw=o2o1234 (bcrypt 해시, 백엔드 checkpw 로 검증). role 2=OWNER(최고관리자).
-- 회사 (고정 UUID — users.company_id FK 가 참조). status 1=active.
INSERT INTO company.companies (company_id, name, business_number, representative_name, email, status)
SELECT '1af1b3d2-8bb6-42bb-b8b7-c6f470fc03dc', 'o2o', NULL, NULL, 'admin@o2o.kr', 1
WHERE NOT EXISTS (
SELECT 1 FROM company.companies WHERE company_id = '1af1b3d2-8bb6-42bb-b8b7-c6f470fc03dc'
);
-- OWNER 유저. password 는 'o2o1234' 의 bcrypt 해시. role 2=OWNER, status 1=active.
INSERT INTO company.users (company_id, id, password, name, email, last_accessed_at, status, role)
SELECT '1af1b3d2-8bb6-42bb-b8b7-c6f470fc03dc',
'o2o',
'$2b$12$kUDvjAU.52BXeVcmHOfxU.J8ob01eleWlPsboaMJWzKEmDei08EgK',
'o2o', 'admin@o2o.kr', now(), 1, 2
WHERE NOT EXISTS (
SELECT 1 FROM company.users WHERE id = 'o2o' AND deleted = FALSE
);