[feat] negodata: 카드 성공률 순위·상품 엑셀 TO-BE 싱크·회사설정 JSON 입출력·목록 포털형 UI

- 협상카드 성공률(사용 세션 대비 타결) 집계 추가, 견적 생성 카드선택 순위 정렬·통계 TOP5
- 상품 엑셀: 품목코드·모델명 라벨화, 리드타임 suffix 제거, 공급사 컬럼 신설(등록 후 supplier_items 매핑)
- 설정 화면 JSON 불러오기/내보내기(빈 값은 미갱신), IMK 설정 JSON 보관
- 설정·회원 페이지 새로고침 forbidden 수정(자식 loader 가 initAuth 대기)
- 협력사·견적 목록도 포털형 카드(툴바+테이블 결합)로 통일
This commit is contained in:
Mina Choi 2026-07-22 17:24:58 +09:00
parent fa9113da55
commit 03322d6ca3
22 changed files with 448 additions and 41 deletions

View File

@ -1,12 +1,12 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Optional, Tuple from typing import Optional, Tuple
from sqlalchemy import select, func, and_, or_, update from sqlalchemy import select, func, and_, or_, update, case
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import users from common.database.model.models import users, chats, sessions
from common.enums import ErrorType from common.enums import ErrorType, SessionStatus
from common.logger import LOG from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
@ -37,6 +37,10 @@ class ICardCRUD(ABC):
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]: async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
pass pass
@abstractmethod
async def card_success_map(self, cdb: AsyncSession) -> Tuple[ErrorType, dict]:
pass
@abstractmethod @abstractmethod
async def owner_company_id(self, cdb: AsyncSession, owner_user_id) -> Tuple[ErrorType, Optional[object]]: async def owner_company_id(self, cdb: AsyncSession, owner_user_id) -> Tuple[ErrorType, Optional[object]]:
pass pass
@ -79,6 +83,28 @@ class CardCRUD(ICardCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0 return ErrorType.DB_RUN_FAILED, [], 0
async def card_success_map(self, cdb: AsyncSession) -> Tuple[ErrorType, dict]:
# 카드별 성공률: 카드 사용(card_used_yn) 채팅이 속한 세션의 타결(DONE) 비율.
# {card_id(UUID): (used_sessions, won_sessions)}. 성공=세션 DONE(협상완료).
try:
stmt = (
select(
chats.card_id,
func.count(func.distinct(chats.session_id)).label("used"),
func.count(func.distinct(case((sessions.status == SessionStatus.DONE.value, chats.session_id)))).label("won"),
)
.join(sessions, sessions.session_id == chats.session_id)
.where(chats.card_used_yn == True, chats.card_id.isnot(None), chats.deleted == False) # noqa: E712
.group_by(chats.card_id)
)
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
if err != ErrorType.SUCCESS:
return err, {}
return ErrorType.SUCCESS, {r[0]: (int(r[1] or 0), int(r[2] or 0)) for r in rows}
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, {}
async def get_by_id(self, cdb: AsyncSession, model, pk_col, card_id) -> Tuple[ErrorType, object]: async def get_by_id(self, cdb: AsyncSession, model, pk_col, card_id) -> Tuple[ErrorType, object]:
try: try:
query = select(model).where(pk_col == card_id, model.deleted == False).limit(1) # noqa: E712 query = select(model).where(pk_col == card_id, model.deleted == False).limit(1) # noqa: E712

View File

@ -55,6 +55,8 @@ class CardData(WebPacketProtocol):
memo: Optional[str] = None memo: Optional[str] = None
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
success_rate: float = 0.0 # 카드 성공률(사용 세션 중 타결 비율). #12 순위용
used_count: int = 0 # 카드 사용 세션 수(표본)
class Res_Card(Res_WebPacketProtocol): class Res_Card(Res_WebPacketProtocol):

View File

@ -137,6 +137,17 @@ class CardService:
merged.sort(key=lambda c: c.created_at or "", reverse=True) merged.sort(key=lambda c: c.created_at or "", reverse=True)
page = merged[pg.skip : pg.skip + pg.size] page = merged[pg.skip : pg.skip + pg.size]
# 카드 성공률(#12) — 카드 사용→타결 집계를 page 카드에 매핑.
_e, success_map = await DB_SESSION_MNG.execute_lambda(
nego_cards.DBType(), DBWRType.DB_READ.value,
lambda s: self.card_crud.card_success_map(s),
)
success_map = success_map or {}
for c in page:
used, won = success_map.get(c.nego_card_id, (0, 0))
c.used_count = used
c.success_rate = (won / used) if used else 0.0
# 작성자명 배치 조인 — 페이지 카드의 작성자 id를 모아 IN 쿼리 1회로 {id:name} 맵을 만들어 매핑. # 작성자명 배치 조인 — 페이지 카드의 작성자 id를 모아 IN 쿼리 1회로 {id:name} 맵을 만들어 매핑.
# (공용 카드는 user_id=NULL → 맵에 없어 creator_name=None). 행마다 조회하지 않으므로 부하 없음. # (공용 카드는 user_id=NULL → 맵에 없어 creator_name=None). 행마다 조회하지 않으므로 부하 없음.
author_ids = list({c.user_id for c in page if c.user_id is not None}) author_ids = list({c.user_id for c in page if c.user_id is not None})

View File

@ -33,4 +33,6 @@ export interface CardData {
memo?: CardDataMemo; memo?: CardDataMemo;
created_at?: CardDataCreatedAt; created_at?: CardDataCreatedAt;
updated_at?: CardDataUpdatedAt; updated_at?: CardDataUpdatedAt;
success_rate?: number;
used_count?: number;
} }

View File

