- 백엔드: supplier_users/supplier_user_tokens ORM 매핑, /v1/supplier/{id}/account 4종(조회·발급·재설정·상태), 회사 스코프 게이팅, 재설정·비활성 시 토큰 삭제로 기존 로그인 즉시 무효화
- 비밀번호: 발급=서버 자동생성(1회 반환), 재설정=커스텀 지정 가능(미지정 시 자동생성)
- 프론트: 협력사 상세 SupplierAccountManager 섹션 + 목록 '채팅 계정' 컬럼, orval 재생성(SupplierType 은 enumLabels 로컬 상수로 이동)
- 테스트: test_supplier_account.py 9건, 전체 스위트 green
367 lines
16 KiB
Python
367 lines
16 KiB
Python
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, 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),
|
|
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) 반환."""
|
|
err_type, supplier = await DB_SESSION_MNG.execute_lambda(
|
|
suppliers.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.supplier_crud.get_by_id(s, supplier_id),
|
|
)
|
|
if err_type != ErrorType.SUCCESS or supplier is None:
|
|
return ErrorType.SUPPLIER_NOT_FOUND, None
|
|
if supplier.company_id != company_uuid:
|
|
return ErrorType.SUPPLIER_NOT_FOUND, None
|
|
return ErrorType.SUCCESS, supplier
|
|
|
|
async def list_suppliers(self, company_id: str, search, pg: PageParams) -> Res_SupplierList:
|
|
res = Res_SupplierList(page=pg.page, size=pg.size)
|
|
company_uuid = uuid.UUID(company_id)
|
|
|
|
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
|
|
suppliers.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.supplier_crud.search(s, company_uuid, search, pg.skip, pg.size),
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
res.suppliers = [SupplierData.model_validate(r) for r in rows]
|
|
# 등록자명 배치 조인 — 페이지 협력사의 user_id를 모아 IN 쿼리 1회로 {id:name} 맵을 만들어 매핑(행별 조회 아님).
|
|
author_ids = list({r.user_id for r in rows if r.user_id is not None})
|
|
if author_ids:
|
|
nm_err, name_map = await DB_SESSION_MNG.execute_lambda(
|
|
suppliers.DBType(), DBWRType.DB_READ.value,
|
|
lambda s: self.supplier_crud.user_name_map(s, author_ids),
|
|
)
|
|
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
|
|
|
|
async def get_supplier(self, company_id: str, supplier_id: str) -> Res_Supplier:
|
|
res = Res_Supplier()
|
|
err_type, supplier = await self._fetch_owned(uuid.UUID(company_id), uuid.UUID(supplier_id))
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
res.supplier = SupplierData.model_validate(supplier)
|
|
if supplier.user_id is not None:
|
|
nm_err, name_map = await DB_SESSION_MNG.execute_lambda(
|
|
suppliers.DBType(), DBWRType.DB_READ.value,
|
|
lambda s: self.supplier_crud.user_name_map(s, [supplier.user_id]),
|
|
)
|
|
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:
|
|
"""업로드 즉시 호출: codes 중 같은 회사 DB 에 이미 있는 코드를 돌려준다(미리보기 사전검사)."""
|
|
res = Res_CheckCodes()
|
|
company_uuid = uuid.UUID(company_id)
|
|
err_type, existing = await DB_SESSION_MNG.execute_lambda(
|
|
suppliers.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.supplier_crud.existing_codes(s, company_uuid, codes),
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
res.existing = list(existing)
|
|
return res
|
|
|
|
async def create_supplier(self, company_id: str, user_id: str, req: Req_CreateSupplier) -> Res_Supplier:
|
|
res = Res_Supplier()
|
|
company_uuid = uuid.UUID(company_id)
|
|
|
|
# DB 중복코드 검증: 같은 회사에 동일 code 가 이미 있으면 거부(프론트는 받아온 목록만 보므로 여기서 최종 차단).
|
|
code = req.code
|
|
if code:
|
|
dup_err, exists = await DB_SESSION_MNG.execute_lambda(
|
|
suppliers.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.supplier_crud.code_exists(s, company_uuid, code),
|
|
)
|
|
if dup_err != ErrorType.SUCCESS:
|
|
res.result.SetResult(dup_err)
|
|
return res
|
|
if exists:
|
|
res.result.SetResult(ErrorType.SUPPLIER_CODE_DUPLICATE)
|
|
return res
|
|
|
|
supplier = suppliers(
|
|
company_id=company_uuid,
|
|
user_id=uuid.UUID(user_id),
|
|
name=req.name,
|
|
code=req.code,
|
|
manager_name=req.manager_name,
|
|
manager_email=req.manager_email,
|
|
manager_contact_number=req.manager_contact_number,
|
|
total_revenue=req.total_revenue,
|
|
)
|
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
|
[suppliers.DBType()],
|
|
[lambda s: self.supplier_crud.add_supplier(s, supplier)],
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
# 서버 기본값(created_at/updated_at)은 insert 후 객체에 실리지 않으므로 재조회한다.
|
|
return await self.get_supplier(company_id, str(supplier.supplier_id))
|
|
|
|
async def update_supplier(self, company_id: str, supplier_id: str, req: Req_UpdateSupplier) -> Res_Supplier:
|
|
res = Res_Supplier()
|
|
company_uuid = uuid.UUID(company_id)
|
|
supplier_uuid = uuid.UUID(supplier_id)
|
|
data = req.model_dump(exclude_unset=True)
|
|
|
|
# 소유권 확인
|
|
err_type, _ = await self._fetch_owned(company_uuid, supplier_uuid)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
|
[suppliers.DBType()],
|
|
[lambda s: self.supplier_crud.update_supplier(s, supplier_uuid, data)],
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
# 갱신 후 재조회
|
|
return await self.get_supplier(company_id, supplier_id)
|
|
|
|
async def delete_supplier(self, company_id: str, supplier_id: str) -> Res_DeleteSupplier:
|
|
res = Res_DeleteSupplier()
|
|
company_uuid = uuid.UUID(company_id)
|
|
supplier_uuid = uuid.UUID(supplier_id)
|
|
|
|
err_type, _ = await self._fetch_owned(company_uuid, supplier_uuid)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
|
[suppliers.DBType()],
|
|
[lambda s: self.supplier_crud.soft_delete(s, supplier_uuid)],
|
|
)
|
|
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
|