import uuid from fastapi import Depends, UploadFile from common.authz import is_owner_or_admin from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import items from common.enums import DBWRType, ErrorType from common.logger import LOG from common.models.gmodel import PageParams from services.azure_blob_client import is_allowed_image, upload_image as blob_upload_image from config.server_configs import storage_config from crud.item_crud import IItemCRUD, ItemCRUD from router.v1.item.protocol import ( ItemCategory, ItemData, Req_CreateItem, Req_UpdateItem, Res_CheckCodes, Res_DeleteItem, Res_Item, Res_ItemCategories, Res_ItemImage, Res_ItemList, ) class ItemService: """상품 비즈니스 로직. company_id 로 소유권을 확인한다(멀티테넌트).""" def __init__(self, item_crud: IItemCRUD = Depends(ItemCRUD)): self.item_crud = item_crud async def _fetch_owned(self, company_uuid: uuid.UUID, item_id: uuid.UUID): """item 조회 + 소유권 확인. (ErrorType, item|None) 반환.""" err_type, item = await DB_SESSION_MNG.execute_lambda( items.DBType(), DBWRType.DB_READ.value, lambda s: self.item_crud.get_by_id(s, item_id), ) if err_type != ErrorType.SUCCESS or item is None: return ErrorType.ITEM_NOT_FOUND, None if item.company_id != company_uuid: return ErrorType.ITEM_NOT_FOUND, None return ErrorType.SUCCESS, item async def list_items(self, company_id: str, search, category, pg: PageParams) -> Res_ItemList: res = Res_ItemList(page=pg.page, size=pg.size) company_uuid = uuid.UUID(company_id) err_type, rows, total = await DB_SESSION_MNG.execute_lambda( items.DBType(), DBWRType.DB_READ.value, lambda s: self.item_crud.search(s, company_uuid, search, category, pg.skip, pg.size), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res res.items = [ItemData.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( items.DBType(), DBWRType.DB_READ.value, lambda s: self.item_crud.user_name_map(s, author_ids), ) if nm_err == ErrorType.SUCCESS: for d in res.items: d.creator_name = name_map.get(d.user_id) # 공급사명 배치 조인 — 페이지 상품의 item_id를 모아 IN 쿼리 1회로 {item_id:[공급사명]} 맵을 만들어 매핑. item_ids = [r.item_id for r in rows] if item_ids: sn_err, supplier_map = await DB_SESSION_MNG.execute_lambda( items.DBType(), DBWRType.DB_READ.value, lambda s: self.item_crud.supplier_name_map(s, item_ids), ) if sn_err == ErrorType.SUCCESS: for d in res.items: d.supplier_names = supplier_map.get(d.item_id, []) res.total = total return res async def list_categories(self, company_id: str) -> Res_ItemCategories: """회사 전체 상품에서 distinct 카테고리(이름+타입)를 돌려준다. 카테고리 목록은 백엔드가 책임진다.""" res = Res_ItemCategories() company_uuid = uuid.UUID(company_id) err_type, rows = await DB_SESSION_MNG.execute_lambda( items.DBType(), DBWRType.DB_READ.value, lambda s: self.item_crud.distinct_categories(s, company_uuid), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res res.categories = [ItemCategory(name=r[0], category_type=r[1] if r[1] is not None else 1) for r in rows] return res async def get_item(self, company_id: str, item_id: str) -> Res_Item: res = Res_Item() err_type, item = await self._fetch_owned(uuid.UUID(company_id), uuid.UUID(item_id)) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res res.item = ItemData.model_validate(item) if item.user_id is not None: nm_err, name_map = await DB_SESSION_MNG.execute_lambda( items.DBType(), DBWRType.DB_READ.value, lambda s: self.item_crud.user_name_map(s, [item.user_id]), ) if nm_err == ErrorType.SUCCESS: res.item.creator_name = name_map.get(item.user_id) 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( items.DBType(), DBWRType.DB_READ.value, lambda s: self.item_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_item(self, company_id: str, user_id: str, req: Req_CreateItem) -> Res_Item: res = Res_Item() company_uuid = uuid.UUID(company_id) # DB 중복코드 검증: 같은 회사에 동일 code 가 이미 있으면 거부(프론트는 받아온 목록만 보므로 여기서 최종 차단). code = req.code if code: dup_err, exists = await DB_SESSION_MNG.execute_lambda( items.DBType(), DBWRType.DB_READ.value, lambda s: self.item_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.ITEM_CODE_DUPLICATE) return res item = items(**req.model_dump(exclude_unset=True), company_id=company_uuid, user_id=uuid.UUID(user_id)) err_type = await DB_SESSION_MNG.execute_lambda_run( [items.DBType()], [lambda s: self.item_crud.add_item(s, item)], ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res # 서버 기본값(created_at/updated_at)은 insert 후 Python 객체에 실리지 않으므로 재조회한다. return await self.get_item(company_id, str(item.item_id)) async def update_item(self, company_id: str, item_id: str, req: Req_UpdateItem, user_id=None, role=None) -> Res_Item: res = Res_Item() company_uuid = uuid.UUID(company_id) item_uuid = uuid.UUID(item_id) data = req.model_dump(exclude_unset=True) # 회사 스코프 확인 err_type, item = await self._fetch_owned(company_uuid, item_uuid) if err_type != ErrorType.SUCCESS or item is None: res.result.SetResult(err_type) return res # 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정(user_id 미지정=내부 호출은 스킵). if user_id is not None and not is_owner_or_admin(item.user_id, user_id, role): res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN) res.msg = "본인이 등록한 상품만 수정할 수 있습니다." return res err_type = await DB_SESSION_MNG.execute_lambda_run( [items.DBType()], [lambda s: self.item_crud.update_item(s, item_uuid, data)], ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res # 갱신 후 재조회 return await self.get_item(company_id, item_id) async def delete_item(self, company_id: str, item_id: str, user_id=None, role=None) -> Res_DeleteItem: res = Res_DeleteItem() company_uuid = uuid.UUID(company_id) item_uuid = uuid.UUID(item_id) err_type, item = await self._fetch_owned(company_uuid, item_uuid) if err_type != ErrorType.SUCCESS or item is None: res.result.SetResult(err_type) return res # 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 삭제(user_id 미지정=내부 호출은 스킵). if user_id is not None and not is_owner_or_admin(item.user_id, user_id, role): res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN) res.msg = "본인이 등록한 상품만 삭제할 수 있습니다." return res err_type = await DB_SESSION_MNG.execute_lambda_run( [items.DBType()], [lambda s: self.item_crud.soft_delete(s, item_uuid)], ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res async def upload_image(self, company_id: str, file: UploadFile) -> Res_ItemImage: """상품 이미지를 Azure Blob 에 올리고 image_url 을 돌려준다. DB 는 건드리지 않는다 — 프론트가 이 URL 을 create/update 의 image_url 로 실어 보낸다.""" res = Res_ItemImage() content_type = file.content_type or "" if not is_allowed_image(content_type): res.result.SetResult(ErrorType.IMAGE_INVALID_TYPE) return res content = await file.read() if len(content) > storage_config.max_image_mb * 1024 * 1024: res.result.SetResult(ErrorType.IMAGE_TOO_LARGE) return res try: res.image_url = await blob_upload_image(company_id, content, content_type) except Exception as ex: LOG.e_no_callstack(ex) res.result.SetResult(ErrorType.IMAGE_UPLOAD_FAILED) return res res.filename = file.filename res.size = len(content) return res