From b33ae05cd6fd3c9a7504fbc027603ee17ea51a39 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 29 Jul 2026 09:51:03 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20negodata:=20=EA=B2=AC=EC=A0=81=20?= =?UTF-8?q?=EB=AA=A9=ED=91=9C=EA=B0=80=C2=B7=EC=95=B5=EC=BB=A4=20=EB=B0=98?= =?UTF-8?q?=EC=98=AC=EB=A6=BC=20+=20=ED=86=B5=EA=B3=84=20=ED=98=91?= =?UTF-8?q?=EC=83=81=EC=B9=B4=EB=93=9C=20=ED=9A=A8=EA=B3=BC=20=E2=80=94=20?= =?UTF-8?q?=EB=AA=A9=ED=91=9C=EA=B0=80=3D=EB=A7=A4=EC=9E=85=EA=B0=80=C3=97?= =?UTF-8?q?(1=E2=88=92=EB=84=A4=EA=B3=A0=EC=9C=A8)=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=C2=B7=EC=82=B0=EC=A0=95=ED=9B=84=EB=B3=B4?= =?UTF-8?q?=EB=A5=BC=20=EB=82=99=EC=B0=B0=EA=B8=B0=EC=A4=80=ED=83=AD?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99=C2=B7=EB=AA=A9=ED=91=9C?= =?UTF-8?q?=EA=B0=80=EC=83=81=ED=95=9C=20=EC=83=81=EC=8B=9C=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=EC=A0=9C=EA=B1=B0,=20=ED=9B=84=EB=B3=B4=EA=B0=80/?= =?UTF-8?q?=EC=95=B5=EC=BB=A4=20=EC=83=9D=EC=84=B1=EA=B0=80=2010=EC=9B=90?= =?UTF-8?q?=20=EB=8B=A8=EC=9C=84=20=EB=B0=98=EC=98=AC=EB=A6=BC(=EC=8B=A0?= =?UTF-8?q?=EA=B7=9C=20=EC=84=B8=EC=85=98,=20schedules=20=EC=95=B5?= =?UTF-8?q?=EC=BB=A4=EC=99=80=20=EB=8F=99=EC=9D=BC=20=EA=B3=B5=EC=8B=9D),?= =?UTF-8?q?=20=EC=82=B0=EC=A0=95=EB=AA=A8=EB=8B=AC=EC=9D=80=20=EA=B5=AC?= =?UTF-8?q?=EB=A7=A4=EB=8B=B4=EB=8B=B9=EC=9E=90=20=EC=A0=9C=EC=8B=9C?= =?UTF-8?q?=EA=B0=80=20=EC=9E=88=EC=96=B4=EB=8F=84=20=ED=9B=84=EB=B3=B4=20?= =?UTF-8?q?=EC=A0=84=EB=B6=80=20=EB=85=B8=EC=B6=9C=C2=B7MD=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EA=B0=80=E2=86=92=EA=B5=AC=EB=A7=A4=EB=8B=B4=EB=8B=B9?= =?UTF-8?q?=EC=9E=90=20=EC=A0=9C=EC=8B=9C=EA=B0=80,=20=EC=8B=A0=EA=B7=9C?= =?UTF-8?q?=EC=83=81=ED=92=88=20=EB=93=B1=EB=A1=9D=EC=97=90=20=EA=B3=B5?= =?UTF-8?q?=EA=B8=89=EC=82=AC=EC=B9=B8=20=EC=B6=94=EA=B0=80,=20=EC=83=81?= =?UTF-8?q?=ED=92=88=EC=83=81=EC=84=B8/=EA=B2=AC=EC=A0=81=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=9D=BC=EB=B2=A8=20=ED=9A=8C=EC=82=AC=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=97=B0=EB=8F=99,=20=ED=86=B5=EA=B3=84=20?= =?UTF-8?q?=ED=98=91=EC=83=81=EC=B9=B4=EB=93=9C=20=ED=9A=A8=EA=B3=BC(?= =?UTF-8?q?=EC=9C=A0=ED=98=95=EB=B3=84=20=EC=82=AC=EC=9A=A9=EB=B9=88?= =?UTF-8?q?=EB=8F=84+=EC=82=AC=EC=9A=A9=EC=A7=81=ED=9B=84=20=ED=8F=89?= =?UTF-8?q?=EA=B7=A0=20=EC=A0=9C=EC=8B=9C=EA=B0=80=20=ED=95=98=EB=9D=BD)?= =?UTF-8?q?=C2=B7=EC=99=80=EC=9D=BC=EB=93=9C=EC=B9=B4=EB=93=9C=201%?= =?UTF-8?q?=EC=9D=B8=ED=95=98=20=EC=A7=91=EA=B3=84,=20mono=20=ED=8F=B0?= =?UTF-8?q?=ED=8A=B8=20=ED=8F=B4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- negodata/backend/common/anchoring/service.py | 8 +- negodata/backend/crud/statistics_crud.py | 55 ++++- .../backend/services/quotation/pricing.py | 14 +- .../backend/services/statistics_service.py | 41 +++- .../components/NewItemSuppliersPicker.tsx | 127 +++++++++++ .../products/components/ProductFormSheet.tsx | 20 +- .../features/products/hooks/useProducts.ts | 6 +- .../components/QuotationCreateModal.tsx | 208 +++++++++--------- .../DrawerHeaderCards.tsx | 15 +- .../QuotationDetailSheet/TargetPriceModal.tsx | 9 +- .../statistics/components/CardEffectChart.tsx | 2 +- .../statistics/components/CardTop5.tsx | 2 +- .../statistics/components/StatTile.tsx | 2 +- negodata/front/src/tokens.css | 2 +- 14 files changed, 373 insertions(+), 138 deletions(-) create mode 100644 negodata/front/src/features/products/components/NewItemSuppliersPicker.tsx 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배를 초과합니다 — 조정해 주세요. + + )} +
+
메모 (선택)