@ -84,15 +84,21 @@ export const router = createBrowserRouter([
{path: 'cards', Component: CardsPage}, {path: 'cards', Component: CardsPage},
{path: 'notifications', Component: NotificationsPage}, {path: 'notifications', Component: NotificationsPage},
{ {
// 최고관리자 전용. 부모 loader 가 initAuth 를 마친 뒤 실행되므로 유저 상태가 복원돼 있다. // 최고관리자 전용. 자식 loader 는 부모와 병렬 실행되므로 여기서도 initAuth 를 기다린다(멱등).
path: 'members', path: 'members',
loader: () => (hasRole('최고관리자') ? null : redirect('/forbidden')), loader: async () => {
await initAuth();
return hasRole('최고관리자') ? null : redirect('/forbidden');
},
Component: MembersPage, Component: MembersPage,
}, },
{ {
// 최고관리자 전용. 회사 브랜딩/용어/커스텀필드 설정. // 최고관리자 전용. 회사 브랜딩/용어/커스텀필드 설정. (자식 loader 는 부모와 병렬 → initAuth 대기 필수)
path: 'settings', path: 'settings',
loader: () => (hasRole('최고관리자') ? null : redirect('/forbidden')), loader: async () => {
await initAuth();
return hasRole('최고관리자') ? null : redirect('/forbidden');
},
Component: SettingsPage, Component: SettingsPage,
}, },
], ],

View File

@ -28,6 +28,8 @@ export function mapCardData(c: CardData): NegotiationCard {
triggerCondition: c.condition ?? undefined, triggerCondition: c.condition ?? undefined,
memo: c.memo ?? undefined, memo: c.memo ?? undefined,
creatorName: c.creator_name ?? undefined, creatorName: c.creator_name ?? undefined,
successRate: c.success_rate ?? 0,
usedCount: c.used_count ?? 0,
}; };
} }

View File

