171 lines
7.4 KiB
Python
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"),
|
|
)
|