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

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

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

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

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

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

View File

@ -85,9 +85,9 @@ class items(MAIN_BASE):
vat_yn = Column(Boolean, nullable=True) # 부가세 포함 여부 vat_yn = Column(Boolean, nullable=True) # 부가세 포함 여부
delivery_fee_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_yn = Column(Boolean, nullable=False, server_default=text("false")) # 최저가 솔루션 보조 컬럼
internet_lowest_price = Column(BigInteger, nullable=True) # 인터넷 최저가 실값(원). 목표가 산정용(KTC: ×(1−수수료)) internet_lowest_price = Column(BigInteger, nullable=True)
purchase_price = Column(BigInteger, nullable=True) # 매입가(원). 목표가 후보(그대로). KTC items.purchase_price purchase_price = Column(BigInteger, nullable=True)
selling_price = Column(BigInteger, nullable=True) # 판매가(원). 목표가 후보(×(1−마진율)), 유통형만. KTC items.selling_price selling_price = Column(BigInteger, nullable=True)
category_type = Column(Integer, nullable=False, server_default=text("1")) # 카테고리 조회용 자동 증가 숫자 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) 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, 앱에서 갱신) 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_round = Column(Integer, nullable=False) # 견적 라운드(스냅샷)
qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적 (QtType) qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적 (QtType)
target_price = Column(BigInteger, nullable=False) # 목표가(원) 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 코드) status = Column(SmallInteger, nullable=False) # 진행 상태 (SessionStatus 코드)
bid_price = Column(BigInteger, nullable=True) # 입찰가(원) bid_price = Column(BigInteger, nullable=True) # 입찰가(원)
bid_at = Column(DateTime(timezone=True), 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_email = Column(String(255), nullable=True) # 담당자 이메일
manager_contact_number = Column(String(20), nullable=True) # 담당자 연락처 manager_contact_number = Column(String(20), nullable=True) # 담당자 연락처
memo = Column(String(100), nullable=True) # 메모 memo = Column(String(100), nullable=True) # 메모
md_price = Column(BigInteger, nullable=True) # MD 제시가(원). 목표가 산정 최우선값 md_price = Column(BigInteger, nullable=True)
supplier_type = Column(SmallInteger, nullable=True) # 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록 supplier_type = Column(SmallInteger, nullable=True)
iteration = Column(Integer, nullable=False, server_default=text("0")) # 반복 횟수 iteration = Column(Integer, nullable=False, server_default=text("0")) # 반복 횟수
preferred_sp_yn = Column(Boolean, nullable=True) # 선호 공급사 지정 여부 preferred_sp_yn = Column(Boolean, nullable=True) # 선호 공급사 지정 여부
preferred_sp_id = Column(UUID(as_uuid=True), nullable=True) # 선호 공급사(partner.suppliers.supplier_id) 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) user_id = Column(UUID(as_uuid=True), nullable=False) # 생성 유저(company.users.user_id)
target_margin_rate = Column(Numeric(8, 6), nullable=False) # 목표 마진율 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)) 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")) # 협상 내 협상카드 사용 횟수 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) 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, 앱에서 갱신) updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)

View File

@ -86,9 +86,9 @@ class items(MainTableMixin, MAIN_BASE):
price = Column(BigInteger, nullable=True) # 금액(원), 스키마 BIGINT price = Column(BigInteger, nullable=True) # 금액(원), 스키마 BIGINT
internet_lowest_price_yn = Column(Boolean, nullable=False, default=False) # 최저가 솔루션 원자성 보존용 internet_lowest_price_yn = Column(Boolean, nullable=False, default=False) # 최저가 솔루션 원자성 보존용
internet_lowest_price = Column(BigInteger, nullable=True) # 인터넷 최저가 실값(원). 목표가 산정용(KTC: ×(1−수수료)) internet_lowest_price = Column(BigInteger, nullable=True)
purchase_price = Column(BigInteger, nullable=True) # 매입가(원). 목표가 후보(그대로). KTC items.purchase_price(Integer) purchase_price = Column(BigInteger, nullable=True)
selling_price = Column(BigInteger, nullable=True) # 판매가(원). 목표가 후보(×(1−마진율)), 유통형만. KTC items.selling_price(Integer) selling_price = Column(BigInteger, nullable=True)
moq = Column(String(50), nullable=True) # 최소 주문 수량 moq = Column(String(50), nullable=True) # 최소 주문 수량
lead_time = Column(SmallInteger, nullable=True) # 주문 후 배송 도착까지 시간 lead_time = Column(SmallInteger, nullable=True) # 주문 후 배송 도착까지 시간
@ -124,7 +124,7 @@ class nego_cards(MainTableMixin, MAIN_BASE):
number = Column(String(10), nullable=True) # 식별번호(카드코드) number = Column(String(10), nullable=True) # 식별번호(카드코드)
script = Column(String(255), nullable=True) # 협상 스크립트(평문 미리보기) script = Column(String(255), nullable=True) # 협상 스크립트(평문 미리보기)
edit_script = Column(JSONB, nullable=True) # 편집된 스크립트(Slate JSON) 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): class wild_cards(MainTableMixin, MAIN_BASE):
@ -137,7 +137,7 @@ class wild_cards(MainTableMixin, MAIN_BASE):
number = Column(String(10), nullable=True) # 식별번호(카드코드) number = Column(String(10), nullable=True) # 식별번호(카드코드)
script = Column(String(255), nullable=True) # 협상 스크립트(평문 미리보기) script = Column(String(255), nullable=True) # 협상 스크립트(평문 미리보기)
edit_script = Column(JSONB, nullable=True) # 편집된 스크립트(Slate JSON) 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) # 사용 조건(트리거) condition = Column(String(255), nullable=True) # 사용 조건(트리거)
available = Column(Boolean, nullable=False, default=False) # 수동 협상 적용 여부(ACTIVE/INACTIVE 매핑) available = Column(Boolean, nullable=False, default=False) # 수동 협상 적용 여부(ACTIVE/INACTIVE 매핑)
memo = Column(String(255), nullable=True) # 자유 메모 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) # 설정 소유 유저 user_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 설정 소유 유저
target_margin_rate = Column(Numeric(8, 6), nullable=False) target_margin_rate = Column(Numeric(8, 6), nullable=False)
anchoring_value = Column(Numeric(8, 6), nullable=False, default=0.01) 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): class quotations(MainTableMixin, MAIN_BASE):
@ -204,8 +203,8 @@ class quotations(MainTableMixin, MAIN_BASE):
manager_email = Column(String(255), nullable=True) manager_email = Column(String(255), nullable=True)
manager_contact_number = Column(String(20), nullable=True) manager_contact_number = Column(String(20), nullable=True)
memo = Column(String(100), nullable=True) memo = Column(String(100), nullable=True)
md_price = Column(BigInteger, nullable=True) # MD 제시가(원). 목표가 산정 최우선값 — 입력은 견적생성 모달 md_price = Column(BigInteger, nullable=True)
supplier_type = Column(SmallInteger, nullable=True) # 협력사 유형(SupplierType). 재견적은 1:1이라 견적에 박는다. 입력은 견적생성 모달 supplier_type = Column(SmallInteger, nullable=True)
iteration = Column(Integer, nullable=False, default=0) iteration = Column(Integer, nullable=False, default=0)
preferred_sp_yn = Column(Boolean, nullable=True) preferred_sp_yn = Column(Boolean, nullable=True)
@ -228,7 +227,7 @@ class sessions(MainTableMixin, MAIN_BASE):
qt_round = Column(Integer, nullable=False) # 견적 라운드 스냅샷 qt_round = Column(Integer, nullable=False) # 견적 라운드 스냅샷
qt_type = Column(SmallInteger, nullable=False) # QuotationType 스냅샷 qt_type = Column(SmallInteger, nullable=False) # QuotationType 스냅샷
target_price = Column(BigInteger, nullable=False) # 목표가(원) 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 코드 status = Column(SmallInteger, nullable=False) # SessionStatus 코드
bid_price = Column(BigInteger, nullable=True) # 입찰가(원) bid_price = Column(BigInteger, nullable=True) # 입찰가(원)
bid_at = Column(DateTime(timezone=True), 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_reason = Column(String(255), nullable=True)
reject_price = Column(BigInteger, nullable=True) reject_price = Column(BigInteger, nullable=True)
reject_delivery_type = Column(SmallInteger, nullable=True) # DeliveryType 코드 reject_delivery_type = Column(SmallInteger, nullable=True) # DeliveryType 코드
email_sent_at = Column(DateTime(timezone=True), nullable=True) # 협상 초청 메일 발송 시각(NULL=미발송)
class chats(MainTableMixin, MAIN_BASE): class chats(MainTableMixin, MAIN_BASE):

View File

@ -4,7 +4,7 @@ from typing import Optional
from fastapi import Query from fastapi import Query
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from common.enums import ErrorType from common.enums import ErrorType, UserRole
class StructModel: class StructModel:
@ -70,9 +70,12 @@ class UserInfo(StructModel):
user_id: str # users.user_id (uuid) — 데이터 스코프 키 user_id: str # users.user_id (uuid) — 데이터 스코프 키
id: str # users.id (로그인 아이디) — get_me 재조회 키 id: str # users.id (로그인 아이디) — get_me 재조회 키
company_id: str # users.company_id (uuid) — 멀티테넌트 스코프 키 company_id: str # users.company_id (uuid) — 멀티테넌트 스코프 키
role: int # users.role (UserRole) — 권한 게이트(최고관리자 등) 판단 키
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs) -> None:
super().__init__() super().__init__()
# 구버전 토큰(role 미포함) 도 디코딩되도록 기본값을 먼저 깔고 kwargs 로 덮어쓴다.
self.role = UserRole.USER.value
for dictionary in args: for dictionary in args:
for key in dictionary: for key in dictionary:
setattr(self, key, dictionary[key]) setattr(self, key, dictionary[key])

View File

