Merge branch 'design/frontend': 채팅 UI 개선 + postgres-init 2파일 체계 통합

- 프론트: 채팅 반응형 레이아웃(~1024px)·정렬 보정, 협상 종료 캐시 무효화, 완료세션 재진입 검증 생략, 목록 테이블 레이아웃 정리
- DB: postgres-init 를 00-init.sql(스키마 전체+기존 DB 보정 ALTER) + temp-data.sql(시드) 2파일로 통합, 구 01~05 폐기
- 카드: script TEXT + tone·strategy_type 컬럼, 협상 카드 시드(일반 11장·와일드 5장, 변수 9종 체계)
- 문서: ENUM_TYPE.md 코드값 조견표 신설

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-07 10:44:56 +09:00
commit dc0e78ec73
33 changed files with 790 additions and 432 deletions

View File

@ -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 대로 외부 연결)

View File

@ -112,6 +112,12 @@ class NegotiationService:
)
if err_type != ErrorType.SUCCESS or quote is None:
return ErrorType.NEGO_NOT_FOUND, None, None
# 협상완료 세션은 결과 열람용 재진입(무변경)이므로 견적마감·마감시간 검증을 건너뛴다.
# reject 는 blocked_statuses 로 DONE 을 이미 막으므로 이 분기는 participate 에만 닿는다.
if sess.status == SessionStatus.DONE.value:
return ErrorType.SUCCESS, sess, quote
if quote.status == QuotationStatus.CLOSED.value:
return ErrorType.NEGO_QUOTATION_CLOSED, None, None

View File

