백엔드 close_and_decide 경로: - 동시 이중 마감 가드: 마감 판정 전 원자적 CLOSED 선점(claim)으로 두 크론 잡·수동마감 경합 직렬화 - 재생성 실패 표면화: regenerate_next_round 결과 검사 → 실패 시 REGEN_FAILED 반환(체인 끊김 은폐 방지) - 차수 충돌 방지: 다음 라운드 = 체인 최신 round+1(chain_max_round 기준) - 재생성 사유 집계 정밀화: 미참여/동가를 양성 표식으로 구분(단독낙찰·거부 오집계 제거) - 재생성 라운드 최소 협상기간 하한(즉시 재마감 캐스케이드 방지) - 잡 루프 per-item 예외 격리(한 건 실패가 배치 전체를 멈추지 않음) 프론트: - useChatController: 무권한 가드를 sessionId 별로 추적해 세션 변경 시 자연 해제 - useScrollLock: 마지막 해제를 rAF 로 지연해 재마운트 사이 일시적 잠금 해제 방지 - quotation 상세 쿼리 placeholderData 로 라운드 전환 중 시트 유지 테스트: - 신규 test_close_and_decide_fixes.py(동시성·차수·집계·기간 하한 검증) - conftest 결함 수정(존재하지 않는 tbl_account TRUNCATE 제거, companies.status 명시) - stale 테스트 갱신(test_quotation_create 를 타입드 Req/신규 응답 형식에 맞게 재작성) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
182 lines
5.5 KiB
Python
182 lines
5.5 KiB
Python
from enum import Enum, auto
|
|
|
|
from fastapi import HTTPException
|
|
|
|
|
|
class CodeEnum(Enum):
|
|
"""OpenAPI 스키마에 x-enum-varnames(멤버 이름)을 실어 orval 이 이름 있는 enum 을 생성하게 하는 베이스."""
|
|
|
|
@classmethod
|
|
def __get_pydantic_json_schema__(cls, core_schema, handler):
|
|
json_schema = handler(core_schema)
|
|
json_schema = handler.resolve_ref_schema(json_schema)
|
|
json_schema["x-enum-varnames"] = [m.name for m in cls]
|
|
return json_schema
|
|
|
|
|
|
class ErrorType(Enum):
|
|
"""서버 전역 결과 코드. Res_WebPacketProtocol.result 에 담겨 클라이언트로 전달된다.
|
|
HTTP status 와 겹치지 않도록 구간을 분리해서 관리한다.
|
|
"""
|
|
|
|
SUCCESS = 0
|
|
FAIL = 1
|
|
|
|
# DB / Redis 에러
|
|
DB_RUN_FAILED = 10
|
|
DB_ALREADY_SAME_KEY = auto()
|
|
DB_INVALID_KEY = auto()
|
|
DB_EMPTY_DATA = auto()
|
|
DB_INVALID_TYPE = auto()
|
|
|
|
# 요청/직렬화 에러
|
|
JSON_PARSE_ERROR = 100
|
|
INVALID_REQUEST_DATA = auto()
|
|
INTERNAL_EXCEPTION = auto()
|
|
|
|
# http 에러 코드와 겹치지 않게 설정 - router 전용 예외 발생 옵션
|
|
HTTP_INVALID_CLIENT_REQUEST = 419
|
|
HTTP_TO_MANY_REQUEST = 429
|
|
HTTP_INVALID_CLIENT_ACCESS = 433
|
|
HTTP_ACCESS_TOKEN_EXPIRED = 434
|
|
HTTP_REFRESH_TOKEN_EXPIRED = 435
|
|
HTTP_INVALID_TOKEN_ACCESS = 436
|
|
|
|
# 계정 관련 에러
|
|
ACCOUNT_INVALID_INFO = 1200
|
|
ACCOUNT_ALREADY_EXIST = auto()
|
|
ACCOUNT_BLOCKED_USER = auto()
|
|
|
|
# 상품 관련 에러
|
|
ITEM_NOT_FOUND = 1300
|
|
ITEM_CODE_DUPLICATE = auto()
|
|
|
|
# 협력사 관련 에러
|
|
SUPPLIER_NOT_FOUND = 1400
|
|
SUPPLIER_CODE_DUPLICATE = auto()
|
|
|
|
# 견적 관련 에러
|
|
QUOTATION_NOT_FOUND = 1500
|
|
QUOTATION_NOT_LATEST_ROUND = auto() # 마지막 차수가 아닌 견적을 재생성하려 함
|
|
|
|
# 견적 설정 관련 에러
|
|
QUOTATION_SETTING_NOT_FOUND = 1600
|
|
|
|
# 협상카드 관련 에러
|
|
CARD_NOT_FOUND = 1700
|
|
|
|
# 이미지 업로드 관련 에러
|
|
IMAGE_INVALID_TYPE = 1800
|
|
IMAGE_TOO_LARGE = auto()
|
|
IMAGE_UPLOAD_FAILED = auto()
|
|
|
|
|
|
# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다.
|
|
EXCEPTION_INVALID_CLIENT_REQUEST = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_REQUEST.value, detail=ErrorType.HTTP_INVALID_CLIENT_REQUEST.name)
|
|
EXCEPTION_TO_MANY_REQUEST = HTTPException(status_code=ErrorType.HTTP_TO_MANY_REQUEST.value, detail=ErrorType.HTTP_TO_MANY_REQUEST.name)
|
|
EXCEPTION_INVALID_CLIENT_ACCESS = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_ACCESS.value, detail=ErrorType.HTTP_INVALID_CLIENT_ACCESS.name)
|
|
EXCEPTION_ACCESS_TOKEN_EXPIRED = HTTPException(status_code=ErrorType.HTTP_ACCESS_TOKEN_EXPIRED.value, detail=ErrorType.HTTP_ACCESS_TOKEN_EXPIRED.name)
|
|
EXCEPTION_REFRESH_TOKEN_EXPIRED = HTTPException(status_code=ErrorType.HTTP_REFRESH_TOKEN_EXPIRED.value, detail=ErrorType.HTTP_REFRESH_TOKEN_EXPIRED.name)
|
|
EXCEPTION_HTTP_INVALID_TOKEN_ACCESS = HTTPException(status_code=ErrorType.HTTP_INVALID_TOKEN_ACCESS.value, detail=ErrorType.HTTP_INVALID_TOKEN_ACCESS.name)
|
|
|
|
|
|
class DBType(Enum):
|
|
"""논리 DB 구분. 모델마다 DBType() 으로 자신이 속한 DB 를 반환한다.
|
|
DB 가 늘어나면 여기에 추가하고 db_session_manager 의 맵에 등록만 하면 된다.
|
|
"""
|
|
|
|
MAIN = 1
|
|
|
|
|
|
class DBWRType(Enum):
|
|
"""Read / Write 접속 구분. 조회는 DB_READ, 변경은 DB_WRITE 엔진을 사용한다."""
|
|
|
|
DB_READ = 1
|
|
DB_WRITE = 2
|
|
|
|
|
|
# 도메인 코드값
|
|
class UserStatus(CodeEnum):
|
|
"""users.status 코드값."""
|
|
|
|
ACTIVE = 1
|
|
INACTIVE = 2
|
|
|
|
|
|
class UserRole(CodeEnum):
|
|
"""users.role 코드값."""
|
|
|
|
USER = 1
|
|
MANAGER = 2
|
|
|
|
|
|
class CompanyStatus(CodeEnum):
|
|
"""companies.status 코드값."""
|
|
|
|
ACTIVE = 1
|
|
INACTIVE = 2
|
|
|
|
|
|
class QuotationType(CodeEnum):
|
|
"""quotations.type 코드값. 1=renego(재협상 1:1), 2=requote(재견적 1:N)."""
|
|
|
|
RENEGO = 1
|
|
REQUOTE = 2
|
|
|
|
|
|
class QuotationStatus(CodeEnum):
|
|
"""quotations.status 코드값(SMALLINT). 프론트 견적상태 뱃지와 매핑된다."""
|
|
|
|
CREATED = 1
|
|
ACTIVE = 2
|
|
CLOSED = 3
|
|
ON_HOLD = 4
|
|
|
|
|
|
class SessionStatus(CodeEnum):
|
|
"""negotiation.sessions.status 코드값. 협력사별 협상 세션 진행 상태."""
|
|
|
|
CREATED = 1
|
|
IN_PROGRESS = 2
|
|
DONE = 3
|
|
NOT_PARTICIPATED = 4
|
|
REJECTED = 5
|
|
|
|
|
|
class CloseOutcome(Enum):
|
|
"""견적 마감 판정 결과(close_and_decide 반환값). 내부 제어·로그용 — DB 저장/프론트 노출 안 함."""
|
|
|
|
AWARDED = "awarded" # 단독 낙찰 확정
|
|
REGENERATED = "regenerated" # 다음 라운드 재생성
|
|
CLOSED = "closed" # 그냥 마감 (선점 실패로 이미 닫혀 있던 경우 포함)
|
|
REGEN_FAILED = "regen_failed" # 재생성 시도했으나 실패 — 원본은 CLOSED 인데 다음 라운드가 없음(체인 끊김, 모니터링 필요)
|
|
|
|
|
|
class ChatSender(CodeEnum):
|
|
"""negotiation.chats.sender 코드값. 채팅 발신 주체."""
|
|
|
|
BOT = 1
|
|
USER = 2
|
|
|
|
|
|
class DeliveryType(CodeEnum):
|
|
"""items.delivery_type 코드값. 협상 채팅의 배송형태 선택지와 동일 집합."""
|
|
|
|
PARTNER = 1 # 협력사배송
|
|
COURIER = 2 # 지정택배배송
|
|
PICKUP = 3 # 픽업배송
|
|
|
|
|
|
class CardStatus(CodeEnum):
|
|
"""nego_cards.status 코드값. 와일드카드의 협상 적용 여부(수동 승인). 일반 협상카드는 상시 ACTIVE."""
|
|
|
|
ACTIVE = 1
|
|
INACTIVE = 2
|
|
|
|
|
|
class CardType(CodeEnum):
|
|
"""negotiation.chats.card_type / quotation_cards.type 코드값. 1=nego_card, 2=wild_card."""
|
|
|
|
NEGO = 1
|
|
WILD = 2
|