@ -53,3 +53,23 @@ class StorageConfig(ConfigModel):
azure_blob_sas_token: str = "" # SAS 토큰(쿼리스트링). 만료 있음 — 만료되면 업로드 실패 azure_blob_sas_token: str = "" # SAS 토큰(쿼리스트링). 만료 있음 — 만료되면 업로드 실패
blob_root: str = "negodata" # 컨테이너 내 최상위 디렉터리(infinith 파일과 분리) blob_root: str = "negodata" # 컨테이너 내 최상위 디렉터리(infinith 파일과 분리)
max_image_mb: int = 4 # 업로드 허용 최대 크기(MB). 프론트 ImageDropzone 와 일치 max_image_mb: int = 4 # 업로드 허용 최대 크기(MB). 프론트 ImageDropzone 와 일치
# 협상 초청 메일 발송 설정. services/email.py 가 ACS → SMTP 순으로 시도한다.
# 1순위: Azure Communication Services(ACS) Email — endpoint + accesskey.
# azure_acs_sender 는 검증된 MailFrom 주소(예: donotreply@negodata.o2o.kr). Blob 과 별개 리소스다.
# 2순위(폴백): SMTP — ACS 미설정 시 사용. 둘 다 비우면 발송 시 EmailUnavailable.
class MailConfig(ConfigModel):
azure_acs_endpoint: str = ""
azure_acs_accesskey: str = ""
azure_acs_sender: str = ""
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_from: str = "Negodata <no-reply@negodata.o2o.kr>"
smtp_starttls: bool = True
@property
def acs_configured(self) -> bool:
return bool(self.azure_acs_endpoint and self.azure_acs_accesskey and self.azure_acs_sender)

View File

@ -1,7 +1,7 @@
import os import os
from config.config_loader import Configs 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 로 변경. # 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경.
APP_ENV = os.environ.get("APP_ENV", "local") 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) main_db_config: MainDBConfig = configs.get(MainDBConfig)
jwt_token_config: JwtToken = configs.get(JwtToken) jwt_token_config: JwtToken = configs.get(JwtToken)
storage_config: StorageConfig = configs.get(StorageConfig) 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 그대로. # DB 접속 env override (config.local.toml 유지, 도커에서 host 만 교체). 로컬은 env 미설정 → toml 그대로.

View File

@ -83,6 +83,18 @@ class IQuotationCRUD(ABC):
async def list_used_cards(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]: async def list_used_cards(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]:
pass 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 @abstractmethod
async def session_counts(self, cdb: AsyncSession, qt_ids) -> Tuple[ErrorType, dict]: async def session_counts(self, cdb: AsyncSession, qt_ids) -> Tuple[ErrorType, dict]:
pass pass
@ -360,11 +372,10 @@ class QuotationCRUD(IQuotationCRUD):
return ErrorType.DB_RUN_FAILED, None return ErrorType.DB_RUN_FAILED, None
async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]: async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]:
"""견적 세팅의 율: {margin, fee, anchoring}. 목표가·앵커링가 산정 입력.""" """견적 세팅의 율: {margin, anchoring}. 목표가·앵커링가 산정 입력. (인터넷 수수료는 상수)"""
try: try:
query = select( query = select(
quotation_settings.target_margin_rate, quotation_settings.target_margin_rate,
quotation_settings.internet_average_fee,
quotation_settings.anchoring_value, quotation_settings.anchoring_value,
).where(quotation_settings.qt_setting_id == qt_setting_id).limit(1) ).where(quotation_settings.qt_setting_id == qt_setting_id).limit(1)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query) err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
@ -375,8 +386,7 @@ class QuotationCRUD(IQuotationCRUD):
r = rows[0] r = rows[0]
return ErrorType.SUCCESS, { return ErrorType.SUCCESS, {
"margin": float(r[0]) if r[0] is not None else None, "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[1]) if r[1] is not None else None,
"anchoring": float(r[2]) if r[2] is not None else None,
} }
except Exception as ex: except Exception as ex:
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
@ -617,6 +627,56 @@ class QuotationCRUD(IQuotationCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [] 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]: async def list_chats(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]:
try: try:
query = ( query = (

View File

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

View File

@ -11,6 +11,7 @@ from common.utils.gtime import GTime
from config.server_configs import web_server_config from config.server_configs import web_server_config
from scheduler import shutdown_scheduler, start_scheduler from scheduler import shutdown_scheduler, start_scheduler
import router.v1.auth.account import router.v1.auth.account
import router.v1.company.user
import router.v1.item.item import router.v1.item.item
import router.v1.supplier.supplier import router.v1.supplier.supplier
import router.v1.card.card import router.v1.card.card
@ -50,7 +51,8 @@ async def log_time(request: Request, call_next):
start_time = time.time() start_time = time.time()
response = await call_next(request) response = await call_next(request)
elapsed = time.time() - start_time 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 return response
@ -61,6 +63,7 @@ async def healthz():
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include. # 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
app.include_router(router.v1.auth.account.router) 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.item.item.router)
app.include_router(router.v1.supplier.supplier.router) app.include_router(router.v1.supplier.supplier.router)
app.include_router(router.v1.card.card.router) app.include_router(router.v1.card.card.router)

View File

@ -92,6 +92,7 @@ class SessionData(WebPacketProtocol):
qt_round: int qt_round: int
qt_type: QuotationType qt_type: QuotationType
target_price: int target_price: int
target_anchoring_price: Optional[int] = None # 앵커링가(원). 목표가×(1−앵커링율)
status: SessionStatus status: SessionStatus
bid_price: Optional[int] = None bid_price: Optional[int] = None
bid_at: Optional[datetime] = None bid_at: Optional[datetime] = None
@ -99,6 +100,7 @@ class SessionData(WebPacketProtocol):
reject_reason: Optional[str] = None reject_reason: Optional[str] = None
reject_price: Optional[int] = None reject_price: Optional[int] = None
reject_delivery_type: Optional[DeliveryType] = None reject_delivery_type: Optional[DeliveryType] = None
email_sent_at: Optional[datetime] = None # 협상 초청 메일 발송 시각(None=미발송). 프론트 발송배지/재발송 판단
url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성 url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성
@ -107,6 +109,13 @@ class Res_CreateQuotation(Res_WebPacketProtocol):
session_count: int = 0 # 함께 생성된 협상 세션 수(토스트 표시용). 세션 풀바디는 별도 GET 으로 조회 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): class Res_QuotationStatus(Res_WebPacketProtocol):
qt_id: Optional[uuid.UUID] = None qt_id: Optional[uuid.UUID] = None
job_status: int = 0 job_status: int = 0

View File

@ -12,6 +12,7 @@ from .protocol import (
Res_CreateQuotation, Res_CreateQuotation,
Res_DeleteQuotation, Res_DeleteQuotation,
Res_LastSupplierType, Res_LastSupplierType,
Res_NotifySessions,
Res_Quotation, Res_Quotation,
Res_QuotationCards, Res_QuotationCards,
Res_QuotationList, 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))) 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="채팅 상세") @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)): 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))) 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="낙찰 결과") @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)): 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))) return RemoveNoneResponse(await service.get_result(str(qt_id)))

View File

@ -14,14 +14,12 @@ class QuotationSettingProtocol(WebPacketProtocol):
class Req_CreateQuotationSetting(QuotationSettingProtocol): class Req_CreateQuotationSetting(QuotationSettingProtocol):
target_margin_rate: float target_margin_rate: float
anchoring_value: float = 0.01 anchoring_value: float = 0.01
internet_average_fee: float = 0.078 # 인터넷 평균 수수료율(목표가 인터넷가 차감)
card_count: int = 3 card_count: int = 3
class Req_UpdateQuotationSetting(QuotationSettingProtocol): class Req_UpdateQuotationSetting(QuotationSettingProtocol):
target_margin_rate: Optional[float] = None target_margin_rate: Optional[float] = None
anchoring_value: Optional[float] = None anchoring_value: Optional[float] = None
internet_average_fee: Optional[float] = None # 인터넷 평균 수수료율
card_count: Optional[int] = None card_count: Optional[int] = None
@ -32,7 +30,6 @@ class QuotationSettingData(WebPacketProtocol):
user_id: Optional[uuid.UUID] = None user_id: Optional[uuid.UUID] = None
target_margin_rate: float target_margin_rate: float
anchoring_value: float anchoring_value: float
internet_average_fee: float = 0.078 # 인터넷 평균 수수료율
card_count: int card_count: int
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None

View File

