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"), )