[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.dialects.postgresql import UUID, JSONB
|
||||||
from sqlalchemy.sql import text
|
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 체크에도 사용된다.
|
# 모든 ORM 모델의 베이스. insert 시 isinstance 체크에도 사용된다.
|
||||||
MAIN_BASE = declarative_base()
|
MAIN_BASE = declarative_base()
|
||||||
@ -127,6 +127,36 @@ class suppliers(MainTableMixin, MAIN_BASE):
|
|||||||
total_revenue = Column(BigInteger, nullable=True) # 총매출액
|
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):
|
class supplier_items(MainTableMixin, MAIN_BASE):
|
||||||
__tablename__ = "supplier_items"
|
__tablename__ = "supplier_items"
|
||||||
# (supplier_id, item_id) 유일성은 soft-delete 인지 부분 유니크 인덱스(uq_supplier_items, WHERE deleted=FALSE)로 DB에서 보장.
|
# (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_NOT_FOUND = 1400
|
||||||
SUPPLIER_CODE_DUPLICATE = auto()
|
SUPPLIER_CODE_DUPLICATE = auto()
|
||||||
SUPPLIER_ITEM_NOT_FOUND = 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
|
QUOTATION_NOT_FOUND = 1500
|
||||||
@ -129,6 +132,21 @@ class CompanyStatus(CodeEnum):
|
|||||||
INACTIVE = 2
|
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):
|
class QuotationType(CodeEnum):
|
||||||
"""quotations.type 코드값. 신규/재 × 협상(1:1)/견적(1:N).
|
"""quotations.type 코드값. 신규/재 × 협상(1:1)/견적(1:N).
|
||||||
1=재협상(1:1), 2=재견적(1:N), 3=신규협상(1:1), 4=신규견적(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 전에 직접 만든다.
|
# 모델이 쓰는 스키마. test DB 는 비어 있을 수 있어 create_all 전에 직접 만든다.
|
||||||
# 또 TRUNCATE 가 unqualified 테이블명을 쓰므로 이 스키마들을 search_path 에 얹어 해석시킨다.
|
# 또 TRUNCATE 가 unqualified 테이블명을 쓰므로 이 스키마들을 search_path 에 얹어 해석시킨다.
|
||||||
_SCHEMAS = ("company", "quotation", "card", "negotiation", "partner")
|
_SCHEMAS = ("company", "quotation", "card", "negotiation", "partner", "supplier")
|
||||||
|
|
||||||
|
|
||||||
def _write_url(cfg) -> str:
|
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_email: Optional[str] = None
|
||||||
manager_contact_number: Optional[str] = None
|
manager_contact_number: Optional[str] = None
|
||||||
total_revenue: Optional[int] = 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
|
created_at: Optional[datetime] = None
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
@ -64,3 +66,35 @@ class Res_CheckCodes(Res_WebPacketProtocol):
|
|||||||
|
|
||||||
class Res_DeleteSupplier(Res_WebPacketProtocol):
|
class Res_DeleteSupplier(Res_WebPacketProtocol):
|
||||||
pass
|
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 services.supplier_service import SupplierService
|
||||||
from .protocol import (
|
from .protocol import (
|
||||||
Req_CheckCodes,
|
Req_CheckCodes,
|
||||||
|
Req_CreateSupplierAccount,
|
||||||
Req_CreateSupplier,
|
Req_CreateSupplier,
|
||||||
|
Req_ResetSupplierAccountPassword,
|
||||||
|
Req_UpdateSupplierAccountStatus,
|
||||||
Req_UpdateSupplier,
|
Req_UpdateSupplier,
|
||||||
|
Res_SupplierAccount,
|
||||||
Res_CheckCodes,
|
Res_CheckCodes,
|
||||||
|
Res_CreateSupplierAccount,
|
||||||
Res_DeleteSupplier,
|
Res_DeleteSupplier,
|
||||||
|
Res_ResetSupplierAccountPassword,
|
||||||
Res_Supplier,
|
Res_Supplier,
|
||||||
Res_SupplierList,
|
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)):
|
async def delete_supplier(supplier_id: UUID, service: SupplierService = Depends(), user_info: UserInfo = Depends(RequireOwner)):
|
||||||
# 협력사 명부는 회사 공유 자원 — 파괴적 삭제는 최고관리자만(RequireOwner 가 비-OWNER 를 403 차단).
|
# 협력사 명부는 회사 공유 자원 — 파괴적 삭제는 최고관리자만(RequireOwner 가 비-OWNER 를 403 차단).
|
||||||
return RemoveNoneResponse(await service.delete_supplier(user_info.company_id, str(supplier_id)))
|
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
|
import uuid
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import suppliers
|
from common.database.model.models import suppliers, supplier_users
|
||||||
from common.enums import DBWRType, ErrorType
|
from common.enums import DBWRType, ErrorType, SupplierUserStatus
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from common.models.gmodel import PageParams
|
from common.models.gmodel import PageParams
|
||||||
from crud.supplier_crud import ISupplierCRUD, SupplierCRUD
|
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 (
|
from router.v1.supplier.protocol import (
|
||||||
|
SupplierAccountData,
|
||||||
|
Req_CreateSupplierAccount,
|
||||||
Req_CreateSupplier,
|
Req_CreateSupplier,
|
||||||
|
Req_ResetSupplierAccountPassword,
|
||||||
|
Req_UpdateSupplierAccountStatus,
|
||||||
Req_UpdateSupplier,
|
Req_UpdateSupplier,
|
||||||
|
Res_SupplierAccount,
|
||||||
Res_CheckCodes,
|
Res_CheckCodes,
|
||||||
|
Res_CreateSupplierAccount,
|
||||||
Res_DeleteSupplier,
|
Res_DeleteSupplier,
|
||||||
|
Res_ResetSupplierAccountPassword,
|
||||||
Res_Supplier,
|
Res_Supplier,
|
||||||
Res_SupplierList,
|
Res_SupplierList,
|
||||||
SupplierData,
|
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:
|
class SupplierService:
|
||||||
"""협력사 비즈니스 로직. company_id 로 소유권을 확인한다(멀티테넌트)."""
|
"""협력사 비즈니스 로직. 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_crud = supplier_crud
|
||||||
|
self.supplier_user_crud = supplier_user_crud
|
||||||
|
|
||||||
async def _fetch_owned(self, company_uuid: uuid.UUID, supplier_id: uuid.UUID):
|
async def _fetch_owned(self, company_uuid: uuid.UUID, supplier_id: uuid.UUID):
|
||||||
"""supplier 조회 + 소유권 확인. (ErrorType, supplier|None) 반환."""
|
"""supplier 조회 + 소유권 확인. (ErrorType, supplier|None) 반환."""
|
||||||
@ -61,6 +92,18 @@ class SupplierService:
|
|||||||
if nm_err == ErrorType.SUCCESS:
|
if nm_err == ErrorType.SUCCESS:
|
||||||
for d in res.suppliers:
|
for d in res.suppliers:
|
||||||
d.creator_name = name_map.get(d.user_id)
|
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
|
res.total = total
|
||||||
return res
|
return res
|
||||||
|
|
||||||
@ -78,6 +121,10 @@ class SupplierService:
|
|||||||
)
|
)
|
||||||
if nm_err == ErrorType.SUCCESS:
|
if nm_err == ErrorType.SUCCESS:
|
||||||
res.supplier.creator_name = name_map.get(supplier.user_id)
|
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
|
return res
|
||||||
|
|
||||||
async def check_codes(self, company_id: str, codes: list) -> Res_CheckCodes:
|
async def check_codes(self, company_id: str, codes: list) -> Res_CheckCodes:
|
||||||
@ -174,3 +221,146 @@ class SupplierService:
|
|||||||
if err_type != ErrorType.SUCCESS:
|
if err_type != ErrorType.SUCCESS:
|
||||||
res.result.SetResult(err_type)
|
res.result.SetResult(err_type)
|
||||||
return res
|
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 './reqCreateQuotationStartTime';
|
||||||
export * from './reqCreateQuotationVersionId';
|
export * from './reqCreateQuotationVersionId';
|
||||||
export * from './reqCreateSupplier';
|
export * from './reqCreateSupplier';
|
||||||
|
export * from './reqCreateSupplierAccount';
|
||||||
export * from './reqCreateSupplierCode';
|
export * from './reqCreateSupplierCode';
|
||||||
export * from './reqCreateSupplierItem';
|
export * from './reqCreateSupplierItem';
|
||||||
export * from './reqCreateSupplierManagerContactNumber';
|
export * from './reqCreateSupplierManagerContactNumber';
|
||||||
@ -170,6 +171,8 @@ export * from './reqCreateSupplierManagerName';
|
|||||||
export * from './reqCreateSupplierTotalRevenue';
|
export * from './reqCreateSupplierTotalRevenue';
|
||||||
export * from './reqLogin';
|
export * from './reqLogin';
|
||||||
export * from './reqRegenerateQuotation';
|
export * from './reqRegenerateQuotation';
|
||||||
|
export * from './reqResetSupplierAccountPassword';
|
||||||
|
export * from './reqResetSupplierAccountPasswordPassword';
|
||||||
export * from './reqUpdateCard';
|
export * from './reqUpdateCard';
|
||||||
export * from './reqUpdateCardCondition';
|
export * from './reqUpdateCardCondition';
|
||||||
export * from './reqUpdateCardEditScript';
|
export * from './reqUpdateCardEditScript';
|
||||||
@ -215,6 +218,7 @@ export * from './reqUpdateQuotationSetting';
|
|||||||
export * from './reqUpdateQuotationSettingCardCount';
|
export * from './reqUpdateQuotationSettingCardCount';
|
||||||
export * from './reqUpdateQuotationSettingTargetMarginRate';
|
export * from './reqUpdateQuotationSettingTargetMarginRate';
|
||||||
export * from './reqUpdateSupplier';
|
export * from './reqUpdateSupplier';
|
||||||
|
export * from './reqUpdateSupplierAccountStatus';
|
||||||
export * from './reqUpdateSupplierCode';
|
export * from './reqUpdateSupplierCode';
|
||||||
export * from './reqUpdateSupplierManagerContactNumber';
|
export * from './reqUpdateSupplierManagerContactNumber';
|
||||||
export * from './reqUpdateSupplierManagerEmail';
|
export * from './reqUpdateSupplierManagerEmail';
|
||||||
@ -239,6 +243,10 @@ export * from './resCompanyUserUser';
|
|||||||
export * from './resCreateQuotation';
|
export * from './resCreateQuotation';
|
||||||
export * from './resCreateQuotationMsg';
|
export * from './resCreateQuotationMsg';
|
||||||
export * from './resCreateQuotationQtId';
|
export * from './resCreateQuotationQtId';
|
||||||
|
export * from './resCreateSupplierAccount';
|
||||||
|
export * from './resCreateSupplierAccountAccount';
|
||||||
|
export * from './resCreateSupplierAccountInitialPassword';
|
||||||
|
export * from './resCreateSupplierAccountMsg';
|
||||||
export * from './resDashboardSummary';
|
export * from './resDashboardSummary';
|
||||||
export * from './resDashboardSummaryMsg';
|
export * from './resDashboardSummaryMsg';
|
||||||
export * from './resDeleteCard';
|
export * from './resDeleteCard';
|
||||||
@ -313,12 +321,18 @@ export * from './resQuotationStatusMsg';
|
|||||||
export * from './resQuotationStatusQtId';
|
export * from './resQuotationStatusQtId';
|
||||||
export * from './resRefreshToken';
|
export * from './resRefreshToken';
|
||||||
export * from './resRefreshTokenMsg';
|
export * from './resRefreshTokenMsg';
|
||||||
|
export * from './resResetSupplierAccountPassword';
|
||||||
|
export * from './resResetSupplierAccountPasswordMsg';
|
||||||
|
export * from './resResetSupplierAccountPasswordNewPassword';
|
||||||
export * from './resSessionChat';
|
export * from './resSessionChat';
|
||||||
export * from './resSessionChatMsg';
|
export * from './resSessionChatMsg';
|
||||||
export * from './resSessionChatSessionId';
|
export * from './resSessionChatSessionId';
|
||||||
export * from './resStatisticsSummary';
|
export * from './resStatisticsSummary';
|
||||||
export * from './resStatisticsSummaryMsg';
|
export * from './resStatisticsSummaryMsg';
|
||||||
export * from './resSupplier';
|
export * from './resSupplier';
|
||||||
|
export * from './resSupplierAccount';
|
||||||
|
export * from './resSupplierAccountAccount';
|
||||||
|
export * from './resSupplierAccountMsg';
|
||||||
export * from './resSupplierItem';
|
export * from './resSupplierItem';
|
||||||
export * from './resSupplierItemList';
|
export * from './resSupplierItemList';
|
||||||
export * from './resSupplierItemListMsg';
|
export * from './resSupplierItemListMsg';
|
||||||
@ -355,7 +369,11 @@ export * from './statOutcome';
|
|||||||
export * from './statParticipation';
|
export * from './statParticipation';
|
||||||
export * from './statScope';
|
export * from './statScope';
|
||||||
export * from './statTypeRow';
|
export * from './statTypeRow';
|
||||||
|
export * from './supplierAccountData';
|
||||||
|
export * from './supplierAccountDataCreatedAt';
|
||||||
export * from './supplierData';
|
export * from './supplierData';
|
||||||
|
export * from './supplierDataAccountLoginId';
|
||||||
|
export * from './supplierDataAccountStatus';
|
||||||
export * from './supplierDataCode';
|
export * from './supplierDataCode';
|
||||||
export * from './supplierDataCreatedAt';
|
export * from './supplierDataCreatedAt';
|
||||||
export * from './supplierDataCreatorName';
|
export * from './supplierDataCreatorName';
|
||||||
@ -368,10 +386,9 @@ export * from './supplierItemData';
|
|||||||
export * from './supplierItemDataCreatedAt';
|
export * from './supplierItemDataCreatedAt';
|
||||||
export * from './supplierItemDataItemCode';
|
export * from './supplierItemDataItemCode';
|
||||||
export * from './supplierItemDataUpdatedAt';
|
export * from './supplierItemDataUpdatedAt';
|
||||||
export * from './supplierType';
|
|
||||||
export * from './targetCandidate';
|
export * from './targetCandidate';
|
||||||
export * from './userRole';
|
export * from './userRole';
|
||||||
export * from './userStatus';
|
export * from './userStatus';
|
||||||
export * from './validationError';
|
export * from './validationError';
|
||||||
export * from './validationErrorCtx';
|
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 { SupplierDataManagerEmail } from './supplierDataManagerEmail';
|
||||||
import type { SupplierDataManagerContactNumber } from './supplierDataManagerContactNumber';
|
import type { SupplierDataManagerContactNumber } from './supplierDataManagerContactNumber';
|
||||||
import type { SupplierDataTotalRevenue } from './supplierDataTotalRevenue';
|
import type { SupplierDataTotalRevenue } from './supplierDataTotalRevenue';
|
||||||
|
import type { SupplierDataAccountLoginId } from './supplierDataAccountLoginId';
|
||||||
|
import type { SupplierDataAccountStatus } from './supplierDataAccountStatus';
|
||||||
import type { SupplierDataCreatedAt } from './supplierDataCreatedAt';
|
import type { SupplierDataCreatedAt } from './supplierDataCreatedAt';
|
||||||
import type { SupplierDataUpdatedAt } from './supplierDataUpdatedAt';
|
import type { SupplierDataUpdatedAt } from './supplierDataUpdatedAt';
|
||||||
|
|
||||||
@ -24,6 +26,8 @@ export interface SupplierData {
|
|||||||
manager_email?: SupplierDataManagerEmail;
|
manager_email?: SupplierDataManagerEmail;
|
||||||
manager_contact_number?: SupplierDataManagerContactNumber;
|
manager_contact_number?: SupplierDataManagerContactNumber;
|
||||||
total_revenue?: SupplierDataTotalRevenue;
|
total_revenue?: SupplierDataTotalRevenue;
|
||||||
|
account_login_id?: SupplierDataAccountLoginId;
|
||||||
|
account_status?: SupplierDataAccountStatus;
|
||||||
created_at?: SupplierDataCreatedAt;
|
created_at?: SupplierDataCreatedAt;
|
||||||
updated_at?: SupplierDataUpdatedAt;
|
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);
|
return useMutation(mutationOptions, queryClient);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
|
||||||
/**
|
|
||||||
* @summary 견적 조회
|
* @summary 견적 조회
|
||||||
*/
|
*/
|
||||||
export const getQuotation = (
|
export const getQuotation = (
|
||||||
@ -1228,3 +1226,4 @@ export function useGetQuotation<TData = Awaited<ReturnType<typeof getQuotation>>
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -28,10 +28,16 @@ import type {
|
|||||||
ListSuppliersParams,
|
ListSuppliersParams,
|
||||||
ReqCheckCodes,
|
ReqCheckCodes,
|
||||||
ReqCreateSupplier,
|
ReqCreateSupplier,
|
||||||
|
ReqCreateSupplierAccount,
|
||||||
|
ReqResetSupplierAccountPassword,
|
||||||
ReqUpdateSupplier,
|
ReqUpdateSupplier,
|
||||||
|
ReqUpdateSupplierAccountStatus,
|
||||||
ResCheckCodes,
|
ResCheckCodes,
|
||||||
|
ResCreateSupplierAccount,
|
||||||
ResDeleteSupplier,
|
ResDeleteSupplier,
|
||||||
|
ResResetSupplierAccountPassword,
|
||||||
ResSupplier,
|
ResSupplier,
|
||||||
|
ResSupplierAccount,
|
||||||
ResSupplierList
|
ResSupplierList
|
||||||
} from '.././model';
|
} from '.././model';
|
||||||
|
|
||||||
@ -420,7 +426,7 @@ export const useUpdateSupplier = <TError = void | HTTPValidationError,
|
|||||||
return useMutation(mutationOptions, queryClient);
|
return useMutation(mutationOptions, queryClient);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* @summary 협력사 삭제
|
* @summary 협력사 삭제 (최고관리자 전용)
|
||||||
*/
|
*/
|
||||||
export const deleteSupplier = (
|
export const deleteSupplier = (
|
||||||
supplierId: string,
|
supplierId: string,
|
||||||
@ -465,7 +471,7 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
|
|||||||
export type DeleteSupplierMutationError = void | HTTPValidationError
|
export type DeleteSupplierMutationError = void | HTTPValidationError
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary 협력사 삭제
|
* @summary 협력사 삭제 (최고관리자 전용)
|
||||||
*/
|
*/
|
||||||
export const useDeleteSupplier = <TError = void | HTTPValidationError,
|
export const useDeleteSupplier = <TError = void | HTTPValidationError,
|
||||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteSupplier>>, TError,{supplierId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
|
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);
|
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 { Sheet } from '@/components/ui/sheet';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { SupplierItemsManager } from './SupplierItemsManager';
|
import { SupplierItemsManager } from './SupplierItemsManager';
|
||||||
|
import { SupplierAccountManager } from './SupplierAccountManager';
|
||||||
import { type Partner } from '../types';
|
import { type Partner } from '../types';
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
@ -215,6 +216,9 @@ export function PartnerFormSheet({
|
|||||||
{/* 취급상품 관리 — 수정 모드(협력사 확정)에서만. 추가/삭제/유형변경은 즉시 서버 반영. */}
|
{/* 취급상품 관리 — 수정 모드(협력사 확정)에서만. 추가/삭제/유형변경은 즉시 서버 반영. */}
|
||||||
{mode === 'edit' && partner && <SupplierItemsManager supplierId={partner.supplier_id} />}
|
{mode === 'edit' && partner && <SupplierItemsManager supplierId={partner.supplier_id} />}
|
||||||
|
|
||||||
|
{/* 채팅 계정 관리 — 수정 모드에서만. 발급/재설정/활성상태는 즉시 서버 반영. */}
|
||||||
|
{mode === 'edit' && partner && <SupplierAccountManager supplierId={partner.supplier_id} />}
|
||||||
|
|
||||||
{/* Buttons wrapper */}
|
{/* Buttons wrapper */}
|
||||||
<div className="pt-4 flex items-center gap-2 border-t border-border mt-8 justify-between">
|
<div className="pt-4 flex items-center gap-2 border-t border-border mt-8 justify-between">
|
||||||
{mode === 'edit' && partner && (
|
{mode === 'edit' && partner && (
|
||||||
|
|||||||
@ -71,6 +71,20 @@ export function PartnerTable({
|
|||||||
cellClassName: 'font-mono text-muted-foreground',
|
cellClassName: 'font-mono text-muted-foreground',
|
||||||
cell: (part) => (part.total_revenue != null ? `₩${Number(part.total_revenue).toLocaleString()}` : '-'),
|
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: '작성자',
|
header: '작성자',
|
||||||
align: 'center',
|
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 { useState } from 'react';
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
import { useListItems } from '@/api/generated/item/item';
|
import { useListItems } from '@/api/generated/item/item';
|
||||||
import { SupplierType } from '@/api/generated/model';
|
|
||||||
import { Typography } from '@/components/ui/typography';
|
import { Typography } from '@/components/ui/typography';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Combobox, type ComboOption } from '@/components/ui/combobox';
|
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 { showToast } from '@/lib/notify';
|
||||||
import { useSupplierItems } from '../hooks/useSupplierItems';
|
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> = {
|
export const DELIVERY_TYPE_LABEL: Record<DeliveryType, string> = {
|
||||||
[DeliveryType.SUPPLIER]: '협력사배송',
|
[DeliveryType.SUPPLIER]: '협력사배송',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user