직접 낙찰(담당자 오프라인 계약가)을 AI 자동 낙찰과 통계·화면에서 구분하기 위해
낙찰 방식을 전용 컬럼으로 박고, 계약가·사유를 성격대로 배치했다. 처음엔 계약가·사유를
sessions.custom.offline_award(JSONB) 한 뭉치에 넣었으나 성격이 갈려 정리한다.
DB (postgres-init: init.sql 정본 + alters/2026-08-11-award-type.sql 보정, 멱등)
- quotations.award_type SMALLINT — 낙찰 방식(1=자동/2=직접). 통계 조회·집계 축이라 컬럼
- quotations.custom JSONB — 직접 낙찰 사유·처리자·시각(award={reason,by,at}). 표시·감사용
- sessions.contract_price BIGINT — 직접 낙찰 계약가. bid_price/reject_price 와 같은 협력사
가격 축이라 세션에. 자동 낙찰은 NULL(투찰가가 곧 계약가)
- 기존 offline_award(JSONB) 데이터를 컬럼·견적 custom 으로 이관 후 키 제거
backend
- 자동 낙찰(close_and_decide)=AUTO, 직접 낙찰(claim_for_award)=MANUAL 로 award_type 기록
- 직접 낙찰: 계약가→세션 contract_price, 사유·처리자·시각→견적 custom.award (한 트랜잭션)
- 통계 계약가 = coalesce(contract_price, bid_price) — JSONB 캐스팅 제거(컬럼끼리, 인덱스·타입 안전)
- QuotationData 에 award_type·custom, SessionData 에 contract_price 노출
- 안 쓰게 된 merge_session_custom 제거
front
- 견적 상세 결과 밴드에 '직접 낙찰' 배지 + 낙찰 사유 표시(견적 custom.award.reason).
거부·미참여 낙찰은 이미 직접 낙찰을 함의하므로 '— 낙찰' 꼬리를 떼 중복 표기 제거
- 목록 결과 배지에 '낙찰(직접)' 표기 — AI 자동낙찰과 한눈에 구분
- offlineAward()→directAwardPrice()/awardMeta() 로 교체(세션 컬럼·견적 custom 에서 읽음)
- 협상현황 표: 부가정보(회사 필드)와 의견(custom.opinion)을 별도 컬럼으로 분리
- 미응찰 건 레일 4번 칸 라벨 '최저 투찰가'→'결과'(투찰 없을 때)
테스트: negodata 110건 통과. 프론트 tsc+eslint 통과. dev DB 적용·화면 확인.
323 lines
13 KiB
Python
323 lines
13 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_FORBIDDEN = 403
|
||
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()
|
||
ACCOUNT_NOT_FOUND = auto()
|
||
ACCOUNT_FORBIDDEN = auto() # 최고관리자 외 접근 / 다른 회사·최고관리자 대상 변경 시도
|
||
|
||
# 상품 관련 에러
|
||
ITEM_NOT_FOUND = 1300
|
||
ITEM_CODE_DUPLICATE = auto()
|
||
|
||
# 협력사 관련 에러
|
||
SUPPLIER_NOT_FOUND = 1400
|
||
SUPPLIER_CODE_DUPLICATE = auto()
|
||
SUPPLIER_ITEM_NOT_FOUND = auto() # 협력사-상품 매핑 미존재
|
||
SUPPLIER_ACCOUNT_NOT_FOUND = auto() # 채팅(협상) 계정 미발급
|
||
SUPPLIER_ACCOUNT_ALREADY_EXISTS = auto() # 협력사당 1계정 — 이미 발급됨
|
||
SUPPLIER_ACCOUNT_LOGIN_ID_DUPLICATE = auto() # 로그인 ID 전역 중복(supplier.supplier_users.id)
|
||
|
||
# 견적 관련 에러
|
||
QUOTATION_NOT_FOUND = 1500
|
||
QUOTATION_NOT_LATEST_ROUND = auto() # 마지막 차수가 아닌 견적을 재생성하려 함
|
||
QUOTATION_TARGET_PRICE_UNAVAILABLE = auto() # md_price·인터넷최저가 둘 다 없어 목표가 산정 불가
|
||
|
||
# 견적 설정 관련 에러
|
||
QUOTATION_SETTING_NOT_FOUND = 1600
|
||
|
||
# 협상카드 관련 에러
|
||
CARD_NOT_FOUND = 1700
|
||
|
||
# 이미지 업로드 관련 에러
|
||
IMAGE_INVALID_TYPE = 1800
|
||
IMAGE_TOO_LARGE = auto()
|
||
IMAGE_UPLOAD_FAILED = auto()
|
||
|
||
# 초청 메일 발송 관련 에러
|
||
EMAIL_NOT_CONFIGURED = 1900 # ACS/SMTP 둘 다 미설정 — 발송 불가(설정 필요)
|
||
EMAIL_SEND_FAILED = auto() # 발송 시도했으나 전부 실패(수신자 0 성공)
|
||
|
||
# LPS(인터넷 최저가 검색) 연동 관련 에러
|
||
LPS_UNAVAILABLE = 2000 # lps_db 미등록(연동 비활성 환경) — 기능 사용 불가
|
||
LPS_REQUEST_FAILED = auto() # LPS 검색요청 API 호출 실패(LPS 다운/네트워크)
|
||
|
||
|
||
# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다.
|
||
EXCEPTION_FORBIDDEN = HTTPException(status_code=ErrorType.HTTP_FORBIDDEN.value, detail=ErrorType.HTTP_FORBIDDEN.name)
|
||
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
|
||
LPS = 2 # 인터넷 최저가 검색(lps_db) — 읽기전용(price_history 동기화 배치용, write 엔진 미등록)
|
||
|
||
|
||
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 코드값.
|
||
1=일반, 2=최고관리자(고객사 최상위), 3=개발자(우리 내부 운영 계정).
|
||
개발자 계정은 고객사에 존재를 노출하지 않는다 — 회원 목록에서 빼고 총계에도 넣지 않는다."""
|
||
|
||
USER = 1
|
||
OWNER = 2 # 최고관리자: 자기 회사 계정 관리 + 회사 설정
|
||
DEVELOPER = 3 # 개발자(내부 운영): 최고관리자 권한 전부 + 고객사에 보이지 않음
|
||
|
||
|
||
class CompanyStatus(CodeEnum):
|
||
"""companies.status 코드값."""
|
||
|
||
ACTIVE = 1
|
||
INACTIVE = 2
|
||
|
||
|
||
class SupplierUserStatus(CodeEnum):
|
||
"""supplier.supplier_users.status 코드값. 협력사의 채팅(협상) 로그인 계정 상태 —
|
||
루트 backend 로그인이 ACTIVE 만 허용하므로 INACTIVE 로 두면 접속이 차단된다."""
|
||
|
||
ACTIVE = 1
|
||
INACTIVE = 2
|
||
|
||
|
||
class SupplierUserRole(CodeEnum):
|
||
"""supplier.supplier_users.role 코드값(루트 backend 소유 코드 미러링)."""
|
||
|
||
USER = 1
|
||
MANAGER = 2
|
||
|
||
|
||
class QuotationType(CodeEnum):
|
||
"""quotations.type 코드값. 신규/재 × 협상(1:1)/견적(1:N).
|
||
1=재협상(1:1), 2=재견적(1:N), 3=신규협상(1:1), 4=신규견적(1:N).
|
||
기존 데이터 보존 위해 재협상/재견적 코드(1·2)는 고정, 신규는 3·4로 추가."""
|
||
|
||
RENEGO = 1 # 재협상(1:1)
|
||
REQUOTE = 2 # 재견적(1:N)
|
||
NEW_NEGO = 3 # 신규협상(1:1)
|
||
NEW_QUOTE = 4 # 신규견적(1:N)
|
||
|
||
@classmethod
|
||
def is_new(cls, code) -> bool:
|
||
"""신규(NEW_*) 견적유형이면 True. 목표가 후보(신규=인터넷최저가만)가 이 분기에 의존하므로 한 곳에서만 판단한다."""
|
||
return code in (cls.NEW_NEGO.value, cls.NEW_QUOTE.value)
|
||
|
||
@classmethod
|
||
def is_auction(cls, code) -> bool:
|
||
"""1:N 경매(REQUOTE/NEW_QUOTE)면 True. 경매는 낙찰 가격정책 없이 '무조건 최저가 낙찰'(mid=over=AWARD 강제).
|
||
나머지(RENEGO/NEW_NEGO)는 1:1 협상 — 사용자가 낙찰 기준을 정하고 협상카드가 발동한다."""
|
||
return code in (cls.REQUOTE.value, cls.NEW_QUOTE.value)
|
||
|
||
|
||
class QuotationStatus(CodeEnum):
|
||
"""quotations.status 코드값(SMALLINT). 프론트 견적상태 뱃지와 매핑된다."""
|
||
|
||
CREATED = 1
|
||
IN_PROGRESS = 2
|
||
CLOSED = 3
|
||
|
||
|
||
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" # 낙찰 확정(기준 충족 단독 최저가)
|
||
OPENED = "opened" # 개찰 — 낙찰자 미정으로 마감(자동 재협상/재생성·결렬 없음, 담당자 수동 처리)
|
||
CLOSED = "closed" # 그냥 마감 (선점 실패로 이미 닫혀 있던 경우 포함)
|
||
|
||
|
||
class CloseReason(CodeEnum):
|
||
"""quotations.close_reason 코드값(SMALLINT). 마감 사유 — 낙찰(AWARDED) 또는 개찰(OPEN_*)로 가른다.
|
||
개찰=결렬(유찰)이 아니라 '낙찰자 미정으로 마감' — 자동 재협상/재생성 없이 담당자가 수동 처리(수동 재생성 등)한다.
|
||
(구 자동재협상 사유 REGEN_*(2~4)·유찰 개념은 폐지. 사유 플래그 값 5~8은 보존해 OPEN_* 로 재명명.)"""
|
||
|
||
AWARDED = 1 # 낙찰 (기준 충족 단독 최저가)
|
||
OPEN_PRICE = 5 # 개찰: 최저가가 낙찰 기준 미달(목표 초과 등) → 낙찰자 미정
|
||
OPEN_EQUAL = 6 # 개찰: 동가(최저가 동점) → 낙찰자 미정
|
||
OPEN_NOSHOW = 7 # 개찰: 전원 미응찰
|
||
OPEN_REJECT = 8 # 개찰: 협상거부 존재
|
||
|
||
|
||
class AwardType(CodeEnum):
|
||
"""quotations.award_type 코드값(SMALLINT). 낙찰이 어떻게 확정됐는지 — 통계에서 AI 협상 성과와
|
||
담당자 오프라인 낙찰을 나눈다. 미마감·미낙찰(개찰)이면 NULL.
|
||
AUTO=시스템이 투찰가로 자동 낙찰 / MANUAL=담당자가 개찰 건을 오프라인 협상해 계약가로 직접 낙찰."""
|
||
|
||
AUTO = 1 # 자동 낙찰(close_and_decide, 투찰가 기준)
|
||
MANUAL = 2 # 직접 낙찰(담당자 계약가 입력 — sessions.custom.offline_award 와 짝)
|
||
|
||
|
||
class PriceGateAction(CodeEnum):
|
||
"""낙찰 기준(견적 단위) 가격게이트 판정값. '앵커링가<투찰가≤목표가'(mid) / '목표가<투찰가'(over) 구간에 적용.
|
||
(투찰가≤앵커링가 는 항상 낙찰.) 기준 미달이면 개찰(낙찰자 미정 마감) — 재협상·결렬 없음.
|
||
1:1 협상: over 는 항상 OPEN(목표 초과는 개찰), mid 만 앵커/목표 선택. 1:N 경매: mid=over=AWARD(무조건 최저가 낙찰)."""
|
||
|
||
AWARD = 1 # 낙찰(자동)
|
||
OPEN = 2 # 개찰(낙찰자 미정 마감)
|
||
|
||
|
||
class NotificationType(CodeEnum):
|
||
"""company.notifications.type 코드값. 견적 생애 이벤트를 작성자에게 통지. 마감 결과 3종(SUCCESS/REGENERATED/FAILURE)은 close_and_decide 와 1:1. 네이밍은 KTC."""
|
||
|
||
SUCCESS = 1 # 낙찰(단독 최저가) — KTC SUCCESS
|
||
REGENERATED = 2 # 다음 라운드 자동 생성(동가/미참여) — KTC 대응어 없어 negodata 유지
|
||
FAILURE = 3 # 결렬: 낙찰 없이 마감(거절/부분/한도) — KTC FAILURE
|
||
CREATED = 4 # 견적 생성됨(작성 직후) — 생성 알림
|
||
RENEGO_REQUESTED = 5 # 공급사가 재협상 요청(IMK #15) — 담당자가 승인/반려할 때까지 배너로 상시 노출
|
||
|
||
|
||
# 처리(승인·반려)하기 전에는 사라지지 않고 화면 하단 배너로 상시 노출되는 알림 유형.
|
||
# 단순 통지(SUCCESS/FAILURE 등)와 달리 담당자의 액션을 기다리는 건이라 읽음 처리만으로 닫지 않는다.
|
||
ACTION_REQUIRED_NOTIFICATIONS = {NotificationType.RENEGO_REQUESTED}
|
||
|
||
|
||
class RenegotiationStatus(CodeEnum):
|
||
"""sessions.custom.renegotiation.status — 공급사 재협상 요청 상태(IMK #15). 전용 테이블 없이 JSONB 에 둔다."""
|
||
|
||
PENDING = 1 # 접수, 담당자 심사 대기
|
||
APPROVED = 2 # 승인 — 다음 라운드 생성 완료
|
||
REJECTED = 3 # 반려 — 사유 기록
|
||
CANCELED = 4 # 공급사가 철회
|
||
|
||
|
||
class ChatSender(CodeEnum):
|
||
"""negotiation.chats.sender 코드값. 채팅 발신 주체."""
|
||
|
||
BOT = 1
|
||
USER = 2
|
||
|
||
|
||
class DeliveryType(CodeEnum):
|
||
"""items.delivery_type 코드값. 협상 채팅의 배송형태 선택지와 동일 집합."""
|
||
|
||
SUPPLIER = 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
|
||
|
||
|
||
class SupplierType(CodeEnum):
|
||
"""partner.supplier_items.supply_type 코드값. 없음(0,미지정)/유통(1)/제조(2)/총판(3).
|
||
없음은 상품-협력사 매핑에서 유형 미지정 상태를 뜻한다."""
|
||
|
||
NONE = 0 # 없음(미지정)
|
||
DISTRIBUTION = 1 # 유통
|
||
MANUFACTURE = 2 # 제조
|
||
SOLE_AGENCY = 3 # 총판
|
||
|
||
|
||
class LowestPriceWebsite(CodeEnum):
|
||
"""partner.item_internet_lowest_prices.website — 최저가 수집 사이트 코드.
|
||
LPS(lps_db.price_history.final_source)의 소스 문자열을 코드값으로 매핑한다."""
|
||
|
||
NAVER = 1
|
||
COUPANG = 2
|
||
GMARKET = 3
|
||
AUCTION = 4
|
||
ST11 = 5
|
||
ETC = 99
|
||
|
||
@classmethod
|
||
def from_source(cls, source) -> "LowestPriceWebsite":
|
||
return {
|
||
"naver": cls.NAVER, "coupang": cls.COUPANG,
|
||
"gmarket": cls.GMARKET, "auction": cls.AUCTION, "st11": cls.ST11,
|
||
}.get((source or "").lower(), cls.ETC)
|
||
|
||
|
||
class CardUsageType(CodeEnum):
|
||
"""nego_cards/wild_cards.usage_type 코드값. 협상카드 사용 범위(신규/재 견적·협상 양쪽 적용).
|
||
공통=모두 적용(기본), 신규견적전용, 재견적전용."""
|
||
|
||
COMMON = 1 # 공통(모두) — 기본
|
||
NEW = 2 # 신규견적전용
|
||
REUSE = 3 # 재견적전용
|