From d47476dc048569ceb6f9793a39c691220e332df5 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Tue, 23 Jun 2026 09:27:04 +0900 Subject: [PATCH] =?UTF-8?q?[refactor]=20negodata/backend:=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EA=B3=84=EC=B8=B5=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=EC=9D=84=20dict=E2=86=92=ED=83=80=EC=9E=85=EB=93=9C=20Req=20?= =?UTF-8?q?=ED=8C=A8=ED=82=B7=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 라우터가 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) --- negodata/backend/router/v1/card/card.py | 4 +- negodata/backend/router/v1/item/item.py | 4 +- .../backend/router/v1/quotation/quotation.py | 2 +- .../v1/quotation_setting/quotation_setting.py | 4 +- .../backend/router/v1/supplier/supplier.py | 4 +- negodata/backend/services/card_service.py | 23 +++--- negodata/backend/services/item_service.py | 11 ++- .../backend/services/quotation_service.py | 82 ++++++++----------- .../services/quotation_setting_service.py | 14 +++- negodata/backend/services/supplier_service.py | 28 +++++-- 10 files changed, 97 insertions(+), 79 deletions(-) diff --git a/negodata/backend/router/v1/card/card.py b/negodata/backend/router/v1/card/card.py index e233947..87f7e55 100644 --- a/negodata/backend/router/v1/card/card.py +++ b/negodata/backend/router/v1/card/card.py @@ -30,7 +30,7 @@ async def list_cards( @router.post(path="/create", response_model=Res_Card, summary="협상카드 등록") async def create_card(req: Req_CreateCard, service: CardService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): return RemoveNoneResponse( - await service.create_card(user_info.user_id, req.model_dump(exclude_unset=True)) + await service.create_card(user_info.user_id, req) ) @@ -43,7 +43,7 @@ async def get_card(card_id: UUID, service: CardService = Depends(), user_info: U async def update_card( card_id: UUID, req: Req_UpdateCard, service: CardService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) ): - return RemoveNoneResponse(await service.update_card(user_info.user_id, str(card_id), req.model_dump(exclude_unset=True))) + return RemoveNoneResponse(await service.update_card(user_info.user_id, str(card_id), req)) @router.delete(path="/delete/{card_id}", response_model=Res_DeleteCard, summary="협상카드 삭제") diff --git a/negodata/backend/router/v1/item/item.py b/negodata/backend/router/v1/item/item.py index c190f2a..2e21d81 100644 --- a/negodata/backend/router/v1/item/item.py +++ b/negodata/backend/router/v1/item/item.py @@ -42,7 +42,7 @@ async def list_item_categories(service: ItemService = Depends(), user_info: User @router.post(path="/create", response_model=Res_Item, summary="상품 등록") async def create_item(req: Req_CreateItem, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): return RemoveNoneResponse( - await service.create_item(user_info.company_id, user_info.user_id, req.model_dump(exclude_unset=True)) + await service.create_item(user_info.company_id, user_info.user_id, req) ) @@ -69,7 +69,7 @@ async def get_item(item_id: UUID, service: ItemService = Depends(), user_info: U async def update_item( item_id: UUID, req: Req_UpdateItem, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) ): - return RemoveNoneResponse(await service.update_item(user_info.company_id, str(item_id), req.model_dump(exclude_unset=True))) + return RemoveNoneResponse(await service.update_item(user_info.company_id, str(item_id), req)) @router.delete(path="/delete/{item_id}", response_model=Res_DeleteItem, summary="상품 삭제") diff --git a/negodata/backend/router/v1/quotation/quotation.py b/negodata/backend/router/v1/quotation/quotation.py index 639d923..8c18431 100644 --- a/negodata/backend/router/v1/quotation/quotation.py +++ b/negodata/backend/router/v1/quotation/quotation.py @@ -45,7 +45,7 @@ async def list_quotations( async def create_quotation( req: Req_CreateQuotation, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) ): - return RemoveNoneResponse(await service.create_quotation(user_info.user_id, req.model_dump(exclude_unset=True))) + return RemoveNoneResponse(await service.create_quotation(user_info.user_id, req)) @router.post(path="/stop/{qt_id}", response_model=Res_Quotation, summary="견적 마감") diff --git a/negodata/backend/router/v1/quotation_setting/quotation_setting.py b/negodata/backend/router/v1/quotation_setting/quotation_setting.py index 5901404..1518e35 100644 --- a/negodata/backend/router/v1/quotation_setting/quotation_setting.py +++ b/negodata/backend/router/v1/quotation_setting/quotation_setting.py @@ -30,7 +30,7 @@ async def create_setting( service: QuotationSettingService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken), ): - return RemoveNoneResponse(await service.create_setting(user_info.user_id, req.model_dump(exclude_unset=True))) + return RemoveNoneResponse(await service.create_setting(user_info.user_id, req)) @router.patch(path="/update/{qt_setting_id}", response_model=Res_QuotationSetting, summary="견적 설정 수정") @@ -41,7 +41,7 @@ async def update_setting( user_info: UserInfo = Depends(IsValidAccessToken), ): return RemoveNoneResponse( - await service.update_setting(user_info.user_id, str(qt_setting_id), req.model_dump(exclude_unset=True)) + await service.update_setting(user_info.user_id, str(qt_setting_id), req) ) diff --git a/negodata/backend/router/v1/supplier/supplier.py b/negodata/backend/router/v1/supplier/supplier.py index 7d22fea..a556670 100644 --- a/negodata/backend/router/v1/supplier/supplier.py +++ b/negodata/backend/router/v1/supplier/supplier.py @@ -35,7 +35,7 @@ async def create_supplier( req: Req_CreateSupplier, service: SupplierService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) ): return RemoveNoneResponse( - await service.create_supplier(user_info.company_id, user_info.user_id, req.model_dump(exclude_unset=True)) + await service.create_supplier(user_info.company_id, user_info.user_id, req) ) @@ -56,7 +56,7 @@ async def update_supplier( supplier_id: UUID, req: Req_UpdateSupplier, service: SupplierService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) ): return RemoveNoneResponse( - await service.update_supplier(user_info.company_id, str(supplier_id), req.model_dump(exclude_unset=True)) + await service.update_supplier(user_info.company_id, str(supplier_id), req) ) diff --git a/negodata/backend/services/card_service.py b/negodata/backend/services/card_service.py index 2f4ec2b..ff2f13c 100644 --- a/negodata/backend/services/card_service.py +++ b/negodata/backend/services/card_service.py @@ -7,7 +7,7 @@ from common.database.model.models import nego_cards, wild_cards from common.enums import CardStatus, DBWRType, ErrorType from common.models.gmodel import PageParams from crud.card_crud import ICardCRUD, CardCRUD -from router.v1.card.protocol import CardData, Res_Card, Res_CardList, Res_DeleteCard +from router.v1.card.protocol import CardData, Req_CreateCard, Req_UpdateCard, Res_Card, Res_CardList, Res_DeleteCard class CardService: @@ -135,24 +135,24 @@ class CardService: return res # ---- 등록 ---------------------------------------------------------------- - async def create_card(self, user_id: str, data: dict) -> Res_Card: + async def create_card(self, user_id: str, req: Req_CreateCard) -> Res_Card: res = Res_Card() user_uuid = uuid.UUID(user_id) - is_wildcard = bool(data.get("is_wildcard", False)) + is_wildcard = req.is_wildcard common = dict( user_id=user_uuid, - name=data.get("name"), - number=data.get("number"), - script=data.get("script"), - edit_script=data.get("edit_script"), + name=req.name, + number=req.number, + script=req.script, + edit_script=req.edit_script, ) if is_wildcard: card = wild_cards( **common, - condition=data.get("condition"), - available=(data.get("status", CardStatus.ACTIVE.value) == CardStatus.ACTIVE.value), - memo=data.get("memo"), + condition=req.condition, + available=(req.status == CardStatus.ACTIVE.value), + memo=req.memo, ) model, pk_attr = wild_cards, "wild_card_id" else: @@ -170,10 +170,11 @@ class CardService: return await self.get_card(user_id, str(getattr(card, pk_attr))) # ---- 수정 ---------------------------------------------------------------- - async def update_card(self, user_id: str, card_id: str, data: dict) -> Res_Card: + async def update_card(self, user_id: str, card_id: str, req: Req_UpdateCard) -> Res_Card: res = Res_Card() user_uuid = uuid.UUID(user_id) card_uuid = uuid.UUID(card_id) + data = req.model_dump(exclude_unset=True) err, model, pk_col, _row, is_wild = await self._find_owned(user_uuid, card_uuid) if err != ErrorType.SUCCESS: diff --git a/negodata/backend/services/item_service.py b/negodata/backend/services/item_service.py index 6db17b0..5dfd86d 100644 --- a/negodata/backend/services/item_service.py +++ b/negodata/backend/services/item_service.py @@ -13,6 +13,8 @@ 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, @@ -96,12 +98,12 @@ class ItemService: res.existing = list(existing) return res - async def create_item(self, company_id: str, user_id: str, data: dict) -> Res_Item: + 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 = data.get("code") + code = req.code if code: dup_err, exists = await DB_SESSION_MNG.execute_lambda( items.DBType(), @@ -115,7 +117,7 @@ class ItemService: res.result.SetResult(ErrorType.ITEM_CODE_DUPLICATE) return res - item = items(**data, company_id=company_uuid, user_id=uuid.UUID(user_id)) + 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)], @@ -126,10 +128,11 @@ class ItemService: # 서버 기본값(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, data: dict) -> Res_Item: + 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) diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index 618d676..146a834 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -11,11 +11,11 @@ from common.utils.gtime import GTime from config.server_configs import web_server_config from crud.quotation_crud import IQuotationCRUD, QuotationCRUD from router.v1.quotation.protocol import ( - AsyncJob, ChatMessageData, QuotationCardData, QuotationData, SessionData, + Req_CreateQuotation, Res_CreateQuotation, Res_DeleteQuotation, Res_Quotation, @@ -133,28 +133,22 @@ class QuotationService: res.quotation = QuotationData.model_validate(quotation) return res - async def create_quotation(self, user_id: str, data: dict) -> Res_CreateQuotation: + async def create_quotation(self, user_id: str, req: Req_CreateQuotation) -> Res_CreateQuotation: res = Res_CreateQuotation() # 세션 생성용 입력은 quotations 컬럼이 아니므로 분리한다(상품 × 공급사 조합마다 세션 1개). - item_ids = data.pop("item_ids", []) or [] - supplier_ids = data.pop("supplier_ids", []) or [] - card_ids = data.pop("card_ids", []) or [] + item_ids = req.item_ids + supplier_ids = req.supplier_ids + card_ids = req.card_ids # NOT NULL 컬럼 보정(프론트 미전송 시 서버 디폴트). - if not data.get("version_id"): - data["version_id"] = self.DEFAULT_VERSION_ID - if not data.get("start_time"): - data["start_time"] = GTime.UTC() - if not data.get("number"): - data["number"] = self._gen_number() - if not data.get("status"): - data["status"] = QuotationStatus.CREATED.value - if not data.get("round"): - data["round"] = 1 # ORM 기본값은 flush 시점이라, 세션 스냅샷용으로 미리 확정한다 + version_id = req.version_id or self.DEFAULT_VERSION_ID + number = req.number or self._gen_number() + status = req.status or QuotationStatus.CREATED.value + round_ = req.round or 1 # ORM 기본값은 flush 시점이라, 세션 스냅샷용으로 미리 확정한다 # DB 컬럼이 naive 라, tz-aware 로 들어온 시각(프론트 toISOString)을 UTC naive 로 맞춘다. - data["start_time"] = self._naive_utc(data.get("start_time")) - data["end_time"] = self._naive_utc(data.get("end_time")) + start_time = self._naive_utc(req.start_time or GTime.UTC()) + end_time = self._naive_utc(req.end_time) # 세션 목표가 입력(상품 단가 + 견적 세팅 목표 마진율). 읽기 트랜잭션에서 먼저 조회. prices = {} @@ -168,7 +162,7 @@ class QuotationService: _err, margin = await DB_SESSION_MNG.execute_lambda( quotations.DBType(), DBWRType.DB_READ.value, - lambda s: self.quotation_crud.get_target_margin(s, data["qt_setting_id"]), + lambda s: self.quotation_crud.get_target_margin(s, req.qt_setting_id), ) margin = margin if _err == ErrorType.SUCCESS else None @@ -188,7 +182,7 @@ class QuotationService: version_id=new_version_id, user_id=uuid.UUID(user_id), code=0, - name=(data.get("number") or "견적버전")[:10], + name=(number or "견적버전")[:10], ) for cid in card_ids: t = card_types.get(cid) @@ -196,11 +190,27 @@ class QuotationService: link_rows.append(version_nego_cards(version_id=new_version_id, nego_card_id=cid)) elif t == 2: link_rows.append(version_wild_cards(version_id=new_version_id, wild_card_id=cid)) - data["version_id"] = new_version_id + version_id = new_version_id # qt_id 를 미리 발급해 세션 FK(quotation_id)와 묶고, 한 트랜잭션에 함께 insert 한다. qt_id = uuid.uuid4() - quotation = quotations(**data, qt_id=qt_id, user_id=uuid.UUID(user_id)) + quotation = quotations( + qt_id=qt_id, + user_id=uuid.UUID(user_id), + qt_setting_id=req.qt_setting_id, + version_id=version_id, + name=req.name, + number=number, + type=req.type, + status=status, + round=round_, + start_time=start_time, + end_time=end_time, + manager_name=req.manager_name, + manager_email=req.manager_email, + manager_contact_number=req.manager_contact_number, + memo=req.memo, + ) session_objs = [] for iid in item_ids: @@ -236,32 +246,10 @@ class QuotationService: res.result.SetResult(err_type) return res - # 서버 기본값(created_at/updated_at) 로드 위해 재조회. - f_err, fresh = await DB_SESSION_MNG.execute_lambda( - quotations.DBType(), - DBWRType.DB_READ.value, - lambda s: self.quotation_crud.get_by_id(s, qt_id), - ) - res.quotation = QuotationData.model_validate(fresh if f_err == ErrorType.SUCCESS and fresh is not None else quotation) - - # 생성된 세션 + 각 세션 chat 실행 URL 을 함께 반환(협상리스트에서 바로 진입 가능). - res.sessions = [ - SessionData( - session_id=so.session_id, - qt_id=so.quotation_id, - supplier_id=so.supplier_id, - item_id=so.item_id, - qt_number=so.qt_number, - qt_round=so.qt_round, - qt_type=so.qt_type, - target_price=so.target_price, - status=so.status, - end_time=so.end_time, - url=self._session_chat_url(so.session_id), - ) - for so in session_objs - ] - res.async_job = AsyncJob(status="created", message=f"협상 세션 {len(session_objs)}건 생성") + # 프론트는 생성 응답 본문을 화면에 쓰지 않고 qt_id 로 목록/상세를 재조회한다. + # 그래서 새 견적 id 와 세션 수만 돌려준다(재조회 get_by_id·세션 풀바디·url 생략). + res.qt_id = qt_id + res.session_count = len(session_objs) return res async def stop_quotation(self, qt_id: str) -> Res_Quotation: diff --git a/negodata/backend/services/quotation_setting_service.py b/negodata/backend/services/quotation_setting_service.py index 5d67854..e614324 100644 --- a/negodata/backend/services/quotation_setting_service.py +++ b/negodata/backend/services/quotation_setting_service.py @@ -8,6 +8,8 @@ from common.enums import DBWRType, ErrorType from crud.quotation_setting_crud import IQuotationSettingCRUD, QuotationSettingCRUD from router.v1.quotation_setting.protocol import ( QuotationSettingData, + Req_CreateQuotationSetting, + Req_UpdateQuotationSetting, Res_DeleteQuotationSetting, Res_QuotationSetting, Res_QuotationSettingList, @@ -58,9 +60,14 @@ class QuotationSettingService: res.setting = QuotationSettingData.model_validate(setting) return res - async def create_setting(self, user_id: str, data: dict) -> Res_QuotationSetting: + async def create_setting(self, user_id: str, req: Req_CreateQuotationSetting) -> Res_QuotationSetting: res = Res_QuotationSetting() - setting = quotation_settings(**data, user_id=uuid.UUID(user_id)) + setting = quotation_settings( + user_id=uuid.UUID(user_id), + target_margin_rate=req.target_margin_rate, + anchoring_value=req.anchoring_value, + card_count=req.card_count, + ) err_type = await DB_SESSION_MNG.execute_lambda_run( [quotation_settings.DBType()], [lambda s: self.qs_crud.add_setting(s, setting)], @@ -71,10 +78,11 @@ class QuotationSettingService: # 서버 기본값(created_at/updated_at)은 insert 후 객체에 실리지 않으므로 재조회한다. return await self.get_setting(user_id, str(setting.qt_setting_id)) - async def update_setting(self, user_id: str, qt_setting_id: str, data: dict) -> Res_QuotationSetting: + async def update_setting(self, user_id: str, qt_setting_id: str, req: Req_UpdateQuotationSetting) -> Res_QuotationSetting: res = Res_QuotationSetting() user_uuid = uuid.UUID(user_id) setting_uuid = uuid.UUID(qt_setting_id) + data = req.model_dump(exclude_unset=True) # 소유권 확인 err_type, _ = await self._fetch_owned(user_uuid, setting_uuid) diff --git a/negodata/backend/services/supplier_service.py b/negodata/backend/services/supplier_service.py index bac747e..54941c2 100644 --- a/negodata/backend/services/supplier_service.py +++ b/negodata/backend/services/supplier_service.py @@ -8,7 +8,15 @@ from common.enums import DBWRType, ErrorType from common.logger import LOG from common.models.gmodel import PageParams from crud.supplier_crud import ISupplierCRUD, SupplierCRUD -from router.v1.supplier.protocol import Res_CheckCodes, Res_DeleteSupplier, Res_Supplier, Res_SupplierList, SupplierData +from router.v1.supplier.protocol import ( + Req_CreateSupplier, + Req_UpdateSupplier, + Res_CheckCodes, + Res_DeleteSupplier, + Res_Supplier, + Res_SupplierList, + SupplierData, +) class SupplierService: @@ -70,12 +78,12 @@ class SupplierService: res.existing = list(existing) return res - async def create_supplier(self, company_id: str, user_id: str, data: dict) -> Res_Supplier: + 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 = data.get("code") + code = req.code if code: dup_err, exists = await DB_SESSION_MNG.execute_lambda( suppliers.DBType(), @@ -89,7 +97,16 @@ class SupplierService: res.result.SetResult(ErrorType.SUPPLIER_CODE_DUPLICATE) return res - supplier = suppliers(**data, company_id=company_uuid, user_id=uuid.UUID(user_id)) + 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, + priority=req.priority, + ) err_type = await DB_SESSION_MNG.execute_lambda_run( [suppliers.DBType()], [lambda s: self.supplier_crud.add_supplier(s, supplier)], @@ -100,10 +117,11 @@ class SupplierService: # 서버 기본값(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, data: dict) -> Res_Supplier: + 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)