diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index 135183f..35d3822 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -5,7 +5,7 @@ from sqlalchemy import Column, Integer, SmallInteger, BigInteger, Numeric, Float from sqlalchemy.dialects.postgresql import UUID, JSONB from sqlalchemy.sql import text -from common.enums import DBType, UserStatus, UserRole, CompanyStatus +from common.enums import DBType, UserStatus, UserRole, CompanyStatus, SupplierUserStatus, SupplierUserRole # 모든 ORM 모델의 베이스. insert 시 isinstance 체크에도 사용된다. MAIN_BASE = declarative_base() @@ -127,6 +127,36 @@ class suppliers(MainTableMixin, MAIN_BASE): total_revenue = Column(BigInteger, nullable=True) # 총매출액 +class supplier_users(MainTableMixin, MAIN_BASE): + __tablename__ = "supplier_users" + __table_args__ = {"schema": "supplier"} + + su_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + supplier_id = Column(UUID(as_uuid=True), nullable=False, index=True) # partner.suppliers (no-FK) + id = Column(String(20), nullable=False, index=True) # 로그인 아이디 + password = Column(String(255), nullable=False) # bcrypt 해시 + name = Column(String(50), nullable=True) + email = Column(String(255), nullable=True) + contact_number = Column(String(20), nullable=True) + last_accessed_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql()) + status = Column(SmallInteger, nullable=False, default=SupplierUserStatus.ACTIVE.value) + role = Column(SmallInteger, nullable=False, default=SupplierUserRole.MANAGER.value) + hide_service_info = Column(Boolean, nullable=False, server_default=text("false"), default=False) + + +# 채팅 계정의 로그인 토큰(루트 backend 소유) — negodata 는 비번재설정/비활성 시 기존 로그인을 끊기 위한 삭제만 한다. +class supplier_user_tokens(MainTableMixin, MAIN_BASE): + __tablename__ = "supplier_user_tokens" + __table_args__ = {"schema": "supplier"} + + sut_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + su_id = Column(UUID(as_uuid=True), nullable=False, index=True) # supplier_users.su_id + type = Column(SmallInteger, nullable=False) + token = Column(JSONB, nullable=False) + issued_at = Column(DateTime(timezone=True), nullable=False) + expired_at = Column(DateTime(timezone=True), nullable=False) + + class supplier_items(MainTableMixin, MAIN_BASE): __tablename__ = "supplier_items" # (supplier_id, item_id) 유일성은 soft-delete 인지 부분 유니크 인덱스(uq_supplier_items, WHERE deleted=FALSE)로 DB에서 보장. diff --git a/negodata/backend/common/enums.py b/negodata/backend/common/enums.py index e735dfa..bc6beee 100644 --- a/negodata/backend/common/enums.py +++ b/negodata/backend/common/enums.py @@ -58,6 +58,9 @@ class ErrorType(Enum): SUPPLIER_NOT_FOUND = 1400 SUPPLIER_CODE_DUPLICATE = auto() SUPPLIER_ITEM_NOT_FOUND = auto() # 협력사-상품 매핑 미존재 + SUPPLIER_ACCOUNT_NOT_FOUND = auto() # 채팅(협상) 계정 미발급 + SUPPLIER_ACCOUNT_ALREADY_EXISTS = auto() # 협력사당 1계정 — 이미 발급됨 + SUPPLIER_ACCOUNT_LOGIN_ID_DUPLICATE = auto() # 로그인 ID 전역 중복(supplier.supplier_users.id) # 견적 관련 에러 QUOTATION_NOT_FOUND = 1500 @@ -129,6 +132,21 @@ class CompanyStatus(CodeEnum): INACTIVE = 2 +class SupplierUserStatus(CodeEnum): + """supplier.supplier_users.status 코드값. 협력사의 채팅(협상) 로그인 계정 상태 — + 루트 backend 로그인이 ACTIVE 만 허용하므로 INACTIVE 로 두면 접속이 차단된다.""" + + ACTIVE = 1 + INACTIVE = 2 + + +class SupplierUserRole(CodeEnum): + """supplier.supplier_users.role 코드값(루트 backend 소유 코드 미러링).""" + + USER = 1 + MANAGER = 2 + + class QuotationType(CodeEnum): """quotations.type 코드값. 신규/재 × 협상(1:1)/견적(1:N). 1=재협상(1:1), 2=재견적(1:N), 3=신규협상(1:1), 4=신규견적(1:N). diff --git a/negodata/backend/conftest.py b/negodata/backend/conftest.py index 655d03c..707bb1a 100644 --- a/negodata/backend/conftest.py +++ b/negodata/backend/conftest.py @@ -19,7 +19,7 @@ from config.server_configs import main_db_config # 모델이 쓰는 스키마. test DB 는 비어 있을 수 있어 create_all 전에 직접 만든다. # 또 TRUNCATE 가 unqualified 테이블명을 쓰므로 이 스키마들을 search_path 에 얹어 해석시킨다. -_SCHEMAS = ("company", "quotation", "card", "negotiation", "partner") +_SCHEMAS = ("company", "quotation", "card", "negotiation", "partner", "supplier") def _write_url(cfg) -> str: diff --git a/negodata/backend/crud/supplier_user_crud.py b/negodata/backend/crud/supplier_user_crud.py new file mode 100644 index 0000000..ca828c1 --- /dev/null +++ b/negodata/backend/crud/supplier_user_crud.py @@ -0,0 +1,122 @@ +from abc import ABC, abstractmethod +from typing import Optional, Tuple + +from sqlalchemy import select, update, delete +from sqlalchemy.ext.asyncio import AsyncSession + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import supplier_users, supplier_user_tokens +from common.enums import ErrorType +from common.logger import LOG +from common.utils.gtime import GTime + + +# 채팅(협상) 로그인 계정 CRUD. supplier.supplier_users 에는 company_id 가 없으므로 +# 회사 스코프(협력사 소유권) 확인은 호출측(서비스)이 마친 뒤 supplier_id 로만 접근한다. +class ISupplierUserCRUD(ABC): + @abstractmethod + async def get_by_supplier(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, Optional[supplier_users]]: + pass + + @abstractmethod + async def login_id_exists(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, bool]: + pass + + @abstractmethod + async def account_map(self, cdb: AsyncSession, supplier_ids) -> Tuple[ErrorType, dict]: + pass + + @abstractmethod + async def add_account(self, cdb: AsyncSession, account: supplier_users) -> ErrorType: + pass + + @abstractmethod + async def update_account(self, cdb: AsyncSession, su_id, data: dict) -> ErrorType: + pass + + @abstractmethod + async def delete_tokens(self, cdb: AsyncSession, su_id) -> ErrorType: + pass + + +class SupplierUserCRUD(ISupplierUserCRUD): + async def get_by_supplier(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, Optional[supplier_users]]: + """협력사의 대표 계정 1건(발급순 첫 계정). 미발급이면 (SUCCESS, None) — 에러가 아니다.""" + try: + query = ( + select(supplier_users) + .where(supplier_users.supplier_id == supplier_id, supplier_users.deleted == False) # noqa: E712 + .order_by(supplier_users.created_at.asc()) + .limit(1) + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, None + 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 login_id_exists(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, bool]: + """로그인 ID 는 채팅 로그인 전역 유일(회사 스코프 아님) — 루트 backend 계정생성과 같은 기준.""" + try: + query = select(supplier_users.su_id).where( + supplier_users.id == login_id, + supplier_users.deleted == False, # noqa: E712 + ).limit(1) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, False + return ErrorType.SUCCESS, len(rows) > 0 + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, False + + async def account_map(self, cdb: AsyncSession, supplier_ids) -> Tuple[ErrorType, dict]: + """supplier_id 목록 → {supplier_id: (login_id, status)}. 목록/상세의 채팅 계정 표기용 배치 조인. + 협력사당 여러 계정(시드 등)이 있으면 발급순 첫 계정만 대표로 남긴다.""" + try: + if not supplier_ids: + return ErrorType.SUCCESS, {} + query = ( + select(supplier_users.supplier_id, supplier_users.id, supplier_users.status) + .where(supplier_users.supplier_id.in_(supplier_ids), supplier_users.deleted == False) # noqa: E712 + .order_by(supplier_users.created_at.asc()) + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, {} + result = {} + for sid, login_id, status in rows: + if sid not in result: + result[sid] = (login_id, status) + return ErrorType.SUCCESS, result + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, {} + + async def add_account(self, cdb: AsyncSession, account: supplier_users) -> ErrorType: + try: + return await DB_SESSION_MNG.insert(cdb, account) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + + async def update_account(self, cdb: AsyncSession, su_id, data: dict) -> ErrorType: + try: + if not data: + return ErrorType.SUCCESS + query = update(supplier_users).where(supplier_users.su_id == su_id).values(**data, updated_at=GTime.UTC()) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + + async def delete_tokens(self, cdb: AsyncSession, su_id) -> ErrorType: + """하드 삭제 — 루트 backend 가 로그인/로그아웃 때 하드 삭제하는 것과 같은 방식.""" + try: + query = delete(supplier_user_tokens).where(supplier_user_tokens.su_id == su_id) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED diff --git a/negodata/backend/router/v1/supplier/protocol.py b/negodata/backend/router/v1/supplier/protocol.py index 7e85216..88ba098 100644 --- a/negodata/backend/router/v1/supplier/protocol.py +++ b/negodata/backend/router/v1/supplier/protocol.py @@ -42,6 +42,8 @@ class SupplierData(WebPacketProtocol): manager_email: Optional[str] = None manager_contact_number: Optional[str] = None total_revenue: Optional[int] = None # 총매출액(원) + account_login_id: Optional[str] = None # 채팅(협상) 계정 로그인 ID. None=미발급 + account_status: Optional[int] = None # SupplierUserStatus. None=미발급 created_at: Optional[datetime] = None updated_at: Optional[datetime] = None @@ -64,3 +66,35 @@ class Res_CheckCodes(Res_WebPacketProtocol): class Res_DeleteSupplier(Res_WebPacketProtocol): pass + + +class SupplierAccountData(WebPacketProtocol): + su_id: uuid.UUID + login_id: str + status: int # SupplierUserStatus + created_at: Optional[datetime] = None + + +class Res_SupplierAccount(Res_WebPacketProtocol): + account: Optional[SupplierAccountData] = None # None=미발급 + + +class Req_CreateSupplierAccount(SupplierProtocol): + login_id: str = "" + + +class Res_CreateSupplierAccount(Res_WebPacketProtocol): + account: Optional[SupplierAccountData] = None + initial_password: Optional[str] = None # 평문 — 이 응답에서만 1회 노출, 저장은 bcrypt 해시 + + +class Req_ResetSupplierAccountPassword(SupplierProtocol): + password: Optional[str] = None # 지정 시 그 값으로, 미지정 시 서버 자동생성 + + +class Res_ResetSupplierAccountPassword(Res_WebPacketProtocol): + new_password: Optional[str] = None # 평문 — 이 응답에서만 1회 노출 + + +class Req_UpdateSupplierAccountStatus(SupplierProtocol): + status: int = 1 # SupplierUserStatus diff --git a/negodata/backend/router/v1/supplier/supplier.py b/negodata/backend/router/v1/supplier/supplier.py index 7925879..49aee19 100644 --- a/negodata/backend/router/v1/supplier/supplier.py +++ b/negodata/backend/router/v1/supplier/supplier.py @@ -7,10 +7,16 @@ from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneRespo from services.supplier_service import SupplierService from .protocol import ( Req_CheckCodes, + Req_CreateSupplierAccount, Req_CreateSupplier, + Req_ResetSupplierAccountPassword, + Req_UpdateSupplierAccountStatus, Req_UpdateSupplier, + Res_SupplierAccount, Res_CheckCodes, + Res_CreateSupplierAccount, Res_DeleteSupplier, + Res_ResetSupplierAccountPassword, Res_Supplier, Res_SupplierList, ) @@ -63,3 +69,32 @@ async def update_supplier( async def delete_supplier(supplier_id: UUID, service: SupplierService = Depends(), user_info: UserInfo = Depends(RequireOwner)): # 협력사 명부는 회사 공유 자원 — 파괴적 삭제는 최고관리자만(RequireOwner 가 비-OWNER 를 403 차단). return RemoveNoneResponse(await service.delete_supplier(user_info.company_id, str(supplier_id))) + + +# ---- 채팅(협상) 계정 — supplier.supplier_users 관리. 게이팅은 협력사 수정과 동일(회사 스코프). +@router.get(path="/{supplier_id}/account", response_model=Res_SupplierAccount, summary="채팅 계정 조회 (미발급이면 account=null)") +async def get_supplier_account( + supplier_id: UUID, service: SupplierService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) +): + return RemoveNoneResponse(await service.get_supplier_account(user_info.company_id, str(supplier_id))) + + +@router.post(path="/{supplier_id}/account/create", response_model=Res_CreateSupplierAccount, summary="채팅 계정 발급 (비밀번호 자동생성·1회 반환)") +async def create_supplier_account( + supplier_id: UUID, req: Req_CreateSupplierAccount, service: SupplierService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) +): + return RemoveNoneResponse(await service.create_supplier_account(user_info.company_id, str(supplier_id), req)) + + +@router.post(path="/{supplier_id}/account/reset-password", response_model=Res_ResetSupplierAccountPassword, summary="채팅 계정 비밀번호 재설정 (커스텀 지정 가능, 기존 로그인 즉시 무효화)") +async def reset_supplier_account_password( + supplier_id: UUID, req: Req_ResetSupplierAccountPassword, service: SupplierService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) +): + return RemoveNoneResponse(await service.reset_supplier_account_password(user_info.company_id, str(supplier_id), req)) + + +@router.patch(path="/{supplier_id}/account/status", response_model=Res_SupplierAccount, summary="채팅 계정 활성/비활성 (비활성 시 기존 로그인 즉시 무효화)") +async def update_supplier_account_status( + supplier_id: UUID, req: Req_UpdateSupplierAccountStatus, service: SupplierService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) +): + return RemoveNoneResponse(await service.update_supplier_account_status(user_info.company_id, str(supplier_id), req)) diff --git a/negodata/backend/services/supplier_service.py b/negodata/backend/services/supplier_service.py index d7705f0..7508d51 100644 --- a/negodata/backend/services/supplier_service.py +++ b/negodata/backend/services/supplier_service.py @@ -1,29 +1,60 @@ +import secrets import uuid from fastapi import Depends from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import suppliers -from common.enums import DBWRType, ErrorType +from common.database.model.models import suppliers, supplier_users +from common.enums import DBWRType, ErrorType, SupplierUserStatus from common.logger import LOG from common.models.gmodel import PageParams from crud.supplier_crud import ISupplierCRUD, SupplierCRUD +from crud.supplier_user_crud import ISupplierUserCRUD, SupplierUserCRUD +from router.v1.validator.dependencies import GetHashedPW from router.v1.supplier.protocol import ( + SupplierAccountData, + Req_CreateSupplierAccount, Req_CreateSupplier, + Req_ResetSupplierAccountPassword, + Req_UpdateSupplierAccountStatus, Req_UpdateSupplier, + Res_SupplierAccount, Res_CheckCodes, + Res_CreateSupplierAccount, Res_DeleteSupplier, + Res_ResetSupplierAccountPassword, Res_Supplier, Res_SupplierList, SupplierData, ) +_PW_ALPHABET = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789" # 혼동 문자(0O1lI) 제외 + + +def _generate_password() -> str: + """XXXX-XXXX 형태 임시 비밀번호. 관리자가 협력사에 전달하기 쉽게 짧은 두 블록으로 나눈다.""" + return "-".join("".join(secrets.choice(_PW_ALPHABET) for _ in range(4)) for _ in range(2)) + + +def _to_supplier_account_data(account: supplier_users) -> SupplierAccountData: + return SupplierAccountData( + su_id=account.su_id, + login_id=account.id, + status=account.status, + created_at=account.created_at, + ) + class SupplierService: """협력사 비즈니스 로직. company_id 로 소유권을 확인한다(멀티테넌트).""" - def __init__(self, supplier_crud: ISupplierCRUD = Depends(SupplierCRUD)): + def __init__( + self, + supplier_crud: ISupplierCRUD = Depends(SupplierCRUD), + supplier_user_crud: ISupplierUserCRUD = Depends(SupplierUserCRUD), + ): self.supplier_crud = supplier_crud + self.supplier_user_crud = supplier_user_crud async def _fetch_owned(self, company_uuid: uuid.UUID, supplier_id: uuid.UUID): """supplier 조회 + 소유권 확인. (ErrorType, supplier|None) 반환.""" @@ -61,6 +92,18 @@ class SupplierService: if nm_err == ErrorType.SUCCESS: for d in res.suppliers: d.creator_name = name_map.get(d.user_id) + # 채팅 계정 배치 조인 — 목록의 '채팅 계정' 컬럼용(IN 쿼리 1회). + supplier_ids = [r.supplier_id for r in rows] + if supplier_ids: + am_err, acc_map = await DB_SESSION_MNG.execute_lambda( + suppliers.DBType(), DBWRType.DB_READ.value, + lambda s: self.supplier_user_crud.account_map(s, supplier_ids), + ) + if am_err == ErrorType.SUCCESS: + for d in res.suppliers: + acc = acc_map.get(d.supplier_id) + if acc: + d.account_login_id, d.account_status = acc res.total = total return res @@ -78,6 +121,10 @@ class SupplierService: ) if nm_err == ErrorType.SUCCESS: res.supplier.creator_name = name_map.get(supplier.user_id) + acc_err, account = await self._fetch_account(supplier.supplier_id) + if acc_err == ErrorType.SUCCESS and account is not None: + res.supplier.account_login_id = account.id + res.supplier.account_status = account.status return res async def check_codes(self, company_id: str, codes: list) -> Res_CheckCodes: @@ -174,3 +221,146 @@ class SupplierService: if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res + + # ---- 채팅(협상) 계정 — 정본은 루트 backend 소유 supplier.supplier_users. + # negodata 는 발급(협력사당 1개)/비번 재설정/활성상태만. 게이팅은 협력사 수정과 동일(회사 스코프). + + async def _fetch_account(self, supplier_id: uuid.UUID): + return await DB_SESSION_MNG.execute_lambda( + supplier_users.DBType(), + DBWRType.DB_READ.value, + lambda s: self.supplier_user_crud.get_by_supplier(s, supplier_id), + ) + + async def get_supplier_account(self, company_id: str, supplier_id: str) -> Res_SupplierAccount: + res = Res_SupplierAccount() + err_type, _ = await self._fetch_owned(uuid.UUID(company_id), uuid.UUID(supplier_id)) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + acc_err, account = await self._fetch_account(uuid.UUID(supplier_id)) + if acc_err != ErrorType.SUCCESS: + res.result.SetResult(acc_err) + return res + if account is not None: + res.account = _to_supplier_account_data(account) + return res + + async def create_supplier_account(self, company_id: str, supplier_id: str, req: Req_CreateSupplierAccount) -> Res_CreateSupplierAccount: + res = Res_CreateSupplierAccount() + supplier_uuid = uuid.UUID(supplier_id) + err_type, supplier = await self._fetch_owned(uuid.UUID(company_id), supplier_uuid) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + acc_err, existing = await self._fetch_account(supplier_uuid) + if acc_err != ErrorType.SUCCESS: + res.result.SetResult(acc_err) + return res + if existing is not None: + res.result.SetResult(ErrorType.SUPPLIER_ACCOUNT_ALREADY_EXISTS) + return res + + login_id = req.login_id.strip() + dup_err, dup = await DB_SESSION_MNG.execute_lambda( + supplier_users.DBType(), + DBWRType.DB_READ.value, + lambda s: self.supplier_user_crud.login_id_exists(s, login_id), + ) + if dup_err != ErrorType.SUCCESS: + res.result.SetResult(dup_err) + return res + if dup: + res.result.SetResult(ErrorType.SUPPLIER_ACCOUNT_LOGIN_ID_DUPLICATE) + return res + + password = _generate_password() + # 담당자 정보를 계정 프로필로 복사 — 채팅 쪽에서 이름/연락처 표기에 쓰인다. + account = supplier_users( + supplier_id=supplier_uuid, + id=login_id, + password=await GetHashedPW(password), + name=supplier.manager_name, + email=supplier.manager_email, + contact_number=supplier.manager_contact_number, + ) + err_type = await DB_SESSION_MNG.execute_lambda_run( + [supplier_users.DBType()], + [lambda s: self.supplier_user_crud.add_account(s, account)], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + re_err, created = await self._fetch_account(supplier_uuid) + if re_err == ErrorType.SUCCESS and created is not None: + res.account = _to_supplier_account_data(created) + res.initial_password = password + return res + + async def reset_supplier_account_password( + self, company_id: str, supplier_id: str, req: Req_ResetSupplierAccountPassword + ) -> Res_ResetSupplierAccountPassword: + res = Res_ResetSupplierAccountPassword() + supplier_uuid = uuid.UUID(supplier_id) + err_type, _ = await self._fetch_owned(uuid.UUID(company_id), supplier_uuid) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + acc_err, account = await self._fetch_account(supplier_uuid) + if acc_err != ErrorType.SUCCESS: + res.result.SetResult(acc_err) + return res + if account is None: + res.result.SetResult(ErrorType.SUPPLIER_ACCOUNT_NOT_FOUND) + return res + + password = (req.password or "").strip() or _generate_password() + hashed = await GetHashedPW(password) + # 비번 교체와 동시에 토큰을 지워 기존 로그인을 즉시 끊는다. + err_type = await DB_SESSION_MNG.execute_lambda_run( + [supplier_users.DBType(), supplier_users.DBType()], + [ + lambda s: self.supplier_user_crud.update_account(s, account.su_id, {"password": hashed}), + lambda s: self.supplier_user_crud.delete_tokens(s, account.su_id), + ], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.new_password = password + return res + + async def update_supplier_account_status(self, company_id: str, supplier_id: str, req: Req_UpdateSupplierAccountStatus) -> Res_SupplierAccount: + res = Res_SupplierAccount() + supplier_uuid = uuid.UUID(supplier_id) + err_type, _ = await self._fetch_owned(uuid.UUID(company_id), supplier_uuid) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + acc_err, account = await self._fetch_account(supplier_uuid) + if acc_err != ErrorType.SUCCESS: + res.result.SetResult(acc_err) + return res + if account is None: + res.result.SetResult(ErrorType.SUPPLIER_ACCOUNT_NOT_FOUND) + return res + + funcs = [lambda s: self.supplier_user_crud.update_account(s, account.su_id, {"status": req.status})] + db_types = [supplier_users.DBType()] + if req.status == SupplierUserStatus.INACTIVE.value: + # 비활성화는 접속 차단이 목적 — 토큰도 지워 이미 로그인된 세션을 끊는다. + funcs.append(lambda s: self.supplier_user_crud.delete_tokens(s, account.su_id)) + db_types.append(supplier_users.DBType()) + err_type = await DB_SESSION_MNG.execute_lambda_run(db_types, funcs) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + re_err, updated = await self._fetch_account(supplier_uuid) + if re_err == ErrorType.SUCCESS and updated is not None: + res.account = _to_supplier_account_data(updated) + return res diff --git a/negodata/backend/tests/test_supplier_account.py b/negodata/backend/tests/test_supplier_account.py new file mode 100644 index 0000000..37c0b4c --- /dev/null +++ b/negodata/backend/tests/test_supplier_account.py @@ -0,0 +1,165 @@ +"""협력사 채팅(협상) 계정 관리 — supplier.supplier_users 발급/조회/비번재설정/활성상태. + +계정 정본은 루트 backend 공유 테이블이므로 negodata 는 협력사(회사 스코프) 소유권을 확인한 뒤 +발급(협력사당 1개, 비번 자동생성·1회 반환)/재설정/비활성만 수행한다. 재설정·비활성 시 토큰이 지워져 +기존 채팅 로그인이 즉시 끊기는 것까지 확인한다. +""" +import uuid +from datetime import datetime, timedelta + +from sqlalchemy import text + + +async def _create_supplier(client, headers, name="채팅협력사", code=None): + body = {"name": name, "code": code or f"CHAT-{uuid.uuid4().hex[:8]}", "manager_name": "김담당", "manager_email": "m@x.co"} + res = (await client.post("/v1/supplier/create", json=body, headers=headers)).json() + return res["supplier"]["supplier_id"] + + +async def _seed_token(db_engine, su_id): + async with db_engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO supplier_user_tokens (sut_id, su_id, type, token, issued_at, expired_at) " + "VALUES (:sut, :su, 1, '{}'::jsonb, :ia, :ea)" + ), + {"sut": uuid.uuid4(), "su": uuid.UUID(su_id), "ia": datetime.utcnow(), "ea": datetime.utcnow() + timedelta(days=1)}, + ) + + +async def _token_count(db_engine, su_id) -> int: + async with db_engine.begin() as conn: + row = await conn.execute(text("SELECT count(*) FROM supplier_user_tokens WHERE su_id = :su"), {"su": uuid.UUID(su_id)}) + return int(row.scalar() or 0) + + +async def _password_hash(db_engine, su_id) -> str: + async with db_engine.begin() as conn: + row = await conn.execute(text("SELECT password FROM supplier_users WHERE su_id = :su"), {"su": uuid.UUID(su_id)}) + return row.scalar() + + +async def test_supplier_account_create_and_get(client, auth_headers): + """검증: 계정 발급(커스텀 로그인 ID) 후 단건 조회·협력사 상세·목록 응답 확인. + 기대결과: initial_password 1회 반환(XXXX-XXXX), 조회 account.login_id 일치, 상세/목록에 account_login_id 노출.""" + h = await auth_headers("caA") + sid = await _create_supplier(client, h) + login_id = f"chat{uuid.uuid4().hex[:8]}" + + created = (await client.post(f"/v1/supplier/{sid}/account/create", json={"login_id": login_id}, headers=h)).json() + assert created["result"]["success"] is True + assert created["account"]["login_id"] == login_id + assert created["account"]["status"] == 1 + pw = created["initial_password"] + assert len(pw) == 9 and pw[4] == "-" + + got = (await client.get(f"/v1/supplier/{sid}/account", headers=h)).json() + assert got["account"]["login_id"] == login_id + assert "initial_password" not in got # 평문 비번은 발급 응답에서만 + + detail = (await client.get(f"/v1/supplier/{sid}", headers=h)).json() + assert detail["supplier"]["account_login_id"] == login_id + assert detail["supplier"]["account_status"] == 1 + lst = (await client.get("/v1/supplier/list", headers=h)).json() + assert {s["supplier_id"]: s.get("account_login_id") for s in lst["suppliers"]}[sid] == login_id + + +async def test_supplier_account_get_before_create_is_null(client, auth_headers): + """검증: 발급 전 채팅 계정 단건 조회. + 기대결과: success=True 에 account 없음(미발급은 에러가 아니라 null).""" + h = await auth_headers("caN") + sid = await _create_supplier(client, h) + got = (await client.get(f"/v1/supplier/{sid}/account", headers=h)).json() + assert got["result"]["success"] is True + assert got.get("account") is None + + +async def test_supplier_account_one_per_supplier(client, auth_headers): + """검증: 이미 계정이 있는 협력사에 재발급 시도. + 기대결과: code=1404(SUPPLIER_ACCOUNT_ALREADY_EXISTS).""" + h = await auth_headers("caO") + sid = await _create_supplier(client, h) + assert (await client.post(f"/v1/supplier/{sid}/account/create", json={"login_id": f"one{uuid.uuid4().hex[:8]}"}, headers=h)).json()["result"]["success"] is True + dup = (await client.post(f"/v1/supplier/{sid}/account/create", json={"login_id": f"two{uuid.uuid4().hex[:8]}"}, headers=h)).json() + assert dup["result"]["code"] == 1404 + + +async def test_supplier_account_login_id_duplicate(client, auth_headers): + """검증: 다른 협력사에서 이미 쓰는 로그인 ID 로 발급 시도. + 기대결과: code=1405(SUPPLIER_ACCOUNT_LOGIN_ID_DUPLICATE) — 로그인 ID 는 전역 유일.""" + h = await auth_headers("caD") + login_id = f"dup{uuid.uuid4().hex[:8]}" + s1 = await _create_supplier(client, h, name="협력사1") + s2 = await _create_supplier(client, h, name="협력사2") + assert (await client.post(f"/v1/supplier/{s1}/account/create", json={"login_id": login_id}, headers=h)).json()["result"]["success"] is True + dup = (await client.post(f"/v1/supplier/{s2}/account/create", json={"login_id": login_id}, headers=h)).json() + assert dup["result"]["code"] == 1405 + + +async def test_supplier_account_reset_password(client, auth_headers, db_engine): + """검증: 비밀번호 재설정 — 새 평문 1회 반환, DB 해시 교체, 로그인 토큰 삭제. + 기대결과: new_password 반환, password 해시가 발급 때와 달라짐, supplier_user_tokens 0건.""" + h = await auth_headers("caR") + sid = await _create_supplier(client, h) + created = (await client.post(f"/v1/supplier/{sid}/account/create", json={"login_id": f"rst{uuid.uuid4().hex[:8]}"}, headers=h)).json() + su_id = created["account"]["su_id"] + before = await _password_hash(db_engine, su_id) + await _seed_token(db_engine, su_id) + + reset = (await client.post(f"/v1/supplier/{sid}/account/reset-password", json={}, headers=h)).json() + assert reset["result"]["success"] is True + assert reset["new_password"] and reset["new_password"] != created["initial_password"] + assert await _password_hash(db_engine, su_id) != before + assert await _token_count(db_engine, su_id) == 0 + + +async def test_supplier_account_reset_password_custom(client, auth_headers, db_engine): + """검증: 비밀번호 재설정에 커스텀 값을 지정(password 필드). + 기대결과: new_password 가 지정값 그대로, DB 해시도 교체됨(지정값 미저장·해시만).""" + h = await auth_headers("caRC") + sid = await _create_supplier(client, h) + created = (await client.post(f"/v1/supplier/{sid}/account/create", json={"login_id": f"cst{uuid.uuid4().hex[:8]}"}, headers=h)).json() + su_id = created["account"]["su_id"] + before = await _password_hash(db_engine, su_id) + + reset = (await client.post(f"/v1/supplier/{sid}/account/reset-password", json={"password": "Custom-Pw9"}, headers=h)).json() + assert reset["result"]["success"] is True + assert reset["new_password"] == "Custom-Pw9" + after = await _password_hash(db_engine, su_id) + assert after != before and after != "Custom-Pw9" + + +async def test_supplier_account_deactivate_and_activate(client, auth_headers, db_engine): + """검증: 비활성화(토큰 삭제 동반) 후 재활성화. + 기대결과: 비활성 시 status=2·토큰 0건, 활성 복귀 시 status=1.""" + h = await auth_headers("caS") + sid = await _create_supplier(client, h) + created = (await client.post(f"/v1/supplier/{sid}/account/create", json={"login_id": f"sts{uuid.uuid4().hex[:8]}"}, headers=h)).json() + su_id = created["account"]["su_id"] + await _seed_token(db_engine, su_id) + + off = (await client.patch(f"/v1/supplier/{sid}/account/status", json={"status": 2}, headers=h)).json() + assert off["account"]["status"] == 2 + assert await _token_count(db_engine, su_id) == 0 + + on = (await client.patch(f"/v1/supplier/{sid}/account/status", json={"status": 1}, headers=h)).json() + assert on["account"]["status"] == 1 + + +async def test_supplier_account_reset_without_account(client, auth_headers): + """검증: 미발급 협력사에 비밀번호 재설정 시도. + 기대결과: code=1403(SUPPLIER_ACCOUNT_NOT_FOUND).""" + h = await auth_headers("caX") + sid = await _create_supplier(client, h) + res = (await client.post(f"/v1/supplier/{sid}/account/reset-password", json={}, headers=h)).json() + assert res["result"]["code"] == 1403 + + +async def test_supplier_account_hidden_across_company(client, auth_headers, other_company_id): + """검증: 회사A 협력사의 채팅 계정을 회사B 유저가 조회/발급 시도. + 기대결과: 둘 다 code=1400(SUPPLIER_NOT_FOUND) — 협력사 자체가 없는 것처럼 막힘.""" + ha = await auth_headers("caCA") + sid = await _create_supplier(client, ha) + hb = await auth_headers("caCB", other_company_id) + assert (await client.get(f"/v1/supplier/{sid}/account", headers=hb)).json()["result"]["code"] == 1400 + assert (await client.post(f"/v1/supplier/{sid}/account/create", json={"login_id": "hack"}, headers=hb)).json()["result"]["code"] == 1400 diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 65b0c7a..e4c514f 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -162,6 +162,7 @@ export * from './reqCreateQuotationSetting'; export * from './reqCreateQuotationStartTime'; export * from './reqCreateQuotationVersionId'; export * from './reqCreateSupplier'; +export * from './reqCreateSupplierAccount'; export * from './reqCreateSupplierCode'; export * from './reqCreateSupplierItem'; export * from './reqCreateSupplierManagerContactNumber'; @@ -170,6 +171,8 @@ export * from './reqCreateSupplierManagerName'; export * from './reqCreateSupplierTotalRevenue'; export * from './reqLogin'; export * from './reqRegenerateQuotation'; +export * from './reqResetSupplierAccountPassword'; +export * from './reqResetSupplierAccountPasswordPassword'; export * from './reqUpdateCard'; export * from './reqUpdateCardCondition'; export * from './reqUpdateCardEditScript'; @@ -215,6 +218,7 @@ export * from './reqUpdateQuotationSetting'; export * from './reqUpdateQuotationSettingCardCount'; export * from './reqUpdateQuotationSettingTargetMarginRate'; export * from './reqUpdateSupplier'; +export * from './reqUpdateSupplierAccountStatus'; export * from './reqUpdateSupplierCode'; export * from './reqUpdateSupplierManagerContactNumber'; export * from './reqUpdateSupplierManagerEmail'; @@ -239,6 +243,10 @@ export * from './resCompanyUserUser'; export * from './resCreateQuotation'; export * from './resCreateQuotationMsg'; export * from './resCreateQuotationQtId'; +export * from './resCreateSupplierAccount'; +export * from './resCreateSupplierAccountAccount'; +export * from './resCreateSupplierAccountInitialPassword'; +export * from './resCreateSupplierAccountMsg'; export * from './resDashboardSummary'; export * from './resDashboardSummaryMsg'; export * from './resDeleteCard'; @@ -313,12 +321,18 @@ export * from './resQuotationStatusMsg'; export * from './resQuotationStatusQtId'; export * from './resRefreshToken'; export * from './resRefreshTokenMsg'; +export * from './resResetSupplierAccountPassword'; +export * from './resResetSupplierAccountPasswordMsg'; +export * from './resResetSupplierAccountPasswordNewPassword'; export * from './resSessionChat'; export * from './resSessionChatMsg'; export * from './resSessionChatSessionId'; export * from './resStatisticsSummary'; export * from './resStatisticsSummaryMsg'; export * from './resSupplier'; +export * from './resSupplierAccount'; +export * from './resSupplierAccountAccount'; +export * from './resSupplierAccountMsg'; export * from './resSupplierItem'; export * from './resSupplierItemList'; export * from './resSupplierItemListMsg'; @@ -355,7 +369,11 @@ export * from './statOutcome'; export * from './statParticipation'; export * from './statScope'; export * from './statTypeRow'; +export * from './supplierAccountData'; +export * from './supplierAccountDataCreatedAt'; export * from './supplierData'; +export * from './supplierDataAccountLoginId'; +export * from './supplierDataAccountStatus'; export * from './supplierDataCode'; export * from './supplierDataCreatedAt'; export * from './supplierDataCreatorName'; @@ -368,10 +386,9 @@ export * from './supplierItemData'; export * from './supplierItemDataCreatedAt'; export * from './supplierItemDataItemCode'; export * from './supplierItemDataUpdatedAt'; -export * from './supplierType'; export * from './targetCandidate'; export * from './userRole'; export * from './userStatus'; export * from './validationError'; export * from './validationErrorCtx'; -export * from './validationErrorLocItem'; +export * from './validationErrorLocItem'; \ No newline at end of file diff --git a/negodata/front/src/api/generated/model/reqCreateSupplierAccount.ts b/negodata/front/src/api/generated/model/reqCreateSupplierAccount.ts new file mode 100644 index 0000000..f58e830 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateSupplierAccount.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export interface ReqCreateSupplierAccount { + login_id?: string; +} diff --git a/negodata/front/src/api/generated/model/reqResetSupplierAccountPassword.ts b/negodata/front/src/api/generated/model/reqResetSupplierAccountPassword.ts new file mode 100644 index 0000000..921f16c --- /dev/null +++ b/negodata/front/src/api/generated/model/reqResetSupplierAccountPassword.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ReqResetSupplierAccountPasswordPassword } from './reqResetSupplierAccountPasswordPassword'; + +export interface ReqResetSupplierAccountPassword { + password?: ReqResetSupplierAccountPasswordPassword; +} diff --git a/negodata/front/src/api/generated/model/reqResetSupplierAccountPasswordPassword.ts b/negodata/front/src/api/generated/model/reqResetSupplierAccountPasswordPassword.ts new file mode 100644 index 0000000..bebd60d --- /dev/null +++ b/negodata/front/src/api/generated/model/reqResetSupplierAccountPasswordPassword.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 ReqResetSupplierAccountPasswordPassword = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateSupplierAccountStatus.ts b/negodata/front/src/api/generated/model/reqUpdateSupplierAccountStatus.ts new file mode 100644 index 0000000..a5d1531 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateSupplierAccountStatus.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export interface ReqUpdateSupplierAccountStatus { + status?: number; +} diff --git a/negodata/front/src/api/generated/model/resCreateSupplierAccount.ts b/negodata/front/src/api/generated/model/resCreateSupplierAccount.ts new file mode 100644 index 0000000..3ac0d43 --- /dev/null +++ b/negodata/front/src/api/generated/model/resCreateSupplierAccount.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 { ResCreateSupplierAccountMsg } from './resCreateSupplierAccountMsg'; +import type { ResCreateSupplierAccountAccount } from './resCreateSupplierAccountAccount'; +import type { ResCreateSupplierAccountInitialPassword } from './resCreateSupplierAccountInitialPassword'; + +export interface ResCreateSupplierAccount { + result?: ErrorInfo; + msg?: ResCreateSupplierAccountMsg; + account?: ResCreateSupplierAccountAccount; + initial_password?: ResCreateSupplierAccountInitialPassword; +} diff --git a/negodata/front/src/api/generated/model/resCreateSupplierAccountAccount.ts b/negodata/front/src/api/generated/model/resCreateSupplierAccountAccount.ts new file mode 100644 index 0000000..5e75943 --- /dev/null +++ b/negodata/front/src/api/generated/model/resCreateSupplierAccountAccount.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { SupplierAccountData } from './supplierAccountData'; + +export type ResCreateSupplierAccountAccount = SupplierAccountData | null; diff --git a/negodata/front/src/api/generated/model/resCreateSupplierAccountInitialPassword.ts b/negodata/front/src/api/generated/model/resCreateSupplierAccountInitialPassword.ts new file mode 100644 index 0000000..cc38f33 --- /dev/null +++ b/negodata/front/src/api/generated/model/resCreateSupplierAccountInitialPassword.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 ResCreateSupplierAccountInitialPassword = string | null; diff --git a/negodata/front/src/api/generated/model/resCreateSupplierAccountMsg.ts b/negodata/front/src/api/generated/model/resCreateSupplierAccountMsg.ts new file mode 100644 index 0000000..2799f6f --- /dev/null +++ b/negodata/front/src/api/generated/model/resCreateSupplierAccountMsg.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 ResCreateSupplierAccountMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resResetSupplierAccountPassword.ts b/negodata/front/src/api/generated/model/resResetSupplierAccountPassword.ts new file mode 100644 index 0000000..8f7784d --- /dev/null +++ b/negodata/front/src/api/generated/model/resResetSupplierAccountPassword.ts @@ -0,0 +1,15 @@ +/** + * 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 { ResResetSupplierAccountPasswordMsg } from './resResetSupplierAccountPasswordMsg'; +import type { ResResetSupplierAccountPasswordNewPassword } from './resResetSupplierAccountPasswordNewPassword'; + +export interface ResResetSupplierAccountPassword { + result?: ErrorInfo; + msg?: ResResetSupplierAccountPasswordMsg; + new_password?: ResResetSupplierAccountPasswordNewPassword; +} diff --git a/negodata/front/src/api/generated/model/resResetSupplierAccountPasswordMsg.ts b/negodata/front/src/api/generated/model/resResetSupplierAccountPasswordMsg.ts new file mode 100644 index 0000000..392cb5d --- /dev/null +++ b/negodata/front/src/api/generated/model/resResetSupplierAccountPasswordMsg.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 ResResetSupplierAccountPasswordMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resResetSupplierAccountPasswordNewPassword.ts b/negodata/front/src/api/generated/model/resResetSupplierAccountPasswordNewPassword.ts new file mode 100644 index 0000000..fb3c587 --- /dev/null +++ b/negodata/front/src/api/generated/model/resResetSupplierAccountPasswordNewPassword.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 ResResetSupplierAccountPasswordNewPassword = string | null; diff --git a/negodata/front/src/api/generated/model/resSupplierAccount.ts b/negodata/front/src/api/generated/model/resSupplierAccount.ts new file mode 100644 index 0000000..120e66a --- /dev/null +++ b/negodata/front/src/api/generated/model/resSupplierAccount.ts @@ -0,0 +1,15 @@ +/** + * 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 { ResSupplierAccountMsg } from './resSupplierAccountMsg'; +import type { ResSupplierAccountAccount } from './resSupplierAccountAccount'; + +export interface ResSupplierAccount { + result?: ErrorInfo; + msg?: ResSupplierAccountMsg; + account?: ResSupplierAccountAccount; +} diff --git a/negodata/front/src/api/generated/model/resSupplierAccountAccount.ts b/negodata/front/src/api/generated/model/resSupplierAccountAccount.ts new file mode 100644 index 0000000..03deb92 --- /dev/null +++ b/negodata/front/src/api/generated/model/resSupplierAccountAccount.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { SupplierAccountData } from './supplierAccountData'; + +export type ResSupplierAccountAccount = SupplierAccountData | null; diff --git a/negodata/front/src/api/generated/model/resSupplierAccountMsg.ts b/negodata/front/src/api/generated/model/resSupplierAccountMsg.ts new file mode 100644 index 0000000..b85651c --- /dev/null +++ b/negodata/front/src/api/generated/model/resSupplierAccountMsg.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 ResSupplierAccountMsg = string | null; diff --git a/negodata/front/src/api/generated/model/supplierAccountData.ts b/negodata/front/src/api/generated/model/supplierAccountData.ts new file mode 100644 index 0000000..453fc3d --- /dev/null +++ b/negodata/front/src/api/generated/model/supplierAccountData.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { SupplierAccountDataCreatedAt } from './supplierAccountDataCreatedAt'; + +export interface SupplierAccountData { + su_id: string; + login_id: string; + status: number; + created_at?: SupplierAccountDataCreatedAt; +} diff --git a/negodata/front/src/api/generated/model/supplierAccountDataCreatedAt.ts b/negodata/front/src/api/generated/model/supplierAccountDataCreatedAt.ts new file mode 100644 index 0000000..e5f00a6 --- /dev/null +++ b/negodata/front/src/api/generated/model/supplierAccountDataCreatedAt.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 SupplierAccountDataCreatedAt = string | null; diff --git a/negodata/front/src/api/generated/model/supplierData.ts b/negodata/front/src/api/generated/model/supplierData.ts index a28220c..7afc969 100644 --- a/negodata/front/src/api/generated/model/supplierData.ts +++ b/negodata/front/src/api/generated/model/supplierData.ts @@ -10,6 +10,8 @@ import type { SupplierDataManagerName } from './supplierDataManagerName'; import type { SupplierDataManagerEmail } from './supplierDataManagerEmail'; import type { SupplierDataManagerContactNumber } from './supplierDataManagerContactNumber'; import type { SupplierDataTotalRevenue } from './supplierDataTotalRevenue'; +import type { SupplierDataAccountLoginId } from './supplierDataAccountLoginId'; +import type { SupplierDataAccountStatus } from './supplierDataAccountStatus'; import type { SupplierDataCreatedAt } from './supplierDataCreatedAt'; import type { SupplierDataUpdatedAt } from './supplierDataUpdatedAt'; @@ -24,6 +26,8 @@ export interface SupplierData { manager_email?: SupplierDataManagerEmail; manager_contact_number?: SupplierDataManagerContactNumber; total_revenue?: SupplierDataTotalRevenue; + account_login_id?: SupplierDataAccountLoginId; + account_status?: SupplierDataAccountStatus; created_at?: SupplierDataCreatedAt; updated_at?: SupplierDataUpdatedAt; } diff --git a/negodata/front/src/api/generated/model/supplierDataAccountLoginId.ts b/negodata/front/src/api/generated/model/supplierDataAccountLoginId.ts new file mode 100644 index 0000000..a3cce95 --- /dev/null +++ b/negodata/front/src/api/generated/model/supplierDataAccountLoginId.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 SupplierDataAccountLoginId = string | null; diff --git a/negodata/front/src/api/generated/model/supplierDataAccountStatus.ts b/negodata/front/src/api/generated/model/supplierDataAccountStatus.ts new file mode 100644 index 0000000..61b6822 --- /dev/null +++ b/negodata/front/src/api/generated/model/supplierDataAccountStatus.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 SupplierDataAccountStatus = number | null; diff --git a/negodata/front/src/api/generated/model/supplierType.ts b/negodata/front/src/api/generated/model/supplierType.ts deleted file mode 100644 index fe44d31..0000000 --- a/negodata/front/src/api/generated/model/supplierType.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * Negodata Api Server - * OpenAPI spec version: 0.1.0 - */ - -/** - * partner.supplier_items.supply_type 코드값. 없음(0,미지정)/유통(1)/제조(2)/총판(3). -없음은 상품-협력사 매핑에서 유형 미지정 상태를 뜻한다. - */ -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, -} as const; diff --git a/negodata/front/src/api/generated/quotation/quotation.ts b/negodata/front/src/api/generated/quotation/quotation.ts index dc357b6..b2a028b 100644 --- a/negodata/front/src/api/generated/quotation/quotation.ts +++ b/negodata/front/src/api/generated/quotation/quotation.ts @@ -1135,9 +1135,7 @@ export const useDeleteQuotation = > + diff --git a/negodata/front/src/api/generated/supplier/supplier.ts b/negodata/front/src/api/generated/supplier/supplier.ts index b43dbe1..0b0edb3 100644 --- a/negodata/front/src/api/generated/supplier/supplier.ts +++ b/negodata/front/src/api/generated/supplier/supplier.ts @@ -28,10 +28,16 @@ import type { ListSuppliersParams, ReqCheckCodes, ReqCreateSupplier, + ReqCreateSupplierAccount, + ReqResetSupplierAccountPassword, ReqUpdateSupplier, + ReqUpdateSupplierAccountStatus, ResCheckCodes, + ResCreateSupplierAccount, ResDeleteSupplier, + ResResetSupplierAccountPassword, ResSupplier, + ResSupplierAccount, ResSupplierList } from '.././model'; @@ -420,7 +426,7 @@ export const useUpdateSupplier = (options?: { mutation?:UseMutationOptions>, TError,{supplierId: string}, TContext>, request?: SecondParameter} @@ -480,4 +486,290 @@ export const useDeleteSupplier = ,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/supplier/${supplierId}/account`, method: 'GET', signal + }, + options); + } + + + + +export const getGetSupplierAccountQueryKey = (supplierId?: string,) => { + return [ + `/v1/supplier/${supplierId}/account` + ] as const; + } + + +export const getGetSupplierAccountQueryOptions = >, TError = void | HTTPValidationError>(supplierId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetSupplierAccountQueryKey(supplierId); + + + + const queryFn: QueryFunction>> = ({ signal }) => getSupplierAccount(supplierId, requestOptions, signal); + + + + + + return { queryKey, queryFn, enabled: !!(supplierId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetSupplierAccountQueryResult = NonNullable>> +export type GetSupplierAccountQueryError = void | HTTPValidationError + + +export function useGetSupplierAccount>, TError = void | HTTPValidationError>( + supplierId: string, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetSupplierAccount>, TError = void | HTTPValidationError>( + supplierId: string, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetSupplierAccount>, TError = void | HTTPValidationError>( + supplierId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 채팅 계정 조회 (미발급이면 account=null) + */ + +export function useGetSupplierAccount>, TError = void | HTTPValidationError>( + supplierId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetSupplierAccountQueryOptions(supplierId,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + +/** + * @summary 채팅 계정 발급 (비밀번호 자동생성·1회 반환) + */ +export const createSupplierAccount = ( + supplierId: string, + reqCreateSupplierAccount: ReqCreateSupplierAccount, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/supplier/${supplierId}/account/create`, method: 'POST', + headers: {'Content-Type': 'application/json', }, + data: reqCreateSupplierAccount, signal + }, + options); + } + + + +export const getCreateSupplierAccountMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{supplierId: string;data: ReqCreateSupplierAccount}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{supplierId: string;data: ReqCreateSupplierAccount}, TContext> => { + +const mutationKey = ['createSupplierAccount']; +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>, {supplierId: string;data: ReqCreateSupplierAccount}> = (props) => { + const {supplierId,data} = props ?? {}; + + return createSupplierAccount(supplierId,data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type CreateSupplierAccountMutationResult = NonNullable>> + export type CreateSupplierAccountMutationBody = ReqCreateSupplierAccount + export type CreateSupplierAccountMutationError = void | HTTPValidationError + + /** + * @summary 채팅 계정 발급 (비밀번호 자동생성·1회 반환) + */ +export const useCreateSupplierAccount = (options?: { mutation?:UseMutationOptions>, TError,{supplierId: string;data: ReqCreateSupplierAccount}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {supplierId: string;data: ReqCreateSupplierAccount}, + TContext + > => { + + const mutationOptions = getCreateSupplierAccountMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** + * @summary 채팅 계정 비밀번호 재설정 (커스텀 지정 가능, 기존 로그인 즉시 무효화) + */ +export const resetSupplierAccountPassword = ( + supplierId: string, + reqResetSupplierAccountPassword: ReqResetSupplierAccountPassword, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/supplier/${supplierId}/account/reset-password`, method: 'POST', + headers: {'Content-Type': 'application/json', }, + data: reqResetSupplierAccountPassword, signal + }, + options); + } + + + +export const getResetSupplierAccountPasswordMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{supplierId: string;data: ReqResetSupplierAccountPassword}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{supplierId: string;data: ReqResetSupplierAccountPassword}, TContext> => { + +const mutationKey = ['resetSupplierAccountPassword']; +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>, {supplierId: string;data: ReqResetSupplierAccountPassword}> = (props) => { + const {supplierId,data} = props ?? {}; + + return resetSupplierAccountPassword(supplierId,data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type ResetSupplierAccountPasswordMutationResult = NonNullable>> + export type ResetSupplierAccountPasswordMutationBody = ReqResetSupplierAccountPassword + export type ResetSupplierAccountPasswordMutationError = void | HTTPValidationError + + /** + * @summary 채팅 계정 비밀번호 재설정 (커스텀 지정 가능, 기존 로그인 즉시 무효화) + */ +export const useResetSupplierAccountPassword = (options?: { mutation?:UseMutationOptions>, TError,{supplierId: string;data: ReqResetSupplierAccountPassword}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {supplierId: string;data: ReqResetSupplierAccountPassword}, + TContext + > => { + + const mutationOptions = getResetSupplierAccountPasswordMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** + * @summary 채팅 계정 활성/비활성 (비활성 시 기존 로그인 즉시 무효화) + */ +export const updateSupplierAccountStatus = ( + supplierId: string, + reqUpdateSupplierAccountStatus: ReqUpdateSupplierAccountStatus, + options?: SecondParameter,) => { + + + return customFetch( + {url: `/v1/supplier/${supplierId}/account/status`, method: 'PATCH', + headers: {'Content-Type': 'application/json', }, + data: reqUpdateSupplierAccountStatus + }, + options); + } + + + +export const getUpdateSupplierAccountStatusMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{supplierId: string;data: ReqUpdateSupplierAccountStatus}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{supplierId: string;data: ReqUpdateSupplierAccountStatus}, TContext> => { + +const mutationKey = ['updateSupplierAccountStatus']; +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>, {supplierId: string;data: ReqUpdateSupplierAccountStatus}> = (props) => { + const {supplierId,data} = props ?? {}; + + return updateSupplierAccountStatus(supplierId,data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type UpdateSupplierAccountStatusMutationResult = NonNullable>> + export type UpdateSupplierAccountStatusMutationBody = ReqUpdateSupplierAccountStatus + export type UpdateSupplierAccountStatusMutationError = void | HTTPValidationError + + /** + * @summary 채팅 계정 활성/비활성 (비활성 시 기존 로그인 즉시 무효화) + */ +export const useUpdateSupplierAccountStatus = (options?: { mutation?:UseMutationOptions>, TError,{supplierId: string;data: ReqUpdateSupplierAccountStatus}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {supplierId: string;data: ReqUpdateSupplierAccountStatus}, + TContext + > => { + + const mutationOptions = getUpdateSupplierAccountStatusMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } \ No newline at end of file diff --git a/negodata/front/src/features/partners/components/PartnerFormSheet.tsx b/negodata/front/src/features/partners/components/PartnerFormSheet.tsx index 54c2e2b..b3c3d9c 100644 --- a/negodata/front/src/features/partners/components/PartnerFormSheet.tsx +++ b/negodata/front/src/features/partners/components/PartnerFormSheet.tsx @@ -13,6 +13,7 @@ import { PhoneInput } from '@/components/ui/phone-input'; import { Sheet } from '@/components/ui/sheet'; import { useAuthStore } from '@/stores/auth'; import { SupplierItemsManager } from './SupplierItemsManager'; +import { SupplierAccountManager } from './SupplierAccountManager'; import { type Partner } from '../types'; const schema = z.object({ @@ -215,6 +216,9 @@ export function PartnerFormSheet({ {/* 취급상품 관리 — 수정 모드(협력사 확정)에서만. 추가/삭제/유형변경은 즉시 서버 반영. */} {mode === 'edit' && partner && } + {/* 채팅 계정 관리 — 수정 모드에서만. 발급/재설정/활성상태는 즉시 서버 반영. */} + {mode === 'edit' && partner && } + {/* Buttons wrapper */}
{mode === 'edit' && partner && ( diff --git a/negodata/front/src/features/partners/components/PartnerTable.tsx b/negodata/front/src/features/partners/components/PartnerTable.tsx index 57a7b59..1b77af8 100644 --- a/negodata/front/src/features/partners/components/PartnerTable.tsx +++ b/negodata/front/src/features/partners/components/PartnerTable.tsx @@ -71,6 +71,20 @@ export function PartnerTable({ cellClassName: 'font-mono text-muted-foreground', cell: (part) => (part.total_revenue != null ? `₩${Number(part.total_revenue).toLocaleString()}` : '-'), }, + { + header: '채팅 계정', + align: 'center', + cellClassName: 'font-mono whitespace-nowrap', + cell: (part) => + part.account_login_id ? ( + + {part.account_login_id} + {part.account_status === 2 && 비활성} + + ) : ( + 미발급 + ), + }, { header: '작성자', align: 'center', diff --git a/negodata/front/src/features/partners/components/SupplierAccountManager.tsx b/negodata/front/src/features/partners/components/SupplierAccountManager.tsx new file mode 100644 index 0000000..44f7617 --- /dev/null +++ b/negodata/front/src/features/partners/components/SupplierAccountManager.tsx @@ -0,0 +1,189 @@ +import { useState } from 'react'; +import { Copy, KeyRound } from 'lucide-react'; +import { Typography } from '@/components/ui/typography'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { showToast } from '@/lib/notify'; +import { confirm } from '@/lib/confirm'; +import { useSupplierAccount } from '../hooks/useSupplierAccount'; + +// 협력사 상세의 채팅 계정 관리 섹션 — 발급/비번 재설정/활성상태, 각 조작 즉시 서버 반영. +// 비밀번호는 발급·재설정 직후 이 화면에서만 1회 표시된다(해시 저장이라 재조회 불가). +export function SupplierAccountManager({ supplierId }: { supplierId: string }) { + const { account, isLoading, create, resetPassword, setStatus } = useSupplierAccount(supplierId); + const [loginId, setLoginId] = useState(''); + const [issuedPassword, setIssuedPassword] = useState(null); // 방금 발급/재설정된 평문(1회 표시) + const [resetPw, setResetPw] = useState(null); // 재설정 입력행 값(null=닫힘), 자동생성 프리필 + const [busy, setBusy] = useState(false); + + const run = async (fn: () => Promise) => { + setBusy(true); + try { + await fn(); + } catch (err) { + showToast(err instanceof Error ? err.message : '채팅 계정 처리 실패', 'error'); + } finally { + setBusy(false); + } + }; + + const handleCreate = () => + run(async () => { + const pw = await create(loginId.trim()); + setIssuedPassword(pw); + showToast('채팅 계정이 발급되었습니다.', 'success'); + }); + + const handleApplyReset = async () => { + const pw = (resetPw ?? '').trim(); + if (pw.length < 4) { + showToast('비밀번호는 4자 이상으로 입력하세요.', 'error'); + return; + } + await run(async () => { + const applied = await resetPassword(pw); + setIssuedPassword(applied); + setResetPw(null); + showToast('비밀번호가 재설정되었습니다. 새 비밀번호를 협력사에 전달해 주세요.', 'success'); + }); + }; + + const handleToggleStatus = async () => { + const deactivating = account?.status === 1; + if ( + deactivating && + !(await confirm({ + title: '채팅 계정 비활성화', + description: '협력사가 협상 채팅에 로그인할 수 없게 되고, 이미 로그인된 세션도 즉시 끊깁니다. 계속하시겠습니까?', + confirmText: '비활성화', + destructive: true, + })) + ) + return; + await run(async () => { + await setStatus(deactivating ? 2 : 1); + setIssuedPassword(null); + showToast(deactivating ? '채팅 계정이 비활성화되었습니다.' : '채팅 계정이 활성화되었습니다.', 'info'); + }); + }; + + return ( +
+ 채팅 계정 (협상 참여 로그인) + + {isLoading ? ( + 불러오는 중… + ) : account == null ? ( + // 미발급 — 로그인 ID 직접 입력 후 발급(비밀번호는 서버 자동생성). +
+ + 발급된 채팅 계정이 없습니다. 로그인 ID를 정해 발급하세요. + +
+ setLoginId(e.target.value)} + maxLength={20} + className="text-foreground text-xs flex-1" + placeholder="로그인 ID (영문·숫자, 20자 이내)" + /> + +
+
+ ) : ( + // 발급됨 — ID·상태 표시 + 비번 재설정/활성상태 토글. +
+
+
+ {account.login_id} + + ● {account.status === 1 ? '활성' : '비활성'} + +
+
+ + +
+
+ + {resetPw != null && ( +
+
+ setResetPw(e.target.value)} + maxLength={72} + className="text-foreground text-xs font-mono flex-1" + placeholder="새 비밀번호 (4자 이상)" + /> + + +
+ + ⚠ 적용 시 협력사의 기존 채팅 로그인이 즉시 끊깁니다. + +
+ )} + + {issuedPassword && ( +
+
+ {issuedPassword} + +
+ + ⚠ 비밀번호는 지금만 표시됩니다. 협력사에 전달해 주세요. (분실 시 재설정) + +
+ )} +
+ )} +
+ ); +} + +// 재설정 프리필용 임시 비밀번호(XXXX-XXXX) — 서버 자동생성과 같은 형식, 혼동 문자(0O1lI) 제외. +const PW_ALPHABET = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789'; +function generatePassword(): string { + const block = () => + Array.from(crypto.getRandomValues(new Uint32Array(4)), (n) => PW_ALPHABET[n % PW_ALPHABET.length]).join(''); + return `${block()}-${block()}`; +} diff --git a/negodata/front/src/features/partners/components/SupplierItemsManager.tsx b/negodata/front/src/features/partners/components/SupplierItemsManager.tsx index 671b2ea..b479c10 100644 --- a/negodata/front/src/features/partners/components/SupplierItemsManager.tsx +++ b/negodata/front/src/features/partners/components/SupplierItemsManager.tsx @@ -1,12 +1,11 @@ import { useState } from 'react'; import { Plus, Trash2 } from 'lucide-react'; import { useListItems } from '@/api/generated/item/item'; -import { SupplierType } from '@/api/generated/model'; import { Typography } from '@/components/ui/typography'; import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Combobox, type ComboOption } from '@/components/ui/combobox'; -import { SUPPLIER_TYPE_OPTIONS, supplierTypeLabel } from '@/lib/enumLabels'; +import { SUPPLIER_TYPE_OPTIONS, SupplierType, supplierTypeLabel } from '@/lib/enumLabels'; import { showToast } from '@/lib/notify'; import { useSupplierItems } from '../hooks/useSupplierItems'; diff --git a/negodata/front/src/features/partners/hooks/useSupplierAccount.ts b/negodata/front/src/features/partners/hooks/useSupplierAccount.ts new file mode 100644 index 0000000..1aed68c --- /dev/null +++ b/negodata/front/src/features/partners/hooks/useSupplierAccount.ts @@ -0,0 +1,66 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { + useGetSupplierAccount, + createSupplierAccount, + resetSupplierAccountPassword, + updateSupplierAccountStatus, + getGetSupplierAccountQueryKey, +} from '@/api/generated/supplier/supplier'; +import type { SupplierAccountData } from '@/api/generated/model/supplierAccountData'; +import type { ErrorInfo } from '@/api/generated/model/errorInfo'; + +// 서버 공통응답(result.success=false)을 한글 사유로 변환. 정상이면 null. +function supplierAccountError(result: ErrorInfo | undefined): string | null { + if (!result || result.success !== false) return null; + if (result.desc === 'SUPPLIER_ACCOUNT_LOGIN_ID_DUPLICATE') return '이미 사용 중인 로그인 ID입니다(다른 협력사 포함 전역 중복).'; + if (result.desc === 'SUPPLIER_ACCOUNT_ALREADY_EXISTS') return '이미 발급된 채팅 계정이 있습니다.'; + if (result.desc === 'SUPPLIER_ACCOUNT_NOT_FOUND') return '발급된 채팅 계정이 없습니다.'; + return result.desc || '채팅 계정 처리에 실패했습니다.'; +} + +// 협력사 채팅(협상) 계정 서버 데이터 + 발급/비번재설정/활성상태. 협력사 상세(PartnerFormSheet)에서 쓴다. +// 발급/재설정 응답의 평문 비밀번호는 그 자리에서 1회만 반환된다(재조회 불가). +export function useSupplierAccount(supplierId: string | undefined) { + const queryClient = useQueryClient(); + const query = useGetSupplierAccount(supplierId ?? '', { query: { enabled: !!supplierId } }); + + // 계정 상태는 목록의 '채팅 계정' 컬럼에도 실리므로 목록 쿼리까지 함께 재조회. + const refresh = () => + Promise.all([ + supplierId + ? queryClient.invalidateQueries({ queryKey: getGetSupplierAccountQueryKey(supplierId) }) + : Promise.resolve(), + queryClient.invalidateQueries({ queryKey: ['/v1/supplier/list'] }), + ]); + + const account: SupplierAccountData | null = (query.data?.account as SupplierAccountData | undefined) ?? null; + + const create = async (loginId: string): Promise => { + if (!supplierId) throw new Error('협력사가 지정되지 않았습니다.'); + const res = await createSupplierAccount(supplierId, { login_id: loginId }); + const msg = supplierAccountError(res.result); + if (msg) throw new Error(msg); + await refresh(); + return res.initial_password ?? ''; + }; + + // password 지정 시 그 값으로, 미지정 시 서버 자동생성. + const resetPassword = async (password?: string): Promise => { + if (!supplierId) throw new Error('협력사가 지정되지 않았습니다.'); + const res = await resetSupplierAccountPassword(supplierId, { password }); + const msg = supplierAccountError(res.result); + if (msg) throw new Error(msg); + await refresh(); + return res.new_password ?? ''; + }; + + const setStatus = async (status: number) => { + if (!supplierId) throw new Error('협력사가 지정되지 않았습니다.'); + const res = await updateSupplierAccountStatus(supplierId, { status }); + const msg = supplierAccountError(res.result); + if (msg) throw new Error(msg); + await refresh(); + }; + + return { account, isLoading: query.isLoading, create, resetPassword, setStatus }; +} diff --git a/negodata/front/src/lib/enumLabels.ts b/negodata/front/src/lib/enumLabels.ts index 7ae00b7..9386afc 100644 --- a/negodata/front/src/lib/enumLabels.ts +++ b/negodata/front/src/lib/enumLabels.ts @@ -1,4 +1,14 @@ -import { DeliveryType, UserRole, SupplierType, CardUsageType, UserStatus } from '@/api/generated/model'; +import { DeliveryType, UserRole, CardUsageType, UserStatus } from '@/api/generated/model'; + +// partner.supplier_items.supply_type 코드값(백엔드 common.enums.SupplierType 미러링). +// 백엔드 protocol 이 enum 대신 int 를 쓰게 되면서 openapi(→orval 생성물)에서 빠져 로컬로 정의한다. +export const SupplierType = { + NONE: 0, + DISTRIBUTION: 1, + MANUFACTURE: 2, + SOLE_AGENCY: 3, +} as const; +export type SupplierType = (typeof SupplierType)[keyof typeof SupplierType]; export const DELIVERY_TYPE_LABEL: Record = { [DeliveryType.SUPPLIER]: '협력사배송',