[feat] negodata: 협력사 채팅 계정(supplier_users) 관리 — 발급·비번 재설정·활성상태
- 백엔드: supplier_users/supplier_user_tokens ORM 매핑, /v1/supplier/{id}/account 4종(조회·발급·재설정·상태), 회사 스코프 게이팅, 재설정·비활성 시 토큰 삭제로 기존 로그인 즉시 무효화
- 비밀번호: 발급=서버 자동생성(1회 반환), 재설정=커스텀 지정 가능(미지정 시 자동생성)
- 프론트: 협력사 상세 SupplierAccountManager 섹션 + 목록 '채팅 계정' 컬럼, orval 재생성(SupplierType 은 enumLabels 로컬 상수로 이동)
- 테스트: test_supplier_account.py 9건, 전체 스위트 green
This commit is contained in:
parent
a80dac20ed
commit
7f1c0be2b3
@ -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에서 보장.
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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:
|
||||
|
||||
122
negodata/backend/crud/supplier_user_crud.py
Normal file
122
negodata/backend/crud/supplier_user_crud.py
Normal file
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
165
negodata/backend/tests/test_supplier_account.py
Normal file
165
negodata/backend/tests/test_supplier_account.py
Normal file
@ -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
|
||||
@ -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';
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
15
negodata/front/src/api/generated/model/resSupplierAccount.ts
Normal file
15
negodata/front/src/api/generated/model/resSupplierAccount.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -1135,9 +1135,7 @@ export const useDeleteQuotation = <TError = void | HTTPValidationError,
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
/**
|
||||
* @summary 견적 조회
|
||||
*/
|
||||
export const getQuotation = (
|
||||
@ -1228,3 +1226,4 @@ export function useGetQuotation<TData = Awaited<ReturnType<typeof getQuotation>>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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 = <TError = void | HTTPValidationError,
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
/**
|
||||
* @summary 협력사 삭제
|
||||
* @summary 협력사 삭제 (최고관리자 전용)
|
||||
*/
|
||||
export const deleteSupplier = (
|
||||
supplierId: string,
|
||||
@ -465,7 +471,7 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||
export type DeleteSupplierMutationError = void | HTTPValidationError
|
||||
|
||||
/**
|
||||
* @summary 협력사 삭제
|
||||
* @summary 협력사 삭제 (최고관리자 전용)
|
||||
*/
|
||||
export const useDeleteSupplier = <TError = void | HTTPValidationError,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteSupplier>>, TError,{supplierId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
@ -480,4 +486,290 @@ export const useDeleteSupplier = <TError = void | HTTPValidationError,
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
/**
|
||||
* @summary 채팅 계정 조회 (미발급이면 account=null)
|
||||
*/
|
||||
export const getSupplierAccount = (
|
||||
supplierId: string,
|
||||
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customFetch<ResSupplierAccount>(
|
||||
{url: `/v1/supplier/${supplierId}/account`, method: 'GET', signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export const getGetSupplierAccountQueryKey = (supplierId?: string,) => {
|
||||
return [
|
||||
`/v1/supplier/${supplierId}/account`
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
export const getGetSupplierAccountQueryOptions = <TData = Awaited<ReturnType<typeof getSupplierAccount>>, TError = void | HTTPValidationError>(supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupplierAccount>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
) => {
|
||||
|
||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetSupplierAccountQueryKey(supplierId);
|
||||
|
||||
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getSupplierAccount>>> = ({ signal }) => getSupplierAccount(supplierId, requestOptions, signal);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, enabled: !!(supplierId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getSupplierAccount>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
}
|
||||
|
||||
export type GetSupplierAccountQueryResult = NonNullable<Awaited<ReturnType<typeof getSupplierAccount>>>
|
||||
export type GetSupplierAccountQueryError = void | HTTPValidationError
|
||||
|
||||
|
||||
export function useGetSupplierAccount<TData = Awaited<ReturnType<typeof getSupplierAccount>>, TError = void | HTTPValidationError>(
|
||||
supplierId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupplierAccount>>, TError, TData>> & Pick<
|
||||
DefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getSupplierAccount>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getSupplierAccount>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useGetSupplierAccount<TData = Awaited<ReturnType<typeof getSupplierAccount>>, TError = void | HTTPValidationError>(
|
||||
supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupplierAccount>>, TError, TData>> & Pick<
|
||||
UndefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getSupplierAccount>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getSupplierAccount>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useGetSupplierAccount<TData = Awaited<ReturnType<typeof getSupplierAccount>>, TError = void | HTTPValidationError>(
|
||||
supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupplierAccount>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
/**
|
||||
* @summary 채팅 계정 조회 (미발급이면 account=null)
|
||||
*/
|
||||
|
||||
export function useGetSupplierAccount<TData = Awaited<ReturnType<typeof getSupplierAccount>>, TError = void | HTTPValidationError>(
|
||||
supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupplierAccount>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||
|
||||
const queryOptions = getGetSupplierAccountQueryOptions(supplierId,options)
|
||||
|
||||
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
|
||||
query.queryKey = queryOptions.queryKey ;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @summary 채팅 계정 발급 (비밀번호 자동생성·1회 반환)
|
||||
*/
|
||||
export const createSupplierAccount = (
|
||||
supplierId: string,
|
||||
reqCreateSupplierAccount: ReqCreateSupplierAccount,
|
||||
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customFetch<ResCreateSupplierAccount>(
|
||||
{url: `/v1/supplier/${supplierId}/account/create`, method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', },
|
||||
data: reqCreateSupplierAccount, signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const getCreateSupplierAccountMutationOptions = <TError = void | HTTPValidationError,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createSupplierAccount>>, TError,{supplierId: string;data: ReqCreateSupplierAccount}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof createSupplierAccount>>, 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<Awaited<ReturnType<typeof createSupplierAccount>>, {supplierId: string;data: ReqCreateSupplierAccount}> = (props) => {
|
||||
const {supplierId,data} = props ?? {};
|
||||
|
||||
return createSupplierAccount(supplierId,data,requestOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type CreateSupplierAccountMutationResult = NonNullable<Awaited<ReturnType<typeof createSupplierAccount>>>
|
||||
export type CreateSupplierAccountMutationBody = ReqCreateSupplierAccount
|
||||
export type CreateSupplierAccountMutationError = void | HTTPValidationError
|
||||
|
||||
/**
|
||||
* @summary 채팅 계정 발급 (비밀번호 자동생성·1회 반환)
|
||||
*/
|
||||
export const useCreateSupplierAccount = <TError = void | HTTPValidationError,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createSupplierAccount>>, TError,{supplierId: string;data: ReqCreateSupplierAccount}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createSupplierAccount>>,
|
||||
TError,
|
||||
{supplierId: string;data: ReqCreateSupplierAccount},
|
||||
TContext
|
||||
> => {
|
||||
|
||||
const mutationOptions = getCreateSupplierAccountMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
/**
|
||||
* @summary 채팅 계정 비밀번호 재설정 (커스텀 지정 가능, 기존 로그인 즉시 무효화)
|
||||
*/
|
||||
export const resetSupplierAccountPassword = (
|
||||
supplierId: string,
|
||||
reqResetSupplierAccountPassword: ReqResetSupplierAccountPassword,
|
||||
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customFetch<ResResetSupplierAccountPassword>(
|
||||
{url: `/v1/supplier/${supplierId}/account/reset-password`, method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', },
|
||||
data: reqResetSupplierAccountPassword, signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const getResetSupplierAccountPasswordMutationOptions = <TError = void | HTTPValidationError,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof resetSupplierAccountPassword>>, TError,{supplierId: string;data: ReqResetSupplierAccountPassword}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof resetSupplierAccountPassword>>, 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<Awaited<ReturnType<typeof resetSupplierAccountPassword>>, {supplierId: string;data: ReqResetSupplierAccountPassword}> = (props) => {
|
||||
const {supplierId,data} = props ?? {};
|
||||
|
||||
return resetSupplierAccountPassword(supplierId,data,requestOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type ResetSupplierAccountPasswordMutationResult = NonNullable<Awaited<ReturnType<typeof resetSupplierAccountPassword>>>
|
||||
export type ResetSupplierAccountPasswordMutationBody = ReqResetSupplierAccountPassword
|
||||
export type ResetSupplierAccountPasswordMutationError = void | HTTPValidationError
|
||||
|
||||
/**
|
||||
* @summary 채팅 계정 비밀번호 재설정 (커스텀 지정 가능, 기존 로그인 즉시 무효화)
|
||||
*/
|
||||
export const useResetSupplierAccountPassword = <TError = void | HTTPValidationError,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof resetSupplierAccountPassword>>, TError,{supplierId: string;data: ReqResetSupplierAccountPassword}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient): UseMutationResult<
|
||||
Awaited<ReturnType<typeof resetSupplierAccountPassword>>,
|
||||
TError,
|
||||
{supplierId: string;data: ReqResetSupplierAccountPassword},
|
||||
TContext
|
||||
> => {
|
||||
|
||||
const mutationOptions = getResetSupplierAccountPasswordMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
/**
|
||||
* @summary 채팅 계정 활성/비활성 (비활성 시 기존 로그인 즉시 무효화)
|
||||
*/
|
||||
export const updateSupplierAccountStatus = (
|
||||
supplierId: string,
|
||||
reqUpdateSupplierAccountStatus: ReqUpdateSupplierAccountStatus,
|
||||
options?: SecondParameter<typeof customFetch>,) => {
|
||||
|
||||
|
||||
return customFetch<ResSupplierAccount>(
|
||||
{url: `/v1/supplier/${supplierId}/account/status`, method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json', },
|
||||
data: reqUpdateSupplierAccountStatus
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const getUpdateSupplierAccountStatusMutationOptions = <TError = void | HTTPValidationError,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateSupplierAccountStatus>>, TError,{supplierId: string;data: ReqUpdateSupplierAccountStatus}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof updateSupplierAccountStatus>>, 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<Awaited<ReturnType<typeof updateSupplierAccountStatus>>, {supplierId: string;data: ReqUpdateSupplierAccountStatus}> = (props) => {
|
||||
const {supplierId,data} = props ?? {};
|
||||
|
||||
return updateSupplierAccountStatus(supplierId,data,requestOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type UpdateSupplierAccountStatusMutationResult = NonNullable<Awaited<ReturnType<typeof updateSupplierAccountStatus>>>
|
||||
export type UpdateSupplierAccountStatusMutationBody = ReqUpdateSupplierAccountStatus
|
||||
export type UpdateSupplierAccountStatusMutationError = void | HTTPValidationError
|
||||
|
||||
/**
|
||||
* @summary 채팅 계정 활성/비활성 (비활성 시 기존 로그인 즉시 무효화)
|
||||
*/
|
||||
export const useUpdateSupplierAccountStatus = <TError = void | HTTPValidationError,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateSupplierAccountStatus>>, TError,{supplierId: string;data: ReqUpdateSupplierAccountStatus}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateSupplierAccountStatus>>,
|
||||
TError,
|
||||
{supplierId: string;data: ReqUpdateSupplierAccountStatus},
|
||||
TContext
|
||||
> => {
|
||||
|
||||
const mutationOptions = getUpdateSupplierAccountStatusMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
|
||||
@ -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 && <SupplierItemsManager supplierId={partner.supplier_id} />}
|
||||
|
||||
{/* 채팅 계정 관리 — 수정 모드에서만. 발급/재설정/활성상태는 즉시 서버 반영. */}
|
||||
{mode === 'edit' && partner && <SupplierAccountManager supplierId={partner.supplier_id} />}
|
||||
|
||||
{/* Buttons wrapper */}
|
||||
<div className="pt-4 flex items-center gap-2 border-t border-border mt-8 justify-between">
|
||||
{mode === 'edit' && partner && (
|
||||
|
||||
@ -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 ? (
|
||||
<span className="text-xs text-foreground">
|
||||
{part.account_login_id}
|
||||
{part.account_status === 2 && <span className="ml-1 text-[10px] text-rose-500">비활성</span>}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground">미발급</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '작성자',
|
||||
align: 'center',
|
||||
|
||||
@ -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<string | null>(null); // 방금 발급/재설정된 평문(1회 표시)
|
||||
const [resetPw, setResetPw] = useState<string | null>(null); // 재설정 입력행 값(null=닫힘), 자동생성 프리필
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const run = async (fn: () => Promise<void>) => {
|
||||
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 (
|
||||
<div className="pt-4 border-t border-border space-y-2">
|
||||
<Typography as="label" variant="label">채팅 계정 (협상 참여 로그인)</Typography>
|
||||
|
||||
{isLoading ? (
|
||||
<Typography as="p" variant="small" className="p-3 text-muted-foreground text-[11px]">불러오는 중…</Typography>
|
||||
) : account == null ? (
|
||||
// 미발급 — 로그인 ID 직접 입력 후 발급(비밀번호는 서버 자동생성).
|
||||
<div className="space-y-1">
|
||||
<Typography as="p" variant="small" className="text-muted-foreground text-[11px]">
|
||||
발급된 채팅 계정이 없습니다. 로그인 ID를 정해 발급하세요.
|
||||
</Typography>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="chat-account-login-id"
|
||||
type="text"
|
||||
value={loginId}
|
||||
onChange={(e) => setLoginId(e.target.value)}
|
||||
maxLength={20}
|
||||
className="text-foreground text-xs flex-1"
|
||||
placeholder="로그인 ID (영문·숫자, 20자 이내)"
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={handleCreate} disabled={busy || !loginId.trim()}>
|
||||
<KeyRound />
|
||||
계정 발급
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 발급됨 — ID·상태 표시 + 비번 재설정/활성상태 토글.
|
||||
<div className="border border-border rounded p-2 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Typography as="span" variant="small" className="font-mono font-semibold truncate">{account.login_id}</Typography>
|
||||
<Typography
|
||||
as="span"
|
||||
variant="small"
|
||||
className={`text-[10px] whitespace-nowrap ${account.status === 1 ? 'text-emerald-600' : 'text-rose-500'}`}
|
||||
>
|
||||
● {account.status === 1 ? '활성' : '비활성'}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIssuedPassword(null);
|
||||
setResetPw(generatePassword());
|
||||
}}
|
||||
disabled={busy || resetPw != null}
|
||||
>
|
||||
비밀번호 재설정
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleToggleStatus} disabled={busy}>
|
||||
{account.status === 1 ? '비활성화' : '활성화'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resetPw != null && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="chat-account-reset-pw"
|
||||
type="text"
|
||||
value={resetPw}
|
||||
onChange={(e) => setResetPw(e.target.value)}
|
||||
maxLength={72}
|
||||
className="text-foreground text-xs font-mono flex-1"
|
||||
placeholder="새 비밀번호 (4자 이상)"
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={handleApplyReset} disabled={busy}>
|
||||
적용
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setResetPw(null)} disabled={busy}>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">
|
||||
⚠ 적용 시 협력사의 기존 채팅 로그인이 즉시 끊깁니다.
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{issuedPassword && (
|
||||
<div className="rounded bg-amber-500/10 border border-amber-500/30 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Typography as="span" variant="small" className="font-mono font-semibold">{issuedPassword}</Typography>
|
||||
<button
|
||||
type="button"
|
||||
title="비밀번호 복사"
|
||||
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-accent cursor-pointer"
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(issuedPassword);
|
||||
showToast('비밀번호가 복사되었습니다.', 'info');
|
||||
}}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<Typography as="p" variant="small" className="text-[10px] text-amber-700 dark:text-amber-500">
|
||||
⚠ 비밀번호는 지금만 표시됩니다. 협력사에 전달해 주세요. (분실 시 재설정)
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 재설정 프리필용 임시 비밀번호(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()}`;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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<string> => {
|
||||
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<string> => {
|
||||
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 };
|
||||
}
|
||||
@ -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, string> = {
|
||||
[DeliveryType.SUPPLIER]: '협력사배송',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user