o2o-negosium-original/negodata/backend/services/renegotiation_service.py

171 lines
7.4 KiB
Python

import uuid
from typing import Optional
from fastapi import Depends
from common.authz import is_owner_or_admin
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import sessions
from common.enums import DBWRType, ErrorType, RenegotiationStatus
from common.logger import LOG
from common.models.gmodel import PageParams
from common.utils.gtime import GTime
from crud.renegotiation_crud import IRenegotiationCRUD, RenegotiationCRUD
from router.v1.renegotiation.protocol import (
RenegotiationData,
Req_ApproveRenegotiation,
Req_RejectRenegotiation,
Res_RenegotiationDecision,
Res_RenegotiationList,
)
from services.quotation import QuotationService
class RenegotiationService:
"""공급사 재협상 요청 심사(IMK #15).
요청 자체는 공급사 포털이 sessions.custom.renegotiation 에 기록한다. 여기서는 조회와 승인/반려만 한다.
승인은 새 로직이 아니라 기존 수동 재생성(regenerate_quotation)을 그대로 호출한다 — 라운드 생성 규칙을 한 곳에 둔다.
"""
def __init__(
self,
crud: IRenegotiationCRUD = Depends(RenegotiationCRUD),
quotation_service: QuotationService = Depends(),
):
self.crud = crud
self.quotation_service = quotation_service
async def list_requests(self, company_id: str, user_id: str, role: int, status: Optional[int], pg: PageParams) -> Res_RenegotiationList:
res = Res_RenegotiationList(page=pg.page, size=pg.size)
# 리스트는 회사 전체가 보인다(현황 공유). 처리 권한은 항목별 can_act 로 내려준다.
err, rows, total = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.list_requests(s, uuid.UUID(company_id), status, pg.skip, pg.size),
)
if err != ErrorType.SUCCESS:
res.result.SetResult(err)
return res
res.requests = [self._to_data(r, user_id, role) for r in rows]
res.total = total
return res
async def approve(self, company_id: str, user_id: str, role: int, session_id: str, req: Req_ApproveRenegotiation) -> Res_RenegotiationDecision:
"""승인 → 원 견적의 다음 라운드 생성. 요청 공급사는 항상 포함한다."""
res = Res_RenegotiationDecision()
err, row, current = await self._load_pending(company_id, session_id)
if err != ErrorType.SUCCESS:
res.result.SetResult(err)
return res
# 승인은 견적 작성자 본인 또는 최고관리자만 — 일반관리자는 남의 견적 처리 불가.
if not is_owner_or_admin(row[13], user_id, role):
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
return res
supplier_ids = list({str(row[2]), *(req.supplier_ids or [])})
created = await self.quotation_service.regenerate_quotation(
str(row[1]), company_id, supplier_ids, user_id=user_id, role=role, regen_label="재협상 요청 재생성"
)
if created.result.success is False:
res.result.SetResult(ErrorType.FAIL)
res.msg = created.msg or "다음 라운드 생성에 실패했습니다."
return res
patch = {
**current,
"status": RenegotiationStatus.APPROVED.value,
"decided_by": user_id,
"decided_at": GTime.UTC().isoformat(),
"memo": (req.memo or "").strip() or None,
"next_quotation_id": str(created.qt_id) if created.qt_id else None,
}
err = await self._save(session_id, patch)
if err != ErrorType.SUCCESS:
# 라운드는 이미 생겼는데 상태만 못 남긴 경우 — 로그로 남기고 성공으로 반환한다(재승인은 막힌다).
LOG.e_no_callstack(f"[renego] 승인 상태 기록 실패 session={session_id} next_qt={created.qt_id}")
res.session_id = session_id
res.status = RenegotiationStatus.APPROVED.value
res.next_quotation_id = str(created.qt_id) if created.qt_id else None
return res
async def reject(self, company_id: str, user_id: str, role: int, session_id: str, req: Req_RejectRenegotiation) -> Res_RenegotiationDecision:
res = Res_RenegotiationDecision()
err, row, current = await self._load_pending(company_id, session_id)
if err != ErrorType.SUCCESS:
res.result.SetResult(err)
return res
# 반려도 승인과 같은 소유권 규칙 — 견적 작성자 본인 또는 최고관리자만.
if not is_owner_or_admin(row[13], user_id, role):
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
return res
patch = {
**current,
"status": RenegotiationStatus.REJECTED.value,
"decided_by": user_id,
"decided_at": GTime.UTC().isoformat(),
"memo": (req.memo or "").strip() or None,
}
err = await self._save(session_id, patch)
if err != ErrorType.SUCCESS:
res.result.SetResult(err)
return res
res.session_id = session_id
res.status = RenegotiationStatus.REJECTED.value
return res
async def _load_pending(self, company_id: str, session_id: str):
"""심사 대상(대기 중) 요청 로드. (err, row, renegotiation dict)"""
try:
sid = uuid.UUID(session_id)
except (ValueError, TypeError):
return ErrorType.INVALID_REQUEST_DATA, None, {}
err, row = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.get_request(s, uuid.UUID(company_id), sid),
)
if err != ErrorType.SUCCESS or row is None:
return ErrorType.QUOTATION_NOT_FOUND, None, {}
current = (row[5] or {}).get("renegotiation") or {}
if current.get("status") != RenegotiationStatus.PENDING.value:
return ErrorType.INVALID_REQUEST_DATA, None, {}
return ErrorType.SUCCESS, row, current
async def _save(self, session_id: str, patch: dict) -> ErrorType:
return await DB_SESSION_MNG.execute_lambda_run(
[sessions.DBType()],
[lambda s: self.crud.merge_custom(s, uuid.UUID(session_id), {"renegotiation": patch})],
)
@staticmethod
def _to_data(row, user_id=None, role=None) -> RenegotiationData:
r = (row[5] or {}).get("renegotiation") or {}
return RenegotiationData(
can_act=is_owner_or_admin(row[13], user_id, role) if user_id is not None else True,
session_id=str(row[0]),
quotation_id=str(row[1]),
supplier_id=str(row[2]),
target_price=row[3],
bid_price=row[4],
qt_number=row[6] or "",
qt_round=row[7] or 0,
qt_name=row[8] or "",
close_reason=row[9],
item_id=str(row[15]) if row[15] else "",
item_name=row[10] or "",
supplier_name=row[11] or "",
owner_name=row[14] or "",
status=r.get("status") or 0,
reason=r.get("reason") or "",
desired_price=r.get("desired_price"),
requested_at=r.get("requested_at") or "",
decided_at=r.get("decided_at"),
decided_by_name=row[12] or None,
memo=r.get("memo"),
next_quotation_id=r.get("next_quotation_id"),
)