[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
# 운영 로그 보기 — docker-compose.prod.yml 기준. 서비스명은 compose 의 아무 서비스나.
# ./logs.sh → 전체 서비스 실시간
# ./logs.sh negodata-backend → 그 서비스만 실시간
# ./logs.sh negosium-backend 200 → 마지막 200줄만 (실시간 아님)
# 운영 로그 보기 — 실행하면 실행중인 컨테이너 번호 메뉴, 0=전체
# ./logs.sh → 메뉴에서 선택
# ./logs.sh 3 → 메뉴 안 띄우고 3번 바로
# ./logs.sh lps-api → 이름으로 바로
set -e
cd "$(dirname "$0")"
SVC="$1"
LINES="$2"
TAIL=100
if [ -z "$SVC" ]; then
docker compose -f docker-compose.prod.yml logs -f --tail 100
elif [ -z "$LINES" ]; then
docker compose -f docker-compose.prod.yml logs -f --tail 100 "$SVC"
else
docker compose -f docker-compose.prod.yml logs --tail "$LINES" "$SVC"
# 우리 프로젝트 컨테이너만 (negosium-db는 compose 라벨이 없어 이름으로 포함)
containers=()
while IFS= read -r name; do
containers+=("$name")
done < <(
{
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

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",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"build": "tsc --noEmit && eslint src && vite build",
"preview": "vite preview",
"clean": "rm -rf dist server.js",
"lint": "tsc --noEmit",
"lint": "tsc --noEmit && eslint src",
"orval": "orval --config ./orval.config.ts"
},
"dependencies": {
@ -50,10 +50,13 @@
"@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"eslint": "^10.7.0",
"eslint-plugin-react-hooks": "^7.1.1",
"orval": "^7.3.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"typescript-eslint": "^8.65.0",
"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);
if (!colorConfig.length) return null;
@ -85,11 +85,11 @@ ${colorConfig
}}
/>
);
};
}
const ChartTooltip = RechartsPrimitive.Tooltip;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type TooltipContentProps = {
active?: boolean;
payload?: any[];
@ -214,7 +214,7 @@ const ChartLegend = RechartsPrimitive.Legend;
type LegendContentProps = {
className?: string;
hideIcon?: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload?: any[];
verticalAlign?: 'top' | 'bottom' | 'middle';
nameKey?: string;
@ -251,7 +251,7 @@ function ChartLegendContent({
}
// payload 항목에서 config 매칭 키를 찾는다(shadcn 원본 로직).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getPayloadConfigFromPayload(config: ChartConfig, payload: any, key: string) {
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; // 숫자만으로 방출
};
export const PhoneInput = forwardRef<HTMLInputElement, PhoneInputProps>(function PhoneInput(
export const PhoneInput = forwardRef<HTMLInputElement, PhoneInputProps>(function PhoneInputInner(
{ value = '', onChange, ...props },
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(협상) */}
<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;
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" />}
<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>
</div>
);

View File

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