o2o-negosium-original/negodata/backend/services/item_service.py
Mina Choi d47476dc04 [refactor] negodata/backend: 서비스 계층 입력을 dict→타입드 Req 패킷으로 통일
라우터가 req.model_dump(exclude_unset=True)로 dict를 만들어 넘기던 것을
req(Req_*) 객체 그대로 전달하도록 변경. 서비스 시그니처를 전부 타입드로 통일.

- create 5개(quotation/card/item/supplier/quotation_setting): ORM은 명시 kwargs
  조립(item만 컬럼 16개라 model_dump 펼침). 경계 검증·타입 유지, **data 결합 제거.
- update 4개: 서비스가 req 받아 내부에서 model_dump(exclude_unset=True) 생성 후
  CRUD(dict)로 전달 — 공식 PATCH 메커니즘 유지.
- 서비스 계층 data: dict 시그니처 0개. CRUD는 dict 유지(의도).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:27:04 +09:00

196 lines
7.7 KiB
Python

import uuid
from fastapi import Depends, UploadFile
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]
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)
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) -> 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, _ = await self._fetch_owned(company_uuid, item_uuid)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
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) -> Res_DeleteItem:
res = Res_DeleteItem()
company_uuid = uuid.UUID(company_id)
item_uuid = uuid.UUID(item_id)
err_type, _ = await self._fetch_owned(company_uuid, item_uuid)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
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