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, custom=req.custom, ) 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