[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>
This commit is contained in:
Mina Choi 2026-06-23 09:27:04 +09:00
parent fd2e5c1cc2
commit d47476dc04
10 changed files with 97 additions and 79 deletions

View File

@ -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="협상카드 삭제")

View File

@ -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="상품 삭제")

View File

@ -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="견적 마감")

View File

@ -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)
)

View File

@ -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)
)

View File

@ -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:

View File

@ -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)

View File

@ -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:

View File

@ -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)

View File

@ -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)