[feat] negodata: 견적 목표가 산정 개편 + 협상 초청메일 + 카드 사용구분
[견적·목표가] - 목표가 산정 KTC 기준 정비(MD 우선, 없으면 인터넷최저가·매입가·판매가 중 최저 / 신규는 인터넷최저가만) - 인터넷 평균 수수료 0.078 상수화(견적설정 컬럼 제거) - 앵커링가 세션 저장 + 목표가 클릭 시 산정내역 모달 - 재생성 시 목표가·앵커링가 재계산 없이 직전 값 상속(KTC) - MD 제시가·협력사 유형(유통·제조·총판·기타) 입력 [협상카드] - 사용 구분(공통/신규견적전용/재견적전용) [협상 진행] - 협력사 초청 메일 발송(일괄/개별·재발송, 발송상태 표시) [기타] - 견적상세 창 닫기 버그 수정, 불필요한 컬럼 주석 정리 - DB: 기존 DB는 postgres-init/04-alter.sql 적용 필요(자동 마이그레이션 없음) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b42d084063
commit
019f3dbeac
@ -85,9 +85,9 @@ class items(MAIN_BASE):
|
|||||||
vat_yn = Column(Boolean, nullable=True) # 부가세 포함 여부
|
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, 앱에서 갱신)
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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])
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 그대로.
|
||||||
|
|||||||
@ -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 = (
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)))
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
125
negodata/backend/services/email.py
Normal file
125
negodata/backend/services/email.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
"""협상 초청 메일 발송.
|
||||||
|
|
||||||
|
1순위: Azure Communication Services(ACS) Email (endpoint + accesskey + 검증된 sender).
|
||||||
|
2순위(폴백): SMTP (aiosmtplib).
|
||||||
|
둘 다 미설정이면 EmailUnavailable 을 던진다(조용한 실패 0).
|
||||||
|
|
||||||
|
설정 출처: config.<APP_ENV>.toml 의 [MailConfig] (config/config_models.py:MailConfig).
|
||||||
|
HTML 본문 템플릿: services/email_templates/*.html ($placeholder 치환).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from html import escape
|
||||||
|
from pathlib import Path
|
||||||
|
from string import Template
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from common.logger import LOG
|
||||||
|
from config.server_configs import mail_config
|
||||||
|
|
||||||
|
_KST = ZoneInfo("Asia/Seoul")
|
||||||
|
|
||||||
|
# HTML 본문은 코드에 박지 않고 파일에서 읽는다(모듈 로드 시 1회). $placeholder 는 string.Template 로 치환.
|
||||||
|
_TEMPLATE_DIR = Path(__file__).parent / "email_templates"
|
||||||
|
_INVITE_HTML = Template((_TEMPLATE_DIR / "invite_email.html").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
class EmailUnavailable(RuntimeError):
|
||||||
|
"""ACS·SMTP 모두 미설정이라 발송 채널이 없음."""
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_acs(to: str, subject: str, html: str, text: str) -> None:
|
||||||
|
"""Azure Communication Services Email — accesskey 인증."""
|
||||||
|
from azure.communication.email.aio import EmailClient
|
||||||
|
|
||||||
|
conn = f"endpoint={mail_config.azure_acs_endpoint};accesskey={mail_config.azure_acs_accesskey}"
|
||||||
|
message = {
|
||||||
|
"senderAddress": mail_config.azure_acs_sender,
|
||||||
|
"recipients": {"to": [{"address": to}]},
|
||||||
|
"content": {"subject": subject, "plainText": text, "html": html},
|
||||||
|
}
|
||||||
|
async with EmailClient.from_connection_string(conn) as client:
|
||||||
|
poller = await client.begin_send(message)
|
||||||
|
await poller.result()
|
||||||
|
LOG.i(f"email sent via ACS → {to} ({subject})")
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_smtp(to: str, subject: str, html: str, text: str) -> None:
|
||||||
|
import aiosmtplib
|
||||||
|
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["From"] = mail_config.smtp_from
|
||||||
|
msg["To"] = to
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg.set_content(text)
|
||||||
|
msg.add_alternative(html, subtype="html")
|
||||||
|
|
||||||
|
await aiosmtplib.send(
|
||||||
|
msg,
|
||||||
|
hostname=mail_config.smtp_host,
|
||||||
|
port=mail_config.smtp_port,
|
||||||
|
username=mail_config.smtp_user or None,
|
||||||
|
password=mail_config.smtp_password or None,
|
||||||
|
start_tls=mail_config.smtp_starttls,
|
||||||
|
)
|
||||||
|
LOG.i(f"email sent via SMTP → {to} ({subject})")
|
||||||
|
|
||||||
|
|
||||||
|
async def send_email(to: str, subject: str, html: str, text: str) -> None:
|
||||||
|
"""ACS(설정 시) → SMTP(폴백) 순으로 1통 발송. 둘 다 없으면 EmailUnavailable."""
|
||||||
|
if mail_config.acs_configured:
|
||||||
|
await _send_acs(to, subject, html, text)
|
||||||
|
return
|
||||||
|
if mail_config.smtp_host:
|
||||||
|
await _send_smtp(to, subject, html, text)
|
||||||
|
return
|
||||||
|
# endpoint/accesskey 만 있고 sender 누락 시 원인을 명확히 안내.
|
||||||
|
if mail_config.azure_acs_endpoint and not mail_config.azure_acs_sender:
|
||||||
|
raise EmailUnavailable("AZURE_ACS_SENDER(검증된 MailFrom 주소) 미설정")
|
||||||
|
raise EmailUnavailable("이메일 미설정 — ACS(endpoint+accesskey+sender) 또는 SMTP 필요")
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_deadline(end_time: datetime | None) -> str:
|
||||||
|
"""협상 마감 시각 → 한국시간 'YYYY-MM-DD HH:MM' 표기. 값 없으면 빈 문자열."""
|
||||||
|
if not end_time:
|
||||||
|
return ""
|
||||||
|
dt = end_time if end_time.tzinfo else end_time.replace(tzinfo=ZoneInfo("UTC"))
|
||||||
|
return dt.astimezone(_KST).strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
def build_invite_email(
|
||||||
|
*,
|
||||||
|
supplier_name: str,
|
||||||
|
quotation_name: str,
|
||||||
|
qt_number: str,
|
||||||
|
end_time: datetime | None,
|
||||||
|
chat_url: str,
|
||||||
|
) -> tuple[str, str, str]:
|
||||||
|
"""협상 초청 메일 (제목/HTML/텍스트) 생성.
|
||||||
|
|
||||||
|
목표가·앵커링가는 협상 전략 값이라 메일에 담지 않는다(공급사에게 노출 금지).
|
||||||
|
공급사는 링크로 협상 화면에 진입해 입찰한다.
|
||||||
|
"""
|
||||||
|
sp = supplier_name or "협력사"
|
||||||
|
deadline = _fmt_deadline(end_time) or "미정"
|
||||||
|
subject = f"[협상 견적 {qt_number}] {quotation_name} — 협상 참여 요청"
|
||||||
|
|
||||||
|
# HTML 본문은 invite_email.html 에서 읽어 치환. 값은 escape 해 HTML 인젝션 방지(견적명 등은 사용자 입력).
|
||||||
|
html = _INVITE_HTML.substitute(
|
||||||
|
supplier_name=escape(sp),
|
||||||
|
quotation_name=escape(quotation_name),
|
||||||
|
qt_number=escape(qt_number),
|
||||||
|
deadline=escape(deadline),
|
||||||
|
chat_url=escape(chat_url),
|
||||||
|
)
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"{sp} 담당자님, 아래 견적 건의 협상에 참여해 주세요.\n\n"
|
||||||
|
f" - 견적명: {quotation_name}\n"
|
||||||
|
f" - 견적번호: {qt_number}\n"
|
||||||
|
f" - 협상 마감: {deadline}\n\n"
|
||||||
|
f"협상 참여 링크: {chat_url}\n"
|
||||||
|
)
|
||||||
|
return subject, html, text
|
||||||
70
negodata/backend/services/email_templates/invite_email.html
Normal file
70
negodata/backend/services/email_templates/invite_email.html
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f5f7;margin:0;padding:24px 12px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="width:480px;max-width:480px;background-color:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb;font-family:'Apple SD Gothic Neo',-apple-system,'Segoe UI',Roboto,'Malgun Gothic',sans-serif;">
|
||||||
|
|
||||||
|
<!-- 헤더 바 -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#2563eb;padding:18px 28px;">
|
||||||
|
<span style="color:#ffffff;font-size:16px;font-weight:700;letter-spacing:1px;">NEGODATA</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- 본문 -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:32px 28px 4px 28px;">
|
||||||
|
<h1 style="margin:0 0 10px 0;font-size:20px;font-weight:700;color:#111827;">협상 참여 요청</h1>
|
||||||
|
<p style="margin:0 0 24px 0;font-size:14px;line-height:1.7;color:#4b5563;">
|
||||||
|
<strong style="color:#111827;">$supplier_name</strong> 담당자님,<br>
|
||||||
|
아래 견적 건의 협상에 참여해 주세요.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 견적 정보 카드 -->
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f9fafb;border:1px solid #eef0f3;border-radius:10px;margin-bottom:28px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:14px 16px;font-size:13px;color:#6b7280;width:88px;">견적명</td>
|
||||||
|
<td style="padding:14px 16px;font-size:13px;color:#111827;font-weight:600;text-align:right;">$quotation_name</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:14px 16px;font-size:13px;color:#6b7280;border-top:1px solid #eef0f3;">견적번호</td>
|
||||||
|
<td style="padding:14px 16px;font-size:13px;color:#374151;text-align:right;border-top:1px solid #eef0f3;">$qt_number</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:14px 16px;font-size:13px;color:#6b7280;border-top:1px solid #eef0f3;">협상 마감</td>
|
||||||
|
<td style="padding:14px 16px;font-size:13px;color:#dc2626;font-weight:700;text-align:right;border-top:1px solid #eef0f3;">$deadline</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- CTA 버튼 (td bgcolor = Outlook 대응 bulletproof) -->
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:4px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#2563eb" style="border-radius:8px;">
|
||||||
|
<!-- TODO: 세션별 협상링크 연결 시 href 를 $$chat_url 로 복원 -->
|
||||||
|
<a href="https://nego.o2o.kr" style="display:inline-block;padding:14px 34px;font-size:15px;font-weight:700;color:#ffffff;text-decoration:none;border-radius:8px;">협상 참여하기 →</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- 푸터 -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:18px 28px 26px 28px;">
|
||||||
|
<p style="margin:0;font-size:11px;line-height:1.7;color:#9ca3af;border-top:1px solid #f0f1f3;padding-top:16px;">
|
||||||
|
버튼이 열리지 않으면 아래 링크를 복사해 접속하세요.<br>
|
||||||
|
<a href="https://nego.o2o.kr" style="color:#6b7280;word-break:break-all;">https://nego.o2o.kr</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<p style="margin:16px 0 0 0;font-size:11px;color:#b0b4bb;font-family:sans-serif;">본 메일은 협상 견적 시스템에서 자동 발송되었습니다.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
@ -22,6 +22,7 @@ from router.v1.quotation.protocol import (
|
|||||||
Res_CreateQuotation,
|
Res_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)
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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];
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
22
negodata/front/src/api/generated/model/listUsersParams.ts
Normal file
22
negodata/front/src/api/generated/model/listUsersParams.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ListUsersParams = {
|
||||||
|
/**
|
||||||
|
* 로그인ID/이름/이메일 검색
|
||||||
|
*/
|
||||||
|
search?: string | null;
|
||||||
|
/**
|
||||||
|
* @minimum 1
|
||||||
|
*/
|
||||||
|
page?: number;
|
||||||
|
/**
|
||||||
|
* @minimum 1
|
||||||
|
* @maximum 100
|
||||||
|
*/
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
@ -13,7 +13,6 @@ export interface QuotationSettingData {
|
|||||||
user_id?: QuotationSettingDataUserId;
|
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;
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
17
negodata/front/src/api/generated/model/reqUpdateMe.ts
Normal file
17
negodata/front/src/api/generated/model/reqUpdateMe.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { ReqUpdateMeName } from './reqUpdateMeName';
|
||||||
|
import type { ReqUpdateMeEmail } from './reqUpdateMeEmail';
|
||||||
|
import type { ReqUpdateMeContactNumber } from './reqUpdateMeContactNumber';
|
||||||
|
import type { ReqUpdateMePassword } from './reqUpdateMePassword';
|
||||||
|
|
||||||
|
export interface ReqUpdateMe {
|
||||||
|
name?: ReqUpdateMeName;
|
||||||
|
email?: ReqUpdateMeEmail;
|
||||||
|
contact_number?: ReqUpdateMeContactNumber;
|
||||||
|
password?: ReqUpdateMePassword;
|
||||||
|
}
|
||||||
@ -5,4 +5,4 @@
|
|||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ReqUpdateQuotationSettingInternetAverageFee = number | null;
|
export type ReqUpdateMeContactNumber = string | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ReqUpdateMeEmail = string | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ReqUpdateMeName = string | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ReqUpdateMePassword = string | null;
|
||||||
@ -6,12 +6,10 @@
|
|||||||
*/
|
*/
|
||||||
import type { ReqUpdateQuotationSettingTargetMarginRate } from './reqUpdateQuotationSettingTargetMarginRate';
|
import type { 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;
|
||||||
}
|
}
|
||||||
|
|||||||
17
negodata/front/src/api/generated/model/resNotifySessions.ts
Normal file
17
negodata/front/src/api/generated/model/resNotifySessions.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { ErrorInfo } from './errorInfo';
|
||||||
|
import type { ResNotifySessionsMsg } from './resNotifySessionsMsg';
|
||||||
|
|
||||||
|
export interface ResNotifySessions {
|
||||||
|
result?: ErrorInfo;
|
||||||
|
msg?: ResNotifySessionsMsg;
|
||||||
|
sent?: number;
|
||||||
|
failed?: number;
|
||||||
|
skipped?: number;
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ResNotifySessionsMsg = string | null;
|
||||||
@ -5,12 +5,14 @@
|
|||||||
* OpenAPI spec version: 0.1.0
|
* 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SessionDataEmailSentAt = string | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SessionDataTargetAnchoringPrice = number | null;
|
||||||
@ -6,15 +6,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* suppliers.type 코드값(KTC 앵커링 기준). 0=기타는 매칭 실패 폴백용 — UI 선택지엔 미노출.
|
* quotations.supplier_type 코드값. 없음(0,미지정)/유통(1)/제조(2)/총판(3).
|
||||||
|
없음은 프론트 폼에 '없음'으로 노출. KTC 앵커링 코드(기타=0)와 매핑 시 0↔없음 대응.
|
||||||
*/
|
*/
|
||||||
export type SupplierType = typeof SupplierType[keyof typeof SupplierType];
|
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;
|
||||||
|
|||||||
18
negodata/front/src/api/generated/model/userStatus.ts
Normal file
18
negodata/front/src/api/generated/model/userStatus.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* users.status 코드값.
|
||||||
|
*/
|
||||||
|
export type UserStatus = typeof UserStatus[keyof typeof UserStatus];
|
||||||
|
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||||
|
export const UserStatus = {
|
||||||
|
ACTIVE: 1,
|
||||||
|
INACTIVE: 2,
|
||||||
|
} as const;
|
||||||
@ -31,6 +31,7 @@ import type {
|
|||||||
ResCreateQuotation,
|
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 = (
|
||||||
|
|||||||
@ -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 © O2O Inc. All rights reserved</Typography>
|
<Typography variant="mono">Copyright © O2O Inc. All rights reserved</Typography>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isProfileOpen && <ProfileSheet open onClose={() => setIsProfileOpen(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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> &
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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()}` : '-'}
|
||||||
|
|||||||
@ -0,0 +1,162 @@
|
|||||||
|
import { X, Check } from 'lucide-react';
|
||||||
|
import { Typography } from '@/components/ui/typography';
|
||||||
|
|
||||||
|
// 세션 목표가 산정내역 모달. backend _calc_target_price 로직을 그대로 재구성해 후보·채택을 보여준다.
|
||||||
|
// (신규견적은 인터넷최저가만 후보, 재는 매입가·판매가까지 / md 입력가 최우선. 앵커링은 설정 anchoring_value 고정율.)
|
||||||
|
type TargetPriceModalProps = {
|
||||||
|
onClose: () => void;
|
||||||
|
qtNumber: string;
|
||||||
|
itemName: string;
|
||||||
|
vatYn?: boolean | null;
|
||||||
|
deliveryFeeYn?: boolean | null;
|
||||||
|
category?: string | null;
|
||||||
|
supplierTypeLabel: string;
|
||||||
|
targetPrice: number;
|
||||||
|
anchoringPrice: number;
|
||||||
|
mdPrice?: number | null;
|
||||||
|
internetLowest?: number | null;
|
||||||
|
purchase?: number | null;
|
||||||
|
selling?: number | null;
|
||||||
|
fee: number;
|
||||||
|
margin: number;
|
||||||
|
anchoringValue: number;
|
||||||
|
isNew: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const won = (n: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-');
|
||||||
|
|
||||||
|
type CandKey = 'md' | 'net' | 'sell' | 'buy';
|
||||||
|
|
||||||
|
export function TargetPriceModal({
|
||||||
|
onClose,
|
||||||
|
qtNumber,
|
||||||
|
itemName,
|
||||||
|
vatYn,
|
||||||
|
deliveryFeeYn,
|
||||||
|
category,
|
||||||
|
supplierTypeLabel,
|
||||||
|
targetPrice,
|
||||||
|
anchoringPrice,
|
||||||
|
mdPrice,
|
||||||
|
internetLowest,
|
||||||
|
purchase,
|
||||||
|
selling,
|
||||||
|
fee,
|
||||||
|
margin,
|
||||||
|
anchoringValue,
|
||||||
|
isNew,
|
||||||
|
}: TargetPriceModalProps) {
|
||||||
|
// 후보값 계산 (신규는 인터넷최저가만, 재는 매입가·판매가까지)
|
||||||
|
const md = mdPrice && mdPrice > 0 ? Math.round(mdPrice) : null;
|
||||||
|
const net = internetLowest && internetLowest > 0 ? Math.round(internetLowest * (1 - fee)) : null;
|
||||||
|
const sell = !isNew && selling && selling > 0 ? Math.round(selling * (1 - margin)) : null;
|
||||||
|
const buy = !isNew && purchase && purchase > 0 ? Math.round(purchase) : null;
|
||||||
|
|
||||||
|
// 채택 후보: md 최우선, 없으면 유효 후보 중 최소값
|
||||||
|
let applied: CandKey | null = null;
|
||||||
|
if (md != null) {
|
||||||
|
applied = 'md';
|
||||||
|
} else {
|
||||||
|
const pool = ([['net', net], ['sell', sell], ['buy', buy]] as [CandKey, number | null][]).filter(
|
||||||
|
(c): c is [CandKey, number] => c[1] != null,
|
||||||
|
);
|
||||||
|
if (pool.length) applied = pool.reduce((m, c) => (c[1] < m[1] ? c : m))[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: { key: CandKey; label: string; sub?: string; value: number | null }[] = [
|
||||||
|
{ key: 'md', label: 'MD 입력가', value: md },
|
||||||
|
{ key: 'net', label: '인터넷 최저가', sub: '인터넷 평균 수수료 적용', value: net },
|
||||||
|
{ key: 'sell', label: '판매가', sub: '목표 마진율 적용', value: sell },
|
||||||
|
{ key: 'buy', label: '매입가', value: buy },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-xs"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md bg-card border border-border rounded-lg shadow-2xl p-6 font-mono text-xs animate-scale-up"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-border">
|
||||||
|
<Typography variant="h3">목표가</Typography>
|
||||||
|
<button onClick={onClose} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground mt-3">
|
||||||
|
견적번호: {qtNumber} · 상품: {itemName}
|
||||||
|
</Typography>
|
||||||
|
<Typography as="p" variant="small" className="text-[11px] font-semibold text-rose-500 mt-1">
|
||||||
|
배송비: {deliveryFeeYn ? '배송비포함' : '배송비별도'} · 부가세: {vatYn ? 'VAT포함' : 'VAT별도'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* 목표가 */}
|
||||||
|
<div className="flex items-center justify-between gap-3 mt-5">
|
||||||
|
<Typography variant="label" className="font-bold">목표가</Typography>
|
||||||
|
<div className="flex-1 text-right bg-muted/50 border border-border rounded px-3 py-2 font-bold text-base text-foreground">
|
||||||
|
{won(targetPrice)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선정방식 */}
|
||||||
|
<div className="mt-4 bg-muted/40 border border-border rounded p-3 space-y-1">
|
||||||
|
<Typography as="p" variant="small" className="font-bold text-foreground text-[11px]">목표가 선정방식</Typography>
|
||||||
|
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">1. MD 입력값 존재 시, 최우선 적용</Typography>
|
||||||
|
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">2. 다음 중 가장 작은 값 — 인터넷최저가×(1−수수료) | 매입가 | 판매가×(1−목표 마진율)</Typography>
|
||||||
|
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">
|
||||||
|
* 인터넷 평균 수수료: {fee} · 목표 마진율: {margin}{isNew ? ' · 신규견적이라 인터넷최저가만 적용' : ''}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 후보 */}
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{rows.map((r) => {
|
||||||
|
const on = r.key === applied;
|
||||||
|
return (
|
||||||
|
<div key={r.key} className="flex items-center gap-2">
|
||||||
|
<span className={`w-4 shrink-0 ${on ? 'text-emerald-600' : 'text-transparent'}`}>
|
||||||
|
{on ? <Check size={14} /> : null}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Typography as="span" variant="small" className={`text-[11px] ${on ? 'font-bold text-foreground' : 'text-muted-foreground'}`}>
|
||||||
|
{r.label}
|
||||||
|
</Typography>
|
||||||
|
{r.sub && (
|
||||||
|
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground ml-1">
|
||||||
|
({r.sub})
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`text-right min-w-[96px] px-2 py-1 rounded border ${
|
||||||
|
on
|
||||||
|
? 'border-emerald-300 bg-emerald-50 dark:bg-emerald-950/20 font-bold text-foreground'
|
||||||
|
: 'border-border bg-muted/30 text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{won(r.value)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 앵커링 (negodata: 설정 anchoring_value 고정율) */}
|
||||||
|
<div className="mt-4 bg-muted/40 border border-border rounded p-3 space-y-1">
|
||||||
|
<Typography as="p" variant="small" className="font-bold text-foreground text-[11px]">앵커링</Typography>
|
||||||
|
{category && (
|
||||||
|
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">서비스 카테고리: {category}</Typography>
|
||||||
|
)}
|
||||||
|
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">공급 업체 유형: {supplierTypeLabel}</Typography>
|
||||||
|
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">앵커링 값: {anchoringValue}</Typography>
|
||||||
|
<Typography as="p" variant="small" className="text-[11px] font-bold text-foreground">
|
||||||
|
앵커링가: {won(anchoringPrice)} <span className="font-normal text-[10px] text-muted-foreground">= 목표가×(1−{anchoringValue})</span>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
|||||||
import { useListSettings } from '@/api/generated/quotation-setting/quotation-setting';
|
import { 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
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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]: '최고관리자',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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 -- 소프트 삭제 여부
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user