@ -0,0 +1,125 @@
"""협상 초청 메일 발송.
1순위: Azure Communication Services(ACS) Email (endpoint + accesskey + 검증된 sender).
2순위(폴백): SMTP (aiosmtplib).
둘 다 미설정이면 EmailUnavailable 을 던진다(조용한 실패 0).
설정 출처: config.<APP_ENV>.toml 의 [MailConfig] (config/config_models.py:MailConfig).
HTML 본문 템플릿: services/email_templates/*.html ($placeholder 치환).
"""
from __future__ import annotations
from datetime import datetime
from email.message import EmailMessage
from html import escape
from pathlib import Path
from string import Template
from zoneinfo import ZoneInfo
from common.logger import LOG
from config.server_configs import mail_config
_KST = ZoneInfo("Asia/Seoul")
# HTML 본문은 코드에 박지 않고 파일에서 읽는다(모듈 로드 시 1회). $placeholder 는 string.Template 로 치환.
_TEMPLATE_DIR = Path(__file__).parent / "email_templates"
_INVITE_HTML = Template((_TEMPLATE_DIR / "invite_email.html").read_text(encoding="utf-8"))
class EmailUnavailable(RuntimeError):
"""ACS·SMTP 모두 미설정이라 발송 채널이 없음."""
async def _send_acs(to: str, subject: str, html: str, text: str) -> None:
"""Azure Communication Services Email — accesskey 인증."""
from azure.communication.email.aio import EmailClient
conn = f"endpoint={mail_config.azure_acs_endpoint};accesskey={mail_config.azure_acs_accesskey}"
message = {
"senderAddress": mail_config.azure_acs_sender,
"recipients": {"to": [{"address": to}]},
"content": {"subject": subject, "plainText": text, "html": html},
}
async with EmailClient.from_connection_string(conn) as client:
poller = await client.begin_send(message)
await poller.result()
LOG.i(f"email sent via ACS → {to} ({subject})")
async def _send_smtp(to: str, subject: str, html: str, text: str) -> None:
import aiosmtplib
msg = EmailMessage()
msg["From"] = mail_config.smtp_from
msg["To"] = to
msg["Subject"] = subject
msg.set_content(text)
msg.add_alternative(html, subtype="html")
await aiosmtplib.send(
msg,
hostname=mail_config.smtp_host,
port=mail_config.smtp_port,
username=mail_config.smtp_user or None,
password=mail_config.smtp_password or None,
start_tls=mail_config.smtp_starttls,
)
LOG.i(f"email sent via SMTP → {to} ({subject})")
async def send_email(to: str, subject: str, html: str, text: str) -> None:
"""ACS(설정 시) → SMTP(폴백) 순으로 1통 발송. 둘 다 없으면 EmailUnavailable."""
if mail_config.acs_configured:
await _send_acs(to, subject, html, text)
return
if mail_config.smtp_host:
await _send_smtp(to, subject, html, text)
return
# endpoint/accesskey 만 있고 sender 누락 시 원인을 명확히 안내.
if mail_config.azure_acs_endpoint and not mail_config.azure_acs_sender:
raise EmailUnavailable("AZURE_ACS_SENDER(검증된 MailFrom 주소) 미설정")
raise EmailUnavailable("이메일 미설정 — ACS(endpoint+accesskey+sender) 또는 SMTP 필요")
def _fmt_deadline(end_time: datetime | None) -> str:
"""협상 마감 시각 → 한국시간 'YYYY-MM-DD HH:MM' 표기. 값 없으면 빈 문자열."""
if not end_time:
return ""
dt = end_time if end_time.tzinfo else end_time.replace(tzinfo=ZoneInfo("UTC"))
return dt.astimezone(_KST).strftime("%Y-%m-%d %H:%M")
def build_invite_email(
*,
supplier_name: str,
quotation_name: str,
qt_number: str,
end_time: datetime | None,
chat_url: str,
) -> tuple[str, str, str]:
"""협상 초청 메일 (제목/HTML/텍스트) 생성.
목표가·앵커링가는 협상 전략 값이라 메일에 담지 않는다(공급사에게 노출 금지).
공급사는 링크로 협상 화면에 진입해 입찰한다.
"""
sp = supplier_name or "협력사"
deadline = _fmt_deadline(end_time) or "미정"
subject = f"[협상 견적 {qt_number}] {quotation_name} — 협상 참여 요청"
# HTML 본문은 invite_email.html 에서 읽어 치환. 값은 escape 해 HTML 인젝션 방지(견적명 등은 사용자 입력).
html = _INVITE_HTML.substitute(
supplier_name=escape(sp),
quotation_name=escape(quotation_name),
qt_number=escape(qt_number),
deadline=escape(deadline),
chat_url=escape(chat_url),
)
text = (
f"{sp} 담당자님, 아래 견적 건의 협상에 참여해 주세요.\n\n"
f" - 견적명: {quotation_name}\n"
f" - 견적번호: {qt_number}\n"
f" - 협상 마감: {deadline}\n\n"
f"협상 참여 링크: {chat_url}\n"
)
return subject, html, text

View File

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

View File

@ -22,6 +22,7 @@ from router.v1.quotation.protocol import (
Res_CreateQuotation, Res_CreateQuotation,
Res_DeleteQuotation, Res_DeleteQuotation,
Res_LastSupplierType, Res_LastSupplierType,
Res_NotifySessions,
Res_Quotation, Res_Quotation,
Res_QuotationCards, Res_QuotationCards,
Res_QuotationList, Res_QuotationList,
@ -30,6 +31,7 @@ from router.v1.quotation.protocol import (
Res_QuotationStatus, Res_QuotationStatus,
Res_SessionChat, Res_SessionChat,
) )
from services.email import EmailUnavailable, build_invite_email, send_email
class QuotationService: class QuotationService:
@ -51,6 +53,9 @@ class QuotationService:
# TODO 하한값 변경 해야함 !!! feat. MarineYang # TODO 하한값 변경 해야함 !!! feat. MarineYang
MIN_REGEN_DURATION = timedelta(hours=1) MIN_REGEN_DURATION = timedelta(hours=1)
# 인터넷 평균 수수료율(상수). 시장 평균값이라 견적/세팅별로 두지 않고 고정. 목표가=인터넷최저가×(1−값).
INTERNET_AVERAGE_FEE = 0.078
def __init__(self, quotation_crud: IQuotationCRUD = Depends(QuotationCRUD)): def __init__(self, quotation_crud: IQuotationCRUD = Depends(QuotationCRUD)):
self.quotation_crud = quotation_crud self.quotation_crud = quotation_crud
@ -73,6 +78,11 @@ class QuotationService:
③ 후보 0개 → 견적 생성 불가(ValueError).""" ③ 후보 0개 → 견적 생성 불가(ValueError)."""
if md_price: if md_price:
return int(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 = [] candidates = []
if internet_lowest: if internet_lowest:
candidates.append(int(internet_lowest) * (1 - (fee or 0.0))) 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), lambda s: self.quotation_crud.get_setting_rates(s, qt_setting_id),
) )
rates = rates if _err == ErrorType.SUCCESS else {} 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 # 판매가 차감 목표마진율 margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율
anchoring = rates.get("anchoring") or 0.0 # 앵커링가 = 목표가×(1−값) anchoring = rates.get("anchoring") or 0.0 # 앵커링가 = 목표가×(1−값)
@ -352,6 +362,8 @@ class QuotationService:
else: else:
internet, purchase, selling = prices.get(iid) or (None, None, None) 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) 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이면 목표가와 동일 ap = int(tp * (1 - anchoring)) # 앵커링가 = floor(목표가×(1−앵커링율)); 율 0이면 목표가와 동일
for sid in supplier_ids: for sid in supplier_ids:
session_objs.append( session_objs.append(
@ -369,7 +381,11 @@ class QuotationService:
end_time=quotation.end_time, 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) res.result.SetResult(ErrorType.QUOTATION_TARGET_PRICE_UNAVAILABLE)
return res return res
@ -694,6 +710,7 @@ class QuotationService:
qt_round=r.qt_round, qt_round=r.qt_round,
qt_type=r.qt_type, qt_type=r.qt_type,
target_price=r.target_price, target_price=r.target_price,
target_anchoring_price=r.target_anchoring_price,
status=r.status, status=r.status,
bid_price=r.bid_price, bid_price=r.bid_price,
bid_at=r.bid_at, bid_at=r.bid_at,
@ -701,6 +718,7 @@ class QuotationService:
reject_reason=r.reject_reason, reject_reason=r.reject_reason,
reject_price=r.reject_price, reject_price=r.reject_price,
reject_delivery_type=r.reject_delivery_type, reject_delivery_type=r.reject_delivery_type,
email_sent_at=r.email_sent_at,
url=self._session_chat_url(r.session_id), url=self._session_chat_url(r.session_id),
) )
for r in rows for r in rows
@ -708,6 +726,106 @@ class QuotationService:
res.total = len(res.sessions) res.total = len(res.sessions)
return res 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: async def list_chats(self, session_id: str) -> Res_SessionChat:
res = Res_SessionChat() res = Res_SessionChat()
sess_uuid = uuid.UUID(session_id) sess_uuid = uuid.UUID(session_id)

View File

