From 05fe0de5ce45c7ccee6aca41155bb2a731474aaf Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Thu, 25 Jun 2026 10:50:36 +0900 Subject: [PATCH 01/20] =?UTF-8?q?[feat]=20negodata/front:=20=ED=98=91?= =?UTF-8?q?=EC=83=81=20=EC=B1=84=ED=8C=85=C2=B7=EA=B2=AC=EC=A0=81=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20?= =?UTF-8?q?404=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 협상 채팅 말풍선에서 협력사 제시가 마스킹 + 챗버블 컴포넌트 분리 - 견적 리스트에 재생성된 다음 차수 표시 - 매칭 없는 경로용 404 폴백 페이지 추가 - 견적 재생성 모달 너비 확대 Co-Authored-By: Claude Opus 4.8 (1M context) --- negodata/front/src/app/router.tsx | 5 + .../QuotationDetailSheet/ChatTab.tsx | 380 +++++++++++------- .../QuotationDetailSheet/RegenerateModal.tsx | 2 +- .../quotations/components/QuotationTable.tsx | 94 ++++- negodata/front/src/lib/utils.ts | 7 + negodata/front/src/pages/not-found.tsx | 42 ++ 6 files changed, 359 insertions(+), 171 deletions(-) create mode 100644 negodata/front/src/pages/not-found.tsx diff --git a/negodata/front/src/app/router.tsx b/negodata/front/src/app/router.tsx index e8a8cbe..821e90f 100644 --- a/negodata/front/src/app/router.tsx +++ b/negodata/front/src/app/router.tsx @@ -4,6 +4,7 @@ import {isLoggedIn} from '../stores/auth'; import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout'; import LoginPage from '../pages/login'; import ForbiddenPage from '../pages/forbidden'; +import NotFoundPage from '../pages/not-found'; import ProductsPage from '../pages/products'; import PartnersPage from '../pages/partners'; import QuotationPage from '../pages/quotation'; @@ -57,4 +58,8 @@ export const router = createBrowserRouter([ {path: 'cards', Component: CardsPage}, ], }, + { + path: '*', + Component: NotFoundPage, + }, ]); diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx index e5fff6f..f05c778 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx @@ -3,10 +3,11 @@ import type { SessionData } from '@/api/generated/model/sessionData'; import type { QuotationCardData } from '@/api/generated/model/quotationCardData'; import type { ChatMessageData } from '@/api/generated/model/chatMessageData'; import { ChatSender, CardType } from '@/api/generated/model'; -import { Input } from '@/components/ui/input'; import SlateRenderer from '@/components/SlateRenderer'; +import { Typography } from '@/components/ui/typography'; import { StatusPill, sessionStatusTone } from './StatusPill'; import { type Product, type Partner, sessionStatusLabel } from '../../types'; +import { maskPrices } from '@/lib/utils'; export function ChatTab({ serverSessions, @@ -34,14 +35,14 @@ export function ChatTab({
{/* Sessions list */}
-
+ 참여자 협력사 리스트 -
+
{serverSessions.length === 0 && ( -
+ 참여 협상 세션이 없습니다. (리스트가 비어 있습니다) -
+ )} {serverSessions.map((sd) => { const isSelected = sd.session_id === effectiveSessionId; @@ -56,16 +57,16 @@ export function ChatTab({ }`} >
- {name} + {name} {statusLabel}
-
- 최종 제의 - +
+ 최종 제의 + {sd.bid_price ? `₩${Number(sd.bid_price).toLocaleString()}` : '-'} - +
); @@ -75,162 +76,237 @@ export function ChatTab({ {/* Chat zone */}
-
-
- 협력사: {currentSupplierName} -
-
+
+ + 협력사: {currentSupplierName} + +
{targetPrice != null && ( - - 목표가: ₩{Number(targetPrice).toLocaleString()} - + + 목표가: ₩{Number(targetPrice).toLocaleString()} + )} - - 기록: {chatMessages.length} 메시지 - + + 기록: {chatMessages.length} 메시지 +
{!effectiveSessionId ? ( -
+ 선택된 협력사가 없습니다. -
+ ) : chatMessages.length === 0 ? ( -
+ 기록된 협상 대화가 없습니다. -
+ ) : ( - chatMessages.map((m) => { - const isBot = m.sender === ChatSender.BOT; - // 메시지가 쓴 협상카드 전체(이름만이 아니라 멘트/조건/메모까지) 를 chat_id 로 매칭. - const usedCard = m.card_used_yn - ? serverCards.find((c) => c.session_card_id === m.chat_id) - : undefined; - const cardNodes = Array.isArray(usedCard?.edit_script) ? (usedCard.edit_script as unknown[]) : null; - const isWildCard = usedCard?.type === CardType.WILD; - return ( -
-
-
- {isBot ? 'Negosium Bot' : currentSupplierName} - · - #{m.index} -
- -
- {/* 진행 단계(chats.meta.step). 주로 봇 턴에만 존재. */} - {m.step && ( -
- {m.step} -
- )} - {/* 말풍선 멘트(chats.meta.script). 봇=협상 스크립트, 협력사=입력값. */} - {m.script && ( -

{m.script}

- )} - {/* 제시가: 협력사(user)가 실제로 제시한 가격만 표시. 목표가는 헤더 고정. - 가격 제시 턴이 아니면(target_price=0) 숨긴다(₩0 오표시 방지). */} - {!isBot && m.target_price > 0 && ( -
제시가 ₩{Number(m.target_price).toLocaleString()}
- )} - {usedCard && ( -
- {/* 헤더: 어떤 카드인지(번호·이름·종류) */} -
- - 협상카드 - {usedCard.number && #{usedCard.number}} - {usedCard.name && · {usedCard.name}} - - {isWildCard ? '와일드' : '협상'} - -
- - {/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script */} - {cardNodes ? ( -
- -
- ) : usedCard.script ? ( -

- {usedCard.script} -

- ) : null} - - {/* 와일드카드 부가 정보: 사용 조건 / 메모 */} - {isWildCard && (usedCard.condition || usedCard.memo) && ( -
- {usedCard.condition && ( -
- 조건: {usedCard.condition} -
- )} - {usedCard.memo && ( -
- 메모: {usedCard.memo} -
- )} -
- )} -
- )} -
-
-
- ); - }) + chatMessages.map((m) => + m.sender === ChatSender.BOT ? ( + + ) : ( + + ), + ) )}
- -
- - -
); } + +// 봇(좌측) 말풍선. 진행 단계·협상 스크립트(가격 마스킹)·사용 협상카드를 보여준다. +// 간격은 부모(flex flex-col gap)에서 주고, 말풍선 박스는 block 으로 둬 긴 텍스트 줄바꿈이 깨지지 않게 한다. +function BotBubble({ + message, + currentSupplierName, + currentProduct, + serverCards, +}: { + message: ChatMessageData; + currentSupplierName: string; + currentProduct: Product | undefined; + serverCards: QuotationCardData[]; +}) { + const m = message; + return ( +
+
+
+ Negosium Bot + · + #{m.index} +
+
+ {m.step && ( + + {m.step} + + )} + {m.script && ( + + {maskPrices(m.script)} + + )} + +
+
+
+ ); +} + +// 협력사(우측) 말풍선. 협력사 입력값을 보여주되, 채팅 '내용'에 제시 금액이 노출되지 않도록 maskPrices 로 가린다. +function PartnerBubble({ + message, + currentSupplierName, + currentProduct, + serverCards, +}: { + message: ChatMessageData; + currentSupplierName: string; + currentProduct: Product | undefined; + serverCards: QuotationCardData[]; +}) { + const m = message; + return ( +
+
+
+ {currentSupplierName} + · + #{m.index} +
+
+ {m.step && ( + + {m.step} + + )} + {m.script && ( + + {maskPrices(m.script)} + + )} + +
+
+
+ ); +} + +// 말풍선에 붙는 협상카드 박스(봇/협력사 공용). 카드 미사용 메시지면 아무것도 렌더하지 않는다. +// 톤(amber/primary)만 isBot 으로 가르고, 멘트/조건/메모 렌더 로직은 공유한다. +function UsedCardBox({ + message, + isBot, + currentSupplierName, + currentProduct, + serverCards, +}: { + message: ChatMessageData; + isBot: boolean; + currentSupplierName: string; + currentProduct: Product | undefined; + serverCards: QuotationCardData[]; +}) { + const m = message; + // 메시지가 쓴 협상카드 전체(이름만이 아니라 멘트/조건/메모까지) 를 chat_id 로 매칭. + const usedCard = m.card_used_yn + ? serverCards.find((c) => c.session_card_id === m.chat_id) + : undefined; + if (!usedCard) return null; + const cardNodes = Array.isArray(usedCard.edit_script) ? (usedCard.edit_script as unknown[]) : null; + const isWildCard = usedCard.type === CardType.WILD; + + return ( +
+ {/* 헤더: 어떤 카드인지(번호·이름·종류) */} +
+ + 협상카드 + {usedCard.number && ( + #{usedCard.number} + )} + {usedCard.name && ( + · {usedCard.name} + )} + + {isWildCard ? '와일드' : '협상'} + +
+ + {/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script */} + {cardNodes ? ( +
+ +
+ ) : usedCard.script ? ( + + {usedCard.script} + + ) : null} + + {/* 와일드카드 부가 정보: 사용 조건 / 메모 */} + {isWildCard && (usedCard.condition || usedCard.memo) && ( +
+ {usedCard.condition && ( + + 조건: {usedCard.condition} + + )} + {usedCard.memo && ( + + 메모: {usedCard.memo} + + )} +
+ )} +
+ ); +} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/RegenerateModal.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/RegenerateModal.tsx index 89b0225..b3923c1 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/RegenerateModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/RegenerateModal.tsx @@ -43,7 +43,7 @@ export function RegenerateModal({ open, partners, sessionStatusBySupplier, defau return (
-
+
{/* Header */}
diff --git a/negodata/front/src/features/quotations/components/QuotationTable.tsx b/negodata/front/src/features/quotations/components/QuotationTable.tsx index 293a3d9..9ca85f7 100644 --- a/negodata/front/src/features/quotations/components/QuotationTable.tsx +++ b/negodata/front/src/features/quotations/components/QuotationTable.tsx @@ -1,7 +1,15 @@ -import type { ReactNode } from 'react'; -import { Clock, Building2, Link2 } from 'lucide-react'; +import { type ReactNode } from 'react'; +import { Clock, Building2, Link2, CornerDownRight } from 'lucide-react'; import { DataTable } from '@/components/ui/data-table'; -import { type Estimate, type Product, quotationStatusLabel, quotationTypeLabel } from '../types'; +import { Typography } from '@/components/ui/typography'; +import { useQuotationChain } from '../hooks/useQuotationChain'; +import { + type Estimate, + type Product, + quotationStatusLabel, + quotationTypeLabel, + CHAIN_ROUND_STATE_LABEL, +} from '../types'; import { QuotationType, QuotationStatus } from '@/api/generated/model'; type QuotationTableProps = { @@ -44,13 +52,15 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo const productName = product?.name ?? est.productName; // 목록에 없으면 서버 조인 상품명으로 폴백 return (
- + {est.title} - - + + 대상 상품: {productName ?? '확인 불가'} (₩{(product?.price ?? 0).toLocaleString()}) - + + {/* 이 견적에서 재생성된 다음 차수(직속 자식)만 행 밑에 표시 — 전체 체인 반복 X */} +
); }, @@ -70,17 +80,19 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo className="inline-flex items-center gap-1 hover:text-primary hover:underline cursor-pointer" > - {est.number} + {est.number} ) : ( - est.number + {est.number} ), }, { header: '유형', align: 'center', cell: (est) => ( - {quotationTypeLabel(est.type)} - + ), }, { header: '차수', align: 'center', - cellClassName: 'font-bold font-mono text-sm', - cell: (est) => `${est.round}차`, + cell: (est) => ( + + {est.round}차 + + ), }, { header: '견적상태', align: 'center', cell: (est) => ( - {quotationStatusLabel(est.status)} - + ), }, { @@ -114,22 +131,63 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo cell: (est) => (
- {est.dueDate} + {est.dueDate}
), }, { header: '생성일', cellClassName: 'font-mono text-muted-foreground whitespace-nowrap', - cell: (est) => est.createdDate ?? '-', + cell: (est) => ( + {est.createdDate ?? '-'} + ), }, { header: '협력사수', align: 'center', cellClassName: 'font-mono font-bold text-foreground', - cell: (est) => `${est.participationCount}개사`, + cell: (est) => ( + {est.participationCount}개사 + ), }, ]} /> ); } +function RegeneratedChild({ + number, + currentQtId, + onOpenRound, +}: { + number?: string | null; + currentQtId?: string; + onOpenRound: (qtId: string) => void; +}) { + const { rounds } = useQuotationChain(number); + const current = rounds.find((r) => r.qt_id === currentQtId); + // rounds 는 round 오름차순 → 현재보다 큰 첫 라운드가 직속 자식. + const child = current ? rounds.find((r) => r.round > current.round) : undefined; + if (!child) return null; + + return ( +
+ + + 재생성됨 + + +
+ ); +} diff --git a/negodata/front/src/lib/utils.ts b/negodata/front/src/lib/utils.ts index bd0c391..e433c61 100644 --- a/negodata/front/src/lib/utils.ts +++ b/negodata/front/src/lib/utils.ts @@ -4,3 +4,10 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +// 문자열에서 금액(원/₩) 표기의 숫자만 '***'로 가린다. 접미사·문장 구조는 유지. +export function maskPrices(text: string): string { + return text + .replace(/₩\s?[0-9][0-9,]*/g, "₩***") + .replace(/[0-9][0-9,]*\s*(?=원)/g, "***") +} diff --git a/negodata/front/src/pages/not-found.tsx b/negodata/front/src/pages/not-found.tsx new file mode 100644 index 0000000..0595c3d --- /dev/null +++ b/negodata/front/src/pages/not-found.tsx @@ -0,0 +1,42 @@ +import {Link, useNavigate, useRouteError} from 'react-router'; +import {FileQuestion} from 'lucide-react'; +import { Typography } from '@/components/ui/typography'; + +// 매칭되는 라우트가 없을 때(catch-all '*') 떨어지는 404 페이지. +// 라우터 errorElement 로도 재사용 가능하도록 useRouteError 는 있으면 참고만 한다(없어도 동작). +export default function NotFoundPage() { + const navigate = useNavigate(); + const error = useRouteError() as {status?: number} | undefined; + const status = error?.status ?? 404; + + return ( +
+
+
+ +
+ {status} · 페이지를 찾을 수 없음 + + 요청하신 주소의 페이지가 존재하지 않거나, 이동·삭제되었습니다. +
+ 주소를 다시 확인해 주세요. +
+
+ + + 상품관리로 돌아가기 + +
+
+
+ ); +} From 7e0f88ca039dbc1356a229f2d69025d7ee1007ee Mon Sep 17 00:00:00 2001 From: hbyang Date: Thu, 25 Jun 2026 17:22:21 +0900 Subject: [PATCH 02/20] =?UTF-8?q?[fix]=20=EA=B2=AC=EC=A0=81=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EB=A7=88=EA=B0=90=20=EB=8F=99=EC=8B=9C=EC=84=B1=C2=B7?= =?UTF-8?q?=EC=A0=95=ED=95=A9=EC=84=B1=20+=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=20=EC=95=88=EC=A0=95=ED=99=94=20(=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=ED=9B=84=EC=86=8D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백엔드 close_and_decide 경로: - 동시 이중 마감 가드: 마감 판정 전 원자적 CLOSED 선점(claim)으로 두 크론 잡·수동마감 경합 직렬화 - 재생성 실패 표면화: regenerate_next_round 결과 검사 → 실패 시 REGEN_FAILED 반환(체인 끊김 은폐 방지) - 차수 충돌 방지: 다음 라운드 = 체인 최신 round+1(chain_max_round 기준) - 재생성 사유 집계 정밀화: 미참여/동가를 양성 표식으로 구분(단독낙찰·거부 오집계 제거) - 재생성 라운드 최소 협상기간 하한(즉시 재마감 캐스케이드 방지) - 잡 루프 per-item 예외 격리(한 건 실패가 배치 전체를 멈추지 않음) 프론트: - useChatController: 무권한 가드를 sessionId 별로 추적해 세션 변경 시 자연 해제 - useScrollLock: 마지막 해제를 rAF 로 지연해 재마운트 사이 일시적 잠금 해제 방지 - quotation 상세 쿼리 placeholderData 로 라운드 전환 중 시트 유지 테스트: - 신규 test_close_and_decide_fixes.py(동시성·차수·집계·기간 하한 검증) - conftest 결함 수정(존재하지 않는 tbl_account TRUNCATE 제거, companies.status 명시) - stale 테스트 갱신(test_quotation_create 를 타입드 Req/신규 응답 형식에 맞게 재작성) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../features/chat/hooks/useChatController.ts | 10 +- .../common/database/db_session_manager.py | 36 ++++ negodata/backend/common/enums.py | 3 +- negodata/backend/conftest.py | 8 +- negodata/backend/crud/quotation_crud.py | 38 +++- negodata/backend/scheduler/jobs.py | 33 +++- .../backend/services/quotation_service.py | 90 +++++++-- .../tests/test_close_and_decide_fixes.py | 178 ++++++++++++++++++ negodata/backend/tests/test_features.py | 17 +- negodata/backend/tests/test_item.py | 7 +- negodata/front/src/lib/useScrollLock.ts | 15 +- negodata/front/src/pages/quotation.tsx | 6 +- 12 files changed, 397 insertions(+), 44 deletions(-) create mode 100644 negodata/backend/tests/test_close_and_decide_fixes.py diff --git a/frontend/src/features/chat/hooks/useChatController.ts b/frontend/src/features/chat/hooks/useChatController.ts index fd18d01..64c4ba8 100644 --- a/frontend/src/features/chat/hooks/useChatController.ts +++ b/frontend/src/features/chat/hooks/useChatController.ts @@ -71,13 +71,15 @@ export function useChatController(sessionId: string) { // 진입 로드 실패. 권한 없음/없는 세션(잘못된 접근)이면 토스트 후 목록으로 복귀시킨다. const loadError = initQuery.error ?? messagesQuery.error const isInvalidAccess = isApiError(loadError) && INVALID_ACCESS_CODES.has(loadError.code) - const redirectedRef = useRef(false) + // 이미 리다이렉트한 sessionId 를 기록(boolean 이 아니라 sessionId). 리마운트 없이 sessionId 가 바뀌면 + // (브라우저 뒤로/앞으로 등) 값이 달라져 가드가 자연 해제 → 두 번째 무권한 세션도 토스트+복귀가 동작한다. + const redirectedSessionRef = useRef(null) useEffect(() => { - if (!isInvalidAccess || redirectedRef.current) return - redirectedRef.current = true + if (!isInvalidAccess || redirectedSessionRef.current === sessionId) return + redirectedSessionRef.current = sessionId toast.error('잘못된 접근입니다.') navigate('/list', { replace: true }) - }, [isInvalidAccess, navigate]) + }, [isInvalidAccess, sessionId, navigate]) // init 메타 → 스토어 useEffect(() => { diff --git a/negodata/backend/common/database/db_session_manager.py b/negodata/backend/common/database/db_session_manager.py index 31d9d2f..c2b95cd 100644 --- a/negodata/backend/common/database/db_session_manager.py +++ b/negodata/backend/common/database/db_session_manager.py @@ -156,6 +156,25 @@ class DBSessionManager(Singleton): raise RuntimeError(err_type.name, err_msg) return err_type + async def add_with_rowcount(self, db: AsyncSession, query, err_msg="DB Operation Failed") -> tuple[ErrorType, int]: + """update/delete 등 비-select 쿼리 실행 후 (ErrorType, 영향행수) 반환. + 조건부 갱신(WHERE 로 상태를 거른 UPDATE)이 실제로 적용됐는지 판별하는 동시처리 가드용.""" + try: + if hasattr(query, "column_descriptions"): + raise RuntimeError("DO NOT USE SELECT QUERY IN DBJOB") + res = await db.execute(query, execution_options=immutabledict({"synchronize_session": "fetch"})) + return ErrorType.SUCCESS, res.rowcount + except IntegrityError as ex: + await db.rollback() + err_type = ErrorType.DB_ALREADY_SAME_KEY + LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}") + return err_type, 0 + except Exception as ex: + await db.rollback() + err_type = ErrorType.DB_RUN_FAILED + LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}") + return err_type, 0 + async def execute(self, db: AsyncSession, query, err_msg="DB Query Execution Failed", raise_error=True) -> tuple[ErrorType, list]: """select 쿼리 실행 후 결과 리스트 반환.""" try: @@ -201,5 +220,22 @@ class DBSessionManager(Singleton): finally: await self.end_session(db_type, DBWRType.DB_WRITE.value) + async def execute_lambda_claim(self, db_type: int, func) -> tuple[ErrorType, int]: + """조건부 변경 쿼리 1건을 한 트랜잭션으로 실행/commit 하고 (ErrorType, 적용행수) 반환. + 동시처리 가드용 — func(session) -> (ErrorType, rowcount). 적용행수 0 이면 다른 호출자가 이미 처리한 것. + (Postgres READ COMMITTED 에서 같은 행 UPDATE 는 행 잠금으로 직렬화되어, 진 호출자는 0 을 받는다.)""" + s = await self.start_session(db_type, DBWRType.DB_WRITE.value) + try: + err_type, rowcount = await func(s) + if err_type != ErrorType.SUCCESS: + return err_type, 0 + commit_err = await self.run(s) + return commit_err, rowcount + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, 0 + finally: + await self.end_session(db_type, DBWRType.DB_WRITE.value) + DB_SESSION_MNG = DBSessionManager() diff --git a/negodata/backend/common/enums.py b/negodata/backend/common/enums.py index 9c1b0f7..3c17d82 100644 --- a/negodata/backend/common/enums.py +++ b/negodata/backend/common/enums.py @@ -148,7 +148,8 @@ class CloseOutcome(Enum): AWARDED = "awarded" # 단독 낙찰 확정 REGENERATED = "regenerated" # 다음 라운드 재생성 - CLOSED = "closed" # 그냥 마감 + CLOSED = "closed" # 그냥 마감 (선점 실패로 이미 닫혀 있던 경우 포함) + REGEN_FAILED = "regen_failed" # 재생성 시도했으나 실패 — 원본은 CLOSED 인데 다음 라운드가 없음(체인 끊김, 모니터링 필요) class ChatSender(CodeEnum): diff --git a/negodata/backend/conftest.py b/negodata/backend/conftest.py index da559b8..abcfb81 100644 --- a/negodata/backend/conftest.py +++ b/negodata/backend/conftest.py @@ -13,6 +13,7 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine from common.database.model.models import MAIN_BASE +from common.enums import CompanyStatus from config.server_configs import main_db_config @@ -50,7 +51,7 @@ async def db_engine(): # negodata 도메인 테이블 전부 비워 격리 (CASCADE: FK 미설정이라 안전망) await conn.execute( text( - "TRUNCATE TABLE tbl_account, users, companies, items, suppliers, " + "TRUNCATE TABLE users, companies, items, suppliers, " "quotation_settings, quotations, sessions RESTART IDENTITY CASCADE" ) ) @@ -65,9 +66,10 @@ async def company_id(db_engine) -> str: """ cid = uuid.uuid4() async with db_engine.begin() as conn: + # status 는 NOT NULL(모델 default 는 ORM 전용이라 raw INSERT 엔 안 먹음) → 명시. await conn.execute( - text("INSERT INTO companies (company_id, name) VALUES (:cid, :name)"), - {"cid": cid, "name": "테스트사"}, + text("INSERT INTO companies (company_id, name, status) VALUES (:cid, :name, :status)"), + {"cid": cid, "name": "테스트사", "status": CompanyStatus.ACTIVE.value}, ) return str(cid) diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py index 9a1dddb..dd9482a 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -97,7 +97,7 @@ class IQuotationCRUD(ABC): pass @abstractmethod - async def list_chain_equal_flags(self, cdb: AsyncSession, number, current_round) -> Tuple[ErrorType, list]: + async def list_chain_close_flags(self, cdb: AsyncSession, number, current_round) -> Tuple[ErrorType, list]: pass @abstractmethod @@ -120,6 +120,10 @@ class IQuotationCRUD(ABC): async def bulk_update_sessions_status(self, cdb: AsyncSession, qt_ids, from_statuses: list[int], to_status: int) -> ErrorType: pass + @abstractmethod + async def claim_for_close(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, int]: + pass + class QuotationCRUD(IQuotationCRUD): async def search( @@ -348,6 +352,26 @@ class QuotationCRUD(IQuotationCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED + async def claim_for_close(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, int]: + """[동시 마감 가드] 아직 안 닫힌(status != CLOSED, not deleted) 견적만 CLOSED 로 선점 전이. + 반환: (ErrorType, 적용행수). 동시 호출 시 Postgres 행 잠금으로 직렬화되어 + 실제로 CLOSED 로 바꾼 호출자만 1, 이미 닫혀 있던(진 호출자/재처리) 경우는 0 을 받는다. + close_and_decide 가 이 결과로 '마감 판정 권한'을 단 한 번만 갖도록 한다.""" + try: + query = ( + update(quotations) + .where( + quotations.qt_id == qt_id, + quotations.status != QuotationStatus.CLOSED.value, + quotations.deleted == False, # noqa: E712 + ) + .values(status=QuotationStatus.CLOSED.value, updated_at=GTime.UTC()) + ) + return await DB_SESSION_MNG.add_with_rowcount(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, 0 + async def update_sessions_status(self, cdb: AsyncSession, qt_id, from_statuses: list[int], to_status: int) -> ErrorType: # 견적에 딸린 세션 중 from_statuses 에 속한 것만 to_status 로 일괄 전이(삭제 제외). 다른 상태는 건드리지 않는다. try: @@ -447,11 +471,15 @@ class QuotationCRUD(IQuotationCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, [] - async def list_chain_equal_flags(self, cdb: AsyncSession, number, current_round) -> Tuple[ErrorType, list]: - """[재생성 한도] 같은 견적번호(체인)의 이전 라운드(round < current_round)들의 equal_bid_yn 목록. 삭제 제외. - True=동가로 닫힌 라운드 / 그 외(False·NULL)=미참여로 닫힌 라운드.""" + async def list_chain_close_flags(self, cdb: AsyncSession, number, current_round) -> Tuple[ErrorType, list]: + """[재생성 한도] 같은 견적번호(체인)의 이전 라운드(round < current_round)들의 (preferred_sp_yn, equal_bid_yn) 목록. 삭제 제외. + 마감 사유 식별용 표식: + - equal_bid_yn=True → 동가 재생성 + - preferred_sp_yn=False AND equal_bid_yn=False → 미참여 재생성 + - preferred_sp_yn=True → 단독낙찰(체인 어느 쪽에도 안 셈) + - 둘 다 NULL → 거부/한도 그냥 마감(안 셈)""" try: - query = select(quotations.equal_bid_yn).where( + query = select(quotations.preferred_sp_yn, quotations.equal_bid_yn).where( quotations.number == number, quotations.round < current_round, quotations.deleted == False, # noqa: E712 diff --git a/negodata/backend/scheduler/jobs.py b/negodata/backend/scheduler/jobs.py index bf104c7..c8b9966 100644 --- a/negodata/backend/scheduler/jobs.py +++ b/negodata/backend/scheduler/jobs.py @@ -15,6 +15,27 @@ from crud.quotation_crud import QuotationCRUD from services.quotation_service import QuotationService +async def _close_each(service: QuotationService, qt_ids) -> Counter: + """대상 견적마다 close_and_decide 를 호출하되, 한 건의 예외가 배치 전체를 멈추지 않도록 격리한다. + (예전 per-item try/continue 보존 — 한 견적의 DB 오류 등으로 나머지 견적이 이번 tick 에서 누락되면 안 됨.) + 반환: 결과(CloseOutcome) 카운트 + 예외 발생 건수('error').""" + results = Counter() + for qt_id in qt_ids: + try: + results[await service.close_and_decide(qt_id)] += 1 + except Exception as ex: + results["error"] += 1 + LOG.e_no_callstack(f"[scheduler] close_and_decide 실패 qt={qt_id}: {ex}") + return results + + +def _format_results(results: Counter) -> str: + return ( + f"낙찰 {results[CloseOutcome.AWARDED]} / 재생성 {results[CloseOutcome.REGENERATED]} / " + f"재생성실패 {results[CloseOutcome.REGEN_FAILED]} / 마감 {results[CloseOutcome.CLOSED]} / 오류 {results['error']}" + ) + + async def close_expired_quotations() -> int: """[잡①] 마감일이 지난 견적을 자동 마감 처리한다. 하루 한 번 실행. 대상: 마감 시각이 이미 지났는데 아직 마감되지 않은(삭제되지도 않은) 견적. @@ -32,11 +53,9 @@ async def close_expired_quotations() -> int: LOG.e_no_callstack(f"[scheduler] close_expired 대상 조회 실패: {err_type.name}") return 0 - results = Counter() - for qt_id in qt_ids: - results[await service.close_and_decide(qt_id)] += 1 + results = await _close_each(service, qt_ids) if results: - LOG.i(f"[scheduler] close_expired: 낙찰 {results[CloseOutcome.AWARDED]} / 재생성 {results[CloseOutcome.REGENERATED]} / 마감 {results[CloseOutcome.CLOSED]}") + LOG.i(f"[scheduler] close_expired: {_format_results(results)}") return sum(results.values()) @@ -56,9 +75,7 @@ async def close_negotiated_quotations() -> int: LOG.e_no_callstack(f"[scheduler] close_negotiated 대상 조회 실패: {err_type.name}") return 0 - results = Counter() - for qt_id in qt_ids: - results[await service.close_and_decide(qt_id)] += 1 + results = await _close_each(service, qt_ids) if results: - LOG.i(f"[scheduler] close_negotiated: 낙찰 {results[CloseOutcome.AWARDED]} / 재생성 {results[CloseOutcome.REGENERATED]} / 마감 {results[CloseOutcome.CLOSED]}") + LOG.i(f"[scheduler] close_negotiated: {_format_results(results)}") return sum(results.values()) diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index 4cf7690..d733d01 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -1,6 +1,6 @@ import re import uuid -from datetime import timezone +from datetime import timezone, timedelta from typing import Optional from fastapi import Depends @@ -8,6 +8,7 @@ from fastapi import Depends from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import quotations, sessions, chats, versions, version_nego_cards, version_wild_cards from common.enums import CloseOutcome, DBWRType, ErrorType, QuotationStatus, QuotationType, SessionStatus +from common.logger import LOG from common.models.gmodel import PageParams from common.utils.gtime import GTime from config.server_configs import web_server_config @@ -43,6 +44,12 @@ class QuotationService: # 재생성 한도: 한 체인(같은 견적번호)에서 사유(미참여/동가)별 최대 1번까지 재생성(순서 무관, 같은 사유 2번 불가). MAX_REGEN_PER_CAUSE = 1 + # 재생성 라운드의 최소 협상기간(방어적 하한). 원본 협상기간이 비정상적으로 짧으면(또는 0/음수면) + # 새 라운드가 생성 즉시 만료돼 다음 크론 tick(*/5분)에 또 마감되는 연쇄를 막는다. + # 정상 견적(수 시간~수일)은 원본 기간을 그대로 쓰며, 이 하한은 비정상적으로 짧은 경우에만 적용된다. + # TODO 하한값 변경 해야함 !!! feat. MarineYang + MIN_REGEN_DURATION = timedelta(hours=1) + def __init__(self, quotation_crud: IQuotationCRUD = Depends(QuotationCRUD)): self.quotation_crud = quotation_crud @@ -190,9 +197,18 @@ class QuotationService: # 3) 다음 라운드의 견적 생성 now = GTime.UTC() - duration = original.end_time - original.start_time - # 진입 경로(크론 마감 / 수동 regenerate_quotation) 모두 '마지막 차수'만 넘기므로 +1 이 곧 체인 다음 차수. - next_round = original.round + 1 + # 원본 협상기간을 이어쓰되, 비정상적으로 짧으면 최소 하한을 적용(즉시 만료→연쇄 재마감 방지). + duration = max(original.end_time - original.start_time, self.MIN_REGEN_DURATION) + # 다음 차수는 '원본 round+1' 이 아니라 '체인(같은 번호) 최신 round+1'. + # 크론 마감과 수동 regenerate_quotation 이 같은 체인을 처리하는 타이밍이 엇갈려도 + # 항상 체인 끝에 이어붙어 uq_quotations_number(number, round) 충돌을 막는다. + _e, chain_max = await DB_SESSION_MNG.execute_lambda( + quotations.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.chain_max_round(s, original.number), + ) + base_round = chain_max if (_e == ErrorType.SUCCESS and chain_max) else original.round + next_round = base_round + 1 # 이름에 '(N차)' 표기. 원래 이름 기준(기존 '(M차)' 표기는 떼고 새로) + name 컬럼 50자 제한 보호. suffix = f" ({next_round}차)" base_name = re.sub(r"\s*\(\d+차\)\s*$", "", original.name or "")[: 50 - len(suffix)] @@ -372,6 +388,26 @@ class QuotationService: ], ) + async def _close_as_no_show(self, qt_uuid) -> None: + """전원 미참여로 '다음 라운드 재생성' 하며 마감 + 미완료 세션 미참여. + 재생성 사유(미참여)를 체인에 남기기 위해 preferred_sp_yn=False, equal_bid_yn=False 로 양성 표식한다 + (단독낙찰=preferred_sp_yn True / 동가=equal_bid_yn True / 거부·한도 등 그냥 마감=둘 다 NULL 과 구분). + _chain_regen_counts 가 이 표식으로 '미참여 재생성 이력'만 정확히 센다.""" + data = { + "status": QuotationStatus.CLOSED.value, + "preferred_sp_yn": False, + "equal_bid_yn": False, + } + await DB_SESSION_MNG.execute_lambda_run( + [quotations.DBType()], + [ + lambda s: self.quotation_crud.update_quotation(s, qt_uuid, data), + lambda s: self.quotation_crud.update_sessions_status( + s, qt_uuid, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value + ), + ], + ) + async def _close_as_equal(self, qt_uuid, equal) -> None: """동가로 마감 + 미완료 세션 미참여. equal_bid_yn/data 를 기록해 둔다 (재생성 한도 계산이 이 플래그로 동가 라운드를 식별하고, 프론트도 동가 정보를 그대로 쓴다).""" @@ -407,6 +443,17 @@ class QuotationService: if err_type != ErrorType.SUCCESS or original is None: return CloseOutcome.CLOSED + # [동시 마감 가드] 마감 판정 전에 원자적으로 status→CLOSED 를 선점한다. + # 두 크론 잡(close_expired / close_negotiated)이나 수동 stop_quotation 이 같은 견적을 + # 동시에 닫으려 해도, 실제로 CLOSED 로 전이한 호출자만 통과하고 진 호출자는 여기서 끝난다 + # → 이중 재생성·uq(number,round) 충돌 방지. (이미 닫힌 견적의 재처리도 여기서 차단) + claim_err, claimed = await DB_SESSION_MNG.execute_lambda_claim( + quotations.DBType(), + lambda s: self.quotation_crud.claim_for_close(s, qt_uuid), + ) + if claim_err != ErrorType.SUCCESS or claimed == 0: + return CloseOutcome.CLOSED + err_type, rows = await DB_SESSION_MNG.execute_lambda( sessions.DBType(), DBWRType.DB_READ.value, @@ -430,7 +477,14 @@ class QuotationService: if equal is not None and equal_used < self.MAX_REGEN_PER_CAUSE: tied_ids = [uuid.UUID(sp["supplier_id"]) for sp in equal["suppliers"]] await self._close_as_equal(qt_uuid, equal) # 동가 기록(equal_bid_yn) 후 마감 - await self.regenerate_next_round(qt_uuid, tied_ids) + regen = await self.regenerate_next_round(qt_uuid, tied_ids) + if not regen.result.success: + # 원본은 이미 CLOSED 인데 다음 라운드 생성이 실패 → 체인이 끊긴 상태. 성공으로 위장하지 않고 드러낸다. + LOG.e_no_callstack( + f"[close] 동가 재생성 실패 qt={qt_uuid} number={original.number} round={original.round} " + f"code={regen.result.code}({regen.result.desc})" + ) + return CloseOutcome.REGEN_FAILED return CloseOutcome.REGENERATED # 3) 협상거부 있음 → 마감만 (재생성 안 함) if has_rejected: @@ -439,26 +493,36 @@ class QuotationService: # 4) 전원 미참여 → 공급사 전체로 다음 라운드 (체인에 미참여 재생성 이력 없을 때만) if not done and rows and no_part_used < self.MAX_REGEN_PER_CAUSE: supplier_ids = list({r.supplier_id for r in rows}) - await self._just_close(qt_uuid) - await self.regenerate_next_round(qt_uuid, supplier_ids) + await self._close_as_no_show(qt_uuid) # 미참여 재생성 표식(preferred_sp_yn=False, equal_bid_yn=False) 후 마감 + regen = await self.regenerate_next_round(qt_uuid, supplier_ids) + if not regen.result.success: + # 원본은 이미 CLOSED 인데 다음 라운드 생성이 실패 → 체인이 끊긴 상태. 성공으로 위장하지 않고 드러낸다. + LOG.e_no_callstack( + f"[close] 미참여 재생성 실패 qt={qt_uuid} number={original.number} round={original.round} " + f"code={regen.result.code}({regen.result.desc})" + ) + return CloseOutcome.REGEN_FAILED return CloseOutcome.REGENERATED # 5) 그 외 / 한도 도달 → 마감만 await self._just_close(qt_uuid) return CloseOutcome.CLOSED async def _chain_regen_counts(self, number: str, current_round: int) -> tuple[int, int]: - """체인(같은 견적번호) 이전 라운드들의 재생성 사유 횟수. 반환: (미참여 횟수, 동가 횟수). - 동가로 닫힌 라운드는 equal_bid_yn=True 로 기록되므로 그 플래그로 센다. - (이전 라운드는 동가 아니면 미참여 둘뿐 — 단독낙찰·거부는 체인을 끝냄 → equal_bid_yn 이 True 아니면 미참여).""" + """체인(같은 견적번호) 이전 라운드들의 '재생성 사유' 횟수. 반환: (미참여 횟수, 동가 횟수). + 마감 시 남긴 양성 표식으로만 센다(오집계 방지): + - 동가 재생성 → equal_bid_yn=True + - 미참여 재생성 → preferred_sp_yn=False AND equal_bid_yn=False + 단독낙찰(preferred_sp_yn=True)·거부/한도 그냥 마감(둘 다 NULL)은 어느 쪽에도 세지 않는다. + (수동 regenerate_quotation 으로 단독낙찰·거부 라운드를 이어붙여도 자동 재생성 한도에 영향 없음.)""" err_type, flags = await DB_SESSION_MNG.execute_lambda( quotations.DBType(), DBWRType.DB_READ.value, - lambda s: self.quotation_crud.list_chain_equal_flags(s, number, current_round), + lambda s: self.quotation_crud.list_chain_close_flags(s, number, current_round), ) if err_type != ErrorType.SUCCESS: return 0, 0 - equal = sum(1 for f in flags if f is True) - no_part = len(flags) - equal + equal = sum(1 for _pref, eq in flags if eq is True) + no_part = sum(1 for pref, eq in flags if pref is False and eq is False) return no_part, equal async def regenerate_quotation(self, qt_id: str, supplier_ids: list) -> Res_CreateQuotation: diff --git a/negodata/backend/tests/test_close_and_decide_fixes.py b/negodata/backend/tests/test_close_and_decide_fixes.py new file mode 100644 index 0000000..d341c42 --- /dev/null +++ b/negodata/backend/tests/test_close_and_decide_fixes.py @@ -0,0 +1,178 @@ +"""close_and_decide 동시성·정합성 수정 검증 (코드리뷰 후속). + +검증 대상: + - #2 동시 이중 마감 가드: 같은 견적을 동시에 close_and_decide 해도 다음 라운드는 1개만 생성 + - #3 차수 매김: 다음 라운드 round = 체인 최신 round + 1 + - #4 재생성 사유 집계: 단독낙찰(preferred_sp_yn=True) 이전 라운드를 '미참여'로 오집계하지 않음 + - #6 재생성 라운드 최소 협상기간 하한(즉시 재마감 캐스케이드 방지) + +실행 전제: tests/test_scheduler.py 와 동일(PostgreSQL, APP_ENV=test). +""" +import asyncio +import uuid +from datetime import datetime, timedelta + +import pytest_asyncio +from sqlalchemy import text + +from common.enums import CloseOutcome, QuotationStatus, QuotationType, SessionStatus +from crud.quotation_crud import QuotationCRUD +from services.quotation_service import QuotationService + +PAST = datetime(2020, 1, 1) + + +@pytest_asyncio.fixture +async def clean(db_engine): + async with db_engine.begin() as conn: + await conn.execute(text("TRUNCATE TABLE sessions, quotations RESTART IDENTITY CASCADE")) + return db_engine + + +async def _seed_quotation( + engine, *, number, round_, status, start_time=PAST, end_time=PAST, + preferred_sp_yn=None, equal_bid_yn=None, +): + qt_id = uuid.uuid4() + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO quotations " + "(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, " + " round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn) VALUES " + "(:qt_id, :user_id, :qt_setting_id, :version_id, :name, :number, :type, :status, " + " :round, 0, :start_time, :end_time, false, :pref, :eq)" + ), + { + "qt_id": qt_id, "user_id": uuid.uuid4(), "qt_setting_id": uuid.uuid4(), + "version_id": uuid.uuid4(), "name": "견적", "number": number, + "type": QuotationType.REQUOTE.value, "status": status, "round": round_, + "start_time": start_time, "end_time": end_time, + "pref": preferred_sp_yn, "eq": equal_bid_yn, + }, + ) + return qt_id + + +async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None): + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO sessions " + "(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, " + " target_price, status, bid_price, end_time) VALUES " + "(:session_id, :quotation_id, :item_id, :supplier_id, :qt_number, :qt_round, :qt_type, " + " 0, :status, :bid_price, :end_time)" + ), + { + "session_id": uuid.uuid4(), "quotation_id": qt_id, "item_id": uuid.uuid4(), + "supplier_id": supplier_id or uuid.uuid4(), "qt_number": "Q", "qt_round": 1, + "qt_type": QuotationType.REQUOTE.value, "status": status, + "bid_price": bid_price, "end_time": PAST, + }, + ) + + +async def _rounds(engine, number): + """체인(number)의 (round, status, end_time, start_time) 목록 — round 오름차순.""" + async with engine.begin() as conn: + return (await conn.execute( + text("SELECT round, status, start_time, end_time FROM quotations " + "WHERE number = :n ORDER BY round"), + {"n": number}, + )).all() + + +# ----- #2 동시 이중 마감 가드 ----- +async def test_concurrent_close_creates_only_one_next_round(clean): + """같은 견적을 5번 동시에 close_and_decide 해도 다음 라운드는 정확히 1개만 생성된다.""" + engine = clean + number = "C-CONCURRENT" + qt = await _seed_quotation(engine, number=number, round_=1, status=QuotationStatus.ACTIVE.value) + # 전원 미참여(미시작 세션만) → close_and_decide 가 '다음 라운드 재생성' 경로를 탄다 + await _add_session(engine, qt, status=SessionStatus.CREATED.value) + await _add_session(engine, qt, status=SessionStatus.CREATED.value) + + service = QuotationService(QuotationCRUD()) + outcomes = await asyncio.gather(*[service.close_and_decide(qt) for _ in range(5)]) + + regenerated = sum(1 for o in outcomes if o == CloseOutcome.REGENERATED) + rounds = await _rounds(engine, number) + round_numbers = [r.round for r in rounds] + + assert regenerated == 1, f"재생성은 1번만 일어나야 함, 실제 {regenerated} ({outcomes})" + assert round_numbers == [1, 2], f"체인은 [1,2] 여야 함(중복/충돌 없음), 실제 {round_numbers}" + + +# ----- #3 차수 + #6 최소 협상기간 하한 ----- +async def test_next_round_numbering_and_min_duration(clean): + """다음 라운드 round = 최신+1, 협상기간이 0이어도 최소 하한(MIN_REGEN_DURATION)이 적용된다.""" + engine = clean + number = "C-DURATION" + # start==end (협상기간 0) → 하한이 적용되지 않으면 새 라운드도 0 길이가 된다 + qt = await _seed_quotation( + engine, number=number, round_=1, status=QuotationStatus.ACTIVE.value, + start_time=PAST, end_time=PAST, + ) + await _add_session(engine, qt, status=SessionStatus.CREATED.value) + + service = QuotationService(QuotationCRUD()) + outcome = await service.close_and_decide(qt) + + assert outcome == CloseOutcome.REGENERATED + rounds = await _rounds(engine, number) + assert [r.round for r in rounds] == [1, 2] + nxt = rounds[1] + duration = nxt.end_time - nxt.start_time + assert duration >= QuotationService.MIN_REGEN_DURATION, ( + f"재생성 라운드 협상기간({duration})이 최소 하한({QuotationService.MIN_REGEN_DURATION}) 이상이어야 함" + ) + + +# ----- #4 재생성 사유 집계: 단독낙찰 이전 라운드를 미참여로 오집계하지 않음 ----- +async def test_awarded_prior_round_not_counted_as_no_show(clean): + """체인에 '단독낙찰'(preferred_sp_yn=True) 이전 라운드가 있어도, 이후 라운드의 미참여 재생성 예산을 소진하지 않는다. + (구버전: equal_bid_yn=False 인 단독낙찰 라운드를 미참여로 세어 round2 재생성이 막혔다.)""" + engine = clean + number = "C-AWARDED-PRIOR" + # round 1: 단독낙찰로 마감(preferred_sp_yn=True). 수동 재생성 등으로 체인이 이어진 상황을 가정. + await _seed_quotation( + engine, number=number, round_=1, status=QuotationStatus.CLOSED.value, + preferred_sp_yn=True, equal_bid_yn=False, + ) + # round 2: 전원 미참여 → 미참여 재생성이 일어나야 한다(round 1 은 미참여로 세면 안 됨) + qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.ACTIVE.value) + await _add_session(engine, qt2, status=SessionStatus.CREATED.value) + + service = QuotationService(QuotationCRUD()) + outcome = await service.close_and_decide(qt2) + + rounds = await _rounds(engine, number) + round_numbers = [r.round for r in rounds] + assert outcome == CloseOutcome.REGENERATED, ( + f"단독낙찰 이전 라운드는 미참여 예산을 소진하지 않아 round2 가 재생성돼야 함, 실제 {outcome}" + ) + assert round_numbers == [1, 2, 3], f"round 3 이 생성돼야 함, 실제 {round_numbers}" + + +# ----- #4 대비: 실제 미참여 이전 라운드는 예산을 소진(한도 1) ----- +async def test_no_show_prior_round_consumes_budget(clean): + """이전 라운드가 '미참여 재생성'(preferred_sp_yn=False, equal_bid_yn=False)이면 예산(1)을 소진 → + 다음 라운드의 미참여는 재생성 없이 그냥 마감된다.""" + engine = clean + number = "C-NOSHOW-PRIOR" + # round 1: 미참여로 마감(양성 표식) → no_part 예산 1 소진 + await _seed_quotation( + engine, number=number, round_=1, status=QuotationStatus.CLOSED.value, + preferred_sp_yn=False, equal_bid_yn=False, + ) + # round 2: 또 전원 미참여 → 한도 도달이라 재생성 없이 그냥 마감 + qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.ACTIVE.value) + await _add_session(engine, qt2, status=SessionStatus.CREATED.value) + + service = QuotationService(QuotationCRUD()) + outcome = await service.close_and_decide(qt2) + + rounds = await _rounds(engine, number) + assert outcome == CloseOutcome.CLOSED, f"미참여 예산 소진 → 그냥 마감이어야 함, 실제 {outcome}" + assert [r.round for r in rounds] == [1, 2], "재생성되면 안 됨(round 3 없음)" diff --git a/negodata/backend/tests/test_features.py b/negodata/backend/tests/test_features.py index 05eb5ca..75792ca 100644 --- a/negodata/backend/tests/test_features.py +++ b/negodata/backend/tests/test_features.py @@ -4,6 +4,8 @@ create(재조회로 created_at 적재) + list + get 경로를 라이브 DB 로 """ import uuid +from common.enums import QuotationStatus, QuotationType + async def _headers(client, company_id, login_id): await client.post( @@ -47,22 +49,27 @@ async def test_quotation_setting_crud(client, company_id): async def test_quotation_create(client, company_id): h = await _headers(client, company_id, "qtuser") + # type/status 는 int 코드(QuotationType/QuotationStatus). number 는 서버가 생성하므로 미전송. body = { "qt_setting_id": str(uuid.uuid4()), "version_id": str(uuid.uuid4()), "name": "견적A", - "number": "Q-001", - "type": "재견적", - "status": "진행중", + "type": QuotationType.REQUOTE.value, + "status": QuotationStatus.ACTIVE.value, "start_time": "2026-06-16T00:00:00", "end_time": "2026-06-17T00:00:00", } r = await client.post("/v1/quotation/create", json=body, headers=h) res = r.json() + # 생성 응답은 본문(quotation)을 안 주고 qt_id/session_count 만 반환 → qt_id 로 재조회한다. assert res["result"]["success"] is True - q = res["quotation"] + qt_id = res["qt_id"] + assert qt_id + + r = await client.get(f"/v1/quotation/{qt_id}", headers=h) + q = r.json()["quotation"] assert q["name"] == "견적A" - assert q["created_at"] # 재조회 픽스 + assert q["created_at"] # 재조회로 created_at 적재 확인 r = await client.get("/v1/quotation/list", headers=h) assert r.json()["total"] >= 1 diff --git a/negodata/backend/tests/test_item.py b/negodata/backend/tests/test_item.py index c8c1cf8..619f98d 100644 --- a/negodata/backend/tests/test_item.py +++ b/negodata/backend/tests/test_item.py @@ -7,6 +7,8 @@ import uuid import pytest_asyncio from sqlalchemy import text +from common.enums import CompanyStatus + async def _headers(client, company_id, login_id="itemuser", pw="pw1234"): await client.post( @@ -21,9 +23,10 @@ async def _headers(client, company_id, login_id="itemuser", pw="pw1234"): async def other_company_id(db_engine) -> str: cid = uuid.uuid4() async with db_engine.begin() as conn: + # status 는 NOT NULL(모델 default 는 ORM 전용이라 raw INSERT 엔 안 먹음) → 명시. await conn.execute( - text("INSERT INTO companies (company_id, name) VALUES (:cid, :name)"), - {"cid": cid, "name": "다른회사"}, + text("INSERT INTO companies (company_id, name, status) VALUES (:cid, :name, :status)"), + {"cid": cid, "name": "다른회사", "status": CompanyStatus.ACTIVE.value}, ) return str(cid) diff --git a/negodata/front/src/lib/useScrollLock.ts b/negodata/front/src/lib/useScrollLock.ts index bbff9f6..da1e6c7 100644 --- a/negodata/front/src/lib/useScrollLock.ts +++ b/negodata/front/src/lib/useScrollLock.ts @@ -6,6 +6,7 @@ import { useEffect } from 'react'; // 중첩 오버레이 대비 카운터로 관리 — 마지막 하나가 닫힐 때만 원복. let lockCount = 0; let prevOverflow = ''; +let pendingRestore = 0; // requestAnimationFrame id (0 = 없음) function scroller(): HTMLElement { return (document.scrollingElement as HTMLElement | null) ?? document.documentElement; @@ -14,7 +15,13 @@ function scroller(): HTMLElement { export function useScrollLock(active = true) { useEffect(() => { if (!active) return; - if (lockCount === 0) { + if (pendingRestore !== 0) { + // 직전 해제로 예약된 복원이 남아 있으면 취소 — 언마운트→재마운트(라운드 전환 등) 사이의 + // 일시적 0 을 흡수한다. 이때 overflow 는 아직 'hidden' 이고 prevOverflow 도 원래 값 그대로다. + cancelAnimationFrame(pendingRestore); + pendingRestore = 0; + } else if (lockCount === 0) { + // 진짜 첫 잠금일 때만 원래 overflow 를 보관하고 잠근다('hidden' 을 prevOverflow 로 캡처하는 사고 방지). const el = scroller(); prevOverflow = el.style.overflow; el.style.overflow = 'hidden'; @@ -23,7 +30,11 @@ export function useScrollLock(active = true) { return () => { lockCount -= 1; if (lockCount === 0) { - scroller().style.overflow = prevOverflow; + // 마지막 해제는 다음 프레임으로 미룬다 — 곧바로 새 잠금이 들어오면(재마운트) 위에서 취소된다. + pendingRestore = requestAnimationFrame(() => { + pendingRestore = 0; + if (lockCount === 0) scroller().style.overflow = prevOverflow; + }); } }; }, [active]); diff --git a/negodata/front/src/pages/quotation.tsx b/negodata/front/src/pages/quotation.tsx index 4e64f69..f6a103b 100644 --- a/negodata/front/src/pages/quotation.tsx +++ b/negodata/front/src/pages/quotation.tsx @@ -1,4 +1,5 @@ import { Settings, Plus } from 'lucide-react'; +import { keepPreviousData } from '@tanstack/react-query'; import { useOverlayRouter } from '@/lib/useOverlayRouter'; import { PageContainer } from '@/components/layout/PageContainer'; import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar'; @@ -51,8 +52,11 @@ export default function QuotationPage() { // 상세 요약은 리스트에서 find 하지 않고 단건 API 로 받아온다(딥링크 시 리스트 의존 제거). // 탭 복귀 시 재조회(자리비운 사이 스케줄러가 마감/낙찰/재생성했을 수 있음). 전역 기본은 false라 상세만 켠다. + // 라운드 전환(detailId 교체) 시 새 데이터 도착 전까지 이전 견적을 유지한다. + // 이렇게 해야 activeQuotation 이 잠시 null 로 떨어지지 않아 시트(key={qt_id})가 언마운트→재마운트되지 않고, + // 그 사이 useScrollLock 이 풀려 배경이 스크롤되는 현상도 사라진다. (목록 등 다른 쿼리와 동일한 패턴) const detailQuery = useGetQuotation(detailId ?? '', { - query: { enabled: !!detailId, refetchOnWindowFocus: true }, + query: { enabled: !!detailId, refetchOnWindowFocus: true, placeholderData: keepPreviousData }, }); const activeQuotation = detailQuery.data?.quotation ?? null; From 0c6a002e298524209f1a46624b031bab6e40f82f Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Fri, 26 Jun 2026 15:06:37 +0900 Subject: [PATCH 03/20] =?UTF-8?q?[fix]=20negodata/backend:=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=20=EC=BB=AC=EB=9F=BC=20timestamptz=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20+=20naive=20=EB=B3=80=ED=99=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - models.py: created_at/updated_at/last_accessed_at/start_time/end_time/bid_at DateTime → DateTime(timezone=True) - quotation_service: tz-aware 입력을 naive 로 깎던 _naive_utc 제거(컬럼이 tz-aware라 불필요), start_time/end_time 직접 사용. negosium_db 는 UTC 고정. Co-Authored-By: Claude Opus 4.8 (1M context) --- negodata/backend/common/database/model/models.py | 14 +++++++------- negodata/backend/services/quotation_service.py | 13 ++----------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index c3c9e31..d0cf970 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -27,8 +27,8 @@ class _DBTypeMixin: # ERD 공통 컬럼 class MainTableMixin(_DBTypeMixin): - created_at = Column(DateTime, nullable=False, server_default=_utc_now_sql()) - updated_at = Column(DateTime, nullable=False, server_default=_utc_now_sql(), onupdate=_utc_now_sql()) + created_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql()) + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql(), onupdate=_utc_now_sql()) deleted = Column(Boolean, nullable=False, server_default=text("false"), default=False) @@ -60,7 +60,7 @@ class users(MainTableMixin, MAIN_BASE): name = Column(String(50), nullable=True) email = Column(String(255), nullable=True) contact_number = Column(String(20), nullable=True) - last_accessed_at = Column(DateTime, nullable=False, server_default=_utc_now_sql()) + last_accessed_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql()) status = Column(SmallInteger, nullable=False, default=UserStatus.ACTIVE.value) role = Column(SmallInteger, nullable=False, default=UserRole.USER.value) @@ -191,8 +191,8 @@ class quotations(MainTableMixin, MAIN_BASE): type = Column(SmallInteger, nullable=False) # QuotationType: 1=renego(1:1) / 2=requote(1:N) round = Column(Integer, nullable=False, default=1) # 재견적 진행 시 증가 status = Column(SmallInteger, nullable=False) # 진행 상태 코드 - start_time = Column(DateTime, nullable=False) - end_time = Column(DateTime, nullable=False) + start_time = Column(DateTime(timezone=True), nullable=False) + end_time = Column(DateTime(timezone=True), nullable=False) manager_name = Column(String(50), nullable=True) manager_email = Column(String(255), nullable=True) @@ -222,8 +222,8 @@ class sessions(MainTableMixin, MAIN_BASE): target_price = Column(BigInteger, nullable=False) # 목표가(원) status = Column(SmallInteger, nullable=False) # SessionStatus 코드 bid_price = Column(BigInteger, nullable=True) # 입찰가(원) - bid_at = Column(DateTime, nullable=True) # 입찰 시각 - end_time = Column(DateTime, nullable=False) # 세션 종료 시각 + bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각 + end_time = Column(DateTime(timezone=True), nullable=False) # 세션 종료 시각 reject_reason = Column(String(255), nullable=True) reject_price = Column(BigInteger, nullable=True) reject_delivery_type = Column(SmallInteger, nullable=True) # DeliveryType 코드 diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index d733d01..d5aa41d 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -68,15 +68,6 @@ class QuotationService: return int(int(price) / (1 + margin)) return int(price) - @staticmethod - def _naive_utc(dt): - """DB 컬럼이 naive(TIMESTAMP WITHOUT TIME ZONE)라, tz-aware 입력(프론트 toISOString 등)은 UTC naive 로 변환.""" - if dt is None: - return dt - if getattr(dt, "tzinfo", None) is not None: - return dt.astimezone(timezone.utc).replace(tzinfo=None) - return dt - @staticmethod def _gen_number() -> str: """견적번호 자동 생성(미지정 시). EST-YYYYMM-XXXX.""" @@ -156,8 +147,8 @@ class QuotationService: type_=req.type, status=req.status or QuotationStatus.CREATED.value, round_=req.round or 1, - start_time=self._naive_utc(req.start_time or GTime.UTC()), - end_time=self._naive_utc(req.end_time), + start_time=req.start_time or GTime.UTC(), + end_time=req.end_time, manager_name=req.manager_name, manager_email=req.manager_email, manager_contact_number=req.manager_contact_number, From 21e9f71f53525423aaf2f1c40e9418f25062b8e5 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Fri, 26 Jun 2026 17:08:43 +0900 Subject: [PATCH 04/20] =?UTF-8?q?[feat]=20negodata/front:=20=EC=83=81?= =?UTF-8?q?=ED=92=88=C2=B7=ED=98=91=EB=A0=A5=EC=82=AC=20=EC=97=91=EC=85=80?= =?UTF-8?q?=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=96=91=EC=8B=9D=20=EC=A0=95?= =?UTF-8?q?=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 업로드 모달/툴바를 드롭다운(일괄 업로드 + 양식 다운로드)으로 정리, UPLOAD_COLUMNS 단일 정의 공유, CSV 템플릿(헤더+예시행) 추가. dropdown-menu UI 추가. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../front/src/components/ui/dropdown-menu.tsx | 55 +++++++++++++++++++ .../partners/components/ExcelUploadModal.tsx | 42 ++++++-------- .../products/components/ExcelUploadModal.tsx | 44 +++++++-------- negodata/front/src/pages/partners.tsx | 26 +++++++-- negodata/front/src/pages/products.tsx | 26 +++++++-- 5 files changed, 132 insertions(+), 61 deletions(-) create mode 100644 negodata/front/src/components/ui/dropdown-menu.tsx diff --git a/negodata/front/src/components/ui/dropdown-menu.tsx b/negodata/front/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..9379dde --- /dev/null +++ b/negodata/front/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,55 @@ +"use client" + +import { Menu as MenuPrimitive } from "@base-ui/react/menu" + +import { cn } from "@/lib/utils" + +// shadcn 스타일 DropdownMenu(@base-ui Menu 기반). 액션 묶음용 — Select 와 달리 값 선택이 아니라 명령 실행. +// 사용: }>... +// ... + +function DropdownMenu(props: MenuPrimitive.Root.Props) { + return +} + +function DropdownMenuTrigger(props: MenuPrimitive.Trigger.Props) { + return +} + +function DropdownMenuContent({ + className, + children, + ...props +}: MenuPrimitive.Popup.Props) { + return ( + + + + {children} + + + + ) +} + +function DropdownMenuItem({ className, ...props }: MenuPrimitive.Item.Props) { + return ( + + ) +} + +export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } diff --git a/negodata/front/src/features/partners/components/ExcelUploadModal.tsx b/negodata/front/src/features/partners/components/ExcelUploadModal.tsx index 541d027..58b6b03 100644 --- a/negodata/front/src/features/partners/components/ExcelUploadModal.tsx +++ b/negodata/front/src/features/partners/components/ExcelUploadModal.tsx @@ -1,5 +1,5 @@ import { useMemo, useRef, useState } from 'react'; -import { Upload, X, FileSpreadsheet, CheckCircle2, Download, Trash2 } from 'lucide-react'; +import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react'; import type { ReqCreateSupplier as SupplierCreate } from '@/api/generated/model/reqCreateSupplier'; import { showToast } from '@/lib/notify'; import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel'; @@ -72,6 +72,21 @@ function toSupplierCreate(row: RawRow): SupplierCreate { }; } +// 업로드 양식(.csv) 다운로드 — 채워 넣을 컬럼 헤더 + 예시 1행. 툴바·모달이 공유한다. +export function downloadPartnerTemplate() { + downloadExcel( + '협력사_업로드_양식', + [ + { header: '협력사명', value: (r) => r.name }, + { header: '식별코드', value: (r) => r.code }, + { header: '담당자명', value: (r) => r.managerName }, + { header: '담당자이메일', value: (r) => r.managerEmail }, + { header: '우선순위', value: (r) => r.priority }, + ], + [{ name: '예시) (주)한빛정밀', code: 'PART-EXAMPLE-001', managerName: '김철수 과장', managerEmail: 'cs.kim@example.com', priority: 'HIGH' }], + ); +} + // 협력사 엑셀 일괄 업로드 모달. 파일 파싱·원본 행 state는 이 컴포넌트가 소유하고, // 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임. export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUploadModalProps) { @@ -98,21 +113,6 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp onClose(); }; - // 업로드 양식(.csv) 다운로드 — 채워 넣을 컬럼 헤더 + 예시 1행 (lib/excel 재사용) - const handleDownloadTemplate = () => { - downloadExcel( - '협력사_업로드_양식', - [ - { header: '협력사명', value: (r) => r.name }, - { header: '식별코드', value: (r) => r.code }, - { header: '담당자명', value: (r) => r.managerName }, - { header: '담당자이메일', value: (r) => r.managerEmail }, - { header: '우선순위', value: (r) => r.priority }, - ], - [{ name: '예시) (주)한빛정밀', code: 'PART-EXAMPLE-001', managerName: '김철수 과장', managerEmail: 'cs.kim@example.com', priority: 'HIGH' }], - ); - }; - // 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생). 헤더는 양식과 동일해야 함. const handleFile = async (file: File) => { const parsed = parseCsv(await file.text()); @@ -243,16 +243,6 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp > 엑셀 파일 업로드하기 - -
) : (
diff --git a/negodata/front/src/features/products/components/ExcelUploadModal.tsx b/negodata/front/src/features/products/components/ExcelUploadModal.tsx index de687cf..c208868 100644 --- a/negodata/front/src/features/products/components/ExcelUploadModal.tsx +++ b/negodata/front/src/features/products/components/ExcelUploadModal.tsx @@ -1,5 +1,5 @@ import { useMemo, useRef, useState } from 'react'; -import { Upload, X, FileSpreadsheet, CheckCircle2, Download, Trash2 } from 'lucide-react'; +import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react'; import type { ReqCreateItem as ItemCreate } from '@/api/generated/model/reqCreateItem'; import { showToast } from '@/lib/notify'; import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel'; @@ -22,6 +22,8 @@ type RawRow = { made_in: string; price: number; minPrice: number; + purchase_price: number; + selling_price: number; image_url: string; moq: string; lead_time: number; @@ -59,6 +61,8 @@ const UPLOAD_COLUMNS: { header: string; key: keyof RawRow }[] = [ { header: '원산지', key: 'made_in' }, { header: '상품 단가', key: 'price' }, { header: '최저한도', key: 'minPrice' }, + { header: '매입가', key: 'purchase_price' }, + { header: '판매가', key: 'selling_price' }, { header: '이미지URL', key: 'image_url' }, { header: '최소주문수량', key: 'moq' }, { header: '리드타임(일)', key: 'lead_time' }, @@ -69,7 +73,7 @@ const UPLOAD_COLUMNS: { header: string; key: keyof RawRow }[] = [ ]; // 숫자 입력 컬럼 / 필수 컬럼(헤더에 * 표기) -const NUMERIC_KEYS = new Set(['price', 'minPrice', 'lead_time']); +const NUMERIC_KEYS = new Set(['price', 'minPrice', 'purchase_price', 'selling_price', 'lead_time']); const REQUIRED_KEYS = new Set(['name', 'code', 'price']); // 양식에 채워 넣는 예시 행(시드 상품과 동일 셋). 다운로드 양식에 그대로 들어간다. @@ -77,19 +81,28 @@ const EXAMPLE_ROWS: Record[] = [ { name: '리튬인산철 배터리 모듈', code: 'BAT-LFP-100', model_name: 'LFP-100A', category: '에너지/배터리', spec: '3.2V 100Ah', manufacturer: '한성에너지', made_in: '대한민국', - price: 1250000, minPrice: 1037500, image_url: 'https://example.com/img/lfp-100a.jpg', + price: 1250000, minPrice: 1037500, purchase_price: 1000000, selling_price: 1250000, image_url: 'https://example.com/img/lfp-100a.jpg', moq: '10 EA', lead_time: 14, quantity_unit: 'EA', delivery_type: '협력사배송', vat_yn: 'Y', delivery_fee_yn: 'N', }, { name: '산업용 6축 로봇암', code: 'ROB-6AX-22', model_name: 'RX-6A', category: '자동화설비', spec: '가반하중 12kg', manufacturer: '오토메카', made_in: '일본', - price: 18900000, minPrice: 16065000, image_url: 'https://example.com/img/rx-6a.jpg', + price: 18900000, minPrice: 16065000, purchase_price: 15000000, selling_price: 18900000, image_url: 'https://example.com/img/rx-6a.jpg', moq: '1 EA', lead_time: 30, quantity_unit: 'EA', delivery_type: '지정택배배송', vat_yn: 'Y', delivery_fee_yn: 'N', }, ]; +// 업로드 양식(.csv) 다운로드 — 전체 컬럼 헤더 + 예시 행(시드 상품 셋). UPLOAD_COLUMNS 단일 정의 공유. 툴바·모달이 공유한다. +export function downloadProductTemplate() { + downloadExcel>( + '상품_업로드_양식', + UPLOAD_COLUMNS.map((c) => ({ header: c.header, value: (r) => r[c.key] })), + EXAMPLE_ROWS, + ); +} + type ExcelUploadModalProps = { open: boolean; products: Product[]; // 코드 중복 검사용 @@ -149,6 +162,8 @@ function toItemCreate(row: RawRow): ItemCreate { manufacturer: row.manufacturer || undefined, made_in: row.made_in || undefined, price: row.price, + purchase_price: row.purchase_price || undefined, + selling_price: row.selling_price || undefined, image_url: row.image_url || undefined, moq: row.moq || undefined, lead_time: row.lead_time || undefined, @@ -187,15 +202,6 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp onClose(); }; - // 업로드 양식(.csv) 다운로드 — 전체 컬럼 헤더 + 예시 행(시드 상품 셋). UPLOAD_COLUMNS 단일 정의 공유. - const handleDownloadTemplate = () => { - downloadExcel>( - '상품_업로드_양식', - UPLOAD_COLUMNS.map((c) => ({ header: c.header, value: (r) => r[c.key] })), - EXAMPLE_ROWS, - ); - }; - // 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생). 헤더는 양식과 동일해야 함. const handleFile = async (file: File) => { const parsed = parseCsv(await file.text()); @@ -211,6 +217,8 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp made_in: r['원산지'] ?? '', price: Number(r['상품 단가']) || 0, minPrice: Number(r['최저한도']) || 0, + purchase_price: Number(r['매입가']) || 0, + selling_price: Number(r['판매가']) || 0, image_url: r['이미지URL'] ?? '', moq: r['최소주문수량'] ?? '', lead_time: Number(r['리드타임(일)']) || 0, @@ -345,16 +353,6 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp > 엑셀 파일 업로드하기 - -
) : (
diff --git a/negodata/front/src/pages/partners.tsx b/negodata/front/src/pages/partners.tsx index a2a8a80..013ddf1 100644 --- a/negodata/front/src/pages/partners.tsx +++ b/negodata/front/src/pages/partners.tsx @@ -1,4 +1,4 @@ -import { Plus, Upload } from 'lucide-react'; +import { Plus, Upload, Download, FileSpreadsheet, ChevronDown } from 'lucide-react'; import { useOverlayRouter } from '@/lib/useOverlayRouter'; import { showToast } from '@/lib/notify'; import { confirm } from '@/lib/confirm'; @@ -6,12 +6,13 @@ import { PageContainer } from '@/components/layout/PageContainer'; import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Button } from '@/components/ui/button'; +import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; import { useServerList } from '@/lib/useServerList'; import { usePartners } from '@/features/partners/hooks/usePartners'; import type { ListSuppliersParams } from '@/api/generated/model/listSuppliersParams'; import { PartnerTable } from '@/features/partners/components/PartnerTable'; import { PartnerFormSheet } from '@/features/partners/components/PartnerFormSheet'; -import { ExcelUploadModal } from '@/features/partners/components/ExcelUploadModal'; +import { ExcelUploadModal, downloadPartnerTemplate } from '@/features/partners/components/ExcelUploadModal'; import { prioritiesList, type Partner } from '@/features/partners/types'; export default function PartnersPage() { @@ -57,10 +58,23 @@ export default function PartnersPage() { - + + }> + + 엑셀업로드 + + + + overlay.open('modal', 'excel')}> + + 일괄 업로드 + + + + 양식 다운로드 + + + - + + }> + + 엑셀업로드 + + + + overlay.open('modal', 'excel')}> + + 일괄 업로드 + + + + 양식 다운로드 + + +
+ {/* 카드 용도(usage_type): 공통 / 신규전용 / 재전용 */} +
+ 카드 용도 + ( + + )} + /> +
+ {/* Title */}
카드이름 diff --git a/negodata/front/src/features/cards/hooks/useCards.ts b/negodata/front/src/features/cards/hooks/useCards.ts index 3afbc4a..e2455f5 100644 --- a/negodata/front/src/features/cards/hooks/useCards.ts +++ b/negodata/front/src/features/cards/hooks/useCards.ts @@ -21,6 +21,7 @@ export type CardInput = { editorScript: Descendant[]; status: 'ACTIVE' | 'INACTIVE'; isWildcard: boolean; + usageType: number; // usage_type(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용 triggerCondition?: string; memo?: string; }; @@ -36,6 +37,7 @@ function cardError(res: ResCard): string | null { function toReq(input: CardInput): ReqCreateCard { return { is_wildcard: input.isWildcard, + usage_type: input.usageType, name: input.title, number: input.code, script: serializeToText(input.editorScript), // 평문 미리보기({변수} 토큰 포함) diff --git a/negodata/front/src/features/cards/types.ts b/negodata/front/src/features/cards/types.ts index 19556c4..c885635 100644 --- a/negodata/front/src/features/cards/types.ts +++ b/negodata/front/src/features/cards/types.ts @@ -1,6 +1,6 @@ import type { NegotiationCard } from '@/types'; import type { CardData } from '@/api/generated/model/cardData'; -import { CardStatus } from '@/api/generated/model'; +import { CardStatus, CardUsageType } from '@/api/generated/model'; export type { NegotiationCard }; @@ -17,6 +17,7 @@ export function mapCardData(c: CardData): NegotiationCard { return { id: c.nego_card_id, isWildcard: c.is_wildcard ?? false, + usageType: c.usage_type ?? CardUsageType.COMMON, code: c.number ?? '', title: c.name ?? '', scriptPreview: c.script ?? '', diff --git a/negodata/front/src/features/products/components/ProductFormSheet.tsx b/negodata/front/src/features/products/components/ProductFormSheet.tsx index 26b1746..3556da7 100644 --- a/negodata/front/src/features/products/components/ProductFormSheet.tsx +++ b/negodata/front/src/features/products/components/ProductFormSheet.tsx @@ -33,6 +33,8 @@ const schema = z.object({ vatYn: z.boolean(), deliveryFeeYn: z.boolean(), internetLowestPriceYn: z.boolean(), + purchasePrice: z.number({ message: '숫자를 입력해 주십시오.' }).min(0, '매입가는 0 이상이어야 합니다.'), + sellingPrice: z.number({ message: '숫자를 입력해 주십시오.' }).min(0, '판매가는 0 이상이어야 합니다.'), }); type FormValues = z.infer; @@ -71,6 +73,8 @@ function buildDefaults(mode: 'create' | 'edit', product: Product | null): FormVa vatYn: product.vat_yn !== false, deliveryFeeYn: product.delivery_fee_yn || false, internetLowestPriceYn: product.internet_lowest_price_yn || false, + purchasePrice: product.purchase_price || 0, + sellingPrice: product.selling_price || 0, }; } return { @@ -91,6 +95,8 @@ function buildDefaults(mode: 'create' | 'edit', product: Product | null): FormVa vatYn: true, deliveryFeeYn: false, internetLowestPriceYn: true, + purchasePrice: 0, + sellingPrice: 0, }; } @@ -145,6 +151,8 @@ export function ProductFormSheet({ vat_yn: v.vatYn, delivery_fee_yn: v.deliveryFeeYn, internet_lowest_price_yn: v.internetLowestPriceYn, + purchase_price: v.purchasePrice, + selling_price: v.sellingPrice, }; if (mode === 'create') { @@ -262,6 +270,33 @@ export function ProductFormSheet({
+
+ {/* 매입가 */} +
+ 매입가 (₩) + + {errors.purchasePrice &&

{errors.purchasePrice.message}

} +
+ {/* 판매가 */} +
+ 판매가 (₩) + + {errors.sellingPrice &&

{errors.sellingPrice.message}

} +
+
+
{/* Model Name */}
diff --git a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx index aacfa10..9dcc606 100644 --- a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx @@ -1,5 +1,7 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { X, PlusSquare, ArrowRight, Loader2 } from 'lucide-react'; +import { useGetSupplierLastType } from '@/api/generated/quotation/quotation'; +import { updateItem } from '@/api/generated/item/item'; import { Button } from '@/components/ui/button'; import { Typography } from '@/components/ui/typography'; import { Input } from '@/components/ui/input'; @@ -7,7 +9,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types'; import type { CreateQuotationInput } from '../hooks/useQuotations'; import { QuotationType } from '@/api/generated/model'; -import { QUOTATION_TYPE_OPTIONS } from '../types'; +import { QUOTATION_TYPE_OPTIONS, supplierTypeOptions } from '../types'; +import { showToast } from '@/lib/notify'; // datetime-local 디폴트값: 현재 한국시간(Asia/Seoul)의 'YYYY-MM-DDTHH:mm'. // sv-SE 로케일이 'YYYY-MM-DD HH:mm:ss' 를 주고, timeZone 명시로 브라우저 TZ 와 무관하게 KST 로 고정한다. @@ -44,9 +47,37 @@ export function QuotationCreateModal({ const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? ''); const [selectedCardIds, setSelectedCardIds] = useState([]); const [memo, setMemo] = useState(''); + const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 마진식으로 목표가 산정 + const [purchaseInput, setPurchaseInput] = useState(''); // 재견적·재협상 매입가(상품 저장값 디폴트, 필수) + const [sellingInput, setSellingInput] = useState(''); // 재견적·재협상 판매가(상품 저장값 디폴트, 선택) + const [supplierType, setSupplierType] = useState(''); // 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록 const [submitting, setSubmitting] = useState(false); const typeOptions = QUOTATION_TYPE_OPTIONS; + // 협력사 유형은 재협상(1:1)에서만. 선택된 협력사의 직전 견적 supplier_type 을 조회해 디폴트로 채운다. + const renegoSupplierId = type === QuotationType.RENEGO ? (selectedPartnerIds[0] ?? '') : ''; + const lastTypeQuery = useGetSupplierLastType(renegoSupplierId, { + query: { enabled: !!renegoSupplierId }, + }); + const prevSupplierType = lastTypeQuery.data?.supplier_type ?? null; // 협력사 직전 견적 유형(없으면 null) + const prevQtNumber = lastTypeQuery.data?.qt_number ?? ''; + // 협력사가 정해지면 직전 견적 유형으로 디폴트(이후 사용자가 바꾸면 그 값 유지). + useEffect(() => { + setSupplierType(prevSupplierType != null ? String(prevSupplierType) : ''); + }, [renegoSupplierId, prevSupplierType]); + + // 재견적·재협상(RE)에서만 매입가/판매가 입력을 노출한다. 상품이 정해지면 그 상품 저장값으로 디폴트. + const selectedProduct = products.find((p) => p.id === productId); + const isReType = type === QuotationType.RENEGO || type === QuotationType.REQUOTE; + const showPrices = isReType && !!productId; + // 상품/유형이 바뀌면 그 상품의 저장된 매입가·판매가로 입력칸을 채운다(이후 사용자가 고치면 유지). + useEffect(() => { + if (!isReType || !selectedProduct) return; + setPurchaseInput(selectedProduct.purchase_price != null ? String(selectedProduct.purchase_price) : ''); + setSellingInput(selectedProduct.selling_price != null ? String(selectedProduct.selling_price) : ''); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [productId, isReType]); + if (!open) return null; const togglePartner = (id: string) => @@ -64,6 +95,17 @@ export function QuotationCreateModal({ if (submitting) return; setSubmitting(true); try { + // 재견적·재협상은 매입가 필수 — 입력된 매입가/판매가를 상품에 저장한 뒤 진행한다. + if (showPrices) { + if (!purchaseInput) { + showToast('재견적·재협상은 매입가가 필수입니다.', 'error'); + return; // finally 에서 submitting 해제 + } + await updateItem(productId, { + purchase_price: Number(purchaseInput), + selling_price: sellingInput ? Number(sellingInput) : undefined, + }); + } // 서버가 견적+세션 생성을 끝내고 응답할 때까지 기다린 뒤에 완료(닫기) 처리한다. const ok = await onCreate({ title, @@ -74,6 +116,8 @@ export function QuotationCreateModal({ settingId, cardIds: selectedCardIds, memo, + mdPrice: mdPrice ? Number(mdPrice) : null, + supplierType: supplierType ? Number(supplierType) : null, }); if (ok) onClose(); } finally { @@ -92,7 +136,7 @@ export function QuotationCreateModal({
)} -
+
{/* Header */}
@@ -107,11 +151,11 @@ export function QuotationCreateModal({ {/* Steps indicator */}
- = 1 ? 'text-primary' : 'text-muted-foreground'}`}>1. 기본 등록 + = 1 ? 'text-primary' : 'text-muted-foreground'}`}>1. 기본 정보 - = 2 ? 'text-primary' : 'text-muted-foreground'}`}>2. 협력사 선택 + = 2 ? 'text-primary' : 'text-muted-foreground'}`}>2. 협력사 초청 - = 3 ? 'text-primary' : 'text-muted-foreground'}`}>3. 설정 및 완료 + = 3 ? 'text-primary' : 'text-muted-foreground'}`}>3. 설정·완료
{/* Step content */} @@ -119,21 +163,10 @@ export function QuotationCreateModal({ {step === 1 && (
-
- 견적건명 - setTitle(e.target.value)} - placeholder="예: 6월 배터리 원부자재 견적 의뢰" - /> -
- -
+ {/* 견적 유형이 맨 위 — 신규/재 여부가 아래 매입가 필수 여부까지 결정한다 */} +
- 유형 + 견적 유형 setTitle(e.target.value)} + placeholder="예: 6월 배터리 원부자재 견적 의뢰" + /> +
+
상품
+ + {/* MD 제시가 — 신규·재 공통(입력 시 목표가로 사용) */} +
+ MD 제시가 (선택) + setMdPrice(e.target.value)} + placeholder="입력 시 목표가로 사용 · 미입력 시 자동 산정" + /> +
+ + {/* 재견적·재협상 — 매입가(필수)·판매가, 선택 상품의 저장값으로 디폴트 */} + {showPrices && ( +
+
+ 매입가 (필수) + setPurchaseInput(e.target.value)} + placeholder="상품 저장값 · 비우면 진행 불가" + /> +
+
+ 판매가 (선택) + setSellingInput(e.target.value)} + placeholder="상품 저장값 · 마진 상한 산정에 사용" + /> +
+
+ )}
)} @@ -226,6 +315,34 @@ export function QuotationCreateModal({ ); })}
+ + {/* 협력사 유형 — 재협상(1:1)에서 협력사 선택 후 노출. 직전 견적 값 자동 디폴트. */} + {type === QuotationType.RENEGO && selectedPartnerIds.length > 0 && ( +
+ 협력사 유형 + + {prevSupplierType != null && ( + + 이전 견적({prevQtNumber}) 값으로 자동 선택됨 · 수정 가능 + + )} +
+ )}
)} @@ -317,7 +434,17 @@ export function QuotationCreateModal({
{step < 3 ? ( - ) : ( diff --git a/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx b/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx index 3fc9ece..b13dc5d 100644 --- a/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx @@ -24,16 +24,18 @@ export function QuotationSettingsModal({ }: QuotationSettingsModalProps) { const [targetMargin, setTargetMargin] = useState(''); const [anchoringValue, setAnchoringValue] = useState(''); + const [internetFee, setInternetFee] = useState('7.8'); const [cardUseCount, setCardUseCount] = useState(''); if (!open) return null; const handleAdd = (e: React.FormEvent) => { e.preventDefault(); - const ok = onAdd({ targetMargin, anchoringValue, cardUseCount }); + const ok = onAdd({ targetMargin, anchoringValue, internetFee, cardUseCount }); if (ok) { setTargetMargin(''); setAnchoringValue(''); + setInternetFee('7.8'); setCardUseCount(''); } }; @@ -99,7 +101,7 @@ export function QuotationSettingsModal({
신규 견적 세팅 추가 -
+
목표 마진율 (%) setTargetMargin(e.target.value)} placeholder="예: 12" /> @@ -108,6 +110,10 @@ export function QuotationSettingsModal({ 앵커링 값 setAnchoringValue(e.target.value)} placeholder="예: 0.01" />
+
+ 인터넷 수수료율 (%) + setInternetFee(e.target.value)} placeholder="예: 7.8" /> +
카드 사용 횟수 setCardUseCount(e.target.value)} placeholder="예: 3" /> diff --git a/negodata/front/src/features/quotations/hooks/useQuotations.ts b/negodata/front/src/features/quotations/hooks/useQuotations.ts index 372e4aa..63ae1b0 100644 --- a/negodata/front/src/features/quotations/hooks/useQuotations.ts +++ b/negodata/front/src/features/quotations/hooks/useQuotations.ts @@ -36,11 +36,14 @@ export type CreateQuotationInput = { settingId: string; cardIds: string[]; memo: string; + mdPrice?: number | null; // MD 제시가(원). 비우면 미전송 → 서버가 기존 마진식으로 목표가 산정 + supplierType?: number | null; // 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록 }; export type SettingInput = { targetMargin: string; anchoringValue: string; + internetFee: string; cardUseCount: string; }; @@ -116,13 +119,14 @@ export function useQuotations(params: ListQuotationsParams) { const addSetting = (input: SettingInput): boolean => { const marginPct = Number(String(input.targetMargin).replace('%', '').trim()); const anchoring = Number(String(input.anchoringValue).trim()); + const feePct = Number(String(input.internetFee).replace('%', '').trim()); const cardCount = parseInt(String(input.cardUseCount).replace(/[^0-9-]/g, ''), 10); - if (!Number.isFinite(marginPct) || !Number.isFinite(anchoring) || !Number.isInteger(cardCount)) { - showToast('목표 마진율·앵커링 값·카드 사용 횟수를 숫자로 입력해야 합니다.', 'error'); + if (!Number.isFinite(marginPct) || !Number.isFinite(anchoring) || !Number.isFinite(feePct) || !Number.isInteger(cardCount)) { + showToast('목표 마진율·앵커링 값·수수료율·카드 사용 횟수를 숫자로 입력해야 합니다.', 'error'); return false; } createSettingMutation.mutate( - { data: { target_margin_rate: marginPct / 100, anchoring_value: anchoring, card_count: cardCount } }, + { data: { target_margin_rate: marginPct / 100, anchoring_value: anchoring, internet_average_fee: feePct / 100, card_count: cardCount } }, { onSuccess: () => { invalidateSettings(); @@ -183,6 +187,8 @@ export function useQuotations(params: ListQuotationsParams) { manager_email: me?.email || undefined, manager_contact_number: me?.contact || undefined, memo: input.memo.trim() || undefined, + md_price: input.mdPrice && input.mdPrice > 0 ? input.mdPrice : undefined, + supplier_type: input.supplierType ?? undefined, }; try { diff --git a/negodata/front/src/features/quotations/types.ts b/negodata/front/src/features/quotations/types.ts index a5970d7..477274f 100644 --- a/negodata/front/src/features/quotations/types.ts +++ b/negodata/front/src/features/quotations/types.ts @@ -4,7 +4,7 @@ import type { QuotationSettingData } from '@/api/generated/model/quotationSettin import type { QuotationData } from '@/api/generated/model/quotationData'; import type { SessionData } from '@/api/generated/model/sessionData'; import type { QuotationCardData } from '@/api/generated/model/quotationCardData'; -import { QuotationType, QuotationStatus, SessionStatus, CardType } from '@/api/generated/model'; +import { QuotationType, QuotationStatus, SessionStatus, CardType, SupplierType } from '@/api/generated/model'; import { DELIVERY_TYPE_LABEL } from '@/lib/enumLabels'; import { toMinPrice } from '@/features/products/types'; import type { Product, Partner, NegotiationCard } from '@/types'; @@ -136,14 +136,29 @@ export const QUOTATION_STATUS_OPTIONS = Object.values(QuotationStatus).map((valu export const QUOTATION_TYPE_LABEL: Record = { [QuotationType.RENEGO]: '재협상', [QuotationType.REQUOTE]: '재견적', + [QuotationType.NEW_NEGO]: '신규협상', + [QuotationType.NEW_QUOTE]: '신규견적', }; export const quotationTypeLabel = (t?: number | null): string => t != null ? QUOTATION_TYPE_LABEL[t as QuotationType] ?? String(t) : ''; -export const QUOTATION_TYPE_OPTIONS = [QuotationType.REQUOTE, QuotationType.RENEGO].map((value) => ({ +export const QUOTATION_TYPE_OPTIONS = [ + QuotationType.NEW_QUOTE, + QuotationType.NEW_NEGO, + QuotationType.REQUOTE, + QuotationType.RENEGO, +].map((value) => ({ value, label: QUOTATION_TYPE_LABEL[value], })); +// 협력사 유형 선택지(견적생성 모달). 재견적은 1:1이라 견적에 협력사 유형을 박는다. 없음(ETC=0) 포함. +export const supplierTypeOptions: { value: number; label: string }[] = [ + { value: SupplierType.DISTRIBUTION, label: '유통' }, + { value: SupplierType.MANUFACTURE, label: '제조' }, + { value: SupplierType.SOLE_AGENCY, label: '총판' }, + { value: SupplierType.ETC, label: '없음' }, +]; + // ── 라운드 체인(같은 견적번호) ─────────────────────────────────────────── // 한 라운드(견적)의 결과를 한 단어로. 낙찰=종료, 동가/마감=후속 라운드 가능, 진행중=아직 안 닫힘. export type ChainRoundState = 'awarded' | 'equal' | 'closed' | 'active'; diff --git a/negodata/front/src/types.ts b/negodata/front/src/types.ts index 6693cd6..53089d5 100644 --- a/negodata/front/src/types.ts +++ b/negodata/front/src/types.ts @@ -23,6 +23,7 @@ export type Partner = SupplierData & { export interface NegotiationCard { id: string; isWildcard: boolean; + usageType: number; // usage_type(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용 code: string; title: string; scriptPreview: string; diff --git a/postgres-init/01-schema.sql b/postgres-init/01-schema.sql index bb7129f..b25ebd3 100644 --- a/postgres-init/01-schema.sql +++ b/postgres-init/01-schema.sql @@ -156,6 +156,9 @@ CREATE TABLE IF NOT EXISTS partner.items ( vat_yn BOOLEAN NULL, -- 부가세 포함 여부 delivery_fee_yn BOOLEAN NULL, -- 배송비 포함 여부 internet_lowest_price_yn BOOLEAN NOT NULL DEFAULT FALSE, -- 최저가 솔루션의 원자성을 보존하기 위한 보조 컬럼 + internet_lowest_price BIGINT NULL, -- 인터넷 최저가 + purchase_price BIGINT NULL, -- 매입가 + selling_price BIGINT NULL, -- 판매가 category_type INTEGER NOT NULL DEFAULT 1, -- 자동으로 늘어나는 숫자 ( 카테고리 찾을때 유용한 컬럼) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) @@ -197,6 +200,7 @@ CREATE TABLE IF NOT EXISTS card.nego_cards ( number VARCHAR(10) NULL, -- 식별번호 script VARCHAR(255) NULL, -- 협상 스크립트 edit_script JSONB NULL, -- 편집된 스크립트(JSON) + usage_type SMALLINT NOT NULL DEFAULT 1, -- 카드 적용 견적 구분(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 @@ -209,6 +213,7 @@ CREATE TABLE IF NOT EXISTS card.wild_cards ( number VARCHAR(10) NULL, -- 식별번호 script VARCHAR(255) NULL, -- 협상 스크립트 edit_script JSONB NULL, -- 편집된 스크립트(JSON) + usage_type SMALLINT NOT NULL DEFAULT 1, -- 카드 적용 견적 구분(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용 condition VARCHAR(255) NULL, -- 커스터마이징 협상 카드이기 때문에 상세 조건을 기재해야 함 available BOOLEAN NOT NULL DEFAULT FALSE, -- 와일드 카드는 수동으로 코드에 추가해야 하기 때문에 컬럼 추가 memo VARCHAR(255) NULL, -- 사용 조건 이외에 자유롭게 적을 수 있는 메모 @@ -243,6 +248,7 @@ CREATE TABLE IF NOT EXISTS quotation.quotation_settings ( user_id uuid NOT NULL, -- 견적 설정을 생성한 유저 아이디(company.users.user_id) target_margin_rate NUMERIC(8,6) NOT NULL, -- 목표 마진율 (정수부 2자리 + 소수 6자리, -99.999999~99.999999) anchoring_value NUMERIC(8,6) NOT NULL DEFAULT 0.01, -- 앵커링 값 (정수부 2자리 + 소수 6자리) + internet_average_fee NUMERIC(8,6) NOT NULL DEFAULT 0.078, -- 인터넷 평균 수수료율 card_count INTEGER NOT NULL DEFAULT 3, -- 한개의 협상 안에서 협상카드 사용 횟수 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) @@ -265,6 +271,8 @@ CREATE TABLE IF NOT EXISTS quotation.quotations ( manager_email VARCHAR(255) NULL, -- 담당자 이메일 manager_contact_number VARCHAR(20) NULL, -- 담당자 연락처 memo VARCHAR(100) NULL, -- 메모 + md_price BIGINT NULL, -- MD 제시가(원). 목표가 산정 최우선값 (견적생성 모달 입력) + supplier_type SMALLINT NULL, -- 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록 (견적생성 모달 입력) iteration INTEGER NOT NULL DEFAULT 0, -- 반복 횟수 preferred_sp_yn BOOLEAN NULL, -- 선호 공급사 지정 여부 preferred_sp_id uuid NULL, -- 선호 공급사(partner.suppliers.supplier_id) @@ -288,6 +296,7 @@ CREATE TABLE IF NOT EXISTS negotiation.sessions ( qt_round INTEGER NOT NULL, -- 견적 라운드(스냅샷) qt_type SMALLINT NOT NULL, -- 견적 유형(스냅샷): 1=renego, 2=requote target_price BIGINT NOT NULL, -- 목표가(원) + target_anchoring_price BIGINT NULL, -- 앵커링가(원) status SMALLINT NOT NULL, -- 진행 상태 (코드, 앱 enum 매핑) bid_price BIGINT NULL, -- 입찰가(원) bid_at TIMESTAMPTZ NULL, -- 입찰 시각 diff --git a/postgres-init/04-alter-negodata-pricing.sql b/postgres-init/04-alter-negodata-pricing.sql new file mode 100644 index 0000000..c3a6c19 --- /dev/null +++ b/postgres-init/04-alter-negodata-pricing.sql @@ -0,0 +1,27 @@ +-- negosium 견적 개편: 가격(매입/판매)·수수료율·앵커링가 + 견적/카드/협력사 분류 컬럼 +-- 적용 대상: 이미 돌아가는 DB (신규/리셋 DB는 postgres-init/01-schema.sql 에 이미 포함). + +-- 상품: 인터넷최저가 실값 + 매입가 + 판매가 +ALTER TABLE partner.items + ADD COLUMN IF NOT EXISTS internet_lowest_price BIGINT, + ADD COLUMN IF NOT EXISTS purchase_price BIGINT, + ADD COLUMN IF NOT EXISTS selling_price BIGINT; + +-- 견적 설정: 인터넷 평균 수수료율 +ALTER TABLE quotation.quotation_settings + ADD COLUMN IF NOT EXISTS internet_average_fee NUMERIC(8,6) NOT NULL DEFAULT 0.078; + +-- 세션: 앵커링가 +ALTER TABLE negotiation.sessions + ADD COLUMN IF NOT EXISTS target_anchoring_price BIGINT; + +-- 견적: MD 제시가 + 협력사(공급채널) 유형 +ALTER TABLE quotation.quotations + ADD COLUMN IF NOT EXISTS md_price BIGINT, + ADD COLUMN IF NOT EXISTS supplier_type SMALLINT; + +-- 카드: 사용 범위 구분(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용 +ALTER TABLE card.nego_cards + ADD COLUMN IF NOT EXISTS usage_type SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE card.wild_cards + ADD COLUMN IF NOT EXISTS usage_type SMALLINT NOT NULL DEFAULT 1; From d2eeea266ae0634276ed850b66ebf7b0ebfc3e39 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Fri, 26 Jun 2026 17:15:59 +0900 Subject: [PATCH 06/20] =?UTF-8?q?[chore]=20negodata:=20ALTER=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=EB=A5=BC=20postgres-migrations/=20?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC=C2=B7=EC=9D=B4=EB=A6=84=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit postgres-init/ 는 신규 DB 부트스트랩(01 schema·02 learning·03 seed) 전용이라 ALTER(기존 DB 대상)는 별도 디렉터리로 분리. 앞으로 마이그레이션은 postgres-migrations/YYYY-MM-DD-설명.sql 로 추가한다. 04-alter-negodata-pricing.sql → 2026-06-26-pricing-classification.sql Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-26-pricing-classification.sql | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename postgres-init/04-alter-negodata-pricing.sql => postgres-migrations/2026-06-26-pricing-classification.sql (100%) diff --git a/postgres-init/04-alter-negodata-pricing.sql b/postgres-migrations/2026-06-26-pricing-classification.sql similarity index 100% rename from postgres-init/04-alter-negodata-pricing.sql rename to postgres-migrations/2026-06-26-pricing-classification.sql From 7da19253657938944e03b09aa018562d56154832 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Fri, 26 Jun 2026 17:18:37 +0900 Subject: [PATCH 07/20] =?UTF-8?q?[chore]=20negodata:=20ALTER=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=EB=A5=BC=20=EB=8B=A8=EC=9D=BC=20?= =?UTF-8?q?=EB=88=84=EC=A0=81=20=ED=8C=8C=EC=9D=BC=EB=A1=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit postgres-migrations/ 폴더(날짜파일) 접고 postgres-init/04-alter.sql 한 파일로. negodata 는 마이그레이션 러너가 없고 수동 psql 이라, IF NOT EXISTS 로 재실행 안전한 단일 alter 파일에 앞으로 변경을 append 하는 방식이 단순. 04-alter-negodata-pricing → 04-alter. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../04-alter.sql | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) rename postgres-migrations/2026-06-26-pricing-classification.sql => postgres-init/04-alter.sql (57%) diff --git a/postgres-migrations/2026-06-26-pricing-classification.sql b/postgres-init/04-alter.sql similarity index 57% rename from postgres-migrations/2026-06-26-pricing-classification.sql rename to postgres-init/04-alter.sql index c3a6c19..c106752 100644 --- a/postgres-migrations/2026-06-26-pricing-classification.sql +++ b/postgres-init/04-alter.sql @@ -1,5 +1,10 @@ --- negosium 견적 개편: 가격(매입/판매)·수수료율·앵커링가 + 견적/카드/협력사 분류 컬럼 --- 적용 대상: 이미 돌아가는 DB (신규/리셋 DB는 postgres-init/01-schema.sql 에 이미 포함). +-- 기존 DB ALTER 누적 파일. 새 컬럼/변경은 이 파일에 계속 append 한다. +-- 전부 IF NOT EXISTS 라 몇 번을 재실행해도 안전(돌리면 최신 상태로 맞춰짐). +-- 신규/리셋 DB 는 01-schema.sql 에 이미 반영돼 있어 이 파일이 필요 없다. + +-- ─────────────────────────────────────────────────────────── +-- [2026-06-26] 견적 개편: 가격(매입/판매)·수수료율·앵커링가 + 견적/카드/협력사 분류 컬럼 +-- ─────────────────────────────────────────────────────────── -- 상품: 인터넷최저가 실값 + 매입가 + 판매가 ALTER TABLE partner.items From a40257ddc1f9a3d7da081addd59abc764877ed3d Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 29 Jun 2026 13:31:32 +0900 Subject: [PATCH 08/20] =?UTF-8?q?[feat]=20negodata:=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20/=20=EC=B5=9C=EA=B3=A0=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90(OWNER)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OWNER 권한 신설, 최고관리자가 자기 회사 소속 직원(USER) 계정 생성·수정·삭제 관리 - 회사 사용자 API(/v1/company/user/*), 회원관리 페이지·폼 - 로그인/프로필 흐름 정비, 무인증 계정생성(/auth/create) 제거 Co-Authored-By: Claude Opus 4.8 (1M context) --- negodata/backend/common/enums.py | 14 +- negodata/backend/crud/user_crud.py | 70 ++- negodata/backend/router/v1/auth/account.py | 19 +- negodata/backend/router/v1/auth/protocol.py | 18 +- .../backend/router/v1/company/protocol.py | 58 +++ negodata/backend/router/v1/company/user.py | 51 +++ .../router/v1/validator/dependencies.py | 9 + negodata/backend/services/auth_service.py | 75 ++-- .../backend/services/company_user_service.py | 158 +++++++ negodata/front/src/api/generated/auth/auth.ts | 133 +++--- .../generated/company-user/company-user.ts | 417 ++++++++++++++++++ .../api/generated/model/companyUserData.ts | 28 ++ .../model/companyUserDataContactNumber.ts | 8 + .../model/companyUserDataCreatedAt.ts | 8 + .../generated/model/companyUserDataEmail.ts | 8 + .../model/companyUserDataLastAccessedAt.ts | 8 + ...teAccountMsg.ts => companyUserDataName.ts} | 2 +- .../model/companyUserDataUpdatedAt.ts | 8 + ...eateAccount.ts => reqCreateCompanyUser.ts} | 4 +- .../generated/model/reqUpdateCompanyUser.ts | 19 + .../reqUpdateCompanyUserContactNumber.ts | 8 + .../model/reqUpdateCompanyUserEmail.ts | 8 + .../model/reqUpdateCompanyUserName.ts | 8 + .../model/reqUpdateCompanyUserPassword.ts | 8 + .../model/reqUpdateCompanyUserStatus.ts | 9 + .../src/api/generated/model/resCompanyUser.ts | 15 + .../api/generated/model/resCompanyUserList.ts | 18 + .../generated/model/resCompanyUserListMsg.ts | 8 + .../api/generated/model/resCompanyUserMsg.ts | 8 + .../api/generated/model/resCompanyUserUser.ts | 9 + ...eateAccount.ts => resDeleteCompanyUser.ts} | 7 +- .../model/resDeleteCompanyUserMsg.ts | 8 + .../front/src/api/generated/model/userRole.ts | 6 +- negodata/front/src/app/router.tsx | 9 +- .../components/layout/AuthenticatedLayout.tsx | 1 + .../features/auth/components/ProfileSheet.tsx | 143 ++++++ negodata/front/src/features/auth/service.ts | 26 +- negodata/front/src/features/members/api.ts | 51 +++ .../members/components/MemberFormSheet.tsx | 256 +++++++++++ .../members/components/MemberTable.tsx | 103 +++++ .../src/features/members/hooks/useMembers.ts | 65 +++ negodata/front/src/features/members/types.ts | 44 ++ negodata/front/src/lib/apiError.ts | 46 ++ negodata/front/src/pages/members.tsx | 96 ++++ negodata/front/src/stores/auth.ts | 3 +- 45 files changed, 1928 insertions(+), 150 deletions(-) create mode 100644 negodata/backend/router/v1/company/protocol.py create mode 100644 negodata/backend/router/v1/company/user.py create mode 100644 negodata/backend/services/company_user_service.py create mode 100644 negodata/front/src/api/generated/company-user/company-user.ts create mode 100644 negodata/front/src/api/generated/model/companyUserData.ts create mode 100644 negodata/front/src/api/generated/model/companyUserDataContactNumber.ts create mode 100644 negodata/front/src/api/generated/model/companyUserDataCreatedAt.ts create mode 100644 negodata/front/src/api/generated/model/companyUserDataEmail.ts create mode 100644 negodata/front/src/api/generated/model/companyUserDataLastAccessedAt.ts rename negodata/front/src/api/generated/model/{resCreateAccountMsg.ts => companyUserDataName.ts} (71%) create mode 100644 negodata/front/src/api/generated/model/companyUserDataUpdatedAt.ts rename negodata/front/src/api/generated/model/{reqCreateAccount.ts => reqCreateCompanyUser.ts} (74%) create mode 100644 negodata/front/src/api/generated/model/reqUpdateCompanyUser.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateCompanyUserContactNumber.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateCompanyUserEmail.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateCompanyUserName.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateCompanyUserPassword.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateCompanyUserStatus.ts create mode 100644 negodata/front/src/api/generated/model/resCompanyUser.ts create mode 100644 negodata/front/src/api/generated/model/resCompanyUserList.ts create mode 100644 negodata/front/src/api/generated/model/resCompanyUserListMsg.ts create mode 100644 negodata/front/src/api/generated/model/resCompanyUserMsg.ts create mode 100644 negodata/front/src/api/generated/model/resCompanyUserUser.ts rename negodata/front/src/api/generated/model/{resCreateAccount.ts => resDeleteCompanyUser.ts} (56%) create mode 100644 negodata/front/src/api/generated/model/resDeleteCompanyUserMsg.ts create mode 100644 negodata/front/src/features/auth/components/ProfileSheet.tsx create mode 100644 negodata/front/src/features/members/api.ts create mode 100644 negodata/front/src/features/members/components/MemberFormSheet.tsx create mode 100644 negodata/front/src/features/members/components/MemberTable.tsx create mode 100644 negodata/front/src/features/members/hooks/useMembers.ts create mode 100644 negodata/front/src/features/members/types.ts create mode 100644 negodata/front/src/lib/apiError.ts create mode 100644 negodata/front/src/pages/members.tsx diff --git a/negodata/backend/common/enums.py b/negodata/backend/common/enums.py index 4b9f862..f040de9 100644 --- a/negodata/backend/common/enums.py +++ b/negodata/backend/common/enums.py @@ -35,6 +35,7 @@ class ErrorType(Enum): INTERNAL_EXCEPTION = auto() # http 에러 코드와 겹치지 않게 설정 - router 전용 예외 발생 옵션 + HTTP_FORBIDDEN = 403 HTTP_INVALID_CLIENT_REQUEST = 419 HTTP_TO_MANY_REQUEST = 429 HTTP_INVALID_CLIENT_ACCESS = 433 @@ -46,6 +47,8 @@ class ErrorType(Enum): ACCOUNT_INVALID_INFO = 1200 ACCOUNT_ALREADY_EXIST = auto() ACCOUNT_BLOCKED_USER = auto() + ACCOUNT_NOT_FOUND = auto() + ACCOUNT_FORBIDDEN = auto() # 최고관리자 외 접근 / 다른 회사·최고관리자 대상 변경 시도 # 상품 관련 에러 ITEM_NOT_FOUND = 1300 @@ -71,8 +74,13 @@ class ErrorType(Enum): IMAGE_TOO_LARGE = auto() IMAGE_UPLOAD_FAILED = auto() + # 초청 메일 발송 관련 에러 + EMAIL_NOT_CONFIGURED = 1900 # ACS/SMTP 둘 다 미설정 — 발송 불가(설정 필요) + EMAIL_SEND_FAILED = auto() # 발송 시도했으나 전부 실패(수신자 0 성공) + # ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다. +EXCEPTION_FORBIDDEN = HTTPException(status_code=ErrorType.HTTP_FORBIDDEN.value, detail=ErrorType.HTTP_FORBIDDEN.name) EXCEPTION_INVALID_CLIENT_REQUEST = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_REQUEST.value, detail=ErrorType.HTTP_INVALID_CLIENT_REQUEST.name) EXCEPTION_TO_MANY_REQUEST = HTTPException(status_code=ErrorType.HTTP_TO_MANY_REQUEST.value, detail=ErrorType.HTTP_TO_MANY_REQUEST.name) EXCEPTION_INVALID_CLIENT_ACCESS = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_ACCESS.value, detail=ErrorType.HTTP_INVALID_CLIENT_ACCESS.name) @@ -105,10 +113,12 @@ class UserStatus(CodeEnum): class UserRole(CodeEnum): - """users.role 코드값.""" + """users.role 코드값. negodata 유저는 전부 회사 직원(관리자측) — + 의미 있는 구분은 '직원 계정 관리 권한 유무' 하나뿐이라 2단계로 둔다. + 1=일반, 2=최고관리자(직원 계정 생성·관리).""" USER = 1 - MANAGER = 2 + OWNER = 2 # 최고관리자: 자기 회사 유저(직원 계정)를 생성·관리 class CompanyStatus(CodeEnum): diff --git a/negodata/backend/crud/user_crud.py b/negodata/backend/crud/user_crud.py index 58a1cbf..25ba03e 100644 --- a/negodata/backend/crud/user_crud.py +++ b/negodata/backend/crud/user_crud.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod -from typing import Tuple +from typing import Optional, Tuple -from sqlalchemy import select, update +from sqlalchemy import select, func, and_, or_, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG @@ -35,6 +35,18 @@ class IUserCRUD(ABC): async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]: pass + @abstractmethod + async def list_by_company(self, cdb: AsyncSession, company_id, search, skip, limit) -> Tuple[ErrorType, list, int]: + pass + + @abstractmethod + async def get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]: + pass + + @abstractmethod + async def update_user(self, cdb: AsyncSession, user_id, data: dict) -> ErrorType: + pass + class UserCRUD(IUserCRUD): async def get_user_by_login_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, users]: @@ -90,3 +102,57 @@ class UserCRUD(IUserCRUD): except Exception as ex: LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, None + + async def list_by_company( + self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int + ) -> Tuple[ErrorType, list, int]: + try: + conditions = [users.deleted == False, users.company_id == company_id] # noqa: E712 + if search: + conditions.append( + or_( + users.id.ilike(f"%{search}%"), + users.name.ilike(f"%{search}%"), + users.email.ilike(f"%{search}%"), + ) + ) + where = and_(*conditions) + + cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(users).where(where)) + if cnt_err != ErrorType.SUCCESS: + return cnt_err, [], 0 + total = int(cnt_rows[0] or 0) if cnt_rows else 0 + + list_err, rows = await DB_SESSION_MNG.execute( + cdb, + select(users).where(where).order_by(users.created_at.desc()).offset(skip).limit(limit), + ) + if list_err != ErrorType.SUCCESS: + return list_err, [], 0 + return ErrorType.SUCCESS, list(rows), total + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [], 0 + + async def get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]: + try: + query = select(users).where(users.user_id == user_id, users.deleted == False).limit(1) # noqa: E712 + err_type, row_list = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, None + if len(row_list) != 1: + return ErrorType.DB_INVALID_KEY, None + return ErrorType.SUCCESS, row_list[0] + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, None + + async def update_user(self, cdb: AsyncSession, user_id, data: dict) -> ErrorType: + try: + if not data: + return ErrorType.SUCCESS + query = update(users).where(users.user_id == user_id).values(**data) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED diff --git a/negodata/backend/router/v1/auth/account.py b/negodata/backend/router/v1/auth/account.py index f2a7d3c..8eb38ca 100644 --- a/negodata/backend/router/v1/auth/account.py +++ b/negodata/backend/router/v1/auth/account.py @@ -4,7 +4,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from common.models.gmodel import UserInfo from router.v1.validator.dependencies import IsValidAccessToken, IsValidRefreshToken, RemoveNoneResponse from services.auth_service import AuthService -from .protocol import Req_CreateAccount, Req_Login, Res_CreateAccount, Res_Login, Res_Me, Res_RefreshToken +from .protocol import Req_Login, Req_UpdateMe, Res_Login, Res_Me, Res_RefreshToken security = HTTPBearer() @@ -17,13 +17,6 @@ async def login(request: Request, req: Req_Login, service: AuthService = Depends return RemoveNoneResponse(await service.attempt_login(req.id, req.password, request.client.host)) -@router.post(path="/create", response_model=Res_CreateAccount, summary="계정 생성", description="새 계정을 생성한다.") -async def create_account(req: Req_CreateAccount, service: AuthService = Depends()): - return RemoveNoneResponse( - await service.create_account(req.id, req.password, req.company_id, req.name, req.email, req.contact_number, req.role) - ) - - @router.post( path="/refresh_token", dependencies=[Depends(IsValidRefreshToken)], @@ -43,3 +36,13 @@ async def refresh_token(service: AuthService = Depends(), credentials: HTTPAutho ) async def me(service: AuthService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): return RemoveNoneResponse(await service.get_me(user_info)) + + +@router.patch( + path="/me", + response_model=Res_Me, + summary="내 정보 수정", + description="본인 이름/이메일/연락처/비밀번호를 수정한다(권한·소속·ID 변경 불가).", +) +async def update_me(req: Req_UpdateMe, service: AuthService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.update_me(user_info, req)) diff --git a/negodata/backend/router/v1/auth/protocol.py b/negodata/backend/router/v1/auth/protocol.py index f07c0d0..1134e1e 100644 --- a/negodata/backend/router/v1/auth/protocol.py +++ b/negodata/backend/router/v1/auth/protocol.py @@ -22,18 +22,12 @@ class Res_Login(Res_WebPacketProtocol): token_type: str = "bearer" -class Req_CreateAccount(AuthProtocol): - id: str = "" - password: str = "" - company_id: str = "" - name: str = "" - email: str = "" - contact_number: str = "" - role: int = UserRole.USER.value - - -class Res_CreateAccount(Res_WebPacketProtocol): - user_id: str = "" +class Req_UpdateMe(AuthProtocol): + # 본인 정보 수정. role·company·id 는 받지 않는다(자기 권한·소속 변경 불가). + name: Optional[str] = None + email: Optional[str] = None + contact_number: Optional[str] = None + password: Optional[str] = None # 비밀번호 변경(옵션). 비우면 유지 class Res_RefreshToken(Res_WebPacketProtocol): diff --git a/negodata/backend/router/v1/company/protocol.py b/negodata/backend/router/v1/company/protocol.py new file mode 100644 index 0000000..91bd450 --- /dev/null +++ b/negodata/backend/router/v1/company/protocol.py @@ -0,0 +1,58 @@ +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import ConfigDict + +from common.enums import UserRole, UserStatus +from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol + + +# 최고관리자가 자기 회사 유저를 관리하는 도메인. company_id 는 토큰값으로 강제된다. +class CompanyUserProtocol(WebPacketProtocol): + pass + + +class Req_CreateCompanyUser(CompanyUserProtocol): + # role 은 받지 않는다 — 최고관리자가 만드는 계정은 항상 일반(USER) 로 서버에서 고정. + id: str = "" + password: str = "" + name: str = "" + email: str = "" + contact_number: str = "" + + +class Req_UpdateCompanyUser(CompanyUserProtocol): + name: Optional[str] = None + email: Optional[str] = None + contact_number: Optional[str] = None + status: Optional[UserStatus] = None # 활성/비활성 전환 + password: Optional[str] = None # 비밀번호 초기화(옵션) + + +class CompanyUserData(WebPacketProtocol): + model_config = ConfigDict(from_attributes=True) + + user_id: uuid.UUID + company_id: uuid.UUID + id: str + name: Optional[str] = None + email: Optional[str] = None + contact_number: Optional[str] = None + status: UserStatus + role: UserRole + last_accessed_at: Optional[datetime] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class Res_CompanyUser(Res_WebPacketProtocol): + user: Optional[CompanyUserData] = None + + +class Res_CompanyUserList(Res_PageProtocol): + users: list[CompanyUserData] = [] + + +class Res_DeleteCompanyUser(Res_WebPacketProtocol): + pass diff --git a/negodata/backend/router/v1/company/user.py b/negodata/backend/router/v1/company/user.py new file mode 100644 index 0000000..76df668 --- /dev/null +++ b/negodata/backend/router/v1/company/user.py @@ -0,0 +1,51 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, Query + +from common.models.gmodel import PageParams, UserInfo +from router.v1.validator.dependencies import RemoveNoneResponse, RequireOwner +from services.company_user_service import CompanyUserService +from .protocol import ( + Req_CreateCompanyUser, + Req_UpdateCompanyUser, + Res_CompanyUser, + Res_CompanyUserList, + Res_DeleteCompanyUser, +) + +# 최고관리자(OWNER) 전용. 모든 엔드포인트가 RequireOwner 로 게이트되며 company_id 는 토큰값으로 스코프된다. +router = APIRouter(prefix="/v1/company/user", tags=["CompanyUser"], responses={404: {"description": "Not found"}}) + + +@router.get(path="/list", response_model=Res_CompanyUserList, summary="회사 유저 목록(최고관리자)") +async def list_users( + service: CompanyUserService = Depends(), + owner: UserInfo = Depends(RequireOwner), + search: str | None = Query(None, description="로그인ID/이름/이메일 검색"), + pg: PageParams = Depends(), +): + return RemoveNoneResponse(await service.list_users(owner.company_id, search, pg)) + + +@router.post(path="/create", response_model=Res_CompanyUser, summary="회사 유저 생성(일반 권한 고정)") +async def create_user( + req: Req_CreateCompanyUser, service: CompanyUserService = Depends(), owner: UserInfo = Depends(RequireOwner) +): + return RemoveNoneResponse(await service.create_user(owner.company_id, req)) + + +@router.get(path="/{user_id}", response_model=Res_CompanyUser, summary="회사 유저 조회") +async def get_user(user_id: UUID, service: CompanyUserService = Depends(), owner: UserInfo = Depends(RequireOwner)): + return RemoveNoneResponse(await service.get_user(owner.company_id, str(user_id))) + + +@router.patch(path="/update/{user_id}", response_model=Res_CompanyUser, summary="회사 유저 수정") +async def update_user( + user_id: UUID, req: Req_UpdateCompanyUser, service: CompanyUserService = Depends(), owner: UserInfo = Depends(RequireOwner) +): + return RemoveNoneResponse(await service.update_user(owner.company_id, str(user_id), req)) + + +@router.delete(path="/delete/{user_id}", response_model=Res_DeleteCompanyUser, summary="회사 유저 삭제") +async def delete_user(user_id: UUID, service: CompanyUserService = Depends(), owner: UserInfo = Depends(RequireOwner)): + return RemoveNoneResponse(await service.delete_user(owner.company_id, str(user_id))) diff --git a/negodata/backend/router/v1/validator/dependencies.py b/negodata/backend/router/v1/validator/dependencies.py index 72b70cf..0bb784f 100644 --- a/negodata/backend/router/v1/validator/dependencies.py +++ b/negodata/backend/router/v1/validator/dependencies.py @@ -10,8 +10,10 @@ from jose import jwt, JWTError, ExpiredSignatureError from common.enums import ( EXCEPTION_ACCESS_TOKEN_EXPIRED, + EXCEPTION_FORBIDDEN, EXCEPTION_INVALID_CLIENT_ACCESS, EXCEPTION_REFRESH_TOKEN_EXPIRED, + UserRole, ) from common.logger import LOG from common.models.gmodel import UserInfo @@ -98,6 +100,13 @@ async def IsValidRefreshToken(credentials: HTTPAuthorizationCredentials = Depend return DecodeRefreshToken(credentials.credentials) +# 최고관리자 전용 엔드포인트 게이트. 액세스 토큰 검증 + role==OWNER 가 아니면 403. +async def RequireOwner(user_info: UserInfo = Depends(IsValidAccessToken)) -> UserInfo: + if user_info.role != UserRole.OWNER.value: + raise EXCEPTION_FORBIDDEN + return user_info + + # ---- ResponseNone 처리 ----------------------------------------------------- # 응답 객체에서 값이 None 인 필드를 재귀적으로 제거하여 페이로드를 줄인다. # 모든 라우터는 return RemoveNoneResponse(await service....) 형태로 반환한다. diff --git a/negodata/backend/services/auth_service.py b/negodata/backend/services/auth_service.py index d3c39af..4eb0a1c 100644 --- a/negodata/backend/services/auth_service.py +++ b/negodata/backend/services/auth_service.py @@ -4,11 +4,11 @@ from fastapi import Depends from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import users -from common.enums import DBWRType, ErrorType, UserStatus, UserRole +from common.enums import DBWRType, ErrorType, UserStatus from common.logger import LOG from common.models.gmodel import UserInfo from crud.user_crud import IUserCRUD, UserCRUD -from router.v1.auth.protocol import CompanyData, Res_CreateAccount, Res_Login, Res_Me, Res_RefreshToken +from router.v1.auth.protocol import CompanyData, Req_UpdateMe, Res_Login, Res_Me, Res_RefreshToken from router.v1.validator.dependencies import ( CreateAccessToken, CreateRefreshToken, @@ -38,6 +38,7 @@ class AuthService: user_id=str(user.user_id), id=user.id, company_id=str(user.company_id), + role=user.role, ) async def attempt_login(self, login_id: str, password: str, connect_ip: str) -> Res_Login: @@ -82,50 +83,6 @@ class AuthService: return res - async def create_account( - self, login_id: str, password: str, company_id: str, name: str, email: str, contact_number: str, role: int - ) -> Res_CreateAccount: - LOG.i(f"CREATE : id={login_id}, company_id={company_id}") - res = Res_CreateAccount() - - # 1) 중복 ID 확인 (Read DB) - err_type = await DB_SESSION_MNG.execute_lambda( - users.DBType(), - DBWRType.DB_READ.value, - lambda s: self.user_crud.is_user(s, login_id), - ) - if err_type == ErrorType.DB_ALREADY_SAME_KEY: - res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST) - return res - if err_type != ErrorType.SUCCESS: - res.result.SetResult(err_type) - return res - - # 2) 계정 생성 (비밀번호는 bcrypt 해시로 저장) - user = users( - company_id=uuid.UUID(company_id), - id=login_id, - password=await GetHashedPW(password), - name=name or None, - email=email or None, - contact_number=contact_number or None, - role=role or UserRole.USER.value, - ) - err_type = await DB_SESSION_MNG.execute_lambda_run( - [users.DBType()], - [lambda s: self.user_crud.add_user(s, user)], - ) - if err_type != ErrorType.SUCCESS: - # 사전 검사와 INSERT 사이의 경쟁 조건에서 unique 위반이 나면 동일 코드로 매핑. - if err_type == ErrorType.DB_ALREADY_SAME_KEY: - res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST) - else: - res.result.SetResult(err_type) - return res - - res.user_id = str(user.user_id) - return res - async def get_me(self, user_info: UserInfo) -> Res_Me: res = Res_Me() @@ -159,6 +116,32 @@ class AuthService: res.company = company return res + async def update_me(self, user_info: UserInfo, req: Req_UpdateMe) -> Res_Me: + res = Res_Me() + data = req.model_dump(exclude_unset=True) + + # 비밀번호: 값 있으면 해시 교체, 비었으면 변경 안 함. + if data.get("password"): + data["password"] = await GetHashedPW(data["password"]) + else: + data.pop("password", None) + # 빈 문자열은 NULL 로 저장(미입력 = 값 비움). + for k in ("name", "email", "contact_number"): + if k in data and data[k] == "": + data[k] = None + + if data: + err_type = await DB_SESSION_MNG.execute_lambda_run( + [users.DBType()], + [lambda s: self.user_crud.update_user(s, uuid.UUID(user_info.user_id), data)], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + # 갱신 후 최신 정보로 응답(프론트가 스토어 갱신에 사용). + return await self.get_me(user_info) + async def refresh_token(self, refresh_token: str) -> Res_RefreshToken: res = Res_RefreshToken() # refresh 토큰 검증은 라우터 Depends(IsValidRefreshToken) 에서 1차 수행됨. diff --git a/negodata/backend/services/company_user_service.py b/negodata/backend/services/company_user_service.py new file mode 100644 index 0000000..a45c98f --- /dev/null +++ b/negodata/backend/services/company_user_service.py @@ -0,0 +1,158 @@ +import uuid + +from fastapi import Depends + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import users +from common.enums import DBWRType, ErrorType, UserRole, UserStatus +from common.utils.gtime import GTime +from crud.user_crud import IUserCRUD, UserCRUD +from router.v1.company.protocol import ( + CompanyUserData, + Req_CreateCompanyUser, + Req_UpdateCompanyUser, + Res_CompanyUser, + Res_CompanyUserList, + Res_DeleteCompanyUser, +) +from router.v1.validator.dependencies import GetHashedPW + + +class CompanyUserService: + """최고관리자(OWNER)의 자기 회사 유저 관리 로직. + + - 라우터에서 RequireOwner 로 1차 권한을 거른 뒤 호출된다. + - company_id 는 토큰값만 쓴다(요청 body 무시) → 남의 회사 데이터 불가. + - 변경 대상이 OWNER 면 거부한다(최고관리자는 앱에서 수정·삭제 불가). + """ + + def __init__(self, user_crud: IUserCRUD = Depends(UserCRUD)): + self.user_crud = user_crud + + async def _fetch_managed(self, company_uuid: uuid.UUID, user_id: uuid.UUID): + """대상 유저 조회 + 같은 회사 + 비-OWNER 확인. (ErrorType, user|None) 반환.""" + err_type, user = await DB_SESSION_MNG.execute_lambda( + users.DBType(), + DBWRType.DB_READ.value, + lambda s: self.user_crud.get_by_user_id(s, user_id), + ) + if err_type != ErrorType.SUCCESS or user is None: + return ErrorType.ACCOUNT_NOT_FOUND, None + if user.company_id != company_uuid: + return ErrorType.ACCOUNT_NOT_FOUND, None + if user.role == UserRole.OWNER.value: + return ErrorType.ACCOUNT_FORBIDDEN, None + return ErrorType.SUCCESS, user + + async def list_users(self, company_id: str, search, pg) -> Res_CompanyUserList: + res = Res_CompanyUserList(page=pg.page, size=pg.size) + company_uuid = uuid.UUID(company_id) + + err_type, rows, total = await DB_SESSION_MNG.execute_lambda( + users.DBType(), + DBWRType.DB_READ.value, + lambda s: self.user_crud.list_by_company(s, company_uuid, search, pg.skip, pg.size), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.users = [CompanyUserData.model_validate(r) for r in rows] + res.total = total + return res + + async def get_user(self, company_id: str, user_id: str) -> Res_CompanyUser: + res = Res_CompanyUser() + err_type, user = await self._fetch_managed(uuid.UUID(company_id), uuid.UUID(user_id)) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.user = CompanyUserData.model_validate(user) + return res + + async def create_user(self, company_id: str, req: Req_CreateCompanyUser) -> Res_CompanyUser: + res = Res_CompanyUser() + company_uuid = uuid.UUID(company_id) + + # 1) 로그인 ID 중복 확인 (id 는 전역 unique) + err_type = await DB_SESSION_MNG.execute_lambda( + users.DBType(), + DBWRType.DB_READ.value, + lambda s: self.user_crud.is_user(s, req.id), + ) + if err_type == ErrorType.DB_ALREADY_SAME_KEY: + res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST) + return res + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + # 2) 생성 — 회사는 토큰값, 권한은 항상 USER 로 고정. + user = users( + company_id=company_uuid, + id=req.id, + password=await GetHashedPW(req.password), + name=req.name or None, + email=req.email or None, + contact_number=req.contact_number or None, + role=UserRole.USER.value, + ) + err_type = await DB_SESSION_MNG.execute_lambda_run( + [users.DBType()], + [lambda s: self.user_crud.add_user(s, user)], + ) + if err_type != ErrorType.SUCCESS: + if err_type == ErrorType.DB_ALREADY_SAME_KEY: + res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST) + else: + res.result.SetResult(err_type) + return res + + # 서버 기본값(created_at 등)은 insert 후 객체에 안 실리므로 재조회. + return await self.get_user(company_id, str(user.user_id)) + + async def update_user(self, company_id: str, user_id: str, req: Req_UpdateCompanyUser) -> Res_CompanyUser: + res = Res_CompanyUser() + company_uuid = uuid.UUID(company_id) + user_uuid = uuid.UUID(user_id) + + err_type, _ = await self._fetch_managed(company_uuid, user_uuid) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + data = req.model_dump(exclude_unset=True) + if data.get("status") is not None: + s = data["status"] # pydantic 은 enum 멤버로 돌려준다 → SMALLINT 값으로 환원 + data["status"] = s.value if isinstance(s, UserStatus) else int(s) + if data.get("password"): + data["password"] = await GetHashedPW(data["password"]) + else: + data.pop("password", None) # 빈 비밀번호는 변경하지 않음 + + err_type = await DB_SESSION_MNG.execute_lambda_run( + [users.DBType()], + [lambda s: self.user_crud.update_user(s, user_uuid, data)], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + return await self.get_user(company_id, user_id) + + async def delete_user(self, company_id: str, user_id: str) -> Res_DeleteCompanyUser: + res = Res_DeleteCompanyUser() + company_uuid = uuid.UUID(company_id) + user_uuid = uuid.UUID(user_id) + + err_type, _ = await self._fetch_managed(company_uuid, user_uuid) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + err_type = await DB_SESSION_MNG.execute_lambda_run( + [users.DBType()], + [lambda s: self.user_crud.update_user(s, user_uuid, {"deleted": True, "updated_at": GTime.UTC()})], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res diff --git a/negodata/front/src/api/generated/auth/auth.ts b/negodata/front/src/api/generated/auth/auth.ts index 1ce886e..ade4767 100644 --- a/negodata/front/src/api/generated/auth/auth.ts +++ b/negodata/front/src/api/generated/auth/auth.ts @@ -25,9 +25,8 @@ import type { import type { HTTPValidationError, - ReqCreateAccount, ReqLogin, - ResCreateAccount, + ReqUpdateMe, ResLogin, ResMe, ResRefreshToken @@ -106,71 +105,6 @@ export const useLogin = ,signal?: AbortSignal -) => { - - - return customFetch( - {url: `/v1/auth/create`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqCreateAccount, signal - }, - options); - } - - - -export const getCreateAccountMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqCreateAccount}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{data: ReqCreateAccount}, TContext> => { - -const mutationKey = ['createAccount']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction>, {data: ReqCreateAccount}> = (props) => { - const {data} = props ?? {}; - - return createAccount(data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type CreateAccountMutationResult = NonNullable>> - export type CreateAccountMutationBody = ReqCreateAccount - export type CreateAccountMutationError = void | HTTPValidationError - - /** - * @summary 계정 생성 - */ -export const useCreateAccount = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqCreateAccount}, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - {data: ReqCreateAccount}, - TContext - > => { - - const mutationOptions = getCreateAccountMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** * refresh 토큰으로 access 토큰을 재발급한다. * @summary 액세스 토큰 갱신 */ @@ -326,3 +260,68 @@ export function useMe>, TError = void>( +/** + * 본인 이름/이메일/연락처/비밀번호를 수정한다(권한·소속·ID 변경 불가). + * @summary 내 정보 수정 + */ +export const updateMe = ( + reqUpdateMe: ReqUpdateMe, + options?: SecondParameter,) => { + + + return customFetch( + {url: `/v1/auth/me`, method: 'PATCH', + headers: {'Content-Type': 'application/json', }, + data: reqUpdateMe + }, + options); + } + + + +export const getUpdateMeMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqUpdateMe}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{data: ReqUpdateMe}, TContext> => { + +const mutationKey = ['updateMe']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {data: ReqUpdateMe}> = (props) => { + const {data} = props ?? {}; + + return updateMe(data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type UpdateMeMutationResult = NonNullable>> + export type UpdateMeMutationBody = ReqUpdateMe + export type UpdateMeMutationError = void | HTTPValidationError + + /** + * @summary 내 정보 수정 + */ +export const useUpdateMe = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqUpdateMe}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {data: ReqUpdateMe}, + TContext + > => { + + const mutationOptions = getUpdateMeMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + \ No newline at end of file diff --git a/negodata/front/src/api/generated/company-user/company-user.ts b/negodata/front/src/api/generated/company-user/company-user.ts new file mode 100644 index 0000000..c6af7f6 --- /dev/null +++ b/negodata/front/src/api/generated/company-user/company-user.ts @@ -0,0 +1,417 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import { + useMutation, + useQuery +} from '@tanstack/react-query'; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + MutationFunction, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult +} from '@tanstack/react-query'; + +import type { + HTTPValidationError, + ListUsersParams, + ReqCreateCompanyUser, + ReqUpdateCompanyUser, + ResCompanyUser, + ResCompanyUserList, + ResDeleteCompanyUser +} from '.././model'; + +import { customFetch } from '../../mutator/custom-fetch'; + + +type SecondParameter unknown> = Parameters[1]; + + + +/** + * @summary 회사 유저 목록(최고관리자) + */ +export const listUsers = ( + params?: ListUsersParams, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/company/user/list`, method: 'GET', + params, signal + }, + options); + } + + + + +export const getListUsersQueryKey = (params?: ListUsersParams,) => { + return [ + `/v1/company/user/list`, ...(params ? [params]: []) + ] as const; + } + + +export const getListUsersQueryOptions = >, TError = void | HTTPValidationError>(params?: ListUsersParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListUsersQueryKey(params); + + + + const queryFn: QueryFunction>> = ({ signal }) => listUsers(params, requestOptions, signal); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type ListUsersQueryResult = NonNullable>> +export type ListUsersQueryError = void | HTTPValidationError + + +export function useListUsers>, TError = void | HTTPValidationError>( + params: undefined | ListUsersParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useListUsers>, TError = void | HTTPValidationError>( + params?: ListUsersParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useListUsers>, TError = void | HTTPValidationError>( + params?: ListUsersParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 회사 유저 목록(최고관리자) + */ + +export function useListUsers>, TError = void | HTTPValidationError>( + params?: ListUsersParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getListUsersQueryOptions(params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + +/** + * @summary 회사 유저 생성(일반 권한 고정) + */ +export const createUser = ( + reqCreateCompanyUser: ReqCreateCompanyUser, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/company/user/create`, method: 'POST', + headers: {'Content-Type': 'application/json', }, + data: reqCreateCompanyUser, signal + }, + options); + } + + + +export const getCreateUserMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqCreateCompanyUser}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{data: ReqCreateCompanyUser}, TContext> => { + +const mutationKey = ['createUser']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {data: ReqCreateCompanyUser}> = (props) => { + const {data} = props ?? {}; + + return createUser(data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type CreateUserMutationResult = NonNullable>> + export type CreateUserMutationBody = ReqCreateCompanyUser + export type CreateUserMutationError = void | HTTPValidationError + + /** + * @summary 회사 유저 생성(일반 권한 고정) + */ +export const useCreateUser = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqCreateCompanyUser}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {data: ReqCreateCompanyUser}, + TContext + > => { + + const mutationOptions = getCreateUserMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** + * @summary 회사 유저 조회 + */ +export const getUser = ( + userId: string, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/company/user/${userId}`, method: 'GET', signal + }, + options); + } + + + + +export const getGetUserQueryKey = (userId?: string,) => { + return [ + `/v1/company/user/${userId}` + ] as const; + } + + +export const getGetUserQueryOptions = >, TError = void | HTTPValidationError>(userId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetUserQueryKey(userId); + + + + const queryFn: QueryFunction>> = ({ signal }) => getUser(userId, requestOptions, signal); + + + + + + return { queryKey, queryFn, enabled: !!(userId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetUserQueryResult = NonNullable>> +export type GetUserQueryError = void | HTTPValidationError + + +export function useGetUser>, TError = void | HTTPValidationError>( + userId: string, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetUser>, TError = void | HTTPValidationError>( + userId: string, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetUser>, TError = void | HTTPValidationError>( + userId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 회사 유저 조회 + */ + +export function useGetUser>, TError = void | HTTPValidationError>( + userId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetUserQueryOptions(userId,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + +/** + * @summary 회사 유저 수정 + */ +export const updateUser = ( + userId: string, + reqUpdateCompanyUser: ReqUpdateCompanyUser, + options?: SecondParameter,) => { + + + return customFetch( + {url: `/v1/company/user/update/${userId}`, method: 'PATCH', + headers: {'Content-Type': 'application/json', }, + data: reqUpdateCompanyUser + }, + options); + } + + + +export const getUpdateUserMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{userId: string;data: ReqUpdateCompanyUser}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{userId: string;data: ReqUpdateCompanyUser}, TContext> => { + +const mutationKey = ['updateUser']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {userId: string;data: ReqUpdateCompanyUser}> = (props) => { + const {userId,data} = props ?? {}; + + return updateUser(userId,data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type UpdateUserMutationResult = NonNullable>> + export type UpdateUserMutationBody = ReqUpdateCompanyUser + export type UpdateUserMutationError = void | HTTPValidationError + + /** + * @summary 회사 유저 수정 + */ +export const useUpdateUser = (options?: { mutation?:UseMutationOptions>, TError,{userId: string;data: ReqUpdateCompanyUser}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {userId: string;data: ReqUpdateCompanyUser}, + TContext + > => { + + const mutationOptions = getUpdateUserMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** + * @summary 회사 유저 삭제 + */ +export const deleteUser = ( + userId: string, + options?: SecondParameter,) => { + + + return customFetch( + {url: `/v1/company/user/delete/${userId}`, method: 'DELETE' + }, + options); + } + + + +export const getDeleteUserMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{userId: string}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{userId: string}, TContext> => { + +const mutationKey = ['deleteUser']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {userId: string}> = (props) => { + const {userId} = props ?? {}; + + return deleteUser(userId,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type DeleteUserMutationResult = NonNullable>> + + export type DeleteUserMutationError = void | HTTPValidationError + + /** + * @summary 회사 유저 삭제 + */ +export const useDeleteUser = (options?: { mutation?:UseMutationOptions>, TError,{userId: string}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {userId: string}, + TContext + > => { + + const mutationOptions = getDeleteUserMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + \ No newline at end of file diff --git a/negodata/front/src/api/generated/model/companyUserData.ts b/negodata/front/src/api/generated/model/companyUserData.ts new file mode 100644 index 0000000..2e5e0cd --- /dev/null +++ b/negodata/front/src/api/generated/model/companyUserData.ts @@ -0,0 +1,28 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { CompanyUserDataName } from './companyUserDataName'; +import type { CompanyUserDataEmail } from './companyUserDataEmail'; +import type { CompanyUserDataContactNumber } from './companyUserDataContactNumber'; +import type { UserStatus } from './userStatus'; +import type { UserRole } from './userRole'; +import type { CompanyUserDataLastAccessedAt } from './companyUserDataLastAccessedAt'; +import type { CompanyUserDataCreatedAt } from './companyUserDataCreatedAt'; +import type { CompanyUserDataUpdatedAt } from './companyUserDataUpdatedAt'; + +export interface CompanyUserData { + user_id: string; + company_id: string; + id: string; + name?: CompanyUserDataName; + email?: CompanyUserDataEmail; + contact_number?: CompanyUserDataContactNumber; + status: UserStatus; + role: UserRole; + last_accessed_at?: CompanyUserDataLastAccessedAt; + created_at?: CompanyUserDataCreatedAt; + updated_at?: CompanyUserDataUpdatedAt; +} diff --git a/negodata/front/src/api/generated/model/companyUserDataContactNumber.ts b/negodata/front/src/api/generated/model/companyUserDataContactNumber.ts new file mode 100644 index 0000000..b0b22b7 --- /dev/null +++ b/negodata/front/src/api/generated/model/companyUserDataContactNumber.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 CompanyUserDataContactNumber = string | null; diff --git a/negodata/front/src/api/generated/model/companyUserDataCreatedAt.ts b/negodata/front/src/api/generated/model/companyUserDataCreatedAt.ts new file mode 100644 index 0000000..f404d0c --- /dev/null +++ b/negodata/front/src/api/generated/model/companyUserDataCreatedAt.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 CompanyUserDataCreatedAt = string | null; diff --git a/negodata/front/src/api/generated/model/companyUserDataEmail.ts b/negodata/front/src/api/generated/model/companyUserDataEmail.ts new file mode 100644 index 0000000..4f7ae11 --- /dev/null +++ b/negodata/front/src/api/generated/model/companyUserDataEmail.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 CompanyUserDataEmail = string | null; diff --git a/negodata/front/src/api/generated/model/companyUserDataLastAccessedAt.ts b/negodata/front/src/api/generated/model/companyUserDataLastAccessedAt.ts new file mode 100644 index 0000000..5934ea6 --- /dev/null +++ b/negodata/front/src/api/generated/model/companyUserDataLastAccessedAt.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 CompanyUserDataLastAccessedAt = string | null; diff --git a/negodata/front/src/api/generated/model/resCreateAccountMsg.ts b/negodata/front/src/api/generated/model/companyUserDataName.ts similarity index 71% rename from negodata/front/src/api/generated/model/resCreateAccountMsg.ts rename to negodata/front/src/api/generated/model/companyUserDataName.ts index 4f87fb8..09040fc 100644 --- a/negodata/front/src/api/generated/model/resCreateAccountMsg.ts +++ b/negodata/front/src/api/generated/model/companyUserDataName.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ResCreateAccountMsg = string | null; +export type CompanyUserDataName = string | null; diff --git a/negodata/front/src/api/generated/model/companyUserDataUpdatedAt.ts b/negodata/front/src/api/generated/model/companyUserDataUpdatedAt.ts new file mode 100644 index 0000000..cfe5ed5 --- /dev/null +++ b/negodata/front/src/api/generated/model/companyUserDataUpdatedAt.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 CompanyUserDataUpdatedAt = string | null; diff --git a/negodata/front/src/api/generated/model/reqCreateAccount.ts b/negodata/front/src/api/generated/model/reqCreateCompanyUser.ts similarity index 74% rename from negodata/front/src/api/generated/model/reqCreateAccount.ts rename to negodata/front/src/api/generated/model/reqCreateCompanyUser.ts index 9fd90fe..795c416 100644 --- a/negodata/front/src/api/generated/model/reqCreateAccount.ts +++ b/negodata/front/src/api/generated/model/reqCreateCompanyUser.ts @@ -5,12 +5,10 @@ * OpenAPI spec version: 0.1.0 */ -export interface ReqCreateAccount { +export interface ReqCreateCompanyUser { id?: string; password?: string; - company_id?: string; name?: string; email?: string; contact_number?: string; - role?: number; } diff --git a/negodata/front/src/api/generated/model/reqUpdateCompanyUser.ts b/negodata/front/src/api/generated/model/reqUpdateCompanyUser.ts new file mode 100644 index 0000000..04d58ee --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCompanyUser.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ReqUpdateCompanyUserName } from './reqUpdateCompanyUserName'; +import type { ReqUpdateCompanyUserEmail } from './reqUpdateCompanyUserEmail'; +import type { ReqUpdateCompanyUserContactNumber } from './reqUpdateCompanyUserContactNumber'; +import type { ReqUpdateCompanyUserStatus } from './reqUpdateCompanyUserStatus'; +import type { ReqUpdateCompanyUserPassword } from './reqUpdateCompanyUserPassword'; + +export interface ReqUpdateCompanyUser { + name?: ReqUpdateCompanyUserName; + email?: ReqUpdateCompanyUserEmail; + contact_number?: ReqUpdateCompanyUserContactNumber; + status?: ReqUpdateCompanyUserStatus; + password?: ReqUpdateCompanyUserPassword; +} diff --git a/negodata/front/src/api/generated/model/reqUpdateCompanyUserContactNumber.ts b/negodata/front/src/api/generated/model/reqUpdateCompanyUserContactNumber.ts new file mode 100644 index 0000000..9c9d955 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCompanyUserContactNumber.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 ReqUpdateCompanyUserContactNumber = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateCompanyUserEmail.ts b/negodata/front/src/api/generated/model/reqUpdateCompanyUserEmail.ts new file mode 100644 index 0000000..f1f6ea8 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCompanyUserEmail.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 ReqUpdateCompanyUserEmail = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateCompanyUserName.ts b/negodata/front/src/api/generated/model/reqUpdateCompanyUserName.ts new file mode 100644 index 0000000..6c35074 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCompanyUserName.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 ReqUpdateCompanyUserName = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateCompanyUserPassword.ts b/negodata/front/src/api/generated/model/reqUpdateCompanyUserPassword.ts new file mode 100644 index 0000000..b64bc68 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCompanyUserPassword.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 ReqUpdateCompanyUserPassword = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateCompanyUserStatus.ts b/negodata/front/src/api/generated/model/reqUpdateCompanyUserStatus.ts new file mode 100644 index 0000000..de4ead2 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCompanyUserStatus.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { UserStatus } from './userStatus'; + +export type ReqUpdateCompanyUserStatus = UserStatus | null; diff --git a/negodata/front/src/api/generated/model/resCompanyUser.ts b/negodata/front/src/api/generated/model/resCompanyUser.ts new file mode 100644 index 0000000..cf32213 --- /dev/null +++ b/negodata/front/src/api/generated/model/resCompanyUser.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from './errorInfo'; +import type { ResCompanyUserMsg } from './resCompanyUserMsg'; +import type { ResCompanyUserUser } from './resCompanyUserUser'; + +export interface ResCompanyUser { + result?: ErrorInfo; + msg?: ResCompanyUserMsg; + user?: ResCompanyUserUser; +} diff --git a/negodata/front/src/api/generated/model/resCompanyUserList.ts b/negodata/front/src/api/generated/model/resCompanyUserList.ts new file mode 100644 index 0000000..da6eecc --- /dev/null +++ b/negodata/front/src/api/generated/model/resCompanyUserList.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from './errorInfo'; +import type { ResCompanyUserListMsg } from './resCompanyUserListMsg'; +import type { CompanyUserData } from './companyUserData'; + +export interface ResCompanyUserList { + result?: ErrorInfo; + msg?: ResCompanyUserListMsg; + total?: number; + page?: number; + size?: number; + users?: CompanyUserData[]; +} diff --git a/negodata/front/src/api/generated/model/resCompanyUserListMsg.ts b/negodata/front/src/api/generated/model/resCompanyUserListMsg.ts new file mode 100644 index 0000000..54d0fae --- /dev/null +++ b/negodata/front/src/api/generated/model/resCompanyUserListMsg.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 ResCompanyUserListMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resCompanyUserMsg.ts b/negodata/front/src/api/generated/model/resCompanyUserMsg.ts new file mode 100644 index 0000000..031ecba --- /dev/null +++ b/negodata/front/src/api/generated/model/resCompanyUserMsg.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 ResCompanyUserMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resCompanyUserUser.ts b/negodata/front/src/api/generated/model/resCompanyUserUser.ts new file mode 100644 index 0000000..f1fdf01 --- /dev/null +++ b/negodata/front/src/api/generated/model/resCompanyUserUser.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { CompanyUserData } from './companyUserData'; + +export type ResCompanyUserUser = CompanyUserData | null; diff --git a/negodata/front/src/api/generated/model/resCreateAccount.ts b/negodata/front/src/api/generated/model/resDeleteCompanyUser.ts similarity index 56% rename from negodata/front/src/api/generated/model/resCreateAccount.ts rename to negodata/front/src/api/generated/model/resDeleteCompanyUser.ts index 527ebe5..7a03135 100644 --- a/negodata/front/src/api/generated/model/resCreateAccount.ts +++ b/negodata/front/src/api/generated/model/resDeleteCompanyUser.ts @@ -5,10 +5,9 @@ * OpenAPI spec version: 0.1.0 */ import type { ErrorInfo } from './errorInfo'; -import type { ResCreateAccountMsg } from './resCreateAccountMsg'; +import type { ResDeleteCompanyUserMsg } from './resDeleteCompanyUserMsg'; -export interface ResCreateAccount { +export interface ResDeleteCompanyUser { result?: ErrorInfo; - msg?: ResCreateAccountMsg; - user_id?: string; + msg?: ResDeleteCompanyUserMsg; } diff --git a/negodata/front/src/api/generated/model/resDeleteCompanyUserMsg.ts b/negodata/front/src/api/generated/model/resDeleteCompanyUserMsg.ts new file mode 100644 index 0000000..ad9f70c --- /dev/null +++ b/negodata/front/src/api/generated/model/resDeleteCompanyUserMsg.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 ResDeleteCompanyUserMsg = string | null; diff --git a/negodata/front/src/api/generated/model/userRole.ts b/negodata/front/src/api/generated/model/userRole.ts index 14c2610..860ab73 100644 --- a/negodata/front/src/api/generated/model/userRole.ts +++ b/negodata/front/src/api/generated/model/userRole.ts @@ -6,7 +6,9 @@ */ /** - * users.role 코드값. + * users.role 코드값. negodata 유저는 전부 회사 직원(관리자측) — +의미 있는 구분은 '직원 계정 관리 권한 유무' 하나뿐이라 2단계로 둔다. +1=일반, 2=최고관리자(직원 계정 생성·관리). */ export type UserRole = typeof UserRole[keyof typeof UserRole]; @@ -14,5 +16,5 @@ export type UserRole = typeof UserRole[keyof typeof UserRole]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const UserRole = { USER: 1, - MANAGER: 2, + OWNER: 2, } as const; diff --git a/negodata/front/src/app/router.tsx b/negodata/front/src/app/router.tsx index 821e90f..0809f23 100644 --- a/negodata/front/src/app/router.tsx +++ b/negodata/front/src/app/router.tsx @@ -1,6 +1,6 @@ import {createBrowserRouter, redirect} from 'react-router'; import {initAuth} from '../features/auth/service'; -import {isLoggedIn} from '../stores/auth'; +import {isLoggedIn, hasRole} from '../stores/auth'; import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout'; import LoginPage from '../pages/login'; import ForbiddenPage from '../pages/forbidden'; @@ -9,6 +9,7 @@ import ProductsPage from '../pages/products'; import PartnersPage from '../pages/partners'; import QuotationPage from '../pages/quotation'; import CardsPage from '../pages/cards'; +import MembersPage from '../pages/members'; export const router = createBrowserRouter([ // dev 전용: import.meta.env.DEV가 false인 프로덕션 빌드에선 이 배열 항목과 @@ -56,6 +57,12 @@ export const router = createBrowserRouter([ {path: 'partners', Component: PartnersPage}, {path: 'quotation', Component: QuotationPage}, {path: 'cards', Component: CardsPage}, + { + // 최고관리자 전용. 부모 loader 가 initAuth 를 마친 뒤 실행되므로 유저 상태가 복원돼 있다. + path: 'members', + loader: () => (hasRole('최고관리자') ? null : redirect('/forbidden')), + Component: MembersPage, + }, ], }, { diff --git a/negodata/front/src/components/layout/AuthenticatedLayout.tsx b/negodata/front/src/components/layout/AuthenticatedLayout.tsx index 444902f..b47f3c0 100644 --- a/negodata/front/src/components/layout/AuthenticatedLayout.tsx +++ b/negodata/front/src/components/layout/AuthenticatedLayout.tsx @@ -9,6 +9,7 @@ const PAGE_TO_PATH: Record = { PARTNERS: '/partners', QUOTATION: '/quotation', CARDS: '/cards', + MEMBERS: '/members', }; export default function AuthenticatedLayout() { diff --git a/negodata/front/src/features/auth/components/ProfileSheet.tsx b/negodata/front/src/features/auth/components/ProfileSheet.tsx new file mode 100644 index 0000000..71f62cf --- /dev/null +++ b/negodata/front/src/features/auth/components/ProfileSheet.tsx @@ -0,0 +1,143 @@ +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { showToast } from '@/lib/notify'; +import { Typography } from '@/components/ui/typography'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Sheet } from '@/components/ui/sheet'; +import { useAuth } from '../useAuth'; +import { updateMe } from '../service'; + +const schema = z.object({ + name: z.string().trim(), + email: z.string().trim().email('이메일 형식을 확인해 주십시오.').or(z.literal('')), + contactNumber: z.string().trim(), + password: z.string(), + passwordConfirm: z.string(), +}); + +type FormValues = z.infer; + +const inputClass = 'text-foreground text-xs'; +const blank = (v: string) => (v.trim() ? v.trim() : null); + +export function ProfileSheet({ open, onClose }: { open: boolean; onClose: () => void }) { + const { user } = useAuth(); + const { + register, + handleSubmit, + setError, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: user?.name ?? '', + email: user?.email ?? '', + contactNumber: user?.contact ?? '', + password: '', + passwordConfirm: '', + }, + }); + + const onValid = async (v: FormValues) => { + if (v.password && v.password.length < 4) { + setError('password', { message: '비밀번호는 4자 이상으로 설정해 주십시오.' }); + return; + } + if (v.password && v.password !== v.passwordConfirm) { + setError('passwordConfirm', { message: '비밀번호가 일치하지 않습니다.' }); + return; + } + try { + await updateMe({ + name: blank(v.name), + email: blank(v.email), + contact_number: blank(v.contactNumber), + ...(v.password ? { password: v.password } : {}), + }); + showToast('내 정보가 수정되었습니다.', 'success'); + onClose(); + } catch (err) { + showToast(err instanceof Error ? err.message : '내 정보 수정 실패', 'error'); + } + }; + + return ( + + + {/* 읽기 전용: 회사 / 로그인ID / 권한 */} +
+
+ 회사 + {user?.company} +
+
+ 로그인 ID + {user?.loginId} +
+
+ 권한등급 + {user?.role} +
+
+ + {/* 이름 */} +
+ 이름 + +
+ + {/* 이메일 */} +
+ 이메일 + + {errors.email &&

{errors.email.message}

} +
+ + {/* 연락처 */} +
+ 연락처 + +
+ + {/* 비밀번호 변경(옵션) */} +
+ 비밀번호 변경 (변경 시에만 입력) + + {errors.password &&

{errors.password.message}

} +
+ + {/* 비밀번호 확인 */} +
+ 비밀번호 확인 + + {errors.passwordConfirm &&

{errors.passwordConfirm.message}

} +
+ +
+ + +
+ +
+ ); +} diff --git a/negodata/front/src/features/auth/service.ts b/negodata/front/src/features/auth/service.ts index 4245c5c..529c074 100644 --- a/negodata/front/src/features/auth/service.ts +++ b/negodata/front/src/features/auth/service.ts @@ -1,4 +1,4 @@ -import {setAccessToken} from '../../api/mutator/custom-fetch'; +import {setAccessToken, customFetch} from '../../api/mutator/custom-fetch'; import { login as loginRequest, refreshToken as refreshRequest, @@ -9,6 +9,7 @@ import type {ErrorInfo} from '../../api/generated/model/errorInfo'; import {useAuthStore, type AuthUser, type UserRole} from '../../stores/auth'; import {UserRole as UserRoleCode} from '../../api/generated/model'; import {USER_ROLE_LABEL} from '../../lib/enumLabels'; +import {toMessage, resultMessage} from '../../lib/apiError'; const ACCESS_KEY = 'negodata.accessToken'; const REFRESH_KEY = 'negodata.refreshToken'; @@ -23,6 +24,7 @@ function ensureOk(res: T): T { function toAuthUser(me: ResMe): AuthUser { return { + userId: me.user_id ?? '', company: me.company?.name ?? '', name: me.name ?? me.id ?? '', loginId: me.id ?? '', @@ -75,6 +77,28 @@ export async function login(loginId: string, password: string): Promise { return me; } +export interface UpdateMePayload { + name?: string | null; + email?: string | null; + contact_number?: string | null; + password?: string | null; +} + +// 본인 정보 수정(PATCH /v1/auth/me). 성공 시 최신 정보로 스토어 갱신. +// HTTP 에러(ApiError)·응답봉투 모두 toMessage/resultMessage 로 한글 통일. +export async function updateMe(payload: UpdateMePayload): Promise { + let me: ResMe; + try { + me = await customFetch({url: '/v1/auth/me', method: 'PATCH', data: payload}); + } catch (err) { + throw new Error(toMessage(err)); + } + const msg = resultMessage(me.result); + if (msg) throw new Error(msg); + useAuthStore.getState().setUser(toAuthUser(me)); + return me; +} + // 백엔드에 logout 엔드포인트가 없으므로 클라이언트 상태만 비운다. export async function logout(): Promise { localStorage.removeItem(ACCESS_KEY); diff --git a/negodata/front/src/features/members/api.ts b/negodata/front/src/features/members/api.ts new file mode 100644 index 0000000..85aa5cb --- /dev/null +++ b/negodata/front/src/features/members/api.ts @@ -0,0 +1,51 @@ +import { customFetch } from '@/api/mutator/custom-fetch'; +import type { ErrorInfo } from '@/api/generated/model/errorInfo'; +import type { + CompanyUserData, + ReqCreateCompanyUser, + ReqUpdateCompanyUser, +} from './types'; + +// 최고관리자 전용 /v1/company/user 엔드포인트 클라이언트. +// 생성 클라이언트(orval)가 아직 없어 공통 customFetch mutator 로 직접 호출한다. +// (백엔드 라우터가 OpenAPI 에 노출된 뒤 `npm run orval` 하면 동일 시그니처의 생성 클라이언트가 만들어진다) + +export interface ListCompanyUsersParams { + search?: string; + page?: number; + size?: number; +} + +export interface ResCompanyUserList { + result?: ErrorInfo; + total?: number; + page?: number; + size?: number; + users?: CompanyUserData[]; +} + +export interface ResCompanyUser { + result?: ErrorInfo; + user?: CompanyUserData; +} + +export interface ResDeleteCompanyUser { + result?: ErrorInfo; +} + +export const listCompanyUsers = (params: ListCompanyUsersParams, signal?: AbortSignal) => + customFetch({ + url: '/v1/company/user/list', + method: 'GET', + params: params as Record, + signal, + }); + +export const createCompanyUser = (data: ReqCreateCompanyUser) => + customFetch({ url: '/v1/company/user/create', method: 'POST', data }); + +export const updateCompanyUser = (userId: string, data: ReqUpdateCompanyUser) => + customFetch({ url: `/v1/company/user/update/${userId}`, method: 'PATCH', data }); + +export const deleteCompanyUser = (userId: string) => + customFetch({ url: `/v1/company/user/delete/${userId}`, method: 'DELETE' }); diff --git a/negodata/front/src/features/members/components/MemberFormSheet.tsx b/negodata/front/src/features/members/components/MemberFormSheet.tsx new file mode 100644 index 0000000..f94182d --- /dev/null +++ b/negodata/front/src/features/members/components/MemberFormSheet.tsx @@ -0,0 +1,256 @@ +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Trash2 } from 'lucide-react'; +import { showToast } from '@/lib/notify'; +import { Typography } from '@/components/ui/typography'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Sheet } from '@/components/ui/sheet'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + UserStatus, + USER_STATUS_LABEL, + type Member, + type ReqCreateCompanyUser, + type ReqUpdateCompanyUser, +} from '../types'; + +// 기본 검증은 zod(이메일 형식 등)로, create/edit 별 id·password 규칙은 onValid 에서 setError 로 처리한다. +const schema = z.object({ + id: z.string(), + password: z.string(), + passwordConfirm: z.string(), + name: z.string().trim(), + email: z.string().trim().email('이메일 형식을 확인해 주십시오.').or(z.literal('')), + contactNumber: z.string().trim(), + status: z.number(), +}); + +type FormValues = z.infer; + +type MemberFormSheetProps = { + open: boolean; + mode: 'create' | 'edit'; + member: Member | null; + onCreate: (data: ReqCreateCompanyUser) => Promise; + onUpdate: (userId: string, data: ReqUpdateCompanyUser) => Promise; + onDelete: (userId: string, label: string) => void; + onClose: () => void; +}; + +function buildDefaults(mode: 'create' | 'edit', member: Member | null): FormValues { + if (mode === 'edit' && member) { + return { + id: member.id, + password: '', + passwordConfirm: '', + name: member.name ?? '', + email: member.email ?? '', + contactNumber: member.contact_number ?? '', + status: member.status, + }; + } + return { id: '', password: '', passwordConfirm: '', name: '', email: '', contactNumber: '', status: UserStatus.ACTIVE }; +} + +const inputClass = 'text-foreground text-xs'; +const blank = (v: string) => (v.trim() ? v.trim() : null); + +export function MemberFormSheet({ + open, + mode, + member, + onCreate, + onUpdate, + onDelete, + onClose, +}: MemberFormSheetProps) { + const { + register, + control, + handleSubmit, + setError, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: buildDefaults(mode, member), + }); + + const onValid = async (v: FormValues) => { + // 비밀번호는 입력했을 때(또는 생성 시)만 4자 이상 + 확인 일치. + if ((mode === 'create' || v.password) && v.password.length < 4) { + setError('password', { message: '비밀번호는 4자 이상으로 설정해 주십시오.' }); + return; + } + if ((mode === 'create' || v.password) && v.password !== v.passwordConfirm) { + setError('passwordConfirm', { message: '비밀번호가 일치하지 않습니다.' }); + return; + } + if (mode === 'create') { + if (!v.id.trim()) { + setError('id', { message: '로그인 ID를 입력해 주십시오.' }); + return; + } + try { + await onCreate({ + id: v.id ?? '', + password: v.password, + name: v.name.trim() || undefined, + email: v.email.trim() || undefined, + contact_number: v.contactNumber.trim() || undefined, + }); + showToast('신규 계정이 생성되었습니다.', 'success'); + onClose(); + } catch (err) { + showToast(err instanceof Error ? err.message : '계정 생성 실패', 'error'); + } + } else if (member) { + try { + await onUpdate(member.user_id, { + name: blank(v.name), + email: blank(v.email), + contact_number: blank(v.contactNumber), + status: v.status as Member['status'], + ...(v.password ? { password: v.password } : {}), + }); + showToast('계정 정보가 수정되었습니다.', 'success'); + onClose(); + } catch (err) { + showToast(err instanceof Error ? err.message : '계정 수정 실패', 'error'); + } + } + }; + + return ( + +
+ {/* 로그인 ID */} +
+ 로그인 ID + {mode === 'create' ? ( + + ) : ( + + )} + {errors.id &&

{errors.id.message}

} +
+ + {/* 비밀번호 */} +
+ + {mode === 'create' ? '비밀번호' : '비밀번호 초기화 (변경 시에만 입력)'} + + + {errors.password &&

{errors.password.message}

} +
+ + {/* 비밀번호 확인 */} +
+ 비밀번호 확인 + + {errors.passwordConfirm &&

{errors.passwordConfirm.message}

} +
+ +
+ {/* 이름 */} +
+ 이름 + +
+ {/* 상태 (edit 전용) */} + {mode === 'edit' && ( +
+ 상태 + ( + + )} + /> +
+ )} +
+ + {/* 이메일 */} +
+ 이메일 + + {errors.email &&

{errors.email.message}

} +
+ + {/* 연락처 */} +
+ 연락처 + +
+ + {/* 버튼 */} +
+ {mode === 'edit' && member && ( + + )} +
+ + +
+
+
+
+ ); +} diff --git a/negodata/front/src/features/members/components/MemberTable.tsx b/negodata/front/src/features/members/components/MemberTable.tsx new file mode 100644 index 0000000..0d9241b --- /dev/null +++ b/negodata/front/src/features/members/components/MemberTable.tsx @@ -0,0 +1,103 @@ +import { Badge } from '@/components/ui/badge'; +import { DataTable } from '@/components/ui/data-table'; +import { TablePagination } from '@/components/ui/table-pagination'; +import { USER_ROLE_LABEL } from '@/lib/enumLabels'; +import { UserStatus, USER_STATUS_LABEL, type Member } from '../types'; + +type MemberTableProps = { + data: Member[]; + onRowClick: (member: Member) => void; + page: number; + totalPages: number; + totalCount: number; + pageSize: number; + onPageChange: (page: number) => void; +}; + +const statusBadgeClass = (status: number) => + status === UserStatus.ACTIVE + ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/20 dark:text-emerald-400 border border-emerald-200' + : 'bg-zinc-100 text-zinc-500 border border-zinc-300'; + +const fmtDate = (v?: string | null) => (v ? new Date(v).toLocaleDateString('sv-SE') : '-'); + +export function MemberTable({ + data, + onRowClick, + page, + totalPages, + totalCount, + pageSize, + onPageChange, +}: MemberTableProps) { + return ( + m.user_id} + onRowClick={onRowClick} + empty="등록된 회사 계정이 없습니다." + footer={ + + } + columns={[ + { + header: '로그인 ID', + align: 'left', + headClassName: 'w-1/5', + cell: (m) => {m.id}, + }, + { + header: '이름', + align: 'left', + cell: (m) => {m.name || '-'}, + }, + { + header: '이메일 / 연락처', + align: 'left', + mobileBlock: true, + cell: (m) => ( +
+
{m.email || '-'}
+
{m.contact_number || '-'}
+
+ ), + }, + { + header: '권한', + align: 'center', + cell: (m) => ( + + {USER_ROLE_LABEL[m.role]} + + ), + }, + { + header: '상태', + align: 'center', + cell: (m) => ( + + {USER_STATUS_LABEL[m.status]} + + ), + }, + { + header: '최근 접속', + align: 'center', + cellClassName: 'font-mono text-[10px] text-muted-foreground', + cell: (m) => fmtDate(m.last_accessed_at), + }, + ]} + /> + ); +} diff --git a/negodata/front/src/features/members/hooks/useMembers.ts b/negodata/front/src/features/members/hooks/useMembers.ts new file mode 100644 index 0000000..0ae444b --- /dev/null +++ b/negodata/front/src/features/members/hooks/useMembers.ts @@ -0,0 +1,65 @@ +import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { ErrorInfo } from '@/api/generated/model/errorInfo'; +import { toMessage, resultMessage } from '@/lib/apiError'; +import { + listCompanyUsers, + createCompanyUser, + updateCompanyUser, + deleteCompanyUser, + type ListCompanyUsersParams, +} from '../api'; +import type { Member, ReqCreateCompanyUser, ReqUpdateCompanyUser } from '../types'; + +const LIST_KEY = '/v1/company/user/list'; + +// HTTP 에러(ApiError, 예: 403)와 응답봉투(result.success=false)를 같은 한글 에러로 일원화. +async function call(p: Promise): Promise { + let res: T; + try { + res = await p; + } catch (err) { + throw new Error(toMessage(err)); + } + const msg = resultMessage(res.result); + if (msg) throw new Error(msg); + return res; +} + +export function useMembers(params: ListCompanyUsersParams) { + const queryClient = useQueryClient(); + + const membersQuery = useQuery({ + queryKey: [LIST_KEY, params], + queryFn: ({ signal }) => listCompanyUsers(params, signal), + placeholderData: keepPreviousData, + }); + + // /v1/company/user/list 로 시작하는 모든 페이지 쿼리를 prefix 매칭으로 재조회. + const refresh = () => queryClient.invalidateQueries({ queryKey: [LIST_KEY] }); + + const createMember = async (data: ReqCreateCompanyUser) => { + await call(createCompanyUser(data)); + await refresh(); + }; + const updateMember = async (userId: string, data: ReqUpdateCompanyUser) => { + await call(updateCompanyUser(userId, data)); + await refresh(); + }; + const deleteMember = async (userId: string) => { + await call(deleteCompanyUser(userId)); + await refresh(); + }; + + const members: Member[] = membersQuery.data?.users ?? []; + const total = membersQuery.data?.total ?? 0; + + return { + members, + total, + createMember, + updateMember, + deleteMember, + refresh, + isLoading: membersQuery.isLoading, + }; +} diff --git a/negodata/front/src/features/members/types.ts b/negodata/front/src/features/members/types.ts new file mode 100644 index 0000000..e668efe --- /dev/null +++ b/negodata/front/src/features/members/types.ts @@ -0,0 +1,44 @@ +import { UserRole } from '@/api/generated/model'; + +// 회사 유저(계정) 상태 코드 — 백엔드 UserStatus 미러. +// (orval 재생성 전까지 로컬 정의. 재생성 후 @/api/generated/model 의 UserStatus 로 교체 가능) +export const UserStatus = { ACTIVE: 1, INACTIVE: 2 } as const; +export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus]; + +export const USER_STATUS_LABEL: Record = { + [UserStatus.ACTIVE]: '활성', + [UserStatus.INACTIVE]: '비활성', +}; + +// 백엔드 CompanyUserData 와 1:1. +export interface CompanyUserData { + user_id: string; + company_id: string; + id: string; // 로그인 ID + name?: string | null; + email?: string | null; + contact_number?: string | null; + status: UserStatus; + role: UserRole; + last_accessed_at?: string | null; + created_at?: string | null; + updated_at?: string | null; +} + +export type Member = CompanyUserData; + +export interface ReqCreateCompanyUser { + id: string; + password: string; + name?: string; + email?: string; + contact_number?: string; +} + +export interface ReqUpdateCompanyUser { + name?: string | null; + email?: string | null; + contact_number?: string | null; + status?: UserStatus | null; + password?: string | null; +} diff --git a/negodata/front/src/lib/apiError.ts b/negodata/front/src/lib/apiError.ts new file mode 100644 index 0000000..4e820b7 --- /dev/null +++ b/negodata/front/src/lib/apiError.ts @@ -0,0 +1,46 @@ +import { ApiError } from '@/api/mutator/custom-fetch'; +import type { ErrorInfo } from '@/api/generated/model/errorInfo'; + +// 백엔드 식별자(ErrorType.name = 응답봉투 result.desc / HTTPException detail) → 사용자용 한글. +// 서버는 안정적인 코드명만 주고, 표시 문구는 여기서만 정한다(서버·프론트 메시지 단일화). +const MESSAGES: Record = { + // HTTP 예외(라우터 단에서 raise) + HTTP_FORBIDDEN: '최고관리자 권한이 필요합니다.', + HTTP_INVALID_CLIENT_ACCESS: '인증에 실패했습니다. 다시 로그인해 주세요.', + HTTP_ACCESS_TOKEN_EXPIRED: '세션이 만료되었습니다. 다시 로그인해 주세요.', + HTTP_REFRESH_TOKEN_EXPIRED: '세션이 만료되었습니다. 다시 로그인해 주세요.', + HTTP_INVALID_TOKEN_ACCESS: '인증에 실패했습니다. 다시 로그인해 주세요.', + // 응답 봉투(result.success=false) + ACCOUNT_INVALID_INFO: '아이디 또는 비밀번호가 올바르지 않습니다.', + ACCOUNT_ALREADY_EXIST: '이미 사용 중인 로그인 ID 입니다.', + ACCOUNT_BLOCKED_USER: '비활성화된 계정입니다.', + ACCOUNT_NOT_FOUND: '대상 계정을 찾을 수 없습니다.', + ACCOUNT_FORBIDDEN: '해당 계정은 수정·삭제할 수 없습니다(최고관리자 대상).', +}; + +const STATUS_FALLBACK: Record = { + 401: '인증에 실패했습니다. 다시 로그인해 주세요.', + 403: MESSAGES.HTTP_FORBIDDEN, + 404: '대상을 찾을 수 없습니다.', + 500: '서버 오류가 발생했습니다.', +}; + +const DEFAULT = '요청 처리 중 오류가 발생했습니다.'; + +const byKey = (key?: string | null): string | null => (key ? MESSAGES[key] ?? null : null); + +// HTTP 에러(ApiError)/일반 에러 → 사용자용 한글. catch 블록에서 사용. +export function toMessage(err: unknown, fallback: string = DEFAULT): string { + if (err instanceof ApiError) { + const detail = (err.data as { detail?: string } | null)?.detail; + return byKey(detail) ?? STATUS_FALLBACK[err.status] ?? fallback; + } + if (err instanceof Error) return err.message; + return fallback; +} + +// 응답 봉투(result.success=false) → 한글. 정상이면 null. +export function resultMessage(result?: ErrorInfo, fallback = '요청에 실패했습니다.'): string | null { + if (!result || result.success !== false) return null; + return byKey(result.desc) ?? result.desc ?? fallback; +} diff --git a/negodata/front/src/pages/members.tsx b/negodata/front/src/pages/members.tsx new file mode 100644 index 0000000..bac9412 --- /dev/null +++ b/negodata/front/src/pages/members.tsx @@ -0,0 +1,96 @@ +import { Plus } from 'lucide-react'; +import { useOverlayRouter } from '@/lib/useOverlayRouter'; +import { showToast } from '@/lib/notify'; +import { confirm } from '@/lib/confirm'; +import { PageContainer } from '@/components/layout/PageContainer'; +import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar'; +import { Button } from '@/components/ui/button'; +import { useServerList } from '@/lib/useServerList'; +import { useMembers } from '@/features/members/hooks/useMembers'; +import { MemberTable } from '@/features/members/components/MemberTable'; +import { MemberFormSheet } from '@/features/members/components/MemberFormSheet'; +import type { Member } from '@/features/members/types'; +import type { ListCompanyUsersParams } from '@/features/members/api'; + +export default function MembersPage() { + const list = useServerList({ pageSize: 10 }); + const params: ListCompanyUsersParams = { + search: list.debouncedSearch || undefined, + page: list.page, + size: list.pageSize, + }; + + const { members, total, createMember, updateMember, deleteMember } = useMembers(params); + const totalPages = list.totalPages(total); + + const overlay = useOverlayRouter(['new', 'detail']); + const editId = overlay.get('detail'); + const editing = editId ? members.find((m) => m.user_id === editId) ?? null : null; + const formMode: 'create' | 'edit' = editId ? 'edit' : 'create'; + const isFormOpen = overlay.has('new') || !!editing; + + const openCreate = () => overlay.open('new'); + const openEdit = (member: Member) => overlay.open('detail', member.user_id); + + const handleDelete = async (userId: string, label: string) => { + if ( + await confirm({ + title: '계정 삭제', + description: `[${label}] 계정을 삭제하시겠습니까?`, + confirmText: '삭제', + destructive: true, + }) + ) { + try { + await deleteMember(userId); + showToast('계정이 삭제되었습니다.', 'info'); + } catch (err) { + showToast(err instanceof Error ? err.message : '계정 삭제 실패', 'error'); + } + } + }; + + return ( + + + + 신규 계정 생성 + + } + > + list.setSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()} + placeholder="로그인 ID, 이름 또는 이메일로 검색..." + /> + + + + + {isFormOpen && ( + + )} + + ); +} diff --git a/negodata/front/src/stores/auth.ts b/negodata/front/src/stores/auth.ts index 8bdbe6d..167c5c6 100644 --- a/negodata/front/src/stores/auth.ts +++ b/negodata/front/src/stores/auth.ts @@ -1,8 +1,9 @@ import {create} from 'zustand'; -export type UserRole = '관리자' | '일반'; +export type UserRole = '최고관리자' | '일반'; export interface AuthUser { + userId: string; company: string; name: string; loginId: string; From b42d084063ed4bb917777c4d651d6d1e63171b47 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 29 Jun 2026 13:32:13 +0900 Subject: [PATCH 09/20] =?UTF-8?q?[feat]=20negodata/front:=20=EC=83=81?= =?UTF-8?q?=ED=92=88=20UI=20=EB=B3=B4=EA=B0=95=20=E2=80=94=20=EB=A7=A4?= =?UTF-8?q?=EC=9E=85=EA=B0=80=C2=B7=ED=8C=90=EB=A7=A4=EA=B0=80=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EB=93=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 상품 등록/목록·엑셀 업로드 화면에 매입가·판매가 등 가격 필드 반영. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/features/products/components/ExcelUploadModal.tsx | 4 +++- .../src/features/products/components/ProductFormSheet.tsx | 7 ++++--- .../src/features/products/components/ProductTable.tsx | 4 ++-- negodata/front/src/features/products/hooks/useProducts.ts | 4 ++-- negodata/front/src/features/products/types.ts | 3 --- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/negodata/front/src/features/products/components/ExcelUploadModal.tsx b/negodata/front/src/features/products/components/ExcelUploadModal.tsx index c208868..3100f33 100644 --- a/negodata/front/src/features/products/components/ExcelUploadModal.tsx +++ b/negodata/front/src/features/products/components/ExcelUploadModal.tsx @@ -171,7 +171,9 @@ function toItemCreate(row: RawRow): ItemCreate { delivery_type: DELIVERY_LABEL_TO_CODE[row.delivery_type.trim()] ?? undefined, vat_yn: row.vat_yn.trim() ? parseYn(row.vat_yn) : undefined, delivery_fee_yn: row.delivery_fee_yn.trim() ? parseYn(row.delivery_fee_yn) : undefined, - internet_lowest_price_yn: false, + // minPrice(최저한도) = 인터넷 최저가(실값) → internet_lowest_price 로 저장(수기 폼과 동일). + internet_lowest_price: row.minPrice, + internet_lowest_price_yn: row.minPrice > 0, }; } diff --git a/negodata/front/src/features/products/components/ProductFormSheet.tsx b/negodata/front/src/features/products/components/ProductFormSheet.tsx index 3556da7..2cce47b 100644 --- a/negodata/front/src/features/products/components/ProductFormSheet.tsx +++ b/negodata/front/src/features/products/components/ProductFormSheet.tsx @@ -12,7 +12,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Sheet } from '@/components/ui/sheet'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { type Product, toMinPrice } from '../types'; +import { type Product } from '../types'; // 폼 검증 스키마. 필수: 상품명/상품코드/단가/최저가. 나머지는 선택. const schema = z.object({ @@ -60,7 +60,7 @@ function buildDefaults(mode: 'create' | 'edit', product: Product | null): FormVa code: product.code || '', category: product.category || '', price: product.price || 0, - minPrice: toMinPrice(product.price), + minPrice: product.internet_lowest_price ?? 0, modelName: product.model_name || '', specification: product.spec || '', manufacturer: product.manufacturer || '', @@ -129,7 +129,7 @@ export function ProductFormSheet({ const deliveryTypes = DELIVERY_TYPE_OPTIONS; - // minPrice는 화면 전용(서버 미전송). 검증된 값만 payload로. + // minPrice = 인터넷 최저가(실값) → internet_lowest_price 로 저장한다. const onValid = async (v: FormValues) => { // 기존 카테고리면 그 category_type(id) 재사용, 처음 쓰는 카테고리면 max+1 부여. const categoryName = v.category.trim(); @@ -151,6 +151,7 @@ export function ProductFormSheet({ vat_yn: v.vatYn, delivery_fee_yn: v.deliveryFeeYn, internet_lowest_price_yn: v.internetLowestPriceYn, + internet_lowest_price: v.minPrice, purchase_price: v.purchasePrice, selling_price: v.sellingPrice, }; diff --git a/negodata/front/src/features/products/components/ProductTable.tsx b/negodata/front/src/features/products/components/ProductTable.tsx index bcbfbd2..c505592 100644 --- a/negodata/front/src/features/products/components/ProductTable.tsx +++ b/negodata/front/src/features/products/components/ProductTable.tsx @@ -2,7 +2,7 @@ import { Image as ImageIcon } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { DataTable } from '@/components/ui/data-table'; import { TablePagination } from '@/components/ui/table-pagination'; -import { type Product, toMinPrice } from '../types'; +import { type Product } from '../types'; type ProductTableProps = { data: Product[]; @@ -91,7 +91,7 @@ export function ProductTable({ header: '인터넷 최저가', align: 'right', cellClassName: 'font-mono font-semibold text-rose-600 dark:text-rose-400', - cell: (prod) => `₩${(prod.minPrice || toMinPrice(prod.price)).toLocaleString()}`, + cell: (prod) => (prod.internet_lowest_price != null ? `₩${Number(prod.internet_lowest_price).toLocaleString()}` : '-'), }, ]} /> diff --git a/negodata/front/src/features/products/hooks/useProducts.ts b/negodata/front/src/features/products/hooks/useProducts.ts index 341db9a..22e628b 100644 --- a/negodata/front/src/features/products/hooks/useProducts.ts +++ b/negodata/front/src/features/products/hooks/useProducts.ts @@ -12,7 +12,7 @@ import type { ReqUpdateItem } from '@/api/generated/model/reqUpdateItem'; import type { ResItem } from '@/api/generated/model/resItem'; import type { ItemData } from '@/api/generated/model/itemData'; import type { BulkFailure } from '@/lib/excel'; -import { type Product, toMinPrice } from '../types'; +import { type Product } from '../types'; // 엑셀 중복검사 + 최저가 모달은 "현재 페이지 밖"의 상품도 코드/ID로 조회해야 해서 // 전체 목록(최대 100건)을 따로 받는다. (카테고리 목록은 더 이상 여기서 만들지 않는다 — 아래 categoriesQuery.) @@ -20,7 +20,7 @@ const META_PARAMS: ListItemsParams = { size: 100 }; // 서버 ItemData → UI Product (화면 전용 파생 필드 부여). function toProduct(it: ItemData): Product { - return { ...it, id: it.item_id, minPrice: toMinPrice(it.price), status: 'ACTIVE' }; + return { ...it, id: it.item_id, minPrice: it.internet_lowest_price ?? 0, status: 'ACTIVE' }; } // 서버 공통응답(result.success=false)을 한글 사유로 변환. 정상이면 null. diff --git a/negodata/front/src/features/products/types.ts b/negodata/front/src/features/products/types.ts index f1b2602..8b47d81 100644 --- a/negodata/front/src/features/products/types.ts +++ b/negodata/front/src/features/products/types.ts @@ -1,4 +1 @@ export type { Product } from '@/types'; - -// 인터넷 최저가 데모 산정(표준 단가의 83%). 서버 미연동 — 표시/초기값 용도. -export const toMinPrice = (price?: number | null) => Math.round((price || 0) * 0.83); From 019f3dbeac036553e99391a71afa4a927cbbed63 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 29 Jun 2026 13:33:08 +0900 Subject: [PATCH 10/20] =?UTF-8?q?[feat]=20negodata:=20=EA=B2=AC=EC=A0=81?= =?UTF-8?q?=20=EB=AA=A9=ED=91=9C=EA=B0=80=20=EC=82=B0=EC=A0=95=20=EA=B0=9C?= =?UTF-8?q?=ED=8E=B8=20+=20=ED=98=91=EC=83=81=20=EC=B4=88=EC=B2=AD?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20+=20=EC=B9=B4=EB=93=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EA=B5=AC=EB=B6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [견적·목표가] - 목표가 산정 KTC 기준 정비(MD 우선, 없으면 인터넷최저가·매입가·판매가 중 최저 / 신규는 인터넷최저가만) - 인터넷 평균 수수료 0.078 상수화(견적설정 컬럼 제거) - 앵커링가 세션 저장 + 목표가 클릭 시 산정내역 모달 - 재생성 시 목표가·앵커링가 재계산 없이 직전 값 상속(KTC) - MD 제시가·협력사 유형(유통·제조·총판·기타) 입력 [협상카드] - 사용 구분(공통/신규견적전용/재견적전용) [협상 진행] - 협력사 초청 메일 발송(일괄/개별·재발송, 발송상태 표시) [기타] - 견적상세 창 닫기 버그 수정, 불필요한 컬럼 주석 정리 - DB: 기존 DB는 postgres-init/04-alter.sql 적용 필요(자동 마이그레이션 없음) Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/common/database/model/models.py | 13 +- .../backend/common/database/model/models.py | 20 +-- negodata/backend/common/models/gmodel.py | 5 +- negodata/backend/config/config_models.py | 20 +++ negodata/backend/config/server_configs.py | 4 +- negodata/backend/crud/quotation_crud.py | 68 +++++++- negodata/backend/requirements.txt | 2 + negodata/backend/router/router.py | 5 +- .../backend/router/v1/quotation/protocol.py | 9 + .../backend/router/v1/quotation/quotation.py | 11 ++ .../router/v1/quotation_setting/protocol.py | 3 - negodata/backend/services/email.py | 125 ++++++++++++++ .../email_templates/invite_email.html | 70 ++++++++ .../backend/services/quotation_service.py | 122 ++++++++++++- .../services/quotation_setting_service.py | 1 - .../src/api/generated/model/cardUsageType.ts | 4 +- .../front/src/api/generated/model/index.ts | 36 +++- .../api/generated/model/listUsersParams.ts | 22 +++ .../generated/model/quotationSettingData.ts | 1 - .../model/reqCreateQuotationSetting.ts | 1 - .../src/api/generated/model/reqUpdateMe.ts | 17 ++ ...rageFee.ts => reqUpdateMeContactNumber.ts} | 2 +- .../api/generated/model/reqUpdateMeEmail.ts | 8 + .../api/generated/model/reqUpdateMeName.ts | 8 + .../generated/model/reqUpdateMePassword.ts | 8 + .../model/reqUpdateQuotationSetting.ts | 2 - .../api/generated/model/resNotifySessions.ts | 17 ++ .../generated/model/resNotifySessionsMsg.ts | 8 + .../src/api/generated/model/sessionData.ts | 4 + .../generated/model/sessionDataEmailSentAt.ts | 8 + .../model/sessionDataTargetAnchoringPrice.ts | 8 + .../src/api/generated/model/supplierType.ts | 5 +- .../src/api/generated/model/userStatus.ts | 18 ++ .../src/api/generated/quotation/quotation.ts | 125 ++++++++++++++ .../front/src/components/layout/Layout.tsx | 34 +++- .../front/src/components/ui/typography.tsx | 5 + .../components/QuotationCreateModal.tsx | 114 ++++++------ .../DrawerHeaderCards.tsx | 10 +- .../QuotationCardsTab.tsx | 3 +- .../SessionsStatusTab.tsx | 159 ++++++++++++++--- .../QuotationDetailSheet/TargetPriceModal.tsx | 162 ++++++++++++++++++ .../components/QuotationDetailSheet/index.tsx | 51 +++++- .../components/QuotationSettingsModal.tsx | 8 +- .../quotations/components/QuotationTable.tsx | 7 +- .../quotations/hooks/useQuotations.ts | 56 +++++- .../front/src/features/quotations/types.ts | 9 +- negodata/front/src/lib/enumLabels.ts | 2 +- negodata/front/src/pages/quotation.tsx | 10 +- negodata/front/src/types.ts | 2 +- postgres-init/01-schema.sql | 4 +- postgres-init/04-alter.sql | 10 +- 51 files changed, 1263 insertions(+), 163 deletions(-) create mode 100644 negodata/backend/services/email.py create mode 100644 negodata/backend/services/email_templates/invite_email.html create mode 100644 negodata/front/src/api/generated/model/listUsersParams.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateMe.ts rename negodata/front/src/api/generated/model/{reqUpdateQuotationSettingInternetAverageFee.ts => reqUpdateMeContactNumber.ts} (62%) create mode 100644 negodata/front/src/api/generated/model/reqUpdateMeEmail.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateMeName.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateMePassword.ts create mode 100644 negodata/front/src/api/generated/model/resNotifySessions.ts create mode 100644 negodata/front/src/api/generated/model/resNotifySessionsMsg.ts create mode 100644 negodata/front/src/api/generated/model/sessionDataEmailSentAt.ts create mode 100644 negodata/front/src/api/generated/model/sessionDataTargetAnchoringPrice.ts create mode 100644 negodata/front/src/api/generated/model/userStatus.ts create mode 100644 negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx diff --git a/backend/common/database/model/models.py b/backend/common/database/model/models.py index be2a365..70ec1a3 100644 --- a/backend/common/database/model/models.py +++ b/backend/common/database/model/models.py @@ -85,9 +85,9 @@ class items(MAIN_BASE): vat_yn = Column(Boolean, nullable=True) # 부가세 포함 여부 delivery_fee_yn = Column(Boolean, nullable=True) # 배송비 포함 여부 internet_lowest_price_yn = Column(Boolean, nullable=False, server_default=text("false")) # 최저가 솔루션 보조 컬럼 - internet_lowest_price = Column(BigInteger, nullable=True) # 인터넷 최저가 실값(원). 목표가 산정용(KTC: ×(1−수수료)) - purchase_price = Column(BigInteger, nullable=True) # 매입가(원). 목표가 후보(그대로). KTC items.purchase_price - selling_price = Column(BigInteger, nullable=True) # 판매가(원). 목표가 후보(×(1−마진율)), 유통형만. KTC items.selling_price + internet_lowest_price = Column(BigInteger, nullable=True) + purchase_price = Column(BigInteger, nullable=True) + selling_price = Column(BigInteger, nullable=True) category_type = Column(Integer, nullable=False, server_default=text("1")) # 카테고리 조회용 자동 증가 숫자 created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC) updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신) @@ -111,7 +111,7 @@ class sessions(MAIN_BASE): qt_round = Column(Integer, nullable=False) # 견적 라운드(스냅샷) qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적 (QtType) target_price = Column(BigInteger, nullable=False) # 목표가(원) - target_anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원)=floor(목표가×(1−anchoring_value)). KTC sessions.target_anchoring_price + target_anchoring_price = Column(BigInteger, nullable=True) status = Column(SmallInteger, nullable=False) # 진행 상태 (SessionStatus 코드) bid_price = Column(BigInteger, nullable=True) # 입찰가(원) bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각 @@ -148,8 +148,8 @@ class quotations(MAIN_BASE): manager_email = Column(String(255), nullable=True) # 담당자 이메일 manager_contact_number = Column(String(20), nullable=True) # 담당자 연락처 memo = Column(String(100), nullable=True) # 메모 - md_price = Column(BigInteger, nullable=True) # MD 제시가(원). 목표가 산정 최우선값 - supplier_type = Column(SmallInteger, nullable=True) # 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록 + md_price = Column(BigInteger, nullable=True) + supplier_type = Column(SmallInteger, nullable=True) iteration = Column(Integer, nullable=False, server_default=text("0")) # 반복 횟수 preferred_sp_yn = Column(Boolean, nullable=True) # 선호 공급사 지정 여부 preferred_sp_id = Column(UUID(as_uuid=True), nullable=True) # 선호 공급사(partner.suppliers.supplier_id) @@ -174,7 +174,6 @@ class quotation_settings(MAIN_BASE): user_id = Column(UUID(as_uuid=True), nullable=False) # 생성 유저(company.users.user_id) target_margin_rate = Column(Numeric(8, 6), nullable=False) # 목표 마진율 anchoring_value = Column(Numeric(8, 6), nullable=False, server_default=text("0.01")) # 앵커링 값(비율) — anchor=round(target*(1-value)) - internet_average_fee = Column(Numeric(8, 6), nullable=False, server_default=text("0.078")) # 인터넷 평균 수수료율. 목표가=인터넷최저가×(1−값). KTC QuotationPrice.internet_average_fee(Float) card_count = Column(Integer, nullable=False, server_default=text("3")) # 협상 내 협상카드 사용 횟수 created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC) updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신) diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index 9367ee1..91ff9b4 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -86,9 +86,9 @@ class items(MainTableMixin, MAIN_BASE): price = Column(BigInteger, nullable=True) # 금액(원), 스키마 BIGINT internet_lowest_price_yn = Column(Boolean, nullable=False, default=False) # 최저가 솔루션 원자성 보존용 - internet_lowest_price = Column(BigInteger, nullable=True) # 인터넷 최저가 실값(원). 목표가 산정용(KTC: ×(1−수수료)) - purchase_price = Column(BigInteger, nullable=True) # 매입가(원). 목표가 후보(그대로). KTC items.purchase_price(Integer) - selling_price = Column(BigInteger, nullable=True) # 판매가(원). 목표가 후보(×(1−마진율)), 유통형만. KTC items.selling_price(Integer) + internet_lowest_price = Column(BigInteger, nullable=True) + purchase_price = Column(BigInteger, nullable=True) + selling_price = Column(BigInteger, nullable=True) moq = Column(String(50), nullable=True) # 최소 주문 수량 lead_time = Column(SmallInteger, nullable=True) # 주문 후 배송 도착까지 시간 @@ -124,7 +124,7 @@ class nego_cards(MainTableMixin, MAIN_BASE): number = Column(String(10), nullable=True) # 식별번호(카드코드) script = Column(String(255), nullable=True) # 협상 스크립트(평문 미리보기) edit_script = Column(JSONB, nullable=True) # 편집된 스크립트(Slate JSON) - usage_type = Column(SmallInteger, nullable=False, default=1) # 카드 적용 견적 구분(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용 + usage_type = Column(SmallInteger, nullable=False, default=1) class wild_cards(MainTableMixin, MAIN_BASE): @@ -137,7 +137,7 @@ class wild_cards(MainTableMixin, MAIN_BASE): number = Column(String(10), nullable=True) # 식별번호(카드코드) script = Column(String(255), nullable=True) # 협상 스크립트(평문 미리보기) edit_script = Column(JSONB, nullable=True) # 편집된 스크립트(Slate JSON) - usage_type = Column(SmallInteger, nullable=False, default=1) # 카드 적용 견적 구분(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용 + usage_type = Column(SmallInteger, nullable=False, default=1) condition = Column(String(255), nullable=True) # 사용 조건(트리거) available = Column(Boolean, nullable=False, default=False) # 수동 협상 적용 여부(ACTIVE/INACTIVE 매핑) memo = Column(String(255), nullable=True) # 자유 메모 @@ -179,8 +179,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) anchoring_value = Column(Numeric(8, 6), nullable=False, default=0.01) - internet_average_fee = Column(Numeric(8, 6), nullable=False, default=0.078) # 인터넷 평균 수수료율. 목표가=인터넷최저가×(1−값). KTC QuotationPrice.internet_average_fee(Float) - card_count = Column(Integer, nullable=False, default=3) # 한 협상 내 협상카드 사용 횟수 + card_count = Column(Integer, nullable=False, default=3) class quotations(MainTableMixin, MAIN_BASE): @@ -204,8 +203,8 @@ class quotations(MainTableMixin, MAIN_BASE): manager_email = Column(String(255), nullable=True) manager_contact_number = Column(String(20), nullable=True) memo = Column(String(100), nullable=True) - md_price = Column(BigInteger, nullable=True) # MD 제시가(원). 목표가 산정 최우선값 — 입력은 견적생성 모달 - supplier_type = Column(SmallInteger, nullable=True) # 협력사 유형(SupplierType). 재견적은 1:1이라 견적에 박는다. 입력은 견적생성 모달 + md_price = Column(BigInteger, nullable=True) + supplier_type = Column(SmallInteger, nullable=True) iteration = Column(Integer, nullable=False, default=0) preferred_sp_yn = Column(Boolean, nullable=True) @@ -228,7 +227,7 @@ class sessions(MainTableMixin, MAIN_BASE): qt_round = Column(Integer, nullable=False) # 견적 라운드 스냅샷 qt_type = Column(SmallInteger, nullable=False) # QuotationType 스냅샷 target_price = Column(BigInteger, nullable=False) # 목표가(원) - target_anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원)=floor(목표가×(1−anchoring_value)). KTC sessions.target_anchoring_price(BigInteger) + target_anchoring_price = Column(BigInteger, nullable=True) status = Column(SmallInteger, nullable=False) # SessionStatus 코드 bid_price = Column(BigInteger, nullable=True) # 입찰가(원) bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각 @@ -236,6 +235,7 @@ class sessions(MainTableMixin, MAIN_BASE): reject_reason = Column(String(255), nullable=True) reject_price = Column(BigInteger, nullable=True) reject_delivery_type = Column(SmallInteger, nullable=True) # DeliveryType 코드 + email_sent_at = Column(DateTime(timezone=True), nullable=True) # 협상 초청 메일 발송 시각(NULL=미발송) class chats(MainTableMixin, MAIN_BASE): diff --git a/negodata/backend/common/models/gmodel.py b/negodata/backend/common/models/gmodel.py index cfa6d5d..bb956fe 100644 --- a/negodata/backend/common/models/gmodel.py +++ b/negodata/backend/common/models/gmodel.py @@ -4,7 +4,7 @@ from typing import Optional from fastapi import Query from pydantic import BaseModel, Field -from common.enums import ErrorType +from common.enums import ErrorType, UserRole class StructModel: @@ -70,9 +70,12 @@ class UserInfo(StructModel): user_id: str # users.user_id (uuid) — 데이터 스코프 키 id: str # users.id (로그인 아이디) — get_me 재조회 키 company_id: str # users.company_id (uuid) — 멀티테넌트 스코프 키 + role: int # users.role (UserRole) — 권한 게이트(최고관리자 등) 판단 키 def __init__(self, *args, **kwargs) -> None: super().__init__() + # 구버전 토큰(role 미포함) 도 디코딩되도록 기본값을 먼저 깔고 kwargs 로 덮어쓴다. + self.role = UserRole.USER.value for dictionary in args: for key in dictionary: setattr(self, key, dictionary[key]) diff --git a/negodata/backend/config/config_models.py b/negodata/backend/config/config_models.py index 97d11ac..a3864df 100644 --- a/negodata/backend/config/config_models.py +++ b/negodata/backend/config/config_models.py @@ -53,3 +53,23 @@ class StorageConfig(ConfigModel): azure_blob_sas_token: str = "" # SAS 토큰(쿼리스트링). 만료 있음 — 만료되면 업로드 실패 blob_root: str = "negodata" # 컨테이너 내 최상위 디렉터리(infinith 파일과 분리) max_image_mb: int = 4 # 업로드 허용 최대 크기(MB). 프론트 ImageDropzone 와 일치 + + +# 협상 초청 메일 발송 설정. services/email.py 가 ACS → SMTP 순으로 시도한다. +# 1순위: Azure Communication Services(ACS) Email — endpoint + accesskey. +# azure_acs_sender 는 검증된 MailFrom 주소(예: donotreply@negodata.o2o.kr). Blob 과 별개 리소스다. +# 2순위(폴백): SMTP — ACS 미설정 시 사용. 둘 다 비우면 발송 시 EmailUnavailable. +class MailConfig(ConfigModel): + azure_acs_endpoint: str = "" + azure_acs_accesskey: str = "" + azure_acs_sender: str = "" + smtp_host: str = "" + smtp_port: int = 587 + smtp_user: str = "" + smtp_password: str = "" + smtp_from: str = "Negodata " + smtp_starttls: bool = True + + @property + def acs_configured(self) -> bool: + return bool(self.azure_acs_endpoint and self.azure_acs_accesskey and self.azure_acs_sender) diff --git a/negodata/backend/config/server_configs.py b/negodata/backend/config/server_configs.py index f9ceaf1..7042709 100644 --- a/negodata/backend/config/server_configs.py +++ b/negodata/backend/config/server_configs.py @@ -1,7 +1,7 @@ import os from config.config_loader import Configs -from config.config_models import WebServerConfig, LogConfig, MainDBConfig, JwtToken, StorageConfig +from config.config_models import WebServerConfig, LogConfig, MainDBConfig, JwtToken, StorageConfig, MailConfig # 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경. APP_ENV = os.environ.get("APP_ENV", "local") @@ -20,6 +20,8 @@ log_config: LogConfig = configs.get(LogConfig) main_db_config: MainDBConfig = configs.get(MainDBConfig) jwt_token_config: JwtToken = configs.get(JwtToken) storage_config: StorageConfig = configs.get(StorageConfig) +# [MailConfig] 섹션이 없는 toml(구버전)에서도 죽지 않도록 기본값으로 폴백(전 필드 빈 값 → 발송 시 EmailUnavailable). +mail_config: MailConfig = configs.get(MailConfig) or MailConfig() # DB 접속 env override (config.local.toml 유지, 도커에서 host 만 교체). 로컬은 env 미설정 → toml 그대로. diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py index 9358bea..4131bff 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -83,6 +83,18 @@ class IQuotationCRUD(ABC): async def list_used_cards(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]: pass + @abstractmethod + async def list_sessions_with_supplier(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]: + pass + + @abstractmethod + async def get_session_with_supplier(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[tuple]]: + pass + + @abstractmethod + async def mark_sessions_emailed(self, cdb: AsyncSession, session_ids, ts) -> ErrorType: + pass + @abstractmethod async def session_counts(self, cdb: AsyncSession, qt_ids) -> Tuple[ErrorType, dict]: pass @@ -360,11 +372,10 @@ class QuotationCRUD(IQuotationCRUD): return ErrorType.DB_RUN_FAILED, None async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]: - """견적 세팅의 율: {margin, fee, anchoring}. 목표가·앵커링가 산정 입력.""" + """견적 세팅의 율: {margin, anchoring}. 목표가·앵커링가 산정 입력. (인터넷 수수료는 상수)""" try: query = select( quotation_settings.target_margin_rate, - quotation_settings.internet_average_fee, quotation_settings.anchoring_value, ).where(quotation_settings.qt_setting_id == qt_setting_id).limit(1) err_type, rows = await DB_SESSION_MNG.execute(cdb, query) @@ -375,8 +386,7 @@ class QuotationCRUD(IQuotationCRUD): r = rows[0] return ErrorType.SUCCESS, { "margin": float(r[0]) if r[0] is not None else None, - "fee": float(r[1]) if r[1] is not None else None, - "anchoring": float(r[2]) if r[2] is not None else None, + "anchoring": float(r[1]) if r[1] is not None else None, } except Exception as ex: LOG.e_no_callstack(ex) @@ -617,6 +627,56 @@ class QuotationCRUD(IQuotationCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, [] + async def list_sessions_with_supplier(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]: + """견적의 세션 + 공급사(담당자 이메일/이름) 조인. 초청 메일 발송 대상 조회용. + 반환: [(session, supplier_name, manager_email), ...] (created_at asc). 행은 인덱스로 언팩.""" + try: + query = ( + select(sessions, suppliers.name, suppliers.manager_email) + .outerjoin(suppliers, suppliers.supplier_id == sessions.supplier_id) + .where(sessions.quotation_id == qt_id, sessions.deleted == False) # noqa: E712 + .order_by(sessions.created_at.asc()) + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, [] + return ErrorType.SUCCESS, list(rows) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + + async def get_session_with_supplier(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[tuple]]: + """단일 세션 + 공급사(이름/이메일). 행별 재발송용. 반환: (session, name, email) | None.""" + try: + query = ( + select(sessions, suppliers.name, suppliers.manager_email) + .outerjoin(suppliers, suppliers.supplier_id == sessions.supplier_id) + .where(sessions.session_id == session_id, sessions.deleted == False) # noqa: E712 + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, None + rows = list(rows) + return ErrorType.SUCCESS, (rows[0] if rows else None) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, None + + async def mark_sessions_emailed(self, cdb: AsyncSession, session_ids, ts) -> ErrorType: + """발송 성공 세션들의 email_sent_at 을 ts 로 기록(write).""" + try: + if not session_ids: + return ErrorType.SUCCESS + query = ( + update(sessions) + .where(sessions.session_id.in_(session_ids)) + .values(email_sent_at=ts, updated_at=ts) + ) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + async def list_chats(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]: try: query = ( diff --git a/negodata/backend/requirements.txt b/negodata/backend/requirements.txt index 1d50362..fa46e28 100644 --- a/negodata/backend/requirements.txt +++ b/negodata/backend/requirements.txt @@ -11,3 +11,5 @@ python-multipart openpyxl httpx apscheduler>=3.10 +azure-communication-email>=1.0 # 초청 메일 1순위 발송 채널(ACS Email) +aiosmtplib>=3.0 # 초청 메일 폴백(SMTP) diff --git a/negodata/backend/router/router.py b/negodata/backend/router/router.py index 035373e..1d5bafb 100644 --- a/negodata/backend/router/router.py +++ b/negodata/backend/router/router.py @@ -11,6 +11,7 @@ from common.utils.gtime import GTime from config.server_configs import web_server_config from scheduler import shutdown_scheduler, start_scheduler import router.v1.auth.account +import router.v1.company.user import router.v1.item.item import router.v1.supplier.supplier import router.v1.card.card @@ -50,7 +51,8 @@ async def log_time(request: Request, call_next): start_time = time.time() response = await call_next(request) elapsed = time.time() - start_time - LOG.d(f"took: {elapsed:.4f} - {request.url.path}") + # status_code 를 함께 남긴다(403/4xx 등을 로그만으로 식별 가능하게). + LOG.d(f"{response.status_code} {request.method} {request.url.path} - {elapsed:.4f}s") return response @@ -61,6 +63,7 @@ async def healthz(): # 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.. 를 import 후 include. app.include_router(router.v1.auth.account.router) +app.include_router(router.v1.company.user.router) app.include_router(router.v1.item.item.router) app.include_router(router.v1.supplier.supplier.router) app.include_router(router.v1.card.card.router) diff --git a/negodata/backend/router/v1/quotation/protocol.py b/negodata/backend/router/v1/quotation/protocol.py index 3c402a9..dc150e0 100644 --- a/negodata/backend/router/v1/quotation/protocol.py +++ b/negodata/backend/router/v1/quotation/protocol.py @@ -92,6 +92,7 @@ class SessionData(WebPacketProtocol): qt_round: int qt_type: QuotationType target_price: int + target_anchoring_price: Optional[int] = None # 앵커링가(원). 목표가×(1−앵커링율) status: SessionStatus bid_price: Optional[int] = None bid_at: Optional[datetime] = None @@ -99,6 +100,7 @@ class SessionData(WebPacketProtocol): reject_reason: Optional[str] = None reject_price: Optional[int] = None reject_delivery_type: Optional[DeliveryType] = None + email_sent_at: Optional[datetime] = None # 협상 초청 메일 발송 시각(None=미발송). 프론트 발송배지/재발송 판단 url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성 @@ -107,6 +109,13 @@ class Res_CreateQuotation(Res_WebPacketProtocol): session_count: int = 0 # 함께 생성된 협상 세션 수(토스트 표시용). 세션 풀바디는 별도 GET 으로 조회 +class Res_NotifySessions(Res_WebPacketProtocol): + sent: int = 0 # 발송 성공 세션 수 + failed: int = 0 # 발송 시도했으나 실패한 세션 수 + skipped: int = 0 # 담당자 이메일이 없어 건너뛴 세션 수 + total: int = 0 # 대상 세션 총수 + + class Res_QuotationStatus(Res_WebPacketProtocol): qt_id: Optional[uuid.UUID] = None job_status: int = 0 diff --git a/negodata/backend/router/v1/quotation/quotation.py b/negodata/backend/router/v1/quotation/quotation.py index 488b840..fa97eed 100644 --- a/negodata/backend/router/v1/quotation/quotation.py +++ b/negodata/backend/router/v1/quotation/quotation.py @@ -12,6 +12,7 @@ from .protocol import ( Res_CreateQuotation, Res_DeleteQuotation, Res_LastSupplierType, + Res_NotifySessions, Res_Quotation, Res_QuotationCards, Res_QuotationList, @@ -73,11 +74,21 @@ async def get_quotation_sessions(qt_id: UUID, service: QuotationService = Depend return RemoveNoneResponse(await service.list_sessions(str(qt_id))) +@router.post(path="/{qt_id}/notify", response_model=Res_NotifySessions, summary="협상 초청 메일 발송") +async def notify_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.notify_sessions(str(qt_id))) + + @router.get(path="/session/{session_id}/chat", response_model=Res_SessionChat, summary="채팅 상세") async def get_session_chat(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): return RemoveNoneResponse(await service.list_chats(str(session_id))) +@router.post(path="/session/{session_id}/notify", response_model=Res_NotifySessions, summary="세션 초청 메일 재발송") +async def notify_session(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.notify_session(str(session_id))) + + @router.get(path="/{qt_id}/result", response_model=Res_QuotationResult, summary="낙찰 결과") async def get_quotation_result(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): return RemoveNoneResponse(await service.get_result(str(qt_id))) diff --git a/negodata/backend/router/v1/quotation_setting/protocol.py b/negodata/backend/router/v1/quotation_setting/protocol.py index 2f90855..0518f40 100644 --- a/negodata/backend/router/v1/quotation_setting/protocol.py +++ b/negodata/backend/router/v1/quotation_setting/protocol.py @@ -14,14 +14,12 @@ class QuotationSettingProtocol(WebPacketProtocol): class Req_CreateQuotationSetting(QuotationSettingProtocol): target_margin_rate: float anchoring_value: float = 0.01 - internet_average_fee: float = 0.078 # 인터넷 평균 수수료율(목표가 인터넷가 차감) card_count: int = 3 class Req_UpdateQuotationSetting(QuotationSettingProtocol): target_margin_rate: Optional[float] = None anchoring_value: Optional[float] = None - internet_average_fee: Optional[float] = None # 인터넷 평균 수수료율 card_count: Optional[int] = None @@ -32,7 +30,6 @@ class QuotationSettingData(WebPacketProtocol): user_id: Optional[uuid.UUID] = None target_margin_rate: float anchoring_value: float - internet_average_fee: float = 0.078 # 인터넷 평균 수수료율 card_count: int created_at: Optional[datetime] = None updated_at: Optional[datetime] = None diff --git a/negodata/backend/services/email.py b/negodata/backend/services/email.py new file mode 100644 index 0000000..eaea9ed --- /dev/null +++ b/negodata/backend/services/email.py @@ -0,0 +1,125 @@ +"""협상 초청 메일 발송. + +1순위: Azure Communication Services(ACS) Email (endpoint + accesskey + 검증된 sender). +2순위(폴백): SMTP (aiosmtplib). +둘 다 미설정이면 EmailUnavailable 을 던진다(조용한 실패 0). + +설정 출처: config..toml 의 [MailConfig] (config/config_models.py:MailConfig). +HTML 본문 템플릿: services/email_templates/*.html ($placeholder 치환). +""" +from __future__ import annotations + +from datetime import datetime +from email.message import EmailMessage +from html import escape +from pathlib import Path +from string import Template +from zoneinfo import ZoneInfo + +from common.logger import LOG +from config.server_configs import mail_config + +_KST = ZoneInfo("Asia/Seoul") + +# HTML 본문은 코드에 박지 않고 파일에서 읽는다(모듈 로드 시 1회). $placeholder 는 string.Template 로 치환. +_TEMPLATE_DIR = Path(__file__).parent / "email_templates" +_INVITE_HTML = Template((_TEMPLATE_DIR / "invite_email.html").read_text(encoding="utf-8")) + + +class EmailUnavailable(RuntimeError): + """ACS·SMTP 모두 미설정이라 발송 채널이 없음.""" + + +async def _send_acs(to: str, subject: str, html: str, text: str) -> None: + """Azure Communication Services Email — accesskey 인증.""" + from azure.communication.email.aio import EmailClient + + conn = f"endpoint={mail_config.azure_acs_endpoint};accesskey={mail_config.azure_acs_accesskey}" + message = { + "senderAddress": mail_config.azure_acs_sender, + "recipients": {"to": [{"address": to}]}, + "content": {"subject": subject, "plainText": text, "html": html}, + } + async with EmailClient.from_connection_string(conn) as client: + poller = await client.begin_send(message) + await poller.result() + LOG.i(f"email sent via ACS → {to} ({subject})") + + +async def _send_smtp(to: str, subject: str, html: str, text: str) -> None: + import aiosmtplib + + msg = EmailMessage() + msg["From"] = mail_config.smtp_from + msg["To"] = to + msg["Subject"] = subject + msg.set_content(text) + msg.add_alternative(html, subtype="html") + + await aiosmtplib.send( + msg, + hostname=mail_config.smtp_host, + port=mail_config.smtp_port, + username=mail_config.smtp_user or None, + password=mail_config.smtp_password or None, + start_tls=mail_config.smtp_starttls, + ) + LOG.i(f"email sent via SMTP → {to} ({subject})") + + +async def send_email(to: str, subject: str, html: str, text: str) -> None: + """ACS(설정 시) → SMTP(폴백) 순으로 1통 발송. 둘 다 없으면 EmailUnavailable.""" + if mail_config.acs_configured: + await _send_acs(to, subject, html, text) + return + if mail_config.smtp_host: + await _send_smtp(to, subject, html, text) + return + # endpoint/accesskey 만 있고 sender 누락 시 원인을 명확히 안내. + if mail_config.azure_acs_endpoint and not mail_config.azure_acs_sender: + raise EmailUnavailable("AZURE_ACS_SENDER(검증된 MailFrom 주소) 미설정") + raise EmailUnavailable("이메일 미설정 — ACS(endpoint+accesskey+sender) 또는 SMTP 필요") + + +def _fmt_deadline(end_time: datetime | None) -> str: + """협상 마감 시각 → 한국시간 'YYYY-MM-DD HH:MM' 표기. 값 없으면 빈 문자열.""" + if not end_time: + return "" + dt = end_time if end_time.tzinfo else end_time.replace(tzinfo=ZoneInfo("UTC")) + return dt.astimezone(_KST).strftime("%Y-%m-%d %H:%M") + + +def build_invite_email( + *, + supplier_name: str, + quotation_name: str, + qt_number: str, + end_time: datetime | None, + chat_url: str, +) -> tuple[str, str, str]: + """협상 초청 메일 (제목/HTML/텍스트) 생성. + + 목표가·앵커링가는 협상 전략 값이라 메일에 담지 않는다(공급사에게 노출 금지). + 공급사는 링크로 협상 화면에 진입해 입찰한다. + """ + sp = supplier_name or "협력사" + deadline = _fmt_deadline(end_time) or "미정" + subject = f"[협상 견적 {qt_number}] {quotation_name} — 협상 참여 요청" + + # HTML 본문은 invite_email.html 에서 읽어 치환. 값은 escape 해 HTML 인젝션 방지(견적명 등은 사용자 입력). + html = _INVITE_HTML.substitute( + supplier_name=escape(sp), + quotation_name=escape(quotation_name), + qt_number=escape(qt_number), + deadline=escape(deadline), + chat_url=escape(chat_url), + ) + + text = ( + f"{sp} 담당자님, 아래 견적 건의 협상에 참여해 주세요.\n\n" + f" - 견적명: {quotation_name}\n" + f" - 견적번호: {qt_number}\n" + f" - 협상 마감: {deadline}\n\n" + f"협상 참여 링크: {chat_url}\n" + ) + return subject, html, text diff --git a/negodata/backend/services/email_templates/invite_email.html b/negodata/backend/services/email_templates/invite_email.html new file mode 100644 index 0000000..aa79386 --- /dev/null +++ b/negodata/backend/services/email_templates/invite_email.html @@ -0,0 +1,70 @@ + + + + +
+ + + + + + + + + + + + + + + + + +
+ NEGODATA +
+

협상 참여 요청

+

+ $supplier_name 담당자님,
+ 아래 견적 건의 협상에 참여해 주세요. +

+ + + + + + + + + + + + + + + +
견적명$quotation_name
견적번호$qt_number
협상 마감$deadline
+ + + + + + +
+ + + + +
+ + 협상 참여하기 → +
+
+
+

+ 버튼이 열리지 않으면 아래 링크를 복사해 접속하세요.
+ https://nego.o2o.kr +

+
+

본 메일은 협상 견적 시스템에서 자동 발송되었습니다.

+
diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index 8b35a29..edff67a 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -22,6 +22,7 @@ from router.v1.quotation.protocol import ( Res_CreateQuotation, Res_DeleteQuotation, Res_LastSupplierType, + Res_NotifySessions, Res_Quotation, Res_QuotationCards, Res_QuotationList, @@ -30,6 +31,7 @@ from router.v1.quotation.protocol import ( Res_QuotationStatus, Res_SessionChat, ) +from services.email import EmailUnavailable, build_invite_email, send_email class QuotationService: @@ -51,6 +53,9 @@ class QuotationService: # TODO 하한값 변경 해야함 !!! feat. MarineYang MIN_REGEN_DURATION = timedelta(hours=1) + # 인터넷 평균 수수료율(상수). 시장 평균값이라 견적/세팅별로 두지 않고 고정. 목표가=인터넷최저가×(1−값). + INTERNET_AVERAGE_FEE = 0.078 + def __init__(self, quotation_crud: IQuotationCRUD = Depends(QuotationCRUD)): self.quotation_crud = quotation_crud @@ -73,6 +78,11 @@ class QuotationService: ③ 후보 0개 → 견적 생성 불가(ValueError).""" if md_price: return int(md_price) + # 율은 비율(0~1 미만)이어야 한다. 1 이상이면 (1−율)≤0 → 목표가가 0/음수가 되므로 설정 오류로 막는다. + if not 0.0 <= (fee or 0.0) < 1.0: + raise ValueError(f"인터넷 수수료율은 0 이상 1 미만이어야 합니다: fee={fee}") + if not is_new and not 0.0 <= (margin or 0.0) < 1.0: + raise ValueError(f"목표 마진율은 0 이상 1 미만이어야 합니다: margin={margin}") candidates = [] if internet_lowest: candidates.append(int(internet_lowest) * (1 - (fee or 0.0))) @@ -289,7 +299,7 @@ class QuotationService: lambda s: self.quotation_crud.get_setting_rates(s, qt_setting_id), ) rates = rates if _err == ErrorType.SUCCESS else {} - fee = rates.get("fee") or 0.0 # 인터넷가 차감 수수료율 + fee = self.INTERNET_AVERAGE_FEE # 인터넷가 차감 수수료율(상수) margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율 anchoring = rates.get("anchoring") or 0.0 # 앵커링가 = 목표가×(1−값) @@ -352,6 +362,8 @@ class QuotationService: else: internet, purchase, selling = prices.get(iid) or (None, None, None) tp = self._calc_target_price(md_price, internet, purchase, selling, fee, margin, is_new=is_new) + if not 0.0 <= anchoring < 1.0: # 율 1 이상이면 앵커링가가 0/음수 → 설정 오류로 막는다. + raise ValueError(f"앵커링 값은 0 이상 1 미만이어야 합니다: anchoring={anchoring}") ap = int(tp * (1 - anchoring)) # 앵커링가 = floor(목표가×(1−앵커링율)); 율 0이면 목표가와 동일 for sid in supplier_ids: session_objs.append( @@ -369,7 +381,11 @@ class QuotationService: end_time=quotation.end_time, ) ) - except ValueError: + except ValueError as ex: + LOG.w( + f"[목표가 산정불가] qt_id={qt_id} item={iid} is_new={is_new} " + f"md={md_price} internet={internet} purchase={purchase} selling={selling} :: {ex}" + ) res.result.SetResult(ErrorType.QUOTATION_TARGET_PRICE_UNAVAILABLE) return res @@ -694,6 +710,7 @@ class QuotationService: qt_round=r.qt_round, qt_type=r.qt_type, target_price=r.target_price, + target_anchoring_price=r.target_anchoring_price, status=r.status, bid_price=r.bid_price, bid_at=r.bid_at, @@ -701,6 +718,7 @@ class QuotationService: reject_reason=r.reject_reason, reject_price=r.reject_price, reject_delivery_type=r.reject_delivery_type, + email_sent_at=r.email_sent_at, url=self._session_chat_url(r.session_id), ) for r in rows @@ -708,6 +726,106 @@ class QuotationService: res.total = len(res.sessions) return res + # ----- 협상 초청 메일 (수동 발송) + async def notify_sessions(self, qt_id: str) -> Res_NotifySessions: + """[수동 발송] 견적의 '미발송' 세션(공급사 담당자)에게 협상 초청 메일을 일괄 발송한다. + 대상 = email_sent_at IS NULL + 담당자 이메일 보유.""" + res = Res_NotifySessions() + qt_uuid = uuid.UUID(qt_id) + err_type, quotation = await self._fetch(qt_uuid) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + err_type, rows = await DB_SESSION_MNG.execute_lambda( + sessions.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.list_sessions_with_supplier(s, qt_uuid), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + # 행 언팩: (session, supplier_name, manager_email). + targets = [] # [(session, name, email)] + for r in rows: + sess, sp_name, email = r[0], r[1], r[2] + res.total += 1 + if sess.email_sent_at is not None: + continue # 이미 발송됨 — 재발송은 행 단위 endpoint 로 + if not email: + res.skipped += 1 + continue + targets.append((sess, sp_name, email)) + + sent_ids = await self._send_invites(quotation, targets, res) + if sent_ids: + await self._mark_emailed(sent_ids) + return res + + async def notify_session(self, session_id: str) -> Res_NotifySessions: + """[수동 재발송] 단일 세션(공급사)에 초청 메일 발송(이미 보냈어도 강제 재발송).""" + res = Res_NotifySessions() + sess_uuid = uuid.UUID(session_id) + err_type, got = await DB_SESSION_MNG.execute_lambda( + sessions.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.get_session_with_supplier(s, sess_uuid), + ) + if err_type != ErrorType.SUCCESS or got is None: + res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND) + return res + sess, sp_name, email = got[0], got[1], got[2] + res.total = 1 + err_type, quotation = await self._fetch(sess.quotation_id) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + if not email: + res.skipped = 1 + return res + + sent_ids = await self._send_invites(quotation, [(sess, sp_name, email)], res) + if sent_ids: + await self._mark_emailed(sent_ids) + return res + + async def _send_invites(self, quotation, targets: list, res: Res_NotifySessions) -> list: + """targets [(session, supplier_name, email)] 에 초청 메일 발송. res.sent/failed 를 채우고 + 성공한 session_id 목록을 반환. ACS/SMTP 미설정이면 첫 발송에서 중단(EMAIL_NOT_CONFIGURED).""" + sent_ids = [] + for sess, sp_name, email in targets: + subject, html, text = build_invite_email( + supplier_name=sp_name or "", + quotation_name=quotation.name, + qt_number=quotation.number, + end_time=quotation.end_time, + chat_url=self._session_chat_url(sess.session_id), + ) + try: + await send_email(email, subject, html, text) + sent_ids.append(sess.session_id) + res.sent += 1 + except EmailUnavailable as e: + res.result.SetResult(ErrorType.EMAIL_NOT_CONFIGURED) # 발송 채널 없음 — 더 시도해도 무의미 + res.msg = str(e) + break + except Exception as ex: + LOG.e_no_callstack(ex) + res.failed += 1 + # 보낼 대상이 있었는데 전부 실패면 명시적 실패 코드(설정은 됐으나 발송 실패). + if res.sent == 0 and res.failed > 0 and res.result.success: + res.result.SetResult(ErrorType.EMAIL_SEND_FAILED) + return sent_ids + + async def _mark_emailed(self, session_ids: list) -> None: + """발송 성공 세션들의 email_sent_at 갱신(write 트랜잭션).""" + now = GTime.UTC() + await DB_SESSION_MNG.execute_lambda_run( + [sessions.DBType()], + [lambda s: self.quotation_crud.mark_sessions_emailed(s, session_ids, now)], + ) + async def list_chats(self, session_id: str) -> Res_SessionChat: res = Res_SessionChat() sess_uuid = uuid.UUID(session_id) diff --git a/negodata/backend/services/quotation_setting_service.py b/negodata/backend/services/quotation_setting_service.py index 69ecf84..e614324 100644 --- a/negodata/backend/services/quotation_setting_service.py +++ b/negodata/backend/services/quotation_setting_service.py @@ -66,7 +66,6 @@ class QuotationSettingService: user_id=uuid.UUID(user_id), target_margin_rate=req.target_margin_rate, anchoring_value=req.anchoring_value, - internet_average_fee=req.internet_average_fee, card_count=req.card_count, ) err_type = await DB_SESSION_MNG.execute_lambda_run( diff --git a/negodata/front/src/api/generated/model/cardUsageType.ts b/negodata/front/src/api/generated/model/cardUsageType.ts index 7db4d39..1b9abcf 100644 --- a/negodata/front/src/api/generated/model/cardUsageType.ts +++ b/negodata/front/src/api/generated/model/cardUsageType.ts @@ -6,8 +6,8 @@ */ /** - * nego_cards/wild_cards.usage_type 코드값. 카드가 적용되는 견적 구분. -공통=신규·재 모두(기본), 신규전용=신규협상/신규견적(QuotationType 3·4), 재전용=재협상/재견적(1·2). + * nego_cards/wild_cards.usage_type 코드값. 협상카드 사용 범위(신규/재 견적·협상 양쪽 적용). +공통=모두 적용(기본), 신규견적전용, 재견적전용. */ export type CardUsageType = typeof CardUsageType[keyof typeof CardUsageType]; diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 36fbf55..d0b2814 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -28,6 +28,13 @@ export * from './chatMessageDataScript'; export * from './chatMessageDataStep'; export * from './chatSender'; export * from './companyData'; +export * from './companyUserData'; +export * from './companyUserDataContactNumber'; +export * from './companyUserDataCreatedAt'; +export * from './companyUserDataEmail'; +export * from './companyUserDataLastAccessedAt'; +export * from './companyUserDataName'; +export * from './companyUserDataUpdatedAt'; export * from './deliveryType'; export * from './errorInfo'; export * from './errorInfoCode'; @@ -59,6 +66,7 @@ export * from './listCardsParams'; export * from './listItemsParams'; export * from './listQuotationsParams'; export * from './listSuppliersParams'; +export * from './listUsersParams'; export * from './quotationCardData'; export * from './quotationCardDataCondition'; export * from './quotationCardDataEditScript'; @@ -93,7 +101,6 @@ export * from './quotationSettingDataUserId'; export * from './quotationStatus'; export * from './quotationType'; export * from './reqCheckCodes'; -export * from './reqCreateAccount'; export * from './reqCreateCard'; export * from './reqCreateCardCondition'; export * from './reqCreateCardEditScript'; @@ -101,6 +108,7 @@ export * from './reqCreateCardMemo'; export * from './reqCreateCardName'; export * from './reqCreateCardNumber'; export * from './reqCreateCardScript'; +export * from './reqCreateCompanyUser'; export * from './reqCreateItem'; export * from './reqCreateItemCategory'; export * from './reqCreateItemCode'; @@ -146,6 +154,12 @@ export * from './reqUpdateCardNumber'; export * from './reqUpdateCardScript'; export * from './reqUpdateCardStatus'; export * from './reqUpdateCardUsageType'; +export * from './reqUpdateCompanyUser'; +export * from './reqUpdateCompanyUserContactNumber'; +export * from './reqUpdateCompanyUserEmail'; +export * from './reqUpdateCompanyUserName'; +export * from './reqUpdateCompanyUserPassword'; +export * from './reqUpdateCompanyUserStatus'; export * from './reqUpdateItem'; export * from './reqUpdateItemCategory'; export * from './reqUpdateItemCategoryType'; @@ -167,10 +181,14 @@ export * from './reqUpdateItemQuantityUnit'; export * from './reqUpdateItemSellingPrice'; export * from './reqUpdateItemSpec'; export * from './reqUpdateItemVatYn'; +export * from './reqUpdateMe'; +export * from './reqUpdateMeContactNumber'; +export * from './reqUpdateMeEmail'; +export * from './reqUpdateMeName'; +export * from './reqUpdateMePassword'; export * from './reqUpdateQuotationSetting'; export * from './reqUpdateQuotationSettingAnchoringValue'; export * from './reqUpdateQuotationSettingCardCount'; -export * from './reqUpdateQuotationSettingInternetAverageFee'; export * from './reqUpdateQuotationSettingTargetMarginRate'; export * from './reqUpdateSupplier'; export * from './reqUpdateSupplierCode'; @@ -186,13 +204,18 @@ export * from './resCardListMsg'; export * from './resCardMsg'; export * from './resCheckCodes'; export * from './resCheckCodesMsg'; -export * from './resCreateAccount'; -export * from './resCreateAccountMsg'; +export * from './resCompanyUser'; +export * from './resCompanyUserList'; +export * from './resCompanyUserListMsg'; +export * from './resCompanyUserMsg'; +export * from './resCompanyUserUser'; export * from './resCreateQuotation'; export * from './resCreateQuotationMsg'; export * from './resCreateQuotationQtId'; export * from './resDeleteCard'; export * from './resDeleteCardMsg'; +export * from './resDeleteCompanyUser'; +export * from './resDeleteCompanyUserMsg'; export * from './resDeleteItem'; export * from './resDeleteItemMsg'; export * from './resDeleteQuotation'; @@ -229,6 +252,8 @@ export * from './resMeContactNumber'; export * from './resMeEmail'; export * from './resMeMsg'; export * from './resMeName'; +export * from './resNotifySessions'; +export * from './resNotifySessionsMsg'; export * from './resQuotation'; export * from './resQuotationCards'; export * from './resQuotationCardsMsg'; @@ -268,9 +293,11 @@ export * from './resSupplierSupplier'; export * from './sessionData'; export * from './sessionDataBidAt'; export * from './sessionDataBidPrice'; +export * from './sessionDataEmailSentAt'; export * from './sessionDataRejectDeliveryType'; export * from './sessionDataRejectPrice'; export * from './sessionDataRejectReason'; +export * from './sessionDataTargetAnchoringPrice'; export * from './sessionStatus'; export * from './supplierData'; export * from './supplierDataCode'; @@ -282,6 +309,7 @@ export * from './supplierDataPriority'; export * from './supplierDataUpdatedAt'; export * from './supplierType'; export * from './userRole'; +export * from './userStatus'; export * from './validationError'; export * from './validationErrorCtx'; export * from './validationErrorLocItem'; \ No newline at end of file diff --git a/negodata/front/src/api/generated/model/listUsersParams.ts b/negodata/front/src/api/generated/model/listUsersParams.ts new file mode 100644 index 0000000..6f87482 --- /dev/null +++ b/negodata/front/src/api/generated/model/listUsersParams.ts @@ -0,0 +1,22 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ListUsersParams = { +/** + * 로그인ID/이름/이메일 검색 + */ +search?: string | null; +/** + * @minimum 1 + */ +page?: number; +/** + * @minimum 1 + * @maximum 100 + */ +size?: number; +}; diff --git a/negodata/front/src/api/generated/model/quotationSettingData.ts b/negodata/front/src/api/generated/model/quotationSettingData.ts index 35d46d0..8c49ff9 100644 --- a/negodata/front/src/api/generated/model/quotationSettingData.ts +++ b/negodata/front/src/api/generated/model/quotationSettingData.ts @@ -13,7 +13,6 @@ export interface QuotationSettingData { user_id?: QuotationSettingDataUserId; target_margin_rate: number; anchoring_value: number; - internet_average_fee?: number; card_count: number; created_at?: QuotationSettingDataCreatedAt; updated_at?: QuotationSettingDataUpdatedAt; diff --git a/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts b/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts index 6254a2e..4bc40a5 100644 --- a/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts +++ b/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts @@ -8,6 +8,5 @@ export interface ReqCreateQuotationSetting { target_margin_rate: number; anchoring_value?: number; - internet_average_fee?: number; card_count?: number; } diff --git a/negodata/front/src/api/generated/model/reqUpdateMe.ts b/negodata/front/src/api/generated/model/reqUpdateMe.ts new file mode 100644 index 0000000..27a80f4 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateMe.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ReqUpdateMeName } from './reqUpdateMeName'; +import type { ReqUpdateMeEmail } from './reqUpdateMeEmail'; +import type { ReqUpdateMeContactNumber } from './reqUpdateMeContactNumber'; +import type { ReqUpdateMePassword } from './reqUpdateMePassword'; + +export interface ReqUpdateMe { + name?: ReqUpdateMeName; + email?: ReqUpdateMeEmail; + contact_number?: ReqUpdateMeContactNumber; + password?: ReqUpdateMePassword; +} diff --git a/negodata/front/src/api/generated/model/reqUpdateQuotationSettingInternetAverageFee.ts b/negodata/front/src/api/generated/model/reqUpdateMeContactNumber.ts similarity index 62% rename from negodata/front/src/api/generated/model/reqUpdateQuotationSettingInternetAverageFee.ts rename to negodata/front/src/api/generated/model/reqUpdateMeContactNumber.ts index 7678e83..dc4995b 100644 --- a/negodata/front/src/api/generated/model/reqUpdateQuotationSettingInternetAverageFee.ts +++ b/negodata/front/src/api/generated/model/reqUpdateMeContactNumber.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ReqUpdateQuotationSettingInternetAverageFee = number | null; +export type ReqUpdateMeContactNumber = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateMeEmail.ts b/negodata/front/src/api/generated/model/reqUpdateMeEmail.ts new file mode 100644 index 0000000..dd96611 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateMeEmail.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 ReqUpdateMeEmail = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateMeName.ts b/negodata/front/src/api/generated/model/reqUpdateMeName.ts new file mode 100644 index 0000000..f464c30 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateMeName.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 ReqUpdateMeName = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateMePassword.ts b/negodata/front/src/api/generated/model/reqUpdateMePassword.ts new file mode 100644 index 0000000..660e7b0 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateMePassword.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 ReqUpdateMePassword = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts b/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts index 1235351..857bcd5 100644 --- a/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts +++ b/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts @@ -6,12 +6,10 @@ */ import type { ReqUpdateQuotationSettingTargetMarginRate } from './reqUpdateQuotationSettingTargetMarginRate'; import type { ReqUpdateQuotationSettingAnchoringValue } from './reqUpdateQuotationSettingAnchoringValue'; -import type { ReqUpdateQuotationSettingInternetAverageFee } from './reqUpdateQuotationSettingInternetAverageFee'; import type { ReqUpdateQuotationSettingCardCount } from './reqUpdateQuotationSettingCardCount'; export interface ReqUpdateQuotationSetting { target_margin_rate?: ReqUpdateQuotationSettingTargetMarginRate; anchoring_value?: ReqUpdateQuotationSettingAnchoringValue; - internet_average_fee?: ReqUpdateQuotationSettingInternetAverageFee; card_count?: ReqUpdateQuotationSettingCardCount; } diff --git a/negodata/front/src/api/generated/model/resNotifySessions.ts b/negodata/front/src/api/generated/model/resNotifySessions.ts new file mode 100644 index 0000000..6d23058 --- /dev/null +++ b/negodata/front/src/api/generated/model/resNotifySessions.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from './errorInfo'; +import type { ResNotifySessionsMsg } from './resNotifySessionsMsg'; + +export interface ResNotifySessions { + result?: ErrorInfo; + msg?: ResNotifySessionsMsg; + sent?: number; + failed?: number; + skipped?: number; + total?: number; +} diff --git a/negodata/front/src/api/generated/model/resNotifySessionsMsg.ts b/negodata/front/src/api/generated/model/resNotifySessionsMsg.ts new file mode 100644 index 0000000..2d1d777 --- /dev/null +++ b/negodata/front/src/api/generated/model/resNotifySessionsMsg.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 ResNotifySessionsMsg = string | null; diff --git a/negodata/front/src/api/generated/model/sessionData.ts b/negodata/front/src/api/generated/model/sessionData.ts index b48fec3..69f19fb 100644 --- a/negodata/front/src/api/generated/model/sessionData.ts +++ b/negodata/front/src/api/generated/model/sessionData.ts @@ -5,12 +5,14 @@ * OpenAPI spec version: 0.1.0 */ import type { QuotationType } from './quotationType'; +import type { SessionDataTargetAnchoringPrice } from './sessionDataTargetAnchoringPrice'; import type { SessionStatus } from './sessionStatus'; import type { SessionDataBidPrice } from './sessionDataBidPrice'; import type { SessionDataBidAt } from './sessionDataBidAt'; import type { SessionDataRejectReason } from './sessionDataRejectReason'; import type { SessionDataRejectPrice } from './sessionDataRejectPrice'; import type { SessionDataRejectDeliveryType } from './sessionDataRejectDeliveryType'; +import type { SessionDataEmailSentAt } from './sessionDataEmailSentAt'; export interface SessionData { session_id: string; @@ -21,6 +23,7 @@ export interface SessionData { qt_round: number; qt_type: QuotationType; target_price: number; + target_anchoring_price?: SessionDataTargetAnchoringPrice; status: SessionStatus; bid_price?: SessionDataBidPrice; bid_at?: SessionDataBidAt; @@ -28,5 +31,6 @@ export interface SessionData { reject_reason?: SessionDataRejectReason; reject_price?: SessionDataRejectPrice; reject_delivery_type?: SessionDataRejectDeliveryType; + email_sent_at?: SessionDataEmailSentAt; url?: string; } diff --git a/negodata/front/src/api/generated/model/sessionDataEmailSentAt.ts b/negodata/front/src/api/generated/model/sessionDataEmailSentAt.ts new file mode 100644 index 0000000..aff7d65 --- /dev/null +++ b/negodata/front/src/api/generated/model/sessionDataEmailSentAt.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 SessionDataEmailSentAt = string | null; diff --git a/negodata/front/src/api/generated/model/sessionDataTargetAnchoringPrice.ts b/negodata/front/src/api/generated/model/sessionDataTargetAnchoringPrice.ts new file mode 100644 index 0000000..510d4bd --- /dev/null +++ b/negodata/front/src/api/generated/model/sessionDataTargetAnchoringPrice.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 SessionDataTargetAnchoringPrice = number | null; diff --git a/negodata/front/src/api/generated/model/supplierType.ts b/negodata/front/src/api/generated/model/supplierType.ts index 6fbada4..38cc6ca 100644 --- a/negodata/front/src/api/generated/model/supplierType.ts +++ b/negodata/front/src/api/generated/model/supplierType.ts @@ -6,15 +6,16 @@ */ /** - * suppliers.type 코드값(KTC 앵커링 기준). 0=기타는 매칭 실패 폴백용 — UI 선택지엔 미노출. + * quotations.supplier_type 코드값. 없음(0,미지정)/유통(1)/제조(2)/총판(3). +없음은 프론트 폼에 '없음'으로 노출. KTC 앵커링 코드(기타=0)와 매핑 시 0↔없음 대응. */ export type SupplierType = typeof SupplierType[keyof typeof SupplierType]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const SupplierType = { + NONE: 0, DISTRIBUTION: 1, MANUFACTURE: 2, SOLE_AGENCY: 3, - ETC: 0, } as const; diff --git a/negodata/front/src/api/generated/model/userStatus.ts b/negodata/front/src/api/generated/model/userStatus.ts new file mode 100644 index 0000000..d0a40bb --- /dev/null +++ b/negodata/front/src/api/generated/model/userStatus.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +/** + * users.status 코드값. + */ +export type UserStatus = typeof UserStatus[keyof typeof UserStatus]; + + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const UserStatus = { + ACTIVE: 1, + INACTIVE: 2, +} as const; diff --git a/negodata/front/src/api/generated/quotation/quotation.ts b/negodata/front/src/api/generated/quotation/quotation.ts index 05bc040..b68178e 100644 --- a/negodata/front/src/api/generated/quotation/quotation.ts +++ b/negodata/front/src/api/generated/quotation/quotation.ts @@ -31,6 +31,7 @@ import type { ResCreateQuotation, ResDeleteQuotation, ResLastSupplierType, + ResNotifySessions, ResQuotation, ResQuotationCards, ResQuotationList, @@ -516,6 +517,68 @@ export function useGetQuotationSessions,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/quotation/${qtId}/notify`, method: 'POST', signal + }, + options); + } + + + +export const getNotifyQuotationMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{qtId: string}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{qtId: string}, TContext> => { + +const mutationKey = ['notifyQuotation']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {qtId: string}> = (props) => { + const {qtId} = props ?? {}; + + return notifyQuotation(qtId,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type NotifyQuotationMutationResult = NonNullable>> + + export type NotifyQuotationMutationError = void | HTTPValidationError + + /** + * @summary 협상 초청 메일 발송 + */ +export const useNotifyQuotation = (options?: { mutation?:UseMutationOptions>, TError,{qtId: string}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {qtId: string}, + TContext + > => { + + const mutationOptions = getNotifyQuotationMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** * @summary 채팅 상세 */ export const getSessionChat = ( @@ -608,6 +671,68 @@ export function useGetSessionChat,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/quotation/session/${sessionId}/notify`, method: 'POST', signal + }, + options); + } + + + +export const getNotifySessionMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{sessionId: string}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{sessionId: string}, TContext> => { + +const mutationKey = ['notifySession']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {sessionId: string}> = (props) => { + const {sessionId} = props ?? {}; + + return notifySession(sessionId,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type NotifySessionMutationResult = NonNullable>> + + export type NotifySessionMutationError = void | HTTPValidationError + + /** + * @summary 세션 초청 메일 재발송 + */ +export const useNotifySession = (options?: { mutation?:UseMutationOptions>, TError,{sessionId: string}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {sessionId: string}, + TContext + > => { + + const mutationOptions = getNotifySessionMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** * @summary 낙찰 결과 */ export const getQuotationResult = ( diff --git a/negodata/front/src/components/layout/Layout.tsx b/negodata/front/src/components/layout/Layout.tsx index ee7aeeb..414b0f7 100644 --- a/negodata/front/src/components/layout/Layout.tsx +++ b/negodata/front/src/components/layout/Layout.tsx @@ -1,6 +1,7 @@ import { useState, type ReactNode, type ElementType } from 'react'; import { PageType } from '@/types'; import { useAuth } from '@/features/auth/useAuth'; +import { ProfileSheet } from '@/features/auth/components/ProfileSheet'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Typography } from '@/components/ui/typography'; @@ -8,6 +9,8 @@ import { cn } from '@/lib/utils'; import { Briefcase, Users, + UserCog, + UserPen, FileSpreadsheet, Layers, LogOut, @@ -28,11 +31,13 @@ interface LayoutProps { type SidebarUser = ReturnType['user']; -const menuItems = [ - { type: 'PRODUCTS' as PageType, label: '상품관리', icon: Briefcase, id: 'sidebar-products' }, - { type: 'PARTNERS' as PageType, label: '협력사관리', icon: Users, id: 'sidebar-partners' }, - { type: 'QUOTATION' as PageType, label: '견적관리', icon: FileSpreadsheet, id: 'sidebar-quotation' }, - { type: 'CARDS' as PageType, label: '협상카드관리', icon: Layers, id: 'sidebar-cards' }, +// ownerOnly 항목은 최고관리자에게만 노출된다(렌더 시 user.role 로 필터). +const menuItems: { type: PageType; label: string; icon: ElementType; id: string; ownerOnly?: boolean }[] = [ + { type: 'PRODUCTS', label: '상품관리', icon: Briefcase, id: 'sidebar-products' }, + { type: 'PARTNERS', label: '협력사관리', icon: Users, id: 'sidebar-partners' }, + { type: 'QUOTATION', label: '견적관리', icon: FileSpreadsheet, id: 'sidebar-quotation' }, + { type: 'CARDS', label: '협상카드관리', icon: Layers, id: 'sidebar-cards' }, + { type: 'MEMBERS', label: '회원관리', icon: UserCog, id: 'sidebar-members', ownerOnly: true }, ]; const pageLabelMap: Record = { @@ -40,6 +45,7 @@ const pageLabelMap: Record = { PARTNERS: '협력사관리', QUOTATION: '견적관리', CARDS: '협상카드관리', + MEMBERS: '회원관리', }; export default function Layout({ children, currentPage, setPage, onLogout }: LayoutProps) { @@ -50,6 +56,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay const [isDark, setIsDark] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(true); // 데스크톱 접기 토글 const [isMobileOpen, setIsMobileOpen] = useState(false); // 모바일 드로어 열림 + const [isProfileOpen, setIsProfileOpen] = useState(false); // 내 정보 수정 시트 // 사이드바 펼침 여부(라벨/프로필 노출 기준). 모바일 드로어는 항상 펼친 상태로 본다. const expanded = isMobileOpen || isSidebarOpen; @@ -126,7 +133,9 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay {/* Nav Items */}
+ + {isProfileOpen && setIsProfileOpen(false)} />}
); } diff --git a/negodata/front/src/components/ui/typography.tsx b/negodata/front/src/components/ui/typography.tsx index c31e769..36975b3 100644 --- a/negodata/front/src/components/ui/typography.tsx +++ b/negodata/front/src/components/ui/typography.tsx @@ -21,6 +21,10 @@ const typographyVariants = cva("", { mono: "text-xs font-mono uppercase tracking-wider text-muted-foreground", // 사이드바/헤더 등 chrome 메타텍스트: type scale 최소(text-xs=12px)보다 작은 11px 캡션 caption: "text-[11px] text-muted-foreground leading-normal", + // 링크/클릭 가능한 텍스트: 상시 밑줄 + primary 색. 크기/굵기는 className 으로 합성한다 + // (variant 는 배타적이라 폰트 사이즈를 박지 않음). react-router /
- {/* MD 제시가 — 신규·재 공통(입력 시 목표가로 사용) */} + {/* MD 제시가 — 입력 시 목표가로 사용. 상품에 다른 후보가 없으면 유일 후보라 필수. */}
- MD 제시가 (선택) + + MD 제시가 {mdRequired ? '(필수 — 다른 후보 없음)' : '(선택)'} + setMdPrice(e.target.value)} - placeholder="입력 시 목표가로 사용 · 미입력 시 자동 산정" + placeholder={mdRequired ? '상품에 산정값이 없어 MD가 입력이 필요합니다' : '입력 시 목표가로 사용 · 미입력 시 자동 산정'} />
- {/* 재견적·재협상 — 매입가(필수)·판매가, 선택 상품의 저장값으로 디폴트 */} - {showPrices && ( -
-
- 매입가 (필수) - setPurchaseInput(e.target.value)} - placeholder="상품 저장값 · 비우면 진행 불가" - /> + {/* 목표가 산정 후보 — 상품 값(읽기전용). 신규=인터넷최저가, 재=+매입가·판매가. 수정은 상품 상세에서. */} + {productId && ( +
+
+ + 목표가 산정 후보 ({isReType ? '재' : '신규'}) + +
-
- 판매가 (선택) - setSellingInput(e.target.value)} - placeholder="상품 저장값 · 마진 상한 산정에 사용" - /> +
+ {[ + { 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()}` : '-'} + +
+ ))}
+ {!targetReady && ( + + ⚠ MD 제시가도 없고 상품에 산정할 값도 없습니다 — MD가를 입력하거나 위 ‘상품 상세에서 수정’으로 값을 채워야 목표가가 나옵니다. + + )}
)}
@@ -438,8 +444,8 @@ export function QuotationCreateModal({ type="button" size="sm" onClick={() => { - if (step === 1 && showPrices && !purchaseInput) { - showToast('재견적·재협상은 매입가가 필수입니다.', 'error'); + if (step === 1 && productId && !targetReady) { + showToast('목표가 산정에 쓸 값이 없습니다 — MD 제시가를 입력하거나 상품 상세에서 값을 채워주세요.', 'error'); return; } setStep((prev) => prev + 1); diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx index c8dd522..657dbcc 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx @@ -2,6 +2,8 @@ import type { ReactNode } from 'react'; import { Link } from 'react-router'; import { Package } from 'lucide-react'; import { Card } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import { typographyVariants } from '@/components/ui/typography'; import { InfoField } from './InfoField'; import { QuotationStatusBadge } from './StatusPill'; import type { QuotationData } from '@/api/generated/model/quotationData'; @@ -62,7 +64,10 @@ export function DrawerHeaderCards({ const productSpecRows = currentProduct ? [ { label: '상품코드', value: currentProduct.code || '-' }, - { label: '단가', value: currentProduct.price != null ? `₩${Number(currentProduct.price).toLocaleString()}` : '-' }, + { label: '상품단가', value: currentProduct.price != null ? `₩${Number(currentProduct.price).toLocaleString()}` : '-' }, + { label: '매입가', value: currentProduct.purchase_price != null ? `₩${Number(currentProduct.purchase_price).toLocaleString()}` : '-' }, + { label: '판매가', value: currentProduct.selling_price != null ? `₩${Number(currentProduct.selling_price).toLocaleString()}` : '-' }, + { label: '인터넷 최저가', value: currentProduct.internet_lowest_price != null ? `₩${Number(currentProduct.internet_lowest_price).toLocaleString()}` : '-' }, { label: '모델명', value: currentProduct.model_name || '-' }, { label: '규격', value: currentProduct.spec || '-' }, { label: '제조사', value: currentProduct.manufacturer || '-' }, @@ -146,7 +151,7 @@ export function DrawerHeaderCards({
{currentProduct.name || '-'} @@ -182,7 +187,6 @@ export function DrawerHeaderCards({
) : ( diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/QuotationCardsTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/QuotationCardsTab.tsx index 0df411c..4d4b082 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/QuotationCardsTab.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/QuotationCardsTab.tsx @@ -1,5 +1,6 @@ import { Link } from 'react-router'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; +import { typographyVariants } from '@/components/ui/typography'; import { StatusPill } from './StatusPill'; import { mapServerCardView } from '../../types'; @@ -26,7 +27,7 @@ export function QuotationCardsTab({ quotationCardViews }: { quotationCardViews: {qc.card_id ? ( {qc.card_name} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx index a1b6c7d..50c335e 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx @@ -1,6 +1,10 @@ -import { MessageSquare, ExternalLink, Copy } from 'lucide-react'; +import { useState } from 'react'; +import { MessageSquare, Copy, Mail, MailCheck, Send } from 'lucide-react'; import { showToast } from '@/lib/notify'; +import { confirm } from '@/lib/confirm'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; +import { typographyVariants } from '@/components/ui/typography'; +import { cn } from '@/lib/utils'; import { StatusPill, sessionStatusTone } from './StatusPill'; import { mapServerSessionView, sessionStatusLabel } from '../../types'; @@ -8,20 +12,95 @@ type SessionView = ReturnType; export function SessionsStatusTab({ sessionViews, + canNotify, onOpenChat, + onShowTarget, + onNotifyAll, + onNotifyOne, }: { sessionViews: SessionView[]; + /** 초청 메일 발송 권한(견적 소유자만). false 면 발송 버튼 비활성. */ + canNotify: boolean; onOpenChat: (sessionId: string) => void; + /** 목표가 셀 클릭 → 산정내역 모달. */ + onShowTarget: (sessionId: string) => void; + /** 미발송 세션 전체에 초청 메일 발송. */ + onNotifyAll: () => Promise; + /** 한 세션(공급사)에 초청 메일 발송/재발송. */ + onNotifyOne: (sessionId: string) => Promise; }) { + const [sendingAll, setSendingAll] = useState(false); + const [sendingId, setSendingId] = useState(null); + const unsentCount = sessionViews.filter((s) => !s.email_sent_at).length; + + const handleAll = async () => { + if ( + !(await confirm({ + title: '초청 메일 발송', + description: `미발송 ${unsentCount}곳의 협력사에게 협상 초청 메일을 발송하시겠습니까?`, + confirmText: '발송', + })) + ) + return; + setSendingAll(true); + try { + await onNotifyAll(); + } finally { + setSendingAll(false); + } + }; + + const handleOne = async (sessionId: string, supplierName: string, alreadySent: boolean) => { + if ( + !(await confirm({ + title: alreadySent ? '초청 메일 재발송' : '초청 메일 발송', + description: alreadySent + ? `[${supplierName}]에 협상 초청 메일을 재발송하시겠습니까?` + : `[${supplierName}]에 협상 초청 메일을 발송하시겠습니까?`, + confirmText: alreadySent ? '재발송' : '발송', + })) + ) + return; + setSendingId(sessionId); + try { + await onNotifyOne(sessionId); + } finally { + setSendingId(null); + } + }; + return (
+ {/* 초청 메일 발송 툴바 — 미발송분 일괄 발송. 개별 재발송은 행의 버튼으로. */} +
+ + {unsentCount > 0 ? `미발송 ${unsentCount}건` : '모든 협력사에 초청 메일 발송 완료'} + + +
+
- +
세션 ID 협력사 협상 URL + 초청메일 상품 협상상태 목표가 @@ -36,7 +115,7 @@ export function SessionsStatusTab({ {sessionViews.length === 0 && ( - + 참여 중인 협상 세션이 없습니다. (리스트가 비어 있습니다) @@ -58,37 +137,65 @@ export function SessionsStatusTab({ {sess.url ? ( -
- - 세션 열기 - - -
+ ) : ( - )}
+ +
+
+ {sess.email_sent_at ? ( + + 발송됨 + + ) : ( + + 미발송 + + )} + +
+ {sess.email_sent_at && ( + {sess.email_sent_at} + )} +
+
{sess.item_name} {sessionStatusLabel(sess.status)} - - ₩{sess.target_price?.toLocaleString() || '-'} + + {sess.bid_price ? `₩${sess.bid_price.toLocaleString()}` : '-'} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx new file mode 100644 index 0000000..6441ed5 --- /dev/null +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx @@ -0,0 +1,162 @@ +import { X, Check } from 'lucide-react'; +import { Typography } from '@/components/ui/typography'; + +// 세션 목표가 산정내역 모달. backend _calc_target_price 로직을 그대로 재구성해 후보·채택을 보여준다. +// (신규견적은 인터넷최저가만 후보, 재는 매입가·판매가까지 / md 입력가 최우선. 앵커링은 설정 anchoring_value 고정율.) +type TargetPriceModalProps = { + onClose: () => void; + qtNumber: string; + itemName: string; + vatYn?: boolean | null; + deliveryFeeYn?: boolean | null; + category?: string | null; + supplierTypeLabel: string; + targetPrice: number; + anchoringPrice: number; + mdPrice?: number | null; + internetLowest?: number | null; + purchase?: number | null; + selling?: number | null; + fee: number; + margin: number; + anchoringValue: number; + isNew: boolean; +}; + +const won = (n: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-'); + +type CandKey = 'md' | 'net' | 'sell' | 'buy'; + +export function TargetPriceModal({ + onClose, + qtNumber, + itemName, + vatYn, + deliveryFeeYn, + category, + supplierTypeLabel, + targetPrice, + anchoringPrice, + mdPrice, + internetLowest, + purchase, + selling, + fee, + margin, + anchoringValue, + isNew, +}: TargetPriceModalProps) { + // 후보값 계산 (신규는 인터넷최저가만, 재는 매입가·판매가까지) + const md = mdPrice && mdPrice > 0 ? Math.round(mdPrice) : null; + const net = internetLowest && internetLowest > 0 ? Math.round(internetLowest * (1 - fee)) : null; + const sell = !isNew && selling && selling > 0 ? Math.round(selling * (1 - margin)) : null; + const buy = !isNew && purchase && purchase > 0 ? Math.round(purchase) : null; + + // 채택 후보: md 최우선, 없으면 유효 후보 중 최소값 + let applied: CandKey | null = null; + if (md != null) { + applied = 'md'; + } else { + const pool = ([['net', net], ['sell', sell], ['buy', buy]] as [CandKey, number | null][]).filter( + (c): c is [CandKey, number] => c[1] != null, + ); + if (pool.length) applied = pool.reduce((m, c) => (c[1] < m[1] ? c : m))[0]; + } + + const rows: { key: CandKey; label: string; sub?: string; value: number | null }[] = [ + { key: 'md', label: 'MD 입력가', value: md }, + { key: 'net', label: '인터넷 최저가', sub: '인터넷 평균 수수료 적용', value: net }, + { key: 'sell', label: '판매가', sub: '목표 마진율 적용', value: sell }, + { key: 'buy', label: '매입가', value: buy }, + ]; + + return ( +
+
e.stopPropagation()} + > + {/* 헤더 */} +
+ 목표가 + +
+ + 견적번호: {qtNumber} · 상품: {itemName} + + + 배송비: {deliveryFeeYn ? '배송비포함' : '배송비별도'} · 부가세: {vatYn ? 'VAT포함' : 'VAT별도'} + + + {/* 목표가 */} +
+ 목표가 +
+ {won(targetPrice)} +
+
+ + {/* 선정방식 */} +
+ 목표가 선정방식 + 1. MD 입력값 존재 시, 최우선 적용 + 2. 다음 중 가장 작은 값 — 인터넷최저가×(1−수수료) | 매입가 | 판매가×(1−목표 마진율) + + * 인터넷 평균 수수료: {fee} · 목표 마진율: {margin}{isNew ? ' · 신규견적이라 인터넷최저가만 적용' : ''} + +
+ + {/* 후보 */} +
+ {rows.map((r) => { + const on = r.key === applied; + return ( +
+ + {on ? : null} + +
+ + {r.label} + + {r.sub && ( + + ({r.sub}) + + )} +
+
+ {won(r.value)} +
+
+ ); + })} +
+ + {/* 앵커링 (negodata: 설정 anchoring_value 고정율) */} +
+ 앵커링 + {category && ( + 서비스 카테고리: {category} + )} + 공급 업체 유형: {supplierTypeLabel} + 앵커링 값: {anchoringValue} + + 앵커링가: {won(anchoringPrice)} = 목표가×(1−{anchoringValue}) + +
+
+
+ ); +} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx index beadb82..ec6686f 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx @@ -11,6 +11,7 @@ import { useListSuppliers } from '@/api/generated/supplier/supplier'; import { useListSettings } from '@/api/generated/quotation-setting/quotation-setting'; import { useQuotationChain } from '../../hooks/useQuotationChain'; import { useScrollLock } from '@/lib/useScrollLock'; +import { useAuthStore } from '@/stores/auth'; import type { QuotationData } from '@/api/generated/model/quotationData'; import { mapItem, @@ -18,12 +19,14 @@ import { mapSetting, mapServerSessionView, mapServerCardView, + supplierTypeOptions, } from '../../types'; -import { QuotationStatus } from '@/api/generated/model'; +import { QuotationStatus, QuotationType } from '@/api/generated/model'; import { DrawerHeaderCards } from './DrawerHeaderCards'; import { RoundTimeline } from './RoundTimeline'; import { RegenerateModal } from './RegenerateModal'; import { SessionsStatusTab } from './SessionsStatusTab'; +import { TargetPriceModal } from './TargetPriceModal'; import { QuotationCardsTab } from './QuotationCardsTab'; import { ChatTab } from './ChatTab'; @@ -36,6 +39,10 @@ type QuotationDetailSheetProps = { onSwitchRound: (qtId: string) => void; /** 마감된 견적의 다음 라운드를 수동 생성(공급사 선택). 성공 시 새 qt_id 반환. */ onRegenerate: (qtId: string, supplierIds: string[]) => Promise; + /** 협상 초청 메일 — 견적 단위(미발송 세션 전체) 발송. */ + onNotify: (qtId: string) => Promise; + /** 협상 초청 메일 — 세션(공급사) 단위 재발송. */ + onNotifySession: (sessionId: string, qtId: string) => Promise; onClose: () => void; }; @@ -44,11 +51,14 @@ export function QuotationDetailSheet({ onCloseQuotation, onSwitchRound, onRegenerate, + onNotify, + onNotifySession, onClose, }: QuotationDetailSheetProps) { const [activeTab, setActiveTab] = useState('status'); const [showHeaderCards, setShowHeaderCards] = useState(true); const [regenOpen, setRegenOpen] = useState(false); + const [targetSessionId, setTargetSessionId] = useState(null); // 시트 열린 동안 뒤 견적 리스트(
) 스크롤 잠금 — 옆에 배경 스크롤바가 같이 뜨는 것 방지. useScrollLock(); @@ -60,6 +70,9 @@ export function QuotationDetailSheet({ ).map(mapSetting); const qtId = quotation.qt_id ?? ''; + // 초청 메일 발송은 견적 소유자만. (백엔드 스코프 도입 전까지의 1차 차단 — 본인 견적 아니면 버튼 비활성) + const myUserId = useAuthStore((s) => s.user?.userId); + const canNotify = !!myUserId && quotation.user_id === myUserId; // 협상 세션·사용 카드는 견적 단위, 채팅은 선택 세션 단위로 서버에서 읽는다. // 세션은 협상 진행으로 계속 바뀌므로 탭 복귀 시 재조회한다. 카드는 생성 후 불변이라 끄둔다. const sessionsQuery = useGetQuotationSessions(qtId, { @@ -231,7 +244,14 @@ export function QuotationDetailSheet({ {/* Tab content */}
{activeTab === 'status' && ( - + onNotify(qtId)} + onNotifyOne={(sessionId) => onNotifySession(sessionId, qtId)} + /> )} {activeTab === 'cards' && } @@ -251,6 +271,33 @@ export function QuotationDetailSheet({
+ {targetSessionId && currentItem && (() => { + const ts = sessionViews.find((s) => s.session_id === targetSessionId); + if (!ts) return null; + const rawSetting = settingsQuery.data?.settings?.find((s) => s.qt_setting_id === quotation.qt_setting_id); + return ( + setTargetSessionId(null)} + qtNumber={quotation.number ?? '-'} + itemName={currentItem.name ?? ts.item_name ?? '-'} + vatYn={currentItem.vat_yn} + deliveryFeeYn={currentItem.delivery_fee_yn} + category={currentItem.category} + supplierTypeLabel={supplierTypeOptions.find((o) => o.value === quotation.supplier_type)?.label ?? '-'} + targetPrice={ts.target_price} + anchoringPrice={ts.target_anchoring_price} + mdPrice={quotation.md_price} + internetLowest={currentItem.internet_lowest_price} + purchase={currentItem.purchase_price} + selling={currentItem.selling_price} + fee={0.078} + margin={rawSetting?.target_margin_rate ?? 0} + anchoringValue={rawSetting?.anchoring_value ?? 0} + isNew={quotation.type === QuotationType.NEW_NEGO || quotation.type === QuotationType.NEW_QUOTE} + /> + ); + })()} + {regenOpen && ( { e.preventDefault(); - const ok = onAdd({ targetMargin, anchoringValue, internetFee, cardUseCount }); + const ok = onAdd({ targetMargin, anchoringValue, cardUseCount }); if (ok) { setTargetMargin(''); setAnchoringValue(''); - setInternetFee('7.8'); setCardUseCount(''); } }; @@ -110,10 +108,6 @@ export function QuotationSettingsModal({ 앵커링 값 setAnchoringValue(e.target.value)} placeholder="예: 0.01" /> -
- 인터넷 수수료율 (%) - setInternetFee(e.target.value)} placeholder="예: 7.8" /> -
카드 사용 횟수 setCardUseCount(e.target.value)} placeholder="예: 3" /> diff --git a/negodata/front/src/features/quotations/components/QuotationTable.tsx b/negodata/front/src/features/quotations/components/QuotationTable.tsx index 9ca85f7..2f73bb4 100644 --- a/negodata/front/src/features/quotations/components/QuotationTable.tsx +++ b/negodata/front/src/features/quotations/components/QuotationTable.tsx @@ -1,7 +1,8 @@ import { type ReactNode } from 'react'; import { Clock, Building2, Link2, CornerDownRight } from 'lucide-react'; import { DataTable } from '@/components/ui/data-table'; -import { Typography } from '@/components/ui/typography'; +import { Typography, typographyVariants } from '@/components/ui/typography'; +import { cn } from '@/lib/utils'; import { useQuotationChain } from '../hooks/useQuotationChain'; import { type Estimate, @@ -52,7 +53,7 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo const productName = product?.name ?? est.productName; // 목록에 없으면 서버 조인 상품명으로 폴백 return (
- + {est.title} @@ -77,7 +78,7 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo onFilterChain(est.number!); }} title="이 견적번호의 모든 차수만 보기" - className="inline-flex items-center gap-1 hover:text-primary hover:underline cursor-pointer" + className={cn(typographyVariants({ variant: 'link' }), 'inline-flex items-center gap-1')} > {est.number} diff --git a/negodata/front/src/features/quotations/hooks/useQuotations.ts b/negodata/front/src/features/quotations/hooks/useQuotations.ts index 63ae1b0..c077963 100644 --- a/negodata/front/src/features/quotations/hooks/useQuotations.ts +++ b/negodata/front/src/features/quotations/hooks/useQuotations.ts @@ -15,6 +15,8 @@ import { useCreateQuotation, useStopQuotation, useRegenerateQuotation, + useNotifyQuotation, + useNotifySession, getGetQuotationQueryKey, getGetQuotationSessionsQueryKey, } from '@/api/generated/quotation/quotation'; @@ -43,7 +45,6 @@ export type CreateQuotationInput = { export type SettingInput = { targetMargin: string; anchoringValue: string; - internetFee: string; cardUseCount: string; }; @@ -63,6 +64,8 @@ export function useQuotations(params: ListQuotationsParams) { const createQuotationMutation = useCreateQuotation(); const stopQuotationMutation = useStopQuotation(); const regenerateQuotationMutation = useRegenerateQuotation(); + const notifyQuotationMutation = useNotifyQuotation(); + const notifySessionMutation = useNotifySession(); // 파라미터별 목록 쿼리 키 전부 재조회(prefix 무효화). const invalidateQuotations = () => @@ -119,14 +122,13 @@ export function useQuotations(params: ListQuotationsParams) { const addSetting = (input: SettingInput): boolean => { const marginPct = Number(String(input.targetMargin).replace('%', '').trim()); const anchoring = Number(String(input.anchoringValue).trim()); - const feePct = Number(String(input.internetFee).replace('%', '').trim()); const cardCount = parseInt(String(input.cardUseCount).replace(/[^0-9-]/g, ''), 10); - if (!Number.isFinite(marginPct) || !Number.isFinite(anchoring) || !Number.isFinite(feePct) || !Number.isInteger(cardCount)) { - showToast('목표 마진율·앵커링 값·수수료율·카드 사용 횟수를 숫자로 입력해야 합니다.', 'error'); + if (!Number.isFinite(marginPct) || !Number.isFinite(anchoring) || !Number.isInteger(cardCount)) { + showToast('목표 마진율·앵커링 값·카드 사용 횟수를 숫자로 입력해야 합니다.', 'error'); return false; } createSettingMutation.mutate( - { data: { target_margin_rate: marginPct / 100, anchoring_value: anchoring, internet_average_fee: feePct / 100, card_count: cardCount } }, + { data: { target_margin_rate: marginPct / 100, anchoring_value: anchoring, card_count: cardCount } }, { onSuccess: () => { invalidateSettings(); @@ -236,6 +238,48 @@ export function useQuotations(params: ListQuotationsParams) { } }; + // 협상 초청 메일 — 견적 단위(미발송 세션 전체) 발송. 발송 후 세션 재조회로 발송배지 갱신. + const notifyQuotation = async (qtId: string): Promise => { + try { + const res = await notifyQuotationMutation.mutateAsync({ qtId }); + if (!res?.result?.success) { + const reason = res?.msg ?? res?.result?.desc ?? '서버 오류'; + showToast(`초청 메일 발송 실패: ${reason}`, 'error'); + return; + } + const sent = res.sent ?? 0; + const parts = [`${sent}건 발송`]; + if (res.failed) parts.push(`${res.failed}건 실패`); + if (res.skipped) parts.push(`${res.skipped}건 이메일없음`); + showToast(`초청 메일 — ${parts.join(' · ')}`, sent > 0 ? 'success' : 'info'); + } catch { + showToast('초청 메일 발송에 실패했습니다.', 'error'); + } finally { + queryClient.invalidateQueries({ queryKey: getGetQuotationSessionsQueryKey(qtId) }); + } + }; + + // 협상 초청 메일 — 세션(공급사) 단위 재발송. qtId 는 세션 목록 재조회용. + const notifySession = async (sessionId: string, qtId: string): Promise => { + try { + const res = await notifySessionMutation.mutateAsync({ sessionId }); + if (!res?.result?.success) { + const reason = res?.msg ?? res?.result?.desc ?? '서버 오류'; + showToast(`재발송 실패: ${reason}`, 'error'); + } else if (res.skipped) { + showToast('담당자 이메일이 없어 발송하지 못했습니다.', 'info'); + } else if (res.sent) { + showToast('초청 메일을 재발송했습니다.', 'success'); + } else { + showToast('초청 메일 발송에 실패했습니다.', 'error'); + } + } catch { + showToast('재발송에 실패했습니다.', 'error'); + } finally { + queryClient.invalidateQueries({ queryKey: getGetQuotationSessionsQueryKey(qtId) }); + } + }; + return { products, partners, @@ -248,5 +292,7 @@ export function useQuotations(params: ListQuotationsParams) { deleteSetting, createQuotation, regenerateQuotation, + notifyQuotation, + notifySession, }; } diff --git a/negodata/front/src/features/quotations/types.ts b/negodata/front/src/features/quotations/types.ts index 477274f..7302709 100644 --- a/negodata/front/src/features/quotations/types.ts +++ b/negodata/front/src/features/quotations/types.ts @@ -6,7 +6,6 @@ import type { SessionData } from '@/api/generated/model/sessionData'; import type { QuotationCardData } from '@/api/generated/model/quotationCardData'; import { QuotationType, QuotationStatus, SessionStatus, CardType, SupplierType } from '@/api/generated/model'; import { DELIVERY_TYPE_LABEL } from '@/lib/enumLabels'; -import { toMinPrice } from '@/features/products/types'; import type { Product, Partner, NegotiationCard } from '@/types'; export type { Product, Partner, NegotiationCard } from '@/types'; @@ -41,7 +40,7 @@ export interface QuotationSetting { // ── 서버 응답 → UI 모델 매퍼 ───────────────────────────────────────────── export function mapItem(it: ItemData): Product { - return { ...it, id: it.item_id, minPrice: toMinPrice(it.price), status: 'ACTIVE' } as Product; + return { ...it, id: it.item_id, minPrice: it.internet_lowest_price ?? 0, status: 'ACTIVE' } as Product; } export function mapSupplier(sp: SupplierData): Partner { @@ -156,7 +155,7 @@ export const supplierTypeOptions: { value: number; label: string }[] = [ { value: SupplierType.DISTRIBUTION, label: '유통' }, { value: SupplierType.MANUFACTURE, label: '제조' }, { value: SupplierType.SOLE_AGENCY, label: '총판' }, - { value: SupplierType.ETC, label: '없음' }, + { value: SupplierType.NONE, label: '없음' }, ]; // ── 라운드 체인(같은 견적번호) ─────────────────────────────────────────── @@ -198,6 +197,7 @@ export type SessionView = { item_name: string; status: number; target_price: number; + target_anchoring_price: number; bid_price: number | null; bid_at: string; reject_reason: string | null; @@ -205,6 +205,7 @@ export type SessionView = { reject_delivery_type: string | null; end_time: string; url: string; // 세션 chat 실행 URL(공급사 협상 프론트) + email_sent_at: string | null; // 초청 메일 발송 시각(KST). null=미발송 → 발송배지/재발송 판단 }; export type QuotationCardView = { @@ -262,6 +263,7 @@ export function mapServerSessionView(sd: SessionData, partners: Partner[], produ item_name: product?.name || '-', status: sd.status, target_price: sd.target_price ?? 0, + target_anchoring_price: sd.target_anchoring_price ?? 0, bid_price: sd.bid_price ?? null, bid_at: sd.bid_at ? fmtDateTime(sd.bid_at) : '-', reject_reason: sd.reject_reason ?? null, @@ -271,6 +273,7 @@ export function mapServerSessionView(sd: SessionData, partners: Partner[], produ : null, end_time: fmtDateTime(sd.end_time), url: sd.url || '', + email_sent_at: sd.email_sent_at ? fmtDateTime(sd.email_sent_at) : null, }; } diff --git a/negodata/front/src/lib/enumLabels.ts b/negodata/front/src/lib/enumLabels.ts index 5d4aff1..0a7ef35 100644 --- a/negodata/front/src/lib/enumLabels.ts +++ b/negodata/front/src/lib/enumLabels.ts @@ -12,5 +12,5 @@ export const DELIVERY_TYPE_OPTIONS = Object.values(DeliveryType).map((value) => export const USER_ROLE_LABEL: Record = { [UserRole.USER]: '일반', - [UserRole.MANAGER]: '관리자', + [UserRole.OWNER]: '최고관리자', }; diff --git a/negodata/front/src/pages/quotation.tsx b/negodata/front/src/pages/quotation.tsx index f6a103b..0f16516 100644 --- a/negodata/front/src/pages/quotation.tsx +++ b/negodata/front/src/pages/quotation.tsx @@ -42,6 +42,8 @@ export default function QuotationPage() { deleteSetting, createQuotation, regenerateQuotation, + notifyQuotation, + notifySession, } = useQuotations(params); const totalPages = list.totalPages(total); @@ -58,7 +60,9 @@ export default function QuotationPage() { const detailQuery = useGetQuotation(detailId ?? '', { query: { enabled: !!detailId, refetchOnWindowFocus: true, placeholderData: keepPreviousData }, }); - const activeQuotation = detailQuery.data?.quotation ?? null; + // detailId 로 게이트한다 — keepPreviousData 가 닫은 뒤에도 이전 견적을 들고 있어 + // detailId 가 null(닫힘)이어도 시트가 안 사라지던 버그 방지. 라운드 전환(둘 다 truthy)은 영향 없음. + const activeQuotation = detailId ? (detailQuery.data?.quotation ?? null) : null; return ( @@ -154,13 +158,15 @@ export default function QuotationPage() { } /> - {activeQuotation && ( + {detailId && activeQuotation && ( overlay.open('detail', qtId, { replace: true })} onRegenerate={regenerateQuotation} + onNotify={notifyQuotation} + onNotifySession={notifySession} onClose={overlay.close} /> )} diff --git a/negodata/front/src/types.ts b/negodata/front/src/types.ts index 53089d5..66dc7ec 100644 --- a/negodata/front/src/types.ts +++ b/negodata/front/src/types.ts @@ -33,4 +33,4 @@ export interface NegotiationCard { memo?: string; } -export type PageType = 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS'; +export type PageType = 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'MEMBERS'; diff --git a/postgres-init/01-schema.sql b/postgres-init/01-schema.sql index b25ebd3..efeb70b 100644 --- a/postgres-init/01-schema.sql +++ b/postgres-init/01-schema.sql @@ -69,7 +69,7 @@ CREATE TABLE IF NOT EXISTS company.users ( contact_number VARCHAR(20) NULL, -- 연락처 last_accessed_at TIMESTAMPTZ NOT NULL, -- 마지막 접속 시각 status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active, 2=inactive - role SMALLINT NOT NULL DEFAULT 1, -- 권한: 1=user, 2=manager + role SMALLINT NOT NULL DEFAULT 1, -- 권한: 1=일반, 2=최고관리자(owner) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 @@ -248,7 +248,6 @@ CREATE TABLE IF NOT EXISTS quotation.quotation_settings ( user_id uuid NOT NULL, -- 견적 설정을 생성한 유저 아이디(company.users.user_id) target_margin_rate NUMERIC(8,6) NOT NULL, -- 목표 마진율 (정수부 2자리 + 소수 6자리, -99.999999~99.999999) anchoring_value NUMERIC(8,6) NOT NULL DEFAULT 0.01, -- 앵커링 값 (정수부 2자리 + 소수 6자리) - internet_average_fee NUMERIC(8,6) NOT NULL DEFAULT 0.078, -- 인터넷 평균 수수료율 card_count INTEGER NOT NULL DEFAULT 3, -- 한개의 협상 안에서 협상카드 사용 횟수 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) @@ -304,6 +303,7 @@ CREATE TABLE IF NOT EXISTS negotiation.sessions ( reject_reason VARCHAR(255) NULL, -- 거절 사유 reject_price BIGINT NULL, -- 거절 시 제시가(원) reject_delivery_type SMALLINT NULL, -- 거절 시 배송 유형 (코드, 앱 enum 매핑) + email_sent_at TIMESTAMPTZ NULL, -- 협상 초청 메일 발송 시각(NULL=미발송). 수동 발송 버튼이 채움 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 diff --git a/postgres-init/04-alter.sql b/postgres-init/04-alter.sql index c106752..92de36f 100644 --- a/postgres-init/04-alter.sql +++ b/postgres-init/04-alter.sql @@ -12,10 +12,6 @@ ALTER TABLE partner.items ADD COLUMN IF NOT EXISTS purchase_price BIGINT, ADD COLUMN IF NOT EXISTS selling_price BIGINT; --- 견적 설정: 인터넷 평균 수수료율 -ALTER TABLE quotation.quotation_settings - ADD COLUMN IF NOT EXISTS internet_average_fee NUMERIC(8,6) NOT NULL DEFAULT 0.078; - -- 세션: 앵커링가 ALTER TABLE negotiation.sessions ADD COLUMN IF NOT EXISTS target_anchoring_price BIGINT; @@ -30,3 +26,9 @@ ALTER TABLE card.nego_cards ADD COLUMN IF NOT EXISTS usage_type SMALLINT NOT NULL DEFAULT 1; ALTER TABLE card.wild_cards ADD COLUMN IF NOT EXISTS usage_type SMALLINT NOT NULL DEFAULT 1; + +-- ─────────────────────────────────────────────────────────── +-- [2026-06-29] 협상 초청 메일: 세션별 발송 시각(수동 발송 버튼이 채움) +-- ─────────────────────────────────────────────────────────── +ALTER TABLE negotiation.sessions + ADD COLUMN IF NOT EXISTS email_sent_at TIMESTAMPTZ; From 33ce82997aee2c0080ad0a2d8ad51894aff0e216 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Tue, 30 Jun 2026 08:40:30 +0900 Subject: [PATCH 11/20] =?UTF-8?q?[refactor]=20negodata:=20=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=EA=B0=80=20=EC=82=B0=EC=A0=95=EB=82=B4=EC=97=AD?= =?UTF-8?q?=EC=9D=84=20=EB=B0=B1=EC=97=94=EB=93=9C=20/target-breakdown=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 생성(_calc_target_price)과 표시(get_target_breakdown)가 _candidates 헬퍼를 공유 → 프론트 재계산 제거, 저장된 목표가와 항상 일치 - TargetPriceModal: useGetTargetBreakdown 으로 후보·채택·앵커링가 '표시만' - QuotationType.is_new() 한 곳에서 신규유형 판단(생성·산정 공통) - enum 라벨 단일출처(lib/enumLabels.ts): 협력사유형/카드 사용구분/유저상태 members·CardFormSheet 는 거기서 re-export Co-Authored-By: Claude Opus 4.8 (1M context) --- negodata/backend/common/enums.py | 5 + .../backend/router/v1/quotation/protocol.py | 22 ++ .../backend/router/v1/quotation/quotation.py | 6 + .../backend/services/quotation_service.py | 109 ++++++++-- .../front/src/api/generated/model/index.ts | 9 + .../api/generated/model/resTargetBreakdown.ts | 33 +++ .../model/resTargetBreakdownChosenBasis.ts | 8 + .../model/resTargetBreakdownInternetLowest.ts | 8 + .../model/resTargetBreakdownMdPrice.ts | 8 + .../generated/model/resTargetBreakdownMsg.ts | 8 + .../model/resTargetBreakdownPurchase.ts | 8 + .../model/resTargetBreakdownSelling.ts | 8 + .../resTargetBreakdownTargetAnchoringPrice.ts | 8 + .../api/generated/model/targetCandidate.ts | 12 ++ .../src/api/generated/quotation/quotation.ts | 95 ++++++++- .../cards/components/CardFormSheet.tsx | 14 +- negodata/front/src/features/members/types.ts | 14 +- .../components/QuotationCreateModal.tsx | 11 +- .../QuotationDetailSheet/TargetPriceModal.tsx | 194 ++++++++---------- .../components/QuotationDetailSheet/index.tsx | 18 +- .../front/src/features/quotations/types.ts | 14 +- negodata/front/src/lib/enumLabels.ts | 34 ++- 22 files changed, 471 insertions(+), 175 deletions(-) create mode 100644 negodata/front/src/api/generated/model/resTargetBreakdown.ts create mode 100644 negodata/front/src/api/generated/model/resTargetBreakdownChosenBasis.ts create mode 100644 negodata/front/src/api/generated/model/resTargetBreakdownInternetLowest.ts create mode 100644 negodata/front/src/api/generated/model/resTargetBreakdownMdPrice.ts create mode 100644 negodata/front/src/api/generated/model/resTargetBreakdownMsg.ts create mode 100644 negodata/front/src/api/generated/model/resTargetBreakdownPurchase.ts create mode 100644 negodata/front/src/api/generated/model/resTargetBreakdownSelling.ts create mode 100644 negodata/front/src/api/generated/model/resTargetBreakdownTargetAnchoringPrice.ts create mode 100644 negodata/front/src/api/generated/model/targetCandidate.ts diff --git a/negodata/backend/common/enums.py b/negodata/backend/common/enums.py index f040de9..4d03152 100644 --- a/negodata/backend/common/enums.py +++ b/negodata/backend/common/enums.py @@ -138,6 +138,11 @@ class QuotationType(CodeEnum): NEW_NEGO = 3 # 신규협상(1:1) NEW_QUOTE = 4 # 신규견적(1:N) + @classmethod + def is_new(cls, code) -> bool: + """신규(NEW_*) 견적유형이면 True. 목표가 후보(신규=인터넷최저가만)가 이 분기에 의존하므로 한 곳에서만 판단한다.""" + return code in (cls.NEW_NEGO.value, cls.NEW_QUOTE.value) + class QuotationStatus(CodeEnum): """quotations.status 코드값(SMALLINT). 프론트 견적상태 뱃지와 매핑된다.""" diff --git a/negodata/backend/router/v1/quotation/protocol.py b/negodata/backend/router/v1/quotation/protocol.py index dc150e0..a2cf80f 100644 --- a/negodata/backend/router/v1/quotation/protocol.py +++ b/negodata/backend/router/v1/quotation/protocol.py @@ -180,3 +180,25 @@ class Res_QuotationCards(Res_WebPacketProtocol): class Res_LastSupplierType(Res_WebPacketProtocol): supplier_type: Optional[SupplierType] = None # 협력사 직전 견적의 유형(없으면 None) qt_number: Optional[str] = None # 그 견적의 번호(이전 견적 값임을 표시용) + + +class TargetCandidate(WebPacketProtocol): + basis: str + label: str + value: int + + +class Res_TargetBreakdown(Res_WebPacketProtocol): + is_new: bool = False + is_inherited: bool = False + md_price: Optional[int] = None + internet_lowest: Optional[int] = None + purchase: Optional[int] = None + selling: Optional[int] = None + fee: float = 0.0 + margin: float = 0.0 + anchoring_value: float = 0.0 + candidates: list[TargetCandidate] = [] + chosen_basis: Optional[str] = None + target_price: int = 0 + target_anchoring_price: Optional[int] = None diff --git a/negodata/backend/router/v1/quotation/quotation.py b/negodata/backend/router/v1/quotation/quotation.py index fa97eed..d01ba25 100644 --- a/negodata/backend/router/v1/quotation/quotation.py +++ b/negodata/backend/router/v1/quotation/quotation.py @@ -20,6 +20,7 @@ from .protocol import ( Res_QuotationSessions, Res_QuotationStatus, Res_SessionChat, + Res_TargetBreakdown, ) # 라우터(컨트롤러). 인증(Depends(IsValidAccessToken))으로 UserInfo 를 받는다. @@ -84,6 +85,11 @@ async def get_session_chat(session_id: UUID, service: QuotationService = Depends return RemoveNoneResponse(await service.list_chats(str(session_id))) +@router.get(path="/session/{session_id}/target-breakdown", response_model=Res_TargetBreakdown, summary="세션 목표가 산정내역") +async def get_target_breakdown(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.get_target_breakdown(str(session_id))) + + @router.post(path="/session/{session_id}/notify", response_model=Res_NotifySessions, summary="세션 초청 메일 재발송") async def notify_session(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): return RemoveNoneResponse(await service.notify_session(str(session_id))) diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index edff67a..f31b820 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -30,6 +30,8 @@ from router.v1.quotation.protocol import ( Res_QuotationSessions, Res_QuotationStatus, Res_SessionChat, + Res_TargetBreakdown, + TargetCandidate, ) from services.email import EmailUnavailable, build_invite_email, send_email @@ -56,6 +58,9 @@ class QuotationService: # 인터넷 평균 수수료율(상수). 시장 평균값이라 견적/세팅별로 두지 않고 고정. 목표가=인터넷최저가×(1−값). INTERNET_AVERAGE_FEE = 0.078 + # 목표가 후보 basis 코드 ↔ 표시 라벨(산정내역 응답에서 프론트가 그대로 표기). + _CANDIDATE_LABELS = {"md": "MD 입력가", "internet": "인터넷 최저가", "purchase": "매입가", "selling": "판매가"} + def __init__(self, quotation_crud: IQuotationCRUD = Depends(QuotationCRUD)): self.quotation_crud = quotation_crud @@ -65,6 +70,23 @@ class QuotationService: base = (web_server_config.nego_chat_url or "").rstrip("/") return f"{base}/chat?session_id={session_id}" + @staticmethod + def _candidates(md_price=None, internet_lowest=None, purchase=None, selling=None, fee=0.0, margin=0.0, is_new=False): + """목표가 후보 [(basis, value_float)] 목록(빈 값/0 은 제외). md 있으면 md 단독. + 값은 float(인터넷=가격×(1−수수료), 판매가=가격×(1−마진))이며 채택 시 int() 절삭한다. + _calc_target_price(생성)와 get_target_breakdown(표시)가 공유하는 단일 산정 로직.""" + if md_price: + return [("md", float(int(md_price)))] + out = [] + if internet_lowest: + out.append(("internet", int(internet_lowest) * (1 - (fee or 0.0)))) + if not is_new: # 재(협상·견적)만 매입가·판매가를 후보에 추가. 신규는 인터넷최저가만. + if purchase: + out.append(("purchase", float(int(purchase)))) + if selling: + out.append(("selling", int(selling) * (1 - (margin or 0.0)))) + return out + @staticmethod def _calc_target_price(md_price=None, internet_lowest=None, purchase=None, selling=None, fee=0.0, margin=0.0, is_new=False) -> int: """세션 목표가 (KTC 신규/재 분리 로직, 회사 데이터 풍부도에 graceful 적응) @@ -76,24 +98,16 @@ class QuotationService: - 매입가 (그대로) - 판매가 × (1 − margin) ← margin=quotation_settings.target_margin_rate ③ 후보 0개 → 견적 생성 불가(ValueError).""" - if md_price: - return int(md_price) - # 율은 비율(0~1 미만)이어야 한다. 1 이상이면 (1−율)≤0 → 목표가가 0/음수가 되므로 설정 오류로 막는다. - if not 0.0 <= (fee or 0.0) < 1.0: - raise ValueError(f"인터넷 수수료율은 0 이상 1 미만이어야 합니다: fee={fee}") - if not is_new and not 0.0 <= (margin or 0.0) < 1.0: - raise ValueError(f"목표 마진율은 0 이상 1 미만이어야 합니다: margin={margin}") - candidates = [] - if internet_lowest: - candidates.append(int(internet_lowest) * (1 - (fee or 0.0))) - if not is_new: # 재(협상·견적)만 매입가·판매가를 후보에 추가. 신규는 인터넷최저가만. - if purchase: - candidates.append(int(purchase)) - if selling: - candidates.append(int(selling) * (1 - (margin or 0.0))) - if not candidates: + if not md_price: + # 율은 비율(0~1 미만)이어야 한다. 1 이상이면 (1−율)≤0 → 목표가가 0/음수가 되므로 설정 오류로 막는다. + if not 0.0 <= (fee or 0.0) < 1.0: + raise ValueError(f"인터넷 수수료율은 0 이상 1 미만이어야 합니다: fee={fee}") + if not is_new and not 0.0 <= (margin or 0.0) < 1.0: + raise ValueError(f"목표 마진율은 0 이상 1 미만이어야 합니다: margin={margin}") + cands = QuotationService._candidates(md_price, internet_lowest, purchase, selling, fee, margin, is_new) + if not cands: raise ValueError("타겟 가격 계산 불가: md_price·인터넷최저가" + ("" if is_new else "·매입가·판매가") + " 모두 없음") - return int(min(candidates)) + return int(min(v for _, v in cands)) @staticmethod def _gen_number() -> str: @@ -112,6 +126,65 @@ class QuotationService: return ErrorType.QUOTATION_NOT_FOUND, None return ErrorType.SUCCESS, quotation + async def get_target_breakdown(self, session_id: str) -> Res_TargetBreakdown: + """세션 목표가 산정내역(후보·채택). 저장된 target_price/anchoring 은 그대로 표기하고, + 후보값은 생성과 동일한 _candidates 로직으로 계산해 내려준다(프론트 재계산 제거 → 항상 일치). + 상속분(재생성 라운드)은 현재 후보와 무관하므로 is_inherited=True, 채택 표시는 비운다.""" + res = Res_TargetBreakdown() + err_type, got = await DB_SESSION_MNG.execute_lambda( + sessions.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.get_session_with_supplier(s, uuid.UUID(session_id)), + ) + if err_type != ErrorType.SUCCESS or got is None: + res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND) + return res + sess = got[0] + err_type, quotation = await self._fetch(sess.quotation_id) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + _e, prices = await DB_SESSION_MNG.execute_lambda( + quotations.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.get_item_prices(s, [sess.item_id]), + ) + internet, purchase, selling = (prices or {}).get(sess.item_id) or (None, None, None) + _e, rates = await DB_SESSION_MNG.execute_lambda( + quotations.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.get_setting_rates(s, quotation.qt_setting_id), + ) + rates = rates or {} + fee = self.INTERNET_AVERAGE_FEE + margin = rates.get("margin") or 0.0 + anchoring = rates.get("anchoring") or 0.0 + is_new = QuotationType.is_new(quotation.type) + md = quotation.md_price + + cands = self._candidates(md, internet, purchase, selling, fee, margin, is_new) + chosen_basis, computed = None, None + if cands: + chosen_basis, chosen_val = min(cands, key=lambda c: c[1]) + computed = int(chosen_val) + is_inherited = computed is None or computed != sess.target_price + + res.is_new = is_new + res.is_inherited = is_inherited + res.md_price = int(md) if md else None + res.internet_lowest = int(internet) if internet is not None else None + res.purchase = int(purchase) if purchase is not None else None + res.selling = int(selling) if selling is not None else None + res.fee = fee + res.margin = margin + res.anchoring_value = anchoring + res.candidates = [TargetCandidate(basis=b, label=self._CANDIDATE_LABELS.get(b, b), value=int(v)) for b, v in cands] + res.chosen_basis = None if is_inherited else chosen_basis + res.target_price = sess.target_price + res.target_anchoring_price = sess.target_anchoring_price + return res + async def list_quotations(self, search, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList: res = Res_QuotationList(page=pg.page, size=pg.size) @@ -353,7 +426,7 @@ class QuotationService: # 상품 × 공급사 조합마다 세션 1개. md/매입/판매/인터넷 후보가 하나도 없으면 목표가 산정 불가 → 생성 실패. # 신규(NEW_NEGO/NEW_QUOTE)는 인터넷최저가만, 재(RENEGO/REQUOTE)는 매입가·판매가까지 후보(KTC 신규/재 분리). - is_new = type_ in (QuotationType.NEW_NEGO.value, QuotationType.NEW_QUOTE.value) + is_new = QuotationType.is_new(type_) session_objs = [] try: for iid in item_ids: diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index d0b2814..75dc364 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -290,6 +290,14 @@ export * from './resSupplierList'; export * from './resSupplierListMsg'; export * from './resSupplierMsg'; export * from './resSupplierSupplier'; +export * from './resTargetBreakdown'; +export * from './resTargetBreakdownChosenBasis'; +export * from './resTargetBreakdownInternetLowest'; +export * from './resTargetBreakdownMdPrice'; +export * from './resTargetBreakdownMsg'; +export * from './resTargetBreakdownPurchase'; +export * from './resTargetBreakdownSelling'; +export * from './resTargetBreakdownTargetAnchoringPrice'; export * from './sessionData'; export * from './sessionDataBidAt'; export * from './sessionDataBidPrice'; @@ -308,6 +316,7 @@ export * from './supplierDataManagerName'; export * from './supplierDataPriority'; export * from './supplierDataUpdatedAt'; export * from './supplierType'; +export * from './targetCandidate'; export * from './userRole'; export * from './userStatus'; export * from './validationError'; diff --git a/negodata/front/src/api/generated/model/resTargetBreakdown.ts b/negodata/front/src/api/generated/model/resTargetBreakdown.ts new file mode 100644 index 0000000..4697eed --- /dev/null +++ b/negodata/front/src/api/generated/model/resTargetBreakdown.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from './errorInfo'; +import type { ResTargetBreakdownMsg } from './resTargetBreakdownMsg'; +import type { ResTargetBreakdownMdPrice } from './resTargetBreakdownMdPrice'; +import type { ResTargetBreakdownInternetLowest } from './resTargetBreakdownInternetLowest'; +import type { ResTargetBreakdownPurchase } from './resTargetBreakdownPurchase'; +import type { ResTargetBreakdownSelling } from './resTargetBreakdownSelling'; +import type { TargetCandidate } from './targetCandidate'; +import type { ResTargetBreakdownChosenBasis } from './resTargetBreakdownChosenBasis'; +import type { ResTargetBreakdownTargetAnchoringPrice } from './resTargetBreakdownTargetAnchoringPrice'; + +export interface ResTargetBreakdown { + result?: ErrorInfo; + msg?: ResTargetBreakdownMsg; + is_new?: boolean; + is_inherited?: boolean; + md_price?: ResTargetBreakdownMdPrice; + internet_lowest?: ResTargetBreakdownInternetLowest; + purchase?: ResTargetBreakdownPurchase; + selling?: ResTargetBreakdownSelling; + fee?: number; + margin?: number; + anchoring_value?: number; + candidates?: TargetCandidate[]; + chosen_basis?: ResTargetBreakdownChosenBasis; + target_price?: number; + target_anchoring_price?: ResTargetBreakdownTargetAnchoringPrice; +} diff --git a/negodata/front/src/api/generated/model/resTargetBreakdownChosenBasis.ts b/negodata/front/src/api/generated/model/resTargetBreakdownChosenBasis.ts new file mode 100644 index 0000000..b6ace49 --- /dev/null +++ b/negodata/front/src/api/generated/model/resTargetBreakdownChosenBasis.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 ResTargetBreakdownChosenBasis = string | null; diff --git a/negodata/front/src/api/generated/model/resTargetBreakdownInternetLowest.ts b/negodata/front/src/api/generated/model/resTargetBreakdownInternetLowest.ts new file mode 100644 index 0000000..84cd371 --- /dev/null +++ b/negodata/front/src/api/generated/model/resTargetBreakdownInternetLowest.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 ResTargetBreakdownInternetLowest = number | null; diff --git a/negodata/front/src/api/generated/model/resTargetBreakdownMdPrice.ts b/negodata/front/src/api/generated/model/resTargetBreakdownMdPrice.ts new file mode 100644 index 0000000..c5c7008 --- /dev/null +++ b/negodata/front/src/api/generated/model/resTargetBreakdownMdPrice.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 ResTargetBreakdownMdPrice = number | null; diff --git a/negodata/front/src/api/generated/model/resTargetBreakdownMsg.ts b/negodata/front/src/api/generated/model/resTargetBreakdownMsg.ts new file mode 100644 index 0000000..0ee0e6f --- /dev/null +++ b/negodata/front/src/api/generated/model/resTargetBreakdownMsg.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 ResTargetBreakdownMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resTargetBreakdownPurchase.ts b/negodata/front/src/api/generated/model/resTargetBreakdownPurchase.ts new file mode 100644 index 0000000..4aba8a4 --- /dev/null +++ b/negodata/front/src/api/generated/model/resTargetBreakdownPurchase.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 ResTargetBreakdownPurchase = number | null; diff --git a/negodata/front/src/api/generated/model/resTargetBreakdownSelling.ts b/negodata/front/src/api/generated/model/resTargetBreakdownSelling.ts new file mode 100644 index 0000000..a8307f3 --- /dev/null +++ b/negodata/front/src/api/generated/model/resTargetBreakdownSelling.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 ResTargetBreakdownSelling = number | null; diff --git a/negodata/front/src/api/generated/model/resTargetBreakdownTargetAnchoringPrice.ts b/negodata/front/src/api/generated/model/resTargetBreakdownTargetAnchoringPrice.ts new file mode 100644 index 0000000..34ce92e --- /dev/null +++ b/negodata/front/src/api/generated/model/resTargetBreakdownTargetAnchoringPrice.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 ResTargetBreakdownTargetAnchoringPrice = number | null; diff --git a/negodata/front/src/api/generated/model/targetCandidate.ts b/negodata/front/src/api/generated/model/targetCandidate.ts new file mode 100644 index 0000000..e3c30f3 --- /dev/null +++ b/negodata/front/src/api/generated/model/targetCandidate.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export interface TargetCandidate { + basis: string; + label: string; + value: number; +} diff --git a/negodata/front/src/api/generated/quotation/quotation.ts b/negodata/front/src/api/generated/quotation/quotation.ts index b68178e..a0a91a2 100644 --- a/negodata/front/src/api/generated/quotation/quotation.ts +++ b/negodata/front/src/api/generated/quotation/quotation.ts @@ -38,7 +38,8 @@ import type { ResQuotationResult, ResQuotationSessions, ResQuotationStatus, - ResSessionChat + ResSessionChat, + ResTargetBreakdown } from '.././model'; import { customFetch } from '../../mutator/custom-fetch'; @@ -670,6 +671,98 @@ export function useGetSessionChat,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/quotation/session/${sessionId}/target-breakdown`, method: 'GET', signal + }, + options); + } + + + + +export const getGetTargetBreakdownQueryKey = (sessionId?: string,) => { + return [ + `/v1/quotation/session/${sessionId}/target-breakdown` + ] as const; + } + + +export const getGetTargetBreakdownQueryOptions = >, TError = void | HTTPValidationError>(sessionId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetTargetBreakdownQueryKey(sessionId); + + + + const queryFn: QueryFunction>> = ({ signal }) => getTargetBreakdown(sessionId, requestOptions, signal); + + + + + + return { queryKey, queryFn, enabled: !!(sessionId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetTargetBreakdownQueryResult = NonNullable>> +export type GetTargetBreakdownQueryError = void | HTTPValidationError + + +export function useGetTargetBreakdown>, TError = void | HTTPValidationError>( + sessionId: string, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetTargetBreakdown>, TError = void | HTTPValidationError>( + sessionId: string, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetTargetBreakdown>, TError = void | HTTPValidationError>( + sessionId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 세션 목표가 산정내역 + */ + +export function useGetTargetBreakdown>, TError = void | HTTPValidationError>( + sessionId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetTargetBreakdownQueryOptions(sessionId,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + /** * @summary 세션 초청 메일 재발송 */ diff --git a/negodata/front/src/features/cards/components/CardFormSheet.tsx b/negodata/front/src/features/cards/components/CardFormSheet.tsx index e75a0cd..30beb52 100644 --- a/negodata/front/src/features/cards/components/CardFormSheet.tsx +++ b/negodata/front/src/features/cards/components/CardFormSheet.tsx @@ -10,6 +10,7 @@ import { Sheet } from '@/components/ui/sheet'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { type NegotiationCard, type CardTab, generateCardCode } from '../types'; import { CardUsageType } from '@/api/generated/model'; +import { CARD_USAGE_TYPE_LABEL, CARD_USAGE_TYPE_OPTIONS } from '@/lib/enumLabels'; import type { CardInput } from '../hooks/useCards'; import { CardScriptEditor, deserialize, serializeToText } from '../editor'; @@ -235,18 +236,13 @@ export function CardFormSheet({ )} diff --git a/negodata/front/src/features/members/types.ts b/negodata/front/src/features/members/types.ts index e668efe..d7cf60a 100644 --- a/negodata/front/src/features/members/types.ts +++ b/negodata/front/src/features/members/types.ts @@ -1,14 +1,8 @@ -import { UserRole } from '@/api/generated/model'; +import { UserRole, UserStatus } from '@/api/generated/model'; -// 회사 유저(계정) 상태 코드 — 백엔드 UserStatus 미러. -// (orval 재생성 전까지 로컬 정의. 재생성 후 @/api/generated/model 의 UserStatus 로 교체 가능) -export const UserStatus = { ACTIVE: 1, INACTIVE: 2 } as const; -export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus]; - -export const USER_STATUS_LABEL: Record = { - [UserStatus.ACTIVE]: '활성', - [UserStatus.INACTIVE]: '비활성', -}; +// 유저 상태 코드/라벨은 생성 enum + 중앙 라벨(lib/enumLabels.ts) 단일 출처를 그대로 재노출한다. +export { UserStatus }; +export { USER_STATUS_LABEL } from '@/lib/enumLabels'; // 백엔드 CompanyUserData 와 1:1. export interface CompanyUserData { diff --git a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx index be92979..05359ce 100644 --- a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx @@ -10,7 +10,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types'; import type { CreateQuotationInput } from '../hooks/useQuotations'; import { QuotationType } from '@/api/generated/model'; -import { QUOTATION_TYPE_OPTIONS, supplierTypeOptions } from '../types'; +import { QUOTATION_TYPE_OPTIONS, supplierTypeOptions, isNewQuotationType } from '../types'; +import { supplierTypeLabel } from '@/lib/enumLabels'; import { showToast } from '@/lib/notify'; // datetime-local 디폴트값: 현재 한국시간(Asia/Seoul)의 'YYYY-MM-DDTHH:mm'. @@ -68,7 +69,7 @@ export function QuotationCreateModal({ const navigate = useNavigate(); // 인터넷최저가·매입가·판매가는 상품 속성 — 모달에선 읽기전용으로만 보여주고, 수정은 상품 상세에서 한다. const selectedProduct = products.find((p) => p.id === productId); - const isReType = type === QuotationType.RENEGO || type === QuotationType.REQUOTE; + const isReType = !isNewQuotationType(type); const internetLowest = selectedProduct?.internet_lowest_price ?? null; const purchase = selectedProduct?.purchase_price ?? null; const selling = selectedProduct?.selling_price ?? null; @@ -329,11 +330,7 @@ export function QuotationCreateModal({ + {hasValue && onClear && ( + + )}
); } diff --git a/negodata/front/src/features/quotations/components/QuotationTable.tsx b/negodata/front/src/features/quotations/components/QuotationTable.tsx index 2f73bb4..5e65321 100644 --- a/negodata/front/src/features/quotations/components/QuotationTable.tsx +++ b/negodata/front/src/features/quotations/components/QuotationTable.tsx @@ -143,6 +143,14 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo {est.createdDate ?? '-'} ), }, + { + header: '작성자', + align: 'center', + cellClassName: 'text-muted-foreground whitespace-nowrap', + cell: (est) => ( + {est.creatorName ?? '-'} + ), + }, { header: '협력사수', align: 'center', diff --git a/negodata/front/src/features/quotations/types.ts b/negodata/front/src/features/quotations/types.ts index cff64f1..49c00d7 100644 --- a/negodata/front/src/features/quotations/types.ts +++ b/negodata/front/src/features/quotations/types.ts @@ -18,6 +18,7 @@ export type Estimate = Partial & { productId?: string; productName?: string; partnerIds?: string[]; + creatorName?: string; // 등록자(작성자) 이름. 서버 목록 조인(user_id→users.name) participationCount?: number; winnerPartnerId?: string | null; finalPrice?: number; @@ -82,6 +83,7 @@ export function mapQuotation(q: QuotationData): Estimate { productName: q.item_name ?? undefined, // products 목록에 없을 때 표기 폴백 dueDate: formatDueDate(q.end_time), createdDate: fmtDateTime(q.created_at), + creatorName: q.creator_name ?? undefined, participationCount: q.participation_count ?? 0, winnerPartnerId: q.preferred_sp_id ?? q.preferred_sp_name ?? null, isEqualPrice: !!q.equal_bid_yn, diff --git a/negodata/front/src/lib/useServerList.ts b/negodata/front/src/lib/useServerList.ts index f6f3b60..0b04518 100644 --- a/negodata/front/src/lib/useServerList.ts +++ b/negodata/front/src/lib/useServerList.ts @@ -10,6 +10,7 @@ export type ServerListControls = { search: string; // input value (controlled) setSearch: (v: string) => void; submitSearch: () => void; // 엔터/즉시 검색용 (디바운스·최소길이 무시하고 바로 발사) + clearSearch: () => void; // X 버튼: 입력 비우고 즉시 전체 재검색 debouncedSearch: string; // 쿼리 파라미터용 (디바운스 적용) filters: Record; setFilter: (key: string, value: string) => void; @@ -53,6 +54,13 @@ export function useServerList(opts?: { setDebouncedSearch(search.trim()); setPage(1); }; + // X 버튼: 입력·디바운스 검색어를 즉시 비우고(대기 타이머 취소) 1페이지로 → 전체 목록 재조회. + const clearSearch = () => { + clearTimeout(timerRef.current); + setSearchInput(''); + setDebouncedSearch(''); + setPage(1); + }; const setFilter = (key: string, value: string) => { setFilters((f) => ({ ...f, [key]: value })); setPage(1); @@ -60,5 +68,5 @@ export function useServerList(opts?: { const totalPages = (total: number) => Math.max(1, Math.ceil(total / pageSize)); - return { page, setPage, pageSize, search, setSearch, submitSearch, debouncedSearch, filters, setFilter, totalPages }; + return { page, setPage, pageSize, search, setSearch, submitSearch, clearSearch, debouncedSearch, filters, setFilter, totalPages }; } diff --git a/negodata/front/src/pages/cards.tsx b/negodata/front/src/pages/cards.tsx index 05afe62..cae4944 100644 --- a/negodata/front/src/pages/cards.tsx +++ b/negodata/front/src/pages/cards.tsx @@ -104,6 +104,7 @@ export default function CardsPage() { value={list.search} onChange={(e) => list.setSearch(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()} + onClear={list.clearSearch} placeholder="전체 카드이름, 카드번호, 코드 검색..." /> diff --git a/negodata/front/src/pages/members.tsx b/negodata/front/src/pages/members.tsx index bac9412..b2d6954 100644 --- a/negodata/front/src/pages/members.tsx +++ b/negodata/front/src/pages/members.tsx @@ -65,6 +65,7 @@ export default function MembersPage() { value={list.search} onChange={(e) => list.setSearch(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()} + onClear={list.clearSearch} placeholder="로그인 ID, 이름 또는 이메일로 검색..." /> diff --git a/negodata/front/src/pages/partners.tsx b/negodata/front/src/pages/partners.tsx index 013ddf1..75590b5 100644 --- a/negodata/front/src/pages/partners.tsx +++ b/negodata/front/src/pages/partners.tsx @@ -88,6 +88,7 @@ export default function PartnersPage() { value={list.search} onChange={(e) => list.setSearch(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()} + onClear={list.clearSearch} placeholder="협력사명, 코드 또는 담당자명으로 추적 검색..." /> diff --git a/negodata/front/src/pages/products.tsx b/negodata/front/src/pages/products.tsx index c0c3c90..705ef0f 100644 --- a/negodata/front/src/pages/products.tsx +++ b/negodata/front/src/pages/products.tsx @@ -113,6 +113,7 @@ export default function ProductsPage() { value={list.search} onChange={(e) => list.setSearch(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()} + onClear={list.clearSearch} placeholder="상품명 또는 상품 코드로 통합 검색..." /> diff --git a/negodata/front/src/pages/quotation.tsx b/negodata/front/src/pages/quotation.tsx index 0f16516..fa9e641 100644 --- a/negodata/front/src/pages/quotation.tsx +++ b/negodata/front/src/pages/quotation.tsx @@ -17,15 +17,17 @@ import type { ListQuotationsParams } from '@/api/generated/model/listQuotationsP export default function QuotationPage() { // 검색/상태·유형 필터/페이지 상태 → 서버 쿼리 파라미터로 변환. - const list = useServerList({ pageSize: 10, initialFilters: { status: 'ALL', type: 'ALL' } }); + const list = useServerList({ pageSize: 10, initialFilters: { status: 'ALL', type: 'ALL', mine: 'ALL' } }); const statusFilter = list.filters.status; const typeFilter = list.filters.type; + const mineFilter = list.filters.mine; const statusOptions = QUOTATION_STATUS_OPTIONS; const typeOptions = QUOTATION_TYPE_OPTIONS; const params: ListQuotationsParams = { search: list.debouncedSearch || undefined, status: statusFilter !== 'ALL' ? statusFilter : undefined, type: typeFilter !== 'ALL' ? typeFilter : undefined, + mine: mineFilter === 'MINE' ? true : undefined, page: list.page, size: list.pageSize, }; @@ -94,12 +96,25 @@ export default function QuotationPage() { value={list.search} onChange={(e) => list.setSearch(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()} + onClear={list.clearSearch} placeholder="견적명 또는 견적 번호로 검색..." /> -
+
+ + setSupplierType(v ?? '')}> - - - {(value) => (value ? supplierTypeLabel(Number(value)) : '협력사 유형 선택...')} - - - - {supplierTypeOptions.map((o) => ( - {o.label} - ))} - - - {prevSupplierType != null && ( - - 이전 견적({prevQtNumber}) 값으로 자동 선택됨 · 수정 가능 - - )} -
- )} + {/* 협력사 유형 — 항상 노출(처음부터 입력 가능). 재협상(1:1)이면 선택 협력사의 직전 견적 값으로 자동 디폴트. */} +
+ 협력사 유형 + + {prevSupplierType != null && ( + + 이전 견적({prevQtNumber}) 값으로 자동 선택됨 · 수정 가능 + + )} +
)} @@ -375,7 +379,7 @@ export function QuotationCreateModal({
- 협상카드 및 와일드카드 선택 + 협상카드 및 와일드카드 선택
{cards.filter((c) => !c.isWildcard || c.status === 'ACTIVE').map((card) => { const isChecked = selectedCardIds.includes(card.id); @@ -452,7 +456,7 @@ export function QuotationCreateModal({ ) : ( )}
From f19c6f57ea33d9215b4e4d79f3a43c50c6a7abd6 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Tue, 30 Jun 2026 17:25:38 +0900 Subject: [PATCH 17/20] =?UTF-8?q?[chore]=20negodata:=20enum=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=98=81=EB=AC=B8=EA=B0=92=20=EB=B3=B4=EA=B0=95=20?= =?UTF-8?q?+=20=EA=B2=AC=EC=A0=81=EC=83=81=ED=83=9C=203=EC=A2=85=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20+=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=20=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - enum DDL 주석에 영문값 보강(status/role/delivery/usage_type/qt_type/supplier_type) - 견적상태 3종(생성/진행중/마감)으로 정리(ON_HOLD 제거), 배송 PARTNER→SUPPLIER - 대시보드 손질, 관련 테스트·negosium 견적유형 주석 동반 Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/common/database/model/models.py | 4 +- backend/router/v1/negotiation/protocol.py | 2 +- backend/router/v1/negotiation/session.py | 2 +- backend/scripts/dev_seed.sql | 2 +- .../backend/common/database/model/models.py | 2 +- negodata/backend/common/enums.py | 5 +- negodata/backend/crud/dashboard_crud.py | 41 +++- .../backend/router/v1/dashboard/protocol.py | 6 +- .../backend/services/dashboard_service.py | 6 + .../tests/test_close_and_decide_fixes.py | 8 +- negodata/backend/tests/test_features.py | 2 +- negodata/backend/tests/test_scheduler.py | 22 +- .../src/api/generated/model/dashboardScope.ts | 2 + .../src/api/generated/model/deliveryType.ts | 2 +- .../api/generated/model/quotationStatus.ts | 3 +- .../dashboard/components/ActionWidget.tsx | 72 ++++++ .../dashboard/components/DashboardHero.tsx | 29 +++ .../dashboard/components/DeadlineWidget.tsx | 28 +++ .../components/EmailUnsentWidget.tsx | 27 +++ .../features/dashboard/components/KpiTile.tsx | 39 ++++ .../dashboard/components/LifecycleStepper.tsx | 64 ++++++ .../dashboard/components/RefWidget.tsx | 37 +++ .../dashboard/components/ResultExamples.tsx | 109 +++++++++ .../dashboard/components/ScopeSection.tsx | 42 ++++ .../dashboard/components/StartChecklist.tsx | 136 +++++++++++ negodata/front/src/features/dashboard/fmt.ts | 21 ++ .../front/src/features/dashboard/index.ts | 5 + .../front/src/features/dashboard/tones.ts | 12 + .../SessionsStatusTab.tsx | 4 +- .../QuotationDetailSheet/StatusPill.tsx | 6 +- .../quotations/components/QuotationTable.tsx | 6 +- .../front/src/features/quotations/types.ts | 5 +- negodata/front/src/lib/enumLabels.ts | 2 +- negodata/front/src/pages/dashboard.tsx | 216 +++--------------- negodata/front/src/tokens.css | 4 + postgres-init/01-schema.sql | 32 +-- 36 files changed, 758 insertions(+), 247 deletions(-) create mode 100644 negodata/front/src/features/dashboard/components/ActionWidget.tsx create mode 100644 negodata/front/src/features/dashboard/components/DashboardHero.tsx create mode 100644 negodata/front/src/features/dashboard/components/DeadlineWidget.tsx create mode 100644 negodata/front/src/features/dashboard/components/EmailUnsentWidget.tsx create mode 100644 negodata/front/src/features/dashboard/components/KpiTile.tsx create mode 100644 negodata/front/src/features/dashboard/components/LifecycleStepper.tsx create mode 100644 negodata/front/src/features/dashboard/components/RefWidget.tsx create mode 100644 negodata/front/src/features/dashboard/components/ResultExamples.tsx create mode 100644 negodata/front/src/features/dashboard/components/ScopeSection.tsx create mode 100644 negodata/front/src/features/dashboard/components/StartChecklist.tsx create mode 100644 negodata/front/src/features/dashboard/fmt.ts create mode 100644 negodata/front/src/features/dashboard/index.ts create mode 100644 negodata/front/src/features/dashboard/tones.ts diff --git a/backend/common/database/model/models.py b/backend/common/database/model/models.py index 70ec1a3..a331aa7 100644 --- a/backend/common/database/model/models.py +++ b/backend/common/database/model/models.py @@ -109,7 +109,7 @@ class sessions(MAIN_BASE): supplier_id = Column(UUID(as_uuid=True), nullable=False) # 대상 공급사(partner.suppliers.supplier_id) qt_number = Column(String(30), nullable=False) # 견적번호(스냅샷) qt_round = Column(Integer, nullable=False) # 견적 라운드(스냅샷) - qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적 (QtType) + qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적 (QtType) target_price = Column(BigInteger, nullable=False) # 목표가(원) target_anchoring_price = Column(BigInteger, nullable=True) status = Column(SmallInteger, nullable=False) # 진행 상태 (SessionStatus 코드) @@ -139,7 +139,7 @@ class quotations(MAIN_BASE): version_id = Column(UUID(as_uuid=True), nullable=False) # 버전(card.versions.version_id) name = Column(String(50), nullable=False) # 견적명 number = Column(String(30), nullable=False) # 견적번호 - type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적 (QtType) + type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적 (QtType) round = Column(Integer, nullable=False, server_default=text("1")) # 재견적 회차 status = Column(SmallInteger, nullable=False) # 진행 상태 (QuotationStatus 코드) start_time = Column(DateTime(timezone=True), nullable=False) # 견적 시작 시각 diff --git a/backend/router/v1/negotiation/protocol.py b/backend/router/v1/negotiation/protocol.py index 1101e0e..4687b46 100644 --- a/backend/router/v1/negotiation/protocol.py +++ b/backend/router/v1/negotiation/protocol.py @@ -5,7 +5,7 @@ from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol class ListItem(WebPacketProtocol): session_id: str = "" session_status: int = 0 # SessionStatus 코드 - qt_type: int = 0 # QtType 코드 (1=재협상, 2=재견적) + qt_type: int = 0 # QtType 코드 (1=재협상, 2=재견적, 3=신규협상, 4=신규견적) qt_number: str = "" qt_end_time: str = "" # ISO 8601 (마감 시각) item_code: str = "" diff --git a/backend/router/v1/negotiation/session.py b/backend/router/v1/negotiation/session.py index 324a634..e1f3dca 100644 --- a/backend/router/v1/negotiation/session.py +++ b/backend/router/v1/negotiation/session.py @@ -22,7 +22,7 @@ async def list_sessions( credentials: HTTPAuthorizationCredentials = Depends(security), service: NegotiationService = Depends(), status: Optional[int] = Query(None, description="세션 상태 코드 (SessionStatus)"), - qt_type: Optional[int] = Query(None, description="견적 유형 코드 (QtType: 1=재협상, 2=재견적)"), + qt_type: Optional[int] = Query(None, description="견적 유형 코드 (QtType: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적)"), order: str = Query("asc", description="마감일 정렬: asc(임박순)/desc"), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), diff --git a/backend/scripts/dev_seed.sql b/backend/scripts/dev_seed.sql index 039efe5..9d513f3 100644 --- a/backend/scripts/dev_seed.sql +++ b/backend/scripts/dev_seed.sql @@ -60,7 +60,7 @@ VALUES ('b0000000-0000-0000-0000-000000000006','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','회의실 대형 디스플레이 65인치','IMK-10236','QM65R','삼성전자', 2890000); -- 5) 견적 6개 (end_time = 마감 진실값) --- type: 1=재협상(RENEGO) 2=재견적(REQUOTE) / status: 1=생성 2=진행중 3=마감 +-- type: 1=재협상(RENEGO) 2=재견적(REQUOTE) 3=신규협상(NEW_NEGO) 4=신규견적(NEW_QUOTE) / status: 1=생성 2=진행중 3=마감 INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, round, status, start_time, end_time) VALUES diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index ff1edbe..ce9c570 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -206,7 +206,7 @@ class quotations(MainTableMixin, MAIN_BASE): name = Column(String(50), nullable=False) number = Column(String(30), nullable=False) - type = Column(SmallInteger, nullable=False) # QuotationType: 1=renego(1:1) / 2=requote(1:N) + type = Column(SmallInteger, nullable=False) # QuotationType: 1=renego(1:1) / 2=requote(1:N) / 3=new_nego(1:1) / 4=new_quote(1:N) round = Column(Integer, nullable=False, default=1) # 재견적 진행 시 증가 status = Column(SmallInteger, nullable=False) # 진행 상태 코드 start_time = Column(DateTime(timezone=True), nullable=False) diff --git a/negodata/backend/common/enums.py b/negodata/backend/common/enums.py index 5dcde46..64c8e70 100644 --- a/negodata/backend/common/enums.py +++ b/negodata/backend/common/enums.py @@ -148,9 +148,8 @@ class QuotationStatus(CodeEnum): """quotations.status 코드값(SMALLINT). 프론트 견적상태 뱃지와 매핑된다.""" CREATED = 1 - ACTIVE = 2 + IN_PROGRESS = 2 CLOSED = 3 - ON_HOLD = 4 class SessionStatus(CodeEnum): @@ -191,7 +190,7 @@ class ChatSender(CodeEnum): class DeliveryType(CodeEnum): """items.delivery_type 코드값. 협상 채팅의 배송형태 선택지와 동일 집합.""" - PARTNER = 1 # 협력사배송 + SUPPLIER = 1 # 협력사배송 COURIER = 2 # 지정택배배송 PICKUP = 3 # 픽업배송 diff --git a/negodata/backend/crud/dashboard_crud.py b/negodata/backend/crud/dashboard_crud.py index 9a9897c..965eb5c 100644 --- a/negodata/backend/crud/dashboard_crud.py +++ b/negodata/backend/crud/dashboard_crud.py @@ -31,10 +31,18 @@ class IDashboardCRUD(ABC): async def count_created_since(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, int]: pass + @abstractmethod + async def count_awarded_since(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, int]: + pass + @abstractmethod async def deadline_soon(self, cdb: AsyncSession, company_id, owner, now, horizon, limit) -> Tuple[ErrorType, list, int]: pass + @abstractmethod + async def awarded(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]: + pass + @abstractmethod async def equal_bid(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]: pass @@ -80,6 +88,20 @@ class DashboardCRUD(IDashboardCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, 0 + async def count_awarded_since(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, int]: + try: + # 이번 달 낙찰 = 마감 + 단독 최저 선정(preferred_sp_yn=True) + 낙찰(마감) 시각이 기준일 이후. + where = and_( + *_company_scope(company_id, owner), + quotations.status == QuotationStatus.CLOSED.value, + quotations.preferred_sp_yn.is_(True), + quotations.updated_at >= since, + ) + return await self._count(cdb, where) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, 0 + async def deadline_soon(self, cdb: AsyncSession, company_id, owner, now, horizon, limit) -> Tuple[ErrorType, list, int]: try: where = and_( @@ -94,6 +116,20 @@ class DashboardCRUD(IDashboardCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, [], 0 + async def awarded(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]: + try: + # 낙찰 = 마감 + 단독 최저가 선정(preferred_sp_yn=True). 동가/결렬과 동형(최신 마감순). + where = and_( + *_company_scope(company_id, owner), + quotations.status == QuotationStatus.CLOSED.value, + quotations.preferred_sp_yn.is_(True), + ) + cols = (quotations.qt_id, quotations.name) + return await self._list_with_count(cdb, where, cols, quotations.updated_at.desc(), limit) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [], 0 + async def equal_bid(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]: try: where = and_( @@ -124,7 +160,8 @@ class DashboardCRUD(IDashboardCRUD): async def email_unsent(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]: try: - # 미발송 세션 = email_sent_at IS NULL + 담당자 이메일 보유, 마감 전 견적만(보낼 의미 있는 것). 견적 단위로 묶는다. + # 미발송 세션 = email_sent_at IS NULL + 담당자 이메일 보유, 마감 전 견적만. 견적 단위로 묶는다. + # total = 미발송 '견적' 수(distinct qt_id) — 리스트와 일치. 견적별 미발송 협력사 수는 행의 unsent_count. conds = [ sessions.deleted == False, # noqa: E712 sessions.email_sent_at.is_(None), @@ -146,7 +183,7 @@ class DashboardCRUD(IDashboardCRUD): .where(where) ) - c_err, c_rows = await DB_SESSION_MNG.execute(cdb, _joined(select(func.count()))) + c_err, c_rows = await DB_SESSION_MNG.execute(cdb, _joined(select(func.count(func.distinct(quotations.qt_id))))) if c_err != ErrorType.SUCCESS: return c_err, [], 0 total = int(c_rows[0] or 0) if c_rows else 0 diff --git a/negodata/backend/router/v1/dashboard/protocol.py b/negodata/backend/router/v1/dashboard/protocol.py index 574040f..52492ed 100644 --- a/negodata/backend/router/v1/dashboard/protocol.py +++ b/negodata/backend/router/v1/dashboard/protocol.py @@ -29,16 +29,18 @@ class DashboardEmailUnsentItem(WebPacketProtocol): class DashboardEmailUnsent(WebPacketProtocol): - total: int = 0 # 미발송 '협상(세션)' 전수 — 헤드라인 숫자 + total: int = 0 # 미발송 견적 수(distinct qt_id) — 리스트와 일치하는 헤드라인 숫자 quotations: list[DashboardEmailUnsentItem] = [] # 견적 단위로 묶은 상위 N개 class DashboardScope(WebPacketProtocol): in_progress: int = 0 # 진행중(마감 전) 견적 수 = status != 마감 this_month: int = 0 # 이번 달 생성 견적 수(created_at 기준) + awarded_this_month: int = 0 # 이번 달 낙찰 견적 수(CLOSED·preferred_sp_yn=True, 마감 시각 기준) deadline_soon: DashboardActionList = Field(default_factory=DashboardActionList) email_unsent: DashboardEmailUnsent = Field(default_factory=DashboardEmailUnsent) - equal_bid: DashboardActionList = Field(default_factory=DashboardActionList) # 동가(수동 결정 필요) + awarded: DashboardActionList = Field(default_factory=DashboardActionList) # 낙찰(단독 최저가 선정, preferred_sp_yn=True) + equal_bid: DashboardActionList = Field(default_factory=DashboardActionList) # 동가 마감(자동 다음 차수 생성, equal_bid_yn=True) ruptured: DashboardActionList = Field(default_factory=DashboardActionList) # 결렬(낙찰자 없이 마감) diff --git a/negodata/backend/services/dashboard_service.py b/negodata/backend/services/dashboard_service.py index 05def58..2335f04 100644 --- a/negodata/backend/services/dashboard_service.py +++ b/negodata/backend/services/dashboard_service.py @@ -50,10 +50,16 @@ class DashboardService: scope.this_month = await self._count( lambda s: self.dashboard_crud.count_created_since(s, company_uuid, owner_uuid, month_start) ) + scope.awarded_this_month = await self._count( + lambda s: self.dashboard_crud.count_awarded_since(s, company_uuid, owner_uuid, month_start) + ) scope.deadline_soon = await self._action( lambda s: self.dashboard_crud.deadline_soon(s, company_uuid, owner_uuid, now, horizon, self.LIST_LIMIT), with_end_time=True, ) + scope.awarded = await self._action( + lambda s: self.dashboard_crud.awarded(s, company_uuid, owner_uuid, self.LIST_LIMIT) + ) scope.equal_bid = await self._action( lambda s: self.dashboard_crud.equal_bid(s, company_uuid, owner_uuid, self.LIST_LIMIT) ) diff --git a/negodata/backend/tests/test_close_and_decide_fixes.py b/negodata/backend/tests/test_close_and_decide_fixes.py index d341c42..08b19b6 100644 --- a/negodata/backend/tests/test_close_and_decide_fixes.py +++ b/negodata/backend/tests/test_close_and_decide_fixes.py @@ -88,7 +88,7 @@ async def test_concurrent_close_creates_only_one_next_round(clean): """같은 견적을 5번 동시에 close_and_decide 해도 다음 라운드는 정확히 1개만 생성된다.""" engine = clean number = "C-CONCURRENT" - qt = await _seed_quotation(engine, number=number, round_=1, status=QuotationStatus.ACTIVE.value) + qt = await _seed_quotation(engine, number=number, round_=1, status=QuotationStatus.IN_PROGRESS.value) # 전원 미참여(미시작 세션만) → close_and_decide 가 '다음 라운드 재생성' 경로를 탄다 await _add_session(engine, qt, status=SessionStatus.CREATED.value) await _add_session(engine, qt, status=SessionStatus.CREATED.value) @@ -111,7 +111,7 @@ async def test_next_round_numbering_and_min_duration(clean): number = "C-DURATION" # start==end (협상기간 0) → 하한이 적용되지 않으면 새 라운드도 0 길이가 된다 qt = await _seed_quotation( - engine, number=number, round_=1, status=QuotationStatus.ACTIVE.value, + engine, number=number, round_=1, status=QuotationStatus.IN_PROGRESS.value, start_time=PAST, end_time=PAST, ) await _add_session(engine, qt, status=SessionStatus.CREATED.value) @@ -141,7 +141,7 @@ async def test_awarded_prior_round_not_counted_as_no_show(clean): preferred_sp_yn=True, equal_bid_yn=False, ) # round 2: 전원 미참여 → 미참여 재생성이 일어나야 한다(round 1 은 미참여로 세면 안 됨) - qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.ACTIVE.value) + qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.IN_PROGRESS.value) await _add_session(engine, qt2, status=SessionStatus.CREATED.value) service = QuotationService(QuotationCRUD()) @@ -167,7 +167,7 @@ async def test_no_show_prior_round_consumes_budget(clean): preferred_sp_yn=False, equal_bid_yn=False, ) # round 2: 또 전원 미참여 → 한도 도달이라 재생성 없이 그냥 마감 - qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.ACTIVE.value) + qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.IN_PROGRESS.value) await _add_session(engine, qt2, status=SessionStatus.CREATED.value) service = QuotationService(QuotationCRUD()) diff --git a/negodata/backend/tests/test_features.py b/negodata/backend/tests/test_features.py index 75792ca..99e599b 100644 --- a/negodata/backend/tests/test_features.py +++ b/negodata/backend/tests/test_features.py @@ -55,7 +55,7 @@ async def test_quotation_create(client, company_id): "version_id": str(uuid.uuid4()), "name": "견적A", "type": QuotationType.REQUOTE.value, - "status": QuotationStatus.ACTIVE.value, + "status": QuotationStatus.IN_PROGRESS.value, "start_time": "2026-06-16T00:00:00", "end_time": "2026-06-17T00:00:00", } diff --git a/negodata/backend/tests/test_scheduler.py b/negodata/backend/tests/test_scheduler.py index 73abe7d..2d26d49 100644 --- a/negodata/backend/tests/test_scheduler.py +++ b/negodata/backend/tests/test_scheduler.py @@ -30,7 +30,7 @@ async def clean(db_engine): # ----- 시드 헬퍼 (FK 미설정이라 user/item/supplier 없이 임의 uuid 로 충분) ----- -async def _add_quotation(engine, *, status=QuotationStatus.ACTIVE.value, end_time=PAST, deleted=False): +async def _add_quotation(engine, *, status=QuotationStatus.IN_PROGRESS.value, end_time=PAST, deleted=False): qt_id = uuid.uuid4() async with engine.begin() as conn: await conn.execute( @@ -81,38 +81,38 @@ async def _quotation_row(engine, qt_id): # ----- 잡① close_expired_quotations : 대상 선정(마감시각 지난 미마감만) ----- async def test_close_expired_picks_only_due_and_open(clean): engine = clean - due = await _add_quotation(engine, status=QuotationStatus.ACTIVE.value, end_time=PAST) - future = await _add_quotation(engine, status=QuotationStatus.ACTIVE.value, end_time=FUTURE) + due = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=PAST) + future = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE) already = await _add_quotation(engine, status=QuotationStatus.CLOSED.value, end_time=PAST) - deleted = await _add_quotation(engine, status=QuotationStatus.ACTIVE.value, end_time=PAST, deleted=True) + deleted = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=PAST, deleted=True) n = await jobs.close_expired_quotations() assert n == 1 # 마감 대상은 due 1건뿐 assert (await _quotation_row(engine, due)).status == QuotationStatus.CLOSED.value - assert (await _quotation_row(engine, future)).status == QuotationStatus.ACTIVE.value # 미래 → 안 건드림 + assert (await _quotation_row(engine, future)).status == QuotationStatus.IN_PROGRESS.value # 미래 → 안 건드림 assert (await _quotation_row(engine, already)).status == QuotationStatus.CLOSED.value # 원래부터 CLOSED - assert (await _quotation_row(engine, deleted)).status == QuotationStatus.ACTIVE.value # 삭제분 → 제외 + assert (await _quotation_row(engine, deleted)).status == QuotationStatus.IN_PROGRESS.value # 삭제분 → 제외 # ----- 잡② close_negotiated_quotations : 대상 선정(전 세션 종결 + 세션 1개+) ----- async def test_close_negotiated_picks_when_all_sessions_ended(clean): engine = clean # 전 세션 종결(거부) → 대상 - ended = await _add_quotation(engine, status=QuotationStatus.ACTIVE.value, end_time=FUTURE) + ended = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE) await _add_session(engine, ended, status=SessionStatus.REJECTED.value) # 진행중 세션 하나라도 있으면 → 제외 - pending = await _add_quotation(engine, status=QuotationStatus.ACTIVE.value, end_time=FUTURE) + pending = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE) await _add_session(engine, pending, status=SessionStatus.DONE.value, bid_price=100) await _add_session(engine, pending, status=SessionStatus.IN_PROGRESS.value) # 세션 0개 → 제외 - no_session = await _add_quotation(engine, status=QuotationStatus.ACTIVE.value, end_time=FUTURE) + no_session = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE) await jobs.close_negotiated_quotations() assert (await _quotation_row(engine, ended)).status == QuotationStatus.CLOSED.value - assert (await _quotation_row(engine, pending)).status == QuotationStatus.ACTIVE.value - assert (await _quotation_row(engine, no_session)).status == QuotationStatus.ACTIVE.value + assert (await _quotation_row(engine, pending)).status == QuotationStatus.IN_PROGRESS.value + assert (await _quotation_row(engine, no_session)).status == QuotationStatus.IN_PROGRESS.value # ----- close_and_decide 위임 결과 스모크(잡①을 통해) ----- diff --git a/negodata/front/src/api/generated/model/dashboardScope.ts b/negodata/front/src/api/generated/model/dashboardScope.ts index 0e8fce2..a6d1137 100644 --- a/negodata/front/src/api/generated/model/dashboardScope.ts +++ b/negodata/front/src/api/generated/model/dashboardScope.ts @@ -10,8 +10,10 @@ import type { DashboardEmailUnsent } from './dashboardEmailUnsent'; export interface DashboardScope { in_progress?: number; this_month?: number; + awarded_this_month?: number; deadline_soon?: DashboardActionList; email_unsent?: DashboardEmailUnsent; + awarded?: DashboardActionList; equal_bid?: DashboardActionList; ruptured?: DashboardActionList; } diff --git a/negodata/front/src/api/generated/model/deliveryType.ts b/negodata/front/src/api/generated/model/deliveryType.ts index 2f51973..b1bd428 100644 --- a/negodata/front/src/api/generated/model/deliveryType.ts +++ b/negodata/front/src/api/generated/model/deliveryType.ts @@ -13,7 +13,7 @@ export type DeliveryType = typeof DeliveryType[keyof typeof DeliveryType]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const DeliveryType = { - PARTNER: 1, + SUPPLIER: 1, COURIER: 2, PICKUP: 3, } as const; diff --git a/negodata/front/src/api/generated/model/quotationStatus.ts b/negodata/front/src/api/generated/model/quotationStatus.ts index 1758d54..7d526fd 100644 --- a/negodata/front/src/api/generated/model/quotationStatus.ts +++ b/negodata/front/src/api/generated/model/quotationStatus.ts @@ -14,7 +14,6 @@ export type QuotationStatus = typeof QuotationStatus[keyof typeof QuotationStatu // eslint-disable-next-line @typescript-eslint/no-redeclare export const QuotationStatus = { CREATED: 1, - ACTIVE: 2, + IN_PROGRESS: 2, CLOSED: 3, - ON_HOLD: 4, } as const; diff --git a/negodata/front/src/features/dashboard/components/ActionWidget.tsx b/negodata/front/src/features/dashboard/components/ActionWidget.tsx new file mode 100644 index 0000000..34a8828 --- /dev/null +++ b/negodata/front/src/features/dashboard/components/ActionWidget.tsx @@ -0,0 +1,72 @@ +import type { ElementType, ReactNode } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Typography } from '@/components/ui/typography'; +import { cn } from '@/lib/utils'; +import { TONE_CHIP, type Tone } from '../tones'; + +// 대시보드 액션 위젯 공용 셸: 컬러 아이콘칩·제목·total 배지·빈상태. 본문(행 목록)은 children 으로 받는다. +export function ActionWidget({ + title, + icon: Icon, + tone, + total, + empty, + emptyText = '처리할 항목 없음', + children, +}: { + title: string; + icon: ElementType; + tone: Tone; + total: number; + empty: boolean; + emptyText?: string; + children: ReactNode; +}) { + return ( + + +
+
+ + + + + {title} + +
+ {total} +
+ {empty ? ( + {emptyText} + ) : ( +
{children}
+ )} +
+
+ ); +} + +// 위젯 안의 클릭 가능한 행(견적 1건 → 상세 딥링크). 우측 슬롯에 D-n·미발송 배지 등을 건다. +export function ActionRow({ + name, + right, + onClick, +}: { + name?: string; + right?: ReactNode; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/negodata/front/src/features/dashboard/components/DashboardHero.tsx b/negodata/front/src/features/dashboard/components/DashboardHero.tsx new file mode 100644 index 0000000..57e23a6 --- /dev/null +++ b/negodata/front/src/features/dashboard/components/DashboardHero.tsx @@ -0,0 +1,29 @@ +import { HelpCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Typography } from '@/components/ui/typography'; +import { useAuth } from '@/features/auth/useAuth'; + +// 대시보드 상단 환영 배너. 제품 소개는 온보딩(이용안내 모달)이 맡고, 여기선 누구의/어느 회사 대시보드인지만 보여준다. +// 배지=현재 로그인 회사, 제목=사용자 이름 인사. 목업의 그라데이션 대신 primary 틴트 패널로 절제. +export function DashboardHero({ onOpenGuide }: { onOpenGuide: () => void }) { + const { user } = useAuth(); + const name = user?.name?.trim(); + const company = user?.company?.trim(); + + return ( +
+
+ {company && {company}} + {name ? `${name}님, 환영합니다` : '환영합니다'} + + 견적 생성부터 초청메일·협상·마감·낙찰까지, 지금 처리할 일을 아래에서 한눈에 봅니다. + +
+ +
+ ); +} diff --git a/negodata/front/src/features/dashboard/components/DeadlineWidget.tsx b/negodata/front/src/features/dashboard/components/DeadlineWidget.tsx new file mode 100644 index 0000000..cca2478 --- /dev/null +++ b/negodata/front/src/features/dashboard/components/DeadlineWidget.tsx @@ -0,0 +1,28 @@ +import { Clock } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import type { DashboardActionList } from '@/api/generated/model/dashboardActionList'; +import { ActionWidget, ActionRow } from './ActionWidget'; +import { fmtDeadline } from '../fmt'; + +// 마감 임박: 견적명 + 우측에 마감 D-n. +export function DeadlineWidget({ + data, + onOpen, +}: { + data?: DashboardActionList; + onOpen: (qtId: string) => void; +}) { + const items = data?.items ?? []; + return ( + + {items.map((it) => ( + onOpen(it.qt_id)} + right={{fmtDeadline(it.end_time)}} + /> + ))} + + ); +} diff --git a/negodata/front/src/features/dashboard/components/EmailUnsentWidget.tsx b/negodata/front/src/features/dashboard/components/EmailUnsentWidget.tsx new file mode 100644 index 0000000..4995b0f --- /dev/null +++ b/negodata/front/src/features/dashboard/components/EmailUnsentWidget.tsx @@ -0,0 +1,27 @@ +import { Mail } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import type { DashboardEmailUnsent } from '@/api/generated/model/dashboardEmailUnsent'; +import { ActionWidget, ActionRow } from './ActionWidget'; + +// 메일 미발송: 견적 단위로 묶고 우측에 미발송 협력사 수. 발송 전엔 협상이 시작 안 되므로 미발송 수는 destructive 배지. +export function EmailUnsentWidget({ + data, + onOpen, +}: { + data?: DashboardEmailUnsent; + onOpen: (qtId: string) => void; +}) { + const items = data?.quotations ?? []; + return ( + + {items.map((it) => ( + onOpen(it.qt_id)} + right={미발송 {it.unsent_count ?? 0}곳} + /> + ))} + + ); +} diff --git a/negodata/front/src/features/dashboard/components/KpiTile.tsx b/negodata/front/src/features/dashboard/components/KpiTile.tsx new file mode 100644 index 0000000..ac9a86d --- /dev/null +++ b/negodata/front/src/features/dashboard/components/KpiTile.tsx @@ -0,0 +1,39 @@ +import type { ElementType } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Typography } from '@/components/ui/typography'; +import { cn } from '@/lib/utils'; +import { TONE_CHIP, type Tone } from '../tones'; + +// 대시보드 KPI 숫자 타일. 컬러 아이콘칩 + 값 + 라벨. warn 지표는 값이 0보다 클 때만 숫자를 destructive 로 강조한다. +export function KpiTile({ + label, + value, + icon: Icon, + tone, + alertWhenPositive, +}: { + label: string; + value: number; + icon: ElementType; + tone: Tone; + alertWhenPositive?: boolean; +}) { + const alert = !!alertWhenPositive && value > 0; + return ( + + +
+ +
+
+ + {value} + + + {label} + +
+
+
+ ); +} diff --git a/negodata/front/src/features/dashboard/components/LifecycleStepper.tsx b/negodata/front/src/features/dashboard/components/LifecycleStepper.tsx new file mode 100644 index 0000000..abbdeab --- /dev/null +++ b/negodata/front/src/features/dashboard/components/LifecycleStepper.tsx @@ -0,0 +1,64 @@ +import { ChevronRight } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Typography } from '@/components/ui/typography'; +import { cn } from '@/lib/utils'; +import { STEPS, ACTOR_CLASS } from '@/features/onboarding/steps'; + +// 견적 생성~낙찰 6단계 흐름을 가로 스텝퍼로 한눈에. 단계 정의는 온보딩 모달과 steps.ts 에서 공유한다. +// 3단계(초청메일)는 수동 발송이 필수라 destructive 톤으로 강조. +export function LifecycleStepper() { + return ( + + +
+ 협상은 이렇게 진행됩니다 + + 견적 생성부터 낙찰까지 6단계로 흐릅니다. 3단계 초청메일을 직접 보내야 협상이 시작됩니다. + +
+ +
+ {STEPS.map((step) => { + const Icon = step.icon; + return ( +
+
+
+ {`0${step.num}`} + + [{step.actor}] + +
+ + + {step.title} + + + {step.short} + +
+ + {/* 큰 화면에서 단계 사이 화살표 */} + {step.num < STEPS.length && ( + + )} +
+ ); + })} +
+
+
+ ); +} diff --git a/negodata/front/src/features/dashboard/components/RefWidget.tsx b/negodata/front/src/features/dashboard/components/RefWidget.tsx new file mode 100644 index 0000000..f93370b --- /dev/null +++ b/negodata/front/src/features/dashboard/components/RefWidget.tsx @@ -0,0 +1,37 @@ +import type { ElementType } from 'react'; +import type { DashboardActionList } from '@/api/generated/model/dashboardActionList'; +import { ActionWidget, ActionRow } from './ActionWidget'; +import type { Tone } from '../tones'; + +// 동가 / 결렬: 견적명만 나열(수동 확인 대상). 제목·아이콘·톤은 호출부에서 지정. +export function RefWidget({ + title, + icon, + tone, + data, + onOpen, + emptyText, +}: { + title: string; + icon: ElementType; + tone: Tone; + data?: DashboardActionList; + onOpen: (qtId: string) => void; + emptyText?: string; +}) { + const items = data?.items ?? []; + return ( + + {items.map((it) => ( + onOpen(it.qt_id)} /> + ))} + + ); +} diff --git a/negodata/front/src/features/dashboard/components/ResultExamples.tsx b/negodata/front/src/features/dashboard/components/ResultExamples.tsx new file mode 100644 index 0000000..49c2a98 --- /dev/null +++ b/negodata/front/src/features/dashboard/components/ResultExamples.tsx @@ -0,0 +1,109 @@ +import { Award, RefreshCw } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Typography } from '@/components/ui/typography'; +import { cn } from '@/lib/utils'; +import { TONE_CHIP, type Tone } from '../tones'; + +// 낙찰 판정 결과가 어떻게 보이는지 보여주는 예시(정적). 실제 동가·결렬 현황은 위 스코프 위젯에서 다룬다. +// 데모 수치(금액·절감률)는 설명용이며 실데이터가 아니다. + +interface ResultRow { + label: string; + value: string; +} + +interface ResultCase { + tone: Tone; + tag: string; + qtNo: string; + title: string; + rows: ResultRow[]; + outcome: string; +} + +const CASES: ResultCase[] = [ + { + tone: 'amber', + tag: '동가 → 차수 재생성', + qtNo: 'QT-20260611-A', + title: 'MRO 안전화 일괄 조달 (1차수 마감)', + rows: [ + { label: '최저 투찰가', value: '42,000원' }, + { label: '동가 투찰사', value: '2개사' }, + ], + outcome: '최저가가 같은 2개사가 나와 2차수 협상이 자동 재생성·발송되었습니다.', + }, + { + tone: 'emerald', + tag: '단독 최저 낙찰', + qtNo: 'QT-20260608-F', + title: '오피스 소모품 조달', + rows: [ + { label: '목표가', value: '500,000원' }, + { label: '최종 낙찰가', value: '415,000원' }, + { label: '낙찰자', value: '단독 최저 투찰사' }, + ], + outcome: '단독 최저가 투찰사로 낙찰되어 결과가 알림함으로 통지되었습니다.', + }, +]; + +export function ResultExamples() { + return ( + + +
+ 낙찰 결과는 이렇게 정리됩니다 + 예시 +
+
+ {CASES.map((c) => ( + + ))} +
+
+
+ ); +} + +function ResultCaseCard({ data }: { data: ResultCase }) { + const Icon = data.tone === 'emerald' ? Award : RefreshCw; + return ( +
+
+
+ + + + + {data.tag} + +
+ + {data.qtNo} + +
+ + + {data.title} + + +
+ {data.rows.map((r) => ( +
+ + {r.label} + + + {r.value} + +
+ ))} +
+ + + {data.outcome} + +
+ ); +} diff --git a/negodata/front/src/features/dashboard/components/ScopeSection.tsx b/negodata/front/src/features/dashboard/components/ScopeSection.tsx new file mode 100644 index 0000000..f6666c0 --- /dev/null +++ b/negodata/front/src/features/dashboard/components/ScopeSection.tsx @@ -0,0 +1,42 @@ +import { Activity, CalendarPlus, Award, Ban } from 'lucide-react'; +import { Typography } from '@/components/ui/typography'; +import type { DashboardScope } from '@/api/generated/model/dashboardScope'; +import { KpiTile } from './KpiTile'; +import { DeadlineWidget } from './DeadlineWidget'; +import { EmailUnsentWidget } from './EmailUnsentWidget'; +import { RefWidget } from './RefWidget'; + +// 스코프 1개(회사 전체 / 내 견적) 블록: 라벨 + 요약 KPI + 액션 위젯. +// KPI = 리스트 없는 순수 지표만. +// 위젯 = 관리자가 직접 처리해야 하는 것만. +export function ScopeSection({ + label, + scope, + onOpen, +}: { + label: string; + scope?: DashboardScope; + onOpen: (qtId: string) => void; +}) { + const s = scope ?? {}; + return ( +
+
+ + {label} +
+ +
+ + + +
+ +
+ + + +
+
+ ); +} diff --git a/negodata/front/src/features/dashboard/components/StartChecklist.tsx b/negodata/front/src/features/dashboard/components/StartChecklist.tsx new file mode 100644 index 0000000..531eb33 --- /dev/null +++ b/negodata/front/src/features/dashboard/components/StartChecklist.tsx @@ -0,0 +1,136 @@ +import { useNavigate } from 'react-router'; +import { CheckCircle2, Circle, AlertTriangle } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Typography } from '@/components/ui/typography'; +import { cn } from '@/lib/utils'; + +// 시작하기 체크리스트 — 신규 유저 온보딩 예시(정적). 항목 진행 수치는 데모용 고정값이고, +// 버튼만 실제 등록 페이지로 이동시킨다. 실데이터 연동(상품/협력사/카드 카운트) 전까지 예시로 둔다. + +type ItemStatus = 'done' | 'warn' | 'todo'; + +interface ChecklistItem { + status: ItemStatus; + title: string; + desc: string; + meta?: string; // 우측 완료 배지 텍스트 + actionLabel?: string; + to?: string; +} + +const ITEMS: ChecklistItem[] = [ + { + status: 'done', + title: '상품 등록 완료', + desc: '자동 가격협상에 부칠 품목을 등록했습니다.', + meta: '12개', + }, + { + status: 'done', + title: '협력사 등록 완료', + desc: '투찰에 참여할 공급 협력사를 등록했습니다.', + meta: '5개사', + }, + { + status: 'warn', + title: '협력사 담당자 이메일 누락', + desc: '이메일이 없으면 초청메일을 보낼 수 없어 협상이 시작되지 않습니다.', + actionLabel: '이메일 채우기', + to: '/partners', + }, + { + status: 'todo', + title: '첫 견적 만들기', + desc: '상품·협력사를 묶어 목표가를 정하고 자동협상을 시작합니다.', + actionLabel: '견적 생성', + to: '/quotation', + }, +]; + +const DONE = ITEMS.filter((i) => i.status === 'done').length; + +export function StartChecklist() { + const navigate = useNavigate(); + return ( + + +
+
+
+ 시작하기 체크리스트 + 예시 +
+ 필수 준비를 끝내야 첫 자동협상을 시작할 수 있습니다. +
+ + {DONE} / {ITEMS.length} 완료 + +
+ + {/* 진행률 바 */} +
+
+
+ +
+ {ITEMS.map((item) => ( + item.to && navigate(item.to)} /> + ))} +
+ + + ); +} + +function ChecklistRow({ item, onAction }: { item: ChecklistItem; onAction: () => void }) { + const warn = item.status === 'warn'; + return ( +
+
+ +
+ + {item.title} + + + {item.desc} + +
+
+ + {item.meta ? ( + + {item.meta} + + ) : item.actionLabel ? ( + + ) : null} +
+ ); +} + +function StatusIcon({ status }: { status: ItemStatus }) { + if (status === 'done') return ; + if (status === 'warn') return ; + return ; +} diff --git a/negodata/front/src/features/dashboard/fmt.ts b/negodata/front/src/features/dashboard/fmt.ts new file mode 100644 index 0000000..8cf5339 --- /dev/null +++ b/negodata/front/src/features/dashboard/fmt.ts @@ -0,0 +1,21 @@ +// 백엔드 end_time 은 naive UTC ISO(타임존 표기 없음) → 'Z' 를 붙여 UTC 로 파싱한다. +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; + +// UTC 절대시각(ms) → 한국시간(KST) 기준 '그 날짜의 자정'을 UTC ms 앵커로 반환. +// +9h 시프트 후 UTC 게터로 읽으면 KST 벽시계 날짜가 된다(브라우저 타임존과 무관하게 항상 KST). +function kstDayStart(ms: number): number { + const shifted = new Date(ms + KST_OFFSET_MS); + return Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()); +} + +// 마감까지 남은 '한국시간 캘린더 일수'로 D-n 표기. 경과시간이 아니라 날짜 차이라 +// 오늘 마감(시각 무관)은 항상 D-DAY, 내일이면 D-1. +export function fmtDeadline(s?: string | null): string { + if (!s) return ''; + const due = new Date(s.endsWith('Z') ? s : `${s}Z`); + if (Number.isNaN(due.getTime())) return ''; + const days = Math.round((kstDayStart(due.getTime()) - kstDayStart(Date.now())) / 86_400_000); + if (days < 0) return '지남'; + if (days === 0) return 'D-DAY'; + return `D-${days}`; +} diff --git a/negodata/front/src/features/dashboard/index.ts b/negodata/front/src/features/dashboard/index.ts new file mode 100644 index 0000000..8c503d2 --- /dev/null +++ b/negodata/front/src/features/dashboard/index.ts @@ -0,0 +1,5 @@ +export { DashboardHero } from './components/DashboardHero'; +export { LifecycleStepper } from './components/LifecycleStepper'; +export { ScopeSection } from './components/ScopeSection'; +export { StartChecklist } from './components/StartChecklist'; +export { ResultExamples } from './components/ResultExamples'; diff --git a/negodata/front/src/features/dashboard/tones.ts b/negodata/front/src/features/dashboard/tones.ts new file mode 100644 index 0000000..fdbb304 --- /dev/null +++ b/negodata/front/src/features/dashboard/tones.ts @@ -0,0 +1,12 @@ +// 아이콘 칩 색(배경+글자). StatusPill 의 PILL_TONE 과 같은 계열 — 코드베이스 전반에서 쓰는 공용 톤이라 +// 디자인토큰 범위 안에서 색만 입히는 용도. 생짜 그라데이션 대신 이 맵으로 통일한다. +export type Tone = 'blue' | 'emerald' | 'amber' | 'rose' | 'purple' | 'zinc'; + +export const TONE_CHIP: Record = { + blue: 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300', + emerald: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300', + amber: 'bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300', + rose: 'bg-rose-100 text-rose-700 dark:bg-rose-950/40 dark:text-rose-300', + purple: 'bg-purple-100 text-purple-700 dark:bg-purple-950/40 dark:text-purple-300', + zinc: 'bg-muted text-muted-foreground', +}; diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx index 50c335e..32e7e5d 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx @@ -97,7 +97,6 @@ export function SessionsStatusTab({
- 세션 ID 협력사 협상 URL 초청메일 @@ -115,14 +114,13 @@ export function SessionsStatusTab({ {sessionViews.length === 0 && ( - + 참여 중인 협상 세션이 없습니다. (리스트가 비어 있습니다) )} {sessionViews.map((sess) => ( - {sess.session_id}
{sess.supplier_name} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/StatusPill.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/StatusPill.tsx index 39c9a7c..42bf4f7 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/StatusPill.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/StatusPill.tsx @@ -51,7 +51,7 @@ const QSTATUS_TONE: Record = { box: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-700/50', dot: 'bg-amber-500', }, - [QuotationStatus.ACTIVE]: { + [QuotationStatus.IN_PROGRESS]: { box: 'bg-emerald-100 text-emerald-800 border-emerald-300 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-700/50', dot: 'bg-emerald-500', }, @@ -59,10 +59,6 @@ const QSTATUS_TONE: Record = { box: 'bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-950/40 dark:text-blue-300 dark:border-blue-700/50', dot: 'bg-blue-500', }, - [QuotationStatus.ON_HOLD]: { - box: 'bg-rose-100 text-rose-800 border-rose-300 dark:bg-rose-950/40 dark:text-rose-300 dark:border-rose-700/50', - dot: 'bg-rose-500', - }, }; const QSTATUS_FALLBACK = { box: 'bg-zinc-100 text-zinc-800 border-zinc-300', dot: 'bg-zinc-500' }; diff --git a/negodata/front/src/features/quotations/components/QuotationTable.tsx b/negodata/front/src/features/quotations/components/QuotationTable.tsx index 5e65321..c9006c0 100644 --- a/negodata/front/src/features/quotations/components/QuotationTable.tsx +++ b/negodata/front/src/features/quotations/components/QuotationTable.tsx @@ -26,12 +26,10 @@ const statusBadgeClass = (status?: number | null) => { switch (status) { case QuotationStatus.CREATED: return 'bg-yellow-50 text-yellow-700 border-yellow-300'; - case QuotationStatus.ACTIVE: + case QuotationStatus.IN_PROGRESS: return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/25 dark:text-emerald-400 border-emerald-300/40'; case QuotationStatus.CLOSED: return 'bg-blue-50 text-blue-700 border-blue-300'; - case QuotationStatus.ON_HOLD: - return 'bg-red-50 text-red-700 border-red-300'; default: return 'bg-zinc-100 text-zinc-600'; } @@ -53,7 +51,7 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo const productName = product?.name ?? est.productName; // 목록에 없으면 서버 조인 상품명으로 폴백 return (
- + {est.title} diff --git a/negodata/front/src/features/quotations/types.ts b/negodata/front/src/features/quotations/types.ts index 49c00d7..c4cbe80 100644 --- a/negodata/front/src/features/quotations/types.ts +++ b/negodata/front/src/features/quotations/types.ts @@ -118,13 +118,12 @@ function formatDueDate(end?: string | null): string { return toKstDateTime(end) ?? end; } -export type QtStatusKey = '견적생성' | '견적진행중' | '견적마감' | '협상보류'; +export type QtStatusKey = '견적생성' | '견적진행중' | '견적마감'; export const QUOTATION_STATUS_LABEL: Record = { [QuotationStatus.CREATED]: '견적생성', - [QuotationStatus.ACTIVE]: '견적진행중', + [QuotationStatus.IN_PROGRESS]: '견적진행중', [QuotationStatus.CLOSED]: '견적마감', - [QuotationStatus.ON_HOLD]: '협상보류', }; export const quotationStatusLabel = (s?: number | null): string => s != null ? QUOTATION_STATUS_LABEL[s as QuotationStatus] ?? String(s) : ''; diff --git a/negodata/front/src/lib/enumLabels.ts b/negodata/front/src/lib/enumLabels.ts index 0c2efdd..7ae00b7 100644 --- a/negodata/front/src/lib/enumLabels.ts +++ b/negodata/front/src/lib/enumLabels.ts @@ -1,7 +1,7 @@ import { DeliveryType, UserRole, SupplierType, CardUsageType, UserStatus } from '@/api/generated/model'; export const DELIVERY_TYPE_LABEL: Record = { - [DeliveryType.PARTNER]: '협력사배송', + [DeliveryType.SUPPLIER]: '협력사배송', [DeliveryType.COURIER]: '지정택배배송', [DeliveryType.PICKUP]: '픽업배송', }; diff --git a/negodata/front/src/pages/dashboard.tsx b/negodata/front/src/pages/dashboard.tsx index 45d1e43..6f9c979 100644 --- a/negodata/front/src/pages/dashboard.tsx +++ b/negodata/front/src/pages/dashboard.tsx @@ -1,199 +1,49 @@ -import type { ReactNode } from 'react'; +import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router'; import { PageContainer } from '@/components/layout/PageContainer'; -import { Card, CardContent } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; import { Typography } from '@/components/ui/typography'; +import { DashboardHero, ScopeSection } from '@/features/dashboard'; +import { OnboardingGuideModal } from '@/features/onboarding/OnboardingGuideModal'; +import { useAuth } from '@/features/auth/useAuth'; import { useGetDashboardSummary } from '@/api/generated/dashboard/dashboard'; -import type { DashboardScope } from '@/api/generated/model/dashboardScope'; -import type { DashboardActionList } from '@/api/generated/model/dashboardActionList'; -import type { DashboardEmailUnsent } from '@/api/generated/model/dashboardEmailUnsent'; -// 견적 생성~마감~선정을 한눈에 다루는 대시보드. 회사 전체 + 내 견적 두 스코프를 따로 보여주고, -// 모든 액션 행은 클릭 시 해당 견적 상세로 딥링크(/quotation?detail=qt_id)된다. 읽기 전용. +// 견적 생성~마감~선정을 한눈에 보는 대시보드. +// 회사 전체 스코프는 최고관리자만, 일반 사용자는 '내 견적'만 본다. 위젯 행은 클릭 시 견적 상세로 딥링크. 읽기 전용. +const ONBOARDING_SEEN_KEY = 'negodata_onboarding_seen'; + export default function DashboardPage() { const navigate = useNavigate(); + const { user } = useAuth(); + const isOwner = user?.role === '최고관리자'; const { data, isLoading, isError } = useGetDashboardSummary(); + const [guideOpen, setGuideOpen] = useState(false); + + // 첫 방문 시 1회 자동 노출(localStorage). 이후엔 상단 "이용안내" 버튼으로만 연다. + useEffect(() => { + if (!localStorage.getItem(ONBOARDING_SEEN_KEY)) { + setGuideOpen(true); + localStorage.setItem(ONBOARDING_SEEN_KEY, '1'); + } + }, []); const openQuotation = (qtId: string) => navigate(`/quotation?detail=${qtId}`); - if (isLoading) { - return ( - - 대시보드를 불러오는 중… - - ); - } - if (isError || !data) { - return ( - - 대시보드를 불러오지 못했습니다. - - ); - } - return ( - - + setGuideOpen(true)} /> + + {isLoading ? ( + 대시보드를 불러오는 중… + ) : isError || !data ? ( + 대시보드를 불러오지 못했습니다. + ) : ( + <> + {isOwner && } + + + )} + + ); } - -// ----- 스코프 섹션(회사 전체 / 내 견적 공용) ----- -function ScopeSection({ - title, - scope, - onOpen, -}: { - title: string; - scope?: DashboardScope; - onOpen: (qtId: string) => void; -}) { - const s = scope ?? {}; - return ( -
- {title} - -
- - - - - - -
- -
- - - - -
-
- ); -} - -// ----- KPI 숫자 카드 ----- -function StatCard({ label, value, warn }: { label: string; value: number; warn?: boolean }) { - const danger = !!warn && value > 0; - return ( - - - {label} - - {value} - - - - ); -} - -// ----- 액션 위젯(공용 셸) ----- -function WidgetCard({ - title, - total, - empty, - children, -}: { - title: string; - total: number; - empty: boolean; - children: ReactNode; -}) { - return ( - - -
- {title} - {total} -
- {empty ? ( - 처리할 항목 없음 - ) : ( -
{children}
- )} -
-
- ); -} - -function ActionRow({ name, right, onClick }: { name?: string; right?: ReactNode; onClick: () => void }) { - return ( - - ); -} - -// ----- 마감 임박: 견적 + 마감 D-n ----- -function DeadlineWidget({ data, onOpen }: { data?: DashboardActionList; onOpen: (qtId: string) => void }) { - const items = data?.items ?? []; - return ( - - {items.map((it) => ( - onOpen(it.qt_id)} - right={{fmtDeadline(it.end_time)}} - /> - ))} - - ); -} - -// ----- 메일 미발송: 견적 단위로 묶고 미발송 협력사 수 ----- -function EmailUnsentWidget({ data, onOpen }: { data?: DashboardEmailUnsent; onOpen: (qtId: string) => void }) { - const items = data?.quotations ?? []; - return ( - - {items.map((it) => ( - onOpen(it.qt_id)} - right={미발송 {it.unsent_count ?? 0}곳} - /> - ))} - - ); -} - -// ----- 동가 / 결렬: 견적명만 ----- -function RefWidget({ - title, - data, - onOpen, -}: { - title: string; - data?: DashboardActionList; - onOpen: (qtId: string) => void; -}) { - const items = data?.items ?? []; - return ( - - {items.map((it) => ( - onOpen(it.qt_id)} /> - ))} - - ); -} - -// 백엔드 end_time 은 naive UTC ISO(타임존 표기 없음) → 'Z' 를 붙여 UTC 로 파싱하고 남은 일수로 D-n 표기. -function fmtDeadline(s?: string | null): string { - if (!s) return ''; - const due = new Date(s.endsWith('Z') ? s : `${s}Z`); - const days = Math.ceil((due.getTime() - Date.now()) / 86_400_000); - if (Number.isNaN(days)) return ''; - if (days < 0) return '지남'; - if (days === 0) return 'D-DAY'; - return `D-${days}`; -} diff --git a/negodata/front/src/tokens.css b/negodata/front/src/tokens.css index f06a9ef..9d7016f 100644 --- a/negodata/front/src/tokens.css +++ b/negodata/front/src/tokens.css @@ -28,6 +28,7 @@ --color-secondary-foreground: var(--secondary-foreground); --color-accent: var(--accent); --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); --color-success: var(--success); --color-warning: var(--warning); --color-ring: var(--ring); @@ -51,6 +52,7 @@ --secondary-foreground: #171717; --accent: #f5f5f5; --destructive: #e7000b; + --destructive-foreground: #fafafa; --success: #10b981; --warning: #f59e0b; --ring: #a1a1a1; @@ -89,6 +91,7 @@ --secondary-foreground: #fafafa; --accent: #262626; --destructive: #ff6467; + --destructive-foreground: #fafafa; --success: #10b981; --warning: #f59e0b; --ring: #737373; @@ -131,6 +134,7 @@ --color-input: var(--input); --color-border: var(--border); --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); --color-accent-foreground: var(--accent-foreground); --color-accent: var(--accent); --color-muted-foreground: var(--muted-foreground); diff --git a/postgres-init/01-schema.sql b/postgres-init/01-schema.sql index db6afd9..6ab5c50 100644 --- a/postgres-init/01-schema.sql +++ b/postgres-init/01-schema.sql @@ -53,7 +53,7 @@ CREATE TABLE IF NOT EXISTS company.companies ( contact_number VARCHAR(20) NULL, -- 대표 연락처 website_url VARCHAR(255) NULL, -- 홈페이지 URL industry SMALLINT NULL, -- 업종 ( 필요한 만큼 숫자에 매핑하여 사용 ) - status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active, 2=inactive + status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active(활성), 2=inactive(비활성) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 @@ -68,8 +68,8 @@ CREATE TABLE IF NOT EXISTS company.users ( email VARCHAR(255) NULL, -- 이메일 contact_number VARCHAR(20) NULL, -- 연락처 last_accessed_at TIMESTAMPTZ NOT NULL, -- 마지막 접속 시각 - status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active, 2=inactive - role SMALLINT NOT NULL DEFAULT 1, -- 권한: 1=일반, 2=최고관리자(owner) + status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active(활성), 2=inactive(비활성) + role SMALLINT NOT NULL DEFAULT 1, -- 권한(UserRole): 1=user(일반), 2=owner(최고관리자) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 @@ -99,8 +99,8 @@ CREATE TABLE IF NOT EXISTS supplier.supplier_users ( email VARCHAR(255) NULL, -- 이메일 contact_number VARCHAR(20) NULL, -- 연락처 last_accessed_at TIMESTAMPTZ NOT NULL, -- 마지막 접속 시각 - status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active, 2=inactive - role SMALLINT NOT NULL DEFAULT 1, -- 권한: 1=user, 2=manager + status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active(활성), 2=inactive(비활성) + role SMALLINT NOT NULL DEFAULT 1, -- 권한: 1=user(유저), 2=manager(매니저) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 @@ -152,7 +152,7 @@ CREATE TABLE IF NOT EXISTS partner.items ( manufacturer VARCHAR(50) NULL, -- 제조사 made_in VARCHAR(100) NULL, -- 원산지 quantity_unit VARCHAR(50) NULL, -- 상품 취급 단위 라벨(자유입력): EA/BOX/SET/ROLL ... (ORM String 기준) - delivery_type SMALLINT NULL, -- 배송 유형 (코드, 앱 enum 매핑) + delivery_type SMALLINT NULL, -- 배송 유형(DeliveryType): 1=supplier(협력사배송), 2=courier(지정택배배송), 3=pickup(픽업배송) vat_yn BOOLEAN NULL, -- 부가세 포함 여부 delivery_fee_yn BOOLEAN NULL, -- 배송비 포함 여부 internet_lowest_price_yn BOOLEAN NOT NULL DEFAULT FALSE, -- 최저가 솔루션의 원자성을 보존하기 위한 보조 컬럼 @@ -200,7 +200,7 @@ CREATE TABLE IF NOT EXISTS card.nego_cards ( number VARCHAR(10) NULL, -- 식별번호 script VARCHAR(255) NULL, -- 협상 스크립트 edit_script JSONB NULL, -- 편집된 스크립트(JSON) - usage_type SMALLINT NOT NULL DEFAULT 1, -- 카드 적용 견적 구분(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용 + usage_type SMALLINT NOT NULL DEFAULT 1, -- 카드 적용 견적 구분(CardUsageType): 1=common(공통), 2=new(신규견적전용), 3=reuse(재견적전용) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 @@ -213,7 +213,7 @@ CREATE TABLE IF NOT EXISTS card.wild_cards ( number VARCHAR(10) NULL, -- 식별번호 script VARCHAR(255) NULL, -- 협상 스크립트 edit_script JSONB NULL, -- 편집된 스크립트(JSON) - usage_type SMALLINT NOT NULL DEFAULT 1, -- 카드 적용 견적 구분(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용 + usage_type SMALLINT NOT NULL DEFAULT 1, -- 카드 적용 견적 구분(CardUsageType): 1=common(공통), 2=new(신규견적전용), 3=reuse(재견적전용) condition VARCHAR(255) NULL, -- 커스터마이징 협상 카드이기 때문에 상세 조건을 기재해야 함 available BOOLEAN NOT NULL DEFAULT FALSE, -- 와일드 카드는 수동으로 코드에 추가해야 하기 때문에 컬럼 추가 memo VARCHAR(255) NULL, -- 사용 조건 이외에 자유롭게 적을 수 있는 메모 @@ -261,9 +261,9 @@ CREATE TABLE IF NOT EXISTS quotation.quotations ( version_id uuid NOT NULL, -- 버전(card.versions.version_id) name VARCHAR(50) NOT NULL, -- 견적명 number VARCHAR(30) NOT NULL, -- 견적번호 - type SMALLINT NOT NULL, -- 견적 유형: 1=renego(재협상 1:1), 2=requote(재견적 1:N) + type SMALLINT NOT NULL, -- 견적 유형(QuotationType): 1=renego(재협상 1:1), 2=requote(재견적 1:N), 3=new_nego(신규협상 1:1), 4=new_quote(신규견적 1:N) round INTEGER NOT NULL DEFAULT 1, -- 같은 견적 번호로 재견적 진행 시, 해당 숫자가 증가 - status SMALLINT NOT NULL, -- 진행 상태 (코드, 앱 enum 매핑) + status SMALLINT NOT NULL, -- 진행 상태(QuotationStatus): 1=created(생성), 2=in_progress(진행중), 3=closed(마감) start_time TIMESTAMPTZ NOT NULL, -- 견적 시작 시각 end_time TIMESTAMPTZ NOT NULL, -- 견적 종료 시각 manager_name VARCHAR(50) NULL, -- 담당자명 @@ -271,7 +271,7 @@ CREATE TABLE IF NOT EXISTS quotation.quotations ( manager_contact_number VARCHAR(20) NULL, -- 담당자 연락처 memo VARCHAR(100) NULL, -- 메모 md_price BIGINT NULL, -- MD 제시가(원). 목표가 산정 최우선값 (견적생성 모달 입력) - supplier_type SMALLINT NULL, -- 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록 (견적생성 모달 입력) + supplier_type SMALLINT NULL, -- 협력사 유형(SupplierType): 0=none(없음), 1=distribution(유통), 2=manufacture(제조), 3=sole_agency(총판). 재견적 1:1 견적에 기록 iteration INTEGER NOT NULL DEFAULT 0, -- 반복 횟수 preferred_sp_yn BOOLEAN NULL, -- 선호 공급사 지정 여부 preferred_sp_id uuid NULL, -- 선호 공급사(partner.suppliers.supplier_id) @@ -293,16 +293,16 @@ CREATE TABLE IF NOT EXISTS negotiation.sessions ( supplier_id uuid NOT NULL, -- 대상 공급사(partner.suppliers.supplier_id) qt_number VARCHAR(30) NOT NULL, -- 견적번호(스냅샷) qt_round INTEGER NOT NULL, -- 견적 라운드(스냅샷) - qt_type SMALLINT NOT NULL, -- 견적 유형(스냅샷): 1=renego, 2=requote + qt_type SMALLINT NOT NULL, -- 견적 유형(스냅샷, QuotationType): 1=renego(재협상 1:1), 2=requote(재견적 1:N), 3=new_nego(신규협상 1:1), 4=new_quote(신규견적 1:N) target_price BIGINT NOT NULL, -- 목표가(원) target_anchoring_price BIGINT NULL, -- 앵커링가(원) - status SMALLINT NOT NULL, -- 진행 상태 (코드, 앱 enum 매핑) + status SMALLINT NOT NULL, -- 진행 상태(SessionStatus): 1=created(생성), 2=in_progress(진행중), 3=done(완료), 4=not_participated(미참여), 5=rejected(거부) bid_price BIGINT NULL, -- 입찰가(원) bid_at TIMESTAMPTZ NULL, -- 입찰 시각 end_time TIMESTAMPTZ NOT NULL, -- 세션 종료 시각 reject_reason VARCHAR(255) NULL, -- 거절 사유 reject_price BIGINT NULL, -- 거절 시 제시가(원) - reject_delivery_type SMALLINT NULL, -- 거절 시 배송 유형 (코드, 앱 enum 매핑) + reject_delivery_type SMALLINT NULL, -- 거절 시 배송 유형(DeliveryType): 1=supplier(협력사배송), 2=courier(지정택배배송), 3=pickup(픽업배송) email_sent_at TIMESTAMPTZ NULL, -- 협상 초청 메일 발송 시각(NULL=미발송). 수동 발송 버튼이 채움 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) @@ -314,11 +314,11 @@ CREATE TABLE IF NOT EXISTS negotiation.chats ( session_id uuid NOT NULL, -- 소속 세션(negotiation.sessions.session_id), session 1 : N chats card_id uuid NULL, -- 사용된 카드(card.nego_cards/card.wild_cards) seq INTEGER NOT NULL DEFAULT 1, -- 세션 내 메시지 순번 - sender SMALLINT NOT NULL, -- 발신자 구분 (코드, 앱 enum 매핑) + sender SMALLINT NOT NULL, -- 발신자 구분(ChatSender): 1=bot(봇), 2=user(유저) target_price BIGINT NOT NULL, -- 제시 목표가(원) card_used_yn BOOLEAN NULL, -- 카드 사용 여부 indicator_value NUMERIC(8,6) NULL, -- 소수점 까지 반환할 수도 있음 (정수부 2자리 + 소수 6자리, -99.999999~99.999999) - card_type SMALLINT NULL, -- 카드 유형: 1=nego_card, 2=wild_card + card_type SMALLINT NULL, -- 카드 유형(CardType): 1=nego(협상카드), 2=wild(와일드카드) meta JSONB NULL, -- 말풍선 표현 데이터(script/step/client_step/input_mode/input_options/chat_end). 구조화 컬럼(price/card/indicator) 외 가변 UI 필드만 보관. created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) From 78bef0f5f98df456078820f3983e44c0289dafd5 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 1 Jul 2026 14:41:39 +0900 Subject: [PATCH 18/20] =?UTF-8?q?[fix]=20negodata:=20/me=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20role=20=EC=9D=84=20UserRole=20enum=20=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A7=81=EB=A0=AC=ED=99=94=20(Pydantic=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=A0=20=EC=A0=9C=EA=B1=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- negodata/backend/services/auth_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/negodata/backend/services/auth_service.py b/negodata/backend/services/auth_service.py index 4eb0a1c..cfa320c 100644 --- a/negodata/backend/services/auth_service.py +++ b/negodata/backend/services/auth_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 users -from common.enums import DBWRType, ErrorType, UserStatus +from common.enums import DBWRType, ErrorType, UserRole, UserStatus from common.logger import LOG from common.models.gmodel import UserInfo from crud.user_crud import IUserCRUD, UserCRUD @@ -112,7 +112,7 @@ class AuthService: res.name = user.name res.email = user.email res.contact_number = user.contact_number - res.role = user.role + res.role = UserRole(user.role) res.company = company return res From 82076e138e7c47b1a3667ef6c12ddc6f3b0c50e4 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 1 Jul 2026 15:06:55 +0900 Subject: [PATCH 19/20] =?UTF-8?q?[test]=20negodata:=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=8A=A4=EC=9C=84?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=EC=B6=95=20+=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=ED=94=BD=EC=8A=A4=EC=B2=98(conftest)=20=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test DB 세션마다 자동 create/drop (팀원은 Postgres만 있으면 pytest 한 방) - auth_headers 시드 픽스처(무인증 /auth/create 제거 대응) + other_company_id - 커버: 회사 스코프(견적·상품·협력사·대시보드·세팅), 견적 마감 재견적 O/X + 알림, 견적 생성·목표가, 알림함 읽기, 회사유저 OWNER 게이팅, 기존 파일 검증/기대결과 주석 정비 Co-Authored-By: Claude Opus 4.8 (1M context) --- negodata/backend/conftest.py | 93 ++++++- negodata/backend/tests/test_auth.py | 69 ++--- .../tests/test_close_and_decide_fixes.py | 132 ++++----- negodata/backend/tests/test_company_scope.py | 140 ++++++++++ negodata/backend/tests/test_company_user.py | 88 ++++++ negodata/backend/tests/test_features.py | 38 ++- negodata/backend/tests/test_item.py | 77 +----- negodata/backend/tests/test_notification.py | 99 +++++++ .../tests/test_quotation_close_notify.py | 224 +++++++++++++++ .../backend/tests/test_quotation_create.py | 98 +++++++ negodata/backend/tests/test_scheduler.py | 261 ++++++++++-------- 11 files changed, 989 insertions(+), 330 deletions(-) create mode 100644 negodata/backend/tests/test_company_scope.py create mode 100644 negodata/backend/tests/test_company_user.py create mode 100644 negodata/backend/tests/test_notification.py create mode 100644 negodata/backend/tests/test_quotation_close_notify.py create mode 100644 negodata/backend/tests/test_quotation_create.py diff --git a/negodata/backend/conftest.py b/negodata/backend/conftest.py index abcfb81..655d03c 100644 --- a/negodata/backend/conftest.py +++ b/negodata/backend/conftest.py @@ -27,8 +27,47 @@ def _write_url(cfg) -> str: return f"postgresql+asyncpg://{cfg.write_id}{pw}@{cfg.write_host}:{cfg.write_port}/{cfg.name}" +def _admin_url(cfg) -> str: + """DB 생성용 관리 접속. CREATE DATABASE 는 대상 DB 안에서 못 하므로 기본 'postgres' DB 로 붙는다.""" + pw = f":{cfg.write_pw}" if cfg.write_pw else "" + return f"postgresql+asyncpg://{cfg.write_id}{pw}@{cfg.write_host}:{cfg.write_port}/postgres" + + +async def _drop_test_db(*, recreate: bool): + """test DB 를 지운다(있으면). recreate=True 면 지운 뒤 새로 만든다. + WITH (FORCE): 남아있는 커넥션을 끊고 drop (PG13+). 관리 접속은 기본 'postgres' DB.""" + engine = create_async_engine(_admin_url(main_db_config), isolation_level="AUTOCOMMIT") + try: + async with engine.connect() as conn: + await conn.execute(text(f'DROP DATABASE IF EXISTS "{main_db_config.name}" WITH (FORCE)')) + if recreate: + await conn.execute(text(f'CREATE DATABASE "{main_db_config.name}"')) + finally: + await engine.dispose() + + +@pytest_asyncio.fixture(scope="session", autouse=True) +async def _test_db_lifecycle(): + """테스트 세션 동안만 test DB 를 만들고, 끝나면 내린다. + + 매 세션 '깨끗한 새 DB'로 시작하므로 스키마 낡음(드리프트)이 원천 차단되고, 끝나면 남는 DB 도 없다. + (테이블 구조는 db_engine 의 create_all 이 현재 모델 기준으로 채운다.) + 안전가드: 이름에 'test' 있는 DB 만 만들고/지운다(dev DB 보호). + """ + assert "test" in main_db_config.name, ( + f"비-test DB('{main_db_config.name}') 는 만들거나 지우지 않는다. APP_ENV=test 로 실행하세요." + ) + await _drop_test_db(recreate=True) # 세션 시작: 깨끗한 새 DB + yield + # 세션 종료: 앱 싱글톤 커넥션부터 정리(활성 커넥션 있으면 FORCE 로 끊김) 후 DB 를 내린다. + from common.database.db_session_manager import DB_SESSION_MNG + + await DB_SESSION_MNG.dispose_all() + await _drop_test_db(recreate=False) + + @pytest_asyncio.fixture -async def db_engine(): +async def db_engine(_test_db_lifecycle): """테스트용 스키마를 보장하고, 매 테스트 시작 시 테이블을 비워 격리한다. ⚠ 이 픽스처는 TRUNCATE 한다 → dev DB(negosium_db)를 가리키면 실데이터가 날아간다. @@ -74,15 +113,16 @@ async def company_id(db_engine) -> str: return str(cid) -@pytest_asyncio.fixture(scope="session", autouse=True) -async def _dispose_app_engines(): - """테스트 세션이 끝날 때 앱 싱글톤 엔진을 정리한다. - (이벤트 루프 종료 후 커넥션이 GC 되며 나오는 'Event loop is closed' 경고 제거) - """ - yield - from common.database.db_session_manager import DB_SESSION_MNG - - await DB_SESSION_MNG.dispose_all() +@pytest_asyncio.fixture +async def other_company_id(db_engine) -> str: + """company_id 와 다른 소속사 1개(회사 스코프/IDOR 격리 테스트용).""" + cid = uuid.uuid4() + async with db_engine.begin() as conn: + await conn.execute( + text("INSERT INTO companies (company_id, name, status) VALUES (:cid, :name, :status)"), + {"cid": cid, "name": "다른회사", "status": CompanyStatus.ACTIVE.value}, + ) + return str(cid) @pytest_asyncio.fixture @@ -93,3 +133,36 @@ async def client(db_engine): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: yield ac + + +@pytest_asyncio.fixture +async def auth_headers(db_engine, client, company_id): + """테스트 유저를 시드하고 로그인 헤더(Bearer)를 돌려주는 팩토리. + + 무인증 /v1/auth/create 가 제거(최고관리자 회원관리로 일원화)돼 더는 API 로 계정을 못 만든다. + 그래서 users 행을 직접 INSERT(비번 bcrypt 해시)한 뒤 살아있는 /v1/auth/login 으로 토큰을 받는다. + company 미지정 시 기본 소속사(company_id 픽스처). role 로 OWNER 계정도 만들 수 있다. + 호출: `h = await auth_headers("user1")` / `await auth_headers("userB", other_company_id)`. + """ + from common.enums import UserRole, UserStatus + from router.v1.validator.dependencies import GetHashedPW + + async def _make(login_id, company=None, *, password="pw1234", role=UserRole.USER.value, name="n"): + cid = company or company_id + hashed = await GetHashedPW(password) + async with db_engine.begin() as conn: + # status·role 은 NOT NULL — ORM default 는 raw INSERT 에 안 먹으므로 명시(companies.status 와 동일). + await conn.execute( + text( + "INSERT INTO users (user_id, company_id, id, password, name, status, role, last_accessed_at) " + "VALUES (:uid, :cid, :id, :pw, :name, :status, :role, now())" + ), + { + "uid": uuid.uuid4(), "cid": uuid.UUID(cid), "id": login_id, "pw": hashed, + "name": name, "status": UserStatus.ACTIVE.value, "role": role, + }, + ) + r = await client.post("/v1/auth/login", json={"id": login_id, "password": password}) + return {"Authorization": f"Bearer {r.json()['access_token']}"} + + return _make diff --git a/negodata/backend/tests/test_auth.py b/negodata/backend/tests/test_auth.py index ea23cce..1c44628 100644 --- a/negodata/backend/tests/test_auth.py +++ b/negodata/backend/tests/test_auth.py @@ -1,34 +1,15 @@ -"""auth 도메인 e2e 테스트 (negodata: users/companies 기반). +"""auth 도메인 e2e — 로그인 / 내정보 / 인증거부 흐름. -실행 전제: PostgreSQL(negodata_db)이 떠 있어야 한다. - docker compose up -d # 또는 로컬 postgres - cd negodata/backend && python -m pytest -계정 생성은 company_id 를 요구하므로 company_id 픽스처(conftest)가 소속사를 시드한다. +계정 생성·중복·최고관리자 스코프는 test_company_user.py. 유저 시드/로그인은 auth_headers 픽스처. """ -async def test_create_and_login_flow(client, company_id): - # 1) 계정 생성 (회사 하위로) - r = await client.post( - "/v1/auth/create", - json={"id": "user1", "password": "pw1234", "company_id": company_id, "name": "홍길동"}, - ) - assert r.status_code == 200 - body = r.json() - assert body["result"]["success"] is True - assert body["user_id"] +async def test_login_and_me_flow(auth_headers, client, company_id): + """검증: 시드된 유저가 로그인해 받은 토큰으로 /me 호출. + 기대결과: 200, 본인 id·name·소속사(company_id)가 그대로 반환.""" + h = await auth_headers("user1", name="홍길동") - # 2) 로그인 -> 토큰 발급 - r = await client.post("/v1/auth/login", json={"id": "user1", "password": "pw1234"}) - assert r.status_code == 200 - body = r.json() - assert body["result"]["success"] is True - assert body["access_token"] - assert body["refresh_token"] - access_token = body["access_token"] - - # 3) 보호된 엔드포인트(/me) — 토큰의 유저 + 소속사 반환 - r = await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {access_token}"}) + r = await client.get("/v1/auth/me", headers=h) assert r.status_code == 200 me = r.json() assert me["id"] == "user1" @@ -36,43 +17,27 @@ async def test_create_and_login_flow(client, company_id): assert me["company"]["company_id"] == company_id -async def test_login_with_wrong_password(client, company_id): - await client.post( - "/v1/auth/create", - json={"id": "user2", "password": "correct", "company_id": company_id, "name": "n"}, - ) +async def test_login_with_wrong_password(auth_headers, client): + """검증: 존재하는 계정에 '틀린 비밀번호'로 로그인. + 기대결과: 로그인 실패 — success=False, code=1200(ACCOUNT_INVALID_INFO), 토큰 빈 문자열.""" + await auth_headers("user2") # pw1234 로 시드 r = await client.post("/v1/auth/login", json={"id": "user2", "password": "wrong"}) - assert r.status_code == 200 body = r.json() assert body["result"]["success"] is False - # 자격증명 오류는 ACCOUNT_INVALID_INFO(1200) assert body["result"]["code"] == 1200 - assert body.get("access_token", "") == "" # 실패 시 토큰은 빈 문자열 + assert body.get("access_token", "") == "" async def test_login_nonexistent_account(client): + """검증: 존재하지 않는 계정으로 로그인. + 기대결과: 실패 — success=False (계정 유무를 '틀린 비번'과 구분해 흘리지 않음).""" r = await client.post("/v1/auth/login", json={"id": "ghost", "password": "whatever"}) assert r.json()["result"]["success"] is False -async def test_duplicate_account_create(client, company_id): - r1 = await client.post( - "/v1/auth/create", - json={"id": "dup", "password": "pw1234", "company_id": company_id, "name": "n"}, - ) - assert r1.json()["result"]["success"] is True - - r2 = await client.post( - "/v1/auth/create", - json={"id": "dup", "password": "pw5678", "company_id": company_id, "name": "n2"}, - ) - body = r2.json() - assert body["result"]["success"] is False - # ACCOUNT_ALREADY_EXIST(1201) - assert body["result"]["code"] == 1201 - - async def test_me_without_token_is_rejected(client): + """검증: 토큰 없이 보호 엔드포인트 /me 호출. + 기대결과: 인증 단계에서 거부 — HTTP 401 또는 403.""" r = await client.get("/v1/auth/me") - assert r.status_code in (401, 403) # HTTPBearer 가 자격증명 없음을 거부 + assert r.status_code in (401, 403) diff --git a/negodata/backend/tests/test_close_and_decide_fixes.py b/negodata/backend/tests/test_close_and_decide_fixes.py index 08b19b6..45f9b6f 100644 --- a/negodata/backend/tests/test_close_and_decide_fixes.py +++ b/negodata/backend/tests/test_close_and_decide_fixes.py @@ -6,7 +6,8 @@ - #4 재생성 사유 집계: 단독낙찰(preferred_sp_yn=True) 이전 라운드를 '미참여'로 오집계하지 않음 - #6 재생성 라운드 최소 협상기간 하한(즉시 재마감 캐스케이드 방지) -실행 전제: tests/test_scheduler.py 와 동일(PostgreSQL, APP_ENV=test). +용어: 체인 = 같은 견적번호(number)로 이어지는 라운드들 / 미참여 = 공급사가 협상에 안 들어온 채 마감됨 / + 재생성 = 결판 안 난 견적의 '다음 라운드'를 자동 생성 / 재생성 한도 = 사유(미참여·동가)별로 체인당 1번까지만. """ import asyncio import uuid @@ -29,63 +30,9 @@ async def clean(db_engine): return db_engine -async def _seed_quotation( - engine, *, number, round_, status, start_time=PAST, end_time=PAST, - preferred_sp_yn=None, equal_bid_yn=None, -): - qt_id = uuid.uuid4() - async with engine.begin() as conn: - await conn.execute( - text( - "INSERT INTO quotations " - "(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, " - " round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn) VALUES " - "(:qt_id, :user_id, :qt_setting_id, :version_id, :name, :number, :type, :status, " - " :round, 0, :start_time, :end_time, false, :pref, :eq)" - ), - { - "qt_id": qt_id, "user_id": uuid.uuid4(), "qt_setting_id": uuid.uuid4(), - "version_id": uuid.uuid4(), "name": "견적", "number": number, - "type": QuotationType.REQUOTE.value, "status": status, "round": round_, - "start_time": start_time, "end_time": end_time, - "pref": preferred_sp_yn, "eq": equal_bid_yn, - }, - ) - return qt_id - - -async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None): - async with engine.begin() as conn: - await conn.execute( - text( - "INSERT INTO sessions " - "(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, " - " target_price, status, bid_price, end_time) VALUES " - "(:session_id, :quotation_id, :item_id, :supplier_id, :qt_number, :qt_round, :qt_type, " - " 0, :status, :bid_price, :end_time)" - ), - { - "session_id": uuid.uuid4(), "quotation_id": qt_id, "item_id": uuid.uuid4(), - "supplier_id": supplier_id or uuid.uuid4(), "qt_number": "Q", "qt_round": 1, - "qt_type": QuotationType.REQUOTE.value, "status": status, - "bid_price": bid_price, "end_time": PAST, - }, - ) - - -async def _rounds(engine, number): - """체인(number)의 (round, status, end_time, start_time) 목록 — round 오름차순.""" - async with engine.begin() as conn: - return (await conn.execute( - text("SELECT round, status, start_time, end_time FROM quotations " - "WHERE number = :n ORDER BY round"), - {"n": number}, - )).all() - - -# ----- #2 동시 이중 마감 가드 ----- async def test_concurrent_close_creates_only_one_next_round(clean): - """같은 견적을 5번 동시에 close_and_decide 해도 다음 라운드는 정확히 1개만 생성된다.""" + """검증: 같은 견적(전원 미참여)을 5번 동시에 close_and_decide. + 기대결과: 재생성은 1번만(REGENERATED=1), 체인은 [1,2] — 이중 재생성/충돌 없음.""" engine = clean number = "C-CONCURRENT" qt = await _seed_quotation(engine, number=number, round_=1, status=QuotationStatus.IN_PROGRESS.value) @@ -104,9 +51,9 @@ async def test_concurrent_close_creates_only_one_next_round(clean): assert round_numbers == [1, 2], f"체인은 [1,2] 여야 함(중복/충돌 없음), 실제 {round_numbers}" -# ----- #3 차수 + #6 최소 협상기간 하한 ----- async def test_next_round_numbering_and_min_duration(clean): - """다음 라운드 round = 최신+1, 협상기간이 0이어도 최소 하한(MIN_REGEN_DURATION)이 적용된다.""" + """검증: 협상기간이 0인 견적을 미참여로 재생성. + 기대결과: 체인 [1,2](round=최신+1), 새 라운드 협상기간 ≥ MIN_REGEN_DURATION(즉시 재마감 방지).""" engine = clean number = "C-DURATION" # start==end (협상기간 0) → 하한이 적용되지 않으면 새 라운드도 0 길이가 된다 @@ -129,10 +76,9 @@ async def test_next_round_numbering_and_min_duration(clean): ) -# ----- #4 재생성 사유 집계: 단독낙찰 이전 라운드를 미참여로 오집계하지 않음 ----- async def test_awarded_prior_round_not_counted_as_no_show(clean): - """체인에 '단독낙찰'(preferred_sp_yn=True) 이전 라운드가 있어도, 이후 라운드의 미참여 재생성 예산을 소진하지 않는다. - (구버전: equal_bid_yn=False 인 단독낙찰 라운드를 미참여로 세어 round2 재생성이 막혔다.)""" + """검증: round1=단독낙찰 + round2=전원 미참여 인 체인에서 round2 를 마감. + 기대결과: REGENERATED, 체인 [1,2,3] — 단독낙찰 라운드를 '미참여'로 오집계해 재생성을 막지 않는다.""" engine = clean number = "C-AWARDED-PRIOR" # round 1: 단독낙찰로 마감(preferred_sp_yn=True). 수동 재생성 등으로 체인이 이어진 상황을 가정. @@ -155,10 +101,9 @@ async def test_awarded_prior_round_not_counted_as_no_show(clean): assert round_numbers == [1, 2, 3], f"round 3 이 생성돼야 함, 실제 {round_numbers}" -# ----- #4 대비: 실제 미참여 이전 라운드는 예산을 소진(한도 1) ----- async def test_no_show_prior_round_consumes_budget(clean): - """이전 라운드가 '미참여 재생성'(preferred_sp_yn=False, equal_bid_yn=False)이면 예산(1)을 소진 → - 다음 라운드의 미참여는 재생성 없이 그냥 마감된다.""" + """검증: round1=미참여 재생성 + round2=전원 미참여 인 체인에서 round2 를 마감. + 기대결과: CLOSED, 체인 [1,2] — 미참여 재생성 한도(1) 소진돼 재생성 없이 그냥 마감(round3 없음).""" engine = clean number = "C-NOSHOW-PRIOR" # round 1: 미참여로 마감(양성 표식) → no_part 예산 1 소진 @@ -176,3 +121,60 @@ async def test_no_show_prior_round_consumes_budget(clean): rounds = await _rounds(engine, number) assert outcome == CloseOutcome.CLOSED, f"미참여 예산 소진 → 그냥 마감이어야 함, 실제 {outcome}" assert [r.round for r in rounds] == [1, 2], "재생성되면 안 됨(round 3 없음)" + + +# ===== 헬퍼 (위 테스트들이 쓰는 도우미. 세션 상태·마감 표식을 SQL 로 직접 세팅) ===== +async def _seed_quotation( + engine, *, number, round_, status, start_time=PAST, end_time=PAST, + preferred_sp_yn=None, equal_bid_yn=None, +): + """견적 1건 시드. number/round_ 로 체인을, preferred_sp_yn·equal_bid_yn 으로 '이전 라운드가 어떻게 마감됐는지'를 만든다.""" + qt_id = uuid.uuid4() + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO quotations " + "(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, " + " round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn) VALUES " + "(:qt_id, :user_id, :qt_setting_id, :version_id, :name, :number, :type, :status, " + " :round, 0, :start_time, :end_time, false, :pref, :eq)" + ), + { + "qt_id": qt_id, "user_id": uuid.uuid4(), "qt_setting_id": uuid.uuid4(), + "version_id": uuid.uuid4(), "name": "견적", "number": number, + "type": QuotationType.REQUOTE.value, "status": status, "round": round_, + "start_time": start_time, "end_time": end_time, + "pref": preferred_sp_yn, "eq": equal_bid_yn, + }, + ) + return qt_id + + +async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None): + """세션 1건 시드(공급사 협상 1건).""" + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO sessions " + "(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, " + " target_price, status, bid_price, end_time) VALUES " + "(:session_id, :quotation_id, :item_id, :supplier_id, :qt_number, :qt_round, :qt_type, " + " 0, :status, :bid_price, :end_time)" + ), + { + "session_id": uuid.uuid4(), "quotation_id": qt_id, "item_id": uuid.uuid4(), + "supplier_id": supplier_id or uuid.uuid4(), "qt_number": "Q", "qt_round": 1, + "qt_type": QuotationType.REQUOTE.value, "status": status, + "bid_price": bid_price, "end_time": PAST, + }, + ) + + +async def _rounds(engine, number): + """체인(number)의 (round, status, start_time, end_time) 목록 — round 오름차순.""" + async with engine.begin() as conn: + return (await conn.execute( + text("SELECT round, status, start_time, end_time FROM quotations " + "WHERE number = :n ORDER BY round"), + {"n": number}, + )).all() diff --git a/negodata/backend/tests/test_company_scope.py b/negodata/backend/tests/test_company_scope.py new file mode 100644 index 0000000..94aa93e --- /dev/null +++ b/negodata/backend/tests/test_company_scope.py @@ -0,0 +1,140 @@ +"""회사 스코프(멀티테넌트) — 회사 소유 자원은 '내 회사 것'만 보이고, 남의 회사 것은 막힌다(보안 회귀 방지). + +회사 A 자원을 만들어 두고 회사 B 유저 토큰으로 접근하면 '없음'으로 막히는지 확인한다. +막힘 코드: 견적 1500 / 상품 1300 / 협력사 1400. 견적 하위(세션·상태·결과·카드)도 견적 통해 1500. +견적세팅만 예외 — 회사가 아니라 '유저' 스코프라, 같은 회사라도 다른 유저면 못 본다(1600). +""" +import uuid +from datetime import datetime + +from sqlalchemy import text + +from common.enums import QuotationStatus, QuotationType, SessionStatus + +PAST = datetime(2020, 1, 1) +FUTURE = datetime(2999, 1, 1) + + +# ----- 견적 ----- +async def test_quotation_hidden_across_company(client, auth_headers, other_company_id, db_engine): + """검증: 회사A 견적을 A·B 유저가 각각 단건 조회. + 기대결과: A는 success=True / B는 code=1500(없는 것처럼 막힘).""" + ha = await auth_headers("qA") + qt = await _seed_quotation(db_engine, await _user_id(db_engine, "qA")) + + assert (await client.get(f"/v1/quotation/{qt}", headers=ha)).json()["result"]["success"] is True + hb = await auth_headers("qB", other_company_id) + assert (await client.get(f"/v1/quotation/{qt}", headers=hb)).json()["result"]["code"] == 1500 + + +async def test_quotation_list_is_company_scoped(client, auth_headers, other_company_id, db_engine): + """검증: 회사A만 견적을 가진 상태에서 A·B 유저가 목록 조회. + 기대결과: A 목록 total≥1 / B 목록 total=0.""" + ha = await auth_headers("qlA") + await _seed_quotation(db_engine, await _user_id(db_engine, "qlA"), number="Q-LIST-A") + + assert (await client.get("/v1/quotation/list", headers=ha)).json()["total"] >= 1 + hb = await auth_headers("qlB", other_company_id) + assert (await client.get("/v1/quotation/list", headers=hb)).json()["total"] == 0 + + +async def test_quotation_subresources_hidden_across_company(client, auth_headers, other_company_id, db_engine): + """검증: 회사A 견적의 하위자원(세션·상태·결과·카드)을 회사B 유저가 조회. + 기대결과: 넷 다 code=1500 으로 막힘 (같은 견적을 A 는 정상 조회).""" + ha = await auth_headers("qsA") + qt = await _seed_quotation(db_engine, await _user_id(db_engine, "qsA"), number="Q-SUB") + + hb = await auth_headers("qsB", other_company_id) + for path in (f"/v1/quotation/{qt}/sessions", f"/v1/quotation/{qt}/status", + f"/v1/quotation/{qt}/result", f"/v1/quotation/{qt}/cards"): + assert (await client.get(path, headers=hb)).json()["result"]["code"] == 1500, path + assert (await client.get(f"/v1/quotation/{qt}/status", headers=ha)).json()["result"]["success"] is True + + +# ----- 상품(item) ----- +async def test_item_hidden_across_company(client, auth_headers, other_company_id): + """검증: 회사A 상품을 회사B 유저가 목록·단건 조회. + 기대결과: 목록 total=0, 단건 code=1300(ITEM_NOT_FOUND).""" + ha = await auth_headers("iA") + a_item = (await client.post("/v1/item/create", json={"name": "A상품"}, headers=ha)).json()["item"]["item_id"] + + hb = await auth_headers("iB", other_company_id) + assert (await client.get("/v1/item/list", headers=hb)).json()["total"] == 0 + assert (await client.get(f"/v1/item/{a_item}", headers=hb)).json()["result"]["code"] == 1300 + + +# ----- 협력사(supplier) ----- +async def test_supplier_hidden_across_company(client, auth_headers, other_company_id): + """검증: 회사A 협력사를 회사B 유저가 목록·단건 조회. + 기대결과: 목록 total=0, 단건 code=1400(SUPPLIER_NOT_FOUND).""" + ha = await auth_headers("sA") + a_sup = (await client.post("/v1/supplier/create", json={"name": "A협력사", "code": "SA"}, headers=ha)).json()["supplier"]["supplier_id"] + + hb = await auth_headers("sB", other_company_id) + assert (await client.get("/v1/supplier/list", headers=hb)).json()["total"] == 0 + assert (await client.get(f"/v1/supplier/{a_sup}", headers=hb)).json()["result"]["code"] == 1400 + + +# ----- 대시보드 ----- +async def test_dashboard_is_company_scoped(client, auth_headers, other_company_id, db_engine): + """검증: 회사A만 진행중 견적을 보유. A·B 유저가 각각 대시보드 요약 조회. + 기대결과: A 는 company.in_progress≥1 / B 는 0 (타사 견적이 내 회사 집계에 안 섞임).""" + ha = await auth_headers("dA") + await _seed_quotation(db_engine, await _user_id(db_engine, "dA"), number="Q-DASH") + + assert (await client.get("/v1/dashboard/summary", headers=ha)).json()["company"]["in_progress"] >= 1 + hb = await auth_headers("dB", other_company_id) + assert (await client.get("/v1/dashboard/summary", headers=hb)).json()["company"]["in_progress"] == 0 + + +# ----- 견적세팅(회사 아님 — '유저' 스코프) ----- +async def test_quotation_setting_is_user_scoped(client, auth_headers): + """검증: 유저A 견적세팅을 '같은 회사 다른 유저' B 가 목록/수정 시도. + 기대결과: B 목록엔 안 보이고(total=0), 수정은 code=1600(내 소유 아님) — 견적세팅은 유저 단위.""" + ha = await auth_headers("stA") + a_setting = (await client.post( + "/v1/quotation-setting/create", json={"target_margin_rate": 0.15}, headers=ha + )).json()["setting"]["qt_setting_id"] + + hb = await auth_headers("stB") # 같은 회사(company_id 기본), 다른 유저 + assert (await client.get("/v1/quotation-setting/list", headers=hb)).json()["total"] == 0 + r = await client.patch(f"/v1/quotation-setting/update/{a_setting}", json={"target_margin_rate": 0.2}, headers=hb) + assert r.json()["result"]["code"] == 1600 + + +# ===== 헬퍼 (위 테스트들이 쓰는 도우미) ===== +async def _user_id(engine, login_id): + """auth_headers 로 시드된 유저의 user_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 _seed_quotation(engine, user_id, *, number="Q-SCOPE"): + """작성자=user_id 인 견적 1건 + 세션 1건 시드(진행중).""" + qt_id = uuid.uuid4() + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO quotations " + "(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, " + " round, iteration, start_time, end_time, deleted) VALUES " + "(:qt_id, :uid, :setting, :version, '견적A', :number, :type, :status, 1, 0, :past, :future, false)" + ), + {"qt_id": qt_id, "uid": user_id, "setting": uuid.uuid4(), "version": uuid.uuid4(), + "number": number, "type": QuotationType.REQUOTE.value, + "status": QuotationStatus.IN_PROGRESS.value, "past": PAST, "future": FUTURE}, + ) + await conn.execute( + text( + "INSERT INTO sessions " + "(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, " + " target_price, status, end_time) VALUES " + "(:sid, :qt, :item, :sup, :number, 1, :type, 0, :st, :future)" + ), + {"sid": uuid.uuid4(), "qt": qt_id, "item": uuid.uuid4(), "sup": uuid.uuid4(), + "number": number, "type": QuotationType.REQUOTE.value, + "st": SessionStatus.CREATED.value, "future": FUTURE}, + ) + return qt_id diff --git a/negodata/backend/tests/test_company_user.py b/negodata/backend/tests/test_company_user.py new file mode 100644 index 0000000..04bd3cc --- /dev/null +++ b/negodata/backend/tests/test_company_user.py @@ -0,0 +1,88 @@ +"""직원 계정 관리(/v1/company/user/*) 테스트 — '최고관리자만' 쓸 수 있고, '자기 회사'만 다뤄지는지 확인. + +- 일반 직원 계정으로는 이 기능을 못 쓴다(HTTP 403 으로 막힘). +- 최고관리자는 자기 회사 직원만 목록에 보이고, 생성도 자기 회사로 된다(남의 회사 직원은 안 보임). +- 로그인 아이디는 전체에서 유일해야 해서, 같은 아이디로 또 만들면 거부된다(코드 1201). +""" +import uuid + +from sqlalchemy import text + +from common.enums import UserRole, UserStatus + + +async def test_regular_user_forbidden_on_owner_endpoints(client, auth_headers): + """검증: 일반 USER 토큰으로 최고관리자 전용 엔드포인트(list·create) 호출. + 기대결과: 둘 다 HTTP 403(RequireOwner 차단).""" + h = await auth_headers("plainuser") # role=USER 기본 + + r = await client.get("/v1/company/user/list", headers=h) + assert r.status_code == 403 + + r = await client.post( + "/v1/company/user/create", json={"id": "x", "password": "p", "name": "n"}, headers=h + ) + assert r.status_code == 403 + + +async def test_owner_lists_only_own_company_users(client, auth_headers, company_id, other_company_id, db_engine): + """검증: 회사A OWNER + A직원 + B직원(타사)을 두고 OWNER 가 유저 목록 조회. + 기대결과: 본인·A직원은 목록에 있고 타사(B) 직원은 없음(회사 스코프).""" + owner_h = await auth_headers("ownerA", role=UserRole.OWNER.value) # 회사 A owner + await _seed_user(db_engine, company_id, "empA") # 같은 회사 직원 + await _seed_user(db_engine, other_company_id, "empB") # 다른 회사 직원 + + r = await client.get("/v1/company/user/list", headers=owner_h) + ids = {u["id"] for u in r.json()["users"]} + assert "ownerA" in ids # 본인 + assert "empA" in ids # 자기 회사 직원 + assert "empB" not in ids # 타사 직원은 안 보임 + + +async def test_owner_creates_user_in_own_company(client, auth_headers): + """검증: OWNER 가 직원 계정을 생성한 뒤 목록 조회. + 기대결과: 생성 success=True, 생성한 유저가 자기 회사 목록에 노출.""" + owner_h = await auth_headers("ownerC", role=UserRole.OWNER.value) + + r = await client.post( + "/v1/company/user/create", + json={"id": "newemp", "password": "pw1234", "name": "직원"}, + headers=owner_h, + ) + assert r.json()["result"]["success"] is True + + r = await client.get("/v1/company/user/list", headers=owner_h) + ids = {u["id"] for u in r.json()["users"]} + assert "newemp" in ids + + +async def test_duplicate_login_id_rejected(client, auth_headers): + """검증: OWNER 가 같은 로그인 ID 로 직원 계정을 2번 생성. + 기대결과: 1번째 success=True, 2번째 success=False, code=1201(ACCOUNT_ALREADY_EXIST).""" + owner_h = await auth_headers("ownerD", role=UserRole.OWNER.value) + + r1 = await client.post( + "/v1/company/user/create", json={"id": "dup", "password": "pw1234", "name": "n"}, headers=owner_h + ) + assert r1.json()["result"]["success"] is True + + r2 = await client.post( + "/v1/company/user/create", json={"id": "dup", "password": "pw5678", "name": "n2"}, headers=owner_h + ) + body = r2.json() + assert body["result"]["success"] is False + assert body["result"]["code"] == 1201 + + +# ===== 헬퍼 (위 테스트들이 쓰는 도우미) ===== +async def _seed_user(engine, company_id, login_id, *, role=UserRole.USER.value): + """로그인 안 하는 소속 직원 시드(목록 스코프 확인용). 비번은 임의값.""" + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO users (user_id, company_id, id, password, name, status, role, last_accessed_at) " + "VALUES (:uid, :cid, :id, 'x', 'n', :status, :role, now())" + ), + {"uid": uuid.uuid4(), "cid": uuid.UUID(company_id), "id": login_id, + "status": UserStatus.ACTIVE.value, "role": role}, + ) diff --git a/negodata/backend/tests/test_features.py b/negodata/backend/tests/test_features.py index 99e599b..f6a2e12 100644 --- a/negodata/backend/tests/test_features.py +++ b/negodata/backend/tests/test_features.py @@ -1,29 +1,22 @@ -"""supplier / quotation_setting / quotation 슬라이스 런타임 스모크. +"""협력사·견적세팅·견적을 '만들고 → 목록/단건으로 다시 조회'하는 기본 동작 확인. -create(재조회로 created_at 적재) + list + get 경로를 라이브 DB 로 확인한다. +만든 뒤 다시 읽어와, 서버가 자동으로 채우는 값(생성시각 등)이 제대로 들어갔는지까지 본다. 로그인은 auth_headers. """ import uuid from common.enums import QuotationStatus, QuotationType -async def _headers(client, company_id, login_id): - await client.post( - "/v1/auth/create", - json={"id": login_id, "password": "pw1234", "company_id": company_id, "name": "n"}, - ) - r = await client.post("/v1/auth/login", json={"id": login_id, "password": "pw1234"}) - return {"Authorization": f"Bearer {r.json()['access_token']}"} - - -async def test_supplier_crud(client, company_id): - h = await _headers(client, company_id, "supuser") +async def test_supplier_crud(client, auth_headers): + """검증: 협력사 생성 후 목록·단건 조회. + 기대결과: 생성 success=True, 목록 total=1, 단건 supplier_id 일치, created_at 적재.""" + h = await auth_headers("supuser") r = await client.post("/v1/supplier/create", json={"name": "공급사A", "code": "S1"}, headers=h) body = r.json() assert body["result"]["success"] is True sup = body["supplier"] assert sup["name"] == "공급사A" - assert sup["created_at"] # 재조회 픽스: 서버 기본값 적재 확인 + assert sup["created_at"] # 재조회로 서버 기본값 적재 확인 sid = sup["supplier_id"] r = await client.get("/v1/supplier/list", headers=h) @@ -33,8 +26,10 @@ async def test_supplier_crud(client, company_id): assert r.json()["supplier"]["supplier_id"] == sid -async def test_quotation_setting_crud(client, company_id): - h = await _headers(client, company_id, "qsuser") +async def test_quotation_setting_crud(client, auth_headers): + """검증: 견적 세팅 생성(마진율 0.15) 후 목록 조회. + 기대결과: success=True, target_margin_rate=0.15, card_count 기본 3, 목록 total≥1.""" + h = await auth_headers("qsuser") r = await client.post("/v1/quotation-setting/create", json={"target_margin_rate": 0.15}, headers=h) body = r.json() assert body["result"]["success"] is True @@ -47,9 +42,10 @@ async def test_quotation_setting_crud(client, company_id): assert r.json()["total"] >= 1 -async def test_quotation_create(client, company_id): - h = await _headers(client, company_id, "qtuser") - # type/status 는 int 코드(QuotationType/QuotationStatus). number 는 서버가 생성하므로 미전송. +async def test_quotation_create(client, auth_headers): + """검증: 견적 생성(number 는 서버 생성) 후 qt_id 로 재조회. + 기대결과: 생성 success=True, 재조회 시 name 일치·created_at 적재, 목록 total≥1.""" + h = await auth_headers("qtuser") body = { "qt_setting_id": str(uuid.uuid4()), "version_id": str(uuid.uuid4()), @@ -61,7 +57,7 @@ async def test_quotation_create(client, company_id): } r = await client.post("/v1/quotation/create", json=body, headers=h) res = r.json() - # 생성 응답은 본문(quotation)을 안 주고 qt_id/session_count 만 반환 → qt_id 로 재조회한다. + # 생성 응답엔 quotation 본문이 없고 qt_id/session_count 만 온다 → qt_id 로 재조회 assert res["result"]["success"] is True qt_id = res["qt_id"] assert qt_id @@ -69,7 +65,7 @@ async def test_quotation_create(client, company_id): r = await client.get(f"/v1/quotation/{qt_id}", headers=h) q = r.json()["quotation"] assert q["name"] == "견적A" - assert q["created_at"] # 재조회로 created_at 적재 확인 + assert q["created_at"] r = await client.get("/v1/quotation/list", headers=h) assert r.json()["total"] >= 1 diff --git a/negodata/backend/tests/test_item.py b/negodata/backend/tests/test_item.py index 619f98d..c994cab 100644 --- a/negodata/backend/tests/test_item.py +++ b/negodata/backend/tests/test_item.py @@ -1,40 +1,12 @@ -"""item 도메인 e2e — CRUD + company 멀티테넌트 스코프 검증. - -실행 전제: PostgreSQL(negodata_db). docker compose up -d 후 python -m pytest. -""" +"""item 도메인 e2e — 상품 CRUD. 회사 스코프(타사 격리)는 test_company_scope.py. 로그인은 auth_headers.""" import uuid -import pytest_asyncio -from sqlalchemy import text -from common.enums import CompanyStatus +async def test_item_crud_flow(client, auth_headers): + """검증: 상품 생성→목록→단건→부분수정→soft삭제 전체 흐름. + 기대결과: 각 단계 success, 부분수정은 준 필드만 변경(나머지 유지), soft삭제 후 목록 total=0.""" + h = await auth_headers("itemuser") - -async def _headers(client, company_id, login_id="itemuser", pw="pw1234"): - await client.post( - "/v1/auth/create", - json={"id": login_id, "password": pw, "company_id": company_id, "name": "n"}, - ) - r = await client.post("/v1/auth/login", json={"id": login_id, "password": pw}) - return {"Authorization": f"Bearer {r.json()['access_token']}"} - - -@pytest_asyncio.fixture -async def other_company_id(db_engine) -> str: - cid = uuid.uuid4() - async with db_engine.begin() as conn: - # status 는 NOT NULL(모델 default 는 ORM 전용이라 raw INSERT 엔 안 먹음) → 명시. - await conn.execute( - text("INSERT INTO companies (company_id, name, status) VALUES (:cid, :name, :status)"), - {"cid": cid, "name": "다른회사", "status": CompanyStatus.ACTIVE.value}, - ) - return str(cid) - - -async def test_item_crud_flow(client, company_id): - h = await _headers(client, company_id) - - # 등록 r = await client.post("/v1/item/create", json={"name": "상품A", "price": 1000, "code": "C1"}, headers=h) assert r.status_code == 200 body = r.json() @@ -42,47 +14,28 @@ async def test_item_crud_flow(client, company_id): item_id = body["item"]["item_id"] assert body["item"]["name"] == "상품A" - # 목록 r = await client.get("/v1/item/list", headers=h) body = r.json() assert body["total"] == 1 and len(body["items"]) == 1 - # 단건 조회 r = await client.get(f"/v1/item/{item_id}", headers=h) assert r.json()["item"]["item_id"] == item_id - # 수정 (부분) + # 부분 수정: 준 필드(price)만 바뀌고 안 준 필드(name)는 유지돼야 한다 r = await client.patch(f"/v1/item/update/{item_id}", json={"price": 2000}, headers=h) assert r.json()["item"]["price"] == 2000 - assert r.json()["item"]["name"] == "상품A" # 미지정 필드 유지 + assert r.json()["item"]["name"] == "상품A" - # 삭제 (soft) - r = await client.delete(f"/v1/item/delete/{item_id}", headers=h) - assert r.json()["result"]["success"] is True - - # 삭제 후 목록 0 - r = await client.get("/v1/item/list", headers=h) - assert r.json()["total"] == 0 + # soft delete → 행은 남지만 목록엔 안 잡힌다 + assert (await client.delete(f"/v1/item/delete/{item_id}", headers=h)).json()["result"]["success"] is True + assert (await client.get("/v1/item/list", headers=h)).json()["total"] == 0 -async def test_item_not_found(client, company_id): - h = await _headers(client, company_id) +async def test_item_not_found(client, auth_headers): + """검증: 존재하지 않는 상품 단건 조회. + 기대결과: success=False, code=1300(ITEM_NOT_FOUND).""" + h = await auth_headers("itemuser") r = await client.get(f"/v1/item/{uuid.uuid4()}", headers=h) body = r.json() assert body["result"]["success"] is False - assert body["result"]["code"] == 1300 # ITEM_NOT_FOUND - - -async def test_item_company_scope(client, company_id, other_company_id): - # 회사 A 가 상품 등록 - ha = await _headers(client, company_id, login_id="userA") - r = await client.post("/v1/item/create", json={"name": "A상품"}, headers=ha) - a_item_id = r.json()["item"]["item_id"] - - # 회사 B 유저는 A 의 상품을 목록/단건에서 볼 수 없다 - hb = await _headers(client, other_company_id, login_id="userB") - r = await client.get("/v1/item/list", headers=hb) - assert r.json()["total"] == 0 - - r = await client.get(f"/v1/item/{a_item_id}", headers=hb) - assert r.json()["result"]["code"] == 1300 # 타사 자원은 ITEM_NOT_FOUND + assert body["result"]["code"] == 1300 diff --git a/negodata/backend/tests/test_notification.py b/negodata/backend/tests/test_notification.py new file mode 100644 index 0000000..a8db585 --- /dev/null +++ b/negodata/backend/tests/test_notification.py @@ -0,0 +1,99 @@ +"""알림함 '읽는' 쪽 테스트 — 목록 조회, 안 읽은 개수, 읽음 처리(하나/전체), 그리고 남의 알림은 안 보이는지. + +'마감하면 알림이 쌓이는지'(쓰는 쪽)는 test_quotation_close_notify 가 본다. 여기선 겹치지 않게 '읽는' 동작만 본다. +알림은 원래 견적 마감 때 생기지만, 여기선 테스트를 위해 알림 행을 DB 에 직접 넣는다. +""" +import json +import uuid + +from sqlalchemy import text + +from common.enums import NotificationType + + +async def test_list_and_unread(client, auth_headers, db_engine): + """검증: 내 알림 2건을 시드하고 인박스 목록 조회. + 기대결과: total=2, unread=2, 안읽음이라 read_at 없음(None).""" + h = await auth_headers("notilist") + uid = await _user_id(db_engine, "notilist") + await _seed_notification(db_engine, uid) + await _seed_notification(db_engine, uid, ntype=NotificationType.REGENERATED.value) + + r = await client.get("/v1/notification/list", headers=h) + body = r.json() + assert body["result"]["success"] is True + assert body["total"] == 2 + assert body["unread"] == 2 + assert len(body["notifications"]) == 2 + # 안읽음은 read_at=None → RemoveNoneResponse 가 키를 제거하므로 .get() 으로 확인 + assert all(n.get("read_at") is None for n in body["notifications"]) + + +async def test_inbox_is_user_scoped(client, auth_headers, db_engine): + """검증: 내 알림 1건 + 남의 알림 1건을 시드하고 내 인박스 조회. + 기대결과: total=1, unread=1 — 내 것만 보인다(남의 알림 제외).""" + h = await auth_headers("notiscope") + me = await _user_id(db_engine, "notiscope") + await _seed_notification(db_engine, me) # 내 알림 + await _seed_notification(db_engine, uuid.uuid4()) # 남의 알림(안 보여야 함) + + r = await client.get("/v1/notification/list", headers=h) + body = r.json() + assert body["total"] == 1 and body["unread"] == 1 + + +async def test_read_all_clears_unread(client, auth_headers, db_engine): + """검증: 안읽음 2건 상태에서 read-all 호출 후 다시 목록 조회. + 기대결과: unread=0, 목록엔 그대로 남고(total=2) 모든 read_at 채워짐.""" + h = await auth_headers("notireadall") + uid = await _user_id(db_engine, "notireadall") + await _seed_notification(db_engine, uid) + await _seed_notification(db_engine, uid) + + r = await client.post("/v1/notification/read-all", headers=h) + assert r.json()["result"]["success"] is True + + r = await client.get("/v1/notification/list", headers=h) + body = r.json() + assert body["total"] == 2 and body["unread"] == 0 + assert all(n["read_at"] is not None for n in body["notifications"]) + + +async def test_read_one_decrements_unread(client, auth_headers, db_engine): + """검증: 안읽음 2건 중 1건만 읽음 처리. + 기대결과: unread 2 → 1.""" + h = await auth_headers("notireadone") + uid = await _user_id(db_engine, "notireadone") + await _seed_notification(db_engine, uid) + await _seed_notification(db_engine, uid) + + r = await client.get("/v1/notification/list", headers=h) + target_id = r.json()["notifications"][0]["notification_id"] + + r = await client.post(f"/v1/notification/{target_id}/read", headers=h) + assert r.json()["result"]["success"] is True + + r = await client.get("/v1/notification/list", headers=h) + assert r.json()["unread"] == 1 + + +# ===== 헬퍼 (위 테스트들이 쓰는 도우미) ===== +async def _user_id(engine, login_id): + """auth_headers 로 시드된 유저의 user_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 _seed_notification(engine, user_id, *, ntype=NotificationType.SUCCESS.value, data=None): + """안읽음(read_at NULL) 알림 1건 시드.""" + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO notifications (notification_id, user_id, type, data, read_at) " + "VALUES (:nid, :uid, :type, CAST(:data AS JSONB), NULL)" + ), + {"nid": uuid.uuid4(), "uid": user_id, "type": ntype, + "data": json.dumps(data or {"qt_name": "견적A"})}, + ) diff --git a/negodata/backend/tests/test_quotation_close_notify.py b/negodata/backend/tests/test_quotation_close_notify.py new file mode 100644 index 0000000..95d0ce9 --- /dev/null +++ b/negodata/backend/tests/test_quotation_close_notify.py @@ -0,0 +1,224 @@ +"""견적 마감(close_and_decide) 테스트 — 마감하면 상황별로 결과가 맞게 판정되고, 그 결과가 작성자에게 알림으로 남는지 확인. + +핵심은 '재견적(다음 라운드 재생성)이 나오는 경우 vs 안 나오는 경우'의 구분이다. +각 경우에 (1) 판정이 맞고 (2) 작성자 알림함에 알맞은 알림 1건이 남는지 본다: + · 단독 최저가 → 낙찰 (SUCCESS) [재견적 X] + · 협상 거부 → 결렬 (FAILURE, reason=rejected) [재견적 X] + · 동가/미참여 + 한도 남음 → 재생성 (REGENERATED) [재견적 O] + · 동가/미참여 + 한도 소진 → 결렬 (FAILURE, reason=closed) [재견적 X] +재생성 한도: 사유(동가·미참여)별로 한 체인(같은 견적번호)에서 각 1번까지만. + +공급사의 협상 결과(협상완료/거부/입찰가)는 협상 화면에서만 생기는 값이라 API 로 못 만든다 → SQL 로 직접 넣는다. +마감 판정 로직 자체를 더 깊게 파는 건 test_scheduler·test_close_and_decide_fixes. +""" +import uuid +from datetime import datetime + +import pytest_asyncio +from sqlalchemy import text + +from common.enums import CloseOutcome, NotificationType, QuotationStatus, QuotationType, SessionStatus +from crud.quotation_crud import QuotationCRUD +from services.quotation_service import QuotationService + +PAST = datetime(2020, 1, 1) + + +@pytest_asyncio.fixture +async def clean(db_engine): + """conftest 는 notifications 를 비우지 않는다 → 알림 단언이 다른 테스트에 안 흔들리게 여기서 함께 비운다.""" + async with db_engine.begin() as conn: + await conn.execute(text("TRUNCATE TABLE sessions, quotations, notifications RESTART IDENTITY CASCADE")) + return db_engine + + +# ----- 재견적 X (낙찰·거부) ----- +async def test_award_notifies_success(clean): + """검증: 협상완료 세션 2건(입찰 100·200) — 단독 최저가로 마감. + 기대결과: 재견적 X, 판정 = 낙찰(AWARDED) + 알림 SUCCESS(winner_price=100=최저가, ref_qt_id=그 견적).""" + engine = clean + user_id = uuid.uuid4() + winner = uuid.uuid4() + qt = await _seed_quotation(engine, user_id=user_id, number="N-AWARD") + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=winner) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=200) + + outcome = await _service().close_and_decide(qt) + + assert outcome == CloseOutcome.AWARDED + notis = await _notifications(engine, user_id) + assert len(notis) == 1 + type_, data, ref = notis[0] + assert type_ == NotificationType.SUCCESS.value + assert data["winner_price"] == 100 + assert str(ref) == str(qt) + + +async def test_rejected_notifies_failure(clean): + """검증: 협상거부 세션만 있는 상태로 마감. + 기대결과: 재견적 X, 판정 = 결렬(CLOSED) + 알림 FAILURE(reason=rejected).""" + engine = clean + user_id = uuid.uuid4() + qt = await _seed_quotation(engine, user_id=user_id, number="N-REJECT") + await _add_session(engine, qt, status=SessionStatus.REJECTED.value) + + outcome = await _service().close_and_decide(qt) + + assert outcome == CloseOutcome.CLOSED + notis = await _notifications(engine, user_id) + assert len(notis) == 1 + type_, data, ref = notis[0] + assert type_ == NotificationType.FAILURE.value + assert data["reason"] == "rejected" + assert str(ref) == str(qt) + + +# ----- 재견적 O (동가·미참여, 한도 남음) ----- +async def test_equal_bid_regenerates(clean): + """검증: 협상완료 세션 2건이 '동가'(둘 다 100), 체인에 동가 재생성 이력 없음(한도 남음). + 기대결과: 재견적 O, 판정 = 재생성(REGENERATED) + 알림 REGENERATED(reason=equal, tied_price=100, next_round=2).""" + engine = clean + user_id = uuid.uuid4() + qt = await _seed_quotation(engine, user_id=user_id, number="N-EQUAL") + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100) + + outcome = await _service().close_and_decide(qt) + + assert outcome == CloseOutcome.REGENERATED + notis = await _notifications(engine, user_id) + assert len(notis) == 1 + type_, data, _ = notis[0] + assert type_ == NotificationType.REGENERATED.value + assert data["reason"] == "equal" + assert data["tied_price"] == 100 + assert data["next_round"] == 2 + + +async def test_no_show_regenerates(clean): + """검증: 전원 미참여(미시작 세션만), 체인에 미참여 재생성 이력 없음(한도 남음). + 기대결과: 재견적 O, 판정 = 재생성(REGENERATED) + 알림 REGENERATED(reason=no_show, next_round=2).""" + engine = clean + user_id = uuid.uuid4() + qt = await _seed_quotation(engine, user_id=user_id, number="N-NOSHOW") + await _add_session(engine, qt, status=SessionStatus.CREATED.value) + await _add_session(engine, qt, status=SessionStatus.CREATED.value) + + outcome = await _service().close_and_decide(qt) + + assert outcome == CloseOutcome.REGENERATED + notis = await _notifications(engine, user_id) + assert len(notis) == 1 + type_, data, _ = notis[0] + assert type_ == NotificationType.REGENERATED.value + assert data["reason"] == "no_show" + assert data["next_round"] == 2 + + +# ----- 재견적 X (동가·미참여지만 한도 소진 → 결렬) ----- +async def test_equal_bid_limit_exhausted_fails(clean): + """검증: 1차가 이미 '동가'로 재생성된 체인(동가 한도 1 소진)에서, 2차도 또 동가로 마감. + 기대결과: 재견적 X — 판정 = 결렬(CLOSED) + 알림 FAILURE(reason=closed).""" + engine = clean + user_id = uuid.uuid4() + # 1차: 동가로 마감돼 2차를 만든 상황(equal_bid_yn=True 가 동가 재생성 표식) → 동가 한도 소진 + await _seed_quotation(engine, user_id=user_id, number="N-EQUAL-LIMIT", round_=1, + status=QuotationStatus.CLOSED.value, equal_bid_yn=True) + # 2차: 또 동가 + qt2 = await _seed_quotation(engine, user_id=user_id, number="N-EQUAL-LIMIT", round_=2) + await _add_session(engine, qt2, status=SessionStatus.DONE.value, bid_price=100) + await _add_session(engine, qt2, status=SessionStatus.DONE.value, bid_price=100) + + outcome = await _service().close_and_decide(qt2) + + assert outcome == CloseOutcome.CLOSED # 동가 한도 소진 → 재생성 없이 결렬 + notis = await _notifications(engine, user_id) + assert len(notis) == 1 + type_, data, ref = notis[0] + assert type_ == NotificationType.FAILURE.value + assert data["reason"] == "closed" + assert str(ref) == str(qt2) + + +async def test_no_show_limit_exhausted_fails(clean): + """검증: 1차가 이미 '미참여'로 재생성된 체인(미참여 한도 1 소진)에서, 2차도 또 전원 미참여로 마감. + 기대결과: 재견적 X — 판정 = 결렬(CLOSED) + 알림 FAILURE(reason=closed).""" + engine = clean + user_id = uuid.uuid4() + # 1차: 미참여로 마감돼 2차를 만든 상황(preferred_sp_yn=False·equal_bid_yn=False 가 미참여 재생성 표식) → 미참여 한도 소진 + await _seed_quotation(engine, user_id=user_id, number="N-NOSHOW-LIMIT", round_=1, + status=QuotationStatus.CLOSED.value, preferred_sp_yn=False, equal_bid_yn=False) + # 2차: 또 전원 미참여 + qt2 = await _seed_quotation(engine, user_id=user_id, number="N-NOSHOW-LIMIT", round_=2) + await _add_session(engine, qt2, status=SessionStatus.CREATED.value) + await _add_session(engine, qt2, status=SessionStatus.CREATED.value) + + outcome = await _service().close_and_decide(qt2) + + assert outcome == CloseOutcome.CLOSED # 미참여 한도 소진 → 재생성 없이 결렬 + notis = await _notifications(engine, user_id) + assert len(notis) == 1 + type_, data, ref = notis[0] + assert type_ == NotificationType.FAILURE.value + assert data["reason"] == "closed" + assert str(ref) == str(qt2) + + +# ===== 헬퍼 (위 테스트들이 쓰는 도우미. 세션 입찰값·이전 라운드 표식을 SQL 로 직접 세팅) ===== +async def _seed_quotation( + engine, *, user_id, number, round_=1, status=QuotationStatus.IN_PROGRESS.value, + preferred_sp_yn=None, equal_bid_yn=None, +): + """견적 1건 시드(작성자=user_id). preferred_sp_yn·equal_bid_yn 으로 '이전 라운드가 어떤 사유로 재생성됐는지'를 표식한다 + (동가 재생성=equal_bid_yn True / 미참여 재생성=preferred_sp_yn False AND equal_bid_yn False).""" + qt_id = uuid.uuid4() + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO quotations " + "(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, " + " round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn) VALUES " + "(:qt_id, :user_id, :qt_setting_id, :version_id, '견적A', :number, :type, :status, " + " :round, 0, :start_time, :end_time, false, :pref, :eq)" + ), + { + "qt_id": qt_id, "user_id": user_id, "qt_setting_id": uuid.uuid4(), + "version_id": uuid.uuid4(), "number": number, + "type": QuotationType.REQUOTE.value, "status": status, "round": round_, + "start_time": PAST, "end_time": PAST, + "pref": preferred_sp_yn, "eq": equal_bid_yn, + }, + ) + return qt_id + + +async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None): + """세션 1건 시드(공급사 협상 1건). status/bid_price 로 협상완료·거부·입찰가를 만든다.""" + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO sessions " + "(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, " + " target_price, status, bid_price, end_time) VALUES " + "(:session_id, :quotation_id, :item_id, :supplier_id, 'Q', 1, :qt_type, " + " 0, :status, :bid_price, :end_time)" + ), + { + "session_id": uuid.uuid4(), "quotation_id": qt_id, "item_id": uuid.uuid4(), + "supplier_id": supplier_id or uuid.uuid4(), "qt_type": QuotationType.REQUOTE.value, + "status": status, "bid_price": bid_price, "end_time": PAST, + }, + ) + + +async def _notifications(engine, user_id): + """user_id(작성자) 인박스 알림 (type, data, ref_qt_id) — 생성순.""" + async with engine.begin() as conn: + return (await conn.execute( + text("SELECT type, data, ref_qt_id FROM notifications WHERE user_id = :uid ORDER BY created_at"), + {"uid": user_id}, + )).all() + + +def _service(): + return QuotationService(QuotationCRUD()) diff --git a/negodata/backend/tests/test_quotation_create.py b/negodata/backend/tests/test_quotation_create.py new file mode 100644 index 0000000..f8192ce --- /dev/null +++ b/negodata/backend/tests/test_quotation_create.py @@ -0,0 +1,98 @@ +"""견적 생성 — item×supplier 조합마다 세션이 생기고, 목표가가 산정되는지 검증. + +기존 test_features.test_quotation_create 는 item/supplier 없이 '세션 0건' 경로만 본다. +여기선 상품(인터넷최저가)을 시드해 세션 생성 + 목표가 계산(신규=인터넷최저가×(1−수수료))까지 본다. +서비스(create_quotation)를 직접 호출한다 — HTTP/auth 경로(현재 /v1/auth/create 미존재)를 안 타고 생성 로직만 격리. +""" +import uuid +from datetime import datetime + +from sqlalchemy import text + +from common.enums import QuotationType +from crud.quotation_crud import QuotationCRUD +from router.v1.quotation.protocol import Req_CreateQuotation +from services.quotation_service import QuotationService + +FUTURE = datetime(2999, 1, 1) # 마감시각 미래 — 생성 직후 크론에 안 잡히게 + + +async def test_create_builds_sessions_with_target_price(db_engine, company_id): + """검증: 신규견적을 상품2×공급사2로 생성. + 기대결과: success=True, 세션 4개, 각 목표가 = int(인터넷최저가 × (1−0.078)).""" + item1 = await _seed_item(db_engine, company_id, internet_lowest=100_000) + item2 = await _seed_item(db_engine, company_id, internet_lowest=50_000) + suppliers = [uuid.uuid4(), uuid.uuid4()] + + req = Req_CreateQuotation( + qt_setting_id=uuid.uuid4(), # FK 미설정 — 세팅 없으면 율 0(신규는 인터넷최저가만 쓰므로 무관) + name="신규견적A", + type=QuotationType.NEW_QUOTE.value, + end_time=FUTURE, + item_ids=[item1, item2], + supplier_ids=suppliers, + ) + res = await _service().create_quotation(str(uuid.uuid4()), req) + + assert res.result.success is True + assert res.session_count == 4 # 상품 2 × 공급사 2 + + fee = QuotationService.INTERNET_AVERAGE_FEE + expected = {item1: int(100_000 * (1 - fee)), item2: int(50_000 * (1 - fee))} + rows = await _session_target_prices(db_engine, res.qt_id) + assert len(rows) == 4 + for item_id, target_price in rows: + assert target_price == expected[item_id] # 상품별 목표가가 공급사 수만큼 동일 + + +async def test_create_without_price_fails(db_engine, company_id): + """검증: 가격 후보(인터넷최저가·md 등)가 전무한 상품으로 견적 생성. + 기대결과: 목표가 산정 불가로 success=False, 세션 0건(미생성).""" + item = await _seed_item(db_engine, company_id, internet_lowest=None) + + req = Req_CreateQuotation( + qt_setting_id=uuid.uuid4(), + name="가격없음", + type=QuotationType.NEW_QUOTE.value, + end_time=FUTURE, + item_ids=[item], + supplier_ids=[uuid.uuid4()], + ) + res = await _service().create_quotation(str(uuid.uuid4()), req) + + assert res.result.success is False # QUOTATION_TARGET_PRICE_UNAVAILABLE + rows = await _session_target_prices(db_engine, res.qt_id) if res.qt_id else [] + assert rows == [] + + +# ===== 헬퍼 (위 테스트들이 쓰는 도우미) ===== +def _service(): + return QuotationService(QuotationCRUD()) + + +async def _seed_item(engine, company_id, *, internet_lowest): + """상품 1건 시드(인터넷최저가만). category_type·internet_lowest_price_yn 은 NOT NULL — + ORM default 는 raw INSERT 에 안 먹으므로 명시한다(conftest companies.status 와 같은 이유).""" + 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, internet_lowest_price) VALUES " + "(:item_id, :company_id, :user_id, '상품', 1, false, :ilp)" + ), + {"item_id": item_id, "company_id": uuid.UUID(company_id), + "user_id": uuid.uuid4(), "ilp": internet_lowest}, + ) + return item_id + + +async def _session_target_prices(engine, qt_id): + """생성된 견적의 (item_id -> target_price) 매핑.""" + async with engine.begin() as conn: + rows = (await conn.execute( + text("SELECT item_id, target_price FROM sessions WHERE quotation_id = :qt"), + {"qt": qt_id}, + )).all() + return rows diff --git a/negodata/backend/tests/test_scheduler.py b/negodata/backend/tests/test_scheduler.py index 2d26d49..0abc014 100644 --- a/negodata/backend/tests/test_scheduler.py +++ b/negodata/backend/tests/test_scheduler.py @@ -1,8 +1,15 @@ -"""scheduler 잡 e2e — '대상 선정'(어떤 견적을 고르나) + close_and_decide 위임 결과 검증. +"""scheduler(마감 크론 잡) e2e 테스트 — 어떤 견적을 고르고, 마감하면 결과가 어떻게 나오는지 확인. -실행 전제: PostgreSQL(negodata_db). docker compose up -d 후 python -m pytest tests/test_scheduler.py. -잡은 HTTP 엔드포인트가 없어 scheduler.jobs 함수를 직접 호출한다(앱과 같은 DB_SESSION_MNG 사용 → mock 불필요). -세션 상태(DONE/REJECTED/bid_price 등)는 협상 프론트가 만드는 값이라 API 로 못 만든다 → SQL 로 직접 시드. +용어: 견적 = 한 건의 입찰 공고 / 세션 = 그 견적에 참여한 공급사별 협상 1건 / 마감 = 견적을 닫고 낙찰자를 정함. + +마감을 자동으로 돌리는 크론 잡이 2개 있다(scheduler/jobs.py): + · 잡① close_expired_quotations : 마감시각(end_time)이 지났는데 아직 안 닫힌 견적을 닫는다. + · 잡② close_negotiated_quotations : 참여 세션이 전부 끝난(협상 종료) 견적을 닫는다. +두 잡 모두, 고른 견적마다 close_and_decide() 를 불러 결과(낙찰 / 다음 라운드 재생성 / 그냥 마감)를 정한다. + +이 파일은 그 두 잡이 (1) 마감할 견적을 올바로 고르는지, (2) 마감 결과가 맞는지 확인한다. +잡에는 HTTP 엔드포인트가 없어 scheduler.jobs 함수를 직접 부른다(앱과 같은 DB 연결을 써서 mock 불필요). +세션 상태(협상완료/거부/입찰가 등)는 협상 화면에서만 생기는 값이라 API 로 못 만든다 → SQL 로 직접 넣는다. """ import asyncio import uuid @@ -16,21 +23,144 @@ from sqlalchemy import text from common.enums import QuotationStatus, QuotationType, SessionStatus from scheduler import jobs -PAST = datetime(2020, 1, 1) # 마감시각 지남(잡① 대상) -FUTURE = datetime(2999, 1, 1) # 마감시각 미래(잡① 제외) +PAST = datetime(2020, 1, 1) # 마감시각이 이미 지난 시점(잡①의 마감 대상) +FUTURE = datetime(2999, 1, 1) # 마감시각이 아직 안 온 시점(잡①에서 제외) @pytest_asyncio.fixture async def clean(db_engine): - """conftest 의 db_engine 은 quotations 만 비우고 sessions 는 안 비운다(FK 미설정 → CASCADE 대상 아님). - 잡②(close_negotiated)는 전체 견적을 스캔하므로 다른 테스트가 남긴 세션이 결과를 흔든다 → sessions 도 비워 격리.""" + """각 테스트 시작 전에 quotations·sessions 를 모두 비워 깨끗한 상태로 만든다. + + 공용 db_engine 픽스처는 quotations 만 비운다. 그런데 잡②는 '세션이 다 끝난 견적'을 전체 견적에서 찾으므로, + 앞선 다른 테스트가 남긴 세션이 남아 있으면 엉뚱한 견적이 대상에 끼어든다 → 그래서 여기서 sessions 까지 비운다. + """ async with db_engine.begin() as conn: await conn.execute(text("TRUNCATE TABLE sessions, quotations RESTART IDENTITY CASCADE")) return db_engine -# ----- 시드 헬퍼 (FK 미설정이라 user/item/supplier 없이 임의 uuid 로 충분) ----- +async def test_close_expired_picks_only_due_and_open(clean): + """검증: 잡①을 돌린다. 견적 4개를 섞어둔다 — + ① 마감시각 지난 미마감 ② 마감시각 안 지난 것 ③ 이미 마감된 것 ④ 삭제된 것. + 기대결과: ①(due) 1건만 새로 마감(CLOSED)되고, ②③④ 는 그대로 둔다.""" + engine = clean + due = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=PAST) # 마감시각 지남 + 미마감 → 마감 대상 + future = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE) # 마감시각 안 지남 → 제외 + already = await _add_quotation(engine, status=QuotationStatus.CLOSED.value, end_time=PAST) # 이미 마감 → 제외 + deleted = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=PAST, deleted=True) # 삭제됨 → 제외 + + n = await jobs.close_expired_quotations() + + assert n == 1 # 새로 마감된 건 due 1건뿐 + assert (await _quotation_row(engine, due)).status == QuotationStatus.CLOSED.value + assert (await _quotation_row(engine, future)).status == QuotationStatus.IN_PROGRESS.value # 마감시각 전이라 그대로 + assert (await _quotation_row(engine, already)).status == QuotationStatus.CLOSED.value # 원래부터 마감 + assert (await _quotation_row(engine, deleted)).status == QuotationStatus.IN_PROGRESS.value # 삭제분은 건드리지 않음 + + +async def test_close_negotiated_picks_when_all_sessions_ended(clean): + """검증: 잡②를 돌린다. 견적 3개를 섞어둔다 — + ① 세션이 전부 끝난 것 ② 아직 진행중인 세션이 있는 것 ③ 세션이 아예 없는 것. + 기대결과: ①(세션 다 끝남)만 마감(CLOSED)되고, ②③ 은 제외.""" + engine = clean + # ① 세션이 전부 끝남(거부로 종료) → 마감 대상 + ended = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE) + await _add_session(engine, ended, status=SessionStatus.REJECTED.value) + # ② 아직 진행중인 세션이 하나라도 있음 → 제외 + pending = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE) + await _add_session(engine, pending, status=SessionStatus.DONE.value, bid_price=100) + await _add_session(engine, pending, status=SessionStatus.IN_PROGRESS.value) + # ③ 세션이 아예 없음 → 제외(끝났다고 볼 세션 자체가 없음) + no_session = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE) + + await jobs.close_negotiated_quotations() + + assert (await _quotation_row(engine, ended)).status == QuotationStatus.CLOSED.value + assert (await _quotation_row(engine, pending)).status == QuotationStatus.IN_PROGRESS.value + assert (await _quotation_row(engine, no_session)).status == QuotationStatus.IN_PROGRESS.value + + +async def test_award_single_lowest(clean): + """검증: 두 공급사가 각각 100·200 으로 협상완료(DONE)한, 마감시각 지난 견적을 잡①로 마감. + 기대결과: 마감(CLOSED)되고, 더 싼 100 공급사가 단독 낙찰(낙찰 있음 + 낙찰자=그 공급사).""" + engine = clean + qt = await _add_quotation(engine, end_time=PAST) + winner = uuid.uuid4() + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=winner) # 더 싼 쪽 + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=200) + + await jobs.close_expired_quotations() + + row = await _quotation_row(engine, qt) + assert row.status == QuotationStatus.CLOSED.value + assert row.preferred_sp_yn is True # 낙찰자 있음 + assert str(row.preferred_sp_id) == str(winner) # 최저가가 단독이라 그 공급사로 확정 + + +async def test_rejected_just_closes(clean): + """검증: 입찰 없이 '거부'만 있는, 마감시각 지난 견적을 잡①로 마감. + 기대결과: 마감(CLOSED)되지만 낙찰자는 없음(살 사람이 없으니 그냥 닫힘).""" + engine = clean + qt = await _add_quotation(engine, end_time=PAST) + await _add_session(engine, qt, status=SessionStatus.REJECTED.value) # 입찰가 없이 거부만 + + await jobs.close_expired_quotations() + + row = await _quotation_row(engine, qt) + assert row.status == QuotationStatus.CLOSED.value + assert not row.preferred_sp_yn # 거부뿐이라 낙찰 없이 마감 + + +async def test_scheduler_disabled_without_env(monkeypatch): + """검증: SCHEDULER_ENABLED 환경변수 없이 start_scheduler() 호출. + 기대결과: 스케줄러가 켜지지 않는다(운영에서 실수로 자동 마감이 도는 걸 막는 안전장치).""" + import scheduler + monkeypatch.delenv("SCHEDULER_ENABLED", raising=False) + scheduler._scheduler = None + scheduler.start_scheduler() + assert scheduler._scheduler is None # 환경변수가 1이 아니면 미기동 + + +async def test_scheduler_registers_both_jobs(monkeypatch): + """검증: SCHEDULER_ENABLED=1 로 start_scheduler() 호출. + 기대결과: 마감 잡 2개(close_expired·close_negotiated)가 스케줄에 등록된다.""" + import scheduler + monkeypatch.setenv("SCHEDULER_ENABLED", "1") + scheduler._scheduler = None + scheduler.start_scheduler() + try: + ids = {j.id for j in scheduler._scheduler.get_jobs()} + assert ids == {"close_expired_quotations", "close_negotiated_quotations"} + finally: + scheduler.shutdown_scheduler() + assert scheduler._scheduler is None + + +async def test_scheduler_actually_runs_job_and_closes(clean): + """검증: 스케줄러에 잡을 걸어 실제로 발화시킨다(1초 간격으로). + 기대결과: 스케줄러가 잡을 호출해 마감시각 지난 견적이 몇 초 안에 마감(CLOSED)된다 — '스케줄러→잡→마감' 경로 확인.""" + engine = clean + qt = await _add_quotation(engine, end_time=PAST) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100) + + sched = AsyncIOScheduler(timezone="Asia/Seoul") + sched.add_job(jobs.close_expired_quotations, IntervalTrigger(seconds=1), max_instances=1) + sched.start() + try: + row = None + for _ in range(25): # 잡은 1초 뒤 첫 발화 → 최대 ~5초 동안 0.2초 간격으로 확인 + await asyncio.sleep(0.2) + row = await _quotation_row(engine, qt) + if row.status == QuotationStatus.CLOSED.value: + break + assert row is not None and row.status == QuotationStatus.CLOSED.value # 스케줄러가 잡을 호출해 마감됨 + finally: + sched.shutdown(wait=False) + + +# ===== 헬퍼 (위 테스트들이 쓰는 도우미. FK 미설정이라 user/item/supplier 없이 임의 uuid 로 충분) ===== async def _add_quotation(engine, *, status=QuotationStatus.IN_PROGRESS.value, end_time=PAST, deleted=False): + """견적 1건을 DB 에 직접 넣는다(시드). status/end_time/deleted 로 '대상/제외' 상황을 만든다.""" qt_id = uuid.uuid4() async with engine.begin() as conn: await conn.execute( @@ -52,6 +182,7 @@ async def _add_quotation(engine, *, status=QuotationStatus.IN_PROGRESS.value, en async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None): + """세션(공급사 협상 1건)을 DB 에 직접 넣는다. status/bid_price 로 협상완료·거부·입찰가를 만든다.""" async with engine.begin() as conn: await conn.execute( text( @@ -71,119 +202,9 @@ async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=Non async def _quotation_row(engine, qt_id): + """견적 1건을 다시 읽어온다(마감 후 status·낙찰자 확인용).""" async with engine.begin() as conn: return (await conn.execute( text("SELECT status, preferred_sp_yn, preferred_sp_id FROM quotations WHERE qt_id = :id"), {"id": qt_id}, )).first() - - -# ----- 잡① close_expired_quotations : 대상 선정(마감시각 지난 미마감만) ----- -async def test_close_expired_picks_only_due_and_open(clean): - engine = clean - due = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=PAST) - future = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE) - already = await _add_quotation(engine, status=QuotationStatus.CLOSED.value, end_time=PAST) - deleted = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=PAST, deleted=True) - - n = await jobs.close_expired_quotations() - - assert n == 1 # 마감 대상은 due 1건뿐 - assert (await _quotation_row(engine, due)).status == QuotationStatus.CLOSED.value - assert (await _quotation_row(engine, future)).status == QuotationStatus.IN_PROGRESS.value # 미래 → 안 건드림 - assert (await _quotation_row(engine, already)).status == QuotationStatus.CLOSED.value # 원래부터 CLOSED - assert (await _quotation_row(engine, deleted)).status == QuotationStatus.IN_PROGRESS.value # 삭제분 → 제외 - - -# ----- 잡② close_negotiated_quotations : 대상 선정(전 세션 종결 + 세션 1개+) ----- -async def test_close_negotiated_picks_when_all_sessions_ended(clean): - engine = clean - # 전 세션 종결(거부) → 대상 - ended = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE) - await _add_session(engine, ended, status=SessionStatus.REJECTED.value) - # 진행중 세션 하나라도 있으면 → 제외 - pending = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE) - await _add_session(engine, pending, status=SessionStatus.DONE.value, bid_price=100) - await _add_session(engine, pending, status=SessionStatus.IN_PROGRESS.value) - # 세션 0개 → 제외 - no_session = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE) - - await jobs.close_negotiated_quotations() - - assert (await _quotation_row(engine, ended)).status == QuotationStatus.CLOSED.value - assert (await _quotation_row(engine, pending)).status == QuotationStatus.IN_PROGRESS.value - assert (await _quotation_row(engine, no_session)).status == QuotationStatus.IN_PROGRESS.value - - -# ----- close_and_decide 위임 결과 스모크(잡①을 통해) ----- -async def test_award_single_lowest(clean): - engine = clean - qt = await _add_quotation(engine, end_time=PAST) - winner = uuid.uuid4() - await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=winner) - await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=200) - - await jobs.close_expired_quotations() - - row = await _quotation_row(engine, qt) - assert row.status == QuotationStatus.CLOSED.value - assert row.preferred_sp_yn is True - assert str(row.preferred_sp_id) == str(winner) # 최저가 단독 → 낙찰 확정 - - -async def test_rejected_just_closes(clean): - engine = clean - qt = await _add_quotation(engine, end_time=PAST) - await _add_session(engine, qt, status=SessionStatus.REJECTED.value) # 입찰 없는 거부만 - - await jobs.close_expired_quotations() - - row = await _quotation_row(engine, qt) - assert row.status == QuotationStatus.CLOSED.value - assert not row.preferred_sp_yn # 거부 → 낙찰 없이 그냥 마감 - - -# ----- 스케줄러 와이어링(start_scheduler) : DB 불필요 ----- -async def test_scheduler_disabled_without_env(monkeypatch): - import scheduler - monkeypatch.delenv("SCHEDULER_ENABLED", raising=False) - scheduler._scheduler = None - scheduler.start_scheduler() - assert scheduler._scheduler is None # SCHEDULER_ENABLED != 1 → 미기동 - - -async def test_scheduler_registers_both_jobs(monkeypatch): - import scheduler - monkeypatch.setenv("SCHEDULER_ENABLED", "1") - scheduler._scheduler = None - scheduler.start_scheduler() - try: - ids = {j.id for j in scheduler._scheduler.get_jobs()} - assert ids == {"close_expired_quotations", "close_negotiated_quotations"} - finally: - scheduler.shutdown_scheduler() - assert scheduler._scheduler is None - - -# ----- 스케줄러가 실제로 잡을 호출해 마감까지 가는지(라이브) ----- -async def test_scheduler_actually_runs_job_and_closes(clean): - """스케줄러에 잡을 걸면 정말 호출돼 견적이 마감되는지 확인. - 운영 트리거는 CronTrigger(minute='*/5')라 분 경계까지 기다려야 하므로, 여기선 - 1초 IntervalTrigger 로 같은 잡을 걸어 '스케줄러 → 잡 호출 → 마감' 경로만 몇 초 안에 검증한다.""" - engine = clean - qt = await _add_quotation(engine, end_time=PAST) - await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100) - - sched = AsyncIOScheduler(timezone="Asia/Seoul") - sched.add_job(jobs.close_expired_quotations, IntervalTrigger(seconds=1), max_instances=1) - sched.start() - try: - row = None - for _ in range(25): # 최대 ~5초 폴링(잡은 1초 뒤 첫 발화) - await asyncio.sleep(0.2) - row = await _quotation_row(engine, qt) - if row.status == QuotationStatus.CLOSED.value: - break - assert row is not None and row.status == QuotationStatus.CLOSED.value # 크론이 잡을 호출해 마감 - finally: - sched.shutdown(wait=False) From 43ac308a5e1515136f52bbd915b0225c363ad469 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 1 Jul 2026 15:06:55 +0900 Subject: [PATCH 20/20] =?UTF-8?q?[docs]=20negodata:=20README=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=E2=80=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=EB=B2=95=C2=B7API=20=EB=8F=84=EB=A9=94=EC=9D=B8=C2=B7?= =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8=20=ED=8F=AC=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- negodata/backend/README.md | 80 ++++++++++++++++++++++++++------------ negodata/front/README.md | 5 ++- 2 files changed, 59 insertions(+), 26 deletions(-) diff --git a/negodata/backend/README.md b/negodata/backend/README.md index 87efd66..a475691 100644 --- a/negodata/backend/README.md +++ b/negodata/backend/README.md @@ -1,6 +1,6 @@ # Negodata Backend -DerbyMasters_Server 아키텍처를 이식한 FastAPI 골격. 기능은 **JWT id/pw 로그인**만 예시 구현. +DerbyMasters_Server 아키텍처를 이식한 FastAPI 백엔드. 인증(JWT) 위에 **견적·협력사·상품·견적설정·대시보드·알림·협상카드·회사유저관리** 도메인과 **견적 마감 스케줄러**를 구현. [negosium-backend](../../backend/README.md) 와 동일 구조이며, 실행·테스트·벤치마크 종합은 [레포 최상위 README](../../README.md) 참고. ## 디렉토리 구조 @@ -9,19 +9,20 @@ DerbyMasters_Server 아키텍처를 이식한 FastAPI 골격. 기능은 **JWT id negodata/backend/ ├── web_main.py # 엔트리포인트 (uvicorn) ├── config/ # 환경설정 (APP_ENV 별 toml 로드) +├── conftest.py, tests/ # pytest (test DB 자동 create/drop) — 아래 '테스트' ├── common/ -│ ├── enums.py # ErrorType / DBType / DBWRType / EXCEPTION_* +│ ├── enums.py # ErrorType / 코드값 enum / EXCEPTION_* │ ├── models/gmodel.py # 프로토콜 베이스 (WebPacketProtocol 등) │ └── database/ │ ├── db_session_manager.py# ★ DB Read/Write + 람다 실행 핵심 -│ └── model/models.py # ORM 모델 (tbl_account) -├── crud/user_crud.py # DB 접근 (I*CRUD 인터페이스 + 구현) -├── services/auth_service.py # 비즈니스 로직 +│ └── model/models.py # ORM 모델 (companies·users·quotations·sessions·items·suppliers·notifications·cards …) +├── crud/ # 도메인별 DB 접근(I*CRUD 인터페이스+구현): quotation·supplier·item·dashboard·notification·card·user … +├── services/ # 비즈니스 로직: quotation·supplier·item·dashboard·notification·company_user·auth·email … +├── scheduler/ # 견적 마감 크론 잡(만료 마감 · 협상종결 마감) └── router/ - ├── router.py # FastAPI app - └── v1/ - ├── auth/{account,protocol}.py # 엔드포인트 / Req_·Res_ - └── validator/dependencies.py # ★ JWT 발급·검증, 해시, RemoveNoneResponse + ├── router.py # FastAPI app (CORS 등) + └── v1/ # 도메인별 라우터: auth·quotation·quotation_setting·supplier·item·card·dashboard·notification·company + └── validator/dependencies.py # ★ JWT 발급·검증, 해시(bcrypt), RemoveNoneResponse, RequireOwner ``` ## 핵심 패턴 @@ -40,22 +41,53 @@ negodata/backend/ - **ResponseNone**: 응답의 `None` 필드 재귀 제거(`RemoveNoneResponse`). - **bcrypt 비차단**: `GetHashedPW`/`VerifyPW` 를 `asyncio.to_thread` 로 오프로드(이벤트 루프 비차단). → [벤치마크](../../README.md#성능--벤치마크) -## 엔드포인트 -| Method | Path | 설명 | -|---|---|---| -| POST | `/v1/auth/create` | 계정 생성 (pw bcrypt 해시) | -| POST | `/v1/auth/login` | 로그인, access/refresh 토큰 발급 | -| POST | `/v1/auth/refresh_token` | access 토큰 재발급 (refresh 필요) | -| GET | `/v1/auth/me` | 내 정보 (access 토큰 필요) | +## API 도메인 (`/v1/*`) +전체 스펙은 실행 후 **http://localhost:9400/docs** (Swagger). 주요 도메인: -## 실행 / 테스트 +| prefix | 요약 | +|---|---| +| `/v1/auth` | 로그인 · access 토큰 재발급 · 내 정보(`me`). ※ 무인증 계정 생성은 제거됨 | +| `/v1/company/user` | 최고관리자(OWNER) 전용 — 자기 회사 직원 계정 생성·관리 | +| `/v1/quotation` | 견적 생성·목록·단건·마감·재생성 + 세션·채팅·낙찰결과·카드·초청메일 | +| `/v1/quotation-setting` | 견적 설정(마진율 등) — **유저별** 소유 | +| `/v1/supplier`, `/v1/item` | 협력사 / 상품 CRUD (회사 스코프) | +| `/v1/card` | 협상 카드 | +| `/v1/dashboard` | 요약(회사 전체 + 내 견적) | +| `/v1/notification` | 알림함(목록 · 읽음 처리) | + +> 인증 헤더: `Authorization: Bearer `. 회사 소유 자원은 토큰의 회사로 스코프되고, 계정 관리는 OWNER 만 가능. + +## 실행 ```bash -# 레포 최상위에서 docker compose up -d (backend만; DB 는 외부 PostgreSQL). 상세는 루트 README. cd negodata/backend -pip install -r requirements.txt # 실행 -python web_main.py # APP_ENV 기본 local -pip install pytest pytest-asyncio httpx # 테스트 도구 -python -m pytest +pip install -r requirements.txt +python web_main.py # APP_ENV 기본 local → http://localhost:9400/docs ``` -- 서버: http://localhost:9400/docs -- 환경: `config.{local,test,docker}.toml` (`APP_ENV` 로 선택, docker 는 DB 호스트=`host.docker.internal`, database=`negodata_db`) +환경: `config.{local,test,prod}.toml` (`APP_ENV` 로 선택). + +## 테스트 + +**테스트는 도커가 아니라 호스트(venv)에서 돌린다** — DB(PostgreSQL)만 도커(`negosium-db`, `127.0.0.1:5432`)면 되고, 앱 컨테이너 안엔 pytest 가 없다. test DB(`negosium_test_db`)는 알아서 만들어졌다 지워지므로 **수동 세팅이 필요 없다.** + +```bash +cd negodata/backend +python3 -m venv .venv && source .venv/bin/activate # 최초 1회 (venv 없을 때) +pip install -r requirements.txt # httpx 포함 +pip install pytest pytest-asyncio # 테스트 도구(requirements 에 없음) + +python -m pytest # 전체 (venv 활성화 상태) +python -m pytest -v # 테스트별 PASS/FAIL +python -m pytest tests/test_company_scope.py # 파일 하나만 +python -m pytest -k scope # 이름에 'scope' 든 것만 +``` +venv 를 활성화(`source .venv/bin/activate`)하지 않으면 `.venv/bin/python -m pytest` 로 직접 지정한다. +(시스템에 `python` 명령이 없거나 pytest 가 venv 밖에 없으면 맨 `python -m pytest` 는 실패한다.) +정상이면 마지막 줄에 `NN passed`. + +동작 방식 (전부 [conftest.py](conftest.py) 가 자동 처리 — 손댈 것 없음): +- `APP_ENV` 를 `test` 로 자동 설정 → [config.test.toml](config/config.test.toml) 의 **`negosium_test_db`** 사용(dev DB `negosium_db` 와 완전 분리). +- **세션 시작 시 test DB 를 새로 만들고(CREATE), 끝나면 내린다(DROP).** 매번 현재 모델로 새로 빌드돼 스키마가 낡을 일이 없다. 남는 DB 도 없음. +- 테이블은 `create_all` 로 자동 생성, 매 테스트 전 `TRUNCATE` 로 비워 격리. +- 안전가드: 이름에 `test` 없는 DB 는 만들지도 지우지도 않는다(실 DB 보호). + +> 즉 새로 clone 받은 팀원도 **Postgres 만 켜져 있으면 `python -m pytest` 한 방**이면 끝. diff --git a/negodata/front/README.md b/negodata/front/README.md index f22beba..a2b84f5 100644 --- a/negodata/front/README.md +++ b/negodata/front/README.md @@ -21,8 +21,9 @@ negosium/negodata 협상 플랫폼의 웹 프론트엔드. docker compose up -d --build ``` -- 프론트: **http://localhost:3001** (compose 가 컨테이너 `:3000` → 호스트 `:3001` 로 매핑) +- 프론트: **http://localhost:3000** (compose 서비스 `negodata-front`, `3000:3000` 매핑) - 소스를 바인드마운트하므로 코드 수정은 HMR 로 자동 반영된다. +- (참고: 공급사용 `negosium-front` 는 별개로 `:3300`) ### 프론트만 단독 개발 (선택) @@ -80,7 +81,7 @@ src/ api/ generated/ # orval 자동생성 (직접 수정 금지) mutator/ # custom-fetch (요청 공통 로직: baseURL·토큰·에러) - features/ # 도메인별: auth, products(상품), partners(협력사), quotations(견적), cards + features/ # 도메인별: auth, quotations(견적), products(상품), partners(협력사), cards(협상카드), dashboard, members(회원관리), onboarding components/ # ui(shadcn), layout pages/ # 화면 stores/ # zustand 스토어