[feat] negodata: 견적 목표가·앵커 반올림 + 통계 협상카드 효과 — 목표가=매입가×(1−네고율) 자동입력·산정후보를 낙찰기준탭으로 이동·목표가상한 상시표시 제거, 후보가/앵커 생성가 10원 단위 반올림(신규 세션, schedules 앵커와 동일 공식), 산정모달은 구매담당자 제시가 있어도 후보 전부 노출·MD입력가→구매담당자 제시가, 신규상품 등록에 공급사칸 추가, 상품상세/견적생성 라벨 회사설정 연동, 통계 협상카드 효과(유형별 사용빈도+사용직후 평균 제시가 하락)·와일드카드 1%인하 집계, mono 폰트 폴백

This commit is contained in:
Mina Choi 2026-07-29 09:51:03 +09:00
parent 6534d53696
commit b33ae05cd6
14 changed files with 373 additions and 138 deletions

View File

@ -1,7 +1,7 @@
"""순수 계산 함수 — DB 접근 없음. 원본: schedules/anchoring/src/anchoring/service.py 에서
읽기 경로(칸 해석·앵커가 산출) 두 함수만 발췌 이식. 표본 판정·평가 함수는 배치 전용이라 제외.
모든 산술은 정수(천분율 ‰). float 금지 — 1원 단위 내림의 정확성 보장.
모든 산술은 정수(천분율 ‰). float 금지 — 10원 단위 반올림의 정확성 보장.
"""
from bisect import bisect_right
@ -17,5 +17,7 @@ def calc_price_range_index(target_price: int) -> int:
def calc_anchoring_price(target_price: int, anchoring_value: int) -> int:
"""앵커링가 = 목표가 × (1 − A), 1원 단위 내림. (정수 연산만 — float 곱셈 재도입 금지)"""
return target_price * (1000 - anchoring_value) // 1000
"""앵커링가 = 목표가 × (1 − A), 10원 단위 반올림. (정수 연산만 — float 곱셈 재도입 금지)
schedules/anchoring 의 제안 시점 앵커와 동일 공식 — 생성·제안 앵커가 10원 단위로 일치."""
return (target_price * (1000 - anchoring_value) + 5000) // 10000 * 10

View File

@ -1,13 +1,13 @@
from abc import ABC, abstractmethod
from typing import Tuple
from sqlalchemy import select, func, and_, case
from sqlalchemy import select, func, and_, or_, case
from sqlalchemy.orm import aliased
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import quotations, sessions, items, chats, users
from common.enums import ErrorType, QuotationStatus, CloseReason, SessionStatus
from common.enums import ErrorType, QuotationStatus, CloseReason, SessionStatus, CardType, ChatSender
from common.logger import LOG
@ -67,6 +67,10 @@ class IStatisticsCRUD(ABC):
async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
pass
@abstractmethod
async def card_effect_chats(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
pass
class StatisticsCRUD(IStatisticsCRUD):
async def winning_sessions(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
@ -278,11 +282,18 @@ class StatisticsCRUD(IStatisticsCRUD):
return ErrorType.DB_RUN_FAILED, []
async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
# 카드 유형별 사용 빈도: card_used_yn=True 채팅을 card_type 별 집계(협상형 견적에서만 채팅 생성).
# 카드 유형별 사용 빈도: card_used_yn=True 채팅 + 1% 인하 시스템 카드(meta.step='wild_card_1pct', card_id 미제공)를
# 와일드로 함께 집계. (1% 카드는 카탈로그 카드가 아니라 card_type 로그가 없어 step 으로 잡는다.)
try:
step_1pct = chats.meta["step"].astext == "wild_card_1pct"
ctype = case(
(chats.card_used_yn.is_(True), chats.card_type),
(step_1pct, CardType.WILD.value),
else_=None,
)
conds = [
chats.deleted == False, # noqa: E712
chats.card_used_yn.is_(True),
or_(chats.card_used_yn.is_(True), step_1pct),
quotations.deleted == False, # noqa: E712
quotations.created_at >= since,
quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)),
@ -290,12 +301,44 @@ class StatisticsCRUD(IStatisticsCRUD):
if owner is not None:
conds.append(quotations.user_id == owner)
stmt = (
select(chats.card_type, func.count())
select(ctype, func.count())
.select_from(chats)
.join(sessions, sessions.session_id == chats.session_id)
.join(quotations, quotations.qt_id == sessions.quotation_id)
.where(and_(*conds))
.group_by(chats.card_type)
.group_by(ctype)
)
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
return (err, list(rows) if err == ErrorType.SUCCESS else [])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def card_effect_chats(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
# 카드 사용 직후 제시가 하락 산출용 — 유저 제시가 chat + 카드 사용 chat(1% 인하 포함)을 세션·순번 순으로.
try:
step_1pct = chats.meta["step"].astext == "wild_card_1pct"
conds = [
chats.deleted == False, # noqa: E712
quotations.deleted == False, # noqa: E712
quotations.created_at >= since,
quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)),
or_(
and_(chats.sender == ChatSender.USER.value, chats.target_price > 0),
chats.card_used_yn.is_(True),
step_1pct,
),
]
if owner is not None:
conds.append(quotations.user_id == owner)
stmt = (
select(chats.session_id, chats.seq, chats.sender, chats.target_price, chats.card_used_yn,
chats.card_type, step_1pct, sessions.bid_price, sessions.status)
.select_from(chats)
.join(sessions, sessions.session_id == chats.session_id)
.join(quotations, quotations.qt_id == sessions.quotation_id)
.where(and_(*conds))
.order_by(chats.session_id, chats.seq)
)
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
return (err, list(rows) if err == ErrorType.SUCCESS else [])

