[feat] negodata: 견적 목표가 산정 개편 + 협상 초청메일 + 카드 사용구분

[견적·목표가]
- 목표가 산정 KTC 기준 정비(MD 우선, 없으면 인터넷최저가·매입가·판매가 중 최저 / 신규는 인터넷최저가만)
- 인터넷 평균 수수료 0.078 상수화(견적설정 컬럼 제거)
- 앵커링가 세션 저장 + 목표가 클릭 시 산정내역 모달
- 재생성 시 목표가·앵커링가 재계산 없이 직전 값 상속(KTC)
- MD 제시가·협력사 유형(유통·제조·총판·기타) 입력

[협상카드]
- 사용 구분(공통/신규견적전용/재견적전용)

[협상 진행]
- 협력사 초청 메일 발송(일괄/개별·재발송, 발송상태 표시)

[기타]
- 견적상세 창 닫기 버그 수정, 불필요한 컬럼 주석 정리
- DB: 기존 DB는 postgres-init/04-alter.sql 적용 필요(자동 마이그레이션 없음)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-06-29 13:33:08 +09:00
parent b42d084063
commit 019f3dbeac
51 changed files with 1263 additions and 163 deletions

View File

@ -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(목표가×(1anchoring_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, 앱에서 갱신)

View File

@ -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(목표가×(1anchoring_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):

View File

@ -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])

View File

@ -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 <no-reply@negodata.o2o.kr>"
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)

View File

@ -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 그대로.

View File

@ -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 = (

View File

@ -11,3 +11,5 @@ python-multipart
openpyxl
httpx
apscheduler>=3.10
azure-communication-email>=1.0 # 초청 메일 1순위 발송 채널(ACS Email)
aiosmtplib>=3.0 # 초청 메일 폴백(SMTP)

View File

@ -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.<domain>.<file> 를 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)

View File

@ -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

View File

@ -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)))

View File

@ -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

View File

