- POST /v1/item/{id}/lowest-price: 상품 1건 LPS 즉시 검색요청(manual
우선순위). queued/duplicated/unavailable 상태 반환, 실패는
ErrorType.LPS_UNAVAILABLE(2000 블록 신설)
- GET /v1/item/{id}/lowest-price: 대표 최저가 + 최근 수집 이력(최신순,
LowestPriceEntry 타입화). 조회 전 lps_db 증분 동기화 1회 수행 —
5분 크론을 기다리지 않는 실시간 폴링 UX(멱등·저비용)
- 동시성 방어 2겹: 프로세스 내 asyncio.Lock(크론·온디맨드 직렬화) +
uq_iilp_item_crawl_time 유니크 인덱스(alters/2026-07-10, init.sql
멱등 반영·dev DB 적용) — 경합 진 쪽 tx 실패 후 다음 tick 흡수
- LpsSyncService 무인자 생성자(FastAPI Depends 호환)
- compose: negodata-backend 에 LPS_DB_HOST·LPS_BASE_URL env(미설정 시
연동 비활성으로 조용히 동작)
검증(도커 컨테이너 e2e): 인증→POST queued→LPS 크롤→GET 온디맨드
동기화로 이력 즉시 노출(not_found 정책: 대표값 미변경 확인),
company 스코프 차단(타사 상품 ITEM_NOT_FOUND) 확인
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
671 lines
54 KiB
SQL
671 lines
54 KiB
SQL
-- 단일 초기화 파일 — 스키마 DDL 전부를 이 한 파일로 적용한다 (구 01~05 통합, 2026-07-07).
|
|
-- 시드(임시 데이터)는 temp-data.sql 로 분리. 전부 IF NOT EXISTS 라 재실행 안전.
|
|
-- 기존 DB 에 재실행하면 말미의 "기존 DB 보정(ALTER)" 섹션이 최신 스키마로 맞춰준다.
|
|
--
|
|
-- 단일 PostgreSQL 인스턴스, 단일 database(negosium_db) 안에서 도메인별 schema 로 묶는다.
|
|
-- postgres (1개 서버, 5432)
|
|
-- └── negosium_db
|
|
-- ├── company : companies, users, user_tokens, notifications
|
|
-- ├── supplier : supplier_users, supplier_user_tokens
|
|
-- ├── partner : suppliers, items, item_internet_lowest_prices, supplier_items
|
|
-- ├── card : versions, nego_cards, wild_cards, version_nego_cards, version_wild_cards
|
|
-- ├── quotation : quotation_settings, quotations
|
|
-- ├── negotiation : sessions, chats, results
|
|
-- ├── learning : q_table_versions, q_values, visit_counts, experience_logs, ... (agent 소유)
|
|
-- └── anchoring : adjustments + 뷰 (schedules/anchoring 소유)
|
|
--
|
|
-- 설계 컨벤션
|
|
-- - 단일 DB(negosium_db) 안에서 도메인별 schema 로 묶는다. 테이블은 schema 한정자로 참조한다.
|
|
-- - FK 제약은 걸지 않고 관계 컬럼만 둔다 (무결성은 애플리케이션 레이어에서 관리). schema 간 관계도 동일.
|
|
-- - 코드값(status/role/type 등)은 SMALLINT 정수 코드로 둔다 (1부터; 매핑은 애플리케이션 enum 기준, CHECK 없음).
|
|
-- - 소프트 삭제(deleted BOOLEAN)를 사용하므로 자연키 유니크는 부분 인덱스로 건다.
|
|
-- - 시각은 TIMESTAMPTZ, 금액은 BIGINT, 비율은 NUMERIC 으로 둔다.
|
|
|
|
-- PostgreSQL 은 CREATE DATABASE IF NOT EXISTS 를 지원하지 않으므로,
|
|
-- 존재하지 않을 때만 생성하도록 psql \gexec 로 처리한다 (재실행 안전).
|
|
SELECT 'CREATE DATABASE negosium_db'
|
|
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'negosium_db')\gexec
|
|
|
|
|
|
\connect negosium_db
|
|
|
|
-- 모든 세션에서 시각을 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(비활성)
|
|
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 미러
|
|
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, -- 자동으로 늘어나는 숫자 ( 카테고리 찾을때 유용한 컬럼)
|
|
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=미발송). 수동 발송 버튼이 채움
|
|
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();
|