@ -66,7 +66,6 @@ class QuotationSettingService:
user_id=uuid.UUID(user_id), user_id=uuid.UUID(user_id),
target_margin_rate=req.target_margin_rate, target_margin_rate=req.target_margin_rate,
anchoring_value=req.anchoring_value, anchoring_value=req.anchoring_value,
internet_average_fee=req.internet_average_fee,
card_count=req.card_count, card_count=req.card_count,
) )
err_type = await DB_SESSION_MNG.execute_lambda_run( err_type = await DB_SESSION_MNG.execute_lambda_run(

View File

@ -6,8 +6,8 @@
*/ */
/** /**
* nego_cards/wild_cards.usage_type 코드값. 카드가 적용되는 견적 구분. * nego_cards/wild_cards.usage_type 코드값. 협상카드 사용 범위(신규/재 견적·협상 양쪽 적용).
공통=신규·재 모두(기본), 신규전용=신규협상/신규견적(QuotationType 3·4), 재전용=재협상/재견적(1·2). 공통=모두 적용(기본), 신규견적전용, 재견적전용.
*/ */
export type CardUsageType = typeof CardUsageType[keyof typeof CardUsageType]; export type CardUsageType = typeof CardUsageType[keyof typeof CardUsageType];

View File

@ -28,6 +28,13 @@ export * from './chatMessageDataScript';
export * from './chatMessageDataStep'; export * from './chatMessageDataStep';
export * from './chatSender'; export * from './chatSender';
export * from './companyData'; 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 './deliveryType';
export * from './errorInfo'; export * from './errorInfo';
export * from './errorInfoCode'; export * from './errorInfoCode';
@ -59,6 +66,7 @@ export * from './listCardsParams';
export * from './listItemsParams'; export * from './listItemsParams';
export * from './listQuotationsParams'; export * from './listQuotationsParams';
export * from './listSuppliersParams'; export * from './listSuppliersParams';
export * from './listUsersParams';
export * from './quotationCardData'; export * from './quotationCardData';
export * from './quotationCardDataCondition'; export * from './quotationCardDataCondition';
export * from './quotationCardDataEditScript'; export * from './quotationCardDataEditScript';
@ -93,7 +101,6 @@ export * from './quotationSettingDataUserId';
export * from './quotationStatus'; export * from './quotationStatus';
export * from './quotationType'; export * from './quotationType';
export * from './reqCheckCodes'; export * from './reqCheckCodes';
export * from './reqCreateAccount';
export * from './reqCreateCard'; export * from './reqCreateCard';
export * from './reqCreateCardCondition'; export * from './reqCreateCardCondition';
export * from './reqCreateCardEditScript'; export * from './reqCreateCardEditScript';
@ -101,6 +108,7 @@ export * from './reqCreateCardMemo';
export * from './reqCreateCardName'; export * from './reqCreateCardName';
export * from './reqCreateCardNumber'; export * from './reqCreateCardNumber';
export * from './reqCreateCardScript'; export * from './reqCreateCardScript';
export * from './reqCreateCompanyUser';
export * from './reqCreateItem'; export * from './reqCreateItem';
export * from './reqCreateItemCategory'; export * from './reqCreateItemCategory';
export * from './reqCreateItemCode'; export * from './reqCreateItemCode';
@ -146,6 +154,12 @@ export * from './reqUpdateCardNumber';
export * from './reqUpdateCardScript'; export * from './reqUpdateCardScript';
export * from './reqUpdateCardStatus'; export * from './reqUpdateCardStatus';
export * from './reqUpdateCardUsageType'; 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 './reqUpdateItem';
export * from './reqUpdateItemCategory'; export * from './reqUpdateItemCategory';
export * from './reqUpdateItemCategoryType'; export * from './reqUpdateItemCategoryType';
@ -167,10 +181,14 @@ export * from './reqUpdateItemQuantityUnit';
export * from './reqUpdateItemSellingPrice'; export * from './reqUpdateItemSellingPrice';
export * from './reqUpdateItemSpec'; export * from './reqUpdateItemSpec';
export * from './reqUpdateItemVatYn'; export * from './reqUpdateItemVatYn';
export * from './reqUpdateMe';
export * from './reqUpdateMeContactNumber';
export * from './reqUpdateMeEmail';
export * from './reqUpdateMeName';
export * from './reqUpdateMePassword';
export * from './reqUpdateQuotationSetting'; export * from './reqUpdateQuotationSetting';
export * from './reqUpdateQuotationSettingAnchoringValue'; export * from './reqUpdateQuotationSettingAnchoringValue';
export * from './reqUpdateQuotationSettingCardCount'; export * from './reqUpdateQuotationSettingCardCount';
export * from './reqUpdateQuotationSettingInternetAverageFee';
export * from './reqUpdateQuotationSettingTargetMarginRate'; export * from './reqUpdateQuotationSettingTargetMarginRate';
export * from './reqUpdateSupplier'; export * from './reqUpdateSupplier';
export * from './reqUpdateSupplierCode'; export * from './reqUpdateSupplierCode';
@ -186,13 +204,18 @@ export * from './resCardListMsg';
export * from './resCardMsg'; export * from './resCardMsg';
export * from './resCheckCodes'; export * from './resCheckCodes';
export * from './resCheckCodesMsg'; export * from './resCheckCodesMsg';
export * from './resCreateAccount'; export * from './resCompanyUser';
export * from './resCreateAccountMsg'; export * from './resCompanyUserList';
export * from './resCompanyUserListMsg';
export * from './resCompanyUserMsg';
export * from './resCompanyUserUser';
export * from './resCreateQuotation'; export * from './resCreateQuotation';
export * from './resCreateQuotationMsg'; export * from './resCreateQuotationMsg';
export * from './resCreateQuotationQtId'; export * from './resCreateQuotationQtId';
export * from './resDeleteCard'; export * from './resDeleteCard';
export * from './resDeleteCardMsg'; export * from './resDeleteCardMsg';
export * from './resDeleteCompanyUser';
export * from './resDeleteCompanyUserMsg';
export * from './resDeleteItem'; export * from './resDeleteItem';
export * from './resDeleteItemMsg'; export * from './resDeleteItemMsg';
export * from './resDeleteQuotation'; export * from './resDeleteQuotation';
@ -229,6 +252,8 @@ export * from './resMeContactNumber';
export * from './resMeEmail'; export * from './resMeEmail';
export * from './resMeMsg'; export * from './resMeMsg';
export * from './resMeName'; export * from './resMeName';
export * from './resNotifySessions';
export * from './resNotifySessionsMsg';
export * from './resQuotation'; export * from './resQuotation';
export * from './resQuotationCards'; export * from './resQuotationCards';
export * from './resQuotationCardsMsg'; export * from './resQuotationCardsMsg';
@ -268,9 +293,11 @@ export * from './resSupplierSupplier';
export * from './sessionData'; export * from './sessionData';
export * from './sessionDataBidAt'; export * from './sessionDataBidAt';
export * from './sessionDataBidPrice'; export * from './sessionDataBidPrice';
export * from './sessionDataEmailSentAt';
export * from './sessionDataRejectDeliveryType'; export * from './sessionDataRejectDeliveryType';
export * from './sessionDataRejectPrice'; export * from './sessionDataRejectPrice';
export * from './sessionDataRejectReason'; export * from './sessionDataRejectReason';
export * from './sessionDataTargetAnchoringPrice';
export * from './sessionStatus'; export * from './sessionStatus';
export * from './supplierData'; export * from './supplierData';
export * from './supplierDataCode'; export * from './supplierDataCode';
@ -282,6 +309,7 @@ export * from './supplierDataPriority';
export * from './supplierDataUpdatedAt'; export * from './supplierDataUpdatedAt';
export * from './supplierType'; export * from './supplierType';
export * from './userRole'; export * from './userRole';
export * from './userStatus';
export * from './validationError'; export * from './validationError';
export * from './validationErrorCtx'; export * from './validationErrorCtx';
export * from './validationErrorLocItem'; export * from './validationErrorLocItem';

View File

@ -0,0 +1,22 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ListUsersParams = {
/**
* 로그인ID/이름/이메일 검색
*/
search?: string | null;
/**
* @minimum 1
*/
page?: number;
/**
* @minimum 1
* @maximum 100
*/
size?: number;
};

View File

@ -13,7 +13,6 @@ export interface QuotationSettingData {
user_id?: QuotationSettingDataUserId; user_id?: QuotationSettingDataUserId;
target_margin_rate: number; target_margin_rate: number;
anchoring_value: number; anchoring_value: number;
internet_average_fee?: number;
card_count: number; card_count: number;
created_at?: QuotationSettingDataCreatedAt; created_at?: QuotationSettingDataCreatedAt;
updated_at?: QuotationSettingDataUpdatedAt; updated_at?: QuotationSettingDataUpdatedAt;

View File

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

View File

@ -0,0 +1,17 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { ReqUpdateMeName } from './reqUpdateMeName';
import type { ReqUpdateMeEmail } from './reqUpdateMeEmail';
import type { ReqUpdateMeContactNumber } from './reqUpdateMeContactNumber';
import type { ReqUpdateMePassword } from './reqUpdateMePassword';
export interface ReqUpdateMe {
name?: ReqUpdateMeName;
email?: ReqUpdateMeEmail;
contact_number?: ReqUpdateMeContactNumber;
password?: ReqUpdateMePassword;
}

View File

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

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ReqUpdateMeEmail = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ReqUpdateMeName = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ReqUpdateMePassword = string | null;

View File

@ -6,12 +6,10 @@
*/ */
import type { ReqUpdateQuotationSettingTargetMarginRate } from './reqUpdateQuotationSettingTargetMarginRate'; import type { ReqUpdateQuotationSettingTargetMarginRate } from './reqUpdateQuotationSettingTargetMarginRate';
import type { ReqUpdateQuotationSettingAnchoringValue } from './reqUpdateQuotationSettingAnchoringValue'; import type { ReqUpdateQuotationSettingAnchoringValue } from './reqUpdateQuotationSettingAnchoringValue';
import type { ReqUpdateQuotationSettingInternetAverageFee } from './reqUpdateQuotationSettingInternetAverageFee';
import type { ReqUpdateQuotationSettingCardCount } from './reqUpdateQuotationSettingCardCount'; import type { ReqUpdateQuotationSettingCardCount } from './reqUpdateQuotationSettingCardCount';
export interface ReqUpdateQuotationSetting { export interface ReqUpdateQuotationSetting {
target_margin_rate?: ReqUpdateQuotationSettingTargetMarginRate; target_margin_rate?: ReqUpdateQuotationSettingTargetMarginRate;
anchoring_value?: ReqUpdateQuotationSettingAnchoringValue; anchoring_value?: ReqUpdateQuotationSettingAnchoringValue;
internet_average_fee?: ReqUpdateQuotationSettingInternetAverageFee;
card_count?: ReqUpdateQuotationSettingCardCount; card_count?: ReqUpdateQuotationSettingCardCount;
} }

View File

@ -0,0 +1,17 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { ErrorInfo } from './errorInfo';
import type { ResNotifySessionsMsg } from './resNotifySessionsMsg';
export interface ResNotifySessions {
result?: ErrorInfo;
msg?: ResNotifySessionsMsg;
sent?: number;
failed?: number;
skipped?: number;
total?: number;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ResNotifySessionsMsg = string | null;

View File

@ -5,12 +5,14 @@
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
import type { QuotationType } from './quotationType'; import type { QuotationType } from './quotationType';
import type { SessionDataTargetAnchoringPrice } from './sessionDataTargetAnchoringPrice';
import type { SessionStatus } from './sessionStatus'; import type { SessionStatus } from './sessionStatus';
import type { SessionDataBidPrice } from './sessionDataBidPrice'; import type { SessionDataBidPrice } from './sessionDataBidPrice';
import type { SessionDataBidAt } from './sessionDataBidAt'; import type { SessionDataBidAt } from './sessionDataBidAt';
import type { SessionDataRejectReason } from './sessionDataRejectReason'; import type { SessionDataRejectReason } from './sessionDataRejectReason';
import type { SessionDataRejectPrice } from './sessionDataRejectPrice'; import type { SessionDataRejectPrice } from './sessionDataRejectPrice';
import type { SessionDataRejectDeliveryType } from './sessionDataRejectDeliveryType'; import type { SessionDataRejectDeliveryType } from './sessionDataRejectDeliveryType';
import type { SessionDataEmailSentAt } from './sessionDataEmailSentAt';
export interface SessionData { export interface SessionData {
session_id: string; session_id: string;
@ -21,6 +23,7 @@ export interface SessionData {
qt_round: number; qt_round: number;
qt_type: QuotationType; qt_type: QuotationType;
target_price: number; target_price: number;
target_anchoring_price?: SessionDataTargetAnchoringPrice;
status: SessionStatus; status: SessionStatus;
bid_price?: SessionDataBidPrice; bid_price?: SessionDataBidPrice;
bid_at?: SessionDataBidAt; bid_at?: SessionDataBidAt;
@ -28,5 +31,6 @@ export interface SessionData {
reject_reason?: SessionDataRejectReason; reject_reason?: SessionDataRejectReason;
reject_price?: SessionDataRejectPrice; reject_price?: SessionDataRejectPrice;
reject_delivery_type?: SessionDataRejectDeliveryType; reject_delivery_type?: SessionDataRejectDeliveryType;
email_sent_at?: SessionDataEmailSentAt;
url?: string; url?: string;
} }

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type SessionDataEmailSentAt = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type SessionDataTargetAnchoringPrice = number | null;

View File

@ -6,15 +6,16 @@
*/ */
/** /**
* suppliers.type 코드값(KTC 앵커링 기준). 0=기타는 매칭 실패 폴백용 — UI 선택지엔 미노출. * quotations.supplier_type 코드값. 없음(0,미지정)/유통(1)/제조(2)/총판(3).
없음은 프론트 폼에 '없음'으로 노출. KTC 앵커링 코드(기타=0)와 매핑 시 0↔없음 대응.
*/ */
export type SupplierType = typeof SupplierType[keyof typeof SupplierType]; export type SupplierType = typeof SupplierType[keyof typeof SupplierType];
// eslint-disable-next-line @typescript-eslint/no-redeclare // eslint-disable-next-line @typescript-eslint/no-redeclare
export const SupplierType = { export const SupplierType = {
NONE: 0,
DISTRIBUTION: 1, DISTRIBUTION: 1,
MANUFACTURE: 2, MANUFACTURE: 2,
SOLE_AGENCY: 3, SOLE_AGENCY: 3,
ETC: 0,
} as const; } as const;

View File

@ -0,0 +1,18 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
/**
* users.status 코드값.
*/
export type UserStatus = typeof UserStatus[keyof typeof UserStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const UserStatus = {
ACTIVE: 1,
INACTIVE: 2,
} as const;

View File

@ -31,6 +31,7 @@ import type {
ResCreateQuotation, ResCreateQuotation,
ResDeleteQuotation, ResDeleteQuotation,
ResLastSupplierType, ResLastSupplierType,
ResNotifySessions,
ResQuotation, ResQuotation,
ResQuotationCards, ResQuotationCards,
ResQuotationList, 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 채팅 상세 * @summary 채팅 상세
*/ */
export const getSessionChat = ( 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 낙찰 결과 * @summary 낙찰 결과
*/ */
export const getQuotationResult = ( export const getQuotationResult = (

View File

@ -1,6 +1,7 @@
import { useState, type ReactNode, type ElementType } from 'react'; import { useState, type ReactNode, type ElementType } from 'react';
import { PageType } from '@/types'; import { PageType } from '@/types';
import { useAuth } from '@/features/auth/useAuth'; import { useAuth } from '@/features/auth/useAuth';
import { ProfileSheet } from '@/features/auth/components/ProfileSheet';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Typography } from '@/components/ui/typography'; import { Typography } from '@/components/ui/typography';
@ -8,6 +9,8 @@ import { cn } from '@/lib/utils';
import { import {
Briefcase, Briefcase,
Users, Users,
UserCog,
UserPen,
FileSpreadsheet, FileSpreadsheet,
Layers, Layers,
LogOut, LogOut,
@ -28,11 +31,13 @@ interface LayoutProps {
type SidebarUser = ReturnType<typeof useAuth>['user']; type SidebarUser = ReturnType<typeof useAuth>['user'];
const menuItems = [ // ownerOnly 항목은 최고관리자에게만 노출된다(렌더 시 user.role 로 필터).
{ type: 'PRODUCTS' as PageType, label: '상품관리', icon: Briefcase, id: 'sidebar-products' }, const menuItems: { type: PageType; label: string; icon: ElementType; id: string; ownerOnly?: boolean }[] = [
{ type: 'PARTNERS' as PageType, label: '협력사관리', icon: Users, id: 'sidebar-partners' }, { type: 'PRODUCTS', label: '상품관리', icon: Briefcase, id: 'sidebar-products' },
{ type: 'QUOTATION' as PageType, label: '견적관리', icon: FileSpreadsheet, id: 'sidebar-quotation' }, { type: 'PARTNERS', label: '협력사관리', icon: Users, id: 'sidebar-partners' },
{ type: 'CARDS' as PageType, label: '협상카드관리', icon: Layers, id: 'sidebar-cards' }, { 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> = { const pageLabelMap: Record<PageType, string> = {
@ -40,6 +45,7 @@ const pageLabelMap: Record<PageType, string> = {
PARTNERS: '협력사관리', PARTNERS: '협력사관리',
QUOTATION: '견적관리', QUOTATION: '견적관리',
CARDS: '협상카드관리', CARDS: '협상카드관리',
MEMBERS: '회원관리',
}; };
export default function Layout({ children, currentPage, setPage, onLogout }: LayoutProps) { 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 [isDark, setIsDark] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(true); // 데스크톱 접기 토글 const [isSidebarOpen, setIsSidebarOpen] = useState(true); // 데스크톱 접기 토글
const [isMobileOpen, setIsMobileOpen] = useState(false); // 모바일 드로어 열림 const [isMobileOpen, setIsMobileOpen] = useState(false); // 모바일 드로어 열림
const [isProfileOpen, setIsProfileOpen] = useState(false); // 내 정보 수정 시트
// 사이드바 펼침 여부(라벨/프로필 노출 기준). 모바일 드로어는 항상 펼친 상태로 본다. // 사이드바 펼침 여부(라벨/프로필 노출 기준). 모바일 드로어는 항상 펼친 상태로 본다.
const expanded = isMobileOpen || isSidebarOpen; const expanded = isMobileOpen || isSidebarOpen;
@ -126,7 +133,9 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
{/* Nav Items */} {/* Nav Items */}
<nav className="mt-2 px-3 space-y-1"> <nav className="mt-2 px-3 space-y-1">
{menuItems.map((item) => ( {menuItems
.filter((item) => !item.ownerOnly || user?.role === '최고관리자')
.map((item) => (
<NavItem <NavItem
key={item.type} key={item.type}
item={item} item={item}
@ -143,6 +152,17 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
{/* Sidebar Footer Controls */} {/* Sidebar Footer Controls */}
<div className="p-2 border-t border-border space-y-1"> <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 */} {/* Theme switcher */}
<Button <Button
variant="ghost" variant="ghost"
@ -205,6 +225,8 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
<Typography variant="mono">Copyright &copy; O2O Inc. All rights reserved</Typography> <Typography variant="mono">Copyright &copy; O2O Inc. All rights reserved</Typography>
</footer> </footer>
</div> </div>
{isProfileOpen && <ProfileSheet open onClose={() => setIsProfileOpen(false)} />}
</div> </div>
); );
} }

View File

@ -21,6 +21,10 @@ const typographyVariants = cva("", {
mono: "text-xs font-mono uppercase tracking-wider text-muted-foreground", mono: "text-xs font-mono uppercase tracking-wider text-muted-foreground",
// 사이드바/헤더 등 chrome 메타텍스트: type scale 최소(text-xs=12px)보다 작은 11px 캡션 // 사이드바/헤더 등 chrome 메타텍스트: type scale 최소(text-xs=12px)보다 작은 11px 캡션
caption: "text-[11px] text-muted-foreground leading-normal", 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" }, defaultVariants: { variant: "body" },
@ -37,6 +41,7 @@ const defaultTag: Record<NonNullable<VariantProps<typeof typographyVariants>["va
label: "span", label: "span",
mono: "span", mono: "span",
caption: "span", caption: "span",
link: "span",
} }
type TypographyProps = React.HTMLAttributes<HTMLElement> & type TypographyProps = React.HTMLAttributes<HTMLElement> &

View File

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

View File

@ -2,6 +2,8 @@ import type { ReactNode } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { Package } from 'lucide-react'; import { Package } from 'lucide-react';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { typographyVariants } from '@/components/ui/typography';
import { InfoField } from './InfoField'; import { InfoField } from './InfoField';
import { QuotationStatusBadge } from './StatusPill'; import { QuotationStatusBadge } from './StatusPill';
import type { QuotationData } from '@/api/generated/model/quotationData'; import type { QuotationData } from '@/api/generated/model/quotationData';
@ -62,7 +64,10 @@ export function DrawerHeaderCards({
const productSpecRows = currentProduct const productSpecRows = currentProduct
? [ ? [
{ label: '상품코드', value: currentProduct.code || '-' }, { 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.model_name || '-' },
{ label: '규격', value: currentProduct.spec || '-' }, { label: '규격', value: currentProduct.spec || '-' },
{ label: '제조사', value: currentProduct.manufacturer || '-' }, { label: '제조사', value: currentProduct.manufacturer || '-' },
@ -146,7 +151,7 @@ export function DrawerHeaderCards({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<Link <Link
to={`/products?detail=${currentProduct.id}`} 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 || ''} — 상품 상세로 이동`} title={`${currentProduct.name || ''} — 상품 상세로 이동`}
> >
{currentProduct.name || '-'} {currentProduct.name || '-'}
@ -182,7 +187,6 @@ export function DrawerHeaderCards({
<InfoField <InfoField
label="카드 사용 횟수" label="카드 사용 횟수"
value={selectedSettingObj.card_use_count} value={selectedSettingObj.card_use_count}
className="col-span-2 col-start-1"
/> />
</div> </div>
) : ( ) : (

View File

@ -1,5 +1,6 @@
import { Link } from 'react-router'; import { Link } from 'react-router';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { typographyVariants } from '@/components/ui/typography';
import { StatusPill } from './StatusPill'; import { StatusPill } from './StatusPill';
import { mapServerCardView } from '../../types'; import { mapServerCardView } from '../../types';
@ -26,7 +27,7 @@ export function QuotationCardsTab({ quotationCardViews }: { quotationCardViews:
{qc.card_id ? ( {qc.card_id ? (
<Link <Link
to={`/cards?detail=${qc.card_id}`} to={`/cards?detail=${qc.card_id}`}
className="hover:text-primary hover:underline" className={typographyVariants({ variant: 'link' })}
title={`${qc.card_name} — 협상카드 상세로 이동`} title={`${qc.card_name} — 협상카드 상세로 이동`}
> >
{qc.card_name} {qc.card_name}

View File

@ -1,6 +1,10 @@
import { MessageSquare, ExternalLink, Copy } from 'lucide-react'; import { useState } from 'react';
import { MessageSquare, Copy, Mail, MailCheck, Send } from 'lucide-react';
import { showToast } from '@/lib/notify'; import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; 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 { StatusPill, sessionStatusTone } from './StatusPill';
import { mapServerSessionView, sessionStatusLabel } from '../../types'; import { mapServerSessionView, sessionStatusLabel } from '../../types';
@ -8,20 +12,95 @@ type SessionView = ReturnType<typeof mapServerSessionView>;
export function SessionsStatusTab({ export function SessionsStatusTab({
sessionViews, sessionViews,
canNotify,
onOpenChat, onOpenChat,
onShowTarget,
onNotifyAll,
onNotifyOne,
}: { }: {
sessionViews: SessionView[]; sessionViews: SessionView[];
/** 초청 메일 발송 권한(견적 소유자만). false 면 발송 버튼 비활성. */
canNotify: boolean;
onOpenChat: (sessionId: string) => void; 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 ( return (
<div className="space-y-4"> <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"> <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"> <TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
<TableRow> <TableRow>
<TableHead className="p-3 font-semibold">세션 ID</TableHead> <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">협력사</TableHead>
<TableHead className="p-3 font-semibold font-sans">협상 URL</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 font-sans">상품</TableHead>
<TableHead className="p-3 font-semibold text-center font-sans">협상상태</TableHead> <TableHead className="p-3 font-semibold text-center font-sans">협상상태</TableHead>
<TableHead className="p-3 font-semibold text-right">목표가</TableHead> <TableHead className="p-3 font-semibold text-right">목표가</TableHead>
@ -36,7 +115,7 @@ export function SessionsStatusTab({
<TableBody className="divide-y divide-border"> <TableBody className="divide-y divide-border">
{sessionViews.length === 0 && ( {sessionViews.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={12} className="p-12 text-center text-muted-foreground"> <TableCell colSpan={13} className="p-12 text-center text-muted-foreground">
참여 중인 협상 세션이 없습니다. (리스트가 비어 있습니다) 참여 중인 협상 세션이 없습니다. (리스트가 비어 있습니다)
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -58,37 +137,65 @@ export function SessionsStatusTab({
</TableCell> </TableCell>
<TableCell className="p-3 font-mono"> <TableCell className="p-3 font-mono">
{sess.url ? ( {sess.url ? (
<div className="flex items-center gap-1.5"> <button
<a onClick={() => {
href={sess.url} void navigator.clipboard?.writeText(sess.url);
target="_blank" showToast('협상 URL을 복사했습니다.', 'success');
rel="noopener noreferrer" }}
title={sess.url} title="협상 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" className="p-1 hover:bg-muted rounded text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
> >
<ExternalLink size={11} /> 세션 열기 <Copy size={12} />
</a> </button>
<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>
) : ( ) : (
<span className="text-muted-foreground">-</span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </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 font-semibold font-sans">{sess.item_name}</TableCell>
<TableCell className="p-3 text-center"> <TableCell className="p-3 text-center">
<StatusPill tone={sessionStatusTone(sess.status)}>{sessionStatusLabel(sess.status)}</StatusPill> <StatusPill tone={sessionStatusTone(sess.status)}>{sessionStatusLabel(sess.status)}</StatusPill>
</TableCell> </TableCell>
<TableCell className="p-3 text-right font-bold text-muted-foreground"> <TableCell className="p-3 text-right">
₩{sess.target_price?.toLocaleString() || '-'} <button
type="button"
onClick={() => onShowTarget(sess.session_id)}
title="목표가 산정 내역 보기"
className={cn(typographyVariants({ variant: 'link' }), 'font-bold')}
>
₩{sess.target_price?.toLocaleString() || '-'}
</button>
</TableCell> </TableCell>
<TableCell className="p-3 text-right font-bold text-foreground"> <TableCell className="p-3 text-right font-bold text-foreground">
{sess.bid_price ? `₩${sess.bid_price.toLocaleString()}` : '-'} {sess.bid_price ? `₩${sess.bid_price.toLocaleString()}` : '-'}

View File

@ -0,0 +1,162 @@
import { X, Check } from 'lucide-react';
import { Typography } from '@/components/ui/typography';
// 세션 목표가 산정내역 모달. backend _calc_target_price 로직을 그대로 재구성해 후보·채택을 보여준다.
// (신규견적은 인터넷최저가만 후보, 재는 매입가·판매가까지 / md 입력가 최우선. 앵커링은 설정 anchoring_value 고정율.)
type TargetPriceModalProps = {
onClose: () => void;
qtNumber: string;
itemName: string;
vatYn?: boolean | null;
deliveryFeeYn?: boolean | null;
category?: string | null;
supplierTypeLabel: string;
targetPrice: number;
anchoringPrice: number;
mdPrice?: number | null;
internetLowest?: number | null;
purchase?: number | null;
selling?: number | null;
fee: number;
margin: number;
anchoringValue: number;
isNew: boolean;
};
const won = (n: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-');
type CandKey = 'md' | 'net' | 'sell' | 'buy';
export function TargetPriceModal({
onClose,
qtNumber,
itemName,
vatYn,
deliveryFeeYn,
category,
supplierTypeLabel,
targetPrice,
anchoringPrice,
mdPrice,
internetLowest,
purchase,
selling,
fee,
margin,
anchoringValue,
isNew,
}: TargetPriceModalProps) {
// 후보값 계산 (신규는 인터넷최저가만, 재는 매입가·판매가까지)
const md = mdPrice && mdPrice > 0 ? Math.round(mdPrice) : null;
const net = internetLowest && internetLowest > 0 ? Math.round(internetLowest * (1 - fee)) : null;
const sell = !isNew && selling && selling > 0 ? Math.round(selling * (1 - margin)) : null;
const buy = !isNew && purchase && purchase > 0 ? Math.round(purchase) : null;
// 채택 후보: md 최우선, 없으면 유효 후보 중 최소값
let applied: CandKey | null = null;
if (md != null) {
applied = 'md';
} else {
const pool = ([['net', net], ['sell', sell], ['buy', buy]] as [CandKey, number | null][]).filter(
(c): c is [CandKey, number] => c[1] != null,
);
if (pool.length) applied = pool.reduce((m, c) => (c[1] < m[1] ? c : m))[0];
}
const rows: { key: CandKey; label: string; sub?: string; value: number | null }[] = [
{ key: 'md', label: 'MD 입력가', value: md },
{ key: 'net', label: '인터넷 최저가', sub: '인터넷 평균 수수료 적용', value: net },
{ key: 'sell', label: '판매가', sub: '목표 마진율 적용', value: sell },
{ key: 'buy', label: '매입가', value: buy },
];
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-xs"
onClick={onClose}
>
<div
className="w-full max-w-md bg-card border border-border rounded-lg shadow-2xl p-6 font-mono text-xs animate-scale-up"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="flex items-center justify-between pb-3 border-b border-border">
<Typography variant="h3">목표가</Typography>
<button onClick={onClose} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
<X size={18} />
</button>
</div>
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground mt-3">
견적번호: {qtNumber} · 상품: {itemName}
</Typography>
<Typography as="p" variant="small" className="text-[11px] font-semibold text-rose-500 mt-1">
배송비: {deliveryFeeYn ? '배송비포함' : '배송비별도'} · 부가세: {vatYn ? 'VAT포함' : 'VAT별도'}
</Typography>
{/* 목표가 */}
<div className="flex items-center justify-between gap-3 mt-5">
<Typography variant="label" className="font-bold">목표가</Typography>
<div className="flex-1 text-right bg-muted/50 border border-border rounded px-3 py-2 font-bold text-base text-foreground">
{won(targetPrice)}
</div>
</div>
{/* 선정방식 */}
<div className="mt-4 bg-muted/40 border border-border rounded p-3 space-y-1">
<Typography as="p" variant="small" className="font-bold text-foreground text-[11px]">목표가 선정방식</Typography>
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">1. MD 입력값 존재 시, 최우선 적용</Typography>
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">2. 다음 중 가장 작은 값 — 인터넷최저가×(1−수수료) | 매입가 | 판매가×(1−목표 마진율)</Typography>
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">
* 인터넷 평균 수수료: {fee} · 목표 마진율: {margin}{isNew ? ' · 신규견적이라 인터넷최저가만 적용' : ''}
</Typography>
</div>
{/* 후보 */}
<div className="mt-4 space-y-2">
{rows.map((r) => {
const on = r.key === applied;
return (
<div key={r.key} className="flex items-center gap-2">
<span className={`w-4 shrink-0 ${on ? 'text-emerald-600' : 'text-transparent'}`}>
{on ? <Check size={14} /> : null}
</span>
<div className="flex-1">
<Typography as="span" variant="small" className={`text-[11px] ${on ? 'font-bold text-foreground' : 'text-muted-foreground'}`}>
{r.label}
</Typography>
{r.sub && (
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground ml-1">
({r.sub})
</Typography>
)}
</div>
<div
className={`text-right min-w-[96px] px-2 py-1 rounded border ${
on
? 'border-emerald-300 bg-emerald-50 dark:bg-emerald-950/20 font-bold text-foreground'
: 'border-border bg-muted/30 text-muted-foreground'
}`}
>
{won(r.value)}
</div>
</div>
);
})}
</div>
{/* 앵커링 (negodata: 설정 anchoring_value 고정율) */}
<div className="mt-4 bg-muted/40 border border-border rounded p-3 space-y-1">
<Typography as="p" variant="small" className="font-bold text-foreground text-[11px]">앵커링</Typography>
{category && (
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">서비스 카테고리: {category}</Typography>
)}
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">공급 업체 유형: {supplierTypeLabel}</Typography>
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">앵커링 값: {anchoringValue}</Typography>
<Typography as="p" variant="small" className="text-[11px] font-bold text-foreground">
앵커링가: {won(anchoringPrice)} <span className="font-normal text-[10px] text-muted-foreground">= 목표가×(1−{anchoringValue})</span>
</Typography>
</div>
</div>
</div>
);
}

View File

@ -11,6 +11,7 @@ import { useListSuppliers } from '@/api/generated/supplier/supplier';
import { useListSettings } from '@/api/generated/quotation-setting/quotation-setting'; import { useListSettings } from '@/api/generated/quotation-setting/quotation-setting';
import { useQuotationChain } from '../../hooks/useQuotationChain'; import { useQuotationChain } from '../../hooks/useQuotationChain';
import { useScrollLock } from '@/lib/useScrollLock'; import { useScrollLock } from '@/lib/useScrollLock';
import { useAuthStore } from '@/stores/auth';
import type { QuotationData } from '@/api/generated/model/quotationData'; import type { QuotationData } from '@/api/generated/model/quotationData';
import { import {
mapItem, mapItem,
@ -18,12 +19,14 @@ import {
mapSetting, mapSetting,
mapServerSessionView, mapServerSessionView,
mapServerCardView, mapServerCardView,
supplierTypeOptions,
} from '../../types'; } from '../../types';
import { QuotationStatus } from '@/api/generated/model'; import { QuotationStatus, QuotationType } from '@/api/generated/model';
import { DrawerHeaderCards } from './DrawerHeaderCards'; import { DrawerHeaderCards } from './DrawerHeaderCards';
import { RoundTimeline } from './RoundTimeline'; import { RoundTimeline } from './RoundTimeline';
import { RegenerateModal } from './RegenerateModal'; import { RegenerateModal } from './RegenerateModal';
import { SessionsStatusTab } from './SessionsStatusTab'; import { SessionsStatusTab } from './SessionsStatusTab';
import { TargetPriceModal } from './TargetPriceModal';
import { QuotationCardsTab } from './QuotationCardsTab'; import { QuotationCardsTab } from './QuotationCardsTab';
import { ChatTab } from './ChatTab'; import { ChatTab } from './ChatTab';
@ -36,6 +39,10 @@ type QuotationDetailSheetProps = {
onSwitchRound: (qtId: string) => void; onSwitchRound: (qtId: string) => void;
/** 마감된 견적의 다음 라운드를 수동 생성(공급사 선택). 성공 시 새 qt_id 반환. */ /** 마감된 견적의 다음 라운드를 수동 생성(공급사 선택). 성공 시 새 qt_id 반환. */
onRegenerate: (qtId: string, supplierIds: string[]) => Promise<string | null>; onRegenerate: (qtId: string, supplierIds: string[]) => Promise<string | null>;
/** 협상 초청 메일 — 견적 단위(미발송 세션 전체) 발송. */
onNotify: (qtId: string) => Promise<void>;
/** 협상 초청 메일 — 세션(공급사) 단위 재발송. */
onNotifySession: (sessionId: string, qtId: string) => Promise<void>;
onClose: () => void; onClose: () => void;
}; };
@ -44,11 +51,14 @@ export function QuotationDetailSheet({
onCloseQuotation, onCloseQuotation,
onSwitchRound, onSwitchRound,
onRegenerate, onRegenerate,
onNotify,
onNotifySession,
onClose, onClose,
}: QuotationDetailSheetProps) { }: QuotationDetailSheetProps) {
const [activeTab, setActiveTab] = useState<DrawerTab>('status'); const [activeTab, setActiveTab] = useState<DrawerTab>('status');
const [showHeaderCards, setShowHeaderCards] = useState(true); const [showHeaderCards, setShowHeaderCards] = useState(true);
const [regenOpen, setRegenOpen] = useState(false); const [regenOpen, setRegenOpen] = useState(false);
const [targetSessionId, setTargetSessionId] = useState<string | null>(null);
// 시트 열린 동안 뒤 견적 리스트(<main>) 스크롤 잠금 — 옆에 배경 스크롤바가 같이 뜨는 것 방지. // 시트 열린 동안 뒤 견적 리스트(<main>) 스크롤 잠금 — 옆에 배경 스크롤바가 같이 뜨는 것 방지.
useScrollLock(); useScrollLock();
@ -60,6 +70,9 @@ export function QuotationDetailSheet({
).map(mapSetting); ).map(mapSetting);
const qtId = quotation.qt_id ?? ''; const qtId = quotation.qt_id ?? '';
// 초청 메일 발송은 견적 소유자만. (백엔드 스코프 도입 전까지의 1차 차단 — 본인 견적 아니면 버튼 비활성)
const myUserId = useAuthStore((s) => s.user?.userId);
const canNotify = !!myUserId && quotation.user_id === myUserId;
// 협상 세션·사용 카드는 견적 단위, 채팅은 선택 세션 단위로 서버에서 읽는다. // 협상 세션·사용 카드는 견적 단위, 채팅은 선택 세션 단위로 서버에서 읽는다.
// 세션은 협상 진행으로 계속 바뀌므로 탭 복귀 시 재조회한다. 카드는 생성 후 불변이라 끄둔다. // 세션은 협상 진행으로 계속 바뀌므로 탭 복귀 시 재조회한다. 카드는 생성 후 불변이라 끄둔다.
const sessionsQuery = useGetQuotationSessions(qtId, { const sessionsQuery = useGetQuotationSessions(qtId, {
@ -231,7 +244,14 @@ export function QuotationDetailSheet({
{/* Tab content */} {/* Tab content */}
<div style={{ flex: '1 1 0%' }} className="min-h-0 p-6 overflow-y-auto bg-background/50"> <div style={{ flex: '1 1 0%' }} className="min-h-0 p-6 overflow-y-auto bg-background/50">
{activeTab === 'status' && ( {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} />} {activeTab === 'cards' && <QuotationCardsTab quotationCardViews={quotationCardViews} />}
@ -251,6 +271,33 @@ export function QuotationDetailSheet({
</div> </div>
</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 && ( {regenOpen && (
<RegenerateModal <RegenerateModal
open open

View File

@ -24,18 +24,16 @@ export function QuotationSettingsModal({
}: QuotationSettingsModalProps) { }: QuotationSettingsModalProps) {
const [targetMargin, setTargetMargin] = useState(''); const [targetMargin, setTargetMargin] = useState('');
const [anchoringValue, setAnchoringValue] = useState(''); const [anchoringValue, setAnchoringValue] = useState('');
const [internetFee, setInternetFee] = useState('7.8');
const [cardUseCount, setCardUseCount] = useState(''); const [cardUseCount, setCardUseCount] = useState('');
if (!open) return null; if (!open) return null;
const handleAdd = (e: React.FormEvent) => { const handleAdd = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const ok = onAdd({ targetMargin, anchoringValue, internetFee, cardUseCount }); const ok = onAdd({ targetMargin, anchoringValue, cardUseCount });
if (ok) { if (ok) {
setTargetMargin(''); setTargetMargin('');
setAnchoringValue(''); setAnchoringValue('');
setInternetFee('7.8');
setCardUseCount(''); setCardUseCount('');
} }
}; };
@ -110,10 +108,6 @@ export function QuotationSettingsModal({
<Typography as="label" variant="muted" className="text-[10px] font-semibold">앵커링 값</Typography> <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" /> <Input type="number" step="0.01" value={anchoringValue} onChange={(e) => setAnchoringValue(e.target.value)} placeholder="예: 0.01" />
</div> </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"> <div className="space-y-1">
<Typography as="label" variant="muted" className="text-[10px] font-semibold">카드 사용 횟수</Typography> <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" /> <Input type="number" step="1" value={cardUseCount} onChange={(e) => setCardUseCount(e.target.value)} placeholder="예: 3" />

View File

@ -1,7 +1,8 @@
import { type ReactNode } from 'react'; import { type ReactNode } from 'react';
import { Clock, Building2, Link2, CornerDownRight } from 'lucide-react'; import { Clock, Building2, Link2, CornerDownRight } from 'lucide-react';
import { DataTable } from '@/components/ui/data-table'; 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 { useQuotationChain } from '../hooks/useQuotationChain';
import { import {
type Estimate, type Estimate,
@ -52,7 +53,7 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo
const productName = product?.name ?? est.productName; // 목록에 없으면 서버 조인 상품명으로 폴백 const productName = product?.name ?? est.productName; // 목록에 없으면 서버 조인 상품명으로 폴백
return ( return (
<div> <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} {est.title}
</Typography> </Typography>
<Typography as="span" variant="small" className="mt-0.5 flex items-center gap-1.5 text-[10px] font-mono text-muted-foreground"> <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!); onFilterChain(est.number!);
}} }}
title="이 견적번호의 모든 차수만 보기" 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" /> <Link2 size={11} className="opacity-60" />
<Typography as="span" variant="small" className="text-xs text-inherit">{est.number}</Typography> <Typography as="span" variant="small" className="text-xs text-inherit">{est.number}</Typography>

View File

@ -15,6 +15,8 @@ import {
useCreateQuotation, useCreateQuotation,
useStopQuotation, useStopQuotation,
useRegenerateQuotation, useRegenerateQuotation,
useNotifyQuotation,
useNotifySession,
getGetQuotationQueryKey, getGetQuotationQueryKey,
getGetQuotationSessionsQueryKey, getGetQuotationSessionsQueryKey,
} from '@/api/generated/quotation/quotation'; } from '@/api/generated/quotation/quotation';
@ -43,7 +45,6 @@ export type CreateQuotationInput = {
export type SettingInput = { export type SettingInput = {
targetMargin: string; targetMargin: string;
anchoringValue: string; anchoringValue: string;
internetFee: string;
cardUseCount: string; cardUseCount: string;
}; };
@ -63,6 +64,8 @@ export function useQuotations(params: ListQuotationsParams) {
const createQuotationMutation = useCreateQuotation(); const createQuotationMutation = useCreateQuotation();
const stopQuotationMutation = useStopQuotation(); const stopQuotationMutation = useStopQuotation();
const regenerateQuotationMutation = useRegenerateQuotation(); const regenerateQuotationMutation = useRegenerateQuotation();
const notifyQuotationMutation = useNotifyQuotation();
const notifySessionMutation = useNotifySession();
// 파라미터별 목록 쿼리 키 전부 재조회(prefix 무효화). // 파라미터별 목록 쿼리 키 전부 재조회(prefix 무효화).
const invalidateQuotations = () => const invalidateQuotations = () =>
@ -119,14 +122,13 @@ export function useQuotations(params: ListQuotationsParams) {
const addSetting = (input: SettingInput): boolean => { const addSetting = (input: SettingInput): boolean => {
const marginPct = Number(String(input.targetMargin).replace('%', '').trim()); const marginPct = Number(String(input.targetMargin).replace('%', '').trim());
const anchoring = Number(String(input.anchoringValue).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); const cardCount = parseInt(String(input.cardUseCount).replace(/[^0-9-]/g, ''), 10);
if (!Number.isFinite(marginPct) || !Number.isFinite(anchoring) || !Number.isFinite(feePct) || !Number.isInteger(cardCount)) { if (!Number.isFinite(marginPct) || !Number.isFinite(anchoring) || !Number.isInteger(cardCount)) {
showToast('목표 마진율·앵커링 값·수수료율·카드 사용 횟수를 숫자로 입력해야 합니다.', 'error'); showToast('목표 마진율·앵커링 값·카드 사용 횟수를 숫자로 입력해야 합니다.', 'error');
return false; return false;
} }
createSettingMutation.mutate( 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: () => { onSuccess: () => {
invalidateSettings(); 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 { return {
products, products,
partners, partners,
@ -248,5 +292,7 @@ export function useQuotations(params: ListQuotationsParams) {
deleteSetting, deleteSetting,
createQuotation, createQuotation,
regenerateQuotation, regenerateQuotation,
notifyQuotation,
notifySession,
}; };
} }

View File

@ -6,7 +6,6 @@ import type { SessionData } from '@/api/generated/model/sessionData';
import type { QuotationCardData } from '@/api/generated/model/quotationCardData'; import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
import { QuotationType, QuotationStatus, SessionStatus, CardType, SupplierType } from '@/api/generated/model'; import { QuotationType, QuotationStatus, SessionStatus, CardType, SupplierType } from '@/api/generated/model';
import { DELIVERY_TYPE_LABEL } from '@/lib/enumLabels'; import { DELIVERY_TYPE_LABEL } from '@/lib/enumLabels';
import { toMinPrice } from '@/features/products/types';
import type { Product, Partner, NegotiationCard } from '@/types'; import type { Product, Partner, NegotiationCard } from '@/types';
export type { Product, Partner, NegotiationCard } from '@/types'; export type { Product, Partner, NegotiationCard } from '@/types';
@ -41,7 +40,7 @@ export interface QuotationSetting {
// ── 서버 응답 → UI 모델 매퍼 ───────────────────────────────────────────── // ── 서버 응답 → UI 모델 매퍼 ─────────────────────────────────────────────
export function mapItem(it: ItemData): Product { 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 { export function mapSupplier(sp: SupplierData): Partner {
@ -156,7 +155,7 @@ export const supplierTypeOptions: { value: number; label: string }[] = [
{ value: SupplierType.DISTRIBUTION, label: '유통' }, { value: SupplierType.DISTRIBUTION, label: '유통' },
{ value: SupplierType.MANUFACTURE, label: '제조' }, { value: SupplierType.MANUFACTURE, label: '제조' },
{ value: SupplierType.SOLE_AGENCY, label: '총판' }, { value: SupplierType.SOLE_AGENCY, label: '총판' },
{ value: SupplierType.ETC, label: '없음' }, { value: SupplierType.NONE, label: '없음' },
]; ];
// ── 라운드 체인(같은 견적번호) ─────────────────────────────────────────── // ── 라운드 체인(같은 견적번호) ───────────────────────────────────────────
@ -198,6 +197,7 @@ export type SessionView = {
item_name: string; item_name: string;
status: number; status: number;
target_price: number; target_price: number;
target_anchoring_price: number;
bid_price: number | null; bid_price: number | null;
bid_at: string; bid_at: string;
reject_reason: string | null; reject_reason: string | null;
@ -205,6 +205,7 @@ export type SessionView = {
reject_delivery_type: string | null; reject_delivery_type: string | null;
end_time: string; end_time: string;
url: string; // 세션 chat 실행 URL(공급사 협상 프론트) url: string; // 세션 chat 실행 URL(공급사 협상 프론트)
email_sent_at: string | null; // 초청 메일 발송 시각(KST). null=미발송 → 발송배지/재발송 판단
}; };
export type QuotationCardView = { export type QuotationCardView = {
@ -262,6 +263,7 @@ export function mapServerSessionView(sd: SessionData, partners: Partner[], produ
item_name: product?.name || '-', item_name: product?.name || '-',
status: sd.status, status: sd.status,
target_price: sd.target_price ?? 0, target_price: sd.target_price ?? 0,
target_anchoring_price: sd.target_anchoring_price ?? 0,
bid_price: sd.bid_price ?? null, bid_price: sd.bid_price ?? null,
bid_at: sd.bid_at ? fmtDateTime(sd.bid_at) : '-', bid_at: sd.bid_at ? fmtDateTime(sd.bid_at) : '-',
reject_reason: sd.reject_reason ?? null, reject_reason: sd.reject_reason ?? null,
@ -271,6 +273,7 @@ export function mapServerSessionView(sd: SessionData, partners: Partner[], produ
: null, : null,
end_time: fmtDateTime(sd.end_time), end_time: fmtDateTime(sd.end_time),
url: sd.url || '', url: sd.url || '',
email_sent_at: sd.email_sent_at ? fmtDateTime(sd.email_sent_at) : null,
}; };
} }

View File

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

View File

@ -42,6 +42,8 @@ export default function QuotationPage() {
deleteSetting, deleteSetting,
createQuotation, createQuotation,
regenerateQuotation, regenerateQuotation,
notifyQuotation,
notifySession,
} = useQuotations(params); } = useQuotations(params);
const totalPages = list.totalPages(total); const totalPages = list.totalPages(total);
@ -58,7 +60,9 @@ export default function QuotationPage() {
const detailQuery = useGetQuotation(detailId ?? '', { const detailQuery = useGetQuotation(detailId ?? '', {
query: { enabled: !!detailId, refetchOnWindowFocus: true, placeholderData: keepPreviousData }, 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 ( return (
<PageContainer> <PageContainer>
@ -154,13 +158,15 @@ export default function QuotationPage() {
} }
/> />
{activeQuotation && ( {detailId && activeQuotation && (
<QuotationDetailSheet <QuotationDetailSheet
key={activeQuotation.qt_id} key={activeQuotation.qt_id}
quotation={activeQuotation} quotation={activeQuotation}
onCloseQuotation={closeQuotation} onCloseQuotation={closeQuotation}
onSwitchRound={(qtId) => overlay.open('detail', qtId, { replace: true })} onSwitchRound={(qtId) => overlay.open('detail', qtId, { replace: true })}
onRegenerate={regenerateQuotation} onRegenerate={regenerateQuotation}
onNotify={notifyQuotation}
onNotifySession={notifySession}
onClose={overlay.close} onClose={overlay.close}
/> />
)} )}

View File

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

View File

@ -69,7 +69,7 @@ CREATE TABLE IF NOT EXISTS company.users (
contact_number VARCHAR(20) NULL, -- 연락처 contact_number VARCHAR(20) NULL, -- 연락처
last_accessed_at TIMESTAMPTZ NOT NULL, -- 마지막 접속 시각 last_accessed_at TIMESTAMPTZ NOT NULL, -- 마지막 접속 시각
status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active, 2=inactive 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) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 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) user_id uuid NOT NULL, -- 견적 설정을 생성한 유저 아이디(company.users.user_id)
target_margin_rate NUMERIC(8,6) NOT NULL, -- 목표 마진율 (정수부 2자리 + 소수 6자리, -99.999999~99.999999) 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자리) 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, -- 한개의 협상 안에서 협상카드 사용 횟수 card_count INTEGER NOT NULL DEFAULT 3, -- 한개의 협상 안에서 협상카드 사용 횟수
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_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_reason VARCHAR(255) NULL, -- 거절 사유
reject_price BIGINT NULL, -- 거절 시 제시가(원) reject_price BIGINT NULL, -- 거절 시 제시가(원)
reject_delivery_type SMALLINT NULL, -- 거절 시 배송 유형 (코드, 앱 enum 매핑) reject_delivery_type SMALLINT NULL, -- 거절 시 배송 유형 (코드, 앱 enum 매핑)
email_sent_at TIMESTAMPTZ NULL, -- 협상 초청 메일 발송 시각(NULL=미발송). 수동 발송 버튼이 채움
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부

View File

@ -12,10 +12,6 @@ ALTER TABLE partner.items
ADD COLUMN IF NOT EXISTS purchase_price BIGINT, ADD COLUMN IF NOT EXISTS purchase_price BIGINT,
ADD COLUMN IF NOT EXISTS selling_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 ALTER TABLE negotiation.sessions
ADD COLUMN IF NOT EXISTS target_anchoring_price BIGINT; 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; ADD COLUMN IF NOT EXISTS usage_type SMALLINT NOT NULL DEFAULT 1;
ALTER TABLE card.wild_cards ALTER TABLE card.wild_cards
ADD COLUMN IF NOT EXISTS usage_type SMALLINT NOT NULL DEFAULT 1; 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;