@ -13,6 +13,8 @@ type PartnerTableProps = {
totalCount: number; totalCount: number;
pageSize: number; pageSize: number;
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
/** 툴바와 한 카드로 붙일 때 테이블 자체 테두리/라운드를 죽이는 용도 */
className?: string;
}; };
export function PartnerTable({ export function PartnerTable({
@ -25,9 +27,11 @@ export function PartnerTable({
totalCount, totalCount,
pageSize, pageSize,
onPageChange, onPageChange,
className,
}: PartnerTableProps) { }: PartnerTableProps) {
return ( return (
<DataTable <DataTable
className={className}
data={data} data={data}
rowKey={(part) => part.supplier_id} rowKey={(part) => part.supplier_id}
onRowClick={onRowClick} onRowClick={onRowClick}

View File

@ -8,6 +8,7 @@ import { customFetch } from '@/api/mutator/custom-fetch';
import { Typography } from '@/components/ui/typography'; import { Typography } from '@/components/ui/typography';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useListSuppliers } from '@/api/generated/supplier/supplier';
import { useCompanySettings, useLabels } from '@/features/settings/useCompanySettings'; import { useCompanySettings, useLabels } from '@/features/settings/useCompanySettings';
import type { CustomFieldDef } from '@/features/settings/catalog'; import type { CustomFieldDef } from '@/features/settings/catalog';
import type { Product } from '../types'; import type { Product } from '../types';
@ -22,6 +23,7 @@ type RawRow = {
category: string; category: string;
spec: string; spec: string;
manufacturer: string; manufacturer: string;
suppliers: string; // 공급사명(복수는 쉼표/세미콜론 구분) → 등록 후 supplier_items 매핑
made_in: string; made_in: string;
price: number; price: number;
minPrice: number; minPrice: number;
@ -53,11 +55,12 @@ const isYnToken = (s: string): boolean =>
// base 헤더는 구양식 파일 호환용 별칭으로 계속 인식한다. // base 헤더는 구양식 파일 호환용 별칭으로 계속 인식한다.
const STANDARD_COLUMNS: { base: string; key: Exclude<keyof RawRow, 'id' | 'rowNum' | 'custom'>; labelKey?: string; suffix?: string }[] = [ const STANDARD_COLUMNS: { base: string; key: Exclude<keyof RawRow, 'id' | 'rowNum' | 'custom'>; labelKey?: string; suffix?: string }[] = [
{ base: '상품명', key: 'name' }, { base: '상품명', key: 'name' },
{ base: '상품코드', key: 'code' }, { base: '상품코드', key: 'code', labelKey: 'item.code' },
{ base: '모델번호', key: 'model_name' }, { base: '모델번호', key: 'model_name', labelKey: 'item.model_name' },
{ base: '카테고리', key: 'category', labelKey: 'category' }, { base: '카테고리', key: 'category', labelKey: 'category' },
{ base: '규격', key: 'spec' }, { base: '규격', key: 'spec' },
{ base: '제조사', key: 'manufacturer' }, { base: '제조사', key: 'manufacturer' },
{ base: '공급사', key: 'suppliers' },
{ base: '원산지', key: 'made_in' }, { base: '원산지', key: 'made_in' },
{ base: '상품 단가', key: 'price', labelKey: 'item.price' }, { base: '상품 단가', key: 'price', labelKey: 'item.price' },
{ base: '최저한도', key: 'minPrice' }, { base: '최저한도', key: 'minPrice' },
@ -65,7 +68,7 @@ const STANDARD_COLUMNS: { base: string; key: Exclude<keyof RawRow, 'id' | 'rowNu
{ base: '판매가', key: 'selling_price' }, { base: '판매가', key: 'selling_price' },
{ base: '이미지URL', key: 'image_url' }, { base: '이미지URL', key: 'image_url' },
{ base: '최소주문수량', key: 'moq' }, { base: '최소주문수량', key: 'moq' },
{ base: '리드타임(일)', key: 'lead_time', labelKey: 'lead_time', suffix: '(일)' }, { base: '리드타임(일)', key: 'lead_time', labelKey: 'lead_time' },
{ base: '단위', key: 'quantity_unit' }, { base: '단위', key: 'quantity_unit' },
{ base: '배송형태', key: 'delivery_type' }, { base: '배송형태', key: 'delivery_type' },
{ base: '부가세포함(Y/N)', key: 'vat_yn' }, { base: '부가세포함(Y/N)', key: 'vat_yn' },
@ -108,6 +111,10 @@ function buildColumns(label: LabelFn, itemFields: CustomFieldDef[]): UploadColum
return [...standard, ...custom]; return [...standard, ...custom];
} }
// 공급사 셀 → 이름 배열(쉼표·세미콜론·슬래시 구분, 공백 제거).
const parseSupplierNames = (s: string): string[] =>
s.split(/[,;/]/).map((v) => v.trim()).filter(Boolean);
// 배송형태 라벨 → 코드. 기본 라벨과 회사 설정 라벨(직납 등)을 모두 인식한다. // 배송형태 라벨 → 코드. 기본 라벨과 회사 설정 라벨(직납 등)을 모두 인식한다.
function buildDeliveryMap(label: LabelFn): Record<string, number> { function buildDeliveryMap(label: LabelFn): Record<string, number> {
const map: Record<string, number> = { 협력사배송: 1, 지정택배배송: 2, 픽업배송: 3 }; const map: Record<string, number> = { 협력사배송: 1, 지정택배배송: 2, 픽업배송: 3 };
@ -154,7 +161,8 @@ export function downloadProductTemplate(label: LabelFn, itemFields: CustomFieldD
type ExcelUploadModalProps = { type ExcelUploadModalProps = {
open: boolean; open: boolean;
products: Product[]; // 코드 중복 검사용 products: Product[]; // 코드 중복 검사용
onConfirm: (rows: ItemCreate[]) => Promise<BulkFailure[]>; // supplierIdsByCode: 상품코드 → 공급사 supplier_id 목록. 등록된 item_id 로 supplier_items 매핑에 쓴다.
onConfirm: (rows: ItemCreate[], supplierIdsByCode: Record<string, string[]>) => Promise<BulkFailure[]>;
onClose: () => void; onClose: () => void;
}; };
@ -168,6 +176,7 @@ function validateRows(
deliveryLabels: string[], deliveryLabels: string[],
priceLabel: string, priceLabel: string,
itemFields: CustomFieldDef[], itemFields: CustomFieldDef[],
supplierIdByName: Map<string, string>,
): ValidatedRow[] { ): ValidatedRow[] {
return rows.map((row) => { return rows.map((row) => {
const fail = (message: string): ValidatedRow => ({ ...row, status: '오류', message }); const fail = (message: string): ValidatedRow => ({ ...row, status: '오류', message });
@ -195,6 +204,9 @@ function validateRows(
if (row.image_url.trim() && !/^https?:\/\//i.test(row.image_url.trim())) { if (row.image_url.trim() && !/^https?:\/\//i.test(row.image_url.trim())) {
return fail('이미지URL - http:// 또는 https:// 로 시작하는 주소여야 합니다.'); return fail('이미지URL - http:// 또는 https:// 로 시작하는 주소여야 합니다.');
} }
// 공급사는 등록된 협력사명과 정확히 일치해야 매핑 가능(복수 지정 시 전부).
const unknown = parseSupplierNames(row.suppliers).filter((n) => !supplierIdByName.has(n));
if (unknown.length > 0) return fail(`공급사 - 등록되지 않은 협력사입니다: ${unknown.join(', ')}`);
// 커스텀필드 형식 검증(값이 있을 때만). boolean=Y/N 토큰, number=숫자. // 커스텀필드 형식 검증(값이 있을 때만). boolean=Y/N 토큰, number=숫자.
for (const f of itemFields) { for (const f of itemFields) {
const v = (row.custom[f.key] ?? '').trim(); const v = (row.custom[f.key] ?? '').trim();
@ -254,6 +266,13 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
const itemFields = useMemo(() => settings.item_fields ?? [], [settings.item_fields]); const itemFields = useMemo(() => settings.item_fields ?? [], [settings.item_fields]);
const columns = useMemo(() => buildColumns(label, itemFields), [label, itemFields]); const columns = useMemo(() => buildColumns(label, itemFields), [label, itemFields]);
const deliveryMap = useMemo(() => buildDeliveryMap(label), [label]); const deliveryMap = useMemo(() => buildDeliveryMap(label), [label]);
// 공급사 컬럼 검증·매핑용 협력사 전체 목록(이름 → id).
const supplierList = useListSuppliers({ size: 1000 });
const supplierIdByName = useMemo(() => {
const m = new Map<string, string>();
(supplierList.data?.suppliers ?? []).forEach((sp) => m.set(sp.name.trim(), sp.supplier_id));
return m;
}, [supplierList.data]);
const [excelFile, setExcelFile] = useState<string | null>(null); const [excelFile, setExcelFile] = useState<string | null>(null);
const [rows, setRows] = useState<RawRow[]>([]); const [rows, setRows] = useState<RawRow[]>([]);
@ -266,9 +285,9 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
() => validateRows( () => validateRows(
rows, products, serverErrors, deliveryMap, rows, products, serverErrors, deliveryMap,
[1, 2, 3].map((c) => label(`delivery_type.${c}`)), [1, 2, 3].map((c) => label(`delivery_type.${c}`)),
label('item.price'), itemFields, label('item.price'), itemFields, supplierIdByName,
), ),
[rows, products, serverErrors, deliveryMap, label, itemFields], [rows, products, serverErrors, deliveryMap, label, itemFields, supplierIdByName],
); );
const validRows = validated.filter((r) => r.status === '정상'); const validRows = validated.filter((r) => r.status === '정상');
const validCount = validRows.length; const validCount = validRows.length;
@ -295,7 +314,7 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
const row: RawRow = { const row: RawRow = {
id: `row-${i + 1}`, id: `row-${i + 1}`,
rowNum: i + 2, rowNum: i + 2,
name: '', code: '', model_name: '', category: '', spec: '', manufacturer: '', made_in: '', name: '', code: '', model_name: '', category: '', spec: '', manufacturer: '', suppliers: '', made_in: '',
price: 0, minPrice: 0, purchase_price: 0, selling_price: 0, price: 0, minPrice: 0, purchase_price: 0, selling_price: 0,
image_url: '', moq: '', lead_time: 0, quantity_unit: '', delivery_type: '', vat_yn: '', delivery_fee_yn: '', image_url: '', moq: '', lead_time: 0, quantity_unit: '', delivery_type: '', vat_yn: '', delivery_fee_yn: '',
custom: {}, custom: {},
@ -352,7 +371,12 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
return; return;
} }
try { try {
const failures = await onConfirm(validRows.map((r) => toItemCreate(r, deliveryMap, itemFields))); const supplierIdsByCode: Record<string, string[]> = {};
for (const r of validRows) {
const ids = parseSupplierNames(r.suppliers).map((n) => supplierIdByName.get(n)!).filter(Boolean);
if (ids.length > 0) supplierIdsByCode[r.code] = ids;
}
const failures = await onConfirm(validRows.map((r) => toItemCreate(r, deliveryMap, itemFields)), supplierIdsByCode);
const okCount = validRows.length - failures.length; const okCount = validRows.length - failures.length;
if (failures.length === 0) { if (failures.length === 0) {
showToast(`${okCount}개 상품이 서버에 일괄 등록되었습니다.`, 'success'); showToast(`${okCount}개 상품이 서버에 일괄 등록되었습니다.`, 'success');

View File

@ -233,7 +233,7 @@ export function ProductFormSheet({
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{/* Code */} {/* Code */}
<div className="space-y-1"> <div className="space-y-1">
<Typography as="label" variant="label"></Typography> <Typography as="label" variant="label">{label('item.code')}</Typography>
<Input <Input
id="form-product-code" id="form-product-code"
type="text" type="text"
@ -327,7 +327,7 @@ export function ProductFormSheet({
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{/* Model Name */} {/* Model Name */}
<div className="space-y-1"> <div className="space-y-1">
<Typography as="label" variant="label"></Typography> <Typography as="label" variant="label">{label('item.model_name')}</Typography>
<Input <Input
id="form-product-model" id="form-product-model"
type="text" type="text"

View File

@ -6,6 +6,7 @@ import {
updateItem, updateItem,
deleteItem, deleteItem,
} from '@/api/generated/item/item'; } from '@/api/generated/item/item';
import { createSupplierItem } from '@/api/generated/supplier-item/supplier-item';
import type { ListItemsParams } from '@/api/generated/model/listItemsParams'; import type { ListItemsParams } from '@/api/generated/model/listItemsParams';
import type { ReqCreateItem } from '@/api/generated/model/reqCreateItem'; import type { ReqCreateItem } from '@/api/generated/model/reqCreateItem';
import type { ReqUpdateItem } from '@/api/generated/model/reqUpdateItem'; import type { ReqUpdateItem } from '@/api/generated/model/reqUpdateItem';
@ -68,12 +69,32 @@ export function useProducts(params: ListItemsParams) {
}; };
// 엑셀 일괄 등록 — 행별로 순차 생성하되 실패해도 멈추지 않고 사유를 모은다. // 엑셀 일괄 등록 — 행별로 순차 생성하되 실패해도 멈추지 않고 사유를 모은다.
// 서버 DB 검증(중복코드 등)에 걸린 행은 BulkFailure 로 반환 → 모달이 해당 행만 사유와 함께 남긴다. // 서버 DB 검증(중복코드 등)에 걸린 행은 BulkFailure 로 반환 → 모달이 해당 행만 사유와 함께 남긴다.
const bulkCreate = async (rows: ReqCreateItem[]): Promise<BulkFailure[]> => { // supplierIdsByCode 가 있으면 생성된 item_id 로 공급사(supplier_items) 매핑까지 이어 만든다.
const bulkCreate = async (
rows: ReqCreateItem[],
supplierIdsByCode: Record<string, string[]> = {},
): Promise<BulkFailure[]> => {
const failures: BulkFailure[] = []; const failures: BulkFailure[] = [];
for (const row of rows) { for (const row of rows) {
try { try {
const msg = itemError(await createItem(row)); const res = await createItem(row);
if (msg) failures.push({ code: row.code ?? '', message: msg }); const msg = itemError(res);
if (msg) {
failures.push({ code: row.code ?? '', message: msg });
continue;
}
const itemId = res.item?.item_id;
const supplierIds = supplierIdsByCode[row.code ?? ''] ?? [];
if (itemId) {
for (const supplierId of supplierIds) {
// 매핑 실패는 상품 등록 자체를 실패로 보지 않고 사유만 남긴다.
try {
await createSupplierItem({ supplier_id: supplierId, item_id: itemId });
} catch {
failures.push({ code: row.code ?? '', message: '공급사 매핑에 실패했습니다.' });
}
}
}
} catch (err) { } catch (err) {
failures.push({ code: row.code ?? '', message: err instanceof Error ? err.message : '등록 실패' }); failures.push({ code: row.code ?? '', message: err instanceof Error ? err.message : '등록 실패' });
} }

View File

@ -1,4 +1,4 @@
import { useState, useMemo } from 'react'; import { useState, useMemo, useEffect, useRef } from 'react';
import { X, PlusSquare, ArrowRight, Loader2, Gavel, CheckCheck } from 'lucide-react'; import { X, PlusSquare, ArrowRight, Loader2, Gavel, CheckCheck } from 'lucide-react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item'; import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item';
@ -162,14 +162,23 @@ export function QuotationCreateModal({
}); });
const cardRows = cardQ ? (cardSearch.data?.cards ?? []).map(mapCardData) : cards; const cardRows = cardQ ? (cardSearch.data?.cards ?? []).map(mapCardData) : cards;
const cardOptions: ComboOption[] = cardRows // 성공률(사용 세션 중 타결 비율) 내림차순 — 표본 없는 카드는 뒤로. 상위 3개에 1·2·3위 배지가 붙는다.
const rankedCards = cardRows
.filter((c) => !c.isWildcard || c.status === 'ACTIVE') .filter((c) => !c.isWildcard || c.status === 'ACTIVE')
.map((card) => ({ .slice()
.sort((a, b) => b.successRate - a.successRate || b.usedCount - a.usedCount);
const cardOptions: ComboOption[] = rankedCards
.map((card, i) => ({
id: card.id, id: card.id,
label: card.title, label: card.title,
node: ( node: (
<div> <div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{card.usedCount > 0 && (
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${i < 3 ? 'bg-indigo-50 text-indigo-700' : 'bg-zinc-100 text-zinc-500'}`}>
{i + 1} · {Math.round(card.successRate * 100)}%
</span>
)}
<Typography as="span" variant="small" className="text-muted-foreground font-mono block leading-none">{card.code}</Typography> <Typography as="span" variant="small" className="text-muted-foreground font-mono block leading-none">{card.code}</Typography>
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'}`}> <span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'}`}>
{card.isWildcard ? '와일드' : '협상'} {card.isWildcard ? '와일드' : '협상'}
@ -180,6 +189,29 @@ export function QuotationCreateModal({
), ),
})); }));
// 1·2·3위 배지가 붙는 카드(상위 3개, 사용이력 있는 것만) — 기본 선택 대상.
const topRankedCards = rankedCards.slice(0, 3).filter((c) => c.usedCount > 0);
const topRankedKey = topRankedCards.map((c) => c.id).join(',');
const autoSelectedRef = useRef(false);
// 모달을 열면 추천 상위 3개를 기본 선택해 둔다. 열려 있는 동안 1회만 — 이후 사용자의 추가/해제는 건드리지 않는다.
useEffect(() => {
if (!open) {
autoSelectedRef.current = false;
return;
}
if (autoSelectedRef.current || topRankedCards.length === 0) return; // 목록 로드 전이면 다음 렌더에 재시도
autoSelectedRef.current = true;
setCardDetails((m) => {
const next = new Map(m);
topRankedCards.forEach((c) => next.set(c.id, { code: c.code, title: c.title, isWildcard: c.isWildcard }));
return next;
});
setSelectedCardIds((prev) => (prev.length > 0 ? prev : topRankedCards.map((c) => c.id)));
// topRankedKey = 목록이 확정된 시점만 감지 (배열 재생성으로 매 렌더 도는 것 방지)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, topRankedKey]);
// 선택된 카드 표시행 — 캐시에서 번호/유형/카드명을 읽어 검색어와 무관하게 유지한다. // 선택된 카드 표시행 — 캐시에서 번호/유형/카드명을 읽어 검색어와 무관하게 유지한다.
const selectedCardRows = selectedCardIds.map((id) => { const selectedCardRows = selectedCardIds.map((id) => {
const d = cardDetails.get(id); const d = cardDetails.get(id);

View File

@ -23,6 +23,8 @@ type QuotationTableProps = {
/** 견적번호 클릭 → 그 번호로 목록 필터(같은 체인의 차수만 모아 보기). */ /** 견적번호 클릭 → 그 번호로 목록 필터(같은 체인의 차수만 모아 보기). */
onFilterChain?: (number: string) => void; onFilterChain?: (number: string) => void;
footer?: ReactNode; footer?: ReactNode;
/** 툴바와 한 카드로 붙일 때 테이블 자체 테두리/라운드를 죽이는 용도 */
className?: string;
}; };
const statusBadgeClass = (status?: number | null) => { const statusBadgeClass = (status?: number | null) => {
@ -50,9 +52,10 @@ const outcomeBadgeClass = (state: ChainRoundState) => {
} }
}; };
export function QuotationTable({ data, products, onOpenDetail, onFilterChain, footer }: QuotationTableProps) { export function QuotationTable({ data, products, onOpenDetail, onFilterChain, footer, className }: QuotationTableProps) {
return ( return (
<DataTable <DataTable
className={className}
data={data} data={data}
rowKey={(est) => est.id ?? ''} rowKey={(est) => est.id ?? ''}
onRowClick={(est) => onOpenDetail(est.id ?? '')} onRowClick={(est) => onOpenDetail(est.id ?? '')}

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Palette, Tags, ListPlus, Plus, Trash2, RotateCcw } from 'lucide-react'; import { Palette, Tags, ListPlus, Plus, Trash2, RotateCcw, Download, Upload } from 'lucide-react';
import { showToast } from '@/lib/notify'; import { showToast } from '@/lib/notify';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -59,6 +59,30 @@ export function SettingsView() {
} }
}; };
const fileRef = useRef<HTMLInputElement>(null);
// 현재 편집본을 JSON 파일로 내려받는다(회사 설정 전체 백업/이관용).
const handleExport = () => {
const blob = new Blob([JSON.stringify(draft, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'company-settings.json';
a.click();
URL.revokeObjectURL(url);
};
// JSON 파일 → 편집본 병합. 빈 값(빈 문자열·빈 배열·null)은 "그대로 두기"로 보고 덮어쓰지 않는다.
const handleImport = async (file: File) => {
try {
const parsed = JSON.parse(await file.text()) as CompanySettings;
setDraft((d) => mergeSettings(d, parsed));
showToast('설정 JSON 을 불러왔습니다. 확인 후 저장하십시오.', 'success');
} catch (err) {
showToast(err instanceof Error ? `JSON 파싱 실패 - ${err.message}` : 'JSON 파싱 실패', 'error');
}
};
// 로고 파일 업로드 — 상품 이미지와 동일한 스토리지 엔드포인트(/v1/item/image, Azure Blob)를 재사용해 URL 을 받는다. // 로고 파일 업로드 — 상품 이미지와 동일한 스토리지 엔드포인트(/v1/item/image, Azure Blob)를 재사용해 URL 을 받는다.
const handleUploadLogo = async (file: File): Promise<string> => { const handleUploadLogo = async (file: File): Promise<string> => {
const res = await uploadItemImage({ file: file as unknown as string }); const res = await uploadItemImage({ file: file as unknown as string });
@ -95,6 +119,23 @@ export function SettingsView() {
<RotateCcw size={13} /> <RotateCcw size={13} />
</Button> </Button>
)} )}
<input
ref={fileRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleImport(f);
e.target.value = ''; // 같은 파일 재선택 허용
}}
/>
<Button variant="ghost" size="sm" onClick={() => fileRef.current?.click()}>
<Upload size={13} /> JSON
</Button>
<Button variant="ghost" size="sm" onClick={handleExport}>
<Download size={13} /> JSON
</Button>
<Button size="sm" onClick={handleSave} disabled={!dirty || saving}> <Button size="sm" onClick={handleSave} disabled={!dirty || saving}>
{saving ? '저장 중...' : '변경사항 저장'} {saving ? '저장 중...' : '변경사항 저장'}
</Button> </Button>
@ -243,6 +284,29 @@ export function SettingsView() {
); );
} }
// 불러온 JSON 을 현재 설정에 병합. 값이 비어있는 키(빈 문자열·빈 배열·null·undefined)는
// "업데이트 안 함"으로 보고 기존 값을 유지한다 — 부분 JSON 만 던져도 안전하게 갱신되도록.
function mergeSettings(base: CompanySettings, incoming: CompanySettings): CompanySettings {
const mergeMap = (b: Record<string, string> = {}, i: Record<string, string> = {}) => {
const out = { ...b };
for (const [k, v] of Object.entries(i)) if (typeof v === 'string' && v.trim()) out[k] = v;
return out;
};
const pickFields = (b?: CustomFieldDef[], i?: CustomFieldDef[]) =>
Array.isArray(i) && i.length > 0 ? i : (b ?? []);
return {
...base,
labels: mergeMap(base.labels, incoming.labels),
branding: mergeMap(
base.branding as Record<string, string> | undefined,
incoming.branding as Record<string, string> | undefined,
) as CompanySettings['branding'],
item_fields: pickFields(base.item_fields, incoming.item_fields),
supplier_fields: pickFields(base.supplier_fields, incoming.supplier_fields),
session_fields: pickFields(base.session_fields, incoming.session_fields),
};
}
function SectionCard({ title, desc, children }: { title: string; desc: string; children: React.ReactNode }) { function SectionCard({ title, desc, children }: { title: string; desc: string; children: React.ReactNode }) {
return ( return (
<div className="bg-card border border-border rounded-lg p-5"> <div className="bg-card border border-border rounded-lg p-5">

View File

@ -33,6 +33,8 @@ export type LabelCatalogEntry = {
export const LABEL_CATALOG: LabelCatalogEntry[] = [ export const LABEL_CATALOG: LabelCatalogEntry[] = [
{ key: 'target_margin', base: '목표 마진율', where: '견적 세팅, 목표가 산정내역, 견적 생성' }, { key: 'target_margin', base: '목표 마진율', where: '견적 세팅, 목표가 산정내역, 견적 생성' },
{ key: 'item.price', base: '상품 단가', where: '상품 목록·등록, 엑셀 양식' }, { key: 'item.price', base: '상품 단가', where: '상품 목록·등록, 엑셀 양식' },
{ key: 'item.code', base: '상품코드', where: '상품 목록·등록, 엑셀 양식' },
{ key: 'item.model_name', base: '모델번호', where: '상품 등록, 엑셀 양식' },
{ key: 'category', base: '카테고리', where: '상품 목록·등록·필터, 통계' }, { key: 'category', base: '카테고리', where: '상품 목록·등록·필터, 통계' },
{ key: 'lead_time', base: '리드타임', where: '상품 등록, 엑셀 양식' }, { key: 'lead_time', base: '리드타임', where: '상품 등록, 엑셀 양식' },
{ key: 'delivery_type.1', base: '협력사배송', where: '배송유형 선택지 1' }, { key: 'delivery_type.1', base: '협력사배송', where: '배송유형 선택지 1' },

View File

@ -8,7 +8,9 @@ import { ParticipationChart } from './components/ParticipationChart';
import { TypeSplitChart } from './components/TypeSplitChart'; import { TypeSplitChart } from './components/TypeSplitChart';
import { CategoryChart } from './components/CategoryChart'; import { CategoryChart } from './components/CategoryChart';
import { CardEffectChart } from './components/CardEffectChart'; import { CardEffectChart } from './components/CardEffectChart';
import { CardTop5 } from './components/CardTop5';
import { wonCompact, pct, signedWonCompact } from './fmt'; import { wonCompact, pct, signedWonCompact } from './fmt';
import { fillMonths } from './months';
import type { StatData } from './types'; import type { StatData } from './types';
import { useLabels } from '@/features/settings/useCompanySettings'; import { useLabels } from '@/features/settings/useCompanySettings';
@ -16,6 +18,9 @@ import { useLabels } from '@/features/settings/useCompanySettings';
export function StatisticsView({ data }: { data: StatData }) { export function StatisticsView({ data }: { data: StatData }) {
const label = useLabels(); // 회사 설정 용어(카테고리 등) const label = useLabels(); // 회사 설정 용어(카테고리 등)
const k = data.kpi; const k = data.kpi;
// 월별 차트는 최근 6개월 축을 고정한다(데이터 없는 달은 0) — 한 점만 찍히던 현상 방지.
const trend = fillMonths(data.trend, (month) => ({ month, savings: 0, rate: 0 }));
const markupTrend = fillMonths(data.markupTrend, (month) => ({ month, rate: 0 }));
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 임팩트 요약 KPI */} {/* 임팩트 요약 KPI */}
@ -34,26 +39,21 @@ export function StatisticsView({ data }: { data: StatData }) {
<StatTile label="평균 재견적 라운드" value={k.regenAvgRound.toFixed(1)} icon={RefreshCw} tone="amber" /> <StatTile label="평균 재견적 라운드" value={k.regenAvgRound.toFixed(1)} icon={RefreshCw} tone="amber" />
</div> </div>
{/* 절감 분석 */} {/* 월별 추이 2종 — 같은 성격이라 동일 폭(1:1) */}
<div className="grid gap-4 lg:grid-cols-3"> <div className="grid gap-4 lg:grid-cols-2">
<Panel <Panel title="월별 절감 추이" subtitle="목표가 대비 절감액 · 막대에 마우스를 올리면 절감률">
title="월별 절감 추이" <SavingsTrendChart data={trend} />
subtitle="목표가 대비 절감액 · 막대에 마우스를 올리면 절감률"
className="lg:col-span-2"
>
<SavingsTrendChart data={data.trend} />
</Panel> </Panel>
<Panel title="월별 인상억제율" subtitle="재협상: 직전 라운드 투찰가 대비 인하율"> <Panel title="월별 인상억제율" subtitle="재협상: 직전 라운드 투찰가 대비 인하율">
<MarkupTrendChart data={data.markupTrend} /> <MarkupTrendChart data={markupTrend} />
</Panel>
<Panel title="마감 결과" subtitle="낙찰 vs 개찰 사유 4종">
<OutcomeChart data={data.outcome} />
</Panel> </Panel>
</div> </div>
{/* 성사 · 프로세스 */} {/* 성사 · 프로세스 */}
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Panel title="마감 결과" subtitle="낙찰 vs 개찰 사유 4종">
<OutcomeChart data={data.outcome} />
</Panel>
<Panel title="협력사 참여" subtitle="초대 세션 대비 응찰·미응찰·거부"> <Panel title="협력사 참여" subtitle="초대 세션 대비 응찰·미응찰·거부">
<ParticipationChart data={data.participation} /> <ParticipationChart data={data.participation} />
</Panel> </Panel>
@ -63,13 +63,16 @@ export function StatisticsView({ data }: { data: StatData }) {
</div> </div>
{/* 카테고리 · 카드 */} {/* 카테고리 · 카드 */}
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Panel title={`${label('category')}별 절감`} subtitle="어디서 절감이 났나"> <Panel title={`${label('category')}별 절감`} subtitle="어디서 절감이 났나">
<CategoryChart data={data.categories} /> <CategoryChart data={data.categories} />
</Panel> </Panel>
<Panel title="협상카드 효과" subtitle="유형별 사용빈도 + 사용 직후 평균 제시가 하락"> <Panel title="협상카드 효과" subtitle="유형별 사용빈도 + 사용 직후 평균 제시가 하락">
<CardEffectChart data={data.cards} /> <CardEffectChart data={data.cards} />
</Panel> </Panel>
<Panel title="협상카드 성공률 TOP 5" subtitle="사용 이력 있는 카드 · 사용 세션 중 타결 비율">
<CardTop5 />
</Panel>
</div> </div>
</div> </div>
); );

View File

@ -0,0 +1,68 @@
import { Link } from 'react-router';
import { useListCards } from '@/api/generated/card/card';
import { mapCardData } from '@/features/cards/types';
import { Typography, typographyVariants } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
// 협상카드 성공률 TOP 5 — 카드 목록 API의 success_rate/used_count 로 계산(통계 요약엔 유형별만 있어서).
// 표본 없는 카드(used_count=0)는 성공률이 0으로 잡혀 의미가 없으므로 제외한다.
const TOP_N = 5;
export function CardTop5() {
// 랭킹이므로 넉넉히 받아 프론트에서 정렬(회사 카드 + 공용 카드).
const { data, isLoading } = useListCards({ size: 100 });
const rows = (data?.cards ?? [])
.map(mapCardData)
.filter((c) => c.usedCount > 0)
.sort((a, b) => b.successRate - a.successRate || b.usedCount - a.usedCount)
.slice(0, TOP_N);
if (isLoading) return <Typography variant="muted"> </Typography>;
if (rows.length === 0) return <Typography variant="muted"> .</Typography>;
const max = Math.max(...rows.map((r) => r.successRate), 0.0001);
return (
<div className="flex flex-col gap-2.5">
{rows.map((card, i) => (
<div key={card.id} className="flex items-center gap-3">
<span
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded text-[10px] font-bold',
i === 0 ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
)}
>
{i + 1}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<Link
to={`/cards?detail=${card.id}`}
className={cn(typographyVariants({ variant: 'small' }), 'truncate font-semibold hover:underline')}
title={`${card.title} — 협상카드 상세로 이동`}
>
{card.title}
</Link>
<Typography as="span" variant="small" className="shrink-0 font-mono font-bold text-foreground">
{Math.round(card.successRate * 100)}%
</Typography>
</div>
{/* 성공률 막대 — 1위 대비 상대 길이 */}
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn('h-full rounded-full', card.isWildcard ? 'bg-rose-400' : 'bg-primary')}
style={{ width: `${Math.max(4, (card.successRate / max) * 100)}%` }}
/>
</div>
<Typography as="p" variant="caption" className="mt-1 truncate">
{card.isWildcard ? '와일드카드' : '협상카드'} · {card.code} · {card.usedCount}
</Typography>
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,30 @@
// 월별 차트 축 고정 — 백엔드는 데이터가 있는 달만 내려주므로, 빈 달을 0으로 채워
// 최근 N개월 축을 항상 그린다. (데이터 1건일 때 점 하나만 덩그러니 찍히던 문제)
const WINDOW = 6;
/** 최근 N개월 'YYYY-MM' 배열 (과거→현재). 기준월 포함. */
function recentMonths(n: number, now = new Date()): string[] {
const out: string[] = [];
for (let i = n - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
out.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`);
}
return out;
}
/**
* WINDOW rows .
* , empty(month) .
* ( ) .
*/
export function fillMonths<T extends { month: string }>(
rows: T[],
empty: (month: string) => T,
window = WINDOW,
): T[] {
const byMonth = new Map(rows.map((r) => [r.month, r]));
const axis = recentMonths(window);
// 축보다 과거의 데이터는 버리지 않고 앞에 유지
const older = rows.filter((r) => r.month < axis[0]).sort((a, b) => a.month.localeCompare(b.month));
return [...older, ...axis.map((m) => byMonth.get(m) ?? empty(m))];
}

View File

@ -76,7 +76,10 @@ export default function PartnersPage() {
return ( return (
<PageContainer> <PageContainer>
{/* 검색/액션 바 + 테이블을 한 카드로 붙인다(포털형). */}
<div className="overflow-hidden rounded-lg border border-border bg-card">
<PageToolbar <PageToolbar
className="rounded-none border-0 border-b border-border"
actions={ actions={
<> <>
{isSuperAdmin && ( {isSuperAdmin && (
@ -128,6 +131,7 @@ export default function PartnersPage() {
</PageToolbar> </PageToolbar>
<PartnerTable <PartnerTable
className="rounded-none border-0"
data={partners} data={partners}
selectedIds={selectedIds} selectedIds={selectedIds}
onSelectionChange={setSelectedIds} onSelectionChange={setSelectedIds}
@ -138,6 +142,7 @@ export default function PartnersPage() {
pageSize={list.pageSize} pageSize={list.pageSize}
onPageChange={list.setPage} onPageChange={list.setPage}
/> />
</div>
{isFormOpen && ( {isFormOpen && (
<PartnerFormSheet <PartnerFormSheet

View File

@ -71,7 +71,10 @@ export default function QuotationPage() {
return ( return (
<PageContainer> <PageContainer>
{/* 검색/액션 바 + 테이블을 한 카드로 붙인다(포털형). */}
<div className="overflow-hidden rounded-lg border border-border bg-card">
<PageToolbar <PageToolbar
className="rounded-none border-0 border-b border-border"
actions={ actions={
<> <>
<Button id="quotation-create-btn" onClick={() => overlay.open('create')}> <Button id="quotation-create-btn" onClick={() => overlay.open('create')}>
@ -154,6 +157,7 @@ export default function QuotationPage() {
</PageToolbar> </PageToolbar>
<QuotationTable <QuotationTable
className="rounded-none border-0"
data={quotations} data={quotations}
products={products} products={products}
onOpenDetail={(id) => overlay.open('detail', id)} onOpenDetail={(id) => overlay.open('detail', id)}
@ -174,6 +178,7 @@ export default function QuotationPage() {
/> />
} }
/> />
</div>
{detailId && activeQuotation && ( {detailId && activeQuotation && (
<QuotationDetailSheet <QuotationDetailSheet

View File

@ -33,6 +33,8 @@ export interface NegotiationCard {
triggerCondition?: string; triggerCondition?: string;
memo?: string; memo?: string;
creatorName?: string; // 등록자(작성자) 이름. 공용(user_id NULL) 카드는 없음 creatorName?: string; // 등록자(작성자) 이름. 공용(user_id NULL) 카드는 없음
successRate: number; // 카드 성공률(사용 세션 중 타결 비율). #12 순위
usedCount: number; // 카드 사용 세션 수(표본)
} }
export type PageType = 'DASHBOARD' | 'STATISTICS' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'MEMBERS' | 'SETTINGS' | 'NOTIFICATIONS'; export type PageType = 'DASHBOARD' | 'STATISTICS' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'MEMBERS' | 'SETTINGS' | 'NOTIFICATIONS';

View File

@ -0,0 +1,59 @@
{
"labels": {
"category": "SG명",
"item.code": "품목코드",
"lead_time": "표준납기",
"item.price": "공급가",
"target_margin": "네고율",
"delivery_type.1": "직납",
"delivery_type.2": "IMK물류(배송)",
"delivery_type.3": "IMK물류(집배송)",
"item.model_name": "모델명"
},
"branding": {
"logo_url": "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/negodata/a35152d2-db61-4760-9f1e-beb9736d957f/items/4ab23e35e4f14d888591ca0f6d343fc8.jpg",
"email_header": "iMarketKorea",
"service_name": "아이좋아네고",
"primary_color": "#f551a0"
},
"item_fields": [
{
"key": "order_multiple",
"type": "number",
"label": "발주배수"
},
{
"key": "subcontract_yn",
"type": "boolean",
"label": "하도급"
},
{
"key": "price_linked_yn",
"type": "boolean",
"label": "납품대금연동"
}
],
"session_fields": [
{
"key": "std_lead_time",
"type": "number",
"label": "표준납기"
},
{
"key": "moq",
"type": "text",
"label": "최소주문수량"
},
{
"key": "order_multiple",
"type": "number",
"label": "발주배수"
},
{
"key": "delivery_type",
"type": "text",
"label": "배송유형"
}
],
"supplier_fields": []
}

View File

@ -0,0 +1,36 @@
-- 데모용 카드 사용 이력 시드 (#12 카드 성공률 순위)
-- 과거 채팅 302건은 카드 메타(card_id/card_type/card_used_yn) 저장 전에 쌓여 전부 NULL 이라
-- 성공률 표본이 0 → 카드 선택 UI 에 순위가 뜨지 않는다. 실제 세션 결과(status)는 그대로 두고
-- 봇 발화 행에만 카드를 결정적으로 배정해 표본을 만든다. 멱등(같은 chat_id → 같은 카드).
WITH bot AS (
SELECT c.chat_id,
c.session_id,
row_number() OVER (PARTITION BY c.session_id ORDER BY c.seq) AS rn
FROM negotiation.chats c
WHERE c.sender = 1 AND c.deleted = false
),
sess AS (
SELECT session_id, row_number() OVER (ORDER BY session_id) AS sn
FROM negotiation.sessions
),
cards AS (
SELECT nego_card_id,
row_number() OVER (ORDER BY (number)::int) AS cn,
count(*) OVER () AS total
FROM card.nego_cards
),
pick AS (
-- 세션당 앞쪽 봇 발화 4개까지 카드 사용으로 표시. 세션·발화 순번을 섞어 카드가 고르게 퍼지게.
SELECT b.chat_id,
((s.sn * 3 + b.rn) % (SELECT max(total) FROM cards)) + 1 AS cn
FROM bot b
JOIN sess s ON s.session_id = b.session_id
WHERE b.rn <= 4
)
UPDATE negotiation.chats t
SET card_id = c.nego_card_id,
card_type = 1,
card_used_yn = true
FROM pick p
JOIN cards c ON c.cn = p.cn
WHERE t.chat_id = p.chat_id;