diff --git a/backend/common/database/model/models.py b/backend/common/database/model/models.py index be2a365..70ec1a3 100644 --- a/backend/common/database/model/models.py +++ b/backend/common/database/model/models.py @@ -85,9 +85,9 @@ class items(MAIN_BASE): vat_yn = Column(Boolean, nullable=True) # 부가세 포함 여부 delivery_fee_yn = Column(Boolean, nullable=True) # 배송비 포함 여부 internet_lowest_price_yn = Column(Boolean, nullable=False, server_default=text("false")) # 최저가 솔루션 보조 컬럼 - internet_lowest_price = Column(BigInteger, nullable=True) # 인터넷 최저가 실값(원). 목표가 산정용(KTC: ×(1−수수료)) - purchase_price = Column(BigInteger, nullable=True) # 매입가(원). 목표가 후보(그대로). KTC items.purchase_price - selling_price = Column(BigInteger, nullable=True) # 판매가(원). 목표가 후보(×(1−마진율)), 유통형만. KTC items.selling_price + internet_lowest_price = Column(BigInteger, nullable=True) + purchase_price = Column(BigInteger, nullable=True) + selling_price = Column(BigInteger, nullable=True) category_type = Column(Integer, nullable=False, server_default=text("1")) # 카테고리 조회용 자동 증가 숫자 created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC) updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신) @@ -111,7 +111,7 @@ class sessions(MAIN_BASE): qt_round = Column(Integer, nullable=False) # 견적 라운드(스냅샷) qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적 (QtType) target_price = Column(BigInteger, nullable=False) # 목표가(원) - target_anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원)=floor(목표가×(1−anchoring_value)). KTC sessions.target_anchoring_price + target_anchoring_price = Column(BigInteger, nullable=True) status = Column(SmallInteger, nullable=False) # 진행 상태 (SessionStatus 코드) bid_price = Column(BigInteger, nullable=True) # 입찰가(원) bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각 @@ -148,8 +148,8 @@ class quotations(MAIN_BASE): manager_email = Column(String(255), nullable=True) # 담당자 이메일 manager_contact_number = Column(String(20), nullable=True) # 담당자 연락처 memo = Column(String(100), nullable=True) # 메모 - md_price = Column(BigInteger, nullable=True) # MD 제시가(원). 목표가 산정 최우선값 - supplier_type = Column(SmallInteger, nullable=True) # 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록 + md_price = Column(BigInteger, nullable=True) + supplier_type = Column(SmallInteger, nullable=True) iteration = Column(Integer, nullable=False, server_default=text("0")) # 반복 횟수 preferred_sp_yn = Column(Boolean, nullable=True) # 선호 공급사 지정 여부 preferred_sp_id = Column(UUID(as_uuid=True), nullable=True) # 선호 공급사(partner.suppliers.supplier_id) @@ -174,7 +174,6 @@ class quotation_settings(MAIN_BASE): user_id = Column(UUID(as_uuid=True), nullable=False) # 생성 유저(company.users.user_id) target_margin_rate = Column(Numeric(8, 6), nullable=False) # 목표 마진율 anchoring_value = Column(Numeric(8, 6), nullable=False, server_default=text("0.01")) # 앵커링 값(비율) — anchor=round(target*(1-value)) - internet_average_fee = Column(Numeric(8, 6), nullable=False, server_default=text("0.078")) # 인터넷 평균 수수료율. 목표가=인터넷최저가×(1−값). KTC QuotationPrice.internet_average_fee(Float) card_count = Column(Integer, nullable=False, server_default=text("3")) # 협상 내 협상카드 사용 횟수 created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC) updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신) diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index 9367ee1..91ff9b4 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -86,9 +86,9 @@ class items(MainTableMixin, MAIN_BASE): price = Column(BigInteger, nullable=True) # 금액(원), 스키마 BIGINT internet_lowest_price_yn = Column(Boolean, nullable=False, default=False) # 최저가 솔루션 원자성 보존용 - internet_lowest_price = Column(BigInteger, nullable=True) # 인터넷 최저가 실값(원). 목표가 산정용(KTC: ×(1−수수료)) - purchase_price = Column(BigInteger, nullable=True) # 매입가(원). 목표가 후보(그대로). KTC items.purchase_price(Integer) - selling_price = Column(BigInteger, nullable=True) # 판매가(원). 목표가 후보(×(1−마진율)), 유통형만. KTC items.selling_price(Integer) + internet_lowest_price = Column(BigInteger, nullable=True) + purchase_price = Column(BigInteger, nullable=True) + selling_price = Column(BigInteger, nullable=True) moq = Column(String(50), nullable=True) # 최소 주문 수량 lead_time = Column(SmallInteger, nullable=True) # 주문 후 배송 도착까지 시간 @@ -124,7 +124,7 @@ class nego_cards(MainTableMixin, MAIN_BASE): number = Column(String(10), nullable=True) # 식별번호(카드코드) script = Column(String(255), nullable=True) # 협상 스크립트(평문 미리보기) edit_script = Column(JSONB, nullable=True) # 편집된 스크립트(Slate JSON) - usage_type = Column(SmallInteger, nullable=False, default=1) # 카드 적용 견적 구분(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용 + usage_type = Column(SmallInteger, nullable=False, default=1) class wild_cards(MainTableMixin, MAIN_BASE): @@ -137,7 +137,7 @@ class wild_cards(MainTableMixin, MAIN_BASE): number = Column(String(10), nullable=True) # 식별번호(카드코드) script = Column(String(255), nullable=True) # 협상 스크립트(평문 미리보기) edit_script = Column(JSONB, nullable=True) # 편집된 스크립트(Slate JSON) - usage_type = Column(SmallInteger, nullable=False, default=1) # 카드 적용 견적 구분(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용 + usage_type = Column(SmallInteger, nullable=False, default=1) condition = Column(String(255), nullable=True) # 사용 조건(트리거) available = Column(Boolean, nullable=False, default=False) # 수동 협상 적용 여부(ACTIVE/INACTIVE 매핑) memo = Column(String(255), nullable=True) # 자유 메모 @@ -179,8 +179,7 @@ class quotation_settings(MainTableMixin, MAIN_BASE): user_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 설정 소유 유저 target_margin_rate = Column(Numeric(8, 6), nullable=False) anchoring_value = Column(Numeric(8, 6), nullable=False, default=0.01) - internet_average_fee = Column(Numeric(8, 6), nullable=False, default=0.078) # 인터넷 평균 수수료율. 목표가=인터넷최저가×(1−값). KTC QuotationPrice.internet_average_fee(Float) - card_count = Column(Integer, nullable=False, default=3) # 한 협상 내 협상카드 사용 횟수 + card_count = Column(Integer, nullable=False, default=3) class quotations(MainTableMixin, MAIN_BASE): @@ -204,8 +203,8 @@ class quotations(MainTableMixin, MAIN_BASE): manager_email = Column(String(255), nullable=True) manager_contact_number = Column(String(20), nullable=True) memo = Column(String(100), nullable=True) - md_price = Column(BigInteger, nullable=True) # MD 제시가(원). 목표가 산정 최우선값 — 입력은 견적생성 모달 - supplier_type = Column(SmallInteger, nullable=True) # 협력사 유형(SupplierType). 재견적은 1:1이라 견적에 박는다. 입력은 견적생성 모달 + md_price = Column(BigInteger, nullable=True) + supplier_type = Column(SmallInteger, nullable=True) iteration = Column(Integer, nullable=False, default=0) preferred_sp_yn = Column(Boolean, nullable=True) @@ -228,7 +227,7 @@ class sessions(MainTableMixin, MAIN_BASE): qt_round = Column(Integer, nullable=False) # 견적 라운드 스냅샷 qt_type = Column(SmallInteger, nullable=False) # QuotationType 스냅샷 target_price = Column(BigInteger, nullable=False) # 목표가(원) - target_anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원)=floor(목표가×(1−anchoring_value)). KTC sessions.target_anchoring_price(BigInteger) + target_anchoring_price = Column(BigInteger, nullable=True) status = Column(SmallInteger, nullable=False) # SessionStatus 코드 bid_price = Column(BigInteger, nullable=True) # 입찰가(원) bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각 @@ -236,6 +235,7 @@ class sessions(MainTableMixin, MAIN_BASE): reject_reason = Column(String(255), nullable=True) reject_price = Column(BigInteger, nullable=True) reject_delivery_type = Column(SmallInteger, nullable=True) # DeliveryType 코드 + email_sent_at = Column(DateTime(timezone=True), nullable=True) # 협상 초청 메일 발송 시각(NULL=미발송) class chats(MainTableMixin, MAIN_BASE): diff --git a/negodata/backend/common/models/gmodel.py b/negodata/backend/common/models/gmodel.py index cfa6d5d..bb956fe 100644 --- a/negodata/backend/common/models/gmodel.py +++ b/negodata/backend/common/models/gmodel.py @@ -4,7 +4,7 @@ from typing import Optional from fastapi import Query from pydantic import BaseModel, Field -from common.enums import ErrorType +from common.enums import ErrorType, UserRole class StructModel: @@ -70,9 +70,12 @@ class UserInfo(StructModel): user_id: str # users.user_id (uuid) — 데이터 스코프 키 id: str # users.id (로그인 아이디) — get_me 재조회 키 company_id: str # users.company_id (uuid) — 멀티테넌트 스코프 키 + role: int # users.role (UserRole) — 권한 게이트(최고관리자 등) 판단 키 def __init__(self, *args, **kwargs) -> None: super().__init__() + # 구버전 토큰(role 미포함) 도 디코딩되도록 기본값을 먼저 깔고 kwargs 로 덮어쓴다. + self.role = UserRole.USER.value for dictionary in args: for key in dictionary: setattr(self, key, dictionary[key]) diff --git a/negodata/backend/config/config_models.py b/negodata/backend/config/config_models.py index 97d11ac..a3864df 100644 --- a/negodata/backend/config/config_models.py +++ b/negodata/backend/config/config_models.py @@ -53,3 +53,23 @@ class StorageConfig(ConfigModel): azure_blob_sas_token: str = "" # SAS 토큰(쿼리스트링). 만료 있음 — 만료되면 업로드 실패 blob_root: str = "negodata" # 컨테이너 내 최상위 디렉터리(infinith 파일과 분리) max_image_mb: int = 4 # 업로드 허용 최대 크기(MB). 프론트 ImageDropzone 와 일치 + + +# 협상 초청 메일 발송 설정. services/email.py 가 ACS → SMTP 순으로 시도한다. +# 1순위: Azure Communication Services(ACS) Email — endpoint + accesskey. +# azure_acs_sender 는 검증된 MailFrom 주소(예: donotreply@negodata.o2o.kr). Blob 과 별개 리소스다. +# 2순위(폴백): SMTP — ACS 미설정 시 사용. 둘 다 비우면 발송 시 EmailUnavailable. +class MailConfig(ConfigModel): + azure_acs_endpoint: str = "" + azure_acs_accesskey: str = "" + azure_acs_sender: str = "" + smtp_host: str = "" + smtp_port: int = 587 + smtp_user: str = "" + smtp_password: str = "" + smtp_from: str = "Negodata " + smtp_starttls: bool = True + + @property + def acs_configured(self) -> bool: + return bool(self.azure_acs_endpoint and self.azure_acs_accesskey and self.azure_acs_sender) diff --git a/negodata/backend/config/server_configs.py b/negodata/backend/config/server_configs.py index f9ceaf1..7042709 100644 --- a/negodata/backend/config/server_configs.py +++ b/negodata/backend/config/server_configs.py @@ -1,7 +1,7 @@ import os from config.config_loader import Configs -from config.config_models import WebServerConfig, LogConfig, MainDBConfig, JwtToken, StorageConfig +from config.config_models import WebServerConfig, LogConfig, MainDBConfig, JwtToken, StorageConfig, MailConfig # 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경. APP_ENV = os.environ.get("APP_ENV", "local") @@ -20,6 +20,8 @@ log_config: LogConfig = configs.get(LogConfig) main_db_config: MainDBConfig = configs.get(MainDBConfig) jwt_token_config: JwtToken = configs.get(JwtToken) storage_config: StorageConfig = configs.get(StorageConfig) +# [MailConfig] 섹션이 없는 toml(구버전)에서도 죽지 않도록 기본값으로 폴백(전 필드 빈 값 → 발송 시 EmailUnavailable). +mail_config: MailConfig = configs.get(MailConfig) or MailConfig() # DB 접속 env override (config.local.toml 유지, 도커에서 host 만 교체). 로컬은 env 미설정 → toml 그대로. diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py index 9358bea..4131bff 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -83,6 +83,18 @@ class IQuotationCRUD(ABC): async def list_used_cards(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]: pass + @abstractmethod + async def list_sessions_with_supplier(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]: + pass + + @abstractmethod + async def get_session_with_supplier(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[tuple]]: + pass + + @abstractmethod + async def mark_sessions_emailed(self, cdb: AsyncSession, session_ids, ts) -> ErrorType: + pass + @abstractmethod async def session_counts(self, cdb: AsyncSession, qt_ids) -> Tuple[ErrorType, dict]: pass @@ -360,11 +372,10 @@ class QuotationCRUD(IQuotationCRUD): return ErrorType.DB_RUN_FAILED, None async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]: - """견적 세팅의 율: {margin, fee, anchoring}. 목표가·앵커링가 산정 입력.""" + """견적 세팅의 율: {margin, anchoring}. 목표가·앵커링가 산정 입력. (인터넷 수수료는 상수)""" try: query = select( quotation_settings.target_margin_rate, - quotation_settings.internet_average_fee, quotation_settings.anchoring_value, ).where(quotation_settings.qt_setting_id == qt_setting_id).limit(1) err_type, rows = await DB_SESSION_MNG.execute(cdb, query) @@ -375,8 +386,7 @@ class QuotationCRUD(IQuotationCRUD): r = rows[0] return ErrorType.SUCCESS, { "margin": float(r[0]) if r[0] is not None else None, - "fee": float(r[1]) if r[1] is not None else None, - "anchoring": float(r[2]) if r[2] is not None else None, + "anchoring": float(r[1]) if r[1] is not None else None, } except Exception as ex: LOG.e_no_callstack(ex) @@ -617,6 +627,56 @@ class QuotationCRUD(IQuotationCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, [] + async def list_sessions_with_supplier(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]: + """견적의 세션 + 공급사(담당자 이메일/이름) 조인. 초청 메일 발송 대상 조회용. + 반환: [(session, supplier_name, manager_email), ...] (created_at asc). 행은 인덱스로 언팩.""" + try: + query = ( + select(sessions, suppliers.name, suppliers.manager_email) + .outerjoin(suppliers, suppliers.supplier_id == sessions.supplier_id) + .where(sessions.quotation_id == qt_id, sessions.deleted == False) # noqa: E712 + .order_by(sessions.created_at.asc()) + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, [] + return ErrorType.SUCCESS, list(rows) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + + async def get_session_with_supplier(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[tuple]]: + """단일 세션 + 공급사(이름/이메일). 행별 재발송용. 반환: (session, name, email) | None.""" + try: + query = ( + select(sessions, suppliers.name, suppliers.manager_email) + .outerjoin(suppliers, suppliers.supplier_id == sessions.supplier_id) + .where(sessions.session_id == session_id, sessions.deleted == False) # noqa: E712 + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, None + rows = list(rows) + return ErrorType.SUCCESS, (rows[0] if rows else None) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, None + + async def mark_sessions_emailed(self, cdb: AsyncSession, session_ids, ts) -> ErrorType: + """발송 성공 세션들의 email_sent_at 을 ts 로 기록(write).""" + try: + if not session_ids: + return ErrorType.SUCCESS + query = ( + update(sessions) + .where(sessions.session_id.in_(session_ids)) + .values(email_sent_at=ts, updated_at=ts) + ) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + async def list_chats(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]: try: query = ( diff --git a/negodata/backend/requirements.txt b/negodata/backend/requirements.txt index 1d50362..fa46e28 100644 --- a/negodata/backend/requirements.txt +++ b/negodata/backend/requirements.txt @@ -11,3 +11,5 @@ python-multipart openpyxl httpx apscheduler>=3.10 +azure-communication-email>=1.0 # 초청 메일 1순위 발송 채널(ACS Email) +aiosmtplib>=3.0 # 초청 메일 폴백(SMTP) diff --git a/negodata/backend/router/router.py b/negodata/backend/router/router.py index 035373e..1d5bafb 100644 --- a/negodata/backend/router/router.py +++ b/negodata/backend/router/router.py @@ -11,6 +11,7 @@ from common.utils.gtime import GTime from config.server_configs import web_server_config from scheduler import shutdown_scheduler, start_scheduler import router.v1.auth.account +import router.v1.company.user import router.v1.item.item import router.v1.supplier.supplier import router.v1.card.card @@ -50,7 +51,8 @@ async def log_time(request: Request, call_next): start_time = time.time() response = await call_next(request) elapsed = time.time() - start_time - LOG.d(f"took: {elapsed:.4f} - {request.url.path}") + # status_code 를 함께 남긴다(403/4xx 등을 로그만으로 식별 가능하게). + LOG.d(f"{response.status_code} {request.method} {request.url.path} - {elapsed:.4f}s") return response @@ -61,6 +63,7 @@ async def healthz(): # 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.. 를 import 후 include. app.include_router(router.v1.auth.account.router) +app.include_router(router.v1.company.user.router) app.include_router(router.v1.item.item.router) app.include_router(router.v1.supplier.supplier.router) app.include_router(router.v1.card.card.router) diff --git a/negodata/backend/router/v1/quotation/protocol.py b/negodata/backend/router/v1/quotation/protocol.py index 3c402a9..dc150e0 100644 --- a/negodata/backend/router/v1/quotation/protocol.py +++ b/negodata/backend/router/v1/quotation/protocol.py @@ -92,6 +92,7 @@ class SessionData(WebPacketProtocol): qt_round: int qt_type: QuotationType target_price: int + target_anchoring_price: Optional[int] = None # 앵커링가(원). 목표가×(1−앵커링율) status: SessionStatus bid_price: Optional[int] = None bid_at: Optional[datetime] = None @@ -99,6 +100,7 @@ class SessionData(WebPacketProtocol): reject_reason: Optional[str] = None reject_price: Optional[int] = None reject_delivery_type: Optional[DeliveryType] = None + email_sent_at: Optional[datetime] = None # 협상 초청 메일 발송 시각(None=미발송). 프론트 발송배지/재발송 판단 url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성 @@ -107,6 +109,13 @@ class Res_CreateQuotation(Res_WebPacketProtocol): session_count: int = 0 # 함께 생성된 협상 세션 수(토스트 표시용). 세션 풀바디는 별도 GET 으로 조회 +class Res_NotifySessions(Res_WebPacketProtocol): + sent: int = 0 # 발송 성공 세션 수 + failed: int = 0 # 발송 시도했으나 실패한 세션 수 + skipped: int = 0 # 담당자 이메일이 없어 건너뛴 세션 수 + total: int = 0 # 대상 세션 총수 + + class Res_QuotationStatus(Res_WebPacketProtocol): qt_id: Optional[uuid.UUID] = None job_status: int = 0 diff --git a/negodata/backend/router/v1/quotation/quotation.py b/negodata/backend/router/v1/quotation/quotation.py index 488b840..fa97eed 100644 --- a/negodata/backend/router/v1/quotation/quotation.py +++ b/negodata/backend/router/v1/quotation/quotation.py @@ -12,6 +12,7 @@ from .protocol import ( Res_CreateQuotation, Res_DeleteQuotation, Res_LastSupplierType, + Res_NotifySessions, Res_Quotation, Res_QuotationCards, Res_QuotationList, @@ -73,11 +74,21 @@ async def get_quotation_sessions(qt_id: UUID, service: QuotationService = Depend return RemoveNoneResponse(await service.list_sessions(str(qt_id))) +@router.post(path="/{qt_id}/notify", response_model=Res_NotifySessions, summary="협상 초청 메일 발송") +async def notify_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.notify_sessions(str(qt_id))) + + @router.get(path="/session/{session_id}/chat", response_model=Res_SessionChat, summary="채팅 상세") async def get_session_chat(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): return RemoveNoneResponse(await service.list_chats(str(session_id))) +@router.post(path="/session/{session_id}/notify", response_model=Res_NotifySessions, summary="세션 초청 메일 재발송") +async def notify_session(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.notify_session(str(session_id))) + + @router.get(path="/{qt_id}/result", response_model=Res_QuotationResult, summary="낙찰 결과") async def get_quotation_result(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): return RemoveNoneResponse(await service.get_result(str(qt_id))) diff --git a/negodata/backend/router/v1/quotation_setting/protocol.py b/negodata/backend/router/v1/quotation_setting/protocol.py index 2f90855..0518f40 100644 --- a/negodata/backend/router/v1/quotation_setting/protocol.py +++ b/negodata/backend/router/v1/quotation_setting/protocol.py @@ -14,14 +14,12 @@ class QuotationSettingProtocol(WebPacketProtocol): class Req_CreateQuotationSetting(QuotationSettingProtocol): target_margin_rate: float anchoring_value: float = 0.01 - internet_average_fee: float = 0.078 # 인터넷 평균 수수료율(목표가 인터넷가 차감) card_count: int = 3 class Req_UpdateQuotationSetting(QuotationSettingProtocol): target_margin_rate: Optional[float] = None anchoring_value: Optional[float] = None - internet_average_fee: Optional[float] = None # 인터넷 평균 수수료율 card_count: Optional[int] = None @@ -32,7 +30,6 @@ class QuotationSettingData(WebPacketProtocol): user_id: Optional[uuid.UUID] = None target_margin_rate: float anchoring_value: float - internet_average_fee: float = 0.078 # 인터넷 평균 수수료율 card_count: int created_at: Optional[datetime] = None updated_at: Optional[datetime] = None diff --git a/negodata/backend/services/email.py b/negodata/backend/services/email.py new file mode 100644 index 0000000..eaea9ed --- /dev/null +++ b/negodata/backend/services/email.py @@ -0,0 +1,125 @@ +"""협상 초청 메일 발송. + +1순위: Azure Communication Services(ACS) Email (endpoint + accesskey + 검증된 sender). +2순위(폴백): SMTP (aiosmtplib). +둘 다 미설정이면 EmailUnavailable 을 던진다(조용한 실패 0). + +설정 출처: config..toml 의 [MailConfig] (config/config_models.py:MailConfig). +HTML 본문 템플릿: services/email_templates/*.html ($placeholder 치환). +""" +from __future__ import annotations + +from datetime import datetime +from email.message import EmailMessage +from html import escape +from pathlib import Path +from string import Template +from zoneinfo import ZoneInfo + +from common.logger import LOG +from config.server_configs import mail_config + +_KST = ZoneInfo("Asia/Seoul") + +# HTML 본문은 코드에 박지 않고 파일에서 읽는다(모듈 로드 시 1회). $placeholder 는 string.Template 로 치환. +_TEMPLATE_DIR = Path(__file__).parent / "email_templates" +_INVITE_HTML = Template((_TEMPLATE_DIR / "invite_email.html").read_text(encoding="utf-8")) + + +class EmailUnavailable(RuntimeError): + """ACS·SMTP 모두 미설정이라 발송 채널이 없음.""" + + +async def _send_acs(to: str, subject: str, html: str, text: str) -> None: + """Azure Communication Services Email — accesskey 인증.""" + from azure.communication.email.aio import EmailClient + + conn = f"endpoint={mail_config.azure_acs_endpoint};accesskey={mail_config.azure_acs_accesskey}" + message = { + "senderAddress": mail_config.azure_acs_sender, + "recipients": {"to": [{"address": to}]}, + "content": {"subject": subject, "plainText": text, "html": html}, + } + async with EmailClient.from_connection_string(conn) as client: + poller = await client.begin_send(message) + await poller.result() + LOG.i(f"email sent via ACS → {to} ({subject})") + + +async def _send_smtp(to: str, subject: str, html: str, text: str) -> None: + import aiosmtplib + + msg = EmailMessage() + msg["From"] = mail_config.smtp_from + msg["To"] = to + msg["Subject"] = subject + msg.set_content(text) + msg.add_alternative(html, subtype="html") + + await aiosmtplib.send( + msg, + hostname=mail_config.smtp_host, + port=mail_config.smtp_port, + username=mail_config.smtp_user or None, + password=mail_config.smtp_password or None, + start_tls=mail_config.smtp_starttls, + ) + LOG.i(f"email sent via SMTP → {to} ({subject})") + + +async def send_email(to: str, subject: str, html: str, text: str) -> None: + """ACS(설정 시) → SMTP(폴백) 순으로 1통 발송. 둘 다 없으면 EmailUnavailable.""" + if mail_config.acs_configured: + await _send_acs(to, subject, html, text) + return + if mail_config.smtp_host: + await _send_smtp(to, subject, html, text) + return + # endpoint/accesskey 만 있고 sender 누락 시 원인을 명확히 안내. + if mail_config.azure_acs_endpoint and not mail_config.azure_acs_sender: + raise EmailUnavailable("AZURE_ACS_SENDER(검증된 MailFrom 주소) 미설정") + raise EmailUnavailable("이메일 미설정 — ACS(endpoint+accesskey+sender) 또는 SMTP 필요") + + +def _fmt_deadline(end_time: datetime | None) -> str: + """협상 마감 시각 → 한국시간 'YYYY-MM-DD HH:MM' 표기. 값 없으면 빈 문자열.""" + if not end_time: + return "" + dt = end_time if end_time.tzinfo else end_time.replace(tzinfo=ZoneInfo("UTC")) + return dt.astimezone(_KST).strftime("%Y-%m-%d %H:%M") + + +def build_invite_email( + *, + supplier_name: str, + quotation_name: str, + qt_number: str, + end_time: datetime | None, + chat_url: str, +) -> tuple[str, str, str]: + """협상 초청 메일 (제목/HTML/텍스트) 생성. + + 목표가·앵커링가는 협상 전략 값이라 메일에 담지 않는다(공급사에게 노출 금지). + 공급사는 링크로 협상 화면에 진입해 입찰한다. + """ + sp = supplier_name or "협력사" + deadline = _fmt_deadline(end_time) or "미정" + subject = f"[협상 견적 {qt_number}] {quotation_name} — 협상 참여 요청" + + # HTML 본문은 invite_email.html 에서 읽어 치환. 값은 escape 해 HTML 인젝션 방지(견적명 등은 사용자 입력). + html = _INVITE_HTML.substitute( + supplier_name=escape(sp), + quotation_name=escape(quotation_name), + qt_number=escape(qt_number), + deadline=escape(deadline), + chat_url=escape(chat_url), + ) + + text = ( + f"{sp} 담당자님, 아래 견적 건의 협상에 참여해 주세요.\n\n" + f" - 견적명: {quotation_name}\n" + f" - 견적번호: {qt_number}\n" + f" - 협상 마감: {deadline}\n\n" + f"협상 참여 링크: {chat_url}\n" + ) + return subject, html, text diff --git a/negodata/backend/services/email_templates/invite_email.html b/negodata/backend/services/email_templates/invite_email.html new file mode 100644 index 0000000..aa79386 --- /dev/null +++ b/negodata/backend/services/email_templates/invite_email.html @@ -0,0 +1,70 @@ + + + + +
+ + + + + + + + + + + + + + + + + +
+ NEGODATA +
+

