From 26f518ed6efa8bed39bb27ea51220707f5c70c4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Tue, 7 Jul 2026 10:20:44 +0900 Subject: [PATCH] =?UTF-8?q?refactor(db):=20postgres-init=202=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=B2=B4=EA=B3=84=EB=A1=9C=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=E2=80=94=2000-init.sql(=EC=8A=A4=ED=82=A4=EB=A7=88=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4)=20+=20temp-data.sql(=EC=8B=9C=EB=93=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 00-init.sql: 구 01(도메인)+02(learning)+05(anchoring) 통합, 구 04(누적 ALTER)는 01에 기반영되어 폐기 - temp-data.sql: 구 03 시드 + 협상 카드 시드(일반 11장·와일드 5장, 멱등 가드, available=TRUE) - card 테이블: script TEXT 전환 + tone·strategy_type 컬럼 추가, 카드 변수 9종 체계 문서화 - 참조 갱신: 루트 README·docker-compose 주석, schedules/anchoring conftest(00-init 적용)·README·문서 - anchoring 테스트 20건 통과, 신규 DB 초기화·멱등성 검증 완료 Co-Authored-By: Claude Fable 5 --- README.md | 5 +- docker-compose.yml | 6 +- ...01-schema_202607061714.sql => 00-init.sql} | 260 ++++++++++++++++-- postgres-init/02-learning-schema.sql | 141 ---------- postgres-init/03-seed-negodata.sql | 28 -- postgres-init/04-alter_202607061714.sql | 104 ------- postgres-init/05-anchoring-schema.sql | 69 ----- postgres-init/card-update.sql | 119 ++++++++ postgres-init/temp-data.sql | 218 +++++++++++++++ schedules/anchoring/README.md | 4 +- schedules/anchoring/docs/개발용.md | 6 +- schedules/anchoring/docs/운영및유지보수.md | 6 +- schedules/anchoring/docs/인수인계.md | 4 +- .../migrations/20260706_rename_anchoring.sql | 4 +- schedules/anchoring/tests/conftest.py | 11 +- 15 files changed, 596 insertions(+), 389 deletions(-) rename postgres-init/{01-schema_202607061714.sql => 00-init.sql} (69%) delete mode 100644 postgres-init/02-learning-schema.sql delete mode 100644 postgres-init/03-seed-negodata.sql delete mode 100644 postgres-init/04-alter_202607061714.sql delete mode 100644 postgres-init/05-anchoring-schema.sql create mode 100644 postgres-init/card-update.sql create mode 100644 postgres-init/temp-data.sql diff --git a/README.md b/README.md index d554e1f..3a27cab 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,8 @@ PostgreSQL (외부, 5432) ```bash # 1) DB 준비 (최초 1회) — 사용할 PostgreSQL 에 스키마 + 시드 적용 -psql -h 127.0.0.1 -p 5432 -U postgres -f postgres-init/01-schema*.sql # negosium_db + 도메인 schema -psql -h 127.0.0.1 -p 5432 -U postgres -f postgres-init/02-learning-schema.sql # agent learning 스키마 -psql -h 127.0.0.1 -p 5432 -U postgres -f postgres-init/03-seed-negodata.sql # negodata 전용 시드 +psql -h 127.0.0.1 -p 5432 -U postgres -f postgres-init/00-init.sql # 스키마 전체 (negosium_db + 도메인·learning·anchoring schema) +psql -h 127.0.0.1 -p 5432 -U postgres -f postgres-init/temp-data.sql # 임시 데이터 시드 (admin / admin1234) # 2) 백엔드 기동 docker compose up -d # 두 backend (DB 는 config 대로 외부 연결) diff --git a/docker-compose.yml b/docker-compose.yml index 23ebab2..0446071 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,10 +11,8 @@ # anchoring 배치: 포트 없음 — 상주 스케줄러(격주 토 00:00 KST), docker logs anchoring 으로 확인 # # DB 준비(최초 1회): postgres-init 의 SQL 을 대상 DB 에 적용한다. -# psql -h -p -U -f postgres-init/01-schema*.sql (단일 negosium_db + 도메인별 schema) -# psql -h -p -U -f postgres-init/02-learning-schema.sql (agent learning 스키마) -# psql -h -p -U -f postgres-init/03-seed-negodata.sql (negodata 전용 시드: admin / admin1234, company.users) -# psql -h -p -U -f postgres-init/05-anchoring-schema.sql (anchoring 스키마: adjustments·뷰 — schedules/anchoring 소유) +# psql -h -p -U -f postgres-init/00-init.sql (스키마 전체: negosium_db + 도메인·learning·anchoring schema) +# psql -h -p -U -f postgres-init/temp-data.sql (임시 데이터 시드: admin / admin1234, company.users) services: negosium-backend: diff --git a/postgres-init/01-schema_202607061714.sql b/postgres-init/00-init.sql similarity index 69% rename from postgres-init/01-schema_202607061714.sql rename to postgres-init/00-init.sql index a9ff86b..a487809 100644 --- a/postgres-init/01-schema_202607061714.sql +++ b/postgres-init/00-init.sql @@ -1,12 +1,17 @@ +-- 단일 초기화 파일 — 스키마 DDL 전부를 이 한 파일로 적용한다 (구 01~05 통합, 2026-07-07). +-- 시드(임시 데이터)는 temp-data.sql 로 분리. 전부 IF NOT EXISTS 라 재실행 안전. +-- -- 단일 PostgreSQL 인스턴스, 단일 database(negosium_db) 안에서 도메인별 schema 로 묶는다. -- postgres (1개 서버, 5432) -- └── negosium_db --- ├── company : companies, users, user_tokens +-- ├── company : companies, users, user_tokens, notifications -- ├── supplier : supplier_users, supplier_user_tokens -- ├── partner : suppliers, items, item_internet_lowest_prices -- ├── card : versions, nego_cards, wild_cards, version_nego_cards, version_wild_cards -- ├── quotation : quotation_settings, quotations --- └── negotiation : sessions, chats, results +-- ├── negotiation : sessions, chats, results +-- ├── learning : q_table_versions, q_values, visit_counts, experience_logs, ... (agent 소유) +-- └── anchoring : adjustments + 뷰 (schedules/anchoring 소유) -- -- 설계 컨벤션 -- - 단일 DB(negosium_db) 안에서 도메인별 schema 로 묶는다. 테이블은 schema 한정자로 참조한다. @@ -194,32 +199,36 @@ CREATE TABLE IF NOT EXISTS card.versions ( ); 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 VARCHAR(255) NULL, -- 협상 스크립트 - edit_script JSONB NULL, -- 편집된 스크립트(JSON) - usage_type SMALLINT NOT NULL DEFAULT 1, -- 카드 적용 견적 구분(CardUsageType): 1=common(공통), 2=new(신규견적전용), 3=reuse(재견적전용) - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) - deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 + 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 VARCHAR(255) NULL, -- 협상 스크립트 - edit_script JSONB NULL, -- 편집된 스크립트(JSON) - usage_type SMALLINT NOT NULL DEFAULT 1, -- 카드 적용 견적 구분(CardUsageType): 1=common(공통), 2=new(신규견적전용), 3=reuse(재견적전용) - 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 -- 소프트 삭제 여부 + 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 ( @@ -423,3 +432,204 @@ CREATE INDEX IF NOT EXISTS idx_sessions_end_time ON negotiation.sessions (e -- 상품별 최신 크롤링 최저가 조회 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; + + +-- ============================================================ +-- 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개) + 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; diff --git a/postgres-init/02-learning-schema.sql b/postgres-init/02-learning-schema.sql deleted file mode 100644 index 43d789f..0000000 --- a/postgres-init/02-learning-schema.sql +++ /dev/null @@ -1,141 +0,0 @@ --- ============================================================ --- learning : 협상 에이전트(agent) RL 학습 자산 (Q-Table / 경험로그) --- ============================================================ --- negosium_db 안의 7번째 schema. agent 서비스가 소유한다(backend 는 미사용). --- 01-schema*.sql 과 동일 컨벤션: FK 미사용(앱 레이어 무결성), TIMESTAMPTZ(UTC), 코드값 SMALLINT(1부터). --- --- 멀티테넌트 논리 격리: 모든 테이블에 company_id 컬럼. company.companies.company_id(uuid)를 --- 문자열로 보관하되, 공유 베이스 정책은 예약어 '_base' 를 쓴다(uuid/sentinel 혼용 → VARCHAR). --- 모든 유니크/인덱스는 company_id 선두 복합으로 둔다(테넌트 간 충돌 방지 + 스코프 조회). - -\connect negosium_db - -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개) - 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); diff --git a/postgres-init/03-seed-negodata.sql b/postgres-init/03-seed-negodata.sql deleted file mode 100644 index 34fc05a..0000000 --- a/postgres-init/03-seed-negodata.sql +++ /dev/null @@ -1,28 +0,0 @@ --- negodata 전용 로컬/개발 시드 — company.companies + company.users 만 채운다. --- (negodata 로그인은 company.users 를 쓴다. negosium 의 company.tbl_account 는 대상이 아님.) --- 전체/공용 시드가 아니다. 재실행 안전(WHERE NOT EXISTS) — 운영 DB 에는 적용하지 않는다. --- admin 계정은 negodata 프론트 로그인 폼 기본값(admin / admin1234)에 대응한다. --- 적용(도커 postgres): docker exec -i negosium-db psql -U postgres -d negosium_db < postgres-init/03-seed-negodata.sql --- created_at/updated_at/deleted 은 server_default 로 자동 채워져 INSERT 에 안 넣는다. - -\connect negosium_db - --- 회사 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 유저. company_id 는 위 회사(고정 UUID)에 묶는다(NOT NULL). --- 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 -); diff --git a/postgres-init/04-alter_202607061714.sql b/postgres-init/04-alter_202607061714.sql deleted file mode 100644 index bc902c2..0000000 --- a/postgres-init/04-alter_202607061714.sql +++ /dev/null @@ -1,104 +0,0 @@ --- 기존 DB ALTER 누적 파일. 새 컬럼/변경은 이 파일에 계속 append 한다. --- 전부 IF NOT EXISTS 라 몇 번을 재실행해도 안전(돌리면 최신 상태로 맞춰짐). --- 신규/리셋 DB 는 01-schema*.sql 에 이미 반영돼 있어 이 파일이 필요 없다. - --- ─────────────────────────────────────────────────────────── --- [2026-06-26] 견적 개편: 가격(매입/판매)·수수료율·앵커링가 + 견적/카드/협력사 분류 컬럼 --- ─────────────────────────────────────────────────────────── - --- 상품: 인터넷최저가 실값 + 매입가 + 판매가 -ALTER TABLE partner.items - ADD COLUMN IF NOT EXISTS internet_lowest_price BIGINT, - ADD COLUMN IF NOT EXISTS purchase_price BIGINT, - ADD COLUMN IF NOT EXISTS selling_price BIGINT; - --- 세션: 앵커링가 -ALTER TABLE negotiation.sessions - ADD COLUMN IF NOT EXISTS anchoring_price BIGINT; - --- 견적: MD 제시가 + 협력사(공급채널) 유형 -ALTER TABLE quotation.quotations - ADD COLUMN IF NOT EXISTS md_price BIGINT, - ADD COLUMN IF NOT EXISTS supplier_type SMALLINT; - --- 카드: 사용 범위 구분(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용 -ALTER TABLE card.nego_cards - ADD COLUMN IF NOT EXISTS usage_type SMALLINT NOT NULL DEFAULT 1; -ALTER TABLE card.wild_cards - ADD COLUMN IF NOT EXISTS usage_type SMALLINT NOT NULL DEFAULT 1; - --- ─────────────────────────────────────────────────────────── --- [2026-06-29] 협상 초청 메일: 세션별 발송 시각(수동 발송 버튼이 채움) --- ─────────────────────────────────────────────────────────── -ALTER TABLE negotiation.sessions - ADD COLUMN IF NOT EXISTS email_sent_at TIMESTAMPTZ; - --- ─────────────────────────────────────────────────────────── --- [2026-06-30] 알림(인박스): 협상 이벤트를 견적 작성자에게 통지 --- ─────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS company.notifications ( - notification_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - user_id uuid NOT NULL, -- 수신자(company.users.user_id) = 견적 작성자 - type SMALLINT NOT NULL, -- 알림 유형(NotificationType): 1=success(낙찰), 2=regenerated(재생성), 3=failure(결렬) - ref_qt_id uuid NULL, -- 관련 견적(quotation.quotations.qt_id) - ref_session_id uuid NULL, -- 관련 세션(negotiation.sessions.session_id) - data JSONB NULL, -- 렌더 스냅샷(유형별) - read_at TIMESTAMPTZ NULL, -- 읽은 시각(NULL=안읽음) - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - deleted BOOLEAN NOT NULL DEFAULT FALSE -); -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); - --- ───────────────────────────────────────────────────────────── --- [2026-07-02] 앵커링 v1.2 — sessions 판정·마킹 컬럼 3종 --- (신규 DB 는 01-schema*.sql 에 반영됨. anchoring 스키마 자체(adjustments·뷰)는 --- 05-anchoring-schema.sql 로 적용 — 여기엔 두지 않는다.) -ALTER TABLE negotiation.sessions - ADD COLUMN IF NOT EXISTS anchoring_value SMALLINT NULL, -- 제안 당시 앵커링 값(천분율‰) 박제 - ADD COLUMN IF NOT EXISTS last_offer_price BIGINT NULL, -- 협력사 마지막 제시가(가격 흔적) - ADD COLUMN IF NOT EXISTS used_by_adjustment_id BIGINT NULL; -- 앵커링 배치 소비 마킹 - --- ───────────────────────────────────────────────────────────── --- [2026-07-02] 마감 close_reason 개편 — 견적 마감사유 + 가격정책 3구간 --- (신규 DB 는 01-schema*.sql 에 반영됨.) -ALTER TABLE quotation.quotations - ADD COLUMN IF NOT EXISTS close_reason SMALLINT NULL; -- 마감 사유(CloseReason 1~8), 미마감이면 NULL -ALTER TABLE quotation.quotation_settings - ADD COLUMN IF NOT EXISTS mid_action SMALLINT NOT NULL DEFAULT 1, -- 가격정책(PriceGateAction): 앵커링가<투찰가≤목표가 처리 - ADD COLUMN IF NOT EXISTS over_action SMALLINT NOT NULL DEFAULT 1, -- 가격정책(PriceGateAction): 목표가<투찰가 처리 - ADD COLUMN IF NOT EXISTS regen_limit SMALLINT NOT NULL DEFAULT 1; -- 재생성 최대 횟수(체인 전체 총합, 사유 무관) - --- ───────────────────────────────────────────────────────────── --- [2026-07-06] 낙찰 기준 견적 단위 이관 + 마감 개편(개찰 모델) — quotations(견적 행)에 낙찰 기준 mid/over 미러. --- 마감 판정이 견적 행에서 읽는다. 기준 미달/동가/거부/미응찰은 결렬(유찰)이 아니라 개찰(낙찰자 미정 마감). --- 자동 재협상/재생성 폐지 → regen_limit 제거(설정 템플릿 quotation_settings.regen_limit 은 미사용 잔존). --- (신규 DB 는 01-schema*.sql 에 반영됨. 기존 행은 DEFAULT 1=AWARD 로 백필.) -ALTER TABLE quotation.quotations - ADD COLUMN IF NOT EXISTS mid_action SMALLINT NOT NULL DEFAULT 1, -- 낙찰 기준(PriceGateAction 1=낙찰/2=개찰): 앵커링가<투찰가≤목표가 처리 - ADD COLUMN IF NOT EXISTS over_action SMALLINT NOT NULL DEFAULT 1; -- 낙찰 기준(PriceGateAction 1=낙찰/2=개찰): 목표가<투찰가 처리(1:1 협상은 항상 개찰) -ALTER TABLE quotation.quotations - DROP COLUMN IF EXISTS regen_limit; -- 자동 재생성 폐지로 제거(먼저 추가됐던 dev DB 대비 멱등 DROP) - --- 폐기된 close_reason 코드(구 REGEN_* 2~4) → 개찰(OPEN_*) 로 이관. 미이관 시 QuotationData(CloseReason enum) 검증 실패로 견적 목록 500. -UPDATE quotation.quotations SET close_reason = 5 WHERE close_reason = 2; -- REGEN_PRICE → OPEN_PRICE -UPDATE quotation.quotations SET close_reason = 6 WHERE close_reason = 3; -- REGEN_EQUAL → OPEN_EQUAL -UPDATE quotation.quotations SET close_reason = 7 WHERE close_reason = 4; -- REGEN_NOSHOW → OPEN_NOSHOW - --- quotation_settings 정리 — 낙찰 정책(mid/over/regen)은 견적 단위 이관, 앵커링은 칸 rate(v1.2)로 대체 → 세팅 컬럼 제거. --- 미제거 시 QuotationSettingData(PriceGateAction enum, 값 3=옛 FAIL) 검증 실패로 견적 세팅 목록 500. -ALTER TABLE quotation.quotation_settings - DROP COLUMN IF EXISTS mid_action, - DROP COLUMN IF EXISTS over_action, - DROP COLUMN IF EXISTS regen_limit, - DROP COLUMN IF EXISTS anchoring_value; - --- ───────────────────────────────────────────────────────────── --- [2026-07-06] 협력사: 총매출액 추가 + 우선선정(priority) 제거. --- priority(우선순위 문자열)와 그 파생 등급(rank)은 폐지 — 협력사에서 완전 제거. -ALTER TABLE partner.suppliers - ADD COLUMN IF NOT EXISTS total_revenue BIGINT; -- 총매출액(원, KTC total_revenue 미러) -ALTER TABLE partner.suppliers - DROP COLUMN IF EXISTS priority; diff --git a/postgres-init/05-anchoring-schema.sql b/postgres-init/05-anchoring-schema.sql deleted file mode 100644 index d11aead..0000000 --- a/postgres-init/05-anchoring-schema.sql +++ /dev/null @@ -1,69 +0,0 @@ --- ============================================================ --- anchoring : 앵커링 값 자동 조정 배치 (schedules/anchoring) 자산 --- ============================================================ --- negosium_db 안의 8번째 schema. schedules/anchoring 서비스가 소유한다(backend 는 소비만). --- 01-schema*.sql 과 동일 컨벤션: FK 미사용(앱 레이어 무결성), TIMESTAMPTZ(UTC), 코드값 SMALLINT. --- 규범 문서: schedules/anchoring/docs/개발용.md §6. --- --- sessions 의 앵커링 컬럼(anchoring_price·anchoring_value·last_offer_price·used_by_adjustment_id)은 --- 여기 두지 않는다 — 신규 DB 는 01-schema*.sql, 기존 DB 는 04-alter*.sql 소관. --- 유일한 DDL 원본 — 구 schedules/anchoring/schema.sql 은 여기로 이관 후 삭제(2026-07-06). --- 구 이름(rate_adjustments 등)의 기존 DB 는 schedules/anchoring/migrations/20260706_rename_anchoring.sql 적용. - -\connect negosium_db - -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; diff --git a/postgres-init/card-update.sql b/postgres-init/card-update.sql new file mode 100644 index 0000000..3b15434 --- /dev/null +++ b/postgres-init/card-update.sql @@ -0,0 +1,119 @@ +-- ============================================================ +-- 협상 카드 시드 데이터 (실 적용 카드) — 통합본 +-- nego_cards: 일반 11장 / wild_cards: 와일드 5장 +-- tone: 1=강경 2=정중 3=우호 4=중립 5=단호 +-- strategy_type: 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} 고객사 가격 산정 근거 +-- ============================================================ + +BEGIN; + +-- 1) 컬럼 추가 / 타입 변경 +ALTER TABLE card.nego_cards ALTER COLUMN script TYPE TEXT; +ALTER TABLE card.wild_cards ALTER COLUMN script TYPE TEXT; +ALTER TABLE card.nego_cards ADD COLUMN IF NOT EXISTS tone SMALLINT NULL; -- 카드 톤 코드 +ALTER TABLE card.nego_cards ADD COLUMN IF NOT EXISTS strategy_type SMALLINT NULL; -- 전략 유형 코드 +ALTER TABLE card.wild_cards ADD COLUMN IF NOT EXISTS tone SMALLINT NULL; -- 카드 톤 코드 +ALTER TABLE card.wild_cards ADD COLUMN IF NOT EXISTS strategy_type SMALLINT NULL; -- 전략 유형 코드 + +-- 2) 일반 카드 (nego_cards) +INSERT INTO card.nego_cards + (user_id, name, number, script, edit_script, usage_type, tone, strategy_type) +VALUES + (NULL, '타사 검토 언급', '1', '귀사의 품질과 협력 의지를 높이 평가하고 있어 우선적으로 협의를 진행하고 있습니다. + +다만 당사는 현재 본 건에 대해 복수의 공급 가능 업체와 병행하여 검토를 진행하고 있으며, 최종 선정을 위해서는 제시해 주신 조건이 타 대안 대비 분명한 경쟁력을 갖추어야 합니다. + +귀사께서 충분히 경쟁력 있는 제안을 주실 수 있으리라 기대하며, 재검토하신 조건을 회신해 주시기 바랍니다.', '[{"type": "paragraph", "children": [{"text": "귀사의 품질과 협력 의지를 높이 평가하고 있어 우선적으로 협의를 진행하고 있습니다."}]}, {"type": "paragraph", "children": [{"text": "다만 당사는 현재 본 건에 대해 "}, {"text": "복수의 공급 가능 업체와 병행하여 검토를 진행", "bold": true}, {"text": "하고 있으며, 최종 선정을 위해서는 제시해 주신 조건이 타 대안 대비 분명한 경쟁력을 갖추어야 합니다."}]}, {"type": "paragraph", "children": [{"text": "귀사께서 충분히 경쟁력 있는 제안을 주실 수 있으리라 기대하며, 재검토하신 조건을 회신해 주시기 바랍니다."}]}]'::jsonb, 1, 1, 1), + (NULL, '내부 승인 필요', '2', '제안해 주신 조건은 잘 검토하였습니다. + +다만 해당 수준의 가격은 담당자 선에서 단독으로 확정하기 어려우며, 내부 상위 결재 절차를 거쳐야 하는 사안입니다. 솔직히 말씀드리면, 현재 조건으로는 내부 승인을 받기가 쉽지 않을 것으로 예상됩니다. + +당사가 무리 없이 승인을 진행할 수 있는 수준으로 한 번 더 조정해 주신다면, 신속하게 절차를 밟아 협상을 매듭짓겠습니다.', '[{"type": "paragraph", "children": [{"text": "제안해 주신 조건은 잘 검토하였습니다."}]}, {"type": "paragraph", "children": [{"text": "다만 해당 수준의 가격은 담당자 선에서 단독으로 확정하기 어려우며, "}, {"text": "내부 상위 결재 절차를 거쳐야 하는 사안", "bold": true}, {"text": "입니다. 솔직히 말씀드리면, 현재 조건으로는 내부 승인을 받기가 쉽지 않을 것으로 예상됩니다."}]}, {"type": "paragraph", "children": [{"text": "당사가 무리 없이 승인을 진행할 수 있는 수준으로 한 번 더 조정해 주신다면, 신속하게 절차를 밟아 협상을 매듭짓겠습니다."}]}]'::jsonb, 1, 2, 1), + (NULL, '장기 관계 강조', '3', '귀사와의 협력을 진심으로 소중하게 생각하고 있습니다. + +이번 거래가 단발성으로 끝나지 않고, 앞으로 오랜 기간 함께할 관계의 시작이 되기를 바랍니다. 그런 의미에서 양사가 모두 만족할 수 있는 합리적인 지점을 함께 찾고자 합니다. + +귀사께서 조금만 더 협조해 주신다면, 당사 역시 장기적인 관점에서 최선을 다해 화답하겠습니다. 좋은 합의를 기대합니다.', '[{"type": "paragraph", "children": [{"text": "귀사와의 협력을 진심으로 소중하게 생각하고 있습니다."}]}, {"type": "paragraph", "children": [{"text": "이번 거래가 단발성으로 끝나지 않고, "}, {"text": "앞으로 오랜 기간 함께할 관계의 시작", "bold": true}, {"text": "이 되기를 바랍니다. 그런 의미에서 양사가 모두 만족할 수 있는 합리적인 지점을 함께 찾고자 합니다."}]}, {"type": "paragraph", "children": [{"text": "귀사께서 조금만 더 협조해 주신다면, 당사 역시 장기적인 관점에서 최선을 다해 화답하겠습니다. 좋은 합의를 기대합니다."}]}]'::jsonb, 1, 3, 2), + (NULL, '명분 제공', '4', '그동안 귀사께서 보여주신 협상 태도와 전문성을 높이 평가하고 있습니다. + +이번 조정은 귀사가 일방적으로 양보하는 것이 아니라, 양사가 장기적 협력을 위해 함께 내린 전략적 결정으로 이해해 주시면 감사하겠습니다. 당사 역시 이번 합의를 귀사와의 신뢰를 보여주는 기회로 삼겠습니다. + +서로의 입장을 존중하는 선에서 원만하게 마무리할 수 있기를 바랍니다.', '[{"type": "paragraph", "children": [{"text": "그동안 귀사께서 보여주신 협상 태도와 전문성을 높이 평가하고 있습니다."}]}, {"type": "paragraph", "children": [{"text": "이번 조정은 귀사가 일방적으로 양보하는 것이 아니라, "}, {"text": "양사가 장기적 협력을 위해 함께 내린 전략적 결정", "bold": true}, {"text": "으로 이해해 주시면 감사하겠습니다. 당사 역시 이번 합의를 귀사와의 신뢰를 보여주는 기회로 삼겠습니다."}]}, {"type": "paragraph", "children": [{"text": "서로의 입장을 존중하는 선에서 원만하게 마무리할 수 있기를 바랍니다."}]}]'::jsonb, 1, 2, 2), + (NULL, '공정 합의 제안', '5', '당사가 바라는 것은 어느 한쪽에 치우친 거래가 아니라, 양사 모두가 공정하다고 느낄 수 있는 합의입니다. + +당사는 합리적인 근거 위에서 성실하게 조건을 제시해 왔으며, 귀사 역시 같은 자세로 임해 주시리라 믿습니다. 서로가 공정함을 기준으로 한 걸음씩 다가선다면, 양사 모두 납득할 수 있는 결론에 이를 수 있습니다. + +공정한 합의를 향해 귀사의 전향적인 검토를 부탁드립니다.', '[{"type": "paragraph", "children": [{"text": "당사가 바라는 것은 어느 한쪽에 치우친 거래가 아니라, "}, {"text": "양사 모두가 공정하다고 느낄 수 있는 합의", "bold": true}, {"text": "입니다."}]}, {"type": "paragraph", "children": [{"text": "당사는 합리적인 근거 위에서 성실하게 조건을 제시해 왔으며, 귀사 역시 같은 자세로 임해 주시리라 믿습니다. 서로가 공정함을 기준으로 한 걸음씩 다가선다면, 양사 모두 납득할 수 있는 결론에 이를 수 있습니다."}]}, {"type": "paragraph", "children": [{"text": "공정한 합의를 향해 귀사의 전향적인 검토를 부탁드립니다."}]}]'::jsonb, 1, 4, 2), + (NULL, '총비용 가치 설명', '6', '제안 드린 가격에 대해 부담을 느끼시는 점 충분히 이해합니다. + +다만 당사의 제안 가격은 단순한 단가가 아니라 안정적인 품질과 납기 준수, 사후 지원까지 포함한 총비용 관점에서 산정된 것입니다. 초기 단가만을 기준으로 비교할 경우, 운영 과정에서 발생할 수 있는 추가 비용이나 리스크가 충분히 반영되지 않을 수 있습니다. + +가격 자체보다 귀사께서 얻으실 전체적인 가치를 함께 고려해 주시기를 부탁드립니다.', '[{"type": "paragraph", "children": [{"text": "제안 드린 가격에 대해 부담을 느끼시는 점 충분히 이해합니다."}]}, {"type": "paragraph", "children": [{"text": "다만 당사의 제안 가격은 단순한 단가가 아니라 "}, {"text": "안정적인 품질과 납기 준수, 사후 지원까지 포함한 총비용 관점", "bold": true}, {"text": "에서 산정된 것입니다. 초기 단가만을 기준으로 비교할 경우, 운영 과정에서 발생할 수 있는 추가 비용이나 리스크가 충분히 반영되지 않을 수 있습니다."}]}, {"type": "paragraph", "children": [{"text": "가격 자체보다 귀사께서 얻으실 전체적인 가치를 함께 고려해 주시기를 부탁드립니다."}]}]'::jsonb, 1, 4, 3), + (NULL, '예산 상한 안내', '7', '귀사와의 합의를 진심으로 바라고 있습니다. + +다만 당사 내부 예산 정책상 본 건에 책정 가능한 금액은 {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), + (NULL, '시장가 근거 제시', '8', '당사의 제안 가격이 시장에서 동떨어진 수준이 아님을 함께 확인해 보고자 합니다. + +인터넷 시장 조사 결과, 동종 업계의 유사 거래에서도 본 건과 비슷한 사양은 {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), + (NULL, '조건부 가격 조정', '9', '귀사의 입장을 고려하여 당사가 한 걸음 더 나아가고자 합니다. + +귀사께서 {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), + (NULL, '향후 거래 연계', '10', '이번 거래의 가격을 {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), + (NULL, '양보 가치 강조', '11', '이번 조정은 당사에 결코 작은 일이 아님을 먼저 말씀드리고 싶습니다. + +{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); + +-- 3) 와일드 카드 (wild_cards) — condition NULL, available FALSE +INSERT INTO card.wild_cards + (user_id, name, number, script, edit_script, usage_type, condition, available, memo, tone, strategy_type) +VALUES + (NULL, '목표가 선제안', '1', '안녕하십니까. 금번 협상에 참여해 주셔서 감사합니다. + +당사는 {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, FALSE, NULL, 5, 5), + (NULL, '역제안가 제시', '2', '제안해 주신 {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, FALSE, NULL, 5, 5), + (NULL, '최종 통보', '3', '합리적인 기준에 근거하여 목표 가격을 제안 드렸으나, 귀사의 기존 제안 가격으로는 긍정적인 합의가 어려울 것으로 예상됩니다. + +이번 협상이 결렬되는 경우 우선 협상권을 보장하기 어려우며, 다른 공급 업체를 선정하기 위한 검토가 진행될 수 있습니다. + +귀사와 앞으로 보다 많은 협력 기회를 만들어 나가기를 희망합니다. 다시 한번 고민하신 후 제안 가격을 입력해 주시기 바랍니다.', '[{"type": "paragraph", "children": [{"text": "합리적인 기준에 근거하여 목표 가격을 제안 드렸으나, 귀사의 기존 제안 가격으로는 긍정적인 합의가 어려울 것으로 예상됩니다."}]}, {"type": "paragraph", "children": [{"text": "이번 협상이 결렬되는 경우 "}, {"text": "우선 협상권을 보장하기 어려우며, 다른 공급 업체를 선정하기 위한 검토가 진행될 수 있습니다.", "bold": true}]}, {"type": "paragraph", "children": [{"text": "귀사와 앞으로 보다 많은 협력 기회를 만들어 나가기를 희망합니다. 다시 한번 고민하신 후 제안 가격을 입력해 주시기 바랍니다."}]}]'::jsonb, 1, NULL, FALSE, NULL, 1, 1), + (NULL, '단계적 인하 제안', '4', '당초 당사가 검토한 적정가는 {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, FALSE, NULL, 1, 1), + (NULL, '중간값 절충', '5', '긴 협의 끝에 양측의 입장이 상당히 가까워졌습니다. + +현재 당사 제안 {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, FALSE, NULL, 4, 6); + +COMMIT; \ No newline at end of file diff --git a/postgres-init/temp-data.sql b/postgres-init/temp-data.sql new file mode 100644 index 0000000..ebd8bdd --- /dev/null +++ b/postgres-init/temp-data.sql @@ -0,0 +1,218 @@ +-- 임시 데이터(로컬/개발 시드) — 스키마는 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 에 안 넣는다. + +\connect negosium_db + +-- 회사 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, '타사 검토 언급', '1', + '귀사의 품질과 협력 의지를 높이 평가하고 있어 우선적으로 협의를 진행하고 있습니다. + +다만 당사는 현재 본 건에 대해 복수의 공급 가능 업체와 병행하여 검토를 진행하고 있으며, 최종 선정을 위해서는 제시해 주신 조건이 타 대안 대비 분명한 경쟁력을 갖추어야 합니다. + +귀사께서 충분히 경쟁력 있는 제안을 주실 수 있으리라 기대하며, 재검토하신 조건을 회신해 주시기 바랍니다.', + '[{"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, '내부 승인 필요', '2', + '제안해 주신 조건은 잘 검토하였습니다. + +다만 해당 수준의 가격은 담당자 선에서 단독으로 확정하기 어려우며, 내부 상위 결재 절차를 거쳐야 하는 사안입니다. 솔직히 말씀드리면, 현재 조건으로는 내부 승인을 받기가 쉽지 않을 것으로 예상됩니다. + +당사가 무리 없이 승인을 진행할 수 있는 수준으로 한 번 더 조정해 주신다면, 신속하게 절차를 밟아 협상을 매듭짓겠습니다.', + '[{"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, '장기 관계 강조', '3', + '귀사와의 협력을 진심으로 소중하게 생각하고 있습니다. + +이번 거래가 단발성으로 끝나지 않고, 앞으로 오랜 기간 함께할 관계의 시작이 되기를 바랍니다. 그런 의미에서 양사가 모두 만족할 수 있는 합리적인 지점을 함께 찾고자 합니다. + +귀사께서 조금만 더 협조해 주신다면, 당사 역시 장기적인 관점에서 최선을 다해 화답하겠습니다. 좋은 합의를 기대합니다.', + '[{"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, '명분 제공', '4', + '그동안 귀사께서 보여주신 협상 태도와 전문성을 높이 평가하고 있습니다. + +이번 조정은 귀사가 일방적으로 양보하는 것이 아니라, 양사가 장기적 협력을 위해 함께 내린 전략적 결정으로 이해해 주시면 감사하겠습니다. 당사 역시 이번 합의를 귀사와의 신뢰를 보여주는 기회로 삼겠습니다. + +서로의 입장을 존중하는 선에서 원만하게 마무리할 수 있기를 바랍니다.', + '[{"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, '공정 합의 제안', '5', + '당사가 바라는 것은 어느 한쪽에 치우친 거래가 아니라, 양사 모두가 공정하다고 느낄 수 있는 합의입니다. + +당사는 합리적인 근거 위에서 성실하게 조건을 제시해 왔으며, 귀사 역시 같은 자세로 임해 주시리라 믿습니다. 서로가 공정함을 기준으로 한 걸음씩 다가선다면, 양사 모두 납득할 수 있는 결론에 이를 수 있습니다. + +공정한 합의를 향해 귀사의 전향적인 검토를 부탁드립니다.', + '[{"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, '총비용 가치 설명', '6', + '제안 드린 가격에 대해 부담을 느끼시는 점 충분히 이해합니다. + +다만 당사의 제안 가격은 단순한 단가가 아니라 안정적인 품질과 납기 준수, 사후 지원까지 포함한 총비용 관점에서 산정된 것입니다. 초기 단가만을 기준으로 비교할 경우, 운영 과정에서 발생할 수 있는 추가 비용이나 리스크가 충분히 반영되지 않을 수 있습니다. + +가격 자체보다 귀사께서 얻으실 전체적인 가치를 함께 고려해 주시기를 부탁드립니다.', + '[{"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, '예산 상한 안내', '7', + '귀사와의 합의를 진심으로 바라고 있습니다. + +다만 당사 내부 예산 정책상 본 건에 책정 가능한 금액은 {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, '시장가 근거 제시', '8', + '당사의 제안 가격이 시장에서 동떨어진 수준이 아님을 함께 확인해 보고자 합니다. + +인터넷 시장 조사 결과, 동종 업계의 유사 거래에서도 본 건과 비슷한 사양은 {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, '조건부 가격 조정', '9', + '귀사의 입장을 고려하여 당사가 한 걸음 더 나아가고자 합니다. + +귀사께서 {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, '향후 거래 연계', '10', + '이번 거래의 가격을 {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, '양보 가치 강조', '11', + '이번 조정은 당사에 결코 작은 일이 아님을 먼저 말씀드리고 싶습니다. + +{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, '목표가 선제안', '1', + '안녕하십니까. 금번 협상에 참여해 주셔서 감사합니다. + +당사는 {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, '역제안가 제시', '2', + '제안해 주신 {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, '최종 통보', '3', + '합리적인 기준에 근거하여 목표 가격을 제안 드렸으나, 귀사의 기존 제안 가격으로는 긍정적인 합의가 어려울 것으로 예상됩니다. + +이번 협상이 결렬되는 경우 우선 협상권을 보장하기 어려우며, 다른 공급 업체를 선정하기 위한 검토가 진행될 수 있습니다. + +귀사와 앞으로 보다 많은 협력 기회를 만들어 나가기를 희망합니다. 다시 한번 고민하신 후 제안 가격을 입력해 주시기 바랍니다.', + '[{"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, '단계적 인하 제안', '4', + '당초 당사가 검토한 적정가는 {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, '중간값 절충', '5', + '긴 협의 끝에 양측의 입장이 상당히 가까워졌습니다. + +현재 당사 제안 {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); diff --git a/schedules/anchoring/README.md b/schedules/anchoring/README.md index 58c7180..c04e846 100644 --- a/schedules/anchoring/README.md +++ b/schedules/anchoring/README.md @@ -35,8 +35,8 @@ migrations/ # 기존 DB 이름 개편용 rename 마이그레이션 (DD ## 실행 ```bash -# 0) DDL 적용 — 루트 postgres-init 로 이관됨 (신규 DB: 01~04 이후 05) -psql -h 127.0.0.1 -U postgres -d negosium_db -f ../../postgres-init/05-anchoring-schema.sql +# 0) DDL 적용 — 루트 postgres-init/00-init.sql 로 이관됨 (anchoring 스키마 포함 전체 통합) +psql -h 127.0.0.1 -U postgres -f ../../postgres-init/00-init.sql # 로컬(가상환경) python3 -m venv .venv && .venv/bin/pip install -r requirements.txt diff --git a/schedules/anchoring/docs/개발용.md b/schedules/anchoring/docs/개발용.md index d0dc427..9e7aa07 100644 --- a/schedules/anchoring/docs/개발용.md +++ b/schedules/anchoring/docs/개발용.md @@ -288,7 +288,7 @@ Redis anchor:{company_id}:{supplier_type}:{price_range_index} → rate(‰), TT ## 6. DB 스키마 -프로젝트 컨벤션 준수: FK/CHECK/PG ENUM **없음**, SMALLINT 코드, uuid 키, TIMESTAMPTZ(UTC). DDL 은 `postgres-init/05-anchoring-schema.sql` 한 파일(스키마+테이블+뷰+인덱스, psql 수동 적용 — 2026-07-06 모듈 schema.sql 에서 이관). sessions 앵커링 컬럼은 `01-schema*.sql`·`04-alter*.sql` 소관. 소유 서비스는 여전히 이 모듈이다. +프로젝트 컨벤션 준수: FK/CHECK/PG ENUM **없음**, SMALLINT 코드, uuid 키, TIMESTAMPTZ(UTC). DDL 은 `postgres-init/00-init.sql` 의 anchoring 섹션(스키마+테이블+뷰+인덱스, psql 수동 적용 — 2026-07-06 모듈 schema.sql 에서 이관, 2026-07-07 01~05 통합). sessions 앵커링 컬럼은 같은 파일 negotiation 섹션 소관. 소유 서비스는 여전히 이 모듈이다. **네이밍 결정** — 기존 코드베이스 용어와 통일: @@ -357,7 +357,7 @@ anchoring.current_values -- 칸별 현재값(최신 조정 행). 여기 없는 주의사항: - `sessions.anchoring_price`는 negodata 가 이미 생성 시 채우는 기존 컬럼 — 앵커가 박제로 그대로 활용(신규 컬럼 아님). -- 신규 DB 구축 시 적용 순서: `postgres-init/01~05` (IF NOT EXISTS 라 재적용 안전). **sessions 3컬럼은 backend ORM 이 참조하므로 `postgres-init/01-schema*.sql`·`04-alter*.sql` 에도 반영돼 있다**(backend 가 모듈 DDL 없이도 기동) — anchoring 스키마 자체(테이블·뷰)는 모듈 파일만이 소유. +- 신규 DB 구축: `postgres-init/00-init.sql` 하나로 적용 (IF NOT EXISTS 라 재적용 안전). **sessions 3컬럼은 backend ORM 이 참조하므로 같은 파일 negotiation 섹션에 반영돼 있다**(backend 가 anchoring 섹션 없이도 기동) — anchoring 스키마 자체(테이블·뷰)는 이 모듈이 소유. - backend 모델(`models.py`)에는 **sessions 3컬럼만 추가**한다 — `adjustments` 모델은 backend 에 만들지 않는다(무의존). 배치용 ORM 은 모듈이 자체 보유(읽기전용 sessions/quotations/items 매핑 포함). --- @@ -707,7 +707,7 @@ clamp·격리 케이스: | # | 항목 | 담당 | 상태 | |---|---|---|---| -| 1 | DDL — `anchoring.adjustments` + sessions 3컬럼 ALTER | [우리 — 모듈] `postgres-init/05-anchoring-schema.sql`, psql 적용 시점 협의 | 구현 완료 (§6) | +| 1 | DDL — `anchoring.adjustments` + sessions 3컬럼 ALTER | [우리 — 모듈] `postgres-init/00-init.sql` anchoring 섹션, psql 적용 시점 협의 | 구현 완료 (§6) | | 2 | **세션 생성 시 앵커 산출을 새 시스템으로 교체** — `_build_quotation` 앵커 계산 교체 + 재생성 상속 폐지 | **[인수인계 — negodata]** | §9.1. reader 는 모듈(async)에서 그대로 이식 | | 3 | agent | **변경 없음** | 앵커 비노출 — 스크립트·프로토콜·엔진 무변경, 인수인계 항목 아님 | | 4 | 재협상 식별 | — | `sessions.qt_type = 1` 로 판별 (확인됨) | diff --git a/schedules/anchoring/docs/운영및유지보수.md b/schedules/anchoring/docs/운영및유지보수.md index 8864f30..19bbccf 100644 --- a/schedules/anchoring/docs/운영및유지보수.md +++ b/schedules/anchoring/docs/운영및유지보수.md @@ -54,11 +54,11 @@ ### STEP 1 — DB 스키마 적용 (최초 1회) ```bash -psql -h -U <계정> -d negosium_db -f postgres-init/05-anchoring-schema.sql # 레포 루트에서 +psql -h -U <계정> -f postgres-init/00-init.sql # 레포 루트에서 (스키마 전체 통합 파일) ``` -- 테이블 1개(`anchoring.adjustments`)·조회용 뷰 2개(`value_history`, `current_values`)·인덱스를 추가합니다. - (`negotiation.sessions` 앵커링 컬럼은 `postgres-init/01-schema*.sql`(신규)·`04-alter*.sql`(기존) 소관) +- anchoring 스키마: 테이블 1개(`anchoring.adjustments`)·조회용 뷰 2개(`value_history`, `current_values`)·인덱스를 추가합니다. + (`negotiation.sessions` 앵커링 컬럼도 같은 `00-init.sql` 의 negotiation 섹션에 포함) - `IF NOT EXISTS` 라 **여러 번 실행해도 안전**합니다. ### STEP 2 — 설정 채우기 diff --git a/schedules/anchoring/docs/인수인계.md b/schedules/anchoring/docs/인수인계.md index eb04f56..8f672d3 100644 --- a/schedules/anchoring/docs/인수인계.md +++ b/schedules/anchoring/docs/인수인계.md @@ -14,7 +14,7 @@ |---|---| | `schedules/anchoring/src/anchoring/` 모듈 | `constants.py`(상수·enum) · `base_table.py`(정적 테이블 로더) · `service.py`(순수 계산 함수) · `redis_client.py` · `reader.py`(rate 조회) — **전부 async(SQLAlchemy async + redis.asyncio) 자립형이라 negodata 에 그대로 복사/이식 가능** | | `schedules/anchoring/src/anchoring/resources/anchoring_base.json` | 정적 기본 테이블 (46행 자릿수 사다리, 불변) | -| `postgres-init/05-anchoring-schema.sql` | `anchoring.adjustments` 테이블·뷰·인덱스 — 모듈 소유 DDL, psql 수동 적용 (sessions 컬럼은 01/04 소관) | +| `postgres-init/00-init.sql` (anchoring 섹션) | `anchoring.adjustments` 테이블·뷰·인덱스 — 모듈 소유 DDL, psql 수동 적용 (sessions 컬럼은 같은 파일 negotiation 섹션) | | 루트 `docker-compose.yml` | anchoring 서비스 + redis 동봉(모듈 전용 캐시) — **negodata 는 Redis 를 쓰지 않는다**(`current_values` 뷰 직조회) | | 이 문서 | 적용 위치·변경 전후 명세 | @@ -89,7 +89,7 @@ sessions(..., anchoring_price=ap, anchoring_value=rate, ...) ## 3. 적용 순서 (권장) ``` -① DB 스키마 적용 (postgres-init/05-anchoring-schema.sql — adjustments·뷰·인덱스, sessions 컬럼은 01/04) +① DB 스키마 적용 (postgres-init/00-init.sql — adjustments·뷰·인덱스, sessions 컬럼 포함 전체 통합) ② anchoring 서비스 기동 (schedules/anchoring 컨테이너 — 격주 배치·Redis 캐시 시작) + backend 배포 (마지막 제시가 기록·박제값 소비 — 이 시점부터 표본·조정이 쌓이기 시작) ③ 전환기 점프 확인 (negodata 적용 직전): diff --git a/schedules/anchoring/migrations/20260706_rename_anchoring.sql b/schedules/anchoring/migrations/20260706_rename_anchoring.sql index 822d96d..029d485 100644 --- a/schedules/anchoring/migrations/20260706_rename_anchoring.sql +++ b/schedules/anchoring/migrations/20260706_rename_anchoring.sql @@ -10,7 +10,7 @@ BEGIN; --- 1) 구 이름 뷰 제거 (신 이름 뷰는 마지막에 재생성 — 정의는 postgres-init/05-anchoring-schema.sql 과 동일) +-- 1) 구 이름 뷰 제거 (신 이름 뷰는 마지막에 재생성 — 정의는 postgres-init/00-init.sql 과 동일) DROP VIEW IF EXISTS anchoring.rate_history; DROP VIEW IF EXISTS anchoring.current_rates; @@ -83,7 +83,7 @@ BEGIN END LOOP; END $$; --- 5) 신 이름 뷰 재생성 (postgres-init/05-anchoring-schema.sql §조회용 뷰와 동일 정의) +-- 5) 신 이름 뷰 재생성 (postgres-init/00-init.sql §조회용 뷰와 동일 정의) CREATE OR REPLACE VIEW anchoring.value_history AS SELECT adjustment_id, company_id, diff --git a/schedules/anchoring/tests/conftest.py b/schedules/anchoring/tests/conftest.py index 57fa80b..85be1b7 100644 --- a/schedules/anchoring/tests/conftest.py +++ b/schedules/anchoring/tests/conftest.py @@ -15,7 +15,7 @@ from anchoring import db as adb from anchoring.config import load_config CFG = load_config() -_SCHEMA_SQL = Path(__file__).resolve().parents[3] / "postgres-init" / "05-anchoring-schema.sql" +_SCHEMA_SQL = Path(__file__).resolve().parents[3] / "postgres-init" / "00-init.sql" def _db_available() -> bool: @@ -40,12 +40,17 @@ requires_db = pytest.mark.skipif(not DB_OK, reason="로컬 Postgres(negosium_db) def _schema_statements() -> list[str]: - """05-anchoring-schema.sql 에서 psql 메타(\\connect)·주석을 제거하고 문장 단위로 분리.""" + """00-init.sql 에서 psql 메타(\\connect)·주석을 제거하고 문장 단위로 분리. + + 이미 연결된 DB 에 멱등 적용하므로 클러스터 수준 구문(CREATE DATABASE \\gexec, + ALTER DATABASE)은 건너뛴다. + """ lines = [ line for line in _SCHEMA_SQL.read_text().splitlines() if not line.startswith("\\") and not line.strip().startswith("--") ] - return [s.strip() for s in "\n".join(lines).split(";") if s.strip()] + stmts = [s.strip() for s in "\n".join(lines).split(";") if s.strip()] + return [s for s in stmts if "\\gexec" not in s and not s.startswith("ALTER DATABASE")] @pytest_asyncio.fixture