View File

@ -25,7 +25,7 @@ class PricingMixin:
# 강등 시 딸려가는 것: quotation_settings 컬럼(날짜 SQL 파일)·세팅 화면·산정내역 표기·테스트.
# 목표가 후보 basis 코드 ↔ 표시 라벨(산정내역 응답에서 프론트가 그대로 표기).
_CANDIDATE_LABELS = {"md": "MD 입력가", "internet": "인터넷 최저가", "purchase": "매입가", "selling": "판매가"}
_CANDIDATE_LABELS = {"md": "구매담당자 제시가", "internet": "인터넷 최저가", "purchase": "매입가", "selling": "판매가"}
# 가격 소스별로, 회사 설정 hidden_fields 에서 쓰는 필드 이름. 숨긴 가격은 목표가 후보에서도 뺀다.
_SOURCE_HIDDEN_FIELD = {"internet": "internet_lowest_price", "purchase": "purchase_price", "selling": "selling_price"}
@ -44,10 +44,11 @@ class PricingMixin:
if is_new:
table = [("internet", internet_lowest, fee)]
else:
table = [("internet", internet_lowest, fee), ("purchase", purchase, 0.0), ("selling", selling, margin)]
table = [("internet", internet_lowest, fee), ("purchase", purchase, margin), ("selling", selling, margin)]
hidden = hidden or set()
# 후보가는 10원 단위 반올림(IMK #11) — 목표가·산정내역·자동채움이 다 이 값으로 일치.
return [
(basis, int(price) * (1 - (rate or 0.0)))
(basis, round(int(price) * (1 - (rate or 0.0)) / 10) * 10)
for basis, price, rate in table
if price and PricingMixin._SOURCE_HIDDEN_FIELD[basis] not in hidden
]
@ -169,7 +170,7 @@ class PricingMixin:
value = value_map.get((company, supply_type, price_range)) if company is not None else None
if value is None:
value = get_base_anchoring_value(price_range)
ap = calc_anchoring_price(tp, value) # 목표가×(1000−value)//1000 — float 곱셈 금지(1원 내림 정확성)
ap = calc_anchoring_price(tp, value) # 목표가×(1−value), 10원 반올림(정수 연산 — schedules 앵커와 동일)
anchors[(iid, sid)] = (value, ap)
return anchors
@ -201,7 +202,10 @@ class PricingMixin:
is_new = QuotationType.is_new(quotation.type)
md = quotation.md_price
cands = self._candidates(md, internet, purchase, selling, fee, margin, is_new, hidden)
# 산정내역 화면은 구매담당자 제시가(md)가 있어도 후보를 다 보여준다(생성 로직과 달리 '표시용').
# 생성 시엔 md 가 있으면 md 하나만 쓰지만(_calc_target_price), 여기선 md + 상품후보를 함께 나열해 근거를 드러낸다.
base_cands = self._candidates(None, internet, purchase, selling, fee, margin, is_new, hidden)
cands = ([("md", float(int(md)))] if md else []) + base_cands
chosen_basis = next((b for b, v in cands if int(v) == sess.target_price), None)
is_inherited = chosen_basis is None

View File

