[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:
parent
b42d084063
commit
019f3dbeac
@ -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, 앱에서 갱신)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 그대로.
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -11,3 +11,5 @@ python-multipart
|
||||
openpyxl
|
||||
httpx
|
||||
apscheduler>=3.10
|
||||
azure-communication-email>=1.0 # 초청 메일 1순위 발송 채널(ACS Email)
|
||||
aiosmtplib>=3.0 # 초청 메일 폴백(SMTP)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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
|
||||
|
||||
125
negodata/backend/services/email.py
Normal file
125
negodata/backend/services/email.py
Normal 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
|
||||
70
negodata/backend/services/email_templates/invite_email.html
Normal file
70
negodata/backend/services/email_templates/invite_email.html
Normal 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>
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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];
|
||||
|
||||
|
||||
@ -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';
|
||||
22
negodata/front/src/api/generated/model/listUsersParams.ts
Normal file
22
negodata/front/src/api/generated/model/listUsersParams.ts
Normal 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;
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -8,6 +8,5 @@
|
||||
export interface ReqCreateQuotationSetting {
|
||||
target_margin_rate: number;
|
||||
anchoring_value?: number;
|
||||
internet_average_fee?: number;
|
||||
card_count?: number;
|
||||
}
|
||||
|
||||
17
negodata/front/src/api/generated/model/reqUpdateMe.ts
Normal file
17
negodata/front/src/api/generated/model/reqUpdateMe.ts
Normal 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;
|
||||
}
|
||||
@ -5,4 +5,4 @@
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ReqUpdateQuotationSettingInternetAverageFee = number | null;
|
||||
export type ReqUpdateMeContactNumber = string | null;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
|
||||
17
negodata/front/src/api/generated/model/resNotifySessions.ts
Normal file
17
negodata/front/src/api/generated/model/resNotifySessions.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
|
||||
18
negodata/front/src/api/generated/model/userStatus.ts
Normal file
18
negodata/front/src/api/generated/model/userStatus.ts
Normal 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;
|
||||
@ -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 = (
|
||||
|
||||
@ -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 © O2O Inc. All rights reserved</Typography>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
{isProfileOpen && <ProfileSheet open onClose={() => setIsProfileOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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> &
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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()}` : '-'}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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]: '최고관리자',
|
||||
};
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -33,4 +33,4 @@ export interface NegotiationCard {
|
||||
memo?: string;
|
||||
}
|
||||
|
||||
export type PageType = 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS';
|
||||
export type PageType = 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'MEMBERS';
|
||||
|
||||
@ -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 -- 소프트 삭제 여부
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user