@ -221,6 +221,22 @@ async def test_participate_in_progress_no_state_change(client, nego_seed, db_eng
assert await _quotation_status(db_engine, qid) == 2 # 무변경
async def test_participate_done_bypasses_quotation_closed(client, nego_seed, db_engine):
"""협상완료 세션은 결과 열람용 재진입이므로 견적마감·마감시간이 지나도 참여(진입) 가능."""
token = await _login_token(client)
sid, qid = nego_seed["sids"]["C"], nego_seed["qids"]["C"] # 협상완료
async with db_engine.begin() as conn:
await conn.execute(
text("UPDATE quotation.quotations SET status = 3, end_time = now() - make_interval(hours => 1) WHERE qt_id = :qid"),
{"qid": qid},
) # 견적마감 + 마감시간 경과
r = await _participate(client, token, sid)
assert r.json()["result"]["success"] is True
assert r.json()["session_id"] == str(sid)
assert await _session_status(db_engine, sid) == 3 # 무변경 (협상완료 유지)
assert await _quotation_status(db_engine, qid) == 3 # 무변경 (견적마감 유지)
async def test_participate_session_not_found(client, nego_seed):
token = await _login_token(client)
r = await _participate(client, token, str(uuid.uuid4()))

View File

@ -11,10 +11,8 @@
# anchoring 배치: 포트 없음 — 상주 스케줄러(격주 토 00:00 KST), docker logs anchoring 으로 확인
#
# DB 준비(최초 1회): postgres-init 의 SQL 을 대상 DB 에 적용한다.
# psql -h <host> -p <port> -U <user> -f postgres-init/01-schema*.sql (단일 negosium_db + 도메인별 schema)
# psql -h <host> -p <port> -U <user> -f postgres-init/02-learning-schema.sql (agent learning 스키마)
# psql -h <host> -p <port> -U <user> -f postgres-init/03-seed-negodata.sql (negodata 전용 시드: admin / admin1234, company.users)
# psql -h <host> -p <port> -U <user> -f postgres-init/05-anchoring-schema.sql (anchoring 스키마: adjustments·뷰 — schedules/anchoring 소유)
# psql -h <host> -p <port> -U <user> -f postgres-init/00-init.sql (스키마 전체: negosium_db + 도메인·learning·anchoring schema)
# psql -h <host> -p <port> -U <user> -f postgres-init/temp-data.sql (임시 데이터 시드: admin / admin1234, company.users)
services:
negosium-backend:

View File

@ -85,6 +85,7 @@ export function mapMessage(w: ChatMessageWire): ChatMessage {
export function mapInit(r: ChatInitResponse): ChatInitData {
return {
session_id: r.session_id,
session_status: r.session_status,
item_id: r.item_id,
quotation_id: r.quotation_id,
item_name: r.item_name,

View File

@ -1,6 +1,8 @@
import { useEffect, useRef, memo } from 'react'
import { cn } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
import { SessionStatus } from '@/apis/negotiation/negotiation.type'
import type { ChatMessage as ChatMessageType } from '@/features/chat/types'
import { Indicator } from '@/features/chat/components/templates/Indicator'
import { Summary } from '@/features/chat/components/templates/Summary'
@ -10,8 +12,9 @@ import { RejectCM } from '@/features/chat/components/templates/RejectCM'
export function ChatMessage() {
return (
<div className="flex-1 w-full min-h-0 pr-[16px] pt-[64px]">
<div className="chat-scroll h-full overflow-y-auto flex flex-col pl-[140px] pr-[126px]">
<div className="flex-1 w-full min-h-0 pr-[16px]">
{/* pt-[64px]: 첫 메시지를 우측 협상절차 카드 상단과 맞추되, 스크롤 시 여백도 함께 밀려 올라가도록 스크롤 컨테이너 안쪽에 둔다 */}
<div className="chat-scroll h-full overflow-y-auto flex flex-col pt-[64px] pl-[140px] pr-[126px] max-[1350px]:pl-[80px] max-[1350px]:pr-[72px] max-[1180px]:pl-[48px] max-[1180px]:pr-[40px]">
<ChatList />
</div>
</div>
@ -90,6 +93,9 @@ const MessageItem = memo(function MessageItem({
})
const BotMessage = memo(function BotMessage({ message, isFirst }: { message: ChatMessageType; isFirst?: boolean }) {
// 인디케이터는 진행 중인 협상에서만 표시 — 완료/거부 등 결과 열람 재진입 시에는 값이 와도 숨긴다
const sessionStatus = useChatInitStore((s) => s.session_status)
const showIndicator = sessionStatus === SessionStatus.IN_PROGRESS
return (
<div className="mb-[56px]">
<div className={cn('flex flex-col', !isFirst && 'pt-[36px]')}>
@ -97,7 +103,7 @@ const BotMessage = memo(function BotMessage({ message, isFirst }: { message: Cha
</div>
<div className="flex flex-col gap-4 w-full mt-[32px]">
{message.bot_chat_type === 'indicator' && message.indicator_value != null && (
{showIndicator && message.bot_chat_type === 'indicator' && message.indicator_value != null && (
<Indicator number={message.indicator_value} />
)}
{message.bot_chat_type === 'summaryRSP' && message.summary && <Summary data={message.summary} />}

View File

@ -27,10 +27,10 @@ export function ItemImage() {
function Thumb({ src }: { src: string }) {
if (src) {
return <img src={src} alt="상품 이미지" className="rounded-[16px] w-[220px] h-[220px] object-cover" />
return <img src={src} alt="상품 이미지" className="rounded-[16px] w-[220px] h-[220px] max-[1180px]:w-[200px] max-[1180px]:h-[200px] object-cover" />
}
return (
<div className="flex w-[220px] h-[220px] items-center justify-center rounded-[16px] bg-neutral-20 text-neutral-60">
<div className="flex w-[220px] h-[220px] max-[1180px]:w-[200px] max-[1180px]:h-[200px] items-center justify-center rounded-[16px] bg-neutral-20 text-neutral-60">
<ImageIcon size={48} strokeWidth={1.5} />
</div>
)

View File

@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
import { cn } from '@/lib'
import { ItemImage } from '@/features/chat/components/ItemImage'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
import { formatLeadTime } from '@/features/chat/lib/format'
export function ItemSection() {
return (
@ -80,7 +81,7 @@ function ItemInfo() {
<div className="flex items-start self-stretch gap-1 min-w-0">
<div className={cn(textClass, 'w-[100px]')}>{title}</div>
<div
className={cn(textClass, 'w-[150px] break-keep cursor-default')}
className={cn(textClass, 'flex-1 min-w-0 break-words break-keep cursor-default')}
onMouseEnter={(e) => displayData !== '-' && showTooltip(e, displayData)}
onMouseLeave={hideTooltip}
>
@ -100,14 +101,14 @@ function ItemInfo() {
{item_name}
</div>
<div className="flex flex-1 items-start self-stretch mr-2 overflow-y-auto pr-6 pl-8 min-h-0">
<div className="flex flex-col items-start self-stretch flex-[1_0_auto] gap-2 min-w-0">
<div className="flex flex-1 items-start self-stretch mr-2 overflow-y-auto overflow-x-hidden pr-6 pl-8 min-h-0">
<div className="flex flex-col items-start self-stretch flex-1 gap-2 min-w-0">
{renderRow('상품코드', item_code, true)}
{renderRow('단가', formattedPrice, true)}
{renderRow('모델명', item_model_name, true)}
{renderRow('제조사', item_maker_name)}
{renderRow('최소주문수량', item_min_order_quantity)}
{renderRow('리드타임', item_lead_time)}
{renderRow('리드타임', formatLeadTime(item_lead_time))}
<div className="flex items-start self-stretch gap-1 flex-[1_0]">
<div className="body-3 text-neutral-70 w-[100px]">규격</div>

View File

@ -6,21 +6,23 @@ import { Percent, Price } from '@/features/chat/components/userInputs'
import { GO_TO_LIST_TEXT } from '@/features/chat/lib/userButtonConfig'
import type { UserButtonConfig } from '@/features/chat/types'
// max-[1180px]: 채팅 폭이 좁아지면 버튼 패딩·최소폭 축소, 상품목록은 아이콘만 남긴다
const style = {
goToList:
'flex w-[159px] h-[48px] bg-neutral-40 hover:brightness-[0.97] rounded-[999px] items-center justify-center title-2 text-neutral-80 cursor-pointer gap-2 transition-all ease-out hover:scale-[1.01]',
'flex w-[159px] max-[1180px]:w-[48px] h-[48px] bg-neutral-40 hover:brightness-[0.97] rounded-[999px] items-center justify-center title-2 text-neutral-80 cursor-pointer gap-2 max-[1180px]:gap-0 transition-all ease-out hover:scale-[1.01]',
black:
'flex min-w-[120px] px-[32px] h-[48px] bg-primary hover:brightness-[0.97] rounded-[999px] items-center justify-center title-2 text-primary-foreground cursor-pointer whitespace-nowrap transition-all duration-200 ease-out hover:scale-[1.01]',
gray: 'flex min-w-[120px] px-[32px] h-[48px] bg-neutral-60 rounded-[999px] items-center justify-center title-2 text-neutral-00 cursor-pointer whitespace-nowrap transition-all ease-out hover:scale-[1.01]',
'flex min-w-[120px] px-[32px] max-[1180px]:min-w-[100px] max-[1180px]:px-[20px] h-[48px] bg-primary hover:brightness-[0.97] rounded-[999px] items-center justify-center title-2 text-primary-foreground cursor-pointer whitespace-nowrap transition-all duration-200 ease-out hover:scale-[1.01]',
gray: 'flex min-w-[120px] px-[32px] max-[1180px]:min-w-[100px] max-[1180px]:px-[20px] h-[48px] bg-neutral-60 rounded-[999px] items-center justify-center title-2 text-neutral-00 cursor-pointer whitespace-nowrap transition-all ease-out hover:scale-[1.01]',
white:
'flex min-w-[120px] px-[32px] h-[48px] bg-neutral-00 rounded-[999px] items-center justify-center title-2 text-neutral-80 border border-neutral-80 cursor-pointer whitespace-nowrap transition-all ease-out hover:scale-[1.01]',
'flex min-w-[120px] px-[32px] max-[1180px]:min-w-[100px] max-[1180px]:px-[20px] h-[48px] bg-neutral-00 rounded-[999px] items-center justify-center title-2 text-neutral-80 border border-neutral-80 cursor-pointer whitespace-nowrap transition-all ease-out hover:scale-[1.01]',
}
export function UserButton({ type, text, textList, priceErrorMessage }: UserButtonConfig) {
if (type === '') return null
return (
<div className="flex w-full pb-[52px]">
// pr-[16px]: 채팅 스크롤 영역(ChatMessage 바깥 pr-[16px])과 오른쪽 끝 정렬
<div className="flex w-full pb-[52px] pr-[16px]">
<div className="grid grid-cols-[1fr_auto_1fr] items-center w-full">
<div />
<div className="flex justify-center">
@ -58,9 +60,9 @@ function LoadingDots() {
function GoToList() {
const navigate = useNavigate()
return (
<button className={style.goToList} onClick={() => navigate('/list')} aria-label="상품 목록으로 이동">
<List size={24} />
<span>상품 목록</span>
<button className={style.goToList} onClick={() => navigate('/list')} aria-label="상품 목록으로 이동" title="상품 목록">
<List size={24} className="shrink-0" />
<span className="max-[1180px]:hidden">상품 목록</span>
</button>
)
}

View File

@ -9,7 +9,7 @@ export function MenuSection() {
const [isStepOpen, setIsStepOpen] = useState(true)
return (
<div className="flex flex-col w-[360px] h-full pt-[64px] pr-[24px]">
<div className="flex flex-col w-[360px] max-[1350px]:w-[320px] max-[1180px]:w-[280px] h-full pt-[64px] pr-[24px]">
<div className="flex flex-col flex-1 gap-[28px]">
<MDInformation isOpen={isMDOpen} setIsOpen={setIsMDOpen} />
<NegoStep isOpen={isStepOpen} setIsOpen={setIsStepOpen} />

View File

@ -1,4 +1,5 @@
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
import { formatLeadTime } from '@/features/chat/lib/format'
import type { ChatSummary } from '@/features/chat/types'
// 협상 결과 요약 카드
@ -26,7 +27,7 @@ export function Summary({ data }: { data: ChatSummary }) {
<DetailText title="제품 규격" value={data.item_spec || '-'} />
<DetailText title="최소 주문" value={data.item_moq || '-'} />
<DetailText title="배송 형태" value={data.item_delivery_type || '-'} />
<DetailText title="배송 리드타임" value={data.item_lead_time || '-'} />
<DetailText title="배송 리드타임" value={formatLeadTime(data.item_lead_time) || '-'} />
<PriceText price={data.final_price} isVAT={data.item_isVAT} />
</div>
<div className="flex flex-col">

View File

@ -24,7 +24,7 @@ function InputWithUnit({
const errorId = useId()
return (
<div className="relative flex flex-col gap-3">
<div className="relative flex items-center min-w-[480px]">
<div className="relative flex items-center min-w-[480px] max-[1350px]:min-w-[420px] max-[1180px]:min-w-[320px]">
<input
ref={inputRef}
type="text"

View File

@ -9,6 +9,7 @@ import {
chatKeys,
} from '@/apis/chat'
import { ErrorCode, getApiErrorMessage, isApiError } from '@/apis/types'
import { negotiationKeys, SessionStatus } from '@/apis'
import { toast } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
@ -109,6 +110,12 @@ export function useChatController(sessionId: string) {
const s = useChatStore.getState()
if (data.message) s.appendMessage(mapMessage(data.message))
s.setIsLoading(false)
// 협상 종료 전이(완료/거부 등): 목록·init 캐시를 무효화해
// /list 복귀 시 최신 상태를, 재진입 시 최신 session_status 를 보장한다.
if (data.session_status !== SessionStatus.IN_PROGRESS || data.message?.chat_end) {
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
queryClient.invalidateQueries({ queryKey: chatKeys.init(sessionId) })
}
},
onError: (error) => {
const s = useChatStore.getState()
@ -134,6 +141,9 @@ export function useChatController(sessionId: string) {
// 마감/종료/권한 등 더 진행 불가한 상태면 잠시 후 목록으로 복귀
if (TERMINAL_CODES.has(code)) {
s.bindSend(null) // 입력 잠금(추가 전송 차단)
// 서버 기준 세션 상태가 이미 바뀐 것 — 복귀할 목록과 init 캐시를 무효화한다.
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
queryClient.invalidateQueries({ queryKey: chatKeys.init(sessionId) })
setTimeout(() => navigate('/list'), 1500)
}
},

View File

@ -0,0 +1,5 @@
// 리드타임 표시값: 백엔드는 일수 숫자를 문자열로 내려주므로 "일"을 붙인다. 이미 "일"로 끝나면 그대로 둔다.
export function formatLeadTime(value: string): string {
if (!value) return ''
return value.endsWith('일') ? value : `${value}일`
}

View File

@ -8,6 +8,7 @@ interface ChatInitStore extends ChatInitData {
const initialState: ChatInitData = {
session_id: '',
session_status: 0,
item_id: '',
quotation_id: '',
item_name: '',

View File

@ -65,6 +65,7 @@ export type UserButtonConfig = {
export type ChatInitData = {
session_id: string
session_status: number // SessionStatus 코드 (진입 시점 기준)
item_id: string
quotation_id: string
item_name: string

View File

@ -15,7 +15,7 @@ export function TableSection({ items, selectedId, isLoading, onItemClick }: Tabl
return (
<div className="flex flex-1 flex-col w-full min-h-0">
<div className="h-full overflow-auto rounded-[8px] bg-background">
<table className="w-full min-w-[1431px] table-fixed border-separate border-spacing-0">
<table className="w-full min-w-[1431px] border-separate border-spacing-0">
<thead className="sticky top-0 z-10">
<tr className="h-[53px] bg-table-header text-foreground text-lg font-semibold">
{COLUMNS.map((col) => (
@ -50,7 +50,7 @@ export function TableSection({ items, selectedId, isLoading, onItemClick }: Tabl
key={col.key}
className={cn(
col.width,
'px-4 border-b border-border overflow-hidden text-ellipsis whitespace-nowrap',
'px-4 border-b border-border whitespace-nowrap',
col.align,
)}
>

View File

@ -173,6 +173,10 @@
}
/* 채팅 영역 스크롤바 (배경과 구분되게 진하게) */
/* 트랙 상단 64px 여백: 콘텐츠는 맨 위까지 스크롤되지만 스크롤바는 우측 협상절차 카드 상단과 같은 높이에서 시작 */
.chat-scroll::-webkit-scrollbar-track {
margin-top: 64px;
}
.chat-scroll::-webkit-scrollbar-thumb {
background-color: var(--neutral-40);
}

View File

@ -2,19 +2,19 @@ import { type ReactNode } from 'react'
import { Logo } from '@/components'
import { cn } from '@/lib'
// 좌측 폭: list=반응형 비율 / chat=고정 350px
// 좌측 폭: list=반응형 비율 / chat=고정폭 단계 축소
const SIDEBAR_WIDTH = {
list:
'w-[20%] max-w-[320px] ' +
'max-[1520px]:w-[23%] max-[1410px]:w-[25%] ' +
'max-[1180px]:w-[27%] max-[1024px]:w-[29%] max-[960px]:w-[33%]',
chat: 'w-[350px]',
chat: 'w-[350px] max-[1350px]:w-[310px] max-[1180px]:w-[280px]',
} as const
const styles = {
root: 'flex w-full min-h-screen max-h-screen bg-background',
scrollX: 'flex w-full min-h-screen max-h-screen overflow-x-auto overflow-y-hidden',
scrollXInner: 'flex w-full min-h-screen max-h-screen min-w-[1350px] bg-background',
scrollXInner: 'flex w-full min-h-screen max-h-screen min-w-[1024px] bg-background',
sidebar: 'flex flex-col h-screen pt-2 bg-background',
logoHeader:
'flex w-full h-[56px] pl-[32px] pr-[24px] py-[8px] items-center justify-between shrink-0',

View File

@ -19,7 +19,7 @@ export function ChatPage() {
logoAction={<ListNavButton />}
sidebar={<Sidebar />}
header={
<MainHeaderBar className="px-[140px]">
<MainHeaderBar className="px-[140px] max-[1350px]:px-[80px] max-[1180px]:px-[48px]">
<RemainingTime />
</MainHeaderBar>
}

View File

@ -1,12 +1,18 @@
-- 단일 초기화 파일 — 스키마 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
-- ├── company : companies, users, user_tokens, notifications
-- ├── supplier : supplier_users, supplier_user_tokens
-- ├── partner : suppliers, items, item_internet_lowest_prices
-- ├── 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
-- ├── negotiation : sessions, chats, results
-- ├── learning : q_table_versions, q_values, visit_counts, experience_logs, ... (agent 소유)
-- └── anchoring : adjustments + 뷰 (schedules/anchoring 소유)
--
-- 설계 컨벤션
-- - 단일 DB(negosium_db) 안에서 도메인별 schema 로 묶는다. 테이블은 schema 한정자로 참조한다.
@ -204,32 +210,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 (
@ -436,3 +446,223 @@ 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;
-- ============================================================
-- 기존 DB 보정(ALTER) — 재실행 시 기존 DB 를 최신 스키마로 맞춘다
-- ============================================================
-- 위 CREATE TABLE IF NOT EXISTS 는 기존 테이블을 바꾸지 못하므로, 컬럼 추가/타입 변경은
-- 멱등 ALTER 로 여기에 함께 둔다. 신규 DB 에는 전부 no-op.
-- 새 스키마 변경 시 위 테이블 정의와 이 섹션을 동시에 갱신한다 (구 04-alter*.sql 의 역할).
-- 기준선: 2026-07-07 main 스키마. 그보다 오래된 DB 는 git 이력의 04-alter*.sql 을 먼저 적용.
-- [2026-07-07] 협상 카드: script 길이 제한 해제(TEXT) + 톤·전략 분류 컬럼
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, -- 카드 톤(CardTone): 1=강경, 2=정중, 3=우호, 4=중립, 5=단호
ADD COLUMN IF NOT EXISTS strategy_type SMALLINT NULL; -- 전략 유형(CardStrategyType): 1=경쟁, 2=수용, 3=고수, 4=협력, 5=선점, 6=종결
ALTER TABLE card.wild_cards
ADD COLUMN IF NOT EXISTS tone SMALLINT NULL, -- 카드 톤(CardTone): 1=강경, 2=정중, 3=우호, 4=중립, 5=단호
ADD COLUMN IF NOT EXISTS strategy_type SMALLINT NULL; -- 전략 유형(CardStrategyType): 1=경쟁, 2=수용, 3=고수, 4=협력, 5=선점, 6=종결

View File

@ -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);

View File

@ -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
);

View File

@ -1,120 +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;
-- ─────────────────────────────────────────────────────────────
-- [2026-07-07] 협력사 취급상품 매핑: (협력사, 상품) + 공급유형(SupplierType).
-- (신규 DB 는 01-schema*.sql 에 반영됨.)
CREATE TABLE IF NOT EXISTS partner.supplier_items (
supplier_item_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
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=없음/1=유통/2=제조/3=총판
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_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 UNIQUE INDEX IF NOT EXISTS uq_supplier_items ON partner.supplier_items (supplier_id, item_id) WHERE deleted = FALSE;

View File

@ -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;

212
postgres-init/ENUM_TYPE.md Normal file
View File

@ -0,0 +1,212 @@
# 코드값(ENUM) 조견표
`00-init.sql`·`temp-data.sql`에서 SMALLINT 코드로 쓰는 값들의 정리표.
- **DB에는 PG ENUM/CHECK를 걸지 않는다** — 컬럼은 전부 SMALLINT 코드, 의미는 애플리케이션 enum이 부여한다.
- **진실 원천은 각 서비스의 enums.py**이며 이 문서는 조견표다. 어긋나면 enums.py가 맞다.
- negosium: `backend/common/enums.py`
- negodata: `negodata/backend/common/enums.py`
- learning 스키마: agent 소유(`agent/common/database/model/models.py`)
- anchoring 스키마: schedules/anchoring 소유(`schedules/anchoring/docs/개발용.md` §6)
- **갱신 규칙**: 코드값 컬럼을 추가/변경할 때 enums.py와 이 문서를 함께 갱신한다.
## 계정/회사
### AccountStatus / UserStatus / CompanyStatus — 상태
`company.companies.status` · `company.users.status` · `supplier.supplier_users.status`
| 값 | 코드명 | 의미 |
|---|---|---|
| 1 | ACTIVE | 활성 |
| 2 | INACTIVE | 비활성 |
### UserRole — 유저 권한
`company.users.role` · `supplier.supplier_users.role`
| 값 | 코드명 | 의미 |
|---|---|---|
| 1 | USER | 일반 유저 |
| 2 | OWNER / MANAGER | 아래 참고 |
> 값 2의 이름이 서비스마다 다른 것은 **의도된 설계**다 — 두 테이블의 권한 체계가 서로 다르다. negodata(`company.users`)는 `OWNER`(최고관리자: 자기 회사 직원 계정 생성·관리), negosium(`supplier.supplier_users`)은 `MANAGER`(매니저).
### TokenType — 토큰 종류
`company.user_tokens.type` · `supplier.supplier_user_tokens.type`
| 값 | 코드명 |
|---|---|
| 1 | ACCESS |
| 2 | REFRESH |
## 견적/협상
### QuotationType (negosium은 QtType) — 견적/세션 유형
`quotation.quotations.type` · `negotiation.sessions.qt_type`(스냅샷)
| 값 | 코드명 | 의미 |
|---|---|---|
| 1 | RENEGO | 재협상 (1:1) |
| 2 | REQUOTE | 재견적 (1:N) |
| 3 | NEW_NEGO | 신규협상 (1:1) |
| 4 | NEW_QUOTE | 신규견적 (1:N) |
1·2(재)는 기존 데이터 보존을 위해 고정, 신규가 3·4.
### QuotationStatus — 견적 진행 상태
`quotation.quotations.status`
| 값 | 코드명 | 의미 |
|---|---|---|
| 1 | CREATED | 견적생성 |
| 2 | IN_PROGRESS | 견적진행중 |
| 3 | CLOSED | 견적마감 |
### SessionStatus — 협상 세션 진행 상태
`negotiation.sessions.status`
| 값 | 코드명 | 의미 |
|---|---|---|
| 1 | CREATED | 협상생성 |
| 2 | IN_PROGRESS | 협상중 |
| 3 | DONE | 협상완료 |
| 4 | NOT_PARTICIPATED | 미참여 |
| 5 | REJECTED | 협상거부 |
### CloseReason — 견적 마감 사유
`quotation.quotations.close_reason` (미마감이면 NULL)
| 값 | 코드명 | 의미 |
|---|---|---|
| 1 | AWARDED | 낙찰 (기준 충족 단독 최저가) |
| 5 | OPEN_PRICE | 개찰: 최저가가 낙찰 기준 미달 → 낙찰자 미정 |
| 6 | OPEN_EQUAL | 개찰: 동가(최저가 동점) |
| 7 | OPEN_NOSHOW | 개찰: 전원 미응찰 |
| 8 | OPEN_REJECT | 개찰: 협상거부 존재 |
2~4는 폐지된 구 자동재협상(REGEN_*) 코드 — 재사용 금지. 개찰은 결렬(유찰)이 아니라 "낙찰자 미정으로 마감"이며 담당자가 수동 처리한다.
### PriceGateAction — 낙찰 기준(가격게이트) 판정
`quotation.quotations.mid_action`(앵커링가<투찰가≤목표가 구간) · `over_action`(목표가<투찰가 구간)
| 값 | 코드명 | 의미 |
|---|---|---|
| 1 | AWARD | 낙찰(자동) |
| 2 | OPEN | 개찰(낙찰자 미정 마감) |
투찰가≤앵커링가는 항상 낙찰. 1:1 협상은 over 항상 OPEN(mid만 선택), 1:N 경매는 mid=over=AWARD 강제.
### SupplierType — 협력사(공급채널) 유형
`quotation.quotations.supplier_type` · `partner.supplier_items.supply_type` · `anchoring.adjustments.supplier_type`
| 값 | 코드명 | 의미 |
|---|---|---|
| 0 | NONE | 없음(미지정) |
| 1 | DISTRIBUTION | 유통 |
| 2 | MANUFACTURE | 제조 |
| 3 | SOLE_AGENCY | 총판 |
anchoring.adjustments는 1~3만 사용(0 없음 — δ: 유통20/제조10/총판15). `supplier_items.supply_type`은 `quotations.supplier_type`과 의미 단위가 달라 컬럼명을 달리 씀(값 집합은 동일).
### NotificationType — 알림 유형
`company.notifications.type`
| 값 | 코드명 | 의미 |
|---|---|---|
| 1 | SUCCESS | 낙찰(단독 최저가) |
| 2 | REGENERATED | 다음 라운드 자동 생성(동가/미참여) |
| 3 | FAILURE | 결렬: 낙찰 없이 마감 |
| 4 | CREATED | 견적 생성됨(작성 직후) |
## 채팅/카드
### ChatSender — 채팅 발신 주체
`negotiation.chats.sender`
| 값 | 코드명 | 의미 |
|---|---|---|
| 1 | BOT | 고객사(갑) — bot 메시지 |
| 2 | USER | 협력사(을) — user 입력 |
### CardType — 카드 유형
`negotiation.chats.card_type`
| 값 | 코드명 | 의미 |
|---|---|---|
| 1 | NEGO | 일반 협상카드 (card.nego_cards) |
| 2 | WILD | 와일드카드 (card.wild_cards) |
### CardUsageType — 카드 적용 견적 구분
`card.nego_cards.usage_type` · `card.wild_cards.usage_type`
| 값 | 코드명 | 의미 |
|---|---|---|
| 1 | COMMON | 공통(모두 적용) — 기본 |
| 2 | NEW | 신규견적전용 |
| 3 | REUSE | 재견적전용 |
### CardTone — 카드 톤 ⚠️ 앱 enum 미정의
`card.nego_cards.tone` · `card.wild_cards.tone` (2026-07-07 카드 시드와 함께 도입 — enums.py 추가 필요)
| 값 | 의미 |
|---|---|
| 1 | 강경 |
| 2 | 정중 |
| 3 | 우호 |
| 4 | 중립 |
| 5 | 단호 |
### CardStrategyType — 카드 전략 유형 ⚠️ 앱 enum 미정의
`card.nego_cards.strategy_type` · `card.wild_cards.strategy_type` (2026-07-07 카드 시드와 함께 도입 — enums.py 추가 필요)
| 값 | 의미 |
|---|---|
| 1 | 경쟁 |
| 2 | 수용 |
| 3 | 고수 |
| 4 | 협력 |
| 5 | 선점 |
| 6 | 종결 |
> 참고: negodata의 `CardStatus`(ACTIVE=1/INACTIVE=2)는 DB 컬럼이 아니다 — API 응답 전용으로, `card.wild_cards.available`(BOOLEAN)에 매핑된다.
## 배송/기타
### DeliveryType — 배송 유형
`partner.items.delivery_type` · `negotiation.sessions.reject_delivery_type`
| 값 | 코드명 | 의미 |
|---|---|---|
| 1 | SUPPLIER | 협력사배송 |
| 2 | COURIER | 지정택배배송 |
| 3 | PICKUP | 픽업배송 |
### learning.q_table_versions.scope — Q-Table 버전 스코프 (agent 소유)
| 값 | 의미 |
|---|---|
| 1 | base (공유 베이스) |
| 2 | tenant (테넌트 전용) |
### 매핑 미확정(앱에서 자유 사용) 코드 컬럼
| 컬럼 | 비고 |
|---|---|
| `company.companies.industry` | 업종 — 필요한 만큼 숫자에 매핑하여 사용 |
| `partner.item_internet_lowest_prices.website` | 크롤링 대상 사이트 — 앱 enum 매핑 |
| `partner.item_internet_lowest_prices.ai_model` | 사용 AI 모델 — 앱 enum 매핑 |

218
postgres-init/temp-data.sql Normal file
View File

@ -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);

View File

@ -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

View File

@ -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` 로 판별 (확인됨) |

View File

@ -54,11 +54,11 @@
### STEP 1 — DB 스키마 적용 (최초 1회)
```bash
psql -h <DB호스트> -U <계정> -d negosium_db -f postgres-init/05-anchoring-schema.sql # 레포 루트에서
psql -h <DB호스트> -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 — 설정 채우기

View File

@ -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 적용 직전):

View File

@ -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,

View File

@ -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