diff --git a/negodata/backend/common/anchoring/service.py b/negodata/backend/common/anchoring/service.py
index 58886ba..3615cff 100644
--- a/negodata/backend/common/anchoring/service.py
+++ b/negodata/backend/common/anchoring/service.py
@@ -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
diff --git a/negodata/backend/crud/statistics_crud.py b/negodata/backend/crud/statistics_crud.py
index f6b73ab..a97cd7c 100644
--- a/negodata/backend/crud/statistics_crud.py
+++ b/negodata/backend/crud/statistics_crud.py
@@ -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 [])
diff --git a/negodata/backend/services/quotation/pricing.py b/negodata/backend/services/quotation/pricing.py
index 2cd5d25..f57266d 100644
--- a/negodata/backend/services/quotation/pricing.py
+++ b/negodata/backend/services/quotation/pricing.py
@@ -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
diff --git a/negodata/backend/services/statistics_service.py b/negodata/backend/services/statistics_service.py
index b0f64b3..38a6328 100644
--- a/negodata/backend/services/statistics_service.py
+++ b/negodata/backend/services/statistics_service.py
@@ -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
diff --git a/negodata/front/src/features/products/components/NewItemSuppliersPicker.tsx b/negodata/front/src/features/products/components/NewItemSuppliersPicker.tsx
new file mode 100644
index 0000000..c149675
--- /dev/null
+++ b/negodata/front/src/features/products/components/NewItemSuppliersPicker.tsx
@@ -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 (
+
+
공급사 ({value.length})
+
+ {/* 추가 행 — 공급사 + 공급유형 선택 후 추가(로컬) */}
+
+
+ { setPickSupplierId(opt.id); setPickLabel(opt.label); }}
+ placeholder="공급사로 추가할 협력사 검색..."
+ searchPlaceholder="협력사명·코드로 검색..."
+ emptyText="일치하는 협력사가 없습니다"
+ />
+
+
+
+
+
+
+
+ {/* 선택한 공급사 목록(로컬) */}
+
+ {value.length === 0 ? (
+
+ 선택한 공급사가 없습니다. (선택)
+
+ ) : (
+ value.map((m) => (
+
+
+
+ {m.label || '-'}
+
+
+
+
+
+
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/negodata/front/src/features/products/components/ProductFormSheet.tsx b/negodata/front/src/features/products/components/ProductFormSheet.tsx
index ed92bac..181ed34 100644
--- a/negodata/front/src/features/products/components/ProductFormSheet.tsx
+++ b/negodata/front/src/features/products/components/ProductFormSheet.tsx
@@ -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; // 카테고리명 → category_type(id) 매핑
nextCategoryType: number; // 신규 카테고리에 부여할 id(= 기존 max + 1)
- onCreate: (data: ItemCreate) => Promise;
+ onCreate: (data: ItemCreate) => Promise; // 생성된 item_id 반환(공급사 매핑용)
onUpdate: (itemId: string, data: ItemUpdate) => Promise;
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([]); // 신규 등록 시 매핑할 공급사(생성 후 매핑)
// 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정·삭제(프론트 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 && }
+ {/* 공급사 선택(IMK #8) — 신규 등록 모드. 로컬로 모으고 상품 생성 후 매핑한다. */}
+ {mode === 'create' && }
{/* 회사 커스텀 필드 — companies.settings.item_fields 정의대로 렌더, items.custom 에 저장 */}
diff --git a/negodata/front/src/features/products/hooks/useProducts.ts b/negodata/front/src/features/products/hooks/useProducts.ts
index 0872452..a3fd88f 100644
--- a/negodata/front/src/features/products/hooks/useProducts.ts
+++ b/negodata/front/src/features/products/hooks/useProducts.ts
@@ -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 => {
+ 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);
diff --git a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx
index 15aee39..7b2e323 100644
--- a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx
+++ b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx
@@ -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(DEFAULT_MID_ACTION); // 앵커~목표가 구간: 낙찰/개찰 (1:1 전용)
const [overAction, setOverAction] = useState(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();
(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({
-
마감기한
+
{label('quotation.due_date')}
- 견적건명
+ {label('quotation.title')}
-
- {/* MD 제시가 — 입력 시 목표가로 사용. 상품에 다른 후보가 없으면 유일 후보라 필수. */}
-
-
- MD 제시가 {mdRequired ? '(필수 — 다른 후보 없음)' : '(선택)'}
-
- setMdPrice(e.target.value)}
- placeholder={mdRequired ? '상품에 산정값이 없어 MD가 입력이 필요합니다' : '입력 시 목표가로 사용 · 미입력 시 자동 산정'}
- />
-
-
- {/* 목표가 산정 후보 — 상품 값(읽기전용). 신규=인터넷최저가, 재=+매입가·판매가. 수정은 상품 상세에서. */}
- {productId && (
-
-
-
- 목표가 산정 후보 ({isReType ? '재' : '신규'})
-
-
-
-
- {[
- { label: '인터넷 최저가', value: internetLowest, show: true },
- { label: '매입가', value: purchase, show: isReType },
- { label: '판매가', value: selling, show: isReType },
- ]
- .filter((r) => r.show)
- .map((r) => (
-
- {r.label}
-
- {r.value != null ? `₩${Number(r.value).toLocaleString()}` : '-'}
-
-
- ))}
-
-
- {[
- { label: '상품단가', value: unitPrice },
- { label: `목표가 상한 (상품단가 × ${TARGET_PRICE_UNIT_LIMIT_MULTIPLIER})`, value: targetPriceLimit },
- ].map((r) => (
-
- {r.label}
-
- {r.value != null ? `₩${Number(r.value).toLocaleString()}` : '-'}
-
-
- ))}
-
- {!targetReady && (
-
- ⚠ MD 제시가도 없고 상품에 산정할 값이 없습니다 — MD가를 입력하거나 위 ‘상품 상세에서 수정’으로 값을 채워야 목표가가 나옵니다.
-
- )}
- {targetLimitExceeded && (
-
- 목표가 예상값 ₩{estimatedTargetPrice?.toLocaleString()}이 상품단가의 2배를 초과합니다 — MD 제시가 또는 상품 가격 정보를 조정해 주세요.
-
- )}
-
- )}
)}
@@ -622,6 +563,73 @@ export function QuotationCreateModal({
)}
+ {/* 목표가 산정 후보(계산식+결과값) — 참고용, 위. 채택된 최저는 녹색 강조. */}
+ {productId && targetBreakdown.length > 0 && (
+
+
+
+ 목표가 산정 후보 ({isReType ? '재' : '신규'})
+
+
+
+
+ {targetBreakdown.map((c) => {
+ const isMin = autoTarget != null && c.value === autoTarget;
+ return (
+
+ {c.label}
+ {c.raw.toLocaleString()}
+ ₩{c.value.toLocaleString()}
+
+ {isMin && 최저}
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* 목표가(구매담당자 제시가) — 후보 최저를 자동 입력. 값이 높든 낮든 이 값이 1순위(실제 목표가). 수정 가능. */}
+
+
+ 목표가 (구매담당자 제시가) {mdRequired ? '(필수 — 산정값 없음)' : ''}
+
+ {
+ setMdTouched(true);
+ setMdPrice(e.target.value);
+ }}
+ placeholder={mdRequired ? '상품에 산정값이 없어 직접 입력이 필요합니다' : '자동 산출값 · 수정 가능'}
+ />
+
+ {autoTarget != null
+ ? '후보 중 최저가 자동 입력됨 · 이 값이 목표가(1순위)로 쓰입니다. 수정 가능.'
+ : '자동 산출값이 없어 직접 입력이 필요합니다.'}
+
+ {!targetReady && (
+
+ ⚠ 목표가를 산정할 값이 없습니다 — 직접 입력하거나 상품 상세에서 인터넷최저가·매입가를 채워주세요.
+
+ )}
+ {targetLimitExceeded && (
+
+ 목표가 ₩{estimatedTargetPrice?.toLocaleString()}이 상품단가의 2배를 초과합니다 — 조정해 주세요.
+
+ )}
+
+
diff --git a/negodata/front/src/features/statistics/components/StatTile.tsx b/negodata/front/src/features/statistics/components/StatTile.tsx
index 75ac6d2..c353aec 100644
--- a/negodata/front/src/features/statistics/components/StatTile.tsx
+++ b/negodata/front/src/features/statistics/components/StatTile.tsx
@@ -40,7 +40,7 @@ export function StatTile({
)}
-
+
{label}
diff --git a/negodata/front/src/tokens.css b/negodata/front/src/tokens.css
index 7ff88f4..eb5b47f 100644
--- a/negodata/front/src/tokens.css
+++ b/negodata/front/src/tokens.css
@@ -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 {