협상 참여 요청

+

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

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

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

+
+

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

+
diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index 8b35a29..edff67a 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -22,6 +22,7 @@ from router.v1.quotation.protocol import ( Res_CreateQuotation, Res_DeleteQuotation, Res_LastSupplierType, + Res_NotifySessions, Res_Quotation, Res_QuotationCards, Res_QuotationList, @@ -30,6 +31,7 @@ from router.v1.quotation.protocol import ( Res_QuotationStatus, Res_SessionChat, ) +from services.email import EmailUnavailable, build_invite_email, send_email class QuotationService: @@ -51,6 +53,9 @@ class QuotationService: # TODO 하한값 변경 해야함 !!! feat. MarineYang MIN_REGEN_DURATION = timedelta(hours=1) + # 인터넷 평균 수수료율(상수). 시장 평균값이라 견적/세팅별로 두지 않고 고정. 목표가=인터넷최저가×(1−값). + INTERNET_AVERAGE_FEE = 0.078 + def __init__(self, quotation_crud: IQuotationCRUD = Depends(QuotationCRUD)): self.quotation_crud = quotation_crud @@ -73,6 +78,11 @@ class QuotationService: ③ 후보 0개 → 견적 생성 불가(ValueError).""" if md_price: return int(md_price) + # 율은 비율(0~1 미만)이어야 한다. 1 이상이면 (1−율)≤0 → 목표가가 0/음수가 되므로 설정 오류로 막는다. + if not 0.0 <= (fee or 0.0) < 1.0: + raise ValueError(f"인터넷 수수료율은 0 이상 1 미만이어야 합니다: fee={fee}") + if not is_new and not 0.0 <= (margin or 0.0) < 1.0: + raise ValueError(f"목표 마진율은 0 이상 1 미만이어야 합니다: margin={margin}") candidates = [] if internet_lowest: candidates.append(int(internet_lowest) * (1 - (fee or 0.0))) @@ -289,7 +299,7 @@ class QuotationService: lambda s: self.quotation_crud.get_setting_rates(s, qt_setting_id), ) rates = rates if _err == ErrorType.SUCCESS else {} - fee = rates.get("fee") or 0.0 # 인터넷가 차감 수수료율 + fee = self.INTERNET_AVERAGE_FEE # 인터넷가 차감 수수료율(상수) margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율 anchoring = rates.get("anchoring") or 0.0 # 앵커링가 = 목표가×(1−값) @@ -352,6 +362,8 @@ class QuotationService: else: internet, purchase, selling = prices.get(iid) or (None, None, None) tp = self._calc_target_price(md_price, internet, purchase, selling, fee, margin, is_new=is_new) + if not 0.0 <= anchoring < 1.0: # 율 1 이상이면 앵커링가가 0/음수 → 설정 오류로 막는다. + raise ValueError(f"앵커링 값은 0 이상 1 미만이어야 합니다: anchoring={anchoring}") ap = int(tp * (1 - anchoring)) # 앵커링가 = floor(목표가×(1−앵커링율)); 율 0이면 목표가와 동일 for sid in supplier_ids: session_objs.append( @@ -369,7 +381,11 @@ class QuotationService: end_time=quotation.end_time, ) ) - except ValueError: + except ValueError as ex: + LOG.w( + f"[목표가 산정불가] qt_id={qt_id} item={iid} is_new={is_new} " + f"md={md_price} internet={internet} purchase={purchase} selling={selling} :: {ex}" + ) res.result.SetResult(ErrorType.QUOTATION_TARGET_PRICE_UNAVAILABLE) return res @@ -694,6 +710,7 @@ class QuotationService: qt_round=r.qt_round, qt_type=r.qt_type, target_price=r.target_price, + target_anchoring_price=r.target_anchoring_price, status=r.status, bid_price=r.bid_price, bid_at=r.bid_at, @@ -701,6 +718,7 @@ class QuotationService: reject_reason=r.reject_reason, reject_price=r.reject_price, reject_delivery_type=r.reject_delivery_type, + email_sent_at=r.email_sent_at, url=self._session_chat_url(r.session_id), ) for r in rows @@ -708,6 +726,106 @@ class QuotationService: res.total = len(res.sessions) return res + # ----- 협상 초청 메일 (수동 발송) + async def notify_sessions(self, qt_id: str) -> Res_NotifySessions: + """[수동 발송] 견적의 '미발송' 세션(공급사 담당자)에게 협상 초청 메일을 일괄 발송한다. + 대상 = email_sent_at IS NULL + 담당자 이메일 보유.""" + res = Res_NotifySessions() + qt_uuid = uuid.UUID(qt_id) + err_type, quotation = await self._fetch(qt_uuid) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + err_type, rows = await DB_SESSION_MNG.execute_lambda( + sessions.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.list_sessions_with_supplier(s, qt_uuid), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + # 행 언팩: (session, supplier_name, manager_email). + targets = [] # [(session, name, email)] + for r in rows: + sess, sp_name, email = r[0], r[1], r[2] + res.total += 1 + if sess.email_sent_at is not None: + continue # 이미 발송됨 — 재발송은 행 단위 endpoint 로 + if not email: + res.skipped += 1 + continue + targets.append((sess, sp_name, email)) + + sent_ids = await self._send_invites(quotation, targets, res) + if sent_ids: + await self._mark_emailed(sent_ids) + return res + + async def notify_session(self, session_id: str) -> Res_NotifySessions: + """[수동 재발송] 단일 세션(공급사)에 초청 메일 발송(이미 보냈어도 강제 재발송).""" + res = Res_NotifySessions() + sess_uuid = uuid.UUID(session_id) + err_type, got = await DB_SESSION_MNG.execute_lambda( + sessions.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.get_session_with_supplier(s, sess_uuid), + ) + if err_type != ErrorType.SUCCESS or got is None: + res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND) + return res + sess, sp_name, email = got[0], got[1], got[2] + res.total = 1 + err_type, quotation = await self._fetch(sess.quotation_id) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + if not email: + res.skipped = 1 + return res + + sent_ids = await self._send_invites(quotation, [(sess, sp_name, email)], res) + if sent_ids: + await self._mark_emailed(sent_ids) + return res + + async def _send_invites(self, quotation, targets: list, res: Res_NotifySessions) -> list: + """targets [(session, supplier_name, email)] 에 초청 메일 발송. res.sent/failed 를 채우고 + 성공한 session_id 목록을 반환. ACS/SMTP 미설정이면 첫 발송에서 중단(EMAIL_NOT_CONFIGURED).""" + sent_ids = [] + for sess, sp_name, email in targets: + subject, html, text = build_invite_email( + supplier_name=sp_name or "", + quotation_name=quotation.name, + qt_number=quotation.number, + end_time=quotation.end_time, + chat_url=self._session_chat_url(sess.session_id), + ) + try: + await send_email(email, subject, html, text) + sent_ids.append(sess.session_id) + res.sent += 1 + except EmailUnavailable as e: + res.result.SetResult(ErrorType.EMAIL_NOT_CONFIGURED) # 발송 채널 없음 — 더 시도해도 무의미 + res.msg = str(e) + break + except Exception as ex: + LOG.e_no_callstack(ex) + res.failed += 1 + # 보낼 대상이 있었는데 전부 실패면 명시적 실패 코드(설정은 됐으나 발송 실패). + if res.sent == 0 and res.failed > 0 and res.result.success: + res.result.SetResult(ErrorType.EMAIL_SEND_FAILED) + return sent_ids + + async def _mark_emailed(self, session_ids: list) -> None: + """발송 성공 세션들의 email_sent_at 갱신(write 트랜잭션).""" + now = GTime.UTC() + await DB_SESSION_MNG.execute_lambda_run( + [sessions.DBType()], + [lambda s: self.quotation_crud.mark_sessions_emailed(s, session_ids, now)], + ) + async def list_chats(self, session_id: str) -> Res_SessionChat: res = Res_SessionChat() sess_uuid = uuid.UUID(session_id) diff --git a/negodata/backend/services/quotation_setting_service.py b/negodata/backend/services/quotation_setting_service.py index 69ecf84..e614324 100644 --- a/negodata/backend/services/quotation_setting_service.py +++ b/negodata/backend/services/quotation_setting_service.py @@ -66,7 +66,6 @@ class QuotationSettingService: user_id=uuid.UUID(user_id), target_margin_rate=req.target_margin_rate, anchoring_value=req.anchoring_value, - internet_average_fee=req.internet_average_fee, card_count=req.card_count, ) err_type = await DB_SESSION_MNG.execute_lambda_run( diff --git a/negodata/front/src/api/generated/model/cardUsageType.ts b/negodata/front/src/api/generated/model/cardUsageType.ts index 7db4d39..1b9abcf 100644 --- a/negodata/front/src/api/generated/model/cardUsageType.ts +++ b/negodata/front/src/api/generated/model/cardUsageType.ts @@ -6,8 +6,8 @@ */ /** - * nego_cards/wild_cards.usage_type 코드값. 카드가 적용되는 견적 구분. -공통=신규·재 모두(기본), 신규전용=신규협상/신규견적(QuotationType 3·4), 재전용=재협상/재견적(1·2). + * nego_cards/wild_cards.usage_type 코드값. 협상카드 사용 범위(신규/재 견적·협상 양쪽 적용). +공통=모두 적용(기본), 신규견적전용, 재견적전용. */ export type CardUsageType = typeof CardUsageType[keyof typeof CardUsageType]; diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 36fbf55..d0b2814 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -28,6 +28,13 @@ export * from './chatMessageDataScript'; export * from './chatMessageDataStep'; export * from './chatSender'; export * from './companyData'; +export * from './companyUserData'; +export * from './companyUserDataContactNumber'; +export * from './companyUserDataCreatedAt'; +export * from './companyUserDataEmail'; +export * from './companyUserDataLastAccessedAt'; +export * from './companyUserDataName'; +export * from './companyUserDataUpdatedAt'; export * from './deliveryType'; export * from './errorInfo'; export * from './errorInfoCode'; @@ -59,6 +66,7 @@ export * from './listCardsParams'; export * from './listItemsParams'; export * from './listQuotationsParams'; export * from './listSuppliersParams'; +export * from './listUsersParams'; export * from './quotationCardData'; export * from './quotationCardDataCondition'; export * from './quotationCardDataEditScript'; @@ -93,7 +101,6 @@ export * from './quotationSettingDataUserId'; export * from './quotationStatus'; export * from './quotationType'; export * from './reqCheckCodes'; -export * from './reqCreateAccount'; export * from './reqCreateCard'; export * from './reqCreateCardCondition'; export * from './reqCreateCardEditScript'; @@ -101,6 +108,7 @@ export * from './reqCreateCardMemo'; export * from './reqCreateCardName'; export * from './reqCreateCardNumber'; export * from './reqCreateCardScript'; +export * from './reqCreateCompanyUser'; export * from './reqCreateItem'; export * from './reqCreateItemCategory'; export * from './reqCreateItemCode'; @@ -146,6 +154,12 @@ export * from './reqUpdateCardNumber'; export * from './reqUpdateCardScript'; export * from './reqUpdateCardStatus'; export * from './reqUpdateCardUsageType'; +export * from './reqUpdateCompanyUser'; +export * from './reqUpdateCompanyUserContactNumber'; +export * from './reqUpdateCompanyUserEmail'; +export * from './reqUpdateCompanyUserName'; +export * from './reqUpdateCompanyUserPassword'; +export * from './reqUpdateCompanyUserStatus'; export * from './reqUpdateItem'; export * from './reqUpdateItemCategory'; export * from './reqUpdateItemCategoryType'; @@ -167,10 +181,14 @@ export * from './reqUpdateItemQuantityUnit'; export * from './reqUpdateItemSellingPrice'; export * from './reqUpdateItemSpec'; export * from './reqUpdateItemVatYn'; +export * from './reqUpdateMe'; +export * from './reqUpdateMeContactNumber'; +export * from './reqUpdateMeEmail'; +export * from './reqUpdateMeName'; +export * from './reqUpdateMePassword'; export * from './reqUpdateQuotationSetting'; export * from './reqUpdateQuotationSettingAnchoringValue'; export * from './reqUpdateQuotationSettingCardCount'; -export * from './reqUpdateQuotationSettingInternetAverageFee'; export * from './reqUpdateQuotationSettingTargetMarginRate'; export * from './reqUpdateSupplier'; export * from './reqUpdateSupplierCode'; @@ -186,13 +204,18 @@ export * from './resCardListMsg'; export * from './resCardMsg'; export * from './resCheckCodes'; export * from './resCheckCodesMsg'; -export * from './resCreateAccount'; -export * from './resCreateAccountMsg'; +export * from './resCompanyUser'; +export * from './resCompanyUserList'; +export * from './resCompanyUserListMsg'; +export * from './resCompanyUserMsg'; +export * from './resCompanyUserUser'; export * from './resCreateQuotation'; export * from './resCreateQuotationMsg'; export * from './resCreateQuotationQtId'; export * from './resDeleteCard'; export * from './resDeleteCardMsg'; +export * from './resDeleteCompanyUser'; +export * from './resDeleteCompanyUserMsg'; export * from './resDeleteItem'; export * from './resDeleteItemMsg'; export * from './resDeleteQuotation'; @@ -229,6 +252,8 @@ export * from './resMeContactNumber'; export * from './resMeEmail'; export * from './resMeMsg'; export * from './resMeName'; +export * from './resNotifySessions'; +export * from './resNotifySessionsMsg'; export * from './resQuotation'; export * from './resQuotationCards'; export * from './resQuotationCardsMsg'; @@ -268,9 +293,11 @@ export * from './resSupplierSupplier'; export * from './sessionData'; export * from './sessionDataBidAt'; export * from './sessionDataBidPrice'; +export * from './sessionDataEmailSentAt'; export * from './sessionDataRejectDeliveryType'; export * from './sessionDataRejectPrice'; export * from './sessionDataRejectReason'; +export * from './sessionDataTargetAnchoringPrice'; export * from './sessionStatus'; export * from './supplierData'; export * from './supplierDataCode'; @@ -282,6 +309,7 @@ export * from './supplierDataPriority'; export * from './supplierDataUpdatedAt'; export * from './supplierType'; export * from './userRole'; +export * from './userStatus'; export * from './validationError'; export * from './validationErrorCtx'; export * from './validationErrorLocItem'; \ No newline at end of file diff --git a/negodata/front/src/api/generated/model/listUsersParams.ts b/negodata/front/src/api/generated/model/listUsersParams.ts new file mode 100644 index 0000000..6f87482 --- /dev/null +++ b/negodata/front/src/api/generated/model/listUsersParams.ts @@ -0,0 +1,22 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ListUsersParams = { +/** + * 로그인ID/이름/이메일 검색 + */ +search?: string | null; +/** + * @minimum 1 + */ +page?: number; +/** + * @minimum 1 + * @maximum 100 + */ +size?: number; +}; diff --git a/negodata/front/src/api/generated/model/quotationSettingData.ts b/negodata/front/src/api/generated/model/quotationSettingData.ts index 35d46d0..8c49ff9 100644 --- a/negodata/front/src/api/generated/model/quotationSettingData.ts +++ b/negodata/front/src/api/generated/model/quotationSettingData.ts @@ -13,7 +13,6 @@ export interface QuotationSettingData { user_id?: QuotationSettingDataUserId; target_margin_rate: number; anchoring_value: number; - internet_average_fee?: number; card_count: number; created_at?: QuotationSettingDataCreatedAt; updated_at?: QuotationSettingDataUpdatedAt; diff --git a/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts b/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts index 6254a2e..4bc40a5 100644 --- a/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts +++ b/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts @@ -8,6 +8,5 @@ export interface ReqCreateQuotationSetting { target_margin_rate: number; anchoring_value?: number; - internet_average_fee?: number; card_count?: number; } diff --git a/negodata/front/src/api/generated/model/reqUpdateMe.ts b/negodata/front/src/api/generated/model/reqUpdateMe.ts new file mode 100644 index 0000000..27a80f4 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateMe.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ReqUpdateMeName } from './reqUpdateMeName'; +import type { ReqUpdateMeEmail } from './reqUpdateMeEmail'; +import type { ReqUpdateMeContactNumber } from './reqUpdateMeContactNumber'; +import type { ReqUpdateMePassword } from './reqUpdateMePassword'; + +export interface ReqUpdateMe { + name?: ReqUpdateMeName; + email?: ReqUpdateMeEmail; + contact_number?: ReqUpdateMeContactNumber; + password?: ReqUpdateMePassword; +} diff --git a/negodata/front/src/api/generated/model/reqUpdateQuotationSettingInternetAverageFee.ts b/negodata/front/src/api/generated/model/reqUpdateMeContactNumber.ts similarity index 62% rename from negodata/front/src/api/generated/model/reqUpdateQuotationSettingInternetAverageFee.ts rename to negodata/front/src/api/generated/model/reqUpdateMeContactNumber.ts index 7678e83..dc4995b 100644 --- a/negodata/front/src/api/generated/model/reqUpdateQuotationSettingInternetAverageFee.ts +++ b/negodata/front/src/api/generated/model/reqUpdateMeContactNumber.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ReqUpdateQuotationSettingInternetAverageFee = number | null; +export type ReqUpdateMeContactNumber = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateMeEmail.ts b/negodata/front/src/api/generated/model/reqUpdateMeEmail.ts new file mode 100644 index 0000000..dd96611 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateMeEmail.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqUpdateMeEmail = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateMeName.ts b/negodata/front/src/api/generated/model/reqUpdateMeName.ts new file mode 100644 index 0000000..f464c30 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateMeName.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqUpdateMeName = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateMePassword.ts b/negodata/front/src/api/generated/model/reqUpdateMePassword.ts new file mode 100644 index 0000000..660e7b0 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateMePassword.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqUpdateMePassword = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts b/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts index 1235351..857bcd5 100644 --- a/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts +++ b/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts @@ -6,12 +6,10 @@ */ import type { ReqUpdateQuotationSettingTargetMarginRate } from './reqUpdateQuotationSettingTargetMarginRate'; import type { ReqUpdateQuotationSettingAnchoringValue } from './reqUpdateQuotationSettingAnchoringValue'; -import type { ReqUpdateQuotationSettingInternetAverageFee } from './reqUpdateQuotationSettingInternetAverageFee'; import type { ReqUpdateQuotationSettingCardCount } from './reqUpdateQuotationSettingCardCount'; export interface ReqUpdateQuotationSetting { target_margin_rate?: ReqUpdateQuotationSettingTargetMarginRate; anchoring_value?: ReqUpdateQuotationSettingAnchoringValue; - internet_average_fee?: ReqUpdateQuotationSettingInternetAverageFee; card_count?: ReqUpdateQuotationSettingCardCount; } diff --git a/negodata/front/src/api/generated/model/resNotifySessions.ts b/negodata/front/src/api/generated/model/resNotifySessions.ts new file mode 100644 index 0000000..6d23058 --- /dev/null +++ b/negodata/front/src/api/generated/model/resNotifySessions.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from './errorInfo'; +import type { ResNotifySessionsMsg } from './resNotifySessionsMsg'; + +export interface ResNotifySessions { + result?: ErrorInfo; + msg?: ResNotifySessionsMsg; + sent?: number; + failed?: number; + skipped?: number; + total?: number; +} diff --git a/negodata/front/src/api/generated/model/resNotifySessionsMsg.ts b/negodata/front/src/api/generated/model/resNotifySessionsMsg.ts new file mode 100644 index 0000000..2d1d777 --- /dev/null +++ b/negodata/front/src/api/generated/model/resNotifySessionsMsg.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ResNotifySessionsMsg = string | null; diff --git a/negodata/front/src/api/generated/model/sessionData.ts b/negodata/front/src/api/generated/model/sessionData.ts index b48fec3..69f19fb 100644 --- a/negodata/front/src/api/generated/model/sessionData.ts +++ b/negodata/front/src/api/generated/model/sessionData.ts @@ -5,12 +5,14 @@ * OpenAPI spec version: 0.1.0 */ import type { QuotationType } from './quotationType'; +import type { SessionDataTargetAnchoringPrice } from './sessionDataTargetAnchoringPrice'; import type { SessionStatus } from './sessionStatus'; import type { SessionDataBidPrice } from './sessionDataBidPrice'; import type { SessionDataBidAt } from './sessionDataBidAt'; import type { SessionDataRejectReason } from './sessionDataRejectReason'; import type { SessionDataRejectPrice } from './sessionDataRejectPrice'; import type { SessionDataRejectDeliveryType } from './sessionDataRejectDeliveryType'; +import type { SessionDataEmailSentAt } from './sessionDataEmailSentAt'; export interface SessionData { session_id: string; @@ -21,6 +23,7 @@ export interface SessionData { qt_round: number; qt_type: QuotationType; target_price: number; + target_anchoring_price?: SessionDataTargetAnchoringPrice; status: SessionStatus; bid_price?: SessionDataBidPrice; bid_at?: SessionDataBidAt; @@ -28,5 +31,6 @@ export interface SessionData { reject_reason?: SessionDataRejectReason; reject_price?: SessionDataRejectPrice; reject_delivery_type?: SessionDataRejectDeliveryType; + email_sent_at?: SessionDataEmailSentAt; url?: string; } diff --git a/negodata/front/src/api/generated/model/sessionDataEmailSentAt.ts b/negodata/front/src/api/generated/model/sessionDataEmailSentAt.ts new file mode 100644 index 0000000..aff7d65 --- /dev/null +++ b/negodata/front/src/api/generated/model/sessionDataEmailSentAt.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type SessionDataEmailSentAt = string | null; diff --git a/negodata/front/src/api/generated/model/sessionDataTargetAnchoringPrice.ts b/negodata/front/src/api/generated/model/sessionDataTargetAnchoringPrice.ts new file mode 100644 index 0000000..510d4bd --- /dev/null +++ b/negodata/front/src/api/generated/model/sessionDataTargetAnchoringPrice.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type SessionDataTargetAnchoringPrice = number | null; diff --git a/negodata/front/src/api/generated/model/supplierType.ts b/negodata/front/src/api/generated/model/supplierType.ts index 6fbada4..38cc6ca 100644 --- a/negodata/front/src/api/generated/model/supplierType.ts +++ b/negodata/front/src/api/generated/model/supplierType.ts @@ -6,15 +6,16 @@ */ /** - * suppliers.type 코드값(KTC 앵커링 기준). 0=기타는 매칭 실패 폴백용 — UI 선택지엔 미노출. + * quotations.supplier_type 코드값. 없음(0,미지정)/유통(1)/제조(2)/총판(3). +없음은 프론트 폼에 '없음'으로 노출. KTC 앵커링 코드(기타=0)와 매핑 시 0↔없음 대응. */ export type SupplierType = typeof SupplierType[keyof typeof SupplierType]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const SupplierType = { + NONE: 0, DISTRIBUTION: 1, MANUFACTURE: 2, SOLE_AGENCY: 3, - ETC: 0, } as const; diff --git a/negodata/front/src/api/generated/model/userStatus.ts b/negodata/front/src/api/generated/model/userStatus.ts new file mode 100644 index 0000000..d0a40bb --- /dev/null +++ b/negodata/front/src/api/generated/model/userStatus.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +/** + * users.status 코드값. + */ +export type UserStatus = typeof UserStatus[keyof typeof UserStatus]; + + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const UserStatus = { + ACTIVE: 1, + INACTIVE: 2, +} as const; diff --git a/negodata/front/src/api/generated/quotation/quotation.ts b/negodata/front/src/api/generated/quotation/quotation.ts index 05bc040..b68178e 100644 --- a/negodata/front/src/api/generated/quotation/quotation.ts +++ b/negodata/front/src/api/generated/quotation/quotation.ts @@ -31,6 +31,7 @@ import type { ResCreateQuotation, ResDeleteQuotation, ResLastSupplierType, + ResNotifySessions, ResQuotation, ResQuotationCards, ResQuotationList, @@ -516,6 +517,68 @@ export function useGetQuotationSessions,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/quotation/${qtId}/notify`, method: 'POST', signal + }, + options); + } + + + +export const getNotifyQuotationMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{qtId: string}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{qtId: string}, TContext> => { + +const mutationKey = ['notifyQuotation']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {qtId: string}> = (props) => { + const {qtId} = props ?? {}; + + return notifyQuotation(qtId,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type NotifyQuotationMutationResult = NonNullable>> + + export type NotifyQuotationMutationError = void | HTTPValidationError + + /** + * @summary 협상 초청 메일 발송 + */ +export const useNotifyQuotation = (options?: { mutation?:UseMutationOptions>, TError,{qtId: string}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {qtId: string}, + TContext + > => { + + const mutationOptions = getNotifyQuotationMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** * @summary 채팅 상세 */ export const getSessionChat = ( @@ -608,6 +671,68 @@ export function useGetSessionChat,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/quotation/session/${sessionId}/notify`, method: 'POST', signal + }, + options); + } + + + +export const getNotifySessionMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{sessionId: string}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{sessionId: string}, TContext> => { + +const mutationKey = ['notifySession']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {sessionId: string}> = (props) => { + const {sessionId} = props ?? {}; + + return notifySession(sessionId,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type NotifySessionMutationResult = NonNullable>> + + export type NotifySessionMutationError = void | HTTPValidationError + + /** + * @summary 세션 초청 메일 재발송 + */ +export const useNotifySession = (options?: { mutation?:UseMutationOptions>, TError,{sessionId: string}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {sessionId: string}, + TContext + > => { + + const mutationOptions = getNotifySessionMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** * @summary 낙찰 결과 */ export const getQuotationResult = ( diff --git a/negodata/front/src/components/layout/Layout.tsx b/negodata/front/src/components/layout/Layout.tsx index ee7aeeb..414b0f7 100644 --- a/negodata/front/src/components/layout/Layout.tsx +++ b/negodata/front/src/components/layout/Layout.tsx @@ -1,6 +1,7 @@ import { useState, type ReactNode, type ElementType } from 'react'; import { PageType } from '@/types'; import { useAuth } from '@/features/auth/useAuth'; +import { ProfileSheet } from '@/features/auth/components/ProfileSheet'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Typography } from '@/components/ui/typography'; @@ -8,6 +9,8 @@ import { cn } from '@/lib/utils'; import { Briefcase, Users, + UserCog, + UserPen, FileSpreadsheet, Layers, LogOut, @@ -28,11 +31,13 @@ interface LayoutProps { type SidebarUser = ReturnType['user']; -const menuItems = [ - { type: 'PRODUCTS' as PageType, label: '상품관리', icon: Briefcase, id: 'sidebar-products' }, - { type: 'PARTNERS' as PageType, label: '협력사관리', icon: Users, id: 'sidebar-partners' }, - { type: 'QUOTATION' as PageType, label: '견적관리', icon: FileSpreadsheet, id: 'sidebar-quotation' }, - { type: 'CARDS' as PageType, label: '협상카드관리', icon: Layers, id: 'sidebar-cards' }, +// ownerOnly 항목은 최고관리자에게만 노출된다(렌더 시 user.role 로 필터). +const menuItems: { type: PageType; label: string; icon: ElementType; id: string; ownerOnly?: boolean }[] = [ + { type: 'PRODUCTS', label: '상품관리', icon: Briefcase, id: 'sidebar-products' }, + { type: 'PARTNERS', label: '협력사관리', icon: Users, id: 'sidebar-partners' }, + { type: 'QUOTATION', label: '견적관리', icon: FileSpreadsheet, id: 'sidebar-quotation' }, + { type: 'CARDS', label: '협상카드관리', icon: Layers, id: 'sidebar-cards' }, + { type: 'MEMBERS', label: '회원관리', icon: UserCog, id: 'sidebar-members', ownerOnly: true }, ]; const pageLabelMap: Record = { @@ -40,6 +45,7 @@ const pageLabelMap: Record = { PARTNERS: '협력사관리', QUOTATION: '견적관리', CARDS: '협상카드관리', + MEMBERS: '회원관리', }; export default function Layout({ children, currentPage, setPage, onLogout }: LayoutProps) { @@ -50,6 +56,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay const [isDark, setIsDark] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(true); // 데스크톱 접기 토글 const [isMobileOpen, setIsMobileOpen] = useState(false); // 모바일 드로어 열림 + const [isProfileOpen, setIsProfileOpen] = useState(false); // 내 정보 수정 시트 // 사이드바 펼침 여부(라벨/프로필 노출 기준). 모바일 드로어는 항상 펼친 상태로 본다. const expanded = isMobileOpen || isSidebarOpen; @@ -126,7 +133,9 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay {/* Nav Items */}