@ -0,0 +1,125 @@
"""협상 초청 메일 발송.
1순위: Azure Communication Services(ACS) Email (endpoint + accesskey + 검증된 sender).
2순위(폴백): SMTP (aiosmtplib).
미설정이면 EmailUnavailable 던진다(조용한 실패 0).
설정 출처: config.<APP_ENV>.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

View File

@ -0,0 +1,70 @@
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f5f7;margin:0;padding:24px 12px;">
<tr>
<td align="center">
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="width:480px;max-width:480px;background-color:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb;font-family:'Apple SD Gothic Neo',-apple-system,'Segoe UI',Roboto,'Malgun Gothic',sans-serif;">
<!-- 헤더 바 -->
<tr>
<td style="background-color:#2563eb;padding:18px 28px;">
<span style="color:#ffffff;font-size:16px;font-weight:700;letter-spacing:1px;">NEGODATA</span>
</td>
</tr>
<!-- 본문 -->
<tr>
<td style="padding:32px 28px 4px 28px;">
<h1 style="margin:0 0 10px 0;font-size:20px;font-weight:700;color:#111827;">협상 참여 요청</h1>
<p style="margin:0 0 24px 0;font-size:14px;line-height:1.7;color:#4b5563;">
<strong style="color:#111827;">$supplier_name</strong> 담당자님,<br>
아래 견적 건의 협상에 참여해 주세요.
</p>
<!-- 견적 정보 카드 -->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f9fafb;border:1px solid #eef0f3;border-radius:10px;margin-bottom:28px;">
<tr>
<td style="padding:14px 16px;font-size:13px;color:#6b7280;width:88px;">견적명</td>
<td style="padding:14px 16px;font-size:13px;color:#111827;font-weight:600;text-align:right;">$quotation_name</td>
</tr>
<tr>
<td style="padding:14px 16px;font-size:13px;color:#6b7280;border-top:1px solid #eef0f3;">견적번호</td>
<td style="padding:14px 16px;font-size:13px;color:#374151;text-align:right;border-top:1px solid #eef0f3;">$qt_number</td>
</tr>
<tr>
<td style="padding:14px 16px;font-size:13px;color:#6b7280;border-top:1px solid #eef0f3;">협상 마감</td>
<td style="padding:14px 16px;font-size:13px;color:#dc2626;font-weight:700;text-align:right;border-top:1px solid #eef0f3;">$deadline</td>
</tr>
</table>
<!-- CTA 버튼 (td bgcolor = Outlook 대응 bulletproof) -->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:4px;">
<tr>
<td align="center">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td align="center" bgcolor="#2563eb" style="border-radius:8px;">
<!-- TODO: 세션별 협상링크 연결 시 href 를 $$chat_url 로 복원 -->
<a href="https://nego.o2o.kr" style="display:inline-block;padding:14px 34px;font-size:15px;font-weight:700;color:#ffffff;text-decoration:none;border-radius:8px;">협상 참여하기 →</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- 푸터 -->
<tr>
<td style="padding:18px 28px 26px 28px;">
<p style="margin:0;font-size:11px;line-height:1.7;color:#9ca3af;border-top:1px solid #f0f1f3;padding-top:16px;">
버튼이 열리지 않으면 아래 링크를 복사해 접속하세요.<br>
<a href="https://nego.o2o.kr" style="color:#6b7280;word-break:break-all;">https://nego.o2o.kr</a>
</p>
</td>
</tr>
</table>
<p style="margin:16px 0 0 0;font-size:11px;color:#b0b4bb;font-family:sans-serif;">본 메일은 협상 견적 시스템에서 자동 발송되었습니다.</p>
</td>
</tr>
</table>

View File

@ -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)

View File

@ -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(

View File

@ -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];

View File

@ -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';

View File

@ -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;
};

View File

@ -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;

View File

@ -8,6 +8,5 @@
export interface ReqCreateQuotationSetting {
target_margin_rate: number;
anchoring_value?: number;
internet_average_fee?: number;
card_count?: number;
}

View File

@ -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;
}

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export type ReqUpdateQuotationSettingInternetAverageFee = number | null;
export type ReqUpdateMeContactNumber = string | null;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -31,6 +31,7 @@ import type {
ResCreateQuotation,
ResDeleteQuotation,
ResLastSupplierType,
ResNotifySessions,
ResQuotation,
ResQuotationCards,
ResQuotationList,
@ -516,6 +517,68 @@ export function useGetQuotationSessions<TData = Awaited<ReturnType<typeof getQuo
/**
* @summary
*/
export const notifyQuotation = (
qtId: string,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResNotifySessions>(
{url: `/v1/quotation/${qtId}/notify`, method: 'POST', signal
},
options);
}
export const getNotifyQuotationMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof notifyQuotation>>, TError,{qtId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof notifyQuotation>>, 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<Awaited<ReturnType<typeof notifyQuotation>>, {qtId: string}> = (props) => {
const {qtId} = props ?? {};
return notifyQuotation(qtId,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type NotifyQuotationMutationResult = NonNullable<Awaited<ReturnType<typeof notifyQuotation>>>
export type NotifyQuotationMutationError = void | HTTPValidationError
/**
* @summary
*/
export const useNotifyQuotation = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof notifyQuotation>>, TError,{qtId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof notifyQuotation>>,
TError,
{qtId: string},
TContext
> => {
const mutationOptions = getNotifyQuotationMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* @summary
*/
export const getSessionChat = (
@ -608,6 +671,68 @@ export function useGetSessionChat<TData = Awaited<ReturnType<typeof getSessionCh
/**
* @summary
*/
export const notifySession = (
sessionId: string,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResNotifySessions>(
{url: `/v1/quotation/session/${sessionId}/notify`, method: 'POST', signal
},
options);
}
export const getNotifySessionMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof notifySession>>, TError,{sessionId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof notifySession>>, 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<Awaited<ReturnType<typeof notifySession>>, {sessionId: string}> = (props) => {
const {sessionId} = props ?? {};
return notifySession(sessionId,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type NotifySessionMutationResult = NonNullable<Awaited<ReturnType<typeof notifySession>>>
export type NotifySessionMutationError = void | HTTPValidationError
/**
* @summary
*/
export const useNotifySession = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof notifySession>>, TError,{sessionId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof notifySession>>,
TError,
{sessionId: string},
TContext
> => {
const mutationOptions = getNotifySessionMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* @summary
*/
export const getQuotationResult = (

View File

@ -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<typeof useAuth>['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<PageType, string> = {
@ -40,6 +45,7 @@ const pageLabelMap: Record<PageType, string> = {
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 */}
<nav className="mt-2 px-3 space-y-1">
{menuItems.map((item) => (
{menuItems
.filter((item) => !item.ownerOnly || user?.role === '최고관리자')
.map((item) => (
<NavItem
key={item.type}
item={item}
@ -143,6 +152,17 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
{/* Sidebar Footer Controls */}
<div className="p-2 border-t border-border space-y-1">
{/* 내 정보 수정 */}
<Button
id="sidebar-profile-edit"
variant="ghost"
onClick={() => setIsProfileOpen(true)}
className="w-full justify-start gap-3 py-1.5 px-3 h-auto rounded text-[11px] font-semibold text-muted-foreground"
>
<UserPen size={14} className="size-3.5" />
{expanded && <span> </span>}
</Button>
{/* Theme switcher */}
<Button
variant="ghost"
@ -205,6 +225,8 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
<Typography variant="mono">Copyright &copy; O2O Inc. All rights reserved</Typography>
</footer>
</div>
{isProfileOpen && <ProfileSheet open onClose={() => setIsProfileOpen(false)} />}
</div>
);
}

View File

@ -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 <Link>/<button> 엔
// typographyVariants({ variant: 'link' }) 를 className 에 합성해 같은 토큰을 공유한다.
link: "text-primary underline underline-offset-2 decoration-1 cursor-pointer transition-colors hover:text-primary/80",
},
},
defaultVariants: { variant: "body" },
@ -37,6 +41,7 @@ const defaultTag: Record<NonNullable<VariantProps<typeof typographyVariants>["va
label: "span",
mono: "span",
caption: "span",
link: "span",
}
type TypographyProps = React.HTMLAttributes<HTMLElement> &

View File

@ -1,9 +1,10 @@
import { useState, useEffect } from 'react';
import { X, PlusSquare, ArrowRight, Loader2 } from 'lucide-react';
import { useNavigate } from 'react-router';
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 { Typography, typographyVariants } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
@ -47,9 +48,7 @@ export function QuotationCreateModal({
const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? '');
const [selectedCardIds, setSelectedCardIds] = useState<string[]>([]);
const [memo, setMemo] = useState('');
const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 마진식으로 목표가 산정
const [purchaseInput, setPurchaseInput] = useState(''); // 재견적·재협상 매입가(상품 저장값 디폴트, 필수)
const [sellingInput, setSellingInput] = useState(''); // 재견적·재협상 판매가(상품 저장값 디폴트, 선택)
const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 상품값으로 목표가 산정
const [supplierType, setSupplierType] = useState(''); // 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록
const [submitting, setSubmitting] = useState(false);
const typeOptions = QUOTATION_TYPE_OPTIONS;
@ -66,17 +65,19 @@ export function QuotationCreateModal({
setSupplierType(prevSupplierType != null ? String(prevSupplierType) : '');
}, [renegoSupplierId, prevSupplierType]);
// 재견적·재협상(RE)에서만 매입가/판매가 입력을 노출한다. 상품이 정해지면 그 상품 저장값으로 디폴트.
const navigate = useNavigate();
// 인터넷최저가·매입가·판매가는 상품 속성 — 모달에선 읽기전용으로만 보여주고, 수정은 상품 상세에서 한다.
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]);
const internetLowest = selectedProduct?.internet_lowest_price ?? null;
const purchase = selectedProduct?.purchase_price ?? null;
const selling = selectedProduct?.selling_price ?? null;
// 상품에 산정 후보가 있는지(인터넷=공통, 매입·판매=재 한정). 없으면 MD가가 유일한 후보 → 필수가 된다.
const mdNum = Number(mdPrice) || 0;
const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null));
const mdRequired = !!productId && !hasItemCandidate;
// 목표가 산정 가능 여부: MD가가 있으면 무조건 OK. 없으면 상품 후보 중 하나라도 있어야.
const targetReady = mdNum > 0 || hasItemCandidate;
if (!open) return null;
@ -95,16 +96,10 @@ 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,
});
// 목표가 산정에 쓸 값이 없으면(MD가·상품 후보 전무) 생성 차단 — 서버 산정불가 에러 선제 방어.
if (productId && !targetReady) {
showToast('목표가 산정에 쓸 값이 없습니다 — MD 제시가를 입력하거나, 상품 상세에서 인터넷최저가·매입가를 채워주세요.', 'error');
return; // finally 에서 submitting 해제
}
// 서버가 견적+세션 생성을 끝내고 응답할 때까지 기다린 뒤에 완료(닫기) 처리한다.
const ok = await onCreate({
@ -235,9 +230,11 @@ export function QuotationCreateModal({
</Select>
</div>
{/* MD 제시가 — 신규·재 공통(입력 시 목표가로 사용) */}
{/* MD 제시가 — 입력 시 목표가로 사용. 상품에 다른 후보가 없으면 유일 후보라 필수. */}
<div className="space-y-1">
<Typography as="label" variant="label">MD ()</Typography>
<Typography as="label" variant="label" className={mdRequired ? 'text-rose-500' : undefined}>
MD {mdRequired ? '(필수 — 다른 후보 없음)' : '(선택)'}
</Typography>
<Input
id="wizard-md-price"
type="number"
@ -245,37 +242,46 @@ export function QuotationCreateModal({
className="text-xs"
value={mdPrice}
onChange={(e) => setMdPrice(e.target.value)}
placeholder="입력 시 목표가로 사용 · 미입력 시 자동 산정"
placeholder={mdRequired ? '상품에 산정값이 없어 MD가 입력이 필요합니다' : '입력 시 목표가로 사용 · 미입력 시 자동 산정'}
/>
</div>
{/* 재견적·재협상 — 매입가(필수)·판매가, 선택 상품의 저장값으로 디폴트 */}
{showPrices && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1">
<Typography as="label" variant="label" className="text-amber-600"> ()</Typography>
<Input
id="wizard-purchase-price"
type="number"
min={0}
className="text-xs"
value={purchaseInput}
onChange={(e) => setPurchaseInput(e.target.value)}
placeholder="상품 저장값 · 비우면 진행 불가"
/>
{/* 목표가 산정 후보 — 상품 값(읽기전용). 신규=인터넷최저가, 재=+매입가·판매가. 수정은 상품 상세에서. */}
{productId && (
<div className="rounded border border-border bg-muted/20 p-3 space-y-2">
<div className="flex items-center justify-between">
<Typography as="span" variant="label">
<span className="text-muted-foreground font-normal">({isReType ? '재' : '신규'})</span>
</Typography>
<button
type="button"
onClick={() => navigate(`/products?detail=${productId}`)}
className={cn(typographyVariants({ variant: 'link' }), 'text-[10px]')}
>
</button>
</div>
<div className="space-y-1">
<Typography as="label" variant="label"> ()</Typography>
<Input
id="wizard-selling-price"
type="number"
min={0}
className="text-xs"
value={sellingInput}
onChange={(e) => setSellingInput(e.target.value)}
placeholder="상품 저장값 · 마진 상한 산정에 사용"
/>
<div className={`grid ${isReType ? 'grid-cols-3' : 'grid-cols-1'} gap-2`}>
{[
{ label: '인터넷 최저가', value: internetLowest, show: true },
{ label: '매입가', value: purchase, show: isReType },
{ label: '판매가', value: selling, show: isReType },
]
.filter((r) => r.show)
.map((r) => (
<div key={r.label} className="space-y-0.5">
<Typography as="span" variant="label" className="text-muted-foreground">{r.label}</Typography>
<Typography as="span" variant="small" className="font-mono block">
{r.value != null ? `${Number(r.value).toLocaleString()}` : '-'}
</Typography>
</div>
))}
</div>
{!targetReady && (
<Typography as="p" variant="small" className="text-rose-600 leading-snug">
MD MD가를 .
</Typography>
)}
</div>
)}
</div>
@ -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);

View File

@ -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({
<div className="flex-1 min-w-0">
<Link
to={`/products?detail=${currentProduct.id}`}
className="text-foreground font-bold font-sans text-[12px] mb-2 truncate block hover:text-primary hover:underline"
className={cn(typographyVariants({ variant: 'link' }), 'font-bold font-sans text-[12px] mb-2 truncate block')}
title={`${currentProduct.name || ''} — 상품 상세로 이동`}
>
{currentProduct.name || '-'}
@ -182,7 +187,6 @@ export function DrawerHeaderCards({
<InfoField
label="카드 사용 횟수"
value={selectedSettingObj.card_use_count}
className="col-span-2 col-start-1"
/>
</div>
) : (

View File

@ -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 ? (
<Link
to={`/cards?detail=${qc.card_id}`}
className="hover:text-primary hover:underline"
className={typographyVariants({ variant: 'link' })}
title={`${qc.card_name} — 협상카드 상세로 이동`}
>
{qc.card_name}

View File

@ -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<typeof mapServerSessionView>;
export function SessionsStatusTab({
sessionViews,
canNotify,
onOpenChat,
onShowTarget,
onNotifyAll,
onNotifyOne,
}: {
sessionViews: SessionView[];
/** 초청 메일 발송 권한(견적 소유자만). false 면 발송 버튼 비활성. */
canNotify: boolean;
onOpenChat: (sessionId: string) => void;
/** 목표가 셀 클릭 → 산정내역 모달. */
onShowTarget: (sessionId: string) => void;
/** 미발송 세션 전체에 초청 메일 발송. */
onNotifyAll: () => Promise<void>;
/** 한 세션(공급사)에 초청 메일 발송/재발송. */
onNotifyOne: (sessionId: string) => Promise<void>;
}) {
const [sendingAll, setSendingAll] = useState(false);
const [sendingId, setSendingId] = useState<string | null>(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 (
<div className="space-y-4">
{/* 초청 메일 발송 툴바 — 미발송분 일괄 발송. 개별 재발송은 행의 버튼으로. */}
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{unsentCount > 0 ? `미발송 ${unsentCount}` : '모든 협력사에 초청 메일 발송 완료'}
</span>
<button
onClick={handleAll}
disabled={!canNotify || sendingAll || unsentCount === 0}
title={
!canNotify
? '본인이 생성한 견적만 초청 메일을 발송할 수 있습니다.'
: unsentCount === 0
? '발송할 미발송 세션이 없습니다.'
: '미발송 세션 전체에 초청 메일을 발송합니다.'
}
className="flex items-center gap-2 px-3 py-2 bg-primary text-primary-foreground text-xs font-bold rounded hover:opacity-95 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<Mail size={14} />
<span>{sendingAll ? '발송 중…' : `초청 메일 발송${unsentCount > 0 ? ` (${unsentCount})` : ''}`}</span>
</button>
</div>
<div className="border border-border rounded-lg bg-card overflow-x-auto">
<Table className="w-full text-left text-xs border-collapse font-mono min-w-[1250px]">
<Table className="w-full text-left text-xs border-collapse font-mono min-w-[1350px]">
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
<TableRow>
<TableHead className="p-3 font-semibold"> ID</TableHead>
<TableHead className="p-3 font-semibold font-sans"></TableHead>
<TableHead className="p-3 font-semibold font-sans"> URL</TableHead>
<TableHead className="p-3 font-semibold text-center font-sans"></TableHead>
<TableHead className="p-3 font-semibold font-sans"></TableHead>
<TableHead className="p-3 font-semibold text-center font-sans"></TableHead>
<TableHead className="p-3 font-semibold text-right"></TableHead>
@ -36,7 +115,7 @@ export function SessionsStatusTab({
<TableBody className="divide-y divide-border">
{sessionViews.length === 0 && (
<TableRow>
<TableCell colSpan={12} className="p-12 text-center text-muted-foreground">
<TableCell colSpan={13} className="p-12 text-center text-muted-foreground">
. ( )
</TableCell>
</TableRow>
@ -58,37 +137,65 @@ export function SessionsStatusTab({
</TableCell>
<TableCell className="p-3 font-mono">
{sess.url ? (
<div className="flex items-center gap-1.5">
<a
href={sess.url}
target="_blank"
rel="noopener noreferrer"
title={sess.url}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-primary/10 text-primary hover:bg-primary/20 transition-colors text-[10px] font-semibold"
>
<ExternalLink size={11} />
</a>
<button
onClick={() => {
void navigator.clipboard?.writeText(sess.url);
showToast('협상 URL을 복사했습니다.', 'success');
}}
title="협상 URL 복사"
className="p-1 hover:bg-muted rounded text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<Copy size={12} />
</button>
</div>
<button
onClick={() => {
void navigator.clipboard?.writeText(sess.url);
showToast('협상 URL을 복사했습니다.', 'success');
}}
title="협상 URL 복사"
className="p-1 hover:bg-muted rounded text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<Copy size={12} />
</button>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="p-3 text-center font-sans">
<div className="flex flex-col items-center gap-1">
<div className="flex items-center gap-1.5">
{sess.email_sent_at ? (
<span className="inline-flex items-center gap-1 text-emerald-600 text-[10px] font-semibold">
<MailCheck size={11} />
</span>
) : (
<span className="inline-flex items-center gap-1 text-muted-foreground text-[10px] font-semibold">
<Mail size={11} />
</span>
)}
<button
onClick={() => handleOne(sess.session_id, sess.supplier_name, !!sess.email_sent_at)}
disabled={!canNotify || sendingId === sess.session_id}
title={
!canNotify
? '본인이 생성한 견적만 초청 메일을 발송할 수 있습니다.'
: sess.email_sent_at
? '이 협력사에게 초청 메일 재발송'
: '이 협력사에게 초청 메일 발송'
}
className="p-1 rounded text-primary hover:bg-primary/10 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
<Send size={12} />
</button>
</div>
{sess.email_sent_at && (
<span className="text-muted-foreground text-[10px] font-mono">{sess.email_sent_at}</span>
)}
</div>
</TableCell>
<TableCell className="p-3 font-semibold font-sans">{sess.item_name}</TableCell>
<TableCell className="p-3 text-center">
<StatusPill tone={sessionStatusTone(sess.status)}>{sessionStatusLabel(sess.status)}</StatusPill>
</TableCell>
<TableCell className="p-3 text-right font-bold text-muted-foreground">
{sess.target_price?.toLocaleString() || '-'}
<TableCell className="p-3 text-right">
<button
type="button"
onClick={() => onShowTarget(sess.session_id)}
title="목표가 산정 내역 보기"
className={cn(typographyVariants({ variant: 'link' }), 'font-bold')}
>
{sess.target_price?.toLocaleString() || '-'}
</button>
</TableCell>
<TableCell className="p-3 text-right font-bold text-foreground">
{sess.bid_price ? `${sess.bid_price.toLocaleString()}` : '-'}

View File

@ -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 (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-xs"
onClick={onClose}
>
<div
className="w-full max-w-md bg-card border border-border rounded-lg shadow-2xl p-6 font-mono text-xs animate-scale-up"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="flex items-center justify-between pb-3 border-b border-border">
<Typography variant="h3"></Typography>
<button onClick={onClose} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
<X size={18} />
</button>
</div>
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground mt-3">
: {qtNumber} · : {itemName}
</Typography>
<Typography as="p" variant="small" className="text-[11px] font-semibold text-rose-500 mt-1">
: {deliveryFeeYn ? '배송비포함' : '배송비별도'} · : {vatYn ? 'VAT포함' : 'VAT별도'}
</Typography>
{/* 목표가 */}
<div className="flex items-center justify-between gap-3 mt-5">
<Typography variant="label" className="font-bold"></Typography>
<div className="flex-1 text-right bg-muted/50 border border-border rounded px-3 py-2 font-bold text-base text-foreground">
{won(targetPrice)}
</div>
</div>
{/* 선정방식 */}
<div className="mt-4 bg-muted/40 border border-border rounded p-3 space-y-1">
<Typography as="p" variant="small" className="font-bold text-foreground text-[11px]"> </Typography>
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">1. MD , </Typography>
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">2. ×(1) | | ×(1 )</Typography>
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">
* : {fee} · : {margin}{isNew ? ' · 신규견적이라 인터넷최저가만 적용' : ''}
</Typography>
</div>
{/* 후보 */}
<div className="mt-4 space-y-2">
{rows.map((r) => {
const on = r.key === applied;
return (
<div key={r.key} className="flex items-center gap-2">
<span className={`w-4 shrink-0 ${on ? 'text-emerald-600' : 'text-transparent'}`}>
{on ? <Check size={14} /> : null}
</span>
<div className="flex-1">
<Typography as="span" variant="small" className={`text-[11px] ${on ? 'font-bold text-foreground' : 'text-muted-foreground'}`}>
{r.label}
</Typography>
{r.sub && (
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground ml-1">
({r.sub})
</Typography>
)}
</div>
<div
className={`text-right min-w-[96px] px-2 py-1 rounded border ${
on
? 'border-emerald-300 bg-emerald-50 dark:bg-emerald-950/20 font-bold text-foreground'
: 'border-border bg-muted/30 text-muted-foreground'
}`}
>
{won(r.value)}
</div>
</div>
);
})}
</div>
{/* 앵커링 (negodata: 설정 anchoring_value 고정율) */}
<div className="mt-4 bg-muted/40 border border-border rounded p-3 space-y-1">
<Typography as="p" variant="small" className="font-bold text-foreground text-[11px]"></Typography>
{category && (
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground"> : {category}</Typography>
)}
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground"> : {supplierTypeLabel}</Typography>
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground"> : {anchoringValue}</Typography>
<Typography as="p" variant="small" className="text-[11px] font-bold text-foreground">
: {won(anchoringPrice)} <span className="font-normal text-[10px] text-muted-foreground">= ×(1{anchoringValue})</span>
</Typography>
</div>
</div>
</div>
);
}

View File

@ -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<string | null>;
/** 협상 초청 메일 — 견적 단위(미발송 세션 전체) 발송. */
onNotify: (qtId: string) => Promise<void>;
/** 협상 초청 메일 — 세션(공급사) 단위 재발송. */
onNotifySession: (sessionId: string, qtId: string) => Promise<void>;
onClose: () => void;
};
@ -44,11 +51,14 @@ export function QuotationDetailSheet({
onCloseQuotation,
onSwitchRound,
onRegenerate,
onNotify,
onNotifySession,
onClose,
}: QuotationDetailSheetProps) {
const [activeTab, setActiveTab] = useState<DrawerTab>('status');
const [showHeaderCards, setShowHeaderCards] = useState(true);
const [regenOpen, setRegenOpen] = useState(false);
const [targetSessionId, setTargetSessionId] = useState<string | null>(null);
// 시트 열린 동안 뒤 견적 리스트(<main>) 스크롤 잠금 — 옆에 배경 스크롤바가 같이 뜨는 것 방지.
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 */}
<div style={{ flex: '1 1 0%' }} className="min-h-0 p-6 overflow-y-auto bg-background/50">
{activeTab === 'status' && (
<SessionsStatusTab sessionViews={sessionViews} onOpenChat={goToChat} />
<SessionsStatusTab
sessionViews={sessionViews}
canNotify={canNotify}
onOpenChat={goToChat}
onShowTarget={setTargetSessionId}
onNotifyAll={() => onNotify(qtId)}
onNotifyOne={(sessionId) => onNotifySession(sessionId, qtId)}
/>
)}
{activeTab === 'cards' && <QuotationCardsTab quotationCardViews={quotationCardViews} />}
@ -251,6 +271,33 @@ export function QuotationDetailSheet({
</div>
</div>
{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 (
<TargetPriceModal
onClose={() => 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 && (
<RegenerateModal
open

View File

@ -24,18 +24,16 @@ 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, internetFee, cardUseCount });
const ok = onAdd({ targetMargin, anchoringValue, cardUseCount });
if (ok) {
setTargetMargin('');
setAnchoringValue('');
setInternetFee('7.8');
setCardUseCount('');
}
};
@ -110,10 +108,6 @@ export function QuotationSettingsModal({
<Typography as="label" variant="muted" className="text-[10px] font-semibold"> </Typography>
<Input type="number" step="0.01" value={anchoringValue} onChange={(e) => setAnchoringValue(e.target.value)} placeholder="예: 0.01" />
</div>
<div className="space-y-1">
<Typography as="label" variant="muted" className="text-[10px] font-semibold"> (%)</Typography>
<Input type="number" step="0.1" value={internetFee} onChange={(e) => setInternetFee(e.target.value)} placeholder="예: 7.8" />
</div>
<div className="space-y-1">
<Typography as="label" variant="muted" className="text-[10px] font-semibold"> </Typography>
<Input type="number" step="1" value={cardUseCount} onChange={(e) => setCardUseCount(e.target.value)} placeholder="예: 3" />

View File

@ -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 (
<div>
<Typography as="span" variant="small" className="block text-sm font-bold text-foreground hover:underline cursor-pointer">
<Typography as="span" variant="link" className="block text-sm font-bold">
{est.title}
</Typography>
<Typography as="span" variant="small" className="mt-0.5 flex items-center gap-1.5 text-[10px] font-mono text-muted-foreground">
@ -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')}
>
<Link2 size={11} className="opacity-60" />
<Typography as="span" variant="small" className="text-xs text-inherit">{est.number}</Typography>

View File

@ -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<void> => {
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<void> => {
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,
};
}

View File

@ -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,
};
}

View File

@ -12,5 +12,5 @@ export const DELIVERY_TYPE_OPTIONS = Object.values(DeliveryType).map((value) =>
export const USER_ROLE_LABEL: Record<UserRole, string> = {
[UserRole.USER]: '일반',
[UserRole.MANAGER]: '관리자',
[UserRole.OWNER]: '최고관리자',
};

View File

@ -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 (
<PageContainer>
@ -154,13 +158,15 @@ export default function QuotationPage() {
}
/>
{activeQuotation && (
{detailId && activeQuotation && (
<QuotationDetailSheet
key={activeQuotation.qt_id}
quotation={activeQuotation}
onCloseQuotation={closeQuotation}
onSwitchRound={(qtId) => overlay.open('detail', qtId, { replace: true })}
onRegenerate={regenerateQuotation}
onNotify={notifyQuotation}
onNotifySession={notifySession}
onClose={overlay.close}
/>
)}

View File

@ -33,4 +33,4 @@ export interface NegotiationCard {
memo?: string;
}
export type PageType = 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS';
export type PageType = 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'MEMBERS';

View File

@ -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 -- 소프트 삭제 여부

View File

@ -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;