@ -4,7 +4,7 @@ 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, QuotationType, SessionStatus, CardType, CloseReason
from common.enums import DBWRType, ErrorType, QuotationType, SessionStatus, CardType, CloseReason, ChatSender
from common.utils.gtime import GTime
from crud.statistics_crud import StatisticsCRUD, IStatisticsCRUD
from router.v1.statistics.protocol import (
@ -55,6 +55,7 @@ class StatisticsService:
markup = await self._read_scalar(lambda s: self.stat_crud.markup_suppression(s, company_uuid, owner_uuid, since))
markup_rows = await self._read(lambda s: self.stat_crud.markup_suppression_monthly(s, company_uuid, owner_uuid, since))
card_rows = await self._read(lambda s: self.stat_crud.card_usage(s, company_uuid, owner_uuid, since))
card_effect_rows = await self._read(lambda s: self.stat_crud.card_effect_chats(s, company_uuid, owner_uuid, since))
scope.trend = self._trend(win_rows, labels)
scope.markup_trend = [StatMarkupPoint(month=r[0], rate=float(r[1] or 0.0)) for r in markup_rows]
@ -62,7 +63,7 @@ class StatisticsService:
scope.outcome = self._outcome(outcome_rows)
scope.participation = self._participation(part_rows)
scope.type_split = self._type_split(type_rows, win_rows)
scope.cards = self._cards(card_rows)
scope.cards = self._cards(card_rows, self._card_drops(card_effect_rows))
scope.kpi = self._kpi(win_rows, scope.trend, scope.outcome, regen, markup)
return scope
@ -164,13 +165,43 @@ class StatisticsService:
out.append(StatTypeRow(label=label, award_rate=rate, avg_savings=avg, count=cnt))
return out
def _cards(self, card_rows) -> list:
def _cards(self, card_rows, drops: dict) -> list:
by = {int(ct): int(n) for ct, n in card_rows if ct is not None}
return [
StatCardUsage(type="nego", label="협상카드", uses=by.get(CardType.NEGO.value, 0), avg_drop=0),
StatCardUsage(type="wild", label="와일드카드", uses=by.get(CardType.WILD.value, 0), avg_drop=0),
StatCardUsage(type="nego", label="협상카드", uses=by.get(CardType.NEGO.value, 0), avg_drop=int(round(drops.get(CardType.NEGO.value, 0)))),
StatCardUsage(type="wild", label="와일드카드", uses=by.get(CardType.WILD.value, 0), avg_drop=int(round(drops.get(CardType.WILD.value, 0)))),
]
def _card_drops(self, rows) -> dict:
# 카드 사용 직후 제시가 하락(유형별 평균).
# - 일반: 카드 직전 유저 제시가 − 직후 유저 제시가.
# - 1% 인하(수락은 가격 재입력이 아님): 카드 직전 유저 제시가 − 최종 낙찰가(타결 세션).
# rows: (session_id, seq, sender, target_price, card_used_yn, card_type, is_1pct, bid_price, status)
by_sess: dict = {}
for r in rows:
by_sess.setdefault(r[0], []).append(r)
sums = {CardType.NEGO.value: 0, CardType.WILD.value: 0}
cnts = {CardType.NEGO.value: 0, CardType.WILD.value: 0}
for chs in by_sess.values():
for i, ch in enumerate(chs):
_sid, _seq, _sender, _tp, used, ctype, is_1pct, bid_price, status = ch
if not (used or is_1pct):
continue
ct = int(ctype) if ctype else (CardType.WILD.value if is_1pct else None)
if ct not in sums:
continue
prev = next((c[3] for c in reversed(chs[:i]) if c[2] == ChatSender.USER.value and c[3] and c[3] > 0), None)
if is_1pct:
if prev is not None and bid_price and status == SessionStatus.DONE.value and prev >= bid_price:
sums[ct] += (prev - bid_price)
cnts[ct] += 1
else:
after = next((c[3] for c in chs[i + 1:] if c[2] == ChatSender.USER.value and c[3] and c[3] > 0), None)
if prev is not None and after is not None:
sums[ct] += (prev - after)
cnts[ct] += 1
return {ct: (sums[ct] / cnts[ct]) if cnts[ct] else 0 for ct in sums}
# ── 창(최근 6개월) ─────────────────────────────────────────
def _window(self, now):
yy, mm = now.year, now.month

View File

@ -0,0 +1,127 @@
import { useState } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { useListSuppliers } from '@/api/generated/supplier/supplier';
import { Typography } from '@/components/ui/typography';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Combobox, type ComboOption } from '@/components/ui/combobox';
import { SUPPLIER_TYPE_OPTIONS, SupplierType, supplierTypeLabel } from '@/lib/enumLabels';
// 신규 상품 등록 폼용 공급사 선택(IMK #8). itemId 가 아직 없으므로 서버 반영 없이 로컬 상태만 모으고,
// 상품 생성 성공 후 부모(ProductFormSheet)가 생성된 item_id 로 supplier_items 매핑을 만든다.
export type PickedSupplier = { supplierId: string; label: string; supplyType: number };
export function NewItemSuppliersPicker({
value,
onChange,
}: {
value: PickedSupplier[];
onChange: (next: PickedSupplier[]) => void;
}) {
const [q, setQ] = useState('');
const catalogQuery = useListSuppliers({ search: q || undefined, size: 30 }); // 서버검색
const [pickSupplierId, setPickSupplierId] = useState('');
const [pickLabel, setPickLabel] = useState('');
const [pickType, setPickType] = useState(String(SupplierType.NONE)); // 기본 없음(0)
const pickedIds = new Set(value.map((v) => v.supplierId));
const options: ComboOption[] = (catalogQuery.data?.suppliers ?? [])
.filter((s) => !pickedIds.has(s.supplier_id))
.map((s) => ({ id: s.supplier_id, label: `${s.name}${s.code ? ` [${s.code}]` : ''}` }));
const handleAdd = () => {
if (!pickSupplierId) return;
onChange([...value, { supplierId: pickSupplierId, label: pickLabel, supplyType: Number(pickType) }]);
setPickSupplierId('');
setPickLabel('');
setPickType(String(SupplierType.NONE));
setQ('');
};
const handleChangeType = (supplierId: string, v: string) => {
onChange(value.map((it) => (it.supplierId === supplierId ? { ...it, supplyType: Number(v) } : it)));
};
const handleRemove = (supplierId: string) => {
onChange(value.filter((it) => it.supplierId !== supplierId));
};
return (
<div className="pt-4 border-t border-border space-y-2">
<Typography as="label" variant="label">공급사 ({value.length})</Typography>
{/* 추가 행 — 공급사 + 공급유형 선택 후 추가(로컬) */}
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<Combobox
id="new-item-supplier-pick"
options={options}
loading={catalogQuery.isLoading}
onQueryChange={setQ}
value={pickSupplierId || undefined}
selectedLabel={pickLabel}
onSelect={(opt) => { setPickSupplierId(opt.id); setPickLabel(opt.label); }}
placeholder="공급사로 추가할 협력사 검색..."
searchPlaceholder="협력사명·코드로 검색..."
emptyText="일치하는 협력사가 없습니다"
/>
</div>
<div className="w-24 shrink-0">
<Select value={pickType} onValueChange={(v) => setPickType(v ?? String(SupplierType.NONE))}>
<SelectTrigger id="new-item-supplier-pick-type" className="w-full">
<SelectValue>{(value2) => supplierTypeLabel(Number(value2))}</SelectValue>
</SelectTrigger>
<SelectContent>
{SUPPLIER_TYPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="button" size="sm" onClick={handleAdd} disabled={!pickSupplierId}>
<Plus />
추가
</Button>
</div>
{/* 선택한 공급사 목록(로컬) */}
<div className="border border-border rounded divide-y divide-border max-h-48 overflow-y-auto">
{value.length === 0 ? (
<Typography as="p" variant="small" className="p-3 text-muted-foreground text-[11px]">
선택한 공급사가 없습니다. (선택)
</Typography>
) : (
value.map((m) => (
<div key={m.supplierId} className="flex items-center gap-2 p-2">
<div className="flex-1 min-w-0">
<Typography as="span" variant="small" className="font-semibold block truncate">
{m.label || '-'}
</Typography>
</div>
<div className="w-24 shrink-0">
<Select value={String(m.supplyType)} onValueChange={(v) => v != null && handleChangeType(m.supplierId, v)}>
<SelectTrigger className="w-full">
<SelectValue>{(value2) => supplierTypeLabel(Number(value2))}</SelectValue>
</SelectTrigger>
<SelectContent>
{SUPPLIER_TYPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<button
type="button"
onClick={() => handleRemove(m.supplierId)}
title="공급사 제거"
className="p-1 rounded text-muted-foreground hover:text-rose-600 hover:bg-rose-500/10 cursor-pointer"
>
<Trash2 size={14} />
</button>
</div>
))
)}
</div>
</div>
);
}

View File

@ -1,3 +1,4 @@
import { useState } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@ -16,6 +17,8 @@ import { useAuthStore, canManage } from '@/stores/auth';
import { useCompanySettings, useLabels, useHiddenFields } from '@/features/settings/useCompanySettings';
import { CustomFieldInputs, useCustomFieldValues } from '@/features/settings/CustomFieldInputs';
import { ItemSuppliersManager } from './ItemSuppliersManager';
import { NewItemSuppliersPicker, type PickedSupplier } from './NewItemSuppliersPicker';
import { createSupplierItem } from '@/api/generated/supplier-item/supplier-item';
import { type Product } from '../types';
// 폼 검증 스키마. 필수: 상품명/상품코드/단가/최저가. 나머지는 선택.
@ -50,7 +53,7 @@ type ProductFormSheetProps = {
categories: string[]; // 서버 items 에서 distinct 로 뽑은 카테고리 목록(선택지)
categoryTypeByName: Record<string, number>; // 카테고리명 → category_type(id) 매핑
nextCategoryType: number; // 신규 카테고리에 부여할 id(= 기존 max + 1)
onCreate: (data: ItemCreate) => Promise<void>;
onCreate: (data: ItemCreate) => Promise<string | void>; // 생성된 item_id 반환(공급사 매핑용)
onUpdate: (itemId: string, data: ItemUpdate) => Promise<void>;
onDelete: (itemId: string, name: string) => void;
onClose: () => void;
@ -142,6 +145,7 @@ export function ProductFormSheet({
const { settings } = useCompanySettings();
const itemFields = settings.item_fields ?? [];
const customValues = useCustomFieldValues(itemFields, product?.custom);
const [newSuppliers, setNewSuppliers] = useState<PickedSupplier[]>([]); // 신규 등록 시 매핑할 공급사(생성 후 매핑)
// 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정·삭제(프론트 1차 차단, 백엔드도 강제).
const myUserId = useAuthStore((s) => s.user?.userId);
@ -184,7 +188,17 @@ export function ProductFormSheet({
image_url: v.imageUrl || 'https://images.unsplash.com/photo-1593941707882-a5bba14938c7?w=300',
};
try {
await onCreate(payload);
const itemId = await onCreate(payload);
// 생성된 상품에 선택한 공급사 매핑(supplier_items). 매핑 실패는 상품 등록 자체를 막지 않는다.
if (itemId && newSuppliers.length > 0) {
for (const s of newSuppliers) {
try {
await createSupplierItem({ supplier_id: s.supplierId, item_id: itemId, supply_type: s.supplyType });
} catch {
showToast(`공급사 '${s.label}' 매핑에 실패했습니다.`, 'error');
}
}
}
showToast('신규 B2B 상품이 안전하게 등록되었습니다.', 'success');
onClose();
} catch (err) {
@ -481,6 +495,8 @@ export function ProductFormSheet({
{/* 공급사 관리(IMK #20) — 수정 모드(상품 확정)에서만. 추가/삭제/유형변경은 즉시 서버 반영. */}
{mode === 'edit' && product && <ItemSuppliersManager itemId={product.item_id} />}
{/* 공급사 선택(IMK #8) — 신규 등록 모드. 로컬로 모으고 상품 생성 후 매핑한다. */}
{mode === 'create' && <NewItemSuppliersPicker value={newSuppliers} onChange={setNewSuppliers} />}
{/* 회사 커스텀 필드 — companies.settings.item_fields 정의대로 렌더, items.custom 에 저장 */}
<CustomFieldInputs fields={itemFields} state={customValues} title="회사 추가 항목" />

View File

@ -54,10 +54,12 @@ export function useProducts(params: ListItemsParams) {
queryClient.invalidateQueries({ queryKey: ['/v1/item/categories'] }),
]);
const createProduct = async (data: ReqCreateItem) => {
const msg = itemError(await createItem(data));
const createProduct = async (data: ReqCreateItem): Promise<string | undefined> => {
const res = await createItem(data);
const msg = itemError(res);
if (msg) throw new Error(msg);
await refresh();
return res.item?.item_id; // 공급사 매핑용 item_id 반환
};
const updateProduct = async (itemId: string, data: ReqUpdateItem) => {
await updateItem(itemId, data);

View File

@ -7,7 +7,7 @@ import { useListSuppliers } from '@/api/generated/supplier/supplier';
import { useListCards } from '@/api/generated/card/card';
import { mapCardData } from '@/features/cards/types';
import { CONDITION_VARIABLE, CONDITION_LABEL } from '@/features/cards/editor/variables';
import { useLabels } from '@/features/settings/useCompanySettings';
import { useLabels, useHiddenFields } from '@/features/settings/useCompanySettings';
import { Button } from '@/components/ui/button';
import { Typography, typographyVariants } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
@ -96,6 +96,7 @@ export function QuotationCreateModal({
const [midAction, setMidAction] = useState<number>(DEFAULT_MID_ACTION); // 앵커~목표가 구간: 낙찰/개찰 (1:1 전용)
const [overAction, setOverAction] = useState<number>(DEFAULT_OVER_ACTION); // 목표가 초과 구간: 낙찰/개찰 (1:1 전용)
const [submitting, setSubmitting] = useState(false);
const [mdTouched, setMdTouched] = useState(false); // 담당자가 제시가를 직접 건드렸는지 — 안 건드렸으면 자동 산출값을 채운다
const type = toQuotationType(mode, isNew); // 4코드 합성값
const oneToOne = is1v1(type); // 1:1 협상 여부 — 협력사 단일선택·낙찰기준·협상카드 노출을 가른다
@ -128,6 +129,7 @@ export function QuotationCreateModal({
// 선택 상품의 협력사별 공급유형(제조/유통/총판/없음) — 협력사 리스트에 배지로 덧붙인다(리스트 자체는 재조회 안 함).
const supplyTypeQuery = useListItemSupplyTypes(productId, { query: { enabled: !!productId } });
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
const isHidden = useHiddenFields(); // 회사설정으로 감춘 상품 기본필드 — 후보 리스트에서도 제외
const supplyTypeBySupplier = useMemo(() => {
const m = new Map<string, number>();
(supplyTypeQuery.data?.suppliers ?? []).forEach((s) => m.set(s.supplier_id, s.supply_type));
@ -174,11 +176,18 @@ export function QuotationCreateModal({
const lowestUnavailable = !!productId && !(internetLowest && internetLowest > 0);
const lowestPriceLeak = (c: NegotiationCard) =>
lowestUnavailable && (c.scriptPreview ?? '').includes('{internet_lowest_price}');
// (3) 미승인 와일드카드(INACTIVE) — 목록·순위엔 보이되 선택은 막는다(수동 승인 전).
const blockReason = (c: NegotiationCard): string | null =>
conditionUnfilled(c) ? `${CONDITION_LABEL} 미작성` : lowestPriceLeak(c) ? '인터넷 최저가 미수집' : null;
c.isWildcard && c.status !== 'ACTIVE'
? '미승인 와일드카드'
: conditionUnfilled(c)
? `${CONDITION_LABEL} 미작성`
: lowestPriceLeak(c)
? '인터넷 최저가 미수집'
: null;
// 성공률(사용 세션 중 타결 비율) 내림차순 — 표본 없는 카드는 뒤로. 상위 3개에 1·2·3위 배지가 붙는다.
// 미승인 와일드카드도 목록·순위엔 노출(선택은 blockReason 으로 disabled).
const rankedCards = cardRows
.filter((c) => !c.isWildcard || c.status === 'ACTIVE')
.slice()
.sort((a, b) => b.successRate - a.successRate || b.usedCount - a.usedCount);
const cardOptions: ComboOption[] = rankedCards
@ -253,22 +262,27 @@ export function QuotationCreateModal({
const d = cardDetails.get(id);
return { id, code: d?.code ?? '', title: d?.title ?? id, isWildcard: d?.isWildcard ?? false };
});
// 상품에 산정 후보가 있는지(인터넷=공통, 매입·판매=재 한정). 없으면 MD가가 유일한 후보 → 필수가 된다.
const mdNum = Number(mdPrice) || 0;
const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null));
const mdRequired = !!productId && !hasItemCandidate;
// 목표가 산정 가능 여부: MD가가 있으면 무조건 OK. 없으면 상품 후보 중 하나라도 있어야.
const targetReady = mdNum > 0 || hasItemCandidate;
const selectedSetting = quotationSettings.find((s) => s.qt_setting_id === settingId);
const margin = parsePercent(selectedSetting?.target_margin);
const targetCandidates = mdNum > 0
? [mdNum]
: [
internetLowest != null ? internetLowest * (1 - INTERNET_AVERAGE_FEE) : null,
!isReType || purchase == null ? null : purchase,
!isReType || selling == null ? null : selling * (1 - margin),
].filter((v): v is number => v != null && v > 0);
const estimatedTargetPrice = targetCandidates.length ? Math.trunc(Math.min(...targetCandidates)) : null;
// 목표가 산정 후보(계산식+결과값) — 인터넷최저가×(1−수수료)·매입가/판매가×(1−네고율). 회사설정 숨김필드는 제외(백엔드와 동일).
const targetBreakdown = [
{ key: 'internet_lowest_price', label: `${label('item.internet_lowest_price')} × (1−수수료 ${+(INTERNET_AVERAGE_FEE * 100).toFixed(1)}%)`, raw: internetLowest, rate: INTERNET_AVERAGE_FEE, show: internetLowest != null },
{ key: 'purchase_price', label: `${label('item.purchase_price')} × (1−네고율 ${+(margin * 100).toFixed(1)}%)`, raw: purchase, rate: margin, show: isReType && purchase != null },
{ key: 'selling_price', label: `${label('item.selling_price')} × (1−네고율 ${+(margin * 100).toFixed(1)}%)`, raw: selling, rate: margin, show: isReType && selling != null },
]
.filter((c) => c.show && c.raw != null && c.raw > 0 && !isHidden(c.key))
// 서버 _candidates 와 동일하게 10원 단위 반올림(IMK #11) — 후보·목표가·저장값이 다 일치.
.map((c) => ({ key: c.key, label: c.label, raw: c.raw as number, value: Math.round(((c.raw as number) * (1 - c.rate)) / 10) * 10 }));
const autoTarget = targetBreakdown.length ? Math.min(...targetBreakdown.map((c) => c.value)) : null;
// 구매담당자 제시가 필드엔 자동 산출값을 미리 보여주되(IMK #4), 담당자가 직접 건드렸을 때만 md_price 로 전송한다.
// (자동값을 md 로 보내면 서버가 'MD 입력가'로 저장해 산정내역이 매입가 대신 MD로 잡히고 후보가 안 보인다.)
const effectiveMdPrice = mdTouched ? mdPrice : (autoTarget != null ? String(autoTarget) : mdPrice);
const mdNum = Number(effectiveMdPrice) || 0;
const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null));
const mdRequired = !!productId && !hasItemCandidate;
const targetReady = mdNum > 0 || hasItemCandidate;
// 최종 목표가 = 제시가(자동/수동) 있으면 그 값, 없으면 자동 산출값.
const estimatedTargetPrice = mdNum > 0 ? mdNum : autoTarget;
const targetPriceLimit = unitPrice != null && unitPrice > 0
? unitPrice * TARGET_PRICE_UNIT_LIMIT_MULTIPLIER
: null;
@ -304,7 +318,7 @@ export function QuotationCreateModal({
};
// 일괄 선택(IMK #22) — 현재 목록(검색 결과)의 카드를 전부 담는다. 이미 담긴 카드는 유지.
const selectAllCards = () => {
const rows = cardRows.filter((c) => !c.isWildcard || c.status === 'ACTIVE');
const rows = cardRows.filter((c) => !blockReason(c));
setCardDetails((m) => {
const next = new Map(m);
rows.forEach((r) => next.set(r.id, { code: r.code, title: r.title, isWildcard: r.isWildcard }));
@ -320,7 +334,7 @@ export function QuotationCreateModal({
try {
// 목표가 산정에 쓸 값이 없으면(MD가·상품 후보 전무) 생성 차단 — 서버 산정불가 에러 선제 방어.
if (productId && !targetReady) {
showToast('목표가 산정에 쓸 값이 없습니다 — MD 제시가를 입력하거나, 상품 상세에서 인터넷최저가·매입가를 채워주세요.', 'error');
showToast('목표가 산정에 쓸 값이 없습니다 — 구매담당자 제시가를 입력하거나, 상품 상세에서 인터넷최저가·매입가를 채워주세요.', 'error');
return; // finally 에서 submitting 해제
}
if (!isFutureLocalInput(dueDate)) {
@ -341,7 +355,7 @@ export function QuotationCreateModal({
settingId,
cardIds: oneToOne ? selectedCardIds : [],
memo,
mdPrice: mdPrice ? Number(mdPrice) : null,
mdPrice: mdTouched && mdPrice ? Number(mdPrice) : null,
midAction: oneToOne ? midAction : undefined,
overAction: oneToOne ? overAction : undefined,
});
@ -422,7 +436,7 @@ export function QuotationCreateModal({
</div>
<div className="space-y-1">
<Typography as="label" variant="label">마감기한</Typography>
<Typography as="label" variant="label">{label('quotation.due_date')}</Typography>
<input
id="wizard-date"
type="datetime-local"
@ -434,7 +448,7 @@ export function QuotationCreateModal({
</div>
<div className="space-y-1">
<Typography as="label" variant="label">견적건명</Typography>
<Typography as="label" variant="label">{label('quotation.title')}</Typography>
<Input
id="wizard-title"
type="text"
@ -460,79 +474,6 @@ export function QuotationCreateModal({
emptyText="일치하는 상품이 없습니다"
/>
</div>
{/* MD 제시가 — 입력 시 목표가로 사용. 상품에 다른 후보가 없으면 유일 후보라 필수. */}
<div className="space-y-1">
<Typography as="label" variant="label" className={mdRequired ? 'text-rose-500' : undefined}>
MD 제시가 {mdRequired ? '(필수 — 다른 후보 없음)' : '(선택)'}
</Typography>
<Input
id="wizard-md-price"
type="number"
min={0}
className="text-xs"
value={mdPrice}
onChange={(e) => setMdPrice(e.target.value)}
placeholder={mdRequired ? '상품에 산정값이 없어 MD가 입력이 필요합니다' : '입력 시 목표가로 사용 · 미입력 시 자동 산정'}
/>
</div>
{/* 목표가 산정 후보 — 상품 값(읽기전용). 신규=인터넷최저가, 재=+매입가·판매가. 수정은 상품 상세에서. */}
{productId && (
<div className="rounded border border-border bg-muted/20 p-3 space-y-2">
<div className="flex items-center justify-between">
<Typography as="span" variant="label">
목표가 산정 후보 <span className="text-muted-foreground font-normal">({isReType ? '재' : '신규'})</span>
</Typography>
<button
type="button"
onClick={() => navigate(`/products?detail=${productId}`)}
className={cn(typographyVariants({ variant: 'link' }), 'text-[10px]')}
>
상품 상세에서 수정
</button>
</div>
<div className={`grid ${isReType ? 'grid-cols-3' : 'grid-cols-1'} gap-2`}>
{[
{ label: '인터넷 최저가', value: internetLowest, show: true },
{ label: '매입가', value: purchase, show: isReType },
{ label: '판매가', value: selling, show: isReType },
]
.filter((r) => r.show)
.map((r) => (
<div key={r.label} className="space-y-0.5">
<Typography as="span" variant="label" className="text-muted-foreground">{r.label}</Typography>
<Typography as="span" variant="small" className="font-mono block">
{r.value != null ? `₩${Number(r.value).toLocaleString()}` : '-'}
</Typography>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-2 border-t border-border/60 pt-2">
{[
{ label: '상품단가', value: unitPrice },
{ label: `목표가 상한 (상품단가 × ${TARGET_PRICE_UNIT_LIMIT_MULTIPLIER})`, value: targetPriceLimit },
].map((r) => (
<div key={r.label} className="space-y-0.5">
<Typography as="span" variant="label" className="text-muted-foreground">{r.label}</Typography>
<Typography as="span" variant="small" className="font-mono block">
{r.value != null ? `₩${Number(r.value).toLocaleString()}` : '-'}
</Typography>
</div>
))}
</div>
{!targetReady && (
<Typography as="p" variant="small" className="text-rose-600 leading-snug">
⚠ MD 제시가도 없고 상품에 산정할 값이 없습니다 — MD가를 입력하거나 위 ‘상품 상세에서 수정’으로 값을 채워야 목표가가 나옵니다.
</Typography>
)}
{targetLimitExceeded && (
<Typography as="p" variant="small" className="text-rose-600 leading-snug">
목표가 예상값 ₩{estimatedTargetPrice?.toLocaleString()}이 상품단가의 2배를 초과합니다 — MD 제시가 또는 상품 가격 정보를 조정해 주세요.
</Typography>
)}
</div>
)}
</div>
)}
@ -622,6 +563,73 @@ export function QuotationCreateModal({
</div>
)}
{/* 목표가 산정 후보(계산식+결과값) — 참고용, 위. 채택된 최저는 녹색 강조. */}
{productId && targetBreakdown.length > 0 && (
<div className="rounded border border-border bg-muted/20 p-3 space-y-2">
<div className="flex items-center justify-between">
<Typography as="span" variant="label">
목표가 산정 후보 <span className="text-muted-foreground font-normal">({isReType ? '재' : '신규'})</span>
</Typography>
<button
type="button"
onClick={() => navigate(`/products?detail=${productId}`)}
className={cn(typographyVariants({ variant: 'link' }), 'text-[10px]')}
>
상품 상세에서 수정
</button>
</div>
<div className="space-y-1">
{targetBreakdown.map((c) => {
const isMin = autoTarget != null && c.value === autoTarget;
return (
<div key={c.key} className={cn('flex items-center gap-2 rounded px-2 py-1', isMin && 'bg-emerald-50 dark:bg-emerald-950/30')}>
<Typography as="span" variant="small" className="flex-1 min-w-0 truncate text-[11px] text-muted-foreground">{c.label}</Typography>
<Typography as="span" variant="small" className="w-16 shrink-0 text-right font-mono text-[10px] tabular-nums text-muted-foreground/70">{c.raw.toLocaleString()}</Typography>
<Typography as="span" variant="small" className={cn('w-20 shrink-0 text-right font-mono font-bold tabular-nums', isMin && 'text-emerald-700 dark:text-emerald-400')}>₩{c.value.toLocaleString()}</Typography>
<span className="w-9 shrink-0 text-right">
{isMin && <span className="rounded-full border border-emerald-600 px-1.5 text-[8px] font-bold text-emerald-700 dark:text-emerald-400">최저</span>}
</span>
</div>
);
})}
</div>
</div>
)}
{/* 목표가(구매담당자 제시가) — 후보 최저를 자동 입력. 값이 높든 낮든 이 값이 1순위(실제 목표가). 수정 가능. */}
<div className="space-y-1">
<Typography as="label" variant="label" className={mdRequired ? 'text-rose-500' : undefined}>
목표가 (구매담당자 제시가) {mdRequired ? '(필수 — 산정값 없음)' : ''}
</Typography>
<Input
id="wizard-md-price"
type="number"
min={0}
className="text-xs"
value={effectiveMdPrice}
onChange={(e) => {
setMdTouched(true);
setMdPrice(e.target.value);
}}
placeholder={mdRequired ? '상품에 산정값이 없어 직접 입력이 필요합니다' : '자동 산출값 · 수정 가능'}
/>
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">
{autoTarget != null
? '후보 중 최저가 자동 입력됨 · 이 값이 목표가(1순위)로 쓰입니다. 수정 가능.'
: '자동 산출값이 없어 직접 입력이 필요합니다.'}
</Typography>
{!targetReady && (
<Typography as="p" variant="small" className="text-rose-600 leading-snug">
⚠ 목표가를 산정할 값이 없습니다 — 직접 입력하거나 상품 상세에서 인터넷최저가·매입가를 채워주세요.
</Typography>
)}
{targetLimitExceeded && (
<Typography as="p" variant="small" className="text-rose-600 leading-snug">
목표가 ₩{estimatedTargetPrice?.toLocaleString()}이 상품단가의 2배를 초과합니다 — 조정해 주세요.
</Typography>
)}
</div>
<div className="space-y-1">
<Typography as="label" variant="label">메모 (선택)</Typography>
<textarea
@ -697,15 +705,15 @@ export function QuotationCreateModal({
type="button"
size="sm"
onClick={() => {
if (step === 1 && productId && !targetReady) {
showToast('목표가 산정에 쓸 값이 없습니다 — MD 제시가를 입력하거나 상품 상세에서 값을 채워주세요.', 'error');
return;
}
if (step === 1 && !isFutureLocalInput(dueDate)) {
showToast('마감기한은 현재 시각보다 나중으로 설정해 주세요.', 'error');
return;
}
if (step === 1 && targetLimitExceeded) {
if (step === 3 && productId && !targetReady) {
showToast('목표가 산정에 쓸 값이 없습니다 — 구매담당자 제시가를 입력하거나 상품 상세에서 값을 채워주세요.', 'error');
return;
}
if (step === 3 && targetLimitExceeded) {
showToast('목표가는 상품단가의 2배를 초과할 수 없습니다.', 'error');
return;
}

View File

@ -8,7 +8,7 @@ import { InfoField } from './InfoField';
import { QuotationStatusBadge } from './StatusPill';
import { ResultSummaryBand } from './ResultSummaryBand';
import type { QuotationData } from '@/api/generated/model/quotationData';
import { useLabels } from '@/features/settings/useCompanySettings';
import { useLabels, useHiddenFields } from '@/features/settings/useCompanySettings';
import {
type Product,
type QuotationSetting,
@ -52,6 +52,7 @@ export function DrawerHeaderCards({
collapsed = false,
}: DrawerHeaderCardsProps) {
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
const isHidden = useHiddenFields(); // 회사설정으로 감춘 가격 필드는 상품 카드에서도 제외
// 접으면 결과 요약 밴드만 노출(목표가·낙찰·절감). 상세 그리드 계산은 건너뛴다.
if (collapsed) {
return (
@ -83,11 +84,11 @@ export function DrawerHeaderCards({
const won = (n?: number | null) => (n != null ? `₩${Number(n).toLocaleString()}` : '-');
const productPriceRows = currentProduct
? [
{ label: '상품단가', value: won(currentProduct.price) },
{ label: '매입가', value: won(currentProduct.purchase_price) },
{ label: '판매가', value: won(currentProduct.selling_price) },
{ label: '인터넷 최저가', value: won(currentProduct.internet_lowest_price) },
]
{ key: 'price', label: label('item.price'), value: won(currentProduct.price) },
{ key: 'purchase_price', label: label('item.purchase_price'), value: won(currentProduct.purchase_price) },
{ key: 'selling_price', label: label('item.selling_price'), value: won(currentProduct.selling_price) },
{ key: 'internet_lowest_price', label: label('item.internet_lowest_price'), value: won(currentProduct.internet_lowest_price) },
].filter((r) => !isHidden(r.key))
: [];
return (
@ -161,7 +162,7 @@ export function DrawerHeaderCards({
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5 font-mono text-muted-foreground">
{productPriceRows.map((row) => (
<InfoField
key={row.label}
key={row.key}
label={row.label}
value={row.value}
valueClassName="font-sans truncate block"

View File

@ -22,6 +22,7 @@ const won = (n?: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-'
// 회사 설정 용어(목표 마진)를 반영해 컴포넌트 안에서 만든다.
const candidateSub = (marginLabel: string): Record<string, string> => ({
internet: '인터넷 평균 수수료 적용',
purchase: `${marginLabel} 적용`,
selling: `${marginLabel} 적용`,
});
@ -81,13 +82,13 @@ export function TargetPriceModal({
{/* 선정방식 */}
<div className="mt-4 bg-muted/40 border border-border rounded p-3 space-y-1">
<Typography as="p" variant="small" className="font-bold text-foreground text-[11px]">목표가 선정방식</Typography>
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">1. MD 입력값 존재 시, 최우선 적용</Typography>
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">1. 구매담당자 제시가 존재 시, 최우선 적용</Typography>
{/* 설명 문구는 회사 설정(숨김 필드)에 따라 실제 후보와 일치하게 바꾼다. */}
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">
2. 다음 중 가장 작은 값 — {[
!hiddenPrice.includes('internet_lowest_price') && '인터넷최저가×(1−수수료)',
!hiddenPrice.includes('purchase_price') && '매입가',
!hiddenPrice.includes('selling_price') && `판매가×(1−${label('target_margin')})`,
!hiddenPrice.includes('internet_lowest_price') && `${label('item.internet_lowest_price')}×(1−수수료)`,
!hiddenPrice.includes('purchase_price') && `${label('item.purchase_price')}×(1−${label('target_margin')})`,
!hiddenPrice.includes('selling_price') && `${label('item.selling_price')}×(1−${label('target_margin')})`,
].filter(Boolean).join(' | ')}
</Typography>
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">

View File

@ -38,7 +38,7 @@ export function CardEffectChart({ data }: { data: CardTypeUsage[] }) {
{r.label} 평균 하락
</Typography>
<Typography as="p" variant="small" className="font-mono text-[12px] font-bold text-foreground">
{/* TODO: 제시가 하락 델타 미배선 — 백엔드 avg_drop=0 동안 '측정 예정' */}
{/* 사용 직후 유저 제시가 하락(카드 직전−직후 제시가 평균). 표본 없으면 '측정 예정'. */}
{r.avgDrop > 0 ? won(r.avgDrop) : '측정 예정'}
</Typography>
</div>

View File

@ -58,7 +58,7 @@ export function CardTop5() {
</div>
<Typography as="p" variant="caption" className="mt-1 truncate">
{card.isWildcard ? '와일드카드' : '협상카드'} · {card.code} · 사용 {card.usedCount}회
{card.isWildcard ? '와일드카드' : '협상카드'} · 사용 {card.usedCount}회
</Typography>
</div>
</div>

View File

@ -40,7 +40,7 @@ export function StatTile({
</Typography>
)}
</div>
<Typography variant="caption" className="block truncate">
<Typography variant="caption" className="block break-keep">
{label}
</Typography>
</div>

View File

@ -36,7 +36,7 @@
--color-ring: var(--ring);
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
--font-mono: "JetBrains Mono", Consolas, "Segoe UI Mono", ui-monospace, SFMono-Regular, "Menlo", monospace;
}
:root {