[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:
Mina Choi 2026-07-10 10:14:00 +09:00
parent a80dac20ed
commit 7f1c0be2b3
37 changed files with 1385 additions and 36 deletions

View File

@ -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에서 보장.

View File

@ -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).

View File

@ -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:

View 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

View File

@ -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

View File

@ -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))

View File

@ -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

View 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

View File

@ -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';

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -0,0 +1,17 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { ErrorInfo } from './errorInfo';
import type { 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;
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View 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 { ResResetSupplierAccountPasswordMsg } from './resResetSupplierAccountPasswordMsg';
import type { ResResetSupplierAccountPasswordNewPassword } from './resResetSupplierAccountPasswordNewPassword';
export interface ResResetSupplierAccountPassword {
result?: ErrorInfo;
msg?: ResResetSupplierAccountPasswordMsg;
new_password?: ResResetSupplierAccountPasswordNewPassword;
}

View File

@ -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;

View File

@ -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;

View 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;
}

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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>>

View File

@ -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);
}

View File

@ -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 && (

View File

@ -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',

View File

@ -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()}`;
}

View File

@ -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';

View File

@ -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 };
}

View File

@ -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]: '협력사배송',