From 2bf91d64f5d3cd4788ff1874b8c69a747b99e2f4 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 3 Aug 2026 09:33:06 +0900 Subject: [PATCH 1/7] =?UTF-8?q?[refactor]=20negodata/front:=20=EA=B2=AC?= =?UTF-8?q?=EC=A0=81=EB=93=B1=EB=A1=9D=20=EB=AA=A8=EB=8B=AC=20=ED=9B=85=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20+=20=EC=8B=9C=EC=9E=A5=EA=B0=80=20?= =?UTF-8?q?=EB=AA=A8=EC=88=9C=20=EC=B9=B4=EB=93=9C=20=EC=B0=A8=EB=8B=A8(10?= =?UTF-8?q?=EB=B2=88),=20=EC=97=91=EC=85=80=20=EC=97=85=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=20xlsx=20=ED=8C=8C=EC=8B=B1=C2=B7CP949=20=ED=8F=B4=EB=B0=B1(7?= =?UTF-8?q?=EB=B2=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- negodata/front/package-lock.json | 104 ++++ negodata/front/package.json | 1 + .../cards/components/CardExcelUploadModal.tsx | 10 +- .../partners/components/ExcelUploadModal.tsx | 10 +- .../products/components/ExcelUploadModal.tsx | 10 +- .../components/QuotationCreateModal.tsx | 456 ++---------------- .../components/QuotationFormParts.tsx | 237 +++++++++ .../components/quotationForm.utils.ts | 26 + .../quotations/hooks/useCardGating.tsx | 138 ++++++ .../quotations/hooks/useTargetPrice.ts | 110 +++++ negodata/front/src/lib/excel.ts | 23 +- 11 files changed, 706 insertions(+), 419 deletions(-) create mode 100644 negodata/front/src/features/quotations/components/QuotationFormParts.tsx create mode 100644 negodata/front/src/features/quotations/components/quotationForm.utils.ts create mode 100644 negodata/front/src/features/quotations/hooks/useCardGating.tsx create mode 100644 negodata/front/src/features/quotations/hooks/useTargetPrice.ts diff --git a/negodata/front/package-lock.json b/negodata/front/package-lock.json index a999bef..bf7d847 100644 --- a/negodata/front/package-lock.json +++ b/negodata/front/package-lock.json @@ -36,6 +36,7 @@ "tw-animate-css": "^1.4.0", "vaul": "^1.1.2", "vite": "^6.2.3", + "xlsx": "^0.18.5", "zod": "^4.4.3", "zustand": "^5.0.14" }, @@ -4421,6 +4422,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -4940,6 +4950,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -5059,6 +5082,15 @@ "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", "license": "MIT" }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5193,6 +5225,18 @@ } } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6672,6 +6716,15 @@ "node": ">= 0.6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -10949,6 +11002,18 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -12501,6 +12566,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -12594,6 +12677,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/negodata/front/package.json b/negodata/front/package.json index c0d21f9..92f2b1d 100644 --- a/negodata/front/package.json +++ b/negodata/front/package.json @@ -40,6 +40,7 @@ "tw-animate-css": "^1.4.0", "vaul": "^1.1.2", "vite": "^6.2.3", + "xlsx": "^0.18.5", "zod": "^4.4.3", "zustand": "^5.0.14" }, diff --git a/negodata/front/src/features/cards/components/CardExcelUploadModal.tsx b/negodata/front/src/features/cards/components/CardExcelUploadModal.tsx index adf924d..0e158aa 100644 --- a/negodata/front/src/features/cards/components/CardExcelUploadModal.tsx +++ b/negodata/front/src/features/cards/components/CardExcelUploadModal.tsx @@ -2,7 +2,7 @@ import { useMemo, useRef, useState } from 'react'; import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react'; import { useScrollLock } from '@/lib/useScrollLock'; import { showToast } from '@/lib/notify'; -import { downloadExcel, parseCsv, todayStamp, type BulkFailure } from '@/lib/excel'; +import { downloadExcel, readSpreadsheetRows, todayStamp, type BulkFailure } from '@/lib/excel'; import { Typography } from '@/components/ui/typography'; import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; @@ -102,7 +102,13 @@ export function CardExcelUploadModal({ open, onConfirm, onClose }: CardExcelUplo // 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생). 헤더는 양식과 동일해야 함. const handleFile = async (file: File) => { - const parsed = parseCsv(await file.text()); + let parsed: Record[]; + try { + parsed = await readSpreadsheetRows(file); + } catch (err) { + showToast(err instanceof Error ? err.message : '파일을 읽을 수 없습니다.', 'error'); + return; + } const loaded: RawRow[] = parsed.map((r, i) => ({ id: `row-${i + 1}`, rowNum: i + 2, diff --git a/negodata/front/src/features/partners/components/ExcelUploadModal.tsx b/negodata/front/src/features/partners/components/ExcelUploadModal.tsx index 6c09cb7..2f30a4d 100644 --- a/negodata/front/src/features/partners/components/ExcelUploadModal.tsx +++ b/negodata/front/src/features/partners/components/ExcelUploadModal.tsx @@ -3,7 +3,7 @@ import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react'; import { useScrollLock } from '@/lib/useScrollLock'; import type { ReqCreateSupplier as SupplierCreate } from '@/api/generated/model/reqCreateSupplier'; import { showToast } from '@/lib/notify'; -import { downloadExcel, parseCsv, todayStamp, type BulkFailure } from '@/lib/excel'; +import { downloadExcel, readSpreadsheetRows, todayStamp, type BulkFailure } from '@/lib/excel'; import { customFetch } from '@/api/mutator/custom-fetch'; import { Typography } from '@/components/ui/typography'; import { Input } from '@/components/ui/input'; @@ -144,7 +144,13 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp // 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생). 헤더는 양식과 동일해야 함. const handleFile = async (file: File) => { - const parsed = parseCsv(await file.text()); + let parsed: Record[]; + try { + parsed = await readSpreadsheetRows(file); + } catch (err) { + showToast(err instanceof Error ? err.message : '파일을 읽을 수 없습니다.', 'error'); + return; + } const loaded: RawRow[] = parsed.map((r, i) => ({ id: `row-${i + 1}`, rowNum: i + 2, diff --git a/negodata/front/src/features/products/components/ExcelUploadModal.tsx b/negodata/front/src/features/products/components/ExcelUploadModal.tsx index f81be20..32a386e 100644 --- a/negodata/front/src/features/products/components/ExcelUploadModal.tsx +++ b/negodata/front/src/features/products/components/ExcelUploadModal.tsx @@ -3,7 +3,7 @@ import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react'; import { useScrollLock } from '@/lib/useScrollLock'; import type { ReqCreateItem as ItemCreate } from '@/api/generated/model/reqCreateItem'; import { showToast } from '@/lib/notify'; -import { downloadExcel, parseCsv, todayStamp, type BulkFailure } from '@/lib/excel'; +import { downloadExcel, readSpreadsheetRows, todayStamp, type BulkFailure } from '@/lib/excel'; import { customFetch } from '@/api/mutator/custom-fetch'; import { Typography } from '@/components/ui/typography'; import { Input } from '@/components/ui/input'; @@ -344,7 +344,13 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp // 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생). // 헤더는 회사 라벨과 기본 헤더(구양식) 둘 다 인식한다(aliases). const handleFile = async (file: File) => { - const parsed = parseCsv(await file.text()); + let parsed: Record[]; + try { + parsed = await readSpreadsheetRows(file); + } catch (err) { + showToast(err instanceof Error ? err.message : '파일을 읽을 수 없습니다.', 'error'); + return; + } const pick = (r: Record, c: UploadColumn): string => { for (const a of c.aliases) if (r[a] !== undefined) return r[a]; return ''; diff --git a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx index b4d7e4b..d7bf64f 100644 --- a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect, useRef } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import { X, PlusSquare, ArrowRight, Loader2, Gavel, CheckCheck } from 'lucide-react'; import { useNavigate } from 'react-router'; import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item'; @@ -6,8 +6,7 @@ import { useListItems, useGetItem } from '@/api/generated/item/item'; 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, useHiddenFields } from '@/features/settings/useCompanySettings'; +import { useLabels } from '@/features/settings/useCompanySettings'; import { Button } from '@/components/ui/button'; import { Typography, typographyVariants } from '@/components/ui/typography'; import { cn } from '@/lib/utils'; @@ -20,42 +19,16 @@ import type { CreateQuotationInput } from '../hooks/useQuotations'; import { is1v1, toQuotationType, - awardStrategySummary, PriceGateAction, DEFAULT_MID_ACTION, DEFAULT_OVER_ACTION, type QuotationMode, } from '../types'; -import { supplierTypeLabel } from '@/lib/enumLabels'; import { showToast } from '@/lib/notify'; - -const INTERNET_AVERAGE_FEE = 0.078; -const TARGET_PRICE_UNIT_LIMIT_MULTIPLIER = 2; - -// datetime-local 값: 한국시간(Asia/Seoul)의 'YYYY-MM-DDTHH:mm'. -// sv-SE 로케일이 'YYYY-MM-DD HH:mm:ss' 를 주고, timeZone 명시로 브라우저 TZ 와 무관하게 KST 로 고정한다. -function toKstLocalInput(date: Date): string { - const s = date.toLocaleString('sv-SE', { timeZone: 'Asia/Seoul' }); - return s.slice(0, 16).replace(' ', 'T'); -} - -function nowKstLocalInput(): string { - return toKstLocalInput(new Date()); -} - -function defaultDueDateLocalInput(): string { - return toKstLocalInput(new Date(Date.now() + 60 * 60 * 1000)); -} - -function isFutureLocalInput(value: string): boolean { - const time = new Date(value).getTime(); - return Number.isFinite(time) && time > Date.now(); -} - -function parsePercent(value: string | undefined): number { - const n = Number(String(value ?? '').replace('%', '').trim()); - return Number.isFinite(n) ? n / 100 : 0; -} +import { defaultDueDateLocalInput, nowKstLocalInput, isFutureLocalInput } from './quotationForm.utils'; +import { SupplyTypeBadge, SelectedPartnerTable, SelectedCardTable, Segmented, AwardLinePicker } from './QuotationFormParts'; +import { useTargetPrice } from '../hooks/useTargetPrice'; +import { useCardGating } from '../hooks/useCardGating'; type QuotationCreateModalProps = { open: boolean; @@ -141,7 +114,6 @@ 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)); @@ -177,153 +149,49 @@ export function QuotationCreateModal({ }); const cardRows = cardQ ? (cardSearch.data?.cards ?? []).map(mapCardData) : cards; - // ── 카드 선택 게이팅: 협상 멘트에 변수명이 노출될 카드를 선택 단계에서 막는다 ── - // (1) 조건 전략(customer_condition) 미작성 — 저장 시 조건 내용이 있으면 script 에 실제 문구가 - // 주입되고(slate.serialize), 없으면 {customer_condition} 토큰이 그대로 남는다. - const conditionUnfilled = (c: NegotiationCard) => - (c.scriptPreview ?? '').includes(`{${CONDITION_VARIABLE}}`); - // (2) 인터넷 최저가({internet_lowest_price}) 인용 카드는 선택 상품에 최저가가 수집돼 있을 때만 — - // 최저가 없는 상품(items.internet_lowest_price=NULL/0)의 견적에 넣으면 협상 시 토큰이 노출된다. - // 상품 미선택 상태에선 판정 불가라 막지 않는다(상품 선택 후에만 게이팅). - 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 => - c.isWildcard && c.status !== 'ACTIVE' - ? '미승인 와일드카드' - : conditionUnfilled(c) - ? `${CONDITION_LABEL} 미작성` - : lowestPriceLeak(c) - ? '인터넷 최저가 미수집' - : null; - // 성공률(사용 세션 중 타결 비율) 내림차순 — 표본 없는 카드는 뒤로. 상위 3개에 1·2·3위 배지가 붙는다. - // 미승인 와일드카드도 목록·순위엔 노출(선택은 blockReason 으로 disabled). - const rankedCards = cardRows - .slice() - .sort((a, b) => b.successRate - a.successRate || b.usedCount - a.usedCount); - const cardOptions: ComboOption[] = rankedCards - .map((card, i) => { - const reason = blockReason(card); - return { - id: card.id, - label: card.title, - disabled: !!reason, - node: ( -
-
- {card.usedCount > 0 && ( - - {i + 1}위 · 성공률 {Math.round(card.successRate * 100)}% - - )} - {card.code} - - {card.isWildcard ? '와일드' : '협상'} - - {reason && ( - - {reason} - - )} -
- {card.title} -
- ), - }; - }); - // 1·2·3위 배지가 붙는 카드(상위 3개, 사용이력 있는 것만) — 기본 선택 대상. 선택 불가(조건 미작성·최저가 미수집) 카드는 제외. - const topRankedCards = rankedCards.filter((c) => !blockReason(c)).slice(0, 3).filter((c) => c.usedCount > 0); - const topRankedKey = topRankedCards.map((c) => c.id).join(','); - const autoSelectedRef = useRef(false); - - // 모달을 열면 추천 상위 3개를 기본 선택해 둔다. 열려 있는 동안 1회만 — 이후 사용자의 추가/해제는 건드리지 않는다. - useEffect(() => { - if (!open) { - autoSelectedRef.current = false; - return; - } - if (autoSelectedRef.current || topRankedCards.length === 0) return; // 목록 로드 전이면 다음 렌더에 재시도 - autoSelectedRef.current = true; - setCardDetails((m) => { - const next = new Map(m); - topRankedCards.forEach((c) => next.set(c.id, { code: c.code, title: c.title, isWildcard: c.isWildcard })); - return next; - }); - setSelectedCardIds((prev) => (prev.length > 0 ? prev : topRankedCards.map((c) => c.id))); - // topRankedKey = 목록이 확정된 시점만 감지 (배열 재생성으로 매 렌더 도는 것 방지) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, topRankedKey]); - - // 이미 고른 카드 중, 상품을 최저가 미수집 상품으로 바꾸면 {internet_lowest_price} 인용 카드는 자동 해제. - // (picklist disabled 는 신규 선택만 막으므로, 상품 변경 후 잔존 선택분을 여기서 정리해 노출을 막는다.) - useEffect(() => { - if (!lowestUnavailable) return; - const leakIds = new Set( - cardRows.filter((c) => (c.scriptPreview ?? '').includes('{internet_lowest_price}')).map((c) => c.id), - ); - if (leakIds.size === 0) return; - setSelectedCardIds((prev) => (prev.some((id) => leakIds.has(id)) ? prev.filter((id) => !leakIds.has(id)) : prev)); - // productId 변경 시점에만 정리 (cardRows 재생성으로 매 렌더 도는 것 방지) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [lowestUnavailable, productId]); - - // 선택된 카드 표시행 — 캐시에서 번호/유형/카드명을 읽어 검색어와 무관하게 유지한다. - const selectedCardRows = selectedCardIds.map((id) => { - const d = cardDetails.get(id); - return { id, code: d?.code ?? '', title: d?.title ?? id, isWildcard: d?.isWildcard ?? false }; + // ── 목표가 산정 (카드 게이팅이 목표가를 참조하므로 게이팅보다 먼저 계산한다) ── + const { + settingMarginPct, + negoCandidateKey, + targetBreakdown, + autoTarget, + activeCandidateKey, + effectiveApplyNego, + effectiveMdPrice, + mdRequired, + targetReady, + targetLimitExceeded, + estimatedTargetPrice, + submitMdPrice, + } = useTargetPrice({ + quotationSettings, + settingId, + productId, + isReType, + internetLowest, + purchase, + selling, + unitPrice, + mdPrice, + mdTouched, + negoTouched, + applyNego, + selectedCandidateKey, }); - const selectedSetting = quotationSettings.find((s) => s.qt_setting_id === settingId); - const settingMargin = parsePercent(selectedSetting?.target_margin); // 세팅 네고율(비율) - const settingMarginPct = +(settingMargin * 100).toFixed(1); - // 매입가 네고율 차감 여부 — 안 건드리면 세팅값(>0이면 차감), 건드리면 체크박스값. 네고율 값은 세팅값 고정. - const negoAvailable = settingMargin > 0; - const effectiveApplyNego = negoTouched ? applyNego : negoAvailable; - const margin = effectiveApplyNego ? settingMargin : 0; // 미적용이면 0 → 매입가/판매가 그대로 - const negoLabel = (base: string) => - margin > 0 ? `${base} × (1−네고율 ${+(margin * 100).toFixed(1)}%)` : `${base} (네고율 미적용)`; - // 목표가 산정 후보(계산식+결과값) — 인터넷최저가×(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: negoLabel(label('item.purchase_price')), raw: purchase, rate: margin, show: isReType && purchase != null }, - { key: 'selling_price', label: negoLabel(label('item.selling_price')), 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; - // 기본 채택 후보 = 최저(동률이면 첫 후보). 네고율 체크박스는 매입가 후보(없으면 판매가)에 붙인다. - const minCandidateKey = targetBreakdown.find((c) => c.value === autoTarget)?.key ?? null; - const negoCandidateKey = negoAvailable - ? (targetBreakdown.find((c) => c.key === 'purchase_price')?.key ?? targetBreakdown.find((c) => c.key === 'selling_price')?.key ?? null) - : null; - // 채택 후보 — 사용자가 고르면 그 후보, 아니면 최저. 상품 변경 등으로 선택 키가 사라지면 최저로 폴백. - const pickedCandidate = selectedCandidateKey ? targetBreakdown.find((c) => c.key === selectedCandidateKey) : undefined; - const activeCandidateKey = pickedCandidate ? pickedCandidate.key : minCandidateKey; - const targetFromCandidate = pickedCandidate ? pickedCandidate.value : autoTarget; - // 구매담당자 제시가 필드엔 채택 후보값을 미리 보여주되(IMK #4), 담당자가 직접 건드렸을 때만 md_price 로 전송한다. - // (자동값을 md 로 보내면 서버가 'MD 입력가'로 저장해 산정내역이 매입가 대신 MD로 잡히고 후보가 안 보인다.) - const effectiveMdPrice = mdTouched ? mdPrice : (targetFromCandidate != null ? String(targetFromCandidate) : mdPrice); - const mdNum = Number(effectiveMdPrice) || 0; - // 기본(최저·세팅네고)에서 벗어난 선택/토글이면 그 목표가를 md_price 로 박아 서버 저장값과 화면을 일치시킨다. - // (서버는 세팅 rate·최저로 재계산하므로, 오버라이드를 안 보내면 후보 화면과 저장 목표가가 어긋난다.) - const divergedFromDefault = negoTouched || (!!pickedCandidate && pickedCandidate.key !== minCandidateKey); - const submitMdPrice = mdTouched && mdPrice - ? Number(mdPrice) - : divergedFromDefault && targetFromCandidate != null - ? targetFromCandidate - : null; - const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null)); - const mdRequired = !!productId && !hasItemCandidate; - const targetReady = mdNum > 0 || hasItemCandidate; - // 최종 목표가 = 제시가(자동/수동) 있으면 그 값, 없으면 채택 후보값. - const estimatedTargetPrice = mdNum > 0 ? mdNum : targetFromCandidate; - const targetPriceLimit = unitPrice != null && unitPrice > 0 - ? unitPrice * TARGET_PRICE_UNIT_LIMIT_MULTIPLIER - : null; - const targetLimitExceeded = targetPriceLimit != null && estimatedTargetPrice != null && estimatedTargetPrice > targetPriceLimit; + // ── 카드 선택 게이팅 — 부적합 카드 disabled·자동 선택/해제(useCardGating 이 소유) ── + const { blockReason, cardOptions, selectedCardRows } = useCardGating({ + cardRows, + productId, + internetLowest, + estimatedTargetPrice, + open, + selectedCardIds, + cardDetails, + setSelectedCardIds, + setCardDetails, + }); if (!open) return null; const selectMode = (next: QuotationMode) => { @@ -815,235 +683,3 @@ export function QuotationCreateModal({ ); } - -// ── 헬퍼 컴포넌트 (메인 아래) ────────────────────────────────────────────── - -// 협력사 상품조달유형 배지 — type undefined = 유형 미지정(매핑 없음). 초청 협력사는 그 상품을 공급하므로 '미취급'이 아니라 '미정'. -function SupplyTypeBadge({ type }: { type?: number }) { - if (type === undefined) { - return ( - - 미정 - - ); - } - return ( - - {supplierTypeLabel(type)} - - ); -} - -// 선택된 협력사 테이블 — 콤보로 담은 협력사를 검색어와 무관하게 고정 노출한다. 취급유형 컬럼은 상품 선택 시에만. -function SelectedPartnerTable({ - rows, - supplyTypeBySupplier, - showSupplyType, - onRemove, -}: { - rows: { id: string; name: string; email: string }[]; - supplyTypeBySupplier: Map; - showSupplyType: boolean; - onRemove: (id: string) => void; -}) { - if (rows.length === 0) { - return ( -
- - 담긴 협력사가 없습니다 — 위에서 검색해 담아주세요. - -
- ); - } - return ( -
- - - - - - {showSupplyType && } - - - - {rows.map((r) => ( - - - - {showSupplyType && } - - - ))} - -
협력사명담당자 이메일상품조달유형 -
{r.name}{r.email || '-'} - -
-
- ); -} - -// 선택된 카드 테이블 — 콤보로 담은 협상/와일드카드를 검색어와 무관하게 고정 노출한다. -function SelectedCardTable({ - rows, - onRemove, -}: { - rows: { id: string; code: string; title: string; isWildcard: boolean }[]; - onRemove: (id: string) => void; -}) { - if (rows.length === 0) { - return ( -
- - 담긴 카드가 없습니다 — 위에서 검색해 담아주세요. - -
- ); - } - return ( -
- - - - - - - - - - {rows.map((r) => ( - - - - - - - ))} - -
카드번호유형카드명 -
{r.code} - - {r.isWildcard ? '와일드' : '협상'} - - {r.title} - -
-
- ); -} - -// 세그먼트 컨트롤 — 소수의 명명된 이산 선택(진행 방식·대상)에 라디오보다 명확. 값은 문자열. -function Segmented({ - options, - value, - onChange, -}: { - options: { value: string; label: string; sub?: string }[]; - value: string; - onChange: (v: string) => void; -}) { - return ( -
- {options.map((o) => { - const active = o.value === value; - return ( - - ); - })} -
- ); -} - -// 낙찰 기준 컨트롤 — 스펙트럼 선(線) 위 구간을 눌러 낙찰↔개찰 전환. 앵커 이하는 항상 낙찰(고정, 표시만) — -// '앵커~목표가'(mid_action)·'목표가 초과'(over_action) 두 구간만 사용자가 각각 낙찰/개찰로 정한다. -function AwardLinePicker({ - mid, over, onMid, onOver, -}: { - mid: number; - over: number; - onMid: (v: number) => void; - onOver: (v: number) => void; -}) { - const A = PriceGateAction.AWARD; - const O = PriceGateAction.OPEN; - const zones = [ - { label: '앵커링가 이하', win: true, locked: true, toggle: undefined }, - { label: '앵커링가~목표가', win: mid === A, locked: false, toggle: () => onMid(mid === A ? O : A) }, - { label: '목표가 초과', win: over === A, locked: false, toggle: () => onOver(over === A ? O : A) }, - ]; - return ( -
-
- 낙찰 기준 - 구간을 눌러 낙찰↔개찰 · 싸다◀▶비싸다 -
- - 협력사 최저 투찰가가 어느 구간에 오느냐로 낙찰/개찰이 정해집니다. - - {/* 스펙트럼 선: 구간이 곧 선택 버튼 */} -
- {zones.map((z, i) => { - const body = ( - <> - {z.label} - - {z.win ? '낙찰' : '개찰'}{z.locked ? ' 🔒' : ''} - - - ); - const cls = cn('flex-1 px-1 py-2', i > 0 && 'border-l border-border', z.win ? 'bg-emerald-50 dark:bg-emerald-950/30' : 'bg-muted'); - return z.locked ? ( -
{body}
- ) : ( - - ); - })} -
- {/* 경계 마커 — 구간 경계(1/3·2/3)에 ▲ 중앙 정렬(앵커링가·목표가) */} -
- {[ - { left: '33.3333%', label: '앵커링가' }, - { left: '66.6667%', label: '목표가' }, - ].map((mk) => ( - - ▲ - {mk.label} - - ))} -
- {/* 전략 한 줄 요약(관대/기본/엄격 전략) — 기본 전략(목표가까지 낙찰·초과 개찰)일 때만 경계가 애매하니 '목표가 포함' 부기 */} - {(() => { - const t = awardStrategySummary(mid, over); - const isBasic = mid === PriceGateAction.AWARD && over === PriceGateAction.OPEN; - return ( - - {t.strategy} - · {t.desc} - {isBasic && ( - · 목표가 포함 - )} - - ); - })()} -
- ); -} diff --git a/negodata/front/src/features/quotations/components/QuotationFormParts.tsx b/negodata/front/src/features/quotations/components/QuotationFormParts.tsx new file mode 100644 index 0000000..54f36be --- /dev/null +++ b/negodata/front/src/features/quotations/components/QuotationFormParts.tsx @@ -0,0 +1,237 @@ +import { X } from 'lucide-react'; +import { Typography } from '@/components/ui/typography'; +import { cn } from '@/lib/utils'; +import { supplierTypeLabel } from '@/lib/enumLabels'; +import { awardStrategySummary, PriceGateAction } from '../types'; + +// 견적 등록 모달의 프레젠테이션 하위 컴포넌트 모음 — 상태 없음(순수 props). QuotationCreateModal 에서 분리. + +// 협력사 상품조달유형 배지 — type undefined = 유형 미지정(매핑 없음). 초청 협력사는 그 상품을 공급하므로 '미취급'이 아니라 '미정'. +export function SupplyTypeBadge({ type }: { type?: number }) { + if (type === undefined) { + return ( + + 미정 + + ); + } + return ( + + {supplierTypeLabel(type)} + + ); +} + +// 선택된 협력사 테이블 — 콤보로 담은 협력사를 검색어와 무관하게 고정 노출한다. 취급유형 컬럼은 상품 선택 시에만. +export function SelectedPartnerTable({ + rows, + supplyTypeBySupplier, + showSupplyType, + onRemove, +}: { + rows: { id: string; name: string; email: string }[]; + supplyTypeBySupplier: Map; + showSupplyType: boolean; + onRemove: (id: string) => void; +}) { + if (rows.length === 0) { + return ( +
+ + 담긴 협력사가 없습니다 — 위에서 검색해 담아주세요. + +
+ ); + } + return ( +
+ + + + + + {showSupplyType && } + + + + {rows.map((r) => ( + + + + {showSupplyType && } + + + ))} + +
협력사명담당자 이메일상품조달유형 +
{r.name}{r.email || '-'} + +
+
+ ); +} + +// 선택된 카드 테이블 — 콤보로 담은 협상/와일드카드를 검색어와 무관하게 고정 노출한다. +export function SelectedCardTable({ + rows, + onRemove, +}: { + rows: { id: string; code: string; title: string; isWildcard: boolean }[]; + onRemove: (id: string) => void; +}) { + if (rows.length === 0) { + return ( +
+ + 담긴 카드가 없습니다 — 위에서 검색해 담아주세요. + +
+ ); + } + return ( +
+ + + + + + + + + + {rows.map((r) => ( + + + + + + + ))} + +
카드번호유형카드명 +
{r.code} + + {r.isWildcard ? '와일드' : '협상'} + + {r.title} + +
+
+ ); +} + +// 세그먼트 컨트롤 — 소수의 명명된 이산 선택(진행 방식·대상)에 라디오보다 명확. 값은 문자열. +export function Segmented({ + options, + value, + onChange, +}: { + options: { value: string; label: string; sub?: string }[]; + value: string; + onChange: (v: string) => void; +}) { + return ( +
+ {options.map((o) => { + const active = o.value === value; + return ( + + ); + })} +
+ ); +} + +// 낙찰 기준 컨트롤 — 스펙트럼 선(線) 위 구간을 눌러 낙찰↔개찰 전환. 앵커 이하는 항상 낙찰(고정, 표시만) — +// '앵커~목표가'(mid_action)·'목표가 초과'(over_action) 두 구간만 사용자가 각각 낙찰/개찰로 정한다. +export function AwardLinePicker({ + mid, over, onMid, onOver, +}: { + mid: number; + over: number; + onMid: (v: number) => void; + onOver: (v: number) => void; +}) { + const A = PriceGateAction.AWARD; + const O = PriceGateAction.OPEN; + const zones = [ + { label: '앵커링가 이하', win: true, locked: true, toggle: undefined }, + { label: '앵커링가~목표가', win: mid === A, locked: false, toggle: () => onMid(mid === A ? O : A) }, + { label: '목표가 초과', win: over === A, locked: false, toggle: () => onOver(over === A ? O : A) }, + ]; + return ( +
+
+ 낙찰 기준 + 구간을 눌러 낙찰↔개찰 · 싸다◀▶비싸다 +
+ + 협력사 최저 투찰가가 어느 구간에 오느냐로 낙찰/개찰이 정해집니다. + + {/* 스펙트럼 선: 구간이 곧 선택 버튼 */} +
+ {zones.map((z, i) => { + const body = ( + <> + {z.label} + + {z.win ? '낙찰' : '개찰'}{z.locked ? ' 🔒' : ''} + + + ); + const cls = cn('flex-1 px-1 py-2', i > 0 && 'border-l border-border', z.win ? 'bg-emerald-50 dark:bg-emerald-950/30' : 'bg-muted'); + return z.locked ? ( +
{body}
+ ) : ( + + ); + })} +
+ {/* 경계 마커 — 구간 경계(1/3·2/3)에 ▲ 중앙 정렬(앵커링가·목표가) */} +
+ {[ + { left: '33.3333%', label: '앵커링가' }, + { left: '66.6667%', label: '목표가' }, + ].map((mk) => ( + + ▲ + {mk.label} + + ))} +
+ {/* 전략 한 줄 요약(관대/기본/엄격 전략) — 기본 전략(목표가까지 낙찰·초과 개찰)일 때만 경계가 애매하니 '목표가 포함' 부기 */} + {(() => { + const t = awardStrategySummary(mid, over); + const isBasic = mid === PriceGateAction.AWARD && over === PriceGateAction.OPEN; + return ( + + {t.strategy} + · {t.desc} + {isBasic && ( + · 목표가 포함 + )} + + ); + })()} +
+ ); +} diff --git a/negodata/front/src/features/quotations/components/quotationForm.utils.ts b/negodata/front/src/features/quotations/components/quotationForm.utils.ts new file mode 100644 index 0000000..8f41d73 --- /dev/null +++ b/negodata/front/src/features/quotations/components/quotationForm.utils.ts @@ -0,0 +1,26 @@ +// 견적 등록 폼 공용 헬퍼 — datetime-local(KST) 변환과 퍼센트 파싱. QuotationCreateModal 에서 분리. + +// datetime-local 값: 한국시간(Asia/Seoul)의 'YYYY-MM-DDTHH:mm'. +// sv-SE 로케일이 'YYYY-MM-DD HH:mm:ss' 를 주고, timeZone 명시로 브라우저 TZ 와 무관하게 KST 로 고정한다. +function toKstLocalInput(date: Date): string { + const s = date.toLocaleString('sv-SE', { timeZone: 'Asia/Seoul' }); + return s.slice(0, 16).replace(' ', 'T'); +} + +export function nowKstLocalInput(): string { + return toKstLocalInput(new Date()); +} + +export function defaultDueDateLocalInput(): string { + return toKstLocalInput(new Date(Date.now() + 60 * 60 * 1000)); +} + +export function isFutureLocalInput(value: string): boolean { + const time = new Date(value).getTime(); + return Number.isFinite(time) && time > Date.now(); +} + +export function parsePercent(value: string | undefined): number { + const n = Number(String(value ?? '').replace('%', '').trim()); + return Number.isFinite(n) ? n / 100 : 0; +} diff --git a/negodata/front/src/features/quotations/hooks/useCardGating.tsx b/negodata/front/src/features/quotations/hooks/useCardGating.tsx new file mode 100644 index 0000000..e7dd9ba --- /dev/null +++ b/negodata/front/src/features/quotations/hooks/useCardGating.tsx @@ -0,0 +1,138 @@ +import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react'; +import { Typography } from '@/components/ui/typography'; +import { type ComboOption } from '@/components/ui/combobox'; +import { CONDITION_VARIABLE, CONDITION_LABEL } from '@/features/cards/editor/variables'; +import type { NegotiationCard } from '../types'; + +type CardDetail = { code: string; title: string; isWildcard: boolean }; + +type UseCardGatingParams = { + cardRows: NegotiationCard[]; + productId: string; + internetLowest: number | null; + estimatedTargetPrice: number | null; + open: boolean; + selectedCardIds: string[]; + cardDetails: Map; + setSelectedCardIds: Dispatch>; + setCardDetails: Dispatch>>; +}; + +// 카드 선택 게이팅 — 협상 멘트에 변수 토큰이 그대로 노출되거나 논리가 모순되는 카드를 선택 단계에서 막는다. +// 추천 상위 3개 자동 선택·부적합 카드 자동 해제 effect 도 여기서 소유한다(상태는 컴포넌트가 보유, 세터로 갱신). +export function useCardGating({ + cardRows, + productId, + internetLowest, + estimatedTargetPrice, + open, + selectedCardIds, + cardDetails, + setSelectedCardIds, + setCardDetails, +}: UseCardGatingParams) { + // (1) 조건 전략(customer_condition) 미작성 — 저장 시 조건 내용이 있으면 script 에 실제 문구가 + // 주입되고(slate.serialize), 없으면 {customer_condition} 토큰이 그대로 남는다. + const conditionUnfilled = (c: NegotiationCard) => + (c.scriptPreview ?? '').includes(`{${CONDITION_VARIABLE}}`); + // 시장가(인터넷최저가) 인용 카드({internet_lowest_price} 토큰)인지 — (2)·(4) 공용 술어. + const citesInternetLowest = (c: NegotiationCard) => (c.scriptPreview ?? '').includes('{internet_lowest_price}'); + // (2) 인터넷 최저가 인용 카드는 선택 상품에 최저가가 수집돼 있을 때만 — + // 최저가 없는 상품(items.internet_lowest_price=NULL/0)의 견적에 넣으면 협상 시 토큰이 노출된다. + // 상품 미선택 상태에선 판정 불가라 막지 않는다(상품 선택 후에만 게이팅). + const lowestUnavailable = !!productId && !(internetLowest && internetLowest > 0); + const lowestPriceLeak = (c: NegotiationCard) => lowestUnavailable && citesInternetLowest(c); + // (4) 시장가 논리 모순 — 인터넷최저가 ≥ 목표가면 "시장가가 더 싸다" 근거가 성립 안 한다. + // 이런 상품에 시장가 인용 카드를 넣으면 목표가보다 높은 값을 근거로 깎으라는 모순 멘트가 나간다. + const lowestAboveTarget = internetLowest != null && estimatedTargetPrice != null && internetLowest >= estimatedTargetPrice; + const marketContradiction = (c: NegotiationCard) => lowestAboveTarget && citesInternetLowest(c); + // (3) 미승인 와일드카드(INACTIVE) — 목록·순위엔 보이되 선택은 막는다(수동 승인 전). + const blockReason = (c: NegotiationCard): string | null => + c.isWildcard && c.status !== 'ACTIVE' + ? '미승인 와일드카드' + : conditionUnfilled(c) + ? `${CONDITION_LABEL} 미작성` + : lowestPriceLeak(c) + ? '인터넷 최저가 미수집' + : marketContradiction(c) + ? '시장가 근거 성립 불가' + : null; + // 성공률(사용 세션 중 타결 비율) 내림차순 — 표본 없는 카드는 뒤로. 상위 3개에 1·2·3위 배지가 붙는다. + // 미승인 와일드카드도 목록·순위엔 노출(선택은 blockReason 으로 disabled). + const rankedCards = cardRows + .slice() + .sort((a, b) => b.successRate - a.successRate || b.usedCount - a.usedCount); + const cardOptions: ComboOption[] = rankedCards + .map((card, i) => { + const reason = blockReason(card); + return { + id: card.id, + label: card.title, + disabled: !!reason, + node: ( +
+
+ {card.usedCount > 0 && ( + + {i + 1}위 · 성공률 {Math.round(card.successRate * 100)}% + + )} + {card.code} + + {card.isWildcard ? '와일드' : '협상'} + + {reason && ( + + {reason} + + )} +
+ {card.title} +
+ ), + }; + }); + + // 1·2·3위 배지가 붙는 카드(상위 3개, 사용이력 있는 것만) — 기본 선택 대상. 선택 불가(조건 미작성·최저가 미수집) 카드는 제외. + const topRankedCards = rankedCards.filter((c) => !blockReason(c)).slice(0, 3).filter((c) => c.usedCount > 0); + const topRankedKey = topRankedCards.map((c) => c.id).join(','); + const autoSelectedRef = useRef(false); + + // 모달을 열면 추천 상위 3개를 기본 선택해 둔다. 열려 있는 동안 1회만 — 이후 사용자의 추가/해제는 건드리지 않는다. + useEffect(() => { + if (!open) { + autoSelectedRef.current = false; + return; + } + if (autoSelectedRef.current || topRankedCards.length === 0) return; // 목록 로드 전이면 다음 렌더에 재시도 + autoSelectedRef.current = true; + setCardDetails((m) => { + const next = new Map(m); + topRankedCards.forEach((c) => next.set(c.id, { code: c.code, title: c.title, isWildcard: c.isWildcard })); + return next; + }); + setSelectedCardIds((prev) => (prev.length > 0 ? prev : topRankedCards.map((c) => c.id))); + // topRankedKey = 목록이 확정된 시점만 감지 (배열 재생성으로 매 렌더 도는 것 방지) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, topRankedKey]); + + // 이미 고른 카드 중, 시장가 인용 카드({internet_lowest_price})가 부적합해지면 자동 해제. + // 부적합 = 최저가 미수집(lowestUnavailable) 또는 인터넷최저가 ≥ 목표가(lowestAboveTarget, 시장가 근거 모순). + // (picklist disabled 는 신규 선택만 막으므로, 상품·목표가 변경 후 잔존 선택분을 여기서 정리해 노출·모순을 막는다.) + useEffect(() => { + if (!lowestUnavailable && !lowestAboveTarget) return; + const leakIds = new Set(cardRows.filter(citesInternetLowest).map((c) => c.id)); + if (leakIds.size === 0) return; + setSelectedCardIds((prev) => (prev.some((id) => leakIds.has(id)) ? prev.filter((id) => !leakIds.has(id)) : prev)); + // 상품·목표가 변경 시점에만 정리 (cardRows 재생성으로 매 렌더 도는 것 방지) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lowestUnavailable, lowestAboveTarget, productId]); + + // 선택된 카드 표시행 — 캐시에서 번호/유형/카드명을 읽어 검색어와 무관하게 유지한다. + const selectedCardRows = selectedCardIds.map((id) => { + const d = cardDetails.get(id); + return { id, code: d?.code ?? '', title: d?.title ?? id, isWildcard: d?.isWildcard ?? false }; + }); + + return { blockReason, cardOptions, selectedCardRows }; +} diff --git a/negodata/front/src/features/quotations/hooks/useTargetPrice.ts b/negodata/front/src/features/quotations/hooks/useTargetPrice.ts new file mode 100644 index 0000000..96e4ad6 --- /dev/null +++ b/negodata/front/src/features/quotations/hooks/useTargetPrice.ts @@ -0,0 +1,110 @@ +import { useLabels, useHiddenFields } from '@/features/settings/useCompanySettings'; +import type { QuotationSetting } from '../types'; +import { parsePercent } from '../components/quotationForm.utils'; + +const INTERNET_AVERAGE_FEE = 0.078; +const TARGET_PRICE_UNIT_LIMIT_MULTIPLIER = 2; + +type UseTargetPriceParams = { + quotationSettings: QuotationSetting[]; + settingId: string; + productId: string; + isReType: boolean; + // 상품 가격 속성(읽기전용) — 상품 미선택/미수집이면 null. + internetLowest: number | null; + purchase: number | null; + selling: number | null; + unitPrice: number | null; + // 목표가 입력·후보 선택 상태. + mdPrice: string; + mdTouched: boolean; + negoTouched: boolean; + applyNego: boolean; + selectedCandidateKey: string | null; +}; + +// 견적 목표가 산정 — 세팅 네고율·상품 후보(인터넷최저가/매입가/판매가)에서 목표가 후보와 채택값을 파생한다. +// 순수 파생(부작용 없음). 카드 게이팅이 estimatedTargetPrice 를 참조하므로 그보다 먼저 호출한다. +export function useTargetPrice({ + quotationSettings, + settingId, + productId, + isReType, + internetLowest, + purchase, + selling, + unitPrice, + mdPrice, + mdTouched, + negoTouched, + applyNego, + selectedCandidateKey, +}: UseTargetPriceParams) { + const label = useLabels(); // 회사 설정 용어(목표 마진 등) + const isHidden = useHiddenFields(); // 회사설정으로 감춘 상품 기본필드 — 후보 리스트에서도 제외 + + const selectedSetting = quotationSettings.find((s) => s.qt_setting_id === settingId); + const settingMargin = parsePercent(selectedSetting?.target_margin); // 세팅 네고율(비율) + const settingMarginPct = +(settingMargin * 100).toFixed(1); + // 매입가 네고율 차감 여부 — 안 건드리면 세팅값(>0이면 차감), 건드리면 체크박스값. 네고율 값은 세팅값 고정. + const negoAvailable = settingMargin > 0; + const effectiveApplyNego = negoTouched ? applyNego : negoAvailable; + const margin = effectiveApplyNego ? settingMargin : 0; // 미적용이면 0 → 매입가/판매가 그대로 + const negoLabel = (base: string) => + margin > 0 ? `${base} × (1−네고율 ${+(margin * 100).toFixed(1)}%)` : `${base} (네고율 미적용)`; + // 목표가 산정 후보(계산식+결과값) — 인터넷최저가×(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: negoLabel(label('item.purchase_price')), raw: purchase, rate: margin, show: isReType && purchase != null }, + { key: 'selling_price', label: negoLabel(label('item.selling_price')), 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; + // 기본 채택 후보 = 최저(동률이면 첫 후보). 네고율 체크박스는 매입가 후보(없으면 판매가)에 붙인다. + const minCandidateKey = targetBreakdown.find((c) => c.value === autoTarget)?.key ?? null; + const negoCandidateKey = negoAvailable + ? (targetBreakdown.find((c) => c.key === 'purchase_price')?.key ?? targetBreakdown.find((c) => c.key === 'selling_price')?.key ?? null) + : null; + // 채택 후보 — 사용자가 고르면 그 후보, 아니면 최저. 상품 변경 등으로 선택 키가 사라지면 최저로 폴백. + const pickedCandidate = selectedCandidateKey ? targetBreakdown.find((c) => c.key === selectedCandidateKey) : undefined; + const activeCandidateKey = pickedCandidate ? pickedCandidate.key : minCandidateKey; + const targetFromCandidate = pickedCandidate ? pickedCandidate.value : autoTarget; + // 구매담당자 제시가 필드엔 채택 후보값을 미리 보여주되(IMK #4), 담당자가 직접 건드렸을 때만 md_price 로 전송한다. + // (자동값을 md 로 보내면 서버가 'MD 입력가'로 저장해 산정내역이 매입가 대신 MD로 잡히고 후보가 안 보인다.) + const effectiveMdPrice = mdTouched ? mdPrice : (targetFromCandidate != null ? String(targetFromCandidate) : mdPrice); + const mdNum = Number(effectiveMdPrice) || 0; + // 기본(최저·세팅네고)에서 벗어난 선택/토글이면 그 목표가를 md_price 로 박아 서버 저장값과 화면을 일치시킨다. + // (서버는 세팅 rate·최저로 재계산하므로, 오버라이드를 안 보내면 후보 화면과 저장 목표가가 어긋난다.) + const divergedFromDefault = negoTouched || (!!pickedCandidate && pickedCandidate.key !== minCandidateKey); + const submitMdPrice = mdTouched && mdPrice + ? Number(mdPrice) + : divergedFromDefault && targetFromCandidate != null + ? targetFromCandidate + : null; + const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null)); + const mdRequired = !!productId && !hasItemCandidate; + const targetReady = mdNum > 0 || hasItemCandidate; + // 최종 목표가 = 제시가(자동/수동) 있으면 그 값, 없으면 채택 후보값. + const estimatedTargetPrice = mdNum > 0 ? mdNum : targetFromCandidate; + const targetPriceLimit = unitPrice != null && unitPrice > 0 + ? unitPrice * TARGET_PRICE_UNIT_LIMIT_MULTIPLIER + : null; + const targetLimitExceeded = targetPriceLimit != null && estimatedTargetPrice != null && estimatedTargetPrice > targetPriceLimit; + + return { + settingMarginPct, + negoCandidateKey, + targetBreakdown, + autoTarget, + activeCandidateKey, + effectiveApplyNego, + effectiveMdPrice, + mdRequired, + targetReady, + targetLimitExceeded, + estimatedTargetPrice, + submitMdPrice, + }; +} diff --git a/negodata/front/src/lib/excel.ts b/negodata/front/src/lib/excel.ts index d4c7516..8aba898 100644 --- a/negodata/front/src/lib/excel.ts +++ b/negodata/front/src/lib/excel.ts @@ -1,6 +1,5 @@ -// 범용 엑셀(CSV) 내보내기. 클라이언트에서 Blob 다운로드 — 의존성 없음. -// 한글이 Excel에서 깨지지 않도록 UTF-8 BOM을 붙인다. 여러 페이지에서 재사용. -// 진짜 .xlsx(서식/다중시트)가 필요해지면 이 함수 시그니처 유지한 채 SheetJS로 교체 가능. +// 범용 엑셀 내보내기(CSV, UTF-8 BOM)와 업로드 읽기(.xlsx/.xls/.csv). 여러 페이지에서 재사용. +import * as XLSX from 'xlsx'; export type ExcelColumn = { header: string; @@ -63,6 +62,24 @@ function tokenizeCsv(text: string): string[][] { return rows; } +// 업로드 파일 → 행 객체 배열. 진짜 엑셀(.xlsx/.xls)은 SheetJS 로, CSV 는 CP949 폴백으로 읽는다. +export async function readSpreadsheetRows(file: File): Promise[]> { + const buf = new Uint8Array(await file.arrayBuffer()); + const isBinary = (buf[0] === 0x50 && buf[1] === 0x4b) || (buf[0] === 0xd0 && buf[1] === 0xcf); // .xlsx=ZIP, .xls=OLE + if (isBinary) { + const wb = XLSX.read(buf, { type: 'array' }); + const sheet = wb.Sheets[wb.SheetNames[0]]; + return sheet ? parseCsv(XLSX.utils.sheet_to_csv(sheet)) : []; + } + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(buf); + } catch { + text = new TextDecoder('euc-kr').decode(buf); // 한국 Excel 의 CSV 기본 인코딩 + } + return parseCsv(text); +} + // CSV 텍스트 → 헤더 키로 매핑된 객체 배열. 첫 행을 헤더로 본다. 여러 페이지 업로드에서 재사용. export function parseCsv(text: string): Record[] { const rows = tokenizeCsv(text); From 91b8dc77a03981285a2c0c13207d24b08228d7bf Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Tue, 4 Aug 2026 08:56:03 +0900 Subject: [PATCH 2/7] =?UTF-8?q?[feat]=20negodata:=20=ED=83=80=EA=B2=B0=20?= =?UTF-8?q?=EC=83=81=ED=95=9C(done=5Fceiling)=20=E2=80=94=20=EC=84=B8?= =?UTF-8?q?=ED=8C=85=20=EA=B8=B0=EB=B3=B8=EC=9C=A8%=C2=B7=EA=B2=AC?= =?UTF-8?q?=EC=A0=81=20override=C2=B7=EC=84=B8=EC=85=98=20=EB=B0=95?= =?UTF-8?q?=EC=A0=9C,=20=EA=B2=AC=EC=A0=81=EC=83=9D=EC=84=B1=203=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=ED=83=80=EA=B2=B0/=EB=82=99=EC=B0=B0=20=EC=84=B9?= =?UTF-8?q?=EC=85=98=20=EB=B6=84=EB=A6=AC=C2=B7=EC=9E=AC=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EC=83=81=EC=86=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/common/database/model/models.py | 3 + negodata/backend/crud/quotation_crud.py | 10 +- .../backend/router/v1/quotation/protocol.py | 9 +- .../backend/router/v1/quotation/quotation.py | 8 +- .../router/v1/quotation_setting/protocol.py | 3 + negodata/backend/services/quotation/build.py | 65 ++- .../backend/services/quotation/pricing.py | 7 +- .../services/quotation_setting_service.py | 1 + .../backend/tests/test_quotation_anchoring.py | 16 +- .../tests/test_quotation_regenerate.py | 211 +++++++++ .../front/src/api/generated/model/index.ts | 7 + .../src/api/generated/model/quotationData.ts | 2 + .../model/quotationDataDoneCeilingRate.ts | 8 + .../generated/model/quotationSettingData.ts | 1 + .../api/generated/model/reqCreateQuotation.ts | 2 + .../reqCreateQuotationDoneCeilingRate.ts | 8 + .../model/reqCreateQuotationSetting.ts | 1 + .../generated/model/reqRegenerateQuotation.ts | 8 + .../model/reqRegenerateQuotationCardIds.ts | 8 + .../reqRegenerateQuotationDoneCeilingRate.ts | 8 + .../model/reqRegenerateQuotationEndTime.ts | 8 + .../reqRegenerateQuotationTargetPrice.ts | 8 + .../model/reqUpdateQuotationSetting.ts | 2 + ...eqUpdateQuotationSettingDoneCeilingRate.ts | 8 + .../components/QuotationCreateModal.tsx | 134 ++++-- .../QuotationDetailSheet/RegenerateModal.tsx | 400 +++++++++++++++--- .../components/QuotationDetailSheet/index.tsx | 36 +- .../components/QuotationSettingsModal.tsx | 15 +- .../components/quotationForm.utils.ts | 7 +- .../quotations/hooks/useCardGating.tsx | 9 + .../quotations/hooks/useQuotations.ts | 38 +- .../quotations/hooks/useTargetPrice.ts | 12 + .../front/src/features/quotations/types.ts | 2 + negodata/front/src/pages/quotation.tsx | 1 + .../alters/2026-08-03-done-ceiling.sql | 21 + postgres-init/dbeaver/1_init_dbeaver.sql | 3 + postgres-init/init-data/init.sql | 3 + 37 files changed, 962 insertions(+), 131 deletions(-) create mode 100644 negodata/backend/tests/test_quotation_regenerate.py create mode 100644 negodata/front/src/api/generated/model/quotationDataDoneCeilingRate.ts create mode 100644 negodata/front/src/api/generated/model/reqCreateQuotationDoneCeilingRate.ts create mode 100644 negodata/front/src/api/generated/model/reqRegenerateQuotationCardIds.ts create mode 100644 negodata/front/src/api/generated/model/reqRegenerateQuotationDoneCeilingRate.ts create mode 100644 negodata/front/src/api/generated/model/reqRegenerateQuotationEndTime.ts create mode 100644 negodata/front/src/api/generated/model/reqRegenerateQuotationTargetPrice.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateQuotationSettingDoneCeilingRate.ts create mode 100644 postgres-init/alters/2026-08-03-done-ceiling.sql diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index 83344ea..6509aba 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -266,6 +266,7 @@ class quotation_settings(MainTableMixin, MAIN_BASE): user_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 설정 소유 유저 target_margin_rate = Column(Numeric(8, 6), nullable=False) # 목표 마진율(목표가 산정에 사용) card_count = Column(Integer, nullable=False, default=3) + done_ceiling_rate = Column(SmallInteger, nullable=False, server_default=text("50"), default=50) # 협상 완료 상한율(‰). 완료 상한=목표가×(1+값/1000) # 낙찰 가격정책(mid/over/regen)은 견적 단위로 이관, 앵커링은 칸 rate(anchoring v1.2)로 대체 → 세팅 컬럼 제거됨. @@ -303,6 +304,7 @@ class quotations(MainTableMixin, MAIN_BASE): # 1:1 협상: over 는 항상 OPEN(목표 초과=개찰), mid 만 앵커/목표 택1. 1:N 경매: mid=over=AWARD 강제(무조건 최저가 낙찰). mid_action = Column(SmallInteger, nullable=False, server_default=text("1"), default=1) # PriceGateAction: 앵커링가<투찰가≤목표가 처리(1=낙찰/2=개찰) over_action = Column(SmallInteger, nullable=False, server_default=text("1"), default=1) # PriceGateAction: 목표가<투찰가 처리(1=낙찰/2=개찰) + done_ceiling_rate = Column(SmallInteger, nullable=True) # 협상 완료 상한율(‰) 견적별 override. NULL 이면 quotation_settings 값 class sessions(MainTableMixin, MAIN_BASE): @@ -319,6 +321,7 @@ class sessions(MainTableMixin, MAIN_BASE): qt_type = Column(SmallInteger, nullable=False) # QuotationType 스냅샷 target_price = Column(BigInteger, nullable=False) # 목표가(원) anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원) — 생성 시 박제, 이후 수정 금지(앵커링 배치 판정 기준) + done_ceiling_price = Column(BigInteger, nullable=True) # 협상 완료 상한가(원) — 생성 시 박제 = 목표가×(1+완료상한율/1000). 봇 종결·마감이 이 이하면 타결 anchoring_value = Column(SmallInteger, nullable=True) # 제안 당시 앵커링 값(정수 ‰) 박제 — 위와 동일 규칙. 주의: quotation_settings.anchoring_value(구 float 비율)와 무관. 나머지 앵커링 컬럼(last_offer_price 등)은 backend/배치 소유라 매핑 안 함 status = Column(SmallInteger, nullable=False) # SessionStatus 코드 bid_price = Column(BigInteger, nullable=True) # 입찰가(원) diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py index 0606bd5..58b12f4 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -450,21 +450,23 @@ class QuotationCRUD(IQuotationCRUD): return ErrorType.DB_RUN_FAILED, {} async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]: - """견적 세팅의 목표 마진율: {margin}. 목표가 산정 입력(인터넷 수수료는 상수). + """견적 세팅의 목표 마진율·협상 완료 상한율: {margin, done_ceiling_rate}. + margin·수수료는 목표가 산정 입력, done_ceiling_rate(‰)는 완료 상한 = 목표가×(1+값/1000). (낙찰 정책은 견적 단위 이관, 앵커링은 칸 rate v1.2 → 세팅 컬럼 제거됨.)""" try: query = select( quotation_settings.target_margin_rate, + quotation_settings.done_ceiling_rate, ).where(quotation_settings.qt_setting_id == qt_setting_id).limit(1) err_type, rows = await DB_SESSION_MNG.execute(cdb, query) if err_type != ErrorType.SUCCESS: return err_type, {} if not rows: return ErrorType.SUCCESS, {} - # 단일 컬럼 select → execute 가 스칼라 리스트를 돌려준다(Row 아님). - margin = rows[0] + row = rows[0] return ErrorType.SUCCESS, { - "margin": float(margin) if margin is not None else None, + "margin": float(row.target_margin_rate) if row.target_margin_rate is not None else None, + "done_ceiling_rate": int(row.done_ceiling_rate) if row.done_ceiling_rate is not None else None, } except Exception as ex: LOG.e_no_callstack(ex) diff --git a/negodata/backend/router/v1/quotation/protocol.py b/negodata/backend/router/v1/quotation/protocol.py index 3af9e60..2282d70 100644 --- a/negodata/backend/router/v1/quotation/protocol.py +++ b/negodata/backend/router/v1/quotation/protocol.py @@ -33,10 +33,16 @@ class Req_CreateQuotation(QuotationProtocol): # 1:N 경매는 미전송 → 서버가 mid=over=AWARD 강제('무조건 최저가 낙찰'). mid_action: Optional[int] = None # PriceGateAction: 앵커링가<투찰가≤목표가 처리 over_action: Optional[int] = None # PriceGateAction: 목표가<투찰가 처리 + done_ceiling_rate: Optional[int] = None # 협상 완료 상한율(‰) 견적 override. None 이면 회사 세팅값 class Req_RegenerateQuotation(QuotationProtocol): - supplier_ids: list[uuid.UUID] = [] # 다음 라운드에 부를 공급사(프론트 선택). 상품·기간·번호는 원 견적에서 이어받음 + supplier_ids: list[uuid.UUID] = [] # 다음 라운드에 부를 공급사(프론트 선택). 상품·번호는 원 견적에서 이어받음 + # 아래 재지정값은 전부 미전송(None)이면 원 견적/직전 라운드 값을 그대로 승계한다. + card_ids: Optional[list[uuid.UUID]] = None # 다음 라운드 협상카드. 빈 리스트면 카드 없는 새 버전 + target_price: Optional[int] = None # 목표가(원). 이 라운드의 모든 상품에 적용 + end_time: Optional[datetime] = None # 마감기한. 미전송이면 원 견적과 같은 기간 길이로 생성 시각부터 + done_ceiling_rate: Optional[int] = None # 타결 상한율(‰). 완료 상한=목표가×(1+값/1000) class Req_AwardQuotation(QuotationProtocol): @@ -71,6 +77,7 @@ class QuotationData(WebPacketProtocol): close_reason: Optional[CloseReason] = None # 마감 사유(CloseReason). 미마감이면 None mid_action: Optional[int] = None # 낙찰 기준(견적 단위). 상세 드로어 낙찰기준 표시용 over_action: Optional[int] = None + done_ceiling_rate: Optional[int] = None # 타결 상한율(‰) 견적 override. None 이면 견적 세팅값을 따름 participation_count: int = 0 # 견적별 참여 협력사 수(세션 distinct supplier). 목록 집계로 채움. item_id: Optional[uuid.UUID] = None # 대표 상품 id(세션의 첫 item). 목록 조인으로 채움. item_name: Optional[str] = None # 대표 상품명. 목록 조인으로 채움. diff --git a/negodata/backend/router/v1/quotation/quotation.py b/negodata/backend/router/v1/quotation/quotation.py index c10a03a..cdb2422 100644 --- a/negodata/backend/router/v1/quotation/quotation.py +++ b/negodata/backend/router/v1/quotation/quotation.py @@ -72,7 +72,13 @@ async def award_quotation( async def regenerate_quotation( qt_id: UUID, req: Req_RegenerateQuotation, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) ): - return RemoveNoneResponse(await service.regenerate_quotation(str(qt_id), user_info.company_id, req.supplier_ids, user_info.user_id, user_info.role)) + return RemoveNoneResponse( + await service.regenerate_quotation( + str(qt_id), user_info.company_id, req.supplier_ids, user_info.user_id, user_info.role, + card_ids=req.card_ids, target_price=req.target_price, + end_time=req.end_time, done_ceiling_rate=req.done_ceiling_rate, + ) + ) # ----- 견적 상세 (FK로 연결된 하위 데이터 / 일부는 모델 미존재로 스텁) ----- diff --git a/negodata/backend/router/v1/quotation_setting/protocol.py b/negodata/backend/router/v1/quotation_setting/protocol.py index f8e5f89..2a2173b 100644 --- a/negodata/backend/router/v1/quotation_setting/protocol.py +++ b/negodata/backend/router/v1/quotation_setting/protocol.py @@ -15,11 +15,13 @@ class QuotationSettingProtocol(WebPacketProtocol): class Req_CreateQuotationSetting(QuotationSettingProtocol): target_margin_rate: float card_count: int = 3 + done_ceiling_rate: int = 50 # 협상 완료 상한율(‰). 완료 상한=목표가×(1+값/1000) class Req_UpdateQuotationSetting(QuotationSettingProtocol): target_margin_rate: Optional[float] = None card_count: Optional[int] = None + done_ceiling_rate: Optional[int] = None class QuotationSettingData(WebPacketProtocol): @@ -29,6 +31,7 @@ class QuotationSettingData(WebPacketProtocol): user_id: Optional[uuid.UUID] = None target_margin_rate: float card_count: int + done_ceiling_rate: int created_at: Optional[datetime] = None updated_at: Optional[datetime] = None diff --git a/negodata/backend/services/quotation/build.py b/negodata/backend/services/quotation/build.py index b3e65da..a30c1f6 100644 --- a/negodata/backend/services/quotation/build.py +++ b/negodata/backend/services/quotation/build.py @@ -51,9 +51,10 @@ class BuildMixin: md_price=req.md_price, item_ids=req.item_ids, supplier_ids=req.supplier_ids, - card_ids=req.card_ids, + card_ids=req.card_ids or None, # 미선택이면 새 버전 없이 기본 전략 버전(version_id)을 그대로 쓴다 mid_action=req.mid_action, over_action=req.over_action, + done_ceiling_rate=req.done_ceiling_rate, ) if res.result.success: await create_notification( @@ -63,7 +64,11 @@ class BuildMixin: ) return res - async def regenerate_next_round(self, original_qt_id: uuid.UUID, supplier_ids: list, regen_label: Optional[str] = None) -> Res_CreateQuotation: + async def regenerate_next_round( + self, original_qt_id: uuid.UUID, supplier_ids: list, regen_label: Optional[str] = None, *, + card_ids: Optional[list] = None, target_price: Optional[int] = None, + end_time=None, done_ceiling_rate: Optional[int] = None, + ) -> Res_CreateQuotation: """[재생성] 마감된 견적의 '다음 라운드'를 새로 만든다. 호출 경로는 수동 재생성·재협상 승인뿐. 플로우: @@ -73,6 +78,9 @@ class BuildMixin: 견적번호(number)를 원본 그대로 이어받아 '같은 번호 = 한 체인'으로 묶는다(parent_id 대체). supplier_ids: 다음 라운드에 부를 공급사(동가면 동가 업체만, 그 외엔 원 견적 공급사 전체). + + card_ids·target_price·end_time·done_ceiling_rate 는 담당자가 이번 라운드에서만 바꾸는 조정값이다. + 미지정(None)이면 전부 원 견적/직전 라운드 값을 그대로 승계한다(기존 동작). """ res = Res_CreateQuotation() @@ -88,16 +96,22 @@ class BuildMixin: ) item_ids = list({r.item_id for r in rows}) if err_type == ErrorType.SUCCESS else [] # 재생성은 목표가를 재계산하지 않고 직전 라운드 세션 값을 그대로 상속(KTC 방식). - # 앵커링가는 상속하지 않는다 — 생성 시점의 칸 rate 로 항상 재계산·박제(앵커링 v1.2 인수인계 규칙 1). - inherited_target_prices = {r.item_id: r.target_price for r in rows} if err_type == ErrorType.SUCCESS else {} + # 담당자가 목표가를 다시 잡았으면(target_price) 그 값이 이번 라운드 전 상품의 목표가가 된다. + # 앵커링가는 어느 쪽이든 상속하지 않는다 — 생성 시점의 칸 rate 로 항상 재계산·박제(앵커링 v1.2 인수인계 규칙 1). + if target_price is not None: + inherited_target_prices = {iid: target_price for iid in item_ids} + else: + inherited_target_prices = {r.item_id: r.target_price for r in rows} if err_type == ErrorType.SUCCESS else {} # 2) 타입 결정: 공급사 1곳 → 재협상 / 여러 곳 → 재견적 next_type = QuotationType.RENEGO.value if len(supplier_ids) <= 1 else QuotationType.REQUOTE.value # 3) 다음 라운드의 견적 생성 now = GTime.UTC() - # 원본 협상기간을 이어쓰되, 비정상적으로 짧으면 최소 하한을 적용(즉시 만료→연쇄 재마감 방지). + # 마감기한을 다시 잡았으면 그 값, 아니면 원본 협상기간을 이어쓴다. + # 이어쓸 때만 최소 하한을 적용한다(원본 기간이 비정상적으로 짧아 즉시 만료→연쇄 재마감 되는 것 방지). duration = max(original.end_time - original.start_time, self.MIN_REGEN_DURATION) + next_end_time = end_time or (now + duration) # 다음 차수는 '원본 round+1' 이 아니라 '체인(같은 번호) 최신 round+1'. # 크론 마감과 수동 regenerate_quotation 이 같은 체인을 처리하는 타이밍이 엇갈려도 # 항상 체인 끝에 이어붙어 uq_quotations_number(number, round) 충돌을 막는다. @@ -122,23 +136,30 @@ class BuildMixin: status=QuotationStatus.CREATED.value, round_=next_round, start_time=now, - end_time=now + duration, + end_time=next_end_time, manager_name=original.manager_name, manager_email=original.manager_email, manager_contact_number=original.manager_contact_number, memo=original.memo, - md_price=original.md_price, + md_price=target_price if target_price is not None else original.md_price, item_ids=item_ids, supplier_ids=list(supplier_ids), - card_ids=[], # 새 버전 안 만듦(원본 version_id 재사용) + # None=원본 version_id 재사용(새 버전 안 만듦), 리스트=이 카드들로 새 버전 생성(빈 리스트면 카드 없는 버전). + card_ids=card_ids, mid_action=original.mid_action, # 낙찰 기준 상속(타입이 REQUOTE 로 바뀌면 빌더가 AWARD 로 재정규화) over_action=original.over_action, + done_ceiling_rate=done_ceiling_rate if done_ceiling_rate is not None else original.done_ceiling_rate, inherited_target_prices=inherited_target_prices, # 직전 라운드 목표가 상속(앵커링가는 현재 rate 로 재계산) ) - async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list, user_id=None, role=None, regen_label: Optional[str] = None) -> Res_CreateQuotation: + async def regenerate_quotation( + self, qt_id: str, company_id, supplier_ids: list, user_id=None, role=None, regen_label: Optional[str] = None, *, + card_ids: Optional[list] = None, target_price: Optional[int] = None, + end_time=None, done_ceiling_rate: Optional[int] = None, + ) -> Res_CreateQuotation: """[프론트] 마감된 견적을 골라 수동으로 다음 라운드를 생성한다. - 상품·기간·견적번호·카드버전은 원 견적에서 이어받는다(regenerate_next_round).""" + 상품·견적번호는 원 견적에서 이어받고, 카드·목표가·마감기한·타결 상한율은 + 담당자가 모달에서 다시 잡은 값이 있으면 그 값으로 만든다(regenerate_next_round).""" res = Res_CreateQuotation() qt_uuid = uuid.UUID(qt_id) @@ -171,16 +192,23 @@ class BuildMixin: res.msg = "마지막 차수의 견적에서만 다음 라운드를 생성할 수 있습니다." return res - return await self.regenerate_next_round(qt_uuid, supplier_ids, regen_label=regen_label) + return await self.regenerate_next_round( + qt_uuid, supplier_ids, regen_label=regen_label, + card_ids=card_ids, target_price=target_price, + end_time=end_time, done_ceiling_rate=done_ceiling_rate, + ) async def _build_quotation( self, *, user_id: str, qt_setting_id, version_id, name: str, number: str, type_: int, status: int, round_: int, start_time, end_time, manager_name, manager_email, manager_contact_number, memo, md_price, - item_ids: list, supplier_ids: list, card_ids: list, + item_ids: list, supplier_ids: list, + # None = 넘겨받은 version_id 를 그대로 쓴다(카드 승계). 리스트면 이 카드들로 새 버전을 만든다(빈 리스트=카드 없는 버전). + card_ids: Optional[list], mid_action: Optional[int] = None, # 낙찰 기준(견적 단위). 앵커링가<투찰가≤목표가 처리(AWARD/OPEN) over_action: Optional[int] = None, # 목표가<투찰가 처리(1:1 협상은 항상 OPEN) + done_ceiling_rate: Optional[int] = None, # 협상 완료 상한율(‰) 견적 override. None 이면 세팅 기본값 inherited_target_prices: Optional[dict] = None, # 재생성 시 직전 라운드 목표가 상속(KTC). 목표가만 — 앵커는 항상 재계산 ) -> Res_CreateQuotation: """견적 1건 + (상품×공급사) 세션들을 한 트랜잭션으로 생성하는 공통 빌더.""" @@ -195,13 +223,15 @@ class BuildMixin: over_action = over_action or PriceGateAction.AWARD.value # 목표가 계산 재료(가격·비율·회사 설정)를 먼저 모아온다. - prices, fee, margin, hidden = await self._load_target_inputs(item_ids, qt_setting_id, user_id) + prices, fee, margin, hidden, setting_ceiling_rate = await self._load_target_inputs(item_ids, qt_setting_id, user_id) + # 완료 상한율(‰) — 견적 override 우선, 없으면 세팅 기본. 세션에 완료 상한가(원)로 박제한다. + effective_ceiling_rate = done_ceiling_rate if done_ceiling_rate is not None else setting_ceiling_rate # 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다. # (quotation↔card 는 version → version_nego_cards/version_wild_cards 로 연결.) version_obj = None link_rows = [] - if card_ids: + if card_ids is not None: _err, card_types = await DB_SESSION_MNG.execute_lambda( quotations.DBType(), DBWRType.DB_READ.value, @@ -244,6 +274,7 @@ class BuildMixin: md_price=md_price, mid_action=mid_action, over_action=over_action, + done_ceiling_rate=done_ceiling_rate, # 견적 override 원본 저장(None=세팅 따름) ) # 상품 × 공급사 조합마다 세션 1개. md/매입/판매/인터넷 후보가 하나도 없으면 목표가 산정 불가 → 생성 실패. @@ -267,6 +298,11 @@ class BuildMixin: session_objs = [] for iid in item_ids: tp = target_prices[iid] + # 완료 상한가 = 목표가×(1+상한율/1000), 10원 반올림(앵커가와 동일한 정수 연산). 상한율 없으면 목표가로 폴백. + ceiling_price = ( + int((tp * (1000 + effective_ceiling_rate) + 5000) // 10000) * 10 + if effective_ceiling_rate is not None else tp + ) for sid in supplier_ids: value, ap = anchors[(iid, sid)] session_objs.append( @@ -281,6 +317,7 @@ class BuildMixin: target_price=tp, anchoring_price=ap, # 박제 — 이후 수정 금지(협상 판정·앵커링 학습 기준값) anchoring_value=value, + done_ceiling_price=ceiling_price, # 박제 — 봇 종결·마감이 이 이하면 타결 status=SessionStatus.CREATED.value, end_time=quotation.end_time, ) diff --git a/negodata/backend/services/quotation/pricing.py b/negodata/backend/services/quotation/pricing.py index a31fbce..3a3472a 100644 --- a/negodata/backend/services/quotation/pricing.py +++ b/negodata/backend/services/quotation/pricing.py @@ -76,7 +76,7 @@ class PricingMixin: async def _load_target_inputs( self, item_ids: list[uuid.UUID], qt_setting_id, user_id - ) -> tuple[dict, float, float, set]: + ) -> tuple[dict, float, float, set, int | None]: """목표가 계산에 필요한 값들을 한 번에 모아온다. - prices: 상품마다 (인터넷최저가, 매입가, 판매가) — DB 조회 @@ -102,6 +102,7 @@ class PricingMixin: rates = rates if _err == ErrorType.SUCCESS else {} fee = self.INTERNET_AVERAGE_FEE # 인터넷가 차감 수수료율(상수) margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율 + ceiling_rate = rates.get("done_ceiling_rate") # 회사 완료 상한율(‰), 미조회면 None user_uuid = uuid.UUID(user_id) if isinstance(user_id, str) else user_id settings = await DB_SESSION_MNG.execute_lambda( quotations.DBType(), @@ -109,7 +110,7 @@ class PricingMixin: lambda s: self.quotation_crud.get_company_settings(s, user_uuid), ) hidden = set(settings.get("hidden_fields") or []) - return prices, fee, margin, hidden + return prices, fee, margin, hidden, ceiling_rate def _resolve_target_prices( self, *, qt_id, item_ids: list[uuid.UUID], prices: dict, @@ -200,7 +201,7 @@ class PricingMixin: return res # 산정 입력(재료)은 생성과 같은 로더를 공유 — 생성값과 표시값이 어긋나지 않는다. - prices, fee, margin, hidden = await self._load_target_inputs( + prices, fee, margin, hidden, _ceiling_rate = await self._load_target_inputs( [sess.item_id], quotation.qt_setting_id, quotation.user_id ) internet, purchase, selling = (prices or {}).get(sess.item_id) or (None, None, None) diff --git a/negodata/backend/services/quotation_setting_service.py b/negodata/backend/services/quotation_setting_service.py index 167763e..dfe51d3 100644 --- a/negodata/backend/services/quotation_setting_service.py +++ b/negodata/backend/services/quotation_setting_service.py @@ -66,6 +66,7 @@ class QuotationSettingService: user_id=uuid.UUID(user_id), target_margin_rate=req.target_margin_rate, card_count=req.card_count, + done_ceiling_rate=req.done_ceiling_rate, ) err_type = await DB_SESSION_MNG.execute_lambda_run( [quotation_settings.DBType()], diff --git a/negodata/backend/tests/test_quotation_anchoring.py b/negodata/backend/tests/test_quotation_anchoring.py index 70182fc..493f099 100644 --- a/negodata/backend/tests/test_quotation_anchoring.py +++ b/negodata/backend/tests/test_quotation_anchoring.py @@ -1,7 +1,7 @@ """앵커링 v1.2 — 견적 생성 시 칸(회사×상품-협력사 공급유형×가격구간) anchoring_value 로 앵커가를 박제하는지 검증. 이식 명세: schedules/anchoring/docs/인수인계.md §1. -- 앵커가 = 목표가 × (1000 − anchoring_value) // 1000 (정수 연산), anchoring_value 동시 박제 +- 앵커가 = 목표가 × (1000 − anchoring_value), 10원 반올림(calc_anchoring_price), anchoring_value 동시 박제 - 조정 이력 없음 / 매핑 유형 미지정 / anchoring 스키마 미적용 → 정적 테이블 시작값(10‰) 폴백, 견적 생성은 실패하지 않는다(규칙 6) - 재생성 라운드는 목표가만 상속하고 앵커는 생성 시점 anchoring_value 로 재계산(규칙 1 — 상속 폐지) @@ -11,7 +11,7 @@ from datetime import datetime from sqlalchemy import text -from common.anchoring import calc_price_range_index +from common.anchoring import calc_anchoring_price, calc_price_range_index from common.enums import QuotationType from crud.quotation_crud import QuotationCRUD from router.v1.quotation.protocol import Req_CreateQuotation @@ -32,7 +32,7 @@ async def test_create_without_anchoring_schema_falls_back_to_base_value(db_engin assert res.result.success is True tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) # 92,200 rows = await _session_anchor_rows(db_engine, res.qt_id) - assert rows == {item: (tp, tp * (1000 - BASE_VALUE) // 1000, BASE_VALUE)} + assert rows == {item: (tp, calc_anchoring_price(tp, BASE_VALUE), BASE_VALUE)} async def test_create_uses_latest_adjusted_value_per_cell(db_engine, company_id): @@ -49,8 +49,8 @@ async def test_create_uses_latest_adjusted_value_per_cell(db_engine, company_id) assert res.result.success is True rows = await _session_anchor_rows(db_engine, res.qt_id) - assert rows[item_hit] == (tp_hit, tp_hit * 950 // 1000, 50) - assert rows[item_miss] == (tp_miss, tp_miss * 990 // 1000, BASE_VALUE) + assert rows[item_hit] == (tp_hit, calc_anchoring_price(tp_hit, 50), 50) + assert rows[item_miss] == (tp_miss, calc_anchoring_price(tp_miss, BASE_VALUE), BASE_VALUE) async def test_supply_type_unset_uses_base_value(db_engine, company_id): @@ -65,7 +65,7 @@ async def test_supply_type_unset_uses_base_value(db_engine, company_id): assert res.result.success is True rows = await _session_anchor_rows(db_engine, res.qt_id) - assert rows == {item: (tp, tp * 990 // 1000, BASE_VALUE)} + assert rows == {item: (tp, calc_anchoring_price(tp, BASE_VALUE), BASE_VALUE)} async def test_regenerate_inherits_target_but_recomputes_anchor(db_engine, company_id): @@ -79,14 +79,14 @@ async def test_regenerate_inherits_target_but_recomputes_anchor(db_engine, compa assert res1.result.success is True tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) rows1 = await _session_anchor_rows(db_engine, res1.qt_id) - assert rows1 == {item: (tp, tp * 990 // 1000, BASE_VALUE)} # 1라운드는 시작값 + assert rows1 == {item: (tp, calc_anchoring_price(tp, BASE_VALUE), BASE_VALUE)} # 1라운드는 시작값 await _seed_adjustment(db_engine, company_id, supplier_type=1, price_range=calc_price_range_index(tp), value_after=50) res2 = await _service().regenerate_next_round(res1.qt_id, [supplier]) assert res2.result.success is True rows2 = await _session_anchor_rows(db_engine, res2.qt_id) - assert rows2 == {item: (tp, tp * 950 // 1000, 50)} # 목표가 상속 + 앵커만 현재 anchoring_value + assert rows2 == {item: (tp, calc_anchoring_price(tp, 50), 50)} # 목표가 상속 + 앵커만 현재 anchoring_value def test_price_range_index_golden_vectors(): diff --git a/negodata/backend/tests/test_quotation_regenerate.py b/negodata/backend/tests/test_quotation_regenerate.py new file mode 100644 index 0000000..4bd2b73 --- /dev/null +++ b/negodata/backend/tests/test_quotation_regenerate.py @@ -0,0 +1,211 @@ +"""견적 재생성 조정값 — 담당자가 다음 라운드에서만 바꾼 값(카드·목표가·마감기한·타결 상한율)이 반영되는지 검증. + +기본 계약은 '미전송 = 원 견적/직전 라운드 승계'다(기존 동작). 보내면 그 값으로 라운드가 만들어진다. + · card_ids — None=원본 카드 버전 재사용 / 리스트=그 카드들로 새 버전 / []=카드 없는 버전 + · target_price — 이번 라운드 전 상품의 목표가(세션 target_price + quotations.md_price) + · end_time — 마감기한(견적·세션 공통). 미전송이면 원 견적과 같은 협상기간 + · done_ceiling_rate — 타결 상한율(‰) → 세션 done_ceiling_price 로 박제 +앵커링가는 어느 경우든 재계산이라 여기선 보지 않는다(test_quotation_anchoring 소관). +""" +import uuid +from datetime import datetime, timezone + +from sqlalchemy import text + +from common.enums import QuotationStatus, QuotationType +from crud.quotation_crud import QuotationCRUD +from router.v1.quotation.protocol import Req_CreateQuotation +from services.quotation import QuotationService + +FUTURE = datetime(2999, 1, 1) # 마감시각 미래 — 생성 직후 크론에 안 잡히게 +NEXT_DUE = "2999-06-01T00:00:00Z" # 재생성 때 다시 잡는 마감기한(프론트가 보내는 형태 = UTC ISO) +TARGET = 100_000 # 1라운드 목표가(= MD 제시가 그대로) + + +async def test_regenerate_without_overrides_inherits_everything(db_engine, client, auth_headers): + """검증: 조정값 없이 공급사만 보내 재생성. + 기대결과: 목표가·카드 버전·타결 상한율이 원 견적 그대로 승계되고 차수만 +1.""" + ctx = await _closed_round1(db_engine, client, auth_headers, "regen_plain", ceiling_rate=50) + + body = await _regenerate(client, ctx, {"supplier_ids": [str(ctx["supplier"])]}) + + assert body["result"]["success"] is True + q = await _quotation(db_engine, body["qt_id"]) + assert (q["round"], q["md_price"], q["done_ceiling_rate"]) == (2, TARGET, 50) + assert q["version_id"] == ctx["version_id"] # 새 버전 안 만듦 — 원본 카드 버전 재사용 + s = await _session(db_engine, body["qt_id"]) + assert s["target_price"] == TARGET + assert s["done_ceiling_price"] == 105_000 # 목표가 +5% + + +async def test_regenerate_applies_target_price_and_ceiling(db_engine, client, auth_headers): + """검증: 목표가 9만원 + 타결 상한율 100‰(=10%)로 재생성. + 기대결과: 세션 목표가·견적 md_price 가 새 값, 타결 상한가는 새 목표가 기준으로 재계산(99,000).""" + ctx = await _closed_round1(db_engine, client, auth_headers, "regen_target", ceiling_rate=50) + + body = await _regenerate(client, ctx, { + "supplier_ids": [str(ctx["supplier"])], + "target_price": 90_000, + "done_ceiling_rate": 100, + }) + + assert body["result"]["success"] is True + q = await _quotation(db_engine, body["qt_id"]) + assert (q["md_price"], q["done_ceiling_rate"]) == (90_000, 100) + s = await _session(db_engine, body["qt_id"]) + assert (s["target_price"], s["done_ceiling_price"]) == (90_000, 99_000) + + +async def test_regenerate_applies_end_time(db_engine, client, auth_headers): + """검증: 마감기한(UTC ISO)을 직접 지정해 재생성(원 견적 협상기간 승계 대신). + 기대결과: 견적·세션 end_time 이 보낸 시각 그대로. 미지정 경로(승계)와 달리 생성시각+기간이 아니다.""" + ctx = await _closed_round1(db_engine, client, auth_headers, "regen_due", ceiling_rate=50) + + body = await _regenerate(client, ctx, {"supplier_ids": [str(ctx["supplier"])], "end_time": NEXT_DUE}) + + assert body["result"]["success"] is True + q = await _quotation(db_engine, body["qt_id"]) + s = await _session(db_engine, body["qt_id"]) + due = datetime(2999, 6, 1, tzinfo=timezone.utc) + assert q["end_time"] == due + assert s["end_time"] == due + + +async def test_regenerate_replaces_cards_with_new_version(db_engine, client, auth_headers): + """검증: 직전 라운드와 다른 카드 1장으로 재생성. + 기대결과: 원본과 다른 새 버전이 생기고 그 버전엔 보낸 카드만 매핑된다(원본 버전은 그대로 남음).""" + ctx = await _closed_round1(db_engine, client, auth_headers, "regen_cards", ceiling_rate=50) + new_card = await _seed_nego_card(db_engine) + + body = await _regenerate(client, ctx, {"supplier_ids": [str(ctx["supplier"])], "card_ids": [str(new_card)]}) + + assert body["result"]["success"] is True + q = await _quotation(db_engine, body["qt_id"]) + assert q["version_id"] != ctx["version_id"] + assert await _version_cards(db_engine, q["version_id"]) == {new_card} + assert await _version_cards(db_engine, ctx["version_id"]) == {ctx["card_id"]} # 직전 라운드 카드 이력 보존 + + +async def test_regenerate_with_empty_cards_makes_cardless_version(db_engine, client, auth_headers): + """검증: 카드를 전부 해제(빈 리스트)한 채 재생성. + 기대결과: 원본 버전을 그대로 물려받지 않고, 카드가 하나도 안 걸린 새 버전으로 생성된다.""" + ctx = await _closed_round1(db_engine, client, auth_headers, "regen_nocard", ceiling_rate=50) + + body = await _regenerate(client, ctx, {"supplier_ids": [str(ctx["supplier"])], "card_ids": []}) + + assert body["result"]["success"] is True + q = await _quotation(db_engine, body["qt_id"]) + assert q["version_id"] != ctx["version_id"] + assert await _version_cards(db_engine, q["version_id"]) == set() + + +# ===== 헬퍼 ===== +def _service(): + return QuotationService(QuotationCRUD()) + + +async def _closed_round1(engine, client, auth_headers, login_id, *, ceiling_rate): + """재생성 대상(마감된 1라운드)을 만든다 — 카드 1장·공급사 1곳짜리 1:1 협상 견적. + + 생성은 서비스로(견적 생성 API 는 로그인 유저를 작성자로 박으므로 같은 유저로 맞춘다), + 재생성은 HTTP 로 태워 라우터→서비스 인자 전달까지 함께 본다. + """ + headers = await auth_headers(login_id) + user_id = await _user_id(engine, login_id) + item_id = await _seed_item(engine, await _company_of(engine, user_id)) + card_id = await _seed_nego_card(engine) + supplier = uuid.uuid4() + + req = Req_CreateQuotation( + qt_setting_id=uuid.uuid4(), # FK 미설정 — 세팅 없으면 율 0(목표가는 md_price 로 확정) + name="재생성원본", + type=QuotationType.NEW_NEGO.value, + end_time=FUTURE, + md_price=TARGET, + item_ids=[item_id], + supplier_ids=[supplier], + card_ids=[card_id], + done_ceiling_rate=ceiling_rate, + ) + res = await _service().create_quotation(str(user_id), req) + assert res.result.success is True + # 재생성은 마감 견적에서만 — 크론 마감을 기다리지 않고 상태만 CLOSED 로 돌린다. + async with engine.begin() as conn: + await conn.execute( + text("UPDATE quotations SET status = :st WHERE qt_id = :qt"), + {"st": QuotationStatus.CLOSED.value, "qt": res.qt_id}, + ) + original = await _quotation(engine, str(res.qt_id)) + return {"qt_id": str(res.qt_id), "headers": headers, "supplier": supplier, + "card_id": card_id, "version_id": original["version_id"]} + + +async def _regenerate(client, ctx, payload): + r = await client.post(f"/v1/quotation/regenerate/{ctx['qt_id']}", json=payload, headers=ctx["headers"]) + return r.json() + + +async def _user_id(engine, login_id): + async with engine.begin() as conn: + return (await conn.execute( + text("SELECT user_id FROM users WHERE id = :id"), {"id": login_id} + )).scalar_one() + + +async def _company_of(engine, user_id): + async with engine.begin() as conn: + return (await conn.execute( + text("SELECT company_id FROM users WHERE user_id = :uid"), {"uid": user_id} + )).scalar_one() + + +async def _seed_item(engine, company_id): + """상품 1건 시드. NOT NULL 컬럼은 명시(ORM default 는 raw INSERT 에 안 먹음).""" + item_id = uuid.uuid4() + async with engine.begin() as conn: + await conn.execute( + text("INSERT INTO items (item_id, company_id, user_id, name, category_type, internet_lowest_price_yn) " + "VALUES (:item_id, :company_id, :user_id, '상품', 1, false)"), + {"item_id": item_id, "company_id": company_id, "user_id": uuid.uuid4()}, + ) + return item_id + + +async def _seed_nego_card(engine): + card_id = uuid.uuid4() + async with engine.begin() as conn: + await conn.execute( + text("INSERT INTO nego_cards (nego_card_id, user_id, name, number, script, usage_type) " + "VALUES (:cid, :uid, '카드', 'N1', '멘트', 1)"), + {"cid": card_id, "uid": uuid.uuid4()}, + ) + return card_id + + +async def _quotation(engine, qt_id): + async with engine.begin() as conn: + row = (await conn.execute( + text("SELECT round, version_id, md_price, done_ceiling_rate, end_time " + "FROM quotations WHERE qt_id = :qt"), + {"qt": uuid.UUID(qt_id)}, + )).mappings().one() + return dict(row) + + +async def _session(engine, qt_id): + """견적의 세션 1건(상품·공급사 1:1 시드라 단건).""" + async with engine.begin() as conn: + row = (await conn.execute( + text("SELECT target_price, done_ceiling_price, end_time FROM sessions WHERE quotation_id = :qt"), + {"qt": uuid.UUID(qt_id)}, + )).mappings().one() + return dict(row) + + +async def _version_cards(engine, version_id): + async with engine.begin() as conn: + rows = (await conn.execute( + text("SELECT nego_card_id FROM version_nego_cards WHERE version_id = :vid"), + {"vid": version_id}, + )).scalars().all() + return set(rows) diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 6573f67..dececef 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -113,6 +113,7 @@ export * from './quotationData'; export * from './quotationDataCloseReason'; export * from './quotationDataCreatedAt'; export * from './quotationDataCreatorName'; +export * from './quotationDataDoneCeilingRate'; export * from './quotationDataEqualBidData'; export * from './quotationDataEqualBidYn'; export * from './quotationDataItemId'; @@ -176,6 +177,7 @@ export * from './reqCreateItemSellingPrice'; export * from './reqCreateItemSpec'; export * from './reqCreateItemVatYn'; export * from './reqCreateQuotation'; +export * from './reqCreateQuotationDoneCeilingRate'; export * from './reqCreateQuotationManagerContactNumber'; export * from './reqCreateQuotationManagerEmail'; export * from './reqCreateQuotationManagerName'; @@ -198,6 +200,10 @@ export * from './reqCreateSupplierManagerName'; export * from './reqCreateSupplierTotalRevenue'; export * from './reqLogin'; export * from './reqRegenerateQuotation'; +export * from './reqRegenerateQuotationCardIds'; +export * from './reqRegenerateQuotationDoneCeilingRate'; +export * from './reqRegenerateQuotationEndTime'; +export * from './reqRegenerateQuotationTargetPrice'; export * from './reqRejectRenegotiation'; export * from './reqResetSupplierAccountPassword'; export * from './reqResetSupplierAccountPasswordPassword'; @@ -248,6 +254,7 @@ export * from './reqUpdateMeName'; export * from './reqUpdateMePassword'; export * from './reqUpdateQuotationSetting'; export * from './reqUpdateQuotationSettingCardCount'; +export * from './reqUpdateQuotationSettingDoneCeilingRate'; export * from './reqUpdateQuotationSettingTargetMarginRate'; export * from './reqUpdateSupplier'; export * from './reqUpdateSupplierAccountStatus'; diff --git a/negodata/front/src/api/generated/model/quotationData.ts b/negodata/front/src/api/generated/model/quotationData.ts index cdfd361..3fedfa2 100644 --- a/negodata/front/src/api/generated/model/quotationData.ts +++ b/negodata/front/src/api/generated/model/quotationData.ts @@ -19,6 +19,7 @@ import type { QuotationDataEqualBidData } from './quotationDataEqualBidData'; import type { QuotationDataCloseReason } from './quotationDataCloseReason'; import type { QuotationDataMidAction } from './quotationDataMidAction'; import type { QuotationDataOverAction } from './quotationDataOverAction'; +import type { QuotationDataDoneCeilingRate } from './quotationDataDoneCeilingRate'; import type { QuotationDataItemId } from './quotationDataItemId'; import type { QuotationDataItemName } from './quotationDataItemName'; import type { QuotationDataCreatorName } from './quotationDataCreatorName'; @@ -51,6 +52,7 @@ export interface QuotationData { close_reason?: QuotationDataCloseReason; mid_action?: QuotationDataMidAction; over_action?: QuotationDataOverAction; + done_ceiling_rate?: QuotationDataDoneCeilingRate; participation_count?: number; item_id?: QuotationDataItemId; item_name?: QuotationDataItemName; diff --git a/negodata/front/src/api/generated/model/quotationDataDoneCeilingRate.ts b/negodata/front/src/api/generated/model/quotationDataDoneCeilingRate.ts new file mode 100644 index 0000000..08735a0 --- /dev/null +++ b/negodata/front/src/api/generated/model/quotationDataDoneCeilingRate.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type QuotationDataDoneCeilingRate = number | null; diff --git a/negodata/front/src/api/generated/model/quotationSettingData.ts b/negodata/front/src/api/generated/model/quotationSettingData.ts index b8980ce..f323e5b 100644 --- a/negodata/front/src/api/generated/model/quotationSettingData.ts +++ b/negodata/front/src/api/generated/model/quotationSettingData.ts @@ -13,6 +13,7 @@ export interface QuotationSettingData { user_id?: QuotationSettingDataUserId; target_margin_rate: number; card_count: number; + done_ceiling_rate: number; created_at?: QuotationSettingDataCreatedAt; updated_at?: QuotationSettingDataUpdatedAt; } diff --git a/negodata/front/src/api/generated/model/reqCreateQuotation.ts b/negodata/front/src/api/generated/model/reqCreateQuotation.ts index 42b2b65..52624a3 100644 --- a/negodata/front/src/api/generated/model/reqCreateQuotation.ts +++ b/negodata/front/src/api/generated/model/reqCreateQuotation.ts @@ -13,6 +13,7 @@ import type { ReqCreateQuotationMemo } from './reqCreateQuotationMemo'; import type { ReqCreateQuotationMdPrice } from './reqCreateQuotationMdPrice'; import type { ReqCreateQuotationMidAction } from './reqCreateQuotationMidAction'; import type { ReqCreateQuotationOverAction } from './reqCreateQuotationOverAction'; +import type { ReqCreateQuotationDoneCeilingRate } from './reqCreateQuotationDoneCeilingRate'; export interface ReqCreateQuotation { qt_setting_id: string; @@ -33,4 +34,5 @@ export interface ReqCreateQuotation { card_ids?: string[]; mid_action?: ReqCreateQuotationMidAction; over_action?: ReqCreateQuotationOverAction; + done_ceiling_rate?: ReqCreateQuotationDoneCeilingRate; } diff --git a/negodata/front/src/api/generated/model/reqCreateQuotationDoneCeilingRate.ts b/negodata/front/src/api/generated/model/reqCreateQuotationDoneCeilingRate.ts new file mode 100644 index 0000000..c5aa853 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateQuotationDoneCeilingRate.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqCreateQuotationDoneCeilingRate = number | null; diff --git a/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts b/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts index 31a2d7e..b73a334 100644 --- a/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts +++ b/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts @@ -8,4 +8,5 @@ export interface ReqCreateQuotationSetting { target_margin_rate: number; card_count?: number; + done_ceiling_rate?: number; } diff --git a/negodata/front/src/api/generated/model/reqRegenerateQuotation.ts b/negodata/front/src/api/generated/model/reqRegenerateQuotation.ts index 0b07eaa..b8d23fd 100644 --- a/negodata/front/src/api/generated/model/reqRegenerateQuotation.ts +++ b/negodata/front/src/api/generated/model/reqRegenerateQuotation.ts @@ -4,7 +4,15 @@ * Negodata Api Server * OpenAPI spec version: 0.1.0 */ +import type { ReqRegenerateQuotationCardIds } from './reqRegenerateQuotationCardIds'; +import type { ReqRegenerateQuotationTargetPrice } from './reqRegenerateQuotationTargetPrice'; +import type { ReqRegenerateQuotationEndTime } from './reqRegenerateQuotationEndTime'; +import type { ReqRegenerateQuotationDoneCeilingRate } from './reqRegenerateQuotationDoneCeilingRate'; export interface ReqRegenerateQuotation { supplier_ids?: string[]; + card_ids?: ReqRegenerateQuotationCardIds; + target_price?: ReqRegenerateQuotationTargetPrice; + end_time?: ReqRegenerateQuotationEndTime; + done_ceiling_rate?: ReqRegenerateQuotationDoneCeilingRate; } diff --git a/negodata/front/src/api/generated/model/reqRegenerateQuotationCardIds.ts b/negodata/front/src/api/generated/model/reqRegenerateQuotationCardIds.ts new file mode 100644 index 0000000..d6807f9 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqRegenerateQuotationCardIds.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqRegenerateQuotationCardIds = string[] | null; diff --git a/negodata/front/src/api/generated/model/reqRegenerateQuotationDoneCeilingRate.ts b/negodata/front/src/api/generated/model/reqRegenerateQuotationDoneCeilingRate.ts new file mode 100644 index 0000000..cb839f9 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqRegenerateQuotationDoneCeilingRate.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqRegenerateQuotationDoneCeilingRate = number | null; diff --git a/negodata/front/src/api/generated/model/reqRegenerateQuotationEndTime.ts b/negodata/front/src/api/generated/model/reqRegenerateQuotationEndTime.ts new file mode 100644 index 0000000..5e0585c --- /dev/null +++ b/negodata/front/src/api/generated/model/reqRegenerateQuotationEndTime.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqRegenerateQuotationEndTime = string | null; diff --git a/negodata/front/src/api/generated/model/reqRegenerateQuotationTargetPrice.ts b/negodata/front/src/api/generated/model/reqRegenerateQuotationTargetPrice.ts new file mode 100644 index 0000000..98e000e --- /dev/null +++ b/negodata/front/src/api/generated/model/reqRegenerateQuotationTargetPrice.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqRegenerateQuotationTargetPrice = number | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts b/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts index 0070c42..d6b13db 100644 --- a/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts +++ b/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts @@ -6,8 +6,10 @@ */ import type { ReqUpdateQuotationSettingTargetMarginRate } from './reqUpdateQuotationSettingTargetMarginRate'; import type { ReqUpdateQuotationSettingCardCount } from './reqUpdateQuotationSettingCardCount'; +import type { ReqUpdateQuotationSettingDoneCeilingRate } from './reqUpdateQuotationSettingDoneCeilingRate'; export interface ReqUpdateQuotationSetting { target_margin_rate?: ReqUpdateQuotationSettingTargetMarginRate; card_count?: ReqUpdateQuotationSettingCardCount; + done_ceiling_rate?: ReqUpdateQuotationSettingDoneCeilingRate; } diff --git a/negodata/front/src/api/generated/model/reqUpdateQuotationSettingDoneCeilingRate.ts b/negodata/front/src/api/generated/model/reqUpdateQuotationSettingDoneCeilingRate.ts new file mode 100644 index 0000000..fa158ef --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateQuotationSettingDoneCeilingRate.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqUpdateQuotationSettingDoneCeilingRate = number | null; diff --git a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx index d7bf64f..ae7f6f4 100644 --- a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx @@ -12,6 +12,7 @@ import { Typography, typographyVariants } from '@/components/ui/typography'; import { cn } from '@/lib/utils'; import { useScrollLock } from '@/lib/useScrollLock'; import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Combobox, type ComboOption } from '@/components/ui/combobox'; import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types'; @@ -68,6 +69,8 @@ export function QuotationCreateModal({ const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 상품값으로 목표가 산정 const [midAction, setMidAction] = useState(DEFAULT_MID_ACTION); // 앵커~목표가 구간: 낙찰/개찰 (1:1 전용) const [overAction, setOverAction] = useState(DEFAULT_OVER_ACTION); // 목표가 초과 구간: 낙찰/개찰 (1:1 전용) + const [ceilingPct, setCeilingPct] = useState(''); // 협상 완료 상한율(%) 이 견적 override. 비우면 세팅 기본값 + const [ceilingTouched, setCeilingTouched] = useState(false); // 상한율을 직접 건드렸는지 — 안 건드렸으면 세팅값 표시 const [submitting, setSubmitting] = useState(false); const [mdTouched, setMdTouched] = useState(false); // 담당자가 제시가를 직접 건드렸는지 — 안 건드렸으면 자동 산출값을 채운다 // 매입가 네고율 차감 — 이 견적에서만 조정. 안 건드리면 세팅 기본값을 따른다(negoTouched=false). 네고율 값 자체는 세팅값 고정. @@ -164,6 +167,8 @@ export function QuotationCreateModal({ targetLimitExceeded, estimatedTargetPrice, submitMdPrice, + settingCeilingRate, + doneCeilingPrice, } = useTargetPrice({ quotationSettings, settingId, @@ -178,6 +183,7 @@ export function QuotationCreateModal({ negoTouched, applyNego, selectedCandidateKey, + doneCeilingRateOverride: ceilingTouched && ceilingPct !== '' ? Math.round(Number(ceilingPct) * 10) : null, }); // ── 카드 선택 게이팅 — 부적합 카드 disabled·자동 선택/해제(useCardGating 이 소유) ── @@ -268,6 +274,8 @@ export function QuotationCreateModal({ mdPrice: submitMdPrice, midAction: oneToOne ? midAction : undefined, overAction: oneToOne ? overAction : undefined, + // 완료 상한율 override — 직접 건드렸을 때만 전송(‰). 비우면 서버가 세팅 기본값 사용. + doneCeilingRate: ceilingTouched && ceilingPct !== '' ? Math.round(Number(ceilingPct) * 10) : undefined, }); if (ok) onClose(); } finally { @@ -428,7 +436,7 @@ export function QuotationCreateModal({ {(value) => { const qs = quotationSettings.find((s) => s.qt_setting_id === value); return qs - ? `[${label('target_margin')}: ${qs.target_margin}] 카드 ${qs.card_use_count}` + ? `[${label('target_margin')}: ${qs.target_margin}] 카드 ${qs.card_use_count} · 타결상한 +${qs.done_ceiling_rate / 10}%` : ''; }} @@ -436,46 +444,27 @@ export function QuotationCreateModal({ {quotationSettings.map((qs) => ( - [{label('target_margin')}: {qs.target_margin}] 카드 {qs.card_use_count} + [{label('target_margin')}: {qs.target_margin}] 카드 {qs.card_use_count} · 타결상한 +{qs.done_ceiling_rate / 10}% ))} - {oneToOne ? ( - <> - {/* 낙찰 기준(1:1 전용) — 스펙트럼 = 선택. 낙찰선을 앵커/목표가 중 택1, 목표가 초과는 항상 개찰. */} - { - setMidAction(v); - if (v === PriceGateAction.OPEN) setOverAction(PriceGateAction.OPEN); - }} - onOver={(v) => { - setOverAction(v); - if (v === PriceGateAction.AWARD) setMidAction(PriceGateAction.AWARD); - }} - /> - - ) : ( - /* 경매(1:N) — 낙찰 기준·협상카드 없음. 최저가 자동 낙찰 안내만. */ -
- -
- 최저가 자동 낙찰 - - 1:N 견적은 가장 낮은 투찰가가 자동 낙찰됩니다. 낙찰 기준·협상카드 설정이 없습니다. - + {/* ── 타결 기준 (협상) — 봇이 어느 가격까지 합의하면 타결로 볼지 ── */} +
+
+ +
+ 타결 기준 · 협상 + 봇이 어느 가격까지 합의하면 타결로 볼지 정합니다
- )} +
{/* 목표가 산정 후보 — 후보 택1로 목표가 결정(기본=최저). 매입가 후보 행의 체크박스로 네고율 차감 여부 조정. 숨김필드는 제외. */} {productId && targetBreakdown.length > 0 && ( -
+
목표가 산정 후보 ({isReType ? '재' : '신규'}) @@ -579,6 +568,91 @@ export function QuotationCreateModal({ )}
+ {/* 타결 상한 — 목표가 초과 허용폭. 봇이 이 이하로 합의하면 타결, 초과하면 결렬. 비우면 세팅 기본율. */} +
+
+ 타결 상한가 + {/* OFF=세팅 기본율 그대로 · ON=이 견적만 직접 지정 */} + +
+ + {ceilingTouched ? ( +
+ 목표가 + + setCeilingPct(e.target.value)} + /> + % +
+ ) : ( + + 세팅 기본 목표가 +{settingCeilingRate / 10}% 적용 + + )} + + + {doneCeilingPrice != null ? ( + <>최종 합의가가 ₩{doneCeilingPrice.toLocaleString()} 이하면 타결, 초과하면 결렬. + ) : '목표가가 정해지면 타결 상한가가 자동 계산됩니다.'} + +
+
+
+ + {/* ── 낙찰 기준 (마감) — 타결된 투찰가로 누구를 낙찰시킬지 ── */} +
+
+ +
+ 낙찰 기준 · 마감 + 타결된 투찰가로 마감 때 누구를 낙찰시킬지 정합니다 +
+
+
+ {oneToOne ? ( + /* 스펙트럼 = 선택. 낙찰선을 앵커/목표가 중 택1, 목표가 초과는 항상 개찰. */ + { + setMidAction(v); + if (v === PriceGateAction.OPEN) setOverAction(PriceGateAction.OPEN); + }} + onOver={(v) => { + setOverAction(v); + if (v === PriceGateAction.AWARD) setMidAction(PriceGateAction.AWARD); + }} + /> + ) : ( + /* 경매(1:N) — 최저가 자동 낙찰. */ +
+ +
+ 최저가 자동 낙찰 + + 1:N 견적은 가장 낮은 투찰가가 자동 낙찰됩니다. 별도 낙찰 기준 설정이 없습니다. + +
+
+ )} +
+
+
협력사 안내 메모 (선택)