[feat] negodata: 협상 학습 메뉴 신설 — 카드별 학습 성적 · 앵커링 현황/조정 이력
agent(협상카드 강화학습)와 anchoring(구간별 자동 조정)이 쌓아온 결과를 볼 수 있는 화면이 없어 DB 를 직접 열어야 확인이 됐다. 두 축은 성격이 달라 한 화면에 섞지 않고 탭으로 나눈다. 백엔드(읽기 전용 — 값의 주인은 agent·anchoring): - crud/learning_crud.py: learning·anchoring 스키마 경량 조회(negodata ORM 미매핑 테이블) - services/learning_service.py + router/v1/learning: /v1/learning/cards · /anchoring 프론트: - 사이드바 '협상 학습'(/learning) 추가, 탭 = 협상카드 | 앵커링 - 협상카드: 학습 협상·기록·타결 비율 요약 + 카드별 사용/타결/평균 점수(0 기준 좌우 막대) - 앵커링: 구간별 현재 인하폭 + 조정 이력(표본·성공률·값 변화) 검증: 로컬 실협상 3건으로 데이터 생성 후 두 탭 실화면 확인, tsc·eslint 통과.
This commit is contained in:
parent
37fa65707f
commit
e816bfbba4
153
negodata/backend/crud/learning_crud.py
Normal file
153
negodata/backend/crud/learning_crud.py
Normal file
@ -0,0 +1,153 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Tuple
|
||||
|
||||
from sqlalchemy import and_, asc, column, desc, func, select, table
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import nego_cards, wild_cards
|
||||
from common.enums import ErrorType
|
||||
|
||||
# 학습(learning)·앵커링(anchoring) 스키마는 agent·anchoring 서비스 소유라 negodata ORM 에 없다.
|
||||
# 조회 전용이므로 필요한 컬럼만 경량 정의한다 — 이 값들의 주인은 negodata 가 아니다(쓰기 금지).
|
||||
_EXPERIENCE_LOGS = table(
|
||||
"experience_logs",
|
||||
column("company_id"), column("session_id"), column("card_id"), column("reward"),
|
||||
column("settled_price"), column("is_invalidated"), column("created_at"),
|
||||
schema="learning",
|
||||
)
|
||||
_ANCHORING_CURRENT = table(
|
||||
"current_values",
|
||||
column("company_id"), column("supplier_type"), column("price_range_index"),
|
||||
column("anchoring_value"), column("last_adjusted_at"),
|
||||
schema="anchoring",
|
||||
)
|
||||
_ANCHORING_HISTORY = table(
|
||||
"value_history",
|
||||
column("company_id"), column("supplier_type"), column("price_range_index"),
|
||||
column("anchoring_value_before"), column("anchoring_value_after"),
|
||||
column("sample_count"), column("success_count"), column("success_rate"), column("created_at"),
|
||||
schema="anchoring",
|
||||
)
|
||||
|
||||
|
||||
def _valid(company_id) -> list:
|
||||
"""유효 학습 기록 — 무효화 표시된 행은 뺀다(협상 취소·재생성 시 agent 가 표시)."""
|
||||
return [
|
||||
_EXPERIENCE_LOGS.c.company_id == company_id,
|
||||
_EXPERIENCE_LOGS.c.is_invalidated == False, # noqa: E712
|
||||
]
|
||||
|
||||
|
||||
class ILearningCRUD(ABC):
|
||||
@abstractmethod
|
||||
async def learning_summary(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, tuple]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def card_performance(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def card_names(self, cdb: AsyncSession) -> Tuple[ErrorType, list]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def anchoring_current(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def anchoring_history(self, cdb: AsyncSession, company_id, limit: int) -> Tuple[ErrorType, list]:
|
||||
pass
|
||||
|
||||
|
||||
class LearningCRUD(ILearningCRUD):
|
||||
async def learning_summary(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, tuple]:
|
||||
"""(학습 협상 수, 기록 수, 타결 협상 수, 마지막 학습 시각)."""
|
||||
query = select(
|
||||
func.count(func.distinct(_EXPERIENCE_LOGS.c.session_id)),
|
||||
func.count(),
|
||||
func.count(func.distinct(_EXPERIENCE_LOGS.c.session_id))
|
||||
.filter(_EXPERIENCE_LOGS.c.settled_price.isnot(None)),
|
||||
func.max(_EXPERIENCE_LOGS.c.created_at),
|
||||
).where(and_(*_valid(company_id)))
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "learning_summary failed.", raise_error=False)
|
||||
if err_type != ErrorType.SUCCESS or not rows:
|
||||
return err_type, (0, 0, 0, None)
|
||||
return ErrorType.SUCCESS, tuple(rows[0])
|
||||
|
||||
async def card_performance(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]:
|
||||
"""카드별 (카드번호, 사용 협상 수, 사용 횟수, 평균 보상, 타결 협상 수).
|
||||
|
||||
보상(reward)은 agent 가 협상 결과로 매긴 성적이라 카드의 실제 효과를 비교하는 축이 된다.
|
||||
"""
|
||||
query = (
|
||||
select(
|
||||
_EXPERIENCE_LOGS.c.card_id,
|
||||
func.count(func.distinct(_EXPERIENCE_LOGS.c.session_id)),
|
||||
func.count(),
|
||||
func.avg(_EXPERIENCE_LOGS.c.reward),
|
||||
func.count(func.distinct(_EXPERIENCE_LOGS.c.session_id))
|
||||
.filter(_EXPERIENCE_LOGS.c.settled_price.isnot(None)),
|
||||
)
|
||||
.where(and_(*_valid(company_id), _EXPERIENCE_LOGS.c.card_id.isnot(None)))
|
||||
.group_by(_EXPERIENCE_LOGS.c.card_id)
|
||||
.order_by(desc(func.count()))
|
||||
)
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "card_performance failed.", raise_error=False)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, []
|
||||
return ErrorType.SUCCESS, list(rows or [])
|
||||
|
||||
async def card_names(self, cdb: AsyncSession) -> Tuple[ErrorType, list]:
|
||||
"""카드번호 → (번호, 이름, 와일드 여부). 학습 로그의 card_id 가 카드번호 문자열이다.
|
||||
|
||||
UNION 은 실행기가 SELECT 로 인정하지 않아 두 번 나눠 조회한다.
|
||||
"""
|
||||
out = []
|
||||
for model, is_wild in ((nego_cards, 0), (wild_cards, 1)):
|
||||
query = select(model.number, model.name).where(model.deleted == False) # noqa: E712
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "card_names failed.", raise_error=False)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, []
|
||||
out.extend((number, name, is_wild) for number, name in (rows or []))
|
||||
return ErrorType.SUCCESS, out
|
||||
|
||||
async def anchoring_current(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]:
|
||||
"""현재 앵커링 값 — (협력사유형, 가격대 구간, 값, 마지막 조정 시각). 셀 = 유형 × 가격대."""
|
||||
query = (
|
||||
select(
|
||||
_ANCHORING_CURRENT.c.supplier_type,
|
||||
_ANCHORING_CURRENT.c.price_range_index,
|
||||
_ANCHORING_CURRENT.c.anchoring_value,
|
||||
_ANCHORING_CURRENT.c.last_adjusted_at,
|
||||
)
|
||||
.where(_ANCHORING_CURRENT.c.company_id == company_id)
|
||||
.order_by(asc(_ANCHORING_CURRENT.c.supplier_type), asc(_ANCHORING_CURRENT.c.price_range_index))
|
||||
)
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "anchoring_current failed.", raise_error=False)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, []
|
||||
return ErrorType.SUCCESS, list(rows or [])
|
||||
|
||||
async def anchoring_history(self, cdb: AsyncSession, company_id, limit: int) -> Tuple[ErrorType, list]:
|
||||
"""앵커링 조정 이력(최근순) — 조정마다 표본 수·성공률·값 변화가 남는다."""
|
||||
query = (
|
||||
select(
|
||||
_ANCHORING_HISTORY.c.supplier_type,
|
||||
_ANCHORING_HISTORY.c.price_range_index,
|
||||
_ANCHORING_HISTORY.c.anchoring_value_before,
|
||||
_ANCHORING_HISTORY.c.anchoring_value_after,
|
||||
_ANCHORING_HISTORY.c.sample_count,
|
||||
_ANCHORING_HISTORY.c.success_count,
|
||||
_ANCHORING_HISTORY.c.success_rate,
|
||||
_ANCHORING_HISTORY.c.created_at,
|
||||
)
|
||||
.where(_ANCHORING_HISTORY.c.company_id == company_id)
|
||||
.order_by(desc(_ANCHORING_HISTORY.c.created_at))
|
||||
.limit(limit)
|
||||
)
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "anchoring_history failed.", raise_error=False)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, []
|
||||
return ErrorType.SUCCESS, list(rows or [])
|
||||
@ -20,6 +20,7 @@ import router.v1.card.card
|
||||
import router.v1.quotation.quotation
|
||||
import router.v1.quotation_setting.quotation_setting
|
||||
import router.v1.dashboard.dashboard
|
||||
import router.v1.learning.learning
|
||||
import router.v1.statistics.statistics
|
||||
import router.v1.notification.notification
|
||||
import router.v1.renegotiation.renegotiation
|
||||
@ -79,5 +80,6 @@ app.include_router(router.v1.quotation.quotation.router)
|
||||
app.include_router(router.v1.quotation_setting.quotation_setting.router)
|
||||
app.include_router(router.v1.dashboard.dashboard.router)
|
||||
app.include_router(router.v1.statistics.statistics.router)
|
||||
app.include_router(router.v1.learning.learning.router)
|
||||
app.include_router(router.v1.notification.notification.router)
|
||||
app.include_router(router.v1.renegotiation.renegotiation.router)
|
||||
|
||||
0
negodata/backend/router/v1/learning/__init__.py
Normal file
0
negodata/backend/router/v1/learning/__init__.py
Normal file
19
negodata/backend/router/v1/learning/learning.py
Normal file
19
negodata/backend/router/v1/learning/learning.py
Normal file
@ -0,0 +1,19 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from common.models.gmodel import UserInfo
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
|
||||
from services.learning_service import LearningService
|
||||
from .protocol import Res_AnchoringStatus, Res_LearningStatus
|
||||
|
||||
# 협상 학습 현황 — agent(협상카드 학습)·anchoring(앵커링 조정)이 쌓은 결과를 회사 스코프로 읽는다.
|
||||
router = APIRouter(prefix="/v1/learning", tags=["Learning"], responses={404: {"description": "Not found"}})
|
||||
|
||||
|
||||
@router.get(path="/cards", response_model=Res_LearningStatus, summary="협상카드 학습 현황")
|
||||
async def get_learning_status(service: LearningService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.get_learning_status(user_info.company_id))
|
||||
|
||||
|
||||
@router.get(path="/anchoring", response_model=Res_AnchoringStatus, summary="앵커링 현황·조정 이력")
|
||||
async def get_anchoring_status(service: LearningService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.get_anchoring_status(user_info.company_id))
|
||||
59
negodata/backend/router/v1/learning/protocol.py
Normal file
59
negodata/backend/router/v1/learning/protocol.py
Normal file
@ -0,0 +1,59 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
|
||||
class LearningProtocol(WebPacketProtocol):
|
||||
pass
|
||||
|
||||
|
||||
class LearningKpi(LearningProtocol):
|
||||
learned_sessions: int = 0 # 학습에 반영된 협상 수
|
||||
records: int = 0 # 학습 기록 수(카드 선택 1회 = 1건)
|
||||
settled_sessions: int = 0 # 그중 타결된 협상 수
|
||||
settle_rate: float = 0.0 # 타결 비율
|
||||
last_learned_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class CardPerformanceRow(LearningProtocol):
|
||||
number: str # 카드번호(NGC-001 등)
|
||||
name: Optional[str] = None
|
||||
type: str = "nego" # nego | wild
|
||||
used_sessions: int = 0 # 이 카드를 쓴 협상 수
|
||||
uses: int = 0 # 총 사용 횟수
|
||||
avg_reward: float = 0.0 # 평균 보상 — agent 가 협상 결과로 매긴 성적
|
||||
settled_sessions: int = 0
|
||||
settle_rate: float = 0.0 # 이 카드를 쓴 협상의 타결 비율
|
||||
|
||||
|
||||
class AnchoringCell(LearningProtocol):
|
||||
supplier_type: int = 0
|
||||
supplier_type_label: str = "미지정"
|
||||
price_range_index: int = 0 # 목표가 기준 가격대 구간
|
||||
anchoring_value: float = 0.0 # 앵커링 인하폭(‰)
|
||||
last_adjusted_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class AnchoringHistoryRow(LearningProtocol):
|
||||
supplier_type_label: str = "미지정"
|
||||
price_range_index: int = 0
|
||||
value_before: float = 0.0
|
||||
value_after: float = 0.0
|
||||
sample_count: int = 0 # 조정 판단에 쓴 협상 표본 수
|
||||
success_count: int = 0
|
||||
success_rate: float = 0.0
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class Res_LearningStatus(Res_WebPacketProtocol):
|
||||
kpi: LearningKpi = Field(default_factory=LearningKpi)
|
||||
cards: list[CardPerformanceRow] = []
|
||||
|
||||
|
||||
class Res_AnchoringStatus(Res_WebPacketProtocol):
|
||||
cells: list[AnchoringCell] = []
|
||||
history: list[AnchoringHistoryRow] = []
|
||||
adjusted_count: int = 0
|
||||
100
negodata/backend/services/learning_service.py
Normal file
100
negodata/backend/services/learning_service.py
Normal file
@ -0,0 +1,100 @@
|
||||
from fastapi import Depends
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import quotations
|
||||
from common.enums import DBWRType, ErrorType, SupplierType
|
||||
from crud.learning_crud import ILearningCRUD, LearningCRUD
|
||||
from router.v1.learning.protocol import (
|
||||
AnchoringCell,
|
||||
AnchoringHistoryRow,
|
||||
CardPerformanceRow,
|
||||
LearningKpi,
|
||||
Res_AnchoringStatus,
|
||||
Res_LearningStatus,
|
||||
)
|
||||
|
||||
HISTORY_LIMIT = 50 # 앵커링 조정 이력 표시 개수 — 한 화면에서 훑는 용도
|
||||
|
||||
_SUPPLIER_TYPE_LABEL = {
|
||||
SupplierType.NONE.value: "미지정",
|
||||
SupplierType.DISTRIBUTION.value: "유통",
|
||||
SupplierType.MANUFACTURE.value: "제조",
|
||||
SupplierType.SOLE_AGENCY.value: "총판",
|
||||
}
|
||||
|
||||
|
||||
class LearningService:
|
||||
"""협상 학습 현황 — 협상카드 학습(agent Q-learning)과 앵커링 조정 이력을 읽어 보여준다.
|
||||
|
||||
두 값 모두 negodata 가 만드는 값이 아니라 agent·anchoring 서비스가 쌓은 결과다(읽기 전용).
|
||||
"""
|
||||
|
||||
def __init__(self, crud: ILearningCRUD = Depends(LearningCRUD)):
|
||||
self.crud = crud
|
||||
|
||||
async def get_learning_status(self, company_id: str) -> Res_LearningStatus:
|
||||
res = Res_LearningStatus()
|
||||
# learning·anchoring 의 company_id 는 agent 가 테넌트 키를 그대로 넣는 문자열 컬럼이다(UUID 타입 아님).
|
||||
cid = str(company_id)
|
||||
|
||||
summary = await self._read(lambda s: self.crud.learning_summary(s, cid), default=(0, 0, 0, None))
|
||||
sessions, records, settled, last_at = summary
|
||||
res.kpi = LearningKpi(
|
||||
learned_sessions=int(sessions or 0),
|
||||
records=int(records or 0),
|
||||
settled_sessions=int(settled or 0),
|
||||
settle_rate=round((settled or 0) / sessions, 3) if sessions else 0.0,
|
||||
last_learned_at=last_at,
|
||||
)
|
||||
|
||||
rows = await self._read(lambda s: self.crud.card_performance(s, cid), default=[])
|
||||
names = await self._read(lambda s: self.crud.card_names(s), default=[])
|
||||
name_map = {str(number): (name, is_wild) for number, name, is_wild in names if number}
|
||||
for card_id, used_sessions, uses, avg_reward, settled_sessions in rows:
|
||||
number = str(card_id)
|
||||
name, is_wild = name_map.get(number, (None, 0))
|
||||
res.cards.append(CardPerformanceRow(
|
||||
number=number,
|
||||
name=name,
|
||||
type="wild" if is_wild else "nego",
|
||||
used_sessions=int(used_sessions or 0),
|
||||
uses=int(uses or 0),
|
||||
avg_reward=round(float(avg_reward), 3) if avg_reward is not None else 0.0,
|
||||
settled_sessions=int(settled_sessions or 0),
|
||||
settle_rate=round((settled_sessions or 0) / used_sessions, 3) if used_sessions else 0.0,
|
||||
))
|
||||
return res
|
||||
|
||||
async def get_anchoring_status(self, company_id: str) -> Res_AnchoringStatus:
|
||||
res = Res_AnchoringStatus()
|
||||
cid = str(company_id)
|
||||
|
||||
cells = await self._read(lambda s: self.crud.anchoring_current(s, cid), default=[])
|
||||
for supplier_type, price_range_index, value, adjusted_at in cells:
|
||||
res.cells.append(AnchoringCell(
|
||||
supplier_type=int(supplier_type or 0),
|
||||
supplier_type_label=_SUPPLIER_TYPE_LABEL.get(int(supplier_type or 0), "미지정"),
|
||||
price_range_index=int(price_range_index or 0),
|
||||
anchoring_value=float(value) if value is not None else 0.0,
|
||||
last_adjusted_at=adjusted_at,
|
||||
))
|
||||
|
||||
history = await self._read(lambda s: self.crud.anchoring_history(s, cid, HISTORY_LIMIT), default=[])
|
||||
for st, pri, before, after, sample, success, rate, created_at in history:
|
||||
res.history.append(AnchoringHistoryRow(
|
||||
supplier_type_label=_SUPPLIER_TYPE_LABEL.get(int(st or 0), "미지정"),
|
||||
price_range_index=int(pri or 0),
|
||||
value_before=float(before) if before is not None else 0.0,
|
||||
value_after=float(after) if after is not None else 0.0,
|
||||
sample_count=int(sample or 0),
|
||||
success_count=int(success or 0),
|
||||
success_rate=round(float(rate), 3) if rate is not None else 0.0,
|
||||
created_at=created_at,
|
||||
))
|
||||
res.adjusted_count = len(res.history)
|
||||
return res
|
||||
|
||||
async def _read(self, fn, default):
|
||||
"""crud 한 건 실행 — 실패해도 화면은 떠야 하므로 기본값으로 떨어진다."""
|
||||
err, rows = await DB_SESSION_MNG.execute_lambda(quotations.DBType(), DBWRType.DB_READ.value, fn)
|
||||
return rows if err == ErrorType.SUCCESS else default
|
||||
217
negodata/front/src/api/generated/learning/learning.ts
Normal file
217
negodata/front/src/api/generated/learning/learning.ts
Normal file
@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import {
|
||||
useQuery
|
||||
} from '@tanstack/react-query';
|
||||
import type {
|
||||
DataTag,
|
||||
DefinedInitialDataOptions,
|
||||
DefinedUseQueryResult,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UndefinedInitialDataOptions,
|
||||
UseQueryOptions,
|
||||
UseQueryResult
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import type {
|
||||
ResAnchoringStatus,
|
||||
ResLearningStatus
|
||||
} from '.././model';
|
||||
|
||||
import { customFetch } from '../../mutator/custom-fetch';
|
||||
|
||||
|
||||
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @summary 협상카드 학습 현황
|
||||
*/
|
||||
export const getLearningStatus = (
|
||||
|
||||
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customFetch<ResLearningStatus>(
|
||||
{url: `/v1/learning/cards`, method: 'GET', signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export const getGetLearningStatusQueryKey = () => {
|
||||
return [
|
||||
`/v1/learning/cards`
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
export const getGetLearningStatusQueryOptions = <TData = Awaited<ReturnType<typeof getLearningStatus>>, TError = void>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLearningStatus>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
) => {
|
||||
|
||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetLearningStatusQueryKey();
|
||||
|
||||
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getLearningStatus>>> = ({ signal }) => getLearningStatus(requestOptions, signal);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getLearningStatus>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
}
|
||||
|
||||
export type GetLearningStatusQueryResult = NonNullable<Awaited<ReturnType<typeof getLearningStatus>>>
|
||||
export type GetLearningStatusQueryError = void
|
||||
|
||||
|
||||
export function useGetLearningStatus<TData = Awaited<ReturnType<typeof getLearningStatus>>, TError = void>(
|
||||
options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLearningStatus>>, TError, TData>> & Pick<
|
||||
DefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getLearningStatus>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getLearningStatus>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useGetLearningStatus<TData = Awaited<ReturnType<typeof getLearningStatus>>, TError = void>(
|
||||
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLearningStatus>>, TError, TData>> & Pick<
|
||||
UndefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getLearningStatus>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getLearningStatus>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useGetLearningStatus<TData = Awaited<ReturnType<typeof getLearningStatus>>, TError = void>(
|
||||
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLearningStatus>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
/**
|
||||
* @summary 협상카드 학습 현황
|
||||
*/
|
||||
|
||||
export function useGetLearningStatus<TData = Awaited<ReturnType<typeof getLearningStatus>>, TError = void>(
|
||||
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLearningStatus>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||
|
||||
const queryOptions = getGetLearningStatusQueryOptions(options)
|
||||
|
||||
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
|
||||
query.queryKey = queryOptions.queryKey ;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @summary 앵커링 현황·조정 이력
|
||||
*/
|
||||
export const getAnchoringStatus = (
|
||||
|
||||
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customFetch<ResAnchoringStatus>(
|
||||
{url: `/v1/learning/anchoring`, method: 'GET', signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export const getGetAnchoringStatusQueryKey = () => {
|
||||
return [
|
||||
`/v1/learning/anchoring`
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
export const getGetAnchoringStatusQueryOptions = <TData = Awaited<ReturnType<typeof getAnchoringStatus>>, TError = void>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAnchoringStatus>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
) => {
|
||||
|
||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetAnchoringStatusQueryKey();
|
||||
|
||||
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getAnchoringStatus>>> = ({ signal }) => getAnchoringStatus(requestOptions, signal);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getAnchoringStatus>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
}
|
||||
|
||||
export type GetAnchoringStatusQueryResult = NonNullable<Awaited<ReturnType<typeof getAnchoringStatus>>>
|
||||
export type GetAnchoringStatusQueryError = void
|
||||
|
||||
|
||||
export function useGetAnchoringStatus<TData = Awaited<ReturnType<typeof getAnchoringStatus>>, TError = void>(
|
||||
options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAnchoringStatus>>, TError, TData>> & Pick<
|
||||
DefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getAnchoringStatus>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getAnchoringStatus>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useGetAnchoringStatus<TData = Awaited<ReturnType<typeof getAnchoringStatus>>, TError = void>(
|
||||
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAnchoringStatus>>, TError, TData>> & Pick<
|
||||
UndefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getAnchoringStatus>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getAnchoringStatus>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useGetAnchoringStatus<TData = Awaited<ReturnType<typeof getAnchoringStatus>>, TError = void>(
|
||||
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAnchoringStatus>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
/**
|
||||
* @summary 앵커링 현황·조정 이력
|
||||
*/
|
||||
|
||||
export function useGetAnchoringStatus<TData = Awaited<ReturnType<typeof getAnchoringStatus>>, TError = void>(
|
||||
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAnchoringStatus>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||
|
||||
const queryOptions = getGetAnchoringStatusQueryOptions(options)
|
||||
|
||||
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
|
||||
query.queryKey = queryOptions.queryKey ;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
15
negodata/front/src/api/generated/model/anchoringCell.ts
Normal file
15
negodata/front/src/api/generated/model/anchoringCell.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { AnchoringCellLastAdjustedAt } from './anchoringCellLastAdjustedAt';
|
||||
|
||||
export interface AnchoringCell {
|
||||
supplier_type?: number;
|
||||
supplier_type_label?: string;
|
||||
price_range_index?: number;
|
||||
anchoring_value?: number;
|
||||
last_adjusted_at?: AnchoringCellLastAdjustedAt;
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type AnchoringCellLastAdjustedAt = string | null;
|
||||
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { AnchoringHistoryRowCreatedAt } from './anchoringHistoryRowCreatedAt';
|
||||
|
||||
export interface AnchoringHistoryRow {
|
||||
supplier_type_label?: string;
|
||||
price_range_index?: number;
|
||||
value_before?: number;
|
||||
value_after?: number;
|
||||
sample_count?: number;
|
||||
success_count?: number;
|
||||
success_rate?: number;
|
||||
created_at?: AnchoringHistoryRowCreatedAt;
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type AnchoringHistoryRowCreatedAt = string | null;
|
||||
18
negodata/front/src/api/generated/model/cardPerformanceRow.ts
Normal file
18
negodata/front/src/api/generated/model/cardPerformanceRow.ts
Normal file
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { CardPerformanceRowName } from './cardPerformanceRowName';
|
||||
|
||||
export interface CardPerformanceRow {
|
||||
number: string;
|
||||
name?: CardPerformanceRowName;
|
||||
type?: string;
|
||||
used_sessions?: number;
|
||||
uses?: number;
|
||||
avg_reward?: number;
|
||||
settled_sessions?: number;
|
||||
settle_rate?: number;
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type CardPerformanceRowName = string | null;
|
||||
@ -5,6 +5,10 @@
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export * from './anchoringCell';
|
||||
export * from './anchoringCellLastAdjustedAt';
|
||||
export * from './anchoringHistoryRow';
|
||||
export * from './anchoringHistoryRowCreatedAt';
|
||||
export * from './bodyUploadItemImageV1ItemImagePost';
|
||||
export * from './cardData';
|
||||
export * from './cardDataCondition';
|
||||
@ -18,6 +22,8 @@ export * from './cardDataScript';
|
||||
export * from './cardDataTactic';
|
||||
export * from './cardDataUpdatedAt';
|
||||
export * from './cardDataUserId';
|
||||
export * from './cardPerformanceRow';
|
||||
export * from './cardPerformanceRowName';
|
||||
export * from './cardStatus';
|
||||
export * from './cardType';
|
||||
export * from './cardUsageType';
|
||||
@ -77,6 +83,8 @@ export * from './itemDataVatYn';
|
||||
export * from './itemSupplyType';
|
||||
export * from './itemSupplyTypeSupplierItemId';
|
||||
export * from './itemSupplyTypeSupplierName';
|
||||
export * from './learningKpi';
|
||||
export * from './learningKpiLastLearnedAt';
|
||||
export * from './listCardsParams';
|
||||
export * from './listItemsParams';
|
||||
export * from './listNotificationsParams';
|
||||
@ -274,6 +282,8 @@ export * from './reqUpdateSupplierManagerName';
|
||||
export * from './reqUpdateSupplierName';
|
||||
export * from './reqUpdateSupplierTotalRevenue';
|
||||
export * from './reqUpdateSupplyType';
|
||||
export * from './resAnchoringStatus';
|
||||
export * from './resAnchoringStatusMsg';
|
||||
export * from './resBulkMapByNames';
|
||||
export * from './resBulkMapByNamesMsg';
|
||||
export * from './resCard';
|
||||
@ -327,6 +337,8 @@ export * from './resItemListMsg';
|
||||
export * from './resItemMsg';
|
||||
export * from './resItemSupplyTypeList';
|
||||
export * from './resItemSupplyTypeListMsg';
|
||||
export * from './resLearningStatus';
|
||||
export * from './resLearningStatusMsg';
|
||||
export * from './resLogin';
|
||||
export * from './resLoginMsg';
|
||||
export * from './resLowestPriceResult';
|
||||
|
||||
15
negodata/front/src/api/generated/model/learningKpi.ts
Normal file
15
negodata/front/src/api/generated/model/learningKpi.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { LearningKpiLastLearnedAt } from './learningKpiLastLearnedAt';
|
||||
|
||||
export interface LearningKpi {
|
||||
learned_sessions?: number;
|
||||
records?: number;
|
||||
settled_sessions?: number;
|
||||
settle_rate?: number;
|
||||
last_learned_at?: LearningKpiLastLearnedAt;
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type LearningKpiLastLearnedAt = string | null;
|
||||
18
negodata/front/src/api/generated/model/resAnchoringStatus.ts
Normal file
18
negodata/front/src/api/generated/model/resAnchoringStatus.ts
Normal file
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ErrorInfo } from './errorInfo';
|
||||
import type { ResAnchoringStatusMsg } from './resAnchoringStatusMsg';
|
||||
import type { AnchoringCell } from './anchoringCell';
|
||||
import type { AnchoringHistoryRow } from './anchoringHistoryRow';
|
||||
|
||||
export interface ResAnchoringStatus {
|
||||
result?: ErrorInfo;
|
||||
msg?: ResAnchoringStatusMsg;
|
||||
cells?: AnchoringCell[];
|
||||
history?: AnchoringHistoryRow[];
|
||||
adjusted_count?: number;
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ResAnchoringStatusMsg = string | null;
|
||||
17
negodata/front/src/api/generated/model/resLearningStatus.ts
Normal file
17
negodata/front/src/api/generated/model/resLearningStatus.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ErrorInfo } from './errorInfo';
|
||||
import type { ResLearningStatusMsg } from './resLearningStatusMsg';
|
||||
import type { LearningKpi } from './learningKpi';
|
||||
import type { CardPerformanceRow } from './cardPerformanceRow';
|
||||
|
||||
export interface ResLearningStatus {
|
||||
result?: ErrorInfo;
|
||||
msg?: ResLearningStatusMsg;
|
||||
kpi?: LearningKpi;
|
||||
cards?: CardPerformanceRow[];
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ResLearningStatusMsg = string | null;
|
||||
@ -5,6 +5,7 @@ import {hasSeenOnboarding} from '../features/onboarding/storage';
|
||||
import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout';
|
||||
import LoginPage from '../pages/login';
|
||||
import DashboardPage from '../pages/dashboard';
|
||||
import LearningPage from '../pages/learning';
|
||||
import StatisticsPage from '../pages/statistics';
|
||||
import ForbiddenPage from '../pages/forbidden';
|
||||
import DevDesignPage from '../pages/dev-design';
|
||||
@ -69,6 +70,7 @@ export const router = createBrowserRouter([
|
||||
children: [
|
||||
{path: 'dashboard', Component: DashboardPage},
|
||||
{path: 'statistics', Component: StatisticsPage},
|
||||
{path: 'learning', Component: LearningPage},
|
||||
{path: 'products', Component: ProductsPage},
|
||||
{path: 'partners', Component: PartnersPage},
|
||||
{path: 'quotation', Component: QuotationPage},
|
||||
|
||||
@ -7,6 +7,7 @@ import {showToast} from '@/lib/notify';
|
||||
const PAGE_TO_PATH: Record<PageType, string> = {
|
||||
DASHBOARD: '/dashboard',
|
||||
STATISTICS: '/statistics',
|
||||
LEARNING: '/learning',
|
||||
PRODUCTS: '/products',
|
||||
PARTNERS: '/partners',
|
||||
QUOTATION: '/quotation',
|
||||
|
||||
@ -18,6 +18,7 @@ import {
|
||||
import {
|
||||
LayoutDashboard,
|
||||
BarChart3,
|
||||
Brain,
|
||||
Briefcase,
|
||||
Users,
|
||||
UserCog,
|
||||
@ -56,6 +57,7 @@ const menuGroups: { label?: string; items: MenuItem[] }[] = [
|
||||
items: [
|
||||
{ type: 'DASHBOARD', label: '대시보드', icon: LayoutDashboard, id: 'sidebar-dashboard' },
|
||||
{ type: 'STATISTICS', label: '통계', icon: BarChart3, id: 'sidebar-statistics' },
|
||||
{ type: 'LEARNING', label: '협상 학습', icon: Brain, id: 'sidebar-learning' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -96,6 +98,7 @@ const canSee = (item: MenuItem, role?: string): boolean => {
|
||||
const pageLabelMap: Record<PageType, string> = {
|
||||
DASHBOARD: '대시보드',
|
||||
STATISTICS: '통계',
|
||||
LEARNING: '협상 학습',
|
||||
PRODUCTS: '상품관리',
|
||||
PARTNERS: '협력사관리',
|
||||
QUOTATION: '견적관리',
|
||||
|
||||
203
negodata/front/src/features/learning/LearningView.tsx
Normal file
203
negodata/front/src/features/learning/LearningView.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
import { Bot, CheckCircle2, Layers, Ruler } from 'lucide-react';
|
||||
import { Panel } from '@/features/statistics/components/Panel';
|
||||
import { StatTile } from '@/features/statistics/components/StatTile';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { fmtDateTime } from '@/features/quotations/types';
|
||||
import type { AnchoringData, LearningData } from './types';
|
||||
|
||||
// 협상 학습 현황. 협상카드(강화학습)와 앵커링(자동 조정)은 서로 다른 축이라 탭으로 나눠 각각 한 화면에 담는다.
|
||||
// 두 값 모두 agent·anchoring 서비스가 쌓은 결과를 읽기만 한다.
|
||||
|
||||
export function CardLearningView({ data }: { data: LearningData }) {
|
||||
const best = data.cards.filter((c) => c.uses > 0);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<StatTile label="학습한 협상" value={`${data.learnedSessions}건`} icon={Bot} tone="purple" />
|
||||
<StatTile label="학습 기록" value={`${data.records}건`} icon={Layers} tone="blue" />
|
||||
<StatTile label="타결 협상" value={`${data.settledSessions}건`} icon={CheckCircle2} tone="emerald" />
|
||||
<StatTile label="타결 비율" value={pct(data.settleRate)} icon={Ruler} tone="amber" />
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
title="카드별 학습 성적"
|
||||
subtitle="AI가 협상 결과로 매긴 점수입니다. 점수가 높을수록 그 카드를 쓴 협상이 잘 풀렸다는 뜻입니다."
|
||||
right={
|
||||
data.lastLearnedAt ? (
|
||||
<Typography as="span" variant="small" className="text-[11px] text-muted-foreground">
|
||||
최근 학습 {fmtDateTime(data.lastLearnedAt)}
|
||||
</Typography>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{best.length === 0 ? (
|
||||
<EmptyRow text="아직 학습된 협상이 없습니다. 협상이 진행되면 카드별 성적이 쌓입니다." />
|
||||
) : (
|
||||
<Table className="text-xs">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-24">카드번호</TableHead>
|
||||
<TableHead>카드이름</TableHead>
|
||||
<TableHead className="w-20 text-center">종류</TableHead>
|
||||
<TableHead className="w-24 text-right">사용 협상</TableHead>
|
||||
<TableHead className="w-24 text-right">사용 횟수</TableHead>
|
||||
<TableHead className="w-24 text-right">타결 비율</TableHead>
|
||||
<TableHead className="w-28 text-right">평균 점수</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{best.map((c) => (
|
||||
<TableRow key={c.number}>
|
||||
<TableCell className="font-mono">{c.number}</TableCell>
|
||||
<TableCell className="font-medium">{c.name}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<TypeBadge isWild={c.isWild} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{c.usedSessions}</TableCell>
|
||||
<TableCell className="text-right">{c.uses}</TableCell>
|
||||
<TableCell className="text-right">{pct(c.settleRate)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<RewardBar value={c.avgReward} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnchoringStatusView({ data }: { data: AnchoringData }) {
|
||||
const adjusted = data.cells.filter((c) => c.adjustedAt).length;
|
||||
const avg = data.cells.length
|
||||
? data.cells.reduce((sum, c) => sum + c.value, 0) / data.cells.length
|
||||
: 0;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<StatTile label="관리 중인 구간" value={`${data.cells.length}개`} icon={Layers} tone="purple" />
|
||||
<StatTile label="조정된 구간" value={`${adjusted}개`} icon={CheckCircle2} tone="emerald" />
|
||||
<StatTile label="평균 인하폭" value={permille(avg)} icon={Ruler} tone="blue" />
|
||||
<StatTile label="조정 이력" value={`${data.history.length}건`} icon={Bot} tone="amber" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<Panel title="현재 앵커링 값" subtitle="협력사 유형과 가격대 구간마다 따로 관리합니다.">
|
||||
{data.cells.length === 0 ? (
|
||||
<EmptyRow text="아직 설정된 구간이 없습니다. 협상이 쌓이면 구간별로 값이 만들어집니다." />
|
||||
) : (
|
||||
<Table className="text-xs">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-24">협력사 유형</TableHead>
|
||||
<TableHead className="w-24 text-right">가격대</TableHead>
|
||||
<TableHead className="w-24 text-right">인하폭</TableHead>
|
||||
<TableHead className="text-right">마지막 조정</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.cells.map((c) => (
|
||||
<TableRow key={`${c.supplierType}-${c.priceRange}`}>
|
||||
<TableCell>{c.supplierType}</TableCell>
|
||||
<TableCell className="text-right font-mono">{c.priceRange}</TableCell>
|
||||
<TableCell className="text-right font-mono">{permille(c.value)}</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
{c.adjustedAt ? fmtDateTime(c.adjustedAt) : '조정 전'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="조정 이력" subtitle="표본이 충분히 쌓인 구간만 성공률을 보고 값을 조정합니다.">
|
||||
{data.history.length === 0 ? (
|
||||
<EmptyRow text="아직 조정된 이력이 없습니다." />
|
||||
) : (
|
||||
<Table className="text-xs">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-24">협력사 유형</TableHead>
|
||||
<TableHead className="w-16 text-right">가격대</TableHead>
|
||||
<TableHead className="w-28 text-right">변화</TableHead>
|
||||
<TableHead className="w-20 text-right">표본</TableHead>
|
||||
<TableHead className="w-20 text-right">성공률</TableHead>
|
||||
<TableHead className="text-right">시각</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.history.map((h, i) => (
|
||||
<TableRow key={`${h.supplierType}-${h.priceRange}-${h.at ?? i}`}>
|
||||
<TableCell>{h.supplierType}</TableCell>
|
||||
<TableCell className="text-right font-mono">{h.priceRange}</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{permille(h.before)} → <span className="font-semibold">{permille(h.after)}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{h.samples}</TableCell>
|
||||
<TableCell className="text-right">{pct(h.successRate)}</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
{h.at ? fmtDateTime(h.at) : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 평균 점수는 −1~1 범위라 숫자만으론 감이 안 온다 — 0 기준 좌우 막대로 방향과 크기를 같이 보여준다.
|
||||
function RewardBar({ value }: { value: number }) {
|
||||
const width = Math.min(Math.abs(value), 1) * 50;
|
||||
const positive = value >= 0;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="relative h-2 w-24 rounded bg-muted">
|
||||
<div
|
||||
className={`absolute top-0 h-2 ${positive ? 'bg-emerald-500' : 'bg-rose-500'}`}
|
||||
style={{ left: positive ? '50%' : `${50 - width}%`, width: `${width}%` }}
|
||||
/>
|
||||
<div className="absolute left-1/2 top-0 h-2 w-px bg-border" />
|
||||
</div>
|
||||
<span className="w-12 text-right font-mono">{value.toFixed(2)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeBadge({ isWild }: { isWild: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold ${
|
||||
isWild
|
||||
? 'border-amber-300/45 bg-amber-50 text-amber-700 dark:bg-amber-950/20 dark:text-amber-400'
|
||||
: 'border-primary/25 bg-primary/5 text-primary'
|
||||
}`}
|
||||
>
|
||||
{isWild ? '와일드' : '협상'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyRow({ text }: { text: string }) {
|
||||
return (
|
||||
<Typography as="div" variant="small" className="py-10 text-center text-xs text-muted-foreground">
|
||||
{text}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
function pct(v: number): string {
|
||||
return `${(v * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
// 앵커링 값은 천분율(‰) — 목표가에서 이만큼 낮춘 가격이 앵커가가 된다.
|
||||
function permille(v: number): string {
|
||||
return `${v.toFixed(1)}‰`;
|
||||
}
|
||||
51
negodata/front/src/features/learning/api.ts
Normal file
51
negodata/front/src/features/learning/api.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { useGetAnchoringStatus, useGetLearningStatus } from '@/api/generated/learning/learning';
|
||||
import type { AnchoringData, LearningData } from './types';
|
||||
|
||||
// 생성 API(snake_case·전부 optional) → 화면 타입 매퍼. api/generated 는 수정 금지라 여기서 흡수한다.
|
||||
export function useLearningData(): { data: LearningData; isLoading: boolean } {
|
||||
const { data, isLoading } = useGetLearningStatus();
|
||||
const k = data?.kpi ?? {};
|
||||
return {
|
||||
isLoading,
|
||||
data: {
|
||||
learnedSessions: k.learned_sessions ?? 0,
|
||||
records: k.records ?? 0,
|
||||
settledSessions: k.settled_sessions ?? 0,
|
||||
settleRate: k.settle_rate ?? 0,
|
||||
lastLearnedAt: k.last_learned_at ?? null,
|
||||
cards: (data?.cards ?? []).map((c) => ({
|
||||
number: c.number,
|
||||
name: c.name ?? c.number,
|
||||
isWild: c.type === 'wild',
|
||||
usedSessions: c.used_sessions ?? 0,
|
||||
uses: c.uses ?? 0,
|
||||
avgReward: c.avg_reward ?? 0,
|
||||
settleRate: c.settle_rate ?? 0,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useAnchoringData(): { data: AnchoringData; isLoading: boolean } {
|
||||
const { data, isLoading } = useGetAnchoringStatus();
|
||||
return {
|
||||
isLoading,
|
||||
data: {
|
||||
cells: (data?.cells ?? []).map((c) => ({
|
||||
supplierType: c.supplier_type_label ?? '미지정',
|
||||
priceRange: c.price_range_index ?? 0,
|
||||
value: c.anchoring_value ?? 0,
|
||||
adjustedAt: c.last_adjusted_at ?? null,
|
||||
})),
|
||||
history: (data?.history ?? []).map((h) => ({
|
||||
supplierType: h.supplier_type_label ?? '미지정',
|
||||
priceRange: h.price_range_index ?? 0,
|
||||
before: h.value_before ?? 0,
|
||||
after: h.value_after ?? 0,
|
||||
samples: h.sample_count ?? 0,
|
||||
successRate: h.success_rate ?? 0,
|
||||
at: h.created_at ?? null,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
42
negodata/front/src/features/learning/types.ts
Normal file
42
negodata/front/src/features/learning/types.ts
Normal file
@ -0,0 +1,42 @@
|
||||
// 협상 학습 화면 도메인 타입. api/generated(snake_case·전부 optional)를 api.ts 에서 이 형태로 흡수한다.
|
||||
|
||||
export type CardRow = {
|
||||
number: string;
|
||||
name: string;
|
||||
isWild: boolean;
|
||||
usedSessions: number; // 이 카드를 쓴 협상 수
|
||||
uses: number; // 총 사용 횟수
|
||||
avgReward: number; // 평균 보상 — 협상 결과로 매겨진 성적
|
||||
settleRate: number; // 이 카드를 쓴 협상의 타결 비율
|
||||
};
|
||||
|
||||
export type LearningData = {
|
||||
learnedSessions: number;
|
||||
records: number;
|
||||
settledSessions: number;
|
||||
settleRate: number;
|
||||
lastLearnedAt: string | null;
|
||||
cards: CardRow[];
|
||||
};
|
||||
|
||||
export type AnchorCell = {
|
||||
supplierType: string; // 유통/제조/총판/미지정
|
||||
priceRange: number; // 가격대 구간
|
||||
value: number; // 앵커링 인하폭(‰)
|
||||
adjustedAt: string | null;
|
||||
};
|
||||
|
||||
export type AnchorHistoryRow = {
|
||||
supplierType: string;
|
||||
priceRange: number;
|
||||
before: number;
|
||||
after: number;
|
||||
samples: number; // 조정 판단에 쓴 협상 표본 수
|
||||
successRate: number;
|
||||
at: string | null;
|
||||
};
|
||||
|
||||
export type AnchoringData = {
|
||||
cells: AnchorCell[];
|
||||
history: AnchorHistoryRow[];
|
||||
};
|
||||
63
negodata/front/src/pages/learning.tsx
Normal file
63
negodata/front/src/pages/learning.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { useState } from 'react';
|
||||
import { PageContainer } from '@/components/layout/PageContainer';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { AnchoringStatusView, CardLearningView } from '@/features/learning/LearningView';
|
||||
import { useAnchoringData, useLearningData } from '@/features/learning/api';
|
||||
|
||||
// 협상 학습. AI가 협상을 거치며 쌓은 결과를 보는 화면이다.
|
||||
// 협상카드 = 어떤 카드가 잘 먹혔는지(강화학습), 앵커링 = 구간별 인하폭이 어떻게 조정됐는지.
|
||||
// 두 축은 성격이 달라 탭으로 나눈다 — 각각 한 화면에서 끝나야 훑기 좋다.
|
||||
type Tab = 'cards' | 'anchoring';
|
||||
|
||||
export default function LearningPage() {
|
||||
const [tab, setTab] = useState<Tab>('cards');
|
||||
const learning = useLearningData();
|
||||
const anchoring = useAnchoringData();
|
||||
const isLoading = tab === 'cards' ? learning.isLoading : anchoring.isLoading;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Typography variant="h2">협상 학습</Typography>
|
||||
<div className="flex gap-1 rounded-lg border border-border bg-card p-1">
|
||||
<TabButton active={tab === 'cards'} onClick={() => setTab('cards')}>
|
||||
협상카드
|
||||
</TabButton>
|
||||
<TabButton active={tab === 'anchoring'} onClick={() => setTab('anchoring')}>
|
||||
앵커링
|
||||
</TabButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<Typography variant="muted">학습 현황을 불러오는 중…</Typography>
|
||||
) : tab === 'cards' ? (
|
||||
<CardLearningView data={learning.data} />
|
||||
) : (
|
||||
<AnchoringStatusView data={anchoring.data} />
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
|
||||
active ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -45,4 +45,4 @@ export interface CardTactic {
|
||||
offer_variable?: string; // 제시 가격 변수 명시 지정. 없으면 멘트 파싱(마지막 가격 변수) — 멘트에 있는 변수만 허용
|
||||
}
|
||||
|
||||
export type PageType = 'DASHBOARD' | 'STATISTICS' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'RENEGOTIATION' | 'MEMBERS' | 'SETTINGS' | 'DEV_SETTINGS' | 'DESIGN' | 'NOTIFICATIONS';
|
||||
export type PageType = 'DASHBOARD' | 'STATISTICS' | 'LEARNING' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'RENEGOTIATION' | 'MEMBERS' | 'SETTINGS' | 'DEV_SETTINGS' | 'DESIGN' | 'NOTIFICATIONS';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user