[feat] negodata: 결렬 폐지→개찰 마감 개편 + 낙찰기준 견적 단위 이관 + 세팅 컬럼 정리 (백엔드+DB)
Co-Authored-By: Claude Opus 4.8뀨 <noreply@anthropic.com>
This commit is contained in:
parent
1a7674b1e3
commit
38709b3098
@ -190,13 +190,9 @@ class quotation_settings(MainTableMixin, MAIN_BASE):
|
||||
|
||||
qt_setting_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 설정 소유 유저
|
||||
target_margin_rate = Column(Numeric(8, 6), nullable=False)
|
||||
anchoring_value = Column(Numeric(8, 6), nullable=False, default=0.01)
|
||||
target_margin_rate = Column(Numeric(8, 6), nullable=False) # 목표 마진율(목표가 산정에 사용)
|
||||
card_count = Column(Integer, nullable=False, default=3)
|
||||
# 마감 가격정책. 투찰가≤앵커링가는 항상 낙찰(설정없음). 아래 두 구간만 회사가 정한다. 기본 AWARD=현재 '무조건 낙찰' 동작(하위호환).
|
||||
mid_action = Column(SmallInteger, nullable=False, default=1) # PriceGateAction: 앵커링가<투찰가≤목표가 처리(1=낙찰/2=재협상/3=유찰)
|
||||
over_action = Column(SmallInteger, nullable=False, default=1) # PriceGateAction: 목표가<투찰가 처리(1=낙찰/2=재협상/3=유찰)
|
||||
regen_limit = Column(SmallInteger, nullable=False, default=1) # 재생성 최대 횟수(체인 전체 총합, 사유 무관: 목표초과/동가/미참여 합산)
|
||||
# 낙찰 가격정책(mid/over/regen)은 견적 단위로 이관, 앵커링은 칸 rate(anchoring v1.2)로 대체 → 세팅 컬럼 제거됨.
|
||||
|
||||
|
||||
class quotations(MainTableMixin, MAIN_BASE):
|
||||
@ -231,6 +227,11 @@ class quotations(MainTableMixin, MAIN_BASE):
|
||||
equal_bid_data = Column(JSONB, nullable=True)
|
||||
close_reason = Column(SmallInteger, nullable=True) # CloseReason 코드. 마감 시 사유 기록(재생성 한도 카운팅·유찰 사유 구분). 미마감이면 NULL
|
||||
|
||||
# 낙찰 기준(가격게이트) — 견적 단위. 마감 판정(close_and_decide)이 이 행값을 읽는다. 기준 미달이면 개찰(낙찰자 미정 마감).
|
||||
# 1:1 협상: over 는 항상 OPEN(목표 초과=개찰), mid 만 앵커/목표 택1. 1:N 경매: mid=over=AWARD 강제(무조건 최저가 낙찰).
|
||||
mid_action = Column(SmallInteger, nullable=False, server_default=text("1"), default=1) # PriceGateAction: 앵커링가<투찰가≤목표가 처리(1=낙찰/2=개찰)
|
||||
over_action = Column(SmallInteger, nullable=False, server_default=text("1"), default=1) # PriceGateAction: 목표가<투찰가 처리(1=낙찰/2=개찰)
|
||||
|
||||
|
||||
class sessions(MainTableMixin, MAIN_BASE):
|
||||
__tablename__ = "sessions"
|
||||
|
||||
@ -143,6 +143,12 @@ class QuotationType(CodeEnum):
|
||||
"""신규(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). 프론트 견적상태 뱃지와 매핑된다."""
|
||||
@ -165,33 +171,30 @@ class SessionStatus(CodeEnum):
|
||||
class CloseOutcome(Enum):
|
||||
"""견적 마감 판정 결과(close_and_decide 반환값). 내부 제어·로그용 — DB 저장/프론트 노출 안 함."""
|
||||
|
||||
AWARDED = "awarded" # 단독 낙찰 확정
|
||||
REGENERATED = "regenerated" # 다음 라운드 재생성
|
||||
AWARDED = "awarded" # 낙찰 확정(기준 충족 단독 최저가)
|
||||
OPENED = "opened" # 개찰 — 낙찰자 미정으로 마감(자동 재협상/재생성·결렬 없음, 담당자 수동 처리)
|
||||
CLOSED = "closed" # 그냥 마감 (선점 실패로 이미 닫혀 있던 경우 포함)
|
||||
REGEN_FAILED = "regen_failed" # 재생성 시도했으나 실패 — 원본은 CLOSED 인데 다음 라운드가 없음(체인 끊김, 모니터링 필요)
|
||||
|
||||
|
||||
class CloseReason(CodeEnum):
|
||||
"""quotations.close_reason 코드값(SMALLINT). 마감 사유 — 재생성 한도 카운팅(REGEN_*)과 유찰 사유 구분에 쓴다.
|
||||
기존 preferred_sp_yn/equal_bid_yn 2플래그로는 4상태만 표현돼 '목표초과 재협상'이 미참여와 충돌하고 유찰 사유가 뭉개짐 → 이 컬럼으로 명시."""
|
||||
"""quotations.close_reason 코드값(SMALLINT). 마감 사유 — 낙찰(AWARDED) 또는 개찰(OPEN_*)로 가른다.
|
||||
개찰=결렬(유찰)이 아니라 '낙찰자 미정으로 마감' — 자동 재협상/재생성 없이 담당자가 수동 처리(수동 재생성 등)한다.
|
||||
(구 자동재협상 사유 REGEN_*(2~4)·유찰 개념은 폐지. 사유 플래그 값 5~8은 보존해 OPEN_* 로 재명명.)"""
|
||||
|
||||
AWARDED = 1 # 단독 낙찰
|
||||
REGEN_PRICE = 2 # 가격 사유 재협상 (단독 최저가가 가격게이트 초과 → 낙찰 대신 다음 라운드로 더 깎기. 대표: 목표초과 재협상)
|
||||
REGEN_EQUAL = 3 # 동가 재입찰
|
||||
REGEN_NOSHOW = 4 # 미참여 재소집
|
||||
FAIL_PRICE = 5 # 가격 사유 유찰 (가격게이트 초과인데 재협상 안 함/한도 소진)
|
||||
FAIL_EQUAL = 6 # 동가 유찰
|
||||
FAIL_NOSHOW = 7 # 미참여 유찰 (한도 소진)
|
||||
FAIL_REJECT = 8 # 거부 유찰 (협상거부 존재)
|
||||
AWARDED = 1 # 낙찰 (기준 충족 단독 최저가)
|
||||
OPEN_PRICE = 5 # 개찰: 최저가가 낙찰 기준 미달(목표 초과 등) → 낙찰자 미정
|
||||
OPEN_EQUAL = 6 # 개찰: 동가(최저가 동점) → 낙찰자 미정
|
||||
OPEN_NOSHOW = 7 # 개찰: 전원 미응찰
|
||||
OPEN_REJECT = 8 # 개찰: 협상거부 존재
|
||||
|
||||
|
||||
class PriceGateAction(CodeEnum):
|
||||
"""quotation_settings 의 가격 구간별 처리 정책. '앵커링가<투찰가≤목표가'(mid) / '목표가<투찰가'(over) 구간에 적용.
|
||||
(투찰가≤앵커링가 는 항상 낙찰이라 설정 없음.)"""
|
||||
"""낙찰 기준(견적 단위) 가격게이트 판정값. '앵커링가<투찰가≤목표가'(mid) / '목표가<투찰가'(over) 구간에 적용.
|
||||
(투찰가≤앵커링가 는 항상 낙찰.) 기준 미달이면 개찰(낙찰자 미정 마감) — 재협상·결렬 없음.
|
||||
1:1 협상: over 는 항상 OPEN(목표 초과는 개찰), mid 만 앵커/목표 선택. 1:N 경매: mid=over=AWARD(무조건 최저가 낙찰)."""
|
||||
|
||||
AWARD = 1 # 낙찰
|
||||
RENEGO = 2 # 재협상(다음 라운드 재생성)
|
||||
FAIL = 3 # 유찰
|
||||
AWARD = 1 # 낙찰(자동)
|
||||
OPEN = 2 # 개찰(낙찰자 미정 마감)
|
||||
|
||||
|
||||
class NotificationType(CodeEnum):
|
||||
|
||||
@ -425,15 +425,11 @@ class QuotationCRUD(IQuotationCRUD):
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
|
||||
async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]:
|
||||
"""견적 세팅의 율+마감정책: {margin, anchoring, mid_action, over_action, regen_limit}.
|
||||
목표가·앵커링가 산정 입력 + 마감 가격게이트 정책. (인터넷 수수료는 상수)"""
|
||||
"""견적 세팅의 목표 마진율: {margin}. 목표가 산정 입력(인터넷 수수료는 상수).
|
||||
(낙찰 정책은 견적 단위 이관, 앵커링은 칸 rate v1.2 → 세팅 컬럼 제거됨.)"""
|
||||
try:
|
||||
query = select(
|
||||
quotation_settings.target_margin_rate,
|
||||
quotation_settings.anchoring_value,
|
||||
quotation_settings.mid_action,
|
||||
quotation_settings.over_action,
|
||||
quotation_settings.regen_limit,
|
||||
).where(quotation_settings.qt_setting_id == qt_setting_id).limit(1)
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
@ -443,10 +439,6 @@ class QuotationCRUD(IQuotationCRUD):
|
||||
r = rows[0]
|
||||
return ErrorType.SUCCESS, {
|
||||
"margin": float(r[0]) if r[0] is not None else None,
|
||||
"anchoring": float(r[1]) if r[1] is not None else None,
|
||||
"mid_action": r[2],
|
||||
"over_action": r[3],
|
||||
"regen_limit": r[4],
|
||||
}
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
|
||||
@ -30,6 +30,10 @@ class Req_CreateQuotation(QuotationProtocol):
|
||||
item_ids: list[uuid.UUID] = [] # 협상 대상 상품. item×supplier 조합마다 세션 1개 생성
|
||||
supplier_ids: list[uuid.UUID] = [] # 협상 초청 공급사
|
||||
card_ids: list[uuid.UUID] = [] # 선택 협상카드. 버전을 만들어 묶고 quotation.version_id 로 연결
|
||||
# 낙찰 기준 — 1:1 협상만 프론트가 2전략(앵커까지/목표까지)을 mid/over 로 전개해 전송(over 는 항상 OPEN).
|
||||
# 1:N 경매는 미전송 → 서버가 mid=over=AWARD 강제('무조건 최저가 낙찰').
|
||||
mid_action: Optional[int] = None # PriceGateAction: 앵커링가<투찰가≤목표가 처리
|
||||
over_action: Optional[int] = None # PriceGateAction: 목표가<투찰가 처리
|
||||
|
||||
|
||||
class Req_RegenerateQuotation(QuotationProtocol):
|
||||
@ -63,6 +67,8 @@ class QuotationData(WebPacketProtocol):
|
||||
equal_bid_yn: Optional[bool] = None
|
||||
equal_bid_data: Optional[Any] = None
|
||||
close_reason: Optional[CloseReason] = None # 마감 사유(CloseReason). 미마감이면 None
|
||||
mid_action: Optional[int] = None # 낙찰 기준(견적 단위). 상세 드로어 낙찰기준 표시용
|
||||
over_action: Optional[int] = None
|
||||
participation_count: int = 0 # 견적별 참여 협력사 수(세션 distinct supplier). 목록 집계로 채움.
|
||||
item_id: Optional[uuid.UUID] = None # 대표 상품 id(세션의 첫 item). 목록 조인으로 채움.
|
||||
item_name: Optional[str] = None # 대표 상품명. 목록 조인으로 채움.
|
||||
@ -199,7 +205,6 @@ class Res_TargetBreakdown(Res_WebPacketProtocol):
|
||||
selling: Optional[int] = None
|
||||
fee: float = 0.0
|
||||
margin: float = 0.0
|
||||
anchoring_value: float = 0.0
|
||||
candidates: list[TargetCandidate] = []
|
||||
chosen_basis: Optional[str] = None
|
||||
target_price: int = 0
|
||||
|
||||
@ -4,7 +4,6 @@ from typing import Optional
|
||||
|
||||
from pydantic import ConfigDict
|
||||
|
||||
from common.enums import PriceGateAction
|
||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
|
||||
@ -12,22 +11,15 @@ class QuotationSettingProtocol(WebPacketProtocol):
|
||||
pass
|
||||
|
||||
|
||||
# 세팅은 목표 마진율·카드수만. 낙찰 정책(mid/over/regen)은 견적 단위 이관, 앵커링은 칸 rate(v1.2)로 대체 → 컬럼 제거됨.
|
||||
class Req_CreateQuotationSetting(QuotationSettingProtocol):
|
||||
target_margin_rate: float
|
||||
anchoring_value: float = 0.01
|
||||
card_count: int = 3
|
||||
mid_action: PriceGateAction = PriceGateAction.AWARD # 앵커링가<투찰가≤목표가 마감 처리
|
||||
over_action: PriceGateAction = PriceGateAction.AWARD # 목표가<투찰가 마감 처리
|
||||
regen_limit: int = 1 # 재생성 최대 횟수(체인 전체, 사유 무관)
|
||||
|
||||
|
||||
class Req_UpdateQuotationSetting(QuotationSettingProtocol):
|
||||
target_margin_rate: Optional[float] = None
|
||||
anchoring_value: Optional[float] = None
|
||||
card_count: Optional[int] = None
|
||||
mid_action: Optional[PriceGateAction] = None
|
||||
over_action: Optional[PriceGateAction] = None
|
||||
regen_limit: Optional[int] = None
|
||||
|
||||
|
||||
class QuotationSettingData(WebPacketProtocol):
|
||||
@ -36,11 +28,7 @@ class QuotationSettingData(WebPacketProtocol):
|
||||
qt_setting_id: uuid.UUID
|
||||
user_id: Optional[uuid.UUID] = None
|
||||
target_margin_rate: float
|
||||
anchoring_value: float
|
||||
card_count: int
|
||||
mid_action: PriceGateAction = PriceGateAction.AWARD
|
||||
over_action: PriceGateAction = PriceGateAction.AWARD
|
||||
regen_limit: int = 1
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
@ -31,15 +31,15 @@ async def _close_each(service: QuotationService, qt_ids) -> Counter:
|
||||
|
||||
def _format_results(results: Counter) -> str:
|
||||
return (
|
||||
f"낙찰 {results[CloseOutcome.AWARDED]} / 재생성 {results[CloseOutcome.REGENERATED]} / "
|
||||
f"재생성실패 {results[CloseOutcome.REGEN_FAILED]} / 마감 {results[CloseOutcome.CLOSED]} / 오류 {results['error']}"
|
||||
f"낙찰 {results[CloseOutcome.AWARDED]} / 개찰 {results[CloseOutcome.OPENED]} / "
|
||||
f"마감 {results[CloseOutcome.CLOSED]} / 오류 {results['error']}"
|
||||
)
|
||||
|
||||
|
||||
async def close_expired_quotations() -> int:
|
||||
"""[잡①] 마감일이 지난 견적을 자동 마감 처리한다. 하루 한 번 실행.
|
||||
대상: 마감 시각이 이미 지났는데 아직 마감되지 않은(삭제되지도 않은) 견적.
|
||||
처리: 견적마다 close_and_decide 로 결과 판정(낙찰 확정 / 다음 라운드 재생성 / 그냥 마감).
|
||||
처리: 견적마다 close_and_decide 로 결과 판정(낙찰 확정 / 개찰=낙찰자 미정 마감).
|
||||
반환: 처리한 견적 수."""
|
||||
crud = QuotationCRUD()
|
||||
service = QuotationService(crud)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
-- 견적상세 UI 케이스별 데모 시드 (dev DB: negosium_db)
|
||||
-- 안전: 아래 데모 qt_id / 그 세션만 지우고 다시 넣는다(다른 데이터 무손상). 재실행 가능.
|
||||
-- user=모모나(b874f33d) / setting=40614764 / version=...030 / item=고압 에어 컴프레서(a3d1fff5)
|
||||
-- close_reason(CloseReason): 1=낙찰,2=가격재협상,3=동가재입찰,4=미참여재소집,5=가격유찰,6=동가유찰,7=미참여유찰,8=거부유찰
|
||||
-- close_reason(CloseReason): 1=낙찰, 5=가격개찰, 6=동가개찰, 7=미응찰개찰, 8=거부개찰 (개찰=낙찰자 미정 마감, 결렬 아님)
|
||||
BEGIN;
|
||||
|
||||
DELETE FROM negotiation.sessions WHERE quotation_id IN (
|
||||
@ -29,7 +29,7 @@ DELETE FROM quotation.quotations WHERE qt_id IN (
|
||||
'aaaa0010-0000-0000-0000-000000000010'
|
||||
);
|
||||
|
||||
-- ── 견적 10건 (close_reason 8종 전부 + 진행중) ────────────────────────────
|
||||
-- ── 견적 10건 (close_reason 5종: 낙찰+개찰4 + 진행중) ────────────────────────────
|
||||
INSERT INTO quotation.quotations
|
||||
(qt_id, user_id, qt_setting_id, version_id, name, number, type, round, status,
|
||||
start_time, end_time, manager_name, manager_email, manager_contact_number, memo,
|
||||
@ -53,25 +53,25 @@ VALUES
|
||||
-- ③ 동가 재입찰(REGEN_EQUAL): 두 곳 390,000 동률 → 재입찰 진행
|
||||
('aaaa0003-0000-0000-0000-000000000003','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||
'③ 동가 재입찰 데모','EST-DEMO-03',2,1,3,
|
||||
'③ 동가 개찰 데모','EST-DEMO-03',2,1,3,
|
||||
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','동가 발생·다음 라운드 재입찰 케이스',
|
||||
1, false, null, null, true,
|
||||
'[{"supplier_id":"9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09","supplier_name":"(주)대한정밀","bid_price":390000},{"supplier_id":"747897ec-adab-4f3e-9076-15803ddf821f","supplier_name":"글로벌테크놀로지","bid_price":390000}]'::jsonb,
|
||||
3, 400000, 1, now(), now(), false),
|
||||
6, 400000, 1, now(), now(), false),
|
||||
-- ④ 거부 유찰(FAIL_REJECT): 협상거부로 마감
|
||||
('aaaa0004-0000-0000-0000-000000000004','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||
'④ 거부 유찰 데모','EST-DEMO-04',2,1,3,
|
||||
'④ 거부 개찰 데모','EST-DEMO-04',2,1,3,
|
||||
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','협상 거부로 유찰 케이스',
|
||||
1, null, null, null, null, null,
|
||||
8, 400000, 1, now(), now(), false),
|
||||
-- ⑤ 미참여 재소집(REGEN_NOSHOW): 전원 미참여 → 다음 라운드 재소집
|
||||
('aaaa0005-0000-0000-0000-000000000005','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||
'⑤ 미참여 재소집 데모','EST-DEMO-05',1,1,3,
|
||||
'⑤ 미응찰 개찰 데모','EST-DEMO-05',1,1,3,
|
||||
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','전원 미참여·다음 라운드 재소집 케이스',
|
||||
1, false, null, null, false, null,
|
||||
4, 400000, 1, now(), now(), false),
|
||||
7, 400000, 1, now(), now(), false),
|
||||
-- ⑥ 진행중: 일부 투찰 완료, 아직 안 닫힘 (close_reason NULL)
|
||||
('aaaa0006-0000-0000-0000-000000000006','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||
@ -82,21 +82,21 @@ VALUES
|
||||
-- ⑦ 가격 재협상(REGEN_PRICE): 단독 최저가 420,000 > 목표 350,000 → 재협상 진행
|
||||
('aaaa0007-0000-0000-0000-000000000007','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||
'⑦ 가격 재협상 데모','EST-DEMO-07',4,1,3,
|
||||
'⑦ 목표초과 개찰 데모','EST-DEMO-07',4,1,3,
|
||||
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','목표 초과 → 재협상(더 깎기) 진행 케이스',
|
||||
1, false, null, null, false, null,
|
||||
2, 350000, 1, now(), now(), false),
|
||||
5, 350000, 1, now(), now(), false),
|
||||
-- ⑧ 가격 유찰(FAIL_PRICE): 단독 최저가 430,000 > 목표 350,000, over_action=유찰(또는 한도소진) → 유찰
|
||||
('aaaa0008-0000-0000-0000-000000000008','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||
'⑧ 가격 유찰 데모','EST-DEMO-08',4,1,3,
|
||||
'⑧ 목표초과 개찰 데모(2)','EST-DEMO-08',4,1,3,
|
||||
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','목표 초과 → 유찰 케이스',
|
||||
1, false, null, null, false, null,
|
||||
5, 350000, 1, now(), now(), false),
|
||||
-- ⑨ 동가 유찰(FAIL_EQUAL): 동가지만 재입찰 한도 소진/유찰 정책 → 유찰
|
||||
('aaaa0009-0000-0000-0000-000000000009','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||
'⑨ 동가 유찰 데모','EST-DEMO-09',2,2,3,
|
||||
'⑨ 동가 개찰 데모(2)','EST-DEMO-09',2,2,3,
|
||||
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','동가지만 재입찰 한도 소진 → 유찰 케이스',
|
||||
1, false, null, null, true,
|
||||
'[{"supplier_id":"9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09","supplier_name":"(주)대한정밀","bid_price":405000},{"supplier_id":"69e7057a-5919-48cd-b329-13b833bff994","supplier_name":"서울산업소재(주)","bid_price":405000}]'::jsonb,
|
||||
@ -104,7 +104,7 @@ VALUES
|
||||
-- ⑩ 미참여 유찰(FAIL_NOSHOW): 전원 미참여 + 재소집 한도 소진 → 유찰
|
||||
('aaaa0010-0000-0000-0000-000000000010','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||
'⑩ 미참여 유찰 데모','EST-DEMO-10',1,2,3,
|
||||
'⑩ 미응찰 개찰 데모(2)','EST-DEMO-10',1,2,3,
|
||||
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','전원 미참여 + 재소집 한도 소진 → 유찰 케이스',
|
||||
1, false, null, null, false, null,
|
||||
7, 400000, 1, now(), now(), false);
|
||||
|
||||
@ -54,9 +54,6 @@ class QuotationService:
|
||||
# 기본 전략 버전(card.versions 시드). 견적 생성 시 version_id 미지정이면 이 값으로 채운다.
|
||||
DEFAULT_VERSION_ID = uuid.UUID("00000000-0000-0000-0000-000000000030")
|
||||
|
||||
# 재생성 한도: 한 체인(같은 견적번호)에서 사유(미참여/동가)별 최대 1번까지 재생성(순서 무관, 같은 사유 2번 불가).
|
||||
MAX_REGEN_PER_CAUSE = 1
|
||||
|
||||
# 재생성 라운드의 최소 협상기간(방어적 하한). 원본 협상기간이 비정상적으로 짧으면(또는 0/음수면)
|
||||
# 새 라운드가 생성 즉시 만료돼 다음 크론 tick(*/5분)에 또 마감되는 연쇄를 막는다.
|
||||
# 정상 견적(수 시간~수일)은 원본 기간을 그대로 쓰며, 이 하한은 비정상적으로 짧은 경우에만 적용된다.
|
||||
@ -168,7 +165,6 @@ class QuotationService:
|
||||
rates = rates or {}
|
||||
fee = self.INTERNET_AVERAGE_FEE
|
||||
margin = rates.get("margin") or 0.0
|
||||
anchoring = rates.get("anchoring") or 0.0
|
||||
is_new = QuotationType.is_new(quotation.type)
|
||||
md = quotation.md_price
|
||||
|
||||
@ -187,7 +183,6 @@ class QuotationService:
|
||||
res.selling = int(selling) if selling is not None else None
|
||||
res.fee = fee
|
||||
res.margin = margin
|
||||
res.anchoring_value = anchoring
|
||||
res.candidates = [TargetCandidate(basis=b, label=self._CANDIDATE_LABELS.get(b, b), value=int(v)) for b, v in cands]
|
||||
res.chosen_basis = None if is_inherited else chosen_basis
|
||||
res.target_price = sess.target_price
|
||||
@ -298,6 +293,8 @@ class QuotationService:
|
||||
item_ids=req.item_ids,
|
||||
supplier_ids=req.supplier_ids,
|
||||
card_ids=req.card_ids,
|
||||
mid_action=req.mid_action,
|
||||
over_action=req.over_action,
|
||||
)
|
||||
if res.result.success:
|
||||
await create_notification(
|
||||
@ -375,6 +372,8 @@ class QuotationService:
|
||||
item_ids=item_ids,
|
||||
supplier_ids=list(supplier_ids),
|
||||
card_ids=[], # 새 버전 안 만듦(원본 version_id 재사용)
|
||||
mid_action=original.mid_action, # 낙찰 기준 상속(타입이 REQUOTE 로 바뀌면 빌더가 AWARD 로 재정규화)
|
||||
over_action=original.over_action,
|
||||
inherited=inherited, # 직전 라운드 목표가 상속(앵커링가는 현재 rate 로 재계산)
|
||||
)
|
||||
|
||||
@ -384,11 +383,21 @@ class QuotationService:
|
||||
type_: int, status: int, round_: int, start_time, end_time,
|
||||
manager_name, manager_email, manager_contact_number, memo, md_price, supplier_type,
|
||||
item_ids: list, supplier_ids: list, card_ids: list,
|
||||
mid_action: Optional[int] = None, # 낙찰 기준(견적 단위). 앵커링가<투찰가≤목표가 처리(AWARD/OPEN)
|
||||
over_action: Optional[int] = None, # 목표가<투찰가 처리(1:1 협상은 항상 OPEN)
|
||||
inherited: Optional[dict] = None, # 재생성 시 {item_id: target_price} 상속(KTC) — 목표가만. 앵커는 항상 재계산
|
||||
) -> Res_CreateQuotation:
|
||||
"""견적 1건 + (상품×공급사) 세션들을 한 트랜잭션으로 생성하는 공통 빌더."""
|
||||
res = Res_CreateQuotation()
|
||||
|
||||
# 낙찰 기준 정규화 — 1:N 경매는 항상 최저가 낙찰(mid=over=AWARD 강제). 1:1 협상은 요청값(미지정=AWARD).
|
||||
# create/regenerate 양 경로가 이 빌더를 타므로 불변식을 여기 한 곳에서 강제한다(재생성 시 타입 전환도 자동 재정규화).
|
||||
if QuotationType.is_auction(type_):
|
||||
mid_action = over_action = PriceGateAction.AWARD.value
|
||||
else:
|
||||
mid_action = mid_action or PriceGateAction.AWARD.value
|
||||
over_action = over_action or PriceGateAction.AWARD.value
|
||||
|
||||
# 세션 목표가 입력(상품별 인터넷최저가/매입가/판매가 + 세팅 율). 읽기 트랜잭션에서 먼저 조회.
|
||||
prices = {}
|
||||
if item_ids:
|
||||
@ -455,6 +464,8 @@ class QuotationService:
|
||||
memo=memo,
|
||||
md_price=md_price,
|
||||
supplier_type=supplier_type,
|
||||
mid_action=mid_action,
|
||||
over_action=over_action,
|
||||
)
|
||||
|
||||
# 상품 × 공급사 조합마다 세션 1개. md/매입/판매/인터넷 후보가 하나도 없으면 목표가 산정 불가 → 생성 실패.
|
||||
@ -557,11 +568,11 @@ class QuotationService:
|
||||
|
||||
@staticmethod
|
||||
def _gate_action(bid, target, anchor, mid_action, over_action) -> int:
|
||||
"""가격게이트 판정 → PriceGateAction 코드.
|
||||
"""가격게이트 판정 → PriceGateAction 코드(AWARD=낙찰 / OPEN=개찰).
|
||||
bid ≤ 앵커링가 → 무조건 낙찰(AWARD)
|
||||
앵커링가 < bid ≤ 목표가 → mid_action(설정)
|
||||
목표가 < bid → over_action(설정)
|
||||
target/bid 없으면(설정 불완전 등) AWARD 폴백(하위호환: 기존 '무조건 낙찰')."""
|
||||
앵커링가 < bid ≤ 목표가 → mid_action(견적 낙찰 기준)
|
||||
목표가 < bid → over_action(1:1 협상은 항상 OPEN=개찰)
|
||||
target/bid 없으면(산정 불가 등) AWARD 폴백(최저가 그대로 낙찰)."""
|
||||
if bid is None or target is None:
|
||||
return PriceGateAction.AWARD.value
|
||||
bid = int(bid)
|
||||
@ -571,9 +582,9 @@ class QuotationService:
|
||||
return mid_action or PriceGateAction.AWARD.value
|
||||
return over_action or PriceGateAction.AWARD.value
|
||||
|
||||
async def _close(self, qt_uuid, close_reason: int, data: dict = None) -> None:
|
||||
async def _close(self, qt_uuid, close_reason: int, data: Optional[dict] = None) -> None:
|
||||
"""마감 공통: status→CLOSED + close_reason 기록 + (있으면)추가데이터 + 미완료(미시작·진행중) 세션→미참여.
|
||||
close_reason(CloseReason)이 재생성 한도 카운팅·유찰 사유 구분의 단일 근거.
|
||||
close_reason(CloseReason)이 낙찰/개찰 사유 구분의 단일 근거.
|
||||
preferred_sp_*/equal_bid_* 는 프론트 표시용으로 함께 채운다(사유 판별은 close_reason 이 담당)."""
|
||||
payload = {"status": QuotationStatus.CLOSED.value, "close_reason": close_reason}
|
||||
if data:
|
||||
@ -588,28 +599,22 @@ class QuotationService:
|
||||
],
|
||||
)
|
||||
|
||||
async def _regen_and_notify(self, original, qt_uuid, supplier_ids: list, reason: str, equal=None) -> CloseOutcome:
|
||||
"""마감 후 다음 라운드 재생성 + 성공 시 REGENERATED 알림. 실패 시 REGEN_FAILED(원본 CLOSED 인데 체인 끊김 → 노출)."""
|
||||
regen = await self.regenerate_next_round(qt_uuid, supplier_ids)
|
||||
if not regen.result.success:
|
||||
LOG.e_no_callstack(
|
||||
f"[close] 재생성 실패({reason}) qt={qt_uuid} number={original.number} round={original.round} "
|
||||
f"code={regen.result.code}({regen.result.desc})"
|
||||
async def _open(self, qt_uuid, original, close_reason: int, reason: str, data: Optional[dict] = None) -> CloseOutcome:
|
||||
"""개찰 마감 — 낙찰자 미정으로 CLOSED + close_reason(OPEN_*) 기록 + 작성자 알림. 자동 재협상/재생성 없음(담당자 수동 처리).
|
||||
결렬(유찰) 아님. 알림 코드는 유지하되 프론트에서 '개찰'로 표기한다."""
|
||||
await self._close(qt_uuid, close_reason, data)
|
||||
await create_notification(
|
||||
original.user_id, NotificationType.FAILURE,
|
||||
{"qt_name": original.name, "qt_number": original.number, "reason": reason},
|
||||
ref_qt_id=qt_uuid,
|
||||
)
|
||||
return CloseOutcome.REGEN_FAILED
|
||||
payload = {"qt_name": original.name, "qt_number": original.number, "reason": reason, "next_round": original.round + 1}
|
||||
if equal is not None:
|
||||
payload.update({"tied_price": equal["price"], "tied_count": len(equal["suppliers"])})
|
||||
await create_notification(original.user_id, NotificationType.REGENERATED, payload, ref_qt_id=regen.qt_id)
|
||||
return CloseOutcome.REGENERATED
|
||||
return CloseOutcome.OPENED
|
||||
|
||||
async def close_and_decide(self, qt_id) -> CloseOutcome:
|
||||
"""[마감] 견적을 마감하며 결과 판정. 낙찰자는 협상완료 최저가(단독/동가), 그 위에 회사 가격정책(가격게이트)으로 낙찰/재협상/유찰을 가른다.
|
||||
- 단독 최저가: 가격게이트(≤앵커 무조건낙찰 / 앵커~목표 mid_action / 목표초과 over_action) → 낙찰 / 재협상(한도내) / 유찰
|
||||
- 동가: 그 가격 구간 설정이 '유찰'이 아니고 한도 남으면 재입찰(tie 해소), 유찰 설정이거나 한도 소진이면 유찰
|
||||
- 협상거부: 유찰
|
||||
- 전원 미참여: 재소집(한도내) / 유찰
|
||||
재생성 한도: regen_limit = 체인(같은 견적번호) 전체 재생성 '총' 횟수(사유 무관). 마감 사유는 close_reason 에 기록(카운팅·유찰사유 구분의 단일 근거).
|
||||
"""[마감] 견적을 마감하며 결과 판정. 협상완료 단독 최저가가 낙찰 기준(가격게이트)을 통과할 때만 낙찰(AWARDED).
|
||||
- 단독 최저가: ≤앵커 항상 낙찰 / 앵커~목표 mid_action / 목표초과 over_action(1:1 협상은 항상 OPEN=개찰).
|
||||
- 그 외(기준 미달·동가·협상거부·전원 미응찰)는 결렬(유찰)이 아니라 개찰(OPEN_*) — 낙찰자 미정으로 마감.
|
||||
자동 재협상/재생성 없음. 다음 라운드는 담당자가 상세에서 수동 재생성(regenerate_quotation)한다.
|
||||
공통: 원자적 status→CLOSED 선점, 미시작·진행중 세션→미참여."""
|
||||
qt_uuid = qt_id if isinstance(qt_id, uuid.UUID) else uuid.UUID(str(qt_id))
|
||||
|
||||
@ -617,7 +622,7 @@ class QuotationService:
|
||||
if err_type != ErrorType.SUCCESS or original is None:
|
||||
return CloseOutcome.CLOSED
|
||||
|
||||
# [동시 마감 가드] 원자적으로 status→CLOSED 선점. 실제로 전이한 호출자만 통과(이중 재생성·uq(number,round) 충돌 방지).
|
||||
# [동시 마감 가드] 원자적으로 status→CLOSED 선점. 실제로 전이한 호출자만 통과.
|
||||
claim_err, claimed = await DB_SESSION_MNG.execute_lambda_claim(
|
||||
quotations.DBType(),
|
||||
lambda s: self.quotation_crud.claim_for_close(s, qt_uuid),
|
||||
@ -636,29 +641,15 @@ class QuotationService:
|
||||
has_rejected = any(r.status == SessionStatus.REJECTED.value for r in rows)
|
||||
winner, equal = self._pick_winner(done)
|
||||
|
||||
# 가격게이트 입력: 회사 정책(mid/over/limit) + 세션 목표가/앵커링가(견적당 상품 1개라 세션 공통값)
|
||||
_e, rates = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.get_setting_rates(s, original.qt_setting_id),
|
||||
)
|
||||
rates = rates or {}
|
||||
mid_action = rates.get("mid_action") or PriceGateAction.AWARD.value
|
||||
over_action = rates.get("over_action") or PriceGateAction.AWARD.value
|
||||
limit = rates.get("regen_limit")
|
||||
limit = limit if limit is not None else self.MAX_REGEN_PER_CAUSE
|
||||
# 가격게이트 입력: 견적 단위 낙찰 기준(mid/over, 생성 시점 박제) + 세션 목표가/앵커링가(견적당 상품 1개라 세션 공통값)
|
||||
mid_action = original.mid_action or PriceGateAction.AWARD.value
|
||||
over_action = original.over_action or PriceGateAction.AWARD.value
|
||||
target = next((r.target_price for r in rows if r.target_price is not None), None)
|
||||
anchor = next((r.target_anchoring_price for r in rows if r.target_anchoring_price is not None), None)
|
||||
|
||||
# 재생성 총 이력(체인, 사유 무관). regen_limit = 체인 전체 재생성 총 한도.
|
||||
regen_used = await self._chain_regen_count(original.number, original.round)
|
||||
|
||||
# 1) 단독 최저가 → 가격게이트로 낙찰/재협상/유찰
|
||||
# 1) 단독 최저가가 낙찰 기준 통과 → 낙찰. 미달 → 개찰(가격).
|
||||
if winner is not None:
|
||||
action = self._gate_action(winner["bid_price"], target, anchor, mid_action, over_action)
|
||||
if action == PriceGateAction.RENEGO.value and regen_used < limit:
|
||||
await self._close(qt_uuid, CloseReason.REGEN_PRICE.value, {"preferred_sp_yn": False, "equal_bid_yn": False})
|
||||
return await self._regen_and_notify(original, qt_uuid, [winner["supplier_id"]], "price")
|
||||
if action == PriceGateAction.AWARD.value:
|
||||
await self._close(qt_uuid, CloseReason.AWARDED.value, {
|
||||
"preferred_sp_yn": True, "preferred_sp_id": winner["supplier_id"],
|
||||
@ -671,70 +662,20 @@ class QuotationService:
|
||||
ref_qt_id=qt_uuid,
|
||||
)
|
||||
return CloseOutcome.AWARDED
|
||||
# FAIL 이거나, RENEGO 인데 한도 소진 → 가격 유찰
|
||||
await self._close(qt_uuid, CloseReason.FAIL_PRICE.value, {"preferred_sp_yn": False, "equal_bid_yn": False})
|
||||
await create_notification(
|
||||
original.user_id, NotificationType.FAILURE,
|
||||
{"qt_name": original.name, "qt_number": original.number, "reason": "price"},
|
||||
ref_qt_id=qt_uuid,
|
||||
)
|
||||
return CloseOutcome.CLOSED
|
||||
# 개찰(가격) — 낙찰/동가 플래그는 NULL 로 둔다(대시보드 '개찰' 스코프가 preferred/equal 둘 다 NULL 로 집계).
|
||||
return await self._open(qt_uuid, original, CloseReason.OPEN_PRICE.value, "price")
|
||||
|
||||
# 2) 동가 → 그 가격 구간 설정이 '유찰'이 아니고 한도 남으면 재입찰(tie 해소), 유찰 설정이거나 한도 소진이면 유찰.
|
||||
# (동가는 단독 낙찰 불가 → AWARD 설정이어도 '재입찰로 tie 해소'가 자연스러워 기본은 재입찰=하위호환. 회사가 FAIL 로 두면 유찰.)
|
||||
# 2) 동가(최저가 동점) → 개찰(동가). 낙찰자 미정. equal_bid_yn 으로 표기(대시보드 '동가' 스코프).
|
||||
if equal is not None:
|
||||
action = self._gate_action(equal["price"], target, anchor, mid_action, over_action)
|
||||
if action != PriceGateAction.FAIL.value and regen_used < limit:
|
||||
tied_ids = [uuid.UUID(sp["supplier_id"]) for sp in equal["suppliers"]]
|
||||
await self._close(qt_uuid, CloseReason.REGEN_EQUAL.value,
|
||||
{"preferred_sp_yn": False, "equal_bid_yn": True, "equal_bid_data": equal})
|
||||
return await self._regen_and_notify(original, qt_uuid, tied_ids, "equal", equal=equal)
|
||||
await self._close(qt_uuid, CloseReason.FAIL_EQUAL.value,
|
||||
{"preferred_sp_yn": False, "equal_bid_yn": True, "equal_bid_data": equal})
|
||||
await create_notification(
|
||||
original.user_id, NotificationType.FAILURE,
|
||||
{"qt_name": original.name, "qt_number": original.number, "reason": "equal"},
|
||||
ref_qt_id=qt_uuid,
|
||||
)
|
||||
return CloseOutcome.CLOSED
|
||||
return await self._open(qt_uuid, original, CloseReason.OPEN_EQUAL.value, "equal",
|
||||
{"equal_bid_yn": True, "equal_bid_data": equal})
|
||||
|
||||
# 3) 협상거부 있음 → 유찰
|
||||
# 3) 협상거부 있음 → 개찰(거부).
|
||||
if has_rejected:
|
||||
await self._close(qt_uuid, CloseReason.FAIL_REJECT.value)
|
||||
await create_notification(
|
||||
original.user_id, NotificationType.FAILURE,
|
||||
{"qt_name": original.name, "qt_number": original.number, "reason": "rejected"},
|
||||
ref_qt_id=qt_uuid,
|
||||
)
|
||||
return CloseOutcome.CLOSED
|
||||
return await self._open(qt_uuid, original, CloseReason.OPEN_REJECT.value, "rejected")
|
||||
|
||||
# 4) 전원 미참여 → 공급사 전체 재소집(한도 남을 때)
|
||||
if not done and rows and regen_used < limit:
|
||||
supplier_ids = list({r.supplier_id for r in rows})
|
||||
await self._close(qt_uuid, CloseReason.REGEN_NOSHOW.value, {"preferred_sp_yn": False, "equal_bid_yn": False})
|
||||
return await self._regen_and_notify(original, qt_uuid, supplier_ids, "no_show")
|
||||
|
||||
# 5) 그 외 / 한도 도달 → 미참여 유찰
|
||||
await self._close(qt_uuid, CloseReason.FAIL_NOSHOW.value, {"preferred_sp_yn": False, "equal_bid_yn": False})
|
||||
await create_notification(
|
||||
original.user_id, NotificationType.FAILURE,
|
||||
{"qt_name": original.name, "qt_number": original.number, "reason": "closed"},
|
||||
ref_qt_id=qt_uuid,
|
||||
)
|
||||
return CloseOutcome.CLOSED
|
||||
|
||||
async def _chain_regen_count(self, number: str, current_round: int) -> int:
|
||||
"""체인(같은 견적번호) 이전 라운드들의 재생성 '총' 횟수(사유 무관). regen_limit 은 체인 전체 총 한도다.
|
||||
close_reason 의 REGEN_*(가격/동가/미참여) 만 센다 — 낙찰(AWARDED)·유찰(FAIL_*)·미마감(NULL)은 한도에 안 셈."""
|
||||
err_type, reasons = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.list_chain_close_reasons(s, number, current_round),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return 0
|
||||
regen = {CloseReason.REGEN_PRICE.value, CloseReason.REGEN_EQUAL.value, CloseReason.REGEN_NOSHOW.value}
|
||||
return sum(1 for c in reasons if c in regen)
|
||||
# 4) 전원 미응찰 → 개찰(미응찰).
|
||||
return await self._open(qt_uuid, original, CloseReason.OPEN_NOSHOW.value, "no_show")
|
||||
|
||||
async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list) -> Res_CreateQuotation:
|
||||
"""[프론트] 마감된 견적을 골라 수동으로 다음 라운드를 생성한다.
|
||||
|
||||
@ -65,11 +65,7 @@ class QuotationSettingService:
|
||||
setting = quotation_settings(
|
||||
user_id=uuid.UUID(user_id),
|
||||
target_margin_rate=req.target_margin_rate,
|
||||
anchoring_value=req.anchoring_value,
|
||||
card_count=req.card_count,
|
||||
mid_action=req.mid_action.value,
|
||||
over_action=req.over_action.value,
|
||||
regen_limit=req.regen_limit,
|
||||
)
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[quotation_settings.DBType()],
|
||||
@ -86,11 +82,6 @@ class QuotationSettingService:
|
||||
user_uuid = uuid.UUID(user_id)
|
||||
setting_uuid = uuid.UUID(qt_setting_id)
|
||||
data = req.model_dump(exclude_unset=True)
|
||||
# PriceGateAction enum → SMALLINT 코드값으로 변환(모델 컬럼이 int)
|
||||
for k in ("mid_action", "over_action"):
|
||||
v = data.get(k)
|
||||
if v is not None and hasattr(v, "value"):
|
||||
data[k] = v.value
|
||||
|
||||
# 소유권 확인
|
||||
err_type, _ = await self._fetch_owned(user_uuid, setting_uuid)
|
||||
|
||||
@ -1,17 +1,15 @@
|
||||
"""close_and_decide 동시성·정합성 수정 검증 (코드리뷰 후속).
|
||||
"""close_and_decide 마감 판정 검증 — 낙찰(AWARDED) 또는 개찰(OPEN_*, 낙찰자 미정 마감).
|
||||
|
||||
검증 대상:
|
||||
- #2 동시 이중 마감 가드: 같은 견적을 동시에 close_and_decide 해도 다음 라운드는 1개만 생성
|
||||
- #3 차수 매김: 다음 라운드 round = 체인 최신 round + 1
|
||||
- #4 재생성 사유 집계: 단독낙찰(preferred_sp_yn=True) 이전 라운드를 '미참여'로 오집계하지 않음
|
||||
- #6 재생성 라운드 최소 협상기간 하한(즉시 재마감 캐스케이드 방지)
|
||||
- 동시 이중 마감 가드: 같은 견적을 동시에 close_and_decide 해도 실제 마감 전이는 1번만
|
||||
- 낙찰 기준 게이트: 단독 최저가가 기준 통과면 낙찰, 미달이면 개찰(가격)
|
||||
- 동가 / 협상거부 / 전원 미응찰 → 각각 개찰(OPEN_EQUAL / OPEN_REJECT / OPEN_NOSHOW)
|
||||
|
||||
용어: 체인 = 같은 견적번호(number)로 이어지는 라운드들 / 미참여 = 공급사가 협상에 안 들어온 채 마감됨 /
|
||||
재생성 = 결판 안 난 견적의 '다음 라운드'를 자동 생성 / 재생성 한도 = 사유(미참여·동가)별로 체인당 1번까지만.
|
||||
용어: 개찰 = 낙찰자 미정으로 마감(결렬 아님). 자동 재협상/재생성 없음 — 다음 라운드는 담당자가 수동 재생성.
|
||||
"""
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import text
|
||||
@ -30,149 +28,125 @@ async def clean(db_engine):
|
||||
return db_engine
|
||||
|
||||
|
||||
async def test_concurrent_close_creates_only_one_next_round(clean):
|
||||
"""검증: 같은 견적(전원 미참여)을 5번 동시에 close_and_decide.
|
||||
기대결과: 재생성은 1번만(REGENERATED=1), 체인은 [1,2] — 이중 재생성/충돌 없음."""
|
||||
async def test_concurrent_close_single_transition(clean):
|
||||
"""검증: 전원 미응찰 견적을 5번 동시에 close_and_decide.
|
||||
기대결과: 실제 마감 전이(OPENED)는 1번만·나머지는 no-op(CLOSED), 체인 [1](자동 재생성 없음), close_reason=OPEN_NOSHOW."""
|
||||
engine = clean
|
||||
number = "C-CONCURRENT"
|
||||
qt = await _seed_quotation(engine, number=number, round_=1, status=QuotationStatus.IN_PROGRESS.value)
|
||||
# 전원 미참여(미시작 세션만) → close_and_decide 가 '다음 라운드 재생성' 경로를 탄다
|
||||
# 전원 미응찰(미시작 세션만) → 개찰(미응찰) 경로
|
||||
await _add_session(engine, qt, status=SessionStatus.CREATED.value)
|
||||
await _add_session(engine, qt, status=SessionStatus.CREATED.value)
|
||||
|
||||
service = QuotationService(QuotationCRUD())
|
||||
outcomes = await asyncio.gather(*[service.close_and_decide(qt) for _ in range(5)])
|
||||
|
||||
regenerated = sum(1 for o in outcomes if o == CloseOutcome.REGENERATED)
|
||||
opened = sum(1 for o in outcomes if o == CloseOutcome.OPENED)
|
||||
rounds = await _rounds(engine, number)
|
||||
round_numbers = [r.round for r in rounds]
|
||||
|
||||
assert regenerated == 1, f"재생성은 1번만 일어나야 함, 실제 {regenerated} ({outcomes})"
|
||||
assert round_numbers == [1, 2], f"체인은 [1,2] 여야 함(중복/충돌 없음), 실제 {round_numbers}"
|
||||
assert opened == 1, f"실제 마감 전이는 1번만이어야 함, 실제 {opened} ({outcomes})"
|
||||
assert [r.round for r in rounds] == [1], "자동 재생성 없음 — round 2 가 생기면 안 됨"
|
||||
assert rounds[0].close_reason == CloseReason.OPEN_NOSHOW.value
|
||||
|
||||
|
||||
async def test_next_round_numbering_and_min_duration(clean):
|
||||
"""검증: 협상기간이 0인 견적을 미참여로 재생성.
|
||||
기대결과: 체인 [1,2](round=최신+1), 새 라운드 협상기간 ≥ MIN_REGEN_DURATION(즉시 재마감 방지)."""
|
||||
async def test_single_lowest_meets_target_awarded(clean):
|
||||
"""검증: 단독 최저가(bid=100)가 목표가(200) 이내 · 목표까지 낙찰(mid=AWARD).
|
||||
기대결과: AWARDED — 그 협력사로 낙찰(preferred_sp_yn=True), close_reason=AWARDED."""
|
||||
engine = clean
|
||||
number = "C-DURATION"
|
||||
# start==end (협상기간 0) → 하한이 적용되지 않으면 새 라운드도 0 길이가 된다
|
||||
qt = await _seed_quotation(
|
||||
engine, number=number, round_=1, status=QuotationStatus.IN_PROGRESS.value,
|
||||
start_time=PAST, end_time=PAST,
|
||||
)
|
||||
await _add_session(engine, qt, status=SessionStatus.CREATED.value)
|
||||
|
||||
service = QuotationService(QuotationCRUD())
|
||||
outcome = await service.close_and_decide(qt)
|
||||
|
||||
assert outcome == CloseOutcome.REGENERATED
|
||||
rounds = await _rounds(engine, number)
|
||||
assert [r.round for r in rounds] == [1, 2]
|
||||
nxt = rounds[1]
|
||||
duration = nxt.end_time - nxt.start_time
|
||||
assert duration >= QuotationService.MIN_REGEN_DURATION, (
|
||||
f"재생성 라운드 협상기간({duration})이 최소 하한({QuotationService.MIN_REGEN_DURATION}) 이상이어야 함"
|
||||
)
|
||||
|
||||
|
||||
async def test_awarded_prior_round_not_counted_as_no_show(clean):
|
||||
"""검증: round1=단독낙찰 + round2=전원 미참여 인 체인에서 round2 를 마감.
|
||||
기대결과: REGENERATED, 체인 [1,2,3] — 단독낙찰 라운드를 '미참여'로 오집계해 재생성을 막지 않는다."""
|
||||
engine = clean
|
||||
number = "C-AWARDED-PRIOR"
|
||||
# round 1: 단독낙찰로 마감(close_reason=AWARDED). 수동 재생성 등으로 체인이 이어진 상황을 가정.
|
||||
await _seed_quotation(
|
||||
engine, number=number, round_=1, status=QuotationStatus.CLOSED.value,
|
||||
preferred_sp_yn=True, equal_bid_yn=False, close_reason=CloseReason.AWARDED.value,
|
||||
)
|
||||
# round 2: 전원 미참여 → 미참여 재생성이 일어나야 한다(round 1 은 미참여로 세면 안 됨)
|
||||
qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.IN_PROGRESS.value)
|
||||
await _add_session(engine, qt2, status=SessionStatus.CREATED.value)
|
||||
|
||||
service = QuotationService(QuotationCRUD())
|
||||
outcome = await service.close_and_decide(qt2)
|
||||
|
||||
rounds = await _rounds(engine, number)
|
||||
round_numbers = [r.round for r in rounds]
|
||||
assert outcome == CloseOutcome.REGENERATED, (
|
||||
f"단독낙찰 이전 라운드는 미참여 예산을 소진하지 않아 round2 가 재생성돼야 함, 실제 {outcome}"
|
||||
)
|
||||
assert round_numbers == [1, 2, 3], f"round 3 이 생성돼야 함, 실제 {round_numbers}"
|
||||
|
||||
|
||||
async def test_no_show_prior_round_consumes_budget(clean):
|
||||
"""검증: round1=미참여 재생성 + round2=전원 미참여 인 체인에서 round2 를 마감.
|
||||
기대결과: CLOSED, 체인 [1,2] — 미참여 재생성 한도(1) 소진돼 재생성 없이 그냥 마감(round3 없음)."""
|
||||
engine = clean
|
||||
number = "C-NOSHOW-PRIOR"
|
||||
# round 1: 미참여 재생성으로 마감(close_reason=REGEN_NOSHOW) → 미참여 예산 1 소진
|
||||
await _seed_quotation(
|
||||
engine, number=number, round_=1, status=QuotationStatus.CLOSED.value,
|
||||
preferred_sp_yn=False, equal_bid_yn=False, close_reason=CloseReason.REGEN_NOSHOW.value,
|
||||
)
|
||||
# round 2: 또 전원 미참여 → 한도 도달이라 재생성 없이 그냥 마감
|
||||
qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.IN_PROGRESS.value)
|
||||
await _add_session(engine, qt2, status=SessionStatus.CREATED.value)
|
||||
|
||||
service = QuotationService(QuotationCRUD())
|
||||
outcome = await service.close_and_decide(qt2)
|
||||
|
||||
rounds = await _rounds(engine, number)
|
||||
assert outcome == CloseOutcome.CLOSED, f"미참여 예산 소진 → 그냥 마감이어야 함, 실제 {outcome}"
|
||||
assert [r.round for r in rounds] == [1, 2], "재생성되면 안 됨(round 3 없음)"
|
||||
|
||||
|
||||
async def test_over_target_fail_closes(clean):
|
||||
"""검증: over_action=유찰(FAIL) 회사에서 단독 최저가가 목표 초과(session target=0 < bid=100)인 견적을 마감.
|
||||
기대결과: CLOSED + close_reason=FAIL_PRICE — 그 가격에 낙찰 안 하고 유찰(재생성 없음)."""
|
||||
engine = clean
|
||||
setting = await _seed_settings(engine, over_action=PriceGateAction.FAIL.value)
|
||||
qt = await _seed_quotation(engine, number="C-OVER-FAIL", round_=1,
|
||||
status=QuotationStatus.IN_PROGRESS.value, qt_setting_id=setting)
|
||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100)
|
||||
qt = await _seed_quotation(engine, number="C-AWARD", round_=1,
|
||||
status=QuotationStatus.IN_PROGRESS.value,
|
||||
mid_action=PriceGateAction.AWARD.value, over_action=PriceGateAction.OPEN.value)
|
||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, target_price=200)
|
||||
|
||||
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
|
||||
|
||||
rounds = await _rounds(engine, "C-OVER-FAIL")
|
||||
assert outcome == CloseOutcome.CLOSED, f"목표초과+유찰정책 → 유찰이어야 함, 실제 {outcome}"
|
||||
assert [r.round for r in rounds] == [1], "재생성되면 안 됨(round 2 없음)"
|
||||
assert rounds[0].close_reason == CloseReason.FAIL_PRICE.value, f"close_reason=FAIL_PRICE 여야 함, 실제 {rounds[0].close_reason}"
|
||||
rounds = await _rounds(engine, "C-AWARD")
|
||||
assert outcome == CloseOutcome.AWARDED, f"기준 통과 단독 최저가 → 낙찰이어야 함, 실제 {outcome}"
|
||||
assert rounds[0].close_reason == CloseReason.AWARDED.value
|
||||
|
||||
|
||||
async def test_over_target_renego_regenerates(clean):
|
||||
"""검증: over_action=재협상(RENEGO) 회사에서 단독 최저가가 목표 초과인 견적을 첫 라운드에 마감.
|
||||
기대결과: REGENERATED + 체인 [1,2] — 그 가격에 낙찰 안 하고 다음 라운드로 더 깎기(1차 close_reason=REGEN_PRICE)."""
|
||||
async def test_over_target_opens(clean):
|
||||
"""검증: 단독 최저가(bid=200)가 목표가(100) 초과 · 목표초과=개찰(over=OPEN).
|
||||
기대결과: OPENED + close_reason=OPEN_PRICE — 낙찰 안 하고 개찰(자동 재생성 없이 체인 [1])."""
|
||||
engine = clean
|
||||
setting = await _seed_settings(engine, over_action=PriceGateAction.RENEGO.value)
|
||||
qt = await _seed_quotation(engine, number="C-OVER-RENEGO", round_=1,
|
||||
status=QuotationStatus.IN_PROGRESS.value, qt_setting_id=setting)
|
||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100)
|
||||
qt = await _seed_quotation(engine, number="C-OVER", round_=1,
|
||||
status=QuotationStatus.IN_PROGRESS.value,
|
||||
mid_action=PriceGateAction.AWARD.value, over_action=PriceGateAction.OPEN.value)
|
||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=200, target_price=100)
|
||||
|
||||
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
|
||||
|
||||
rounds = await _rounds(engine, "C-OVER-RENEGO")
|
||||
assert outcome == CloseOutcome.REGENERATED, f"목표초과+재협상정책 0회차 → 재생성이어야 함, 실제 {outcome}"
|
||||
assert [r.round for r in rounds] == [1, 2], f"round 2 가 생성돼야 함, 실제 {[r.round for r in rounds]}"
|
||||
assert rounds[0].close_reason == CloseReason.REGEN_PRICE.value, f"1차 close_reason=REGEN_PRICE 여야 함, 실제 {rounds[0].close_reason}"
|
||||
rounds = await _rounds(engine, "C-OVER")
|
||||
assert outcome == CloseOutcome.OPENED, f"목표초과 → 개찰이어야 함, 실제 {outcome}"
|
||||
assert [r.round for r in rounds] == [1], "자동 재생성 없음(round 2 없음)"
|
||||
assert rounds[0].close_reason == CloseReason.OPEN_PRICE.value
|
||||
|
||||
|
||||
# ===== 헬퍼 (위 테스트들이 쓰는 도우미. 세션 상태·마감 표식을 SQL 로 직접 세팅) =====
|
||||
async def test_anchor_only_opens_within_target(clean):
|
||||
"""검증: '앵커링가까지만 낙찰'(mid=OPEN) 에서 앵커(80)<최저가(120)≤목표(200).
|
||||
기대결과: OPENED + OPEN_PRICE — 앵커 위 구간은 낙찰 안 하고 개찰."""
|
||||
engine = clean
|
||||
qt = await _seed_quotation(engine, number="C-ANCHOR", round_=1,
|
||||
status=QuotationStatus.IN_PROGRESS.value,
|
||||
mid_action=PriceGateAction.OPEN.value, over_action=PriceGateAction.OPEN.value)
|
||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=120,
|
||||
target_price=200, target_anchoring_price=80)
|
||||
|
||||
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
|
||||
|
||||
rounds = await _rounds(engine, "C-ANCHOR")
|
||||
assert outcome == CloseOutcome.OPENED
|
||||
assert rounds[0].close_reason == CloseReason.OPEN_PRICE.value
|
||||
|
||||
|
||||
async def test_equal_lowest_opens(clean):
|
||||
"""검증: 최저가 동점(둘 다 100, 목표 200 이내).
|
||||
기대결과: OPENED + close_reason=OPEN_EQUAL(낙찰자 미정) — 자동 재입찰 없음(체인 [1])."""
|
||||
engine = clean
|
||||
qt = await _seed_quotation(engine, number="C-EQUAL", round_=1, status=QuotationStatus.IN_PROGRESS.value)
|
||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, target_price=200)
|
||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, target_price=200)
|
||||
|
||||
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
|
||||
|
||||
rounds = await _rounds(engine, "C-EQUAL")
|
||||
assert outcome == CloseOutcome.OPENED, f"동가 → 개찰이어야 함, 실제 {outcome}"
|
||||
assert [r.round for r in rounds] == [1]
|
||||
assert rounds[0].close_reason == CloseReason.OPEN_EQUAL.value
|
||||
|
||||
|
||||
async def test_rejected_opens(clean):
|
||||
"""검증: 완료 투찰 없이 협상거부 세션만 존재.
|
||||
기대결과: OPENED + close_reason=OPEN_REJECT."""
|
||||
engine = clean
|
||||
qt = await _seed_quotation(engine, number="C-REJECT", round_=1, status=QuotationStatus.IN_PROGRESS.value)
|
||||
await _add_session(engine, qt, status=SessionStatus.REJECTED.value)
|
||||
|
||||
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
|
||||
|
||||
rounds = await _rounds(engine, "C-REJECT")
|
||||
assert outcome == CloseOutcome.OPENED, f"협상거부 → 개찰이어야 함, 실제 {outcome}"
|
||||
assert rounds[0].close_reason == CloseReason.OPEN_REJECT.value
|
||||
|
||||
|
||||
# ===== 헬퍼 (세션 상태·마감 표식을 SQL 로 직접 세팅) =====
|
||||
async def _seed_quotation(
|
||||
engine, *, number, round_, status, start_time=PAST, end_time=PAST,
|
||||
preferred_sp_yn=None, equal_bid_yn=None, close_reason=None, qt_setting_id=None,
|
||||
mid_action=1, over_action=1,
|
||||
):
|
||||
"""견적 1건 시드. number/round_ 로 체인을, close_reason 으로 '이전 라운드가 어떤 사유로 마감/재생성됐는지'를 만든다
|
||||
(재생성 한도 카운팅은 close_reason 의 REGEN_* 만 센다). qt_setting_id 로 마감 가격정책(설정)을 연결. preferred_sp_yn/equal_bid_yn 은 프론트 표시용."""
|
||||
"""견적 1건 시드. number/round_ 로 체인을, close_reason 으로 이전 라운드 마감 사유를 만든다.
|
||||
낙찰 기준(mid/over)은 견적 행에 직접 박제 — close_and_decide 가 이 행에서 읽는다(세팅 아님)."""
|
||||
qt_id = uuid.uuid4()
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO quotations "
|
||||
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, "
|
||||
" round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn, close_reason) VALUES "
|
||||
" round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn, close_reason, "
|
||||
" mid_action, over_action) VALUES "
|
||||
"(:qt_id, :user_id, :qt_setting_id, :version_id, :name, :number, :type, :status, "
|
||||
" :round, 0, :start_time, :end_time, false, :pref, :eq, :creason)"
|
||||
" :round, 0, :start_time, :end_time, false, :pref, :eq, :creason, "
|
||||
" :mid, :over)"
|
||||
),
|
||||
{
|
||||
"qt_id": qt_id, "user_id": uuid.uuid4(), "qt_setting_id": qt_setting_id or uuid.uuid4(),
|
||||
@ -180,51 +154,38 @@ async def _seed_quotation(
|
||||
"type": QuotationType.REQUOTE.value, "status": status, "round": round_,
|
||||
"start_time": start_time, "end_time": end_time,
|
||||
"pref": preferred_sp_yn, "eq": equal_bid_yn, "creason": close_reason,
|
||||
"mid": mid_action, "over": over_action,
|
||||
},
|
||||
)
|
||||
return qt_id
|
||||
|
||||
|
||||
async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None):
|
||||
"""세션 1건 시드(공급사 협상 1건)."""
|
||||
async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None,
|
||||
target_price=0, target_anchoring_price=None):
|
||||
"""세션 1건 시드(공급사 협상 1건). target_price/앵커링가로 가격게이트 입력을 만든다."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO sessions "
|
||||
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
|
||||
" target_price, status, bid_price, end_time) VALUES "
|
||||
" target_price, target_anchoring_price, status, bid_price, end_time) VALUES "
|
||||
"(:session_id, :quotation_id, :item_id, :supplier_id, :qt_number, :qt_round, :qt_type, "
|
||||
" 0, :status, :bid_price, :end_time)"
|
||||
" :target_price, :anchor, :status, :bid_price, :end_time)"
|
||||
),
|
||||
{
|
||||
"session_id": uuid.uuid4(), "quotation_id": qt_id, "item_id": uuid.uuid4(),
|
||||
"supplier_id": supplier_id or uuid.uuid4(), "qt_number": "Q", "qt_round": 1,
|
||||
"qt_type": QuotationType.REQUOTE.value, "status": status,
|
||||
"target_price": target_price, "anchor": target_anchoring_price,
|
||||
"bid_price": bid_price, "end_time": PAST,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _rounds(engine, number):
|
||||
"""체인(number)의 (round, status, start_time, end_time, close_reason) 목록 — round 오름차순."""
|
||||
"""체인(number)의 (round, status, close_reason) 목록 — round 오름차순."""
|
||||
async with engine.begin() as conn:
|
||||
return (await conn.execute(
|
||||
text("SELECT round, status, start_time, end_time, close_reason FROM quotations "
|
||||
"WHERE number = :n ORDER BY round"),
|
||||
text("SELECT round, status, close_reason FROM quotations WHERE number = :n ORDER BY round"),
|
||||
{"n": number},
|
||||
)).all()
|
||||
|
||||
|
||||
async def _seed_settings(engine, *, mid_action=1, over_action=1, regen_limit=1):
|
||||
"""quotation_settings 1건 시드(마감 가격정책 지정). 반환: qt_setting_id."""
|
||||
sid = uuid.uuid4()
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO quotation_settings "
|
||||
"(qt_setting_id, user_id, target_margin_rate, anchoring_value, card_count, mid_action, over_action, regen_limit) "
|
||||
"VALUES (:sid, :uid, 0.1, 0.01, 3, :mid, :over, :lim)"
|
||||
),
|
||||
{"sid": sid, "uid": uuid.uuid4(), "mid": mid_action, "over": over_action, "lim": regen_limit},
|
||||
)
|
||||
return sid
|
||||
|
||||
@ -1,15 +1,14 @@
|
||||
"""견적 마감(close_and_decide) 테스트 — 마감하면 상황별로 결과가 맞게 판정되고, 그 결과가 작성자에게 알림으로 남는지 확인.
|
||||
|
||||
핵심은 '재견적(다음 라운드 재생성)이 나오는 경우 vs 안 나오는 경우'의 구분이다.
|
||||
마감 결과는 낙찰(AWARDED) 또는 개찰(OPENED, 낙찰자 미정 마감). 개찰은 결렬(유찰)이 아니며 자동 재협상/재생성도 없다.
|
||||
각 경우에 (1) 판정이 맞고 (2) 작성자 알림함에 알맞은 알림 1건이 남는지 본다:
|
||||
· 단독 최저가 → 낙찰 (SUCCESS) [재견적 X]
|
||||
· 협상 거부 → 결렬 (FAILURE, reason=rejected) [재견적 X]
|
||||
· 동가/미참여 + 한도 남음 → 재생성 (REGENERATED) [재견적 O]
|
||||
· 동가/미참여 + 한도 소진 → 결렬 (FAILURE, reason=closed) [재견적 X]
|
||||
재생성 한도: 사유(동가·미참여)별로 한 체인(같은 견적번호)에서 각 1번까지만.
|
||||
· 단독 최저가(기준 통과) → 낙찰 (SUCCESS)
|
||||
· 협상 거부 → 개찰 (알림 FAILURE, reason=rejected — 프론트에서 '개찰'로 표기)
|
||||
· 동가 → 개찰 (알림 FAILURE, reason=equal)
|
||||
· 전원 미응찰 → 개찰 (알림 FAILURE, reason=no_show)
|
||||
|
||||
공급사의 협상 결과(협상완료/거부/입찰가)는 협상 화면에서만 생기는 값이라 API 로 못 만든다 → SQL 로 직접 넣는다.
|
||||
마감 판정 로직 자체를 더 깊게 파는 건 test_scheduler·test_close_and_decide_fixes.
|
||||
마감 판정 로직 자체를 더 깊게 파는 건 test_close_and_decide_fixes.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
@ -17,7 +16,7 @@ from datetime import datetime
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import text
|
||||
|
||||
from common.enums import CloseOutcome, CloseReason, NotificationType, QuotationStatus, QuotationType, SessionStatus
|
||||
from common.enums import CloseOutcome, NotificationType, QuotationStatus, QuotationType, SessionStatus
|
||||
from crud.quotation_crud import QuotationCRUD
|
||||
from services.quotation_service import QuotationService
|
||||
|
||||
@ -32,10 +31,9 @@ async def clean(db_engine):
|
||||
return db_engine
|
||||
|
||||
|
||||
# ----- 재견적 X (낙찰·거부) -----
|
||||
async def test_award_notifies_success(clean):
|
||||
"""검증: 협상완료 세션 2건(입찰 100·200) — 단독 최저가로 마감.
|
||||
기대결과: 재견적 X, 판정 = 낙찰(AWARDED) + 알림 SUCCESS(winner_price=100=최저가, ref_qt_id=그 견적)."""
|
||||
"""검증: 협상완료 세션 2건(입찰 100·200) — 단독 최저가로 마감(기본 낙찰 기준=최저가 낙찰).
|
||||
기대결과: 판정 = 낙찰(AWARDED) + 알림 SUCCESS(winner_price=100=최저가, ref_qt_id=그 견적)."""
|
||||
engine = clean
|
||||
user_id = uuid.uuid4()
|
||||
winner = uuid.uuid4()
|
||||
@ -54,9 +52,9 @@ async def test_award_notifies_success(clean):
|
||||
assert str(ref) == str(qt)
|
||||
|
||||
|
||||
async def test_rejected_notifies_failure(clean):
|
||||
async def test_rejected_notifies_open(clean):
|
||||
"""검증: 협상거부 세션만 있는 상태로 마감.
|
||||
기대결과: 재견적 X, 판정 = 결렬(CLOSED) + 알림 FAILURE(reason=rejected)."""
|
||||
기대결과: 판정 = 개찰(OPENED) + 알림(reason=rejected). 결렬 아님·자동 재생성 없음."""
|
||||
engine = clean
|
||||
user_id = uuid.uuid4()
|
||||
qt = await _seed_quotation(engine, user_id=user_id, number="N-REJECT")
|
||||
@ -64,7 +62,7 @@ async def test_rejected_notifies_failure(clean):
|
||||
|
||||
outcome = await _service().close_and_decide(qt)
|
||||
|
||||
assert outcome == CloseOutcome.CLOSED
|
||||
assert outcome == CloseOutcome.OPENED
|
||||
notis = await _notifications(engine, user_id)
|
||||
assert len(notis) == 1
|
||||
type_, data, ref = notis[0]
|
||||
@ -73,10 +71,9 @@ async def test_rejected_notifies_failure(clean):
|
||||
assert str(ref) == str(qt)
|
||||
|
||||
|
||||
# ----- 재견적 O (동가·미참여, 한도 남음) -----
|
||||
async def test_equal_bid_regenerates(clean):
|
||||
"""검증: 협상완료 세션 2건이 '동가'(둘 다 100), 체인에 동가 재생성 이력 없음(한도 남음).
|
||||
기대결과: 재견적 O, 판정 = 재생성(REGENERATED) + 알림 REGENERATED(reason=equal, tied_price=100, next_round=2)."""
|
||||
async def test_equal_bid_opens(clean):
|
||||
"""검증: 협상완료 세션 2건이 '동가'(둘 다 100).
|
||||
기대결과: 판정 = 개찰(OPENED, 낙찰자 미정) + 알림(reason=equal). 자동 재입찰 없음."""
|
||||
engine = clean
|
||||
user_id = uuid.uuid4()
|
||||
qt = await _seed_quotation(engine, user_id=user_id, number="N-EQUAL")
|
||||
@ -85,19 +82,17 @@ async def test_equal_bid_regenerates(clean):
|
||||
|
||||
outcome = await _service().close_and_decide(qt)
|
||||
|
||||
assert outcome == CloseOutcome.REGENERATED
|
||||
assert outcome == CloseOutcome.OPENED
|
||||
notis = await _notifications(engine, user_id)
|
||||
assert len(notis) == 1
|
||||
type_, data, _ = notis[0]
|
||||
assert type_ == NotificationType.REGENERATED.value
|
||||
assert type_ == NotificationType.FAILURE.value
|
||||
assert data["reason"] == "equal"
|
||||
assert data["tied_price"] == 100
|
||||
assert data["next_round"] == 2
|
||||
|
||||
|
||||
async def test_no_show_regenerates(clean):
|
||||
"""검증: 전원 미참여(미시작 세션만), 체인에 미참여 재생성 이력 없음(한도 남음).
|
||||
기대결과: 재견적 O, 판정 = 재생성(REGENERATED) + 알림 REGENERATED(reason=no_show, next_round=2)."""
|
||||
async def test_no_show_opens(clean):
|
||||
"""검증: 전원 미응찰(미시작 세션만).
|
||||
기대결과: 판정 = 개찰(OPENED) + 알림(reason=no_show). 자동 재소집 없음."""
|
||||
engine = clean
|
||||
user_id = uuid.uuid4()
|
||||
qt = await _seed_quotation(engine, user_id=user_id, number="N-NOSHOW")
|
||||
@ -106,89 +101,32 @@ async def test_no_show_regenerates(clean):
|
||||
|
||||
outcome = await _service().close_and_decide(qt)
|
||||
|
||||
assert outcome == CloseOutcome.REGENERATED
|
||||
assert outcome == CloseOutcome.OPENED
|
||||
notis = await _notifications(engine, user_id)
|
||||
assert len(notis) == 1
|
||||
type_, data, _ = notis[0]
|
||||
assert type_ == NotificationType.REGENERATED.value
|
||||
assert type_ == NotificationType.FAILURE.value
|
||||
assert data["reason"] == "no_show"
|
||||
assert data["next_round"] == 2
|
||||
|
||||
|
||||
# ----- 재견적 X (동가·미참여지만 한도 소진 → 결렬) -----
|
||||
async def test_equal_bid_limit_exhausted_fails(clean):
|
||||
"""검증: 1차가 이미 '동가'로 재생성된 체인(동가 한도 1 소진)에서, 2차도 또 동가로 마감.
|
||||
기대결과: 재견적 X — 판정 = 결렬(CLOSED) + 알림 FAILURE(reason=closed)."""
|
||||
engine = clean
|
||||
user_id = uuid.uuid4()
|
||||
# 1차: 동가로 재생성 마감(close_reason=REGEN_EQUAL) → 동가 한도 소진
|
||||
await _seed_quotation(engine, user_id=user_id, number="N-EQUAL-LIMIT", round_=1,
|
||||
status=QuotationStatus.CLOSED.value, equal_bid_yn=True,
|
||||
close_reason=CloseReason.REGEN_EQUAL.value)
|
||||
# 2차: 또 동가
|
||||
qt2 = await _seed_quotation(engine, user_id=user_id, number="N-EQUAL-LIMIT", round_=2)
|
||||
await _add_session(engine, qt2, status=SessionStatus.DONE.value, bid_price=100)
|
||||
await _add_session(engine, qt2, status=SessionStatus.DONE.value, bid_price=100)
|
||||
|
||||
outcome = await _service().close_and_decide(qt2)
|
||||
|
||||
assert outcome == CloseOutcome.CLOSED # 동가 한도 소진 → 재생성 없이 결렬
|
||||
notis = await _notifications(engine, user_id)
|
||||
assert len(notis) == 1
|
||||
type_, data, ref = notis[0]
|
||||
assert type_ == NotificationType.FAILURE.value
|
||||
assert data["reason"] == "equal" # 동가 유찰(FAIL_EQUAL) 사유
|
||||
assert str(ref) == str(qt2)
|
||||
|
||||
|
||||
async def test_no_show_limit_exhausted_fails(clean):
|
||||
"""검증: 1차가 이미 '미참여'로 재생성된 체인(미참여 한도 1 소진)에서, 2차도 또 전원 미참여로 마감.
|
||||
기대결과: 재견적 X — 판정 = 결렬(CLOSED) + 알림 FAILURE(reason=closed)."""
|
||||
engine = clean
|
||||
user_id = uuid.uuid4()
|
||||
# 1차: 미참여로 재생성 마감(close_reason=REGEN_NOSHOW) → 미참여 한도 소진
|
||||
await _seed_quotation(engine, user_id=user_id, number="N-NOSHOW-LIMIT", round_=1,
|
||||
status=QuotationStatus.CLOSED.value, preferred_sp_yn=False, equal_bid_yn=False,
|
||||
close_reason=CloseReason.REGEN_NOSHOW.value)
|
||||
# 2차: 또 전원 미참여
|
||||
qt2 = await _seed_quotation(engine, user_id=user_id, number="N-NOSHOW-LIMIT", round_=2)
|
||||
await _add_session(engine, qt2, status=SessionStatus.CREATED.value)
|
||||
await _add_session(engine, qt2, status=SessionStatus.CREATED.value)
|
||||
|
||||
outcome = await _service().close_and_decide(qt2)
|
||||
|
||||
assert outcome == CloseOutcome.CLOSED # 미참여 한도 소진 → 재생성 없이 결렬
|
||||
notis = await _notifications(engine, user_id)
|
||||
assert len(notis) == 1
|
||||
type_, data, ref = notis[0]
|
||||
assert type_ == NotificationType.FAILURE.value
|
||||
assert data["reason"] == "closed"
|
||||
assert str(ref) == str(qt2)
|
||||
|
||||
|
||||
# ===== 헬퍼 (위 테스트들이 쓰는 도우미. 세션 입찰값·이전 라운드 표식을 SQL 로 직접 세팅) =====
|
||||
async def _seed_quotation(
|
||||
engine, *, user_id, number, round_=1, status=QuotationStatus.IN_PROGRESS.value,
|
||||
preferred_sp_yn=None, equal_bid_yn=None, close_reason=None,
|
||||
):
|
||||
"""견적 1건 시드(작성자=user_id). close_reason 으로 '이전 라운드가 어떤 사유로 재생성됐는지'를 표식한다
|
||||
(재생성 한도 카운팅은 close_reason 의 REGEN_* 만 센다). preferred_sp_yn/equal_bid_yn 은 프론트 표시용."""
|
||||
# ===== 헬퍼 (세션 입찰값을 SQL 로 직접 세팅) =====
|
||||
async def _seed_quotation(engine, *, user_id, number, round_=1, status=QuotationStatus.IN_PROGRESS.value):
|
||||
"""견적 1건 시드(작성자=user_id). 낙찰 기준 mid/over 는 서버 기본(AWARD)=최저가 낙찰."""
|
||||
qt_id = uuid.uuid4()
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO quotations "
|
||||
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, "
|
||||
" round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn, close_reason) VALUES "
|
||||
" round, iteration, start_time, end_time, deleted) VALUES "
|
||||
"(:qt_id, :user_id, :qt_setting_id, :version_id, '견적A', :number, :type, :status, "
|
||||
" :round, 0, :start_time, :end_time, false, :pref, :eq, :creason)"
|
||||
" :round, 0, :start_time, :end_time, false)"
|
||||
),
|
||||
{
|
||||
"qt_id": qt_id, "user_id": user_id, "qt_setting_id": uuid.uuid4(),
|
||||
"version_id": uuid.uuid4(), "number": number,
|
||||
"type": QuotationType.REQUOTE.value, "status": status, "round": round_,
|
||||
"start_time": PAST, "end_time": PAST,
|
||||
"pref": preferred_sp_yn, "eq": equal_bid_yn, "creason": close_reason,
|
||||
},
|
||||
)
|
||||
return qt_id
|
||||
|
||||
@ -9,7 +9,7 @@ from datetime import datetime
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from common.enums import QuotationType
|
||||
from common.enums import PriceGateAction, QuotationType
|
||||
from crud.quotation_crud import QuotationCRUD
|
||||
from router.v1.quotation.protocol import Req_CreateQuotation
|
||||
from services.quotation_service import QuotationService
|
||||
@ -65,11 +65,67 @@ async def test_create_without_price_fails(db_engine, company_id):
|
||||
assert rows == []
|
||||
|
||||
|
||||
async def test_auction_forces_lowest_price_award(db_engine, company_id):
|
||||
"""검증: 1:N 경매(NEW_QUOTE)에 낙찰 기준(OPEN/OPEN)을 실어 생성 요청.
|
||||
기대결과: 경매는 무조건 최저가 낙찰 — 견적 행에 mid=over=AWARD 로 강제 저장(요청값 무시)."""
|
||||
item = await _seed_item(db_engine, company_id, internet_lowest=100_000)
|
||||
|
||||
req = Req_CreateQuotation(
|
||||
qt_setting_id=uuid.uuid4(),
|
||||
name="경매정책강제",
|
||||
type=QuotationType.NEW_QUOTE.value,
|
||||
end_time=FUTURE,
|
||||
item_ids=[item],
|
||||
supplier_ids=[uuid.uuid4(), uuid.uuid4()],
|
||||
mid_action=PriceGateAction.OPEN.value, # 경매엔 의미 없음 — 서버가 덮어야 함
|
||||
over_action=PriceGateAction.OPEN.value,
|
||||
)
|
||||
res = await _service().create_quotation(str(uuid.uuid4()), req)
|
||||
|
||||
assert res.result.success is True
|
||||
mid, over = await _quotation_policy(db_engine, res.qt_id)
|
||||
assert mid == PriceGateAction.AWARD.value, f"경매 mid_action 은 AWARD 강제여야 함, 실제 {mid}"
|
||||
assert over == PriceGateAction.AWARD.value, f"경매 over_action 은 AWARD 강제여야 함, 실제 {over}"
|
||||
|
||||
|
||||
async def test_nego_persists_award_criterion(db_engine, company_id):
|
||||
"""검증: 1:1 협상(NEW_NEGO)에 낙찰 기준(mid=AWARD/over=OPEN=목표까지 낙찰)을 실어 생성.
|
||||
기대결과: 요청값이 견적 행에 그대로 박제(협상은 사용자가 낙찰 기준을 정한다. 목표초과=개찰)."""
|
||||
item = await _seed_item(db_engine, company_id, internet_lowest=100_000)
|
||||
|
||||
req = Req_CreateQuotation(
|
||||
qt_setting_id=uuid.uuid4(),
|
||||
name="협상정책박제",
|
||||
type=QuotationType.NEW_NEGO.value,
|
||||
end_time=FUTURE,
|
||||
item_ids=[item],
|
||||
supplier_ids=[uuid.uuid4()],
|
||||
mid_action=PriceGateAction.AWARD.value,
|
||||
over_action=PriceGateAction.OPEN.value,
|
||||
)
|
||||
res = await _service().create_quotation(str(uuid.uuid4()), req)
|
||||
|
||||
assert res.result.success is True
|
||||
mid, over = await _quotation_policy(db_engine, res.qt_id)
|
||||
assert (mid, over) == (
|
||||
PriceGateAction.AWARD.value, PriceGateAction.OPEN.value,
|
||||
), f"협상 낙찰 기준이 그대로 저장돼야 함, 실제 {(mid, over)}"
|
||||
|
||||
|
||||
# ===== 헬퍼 (위 테스트들이 쓰는 도우미) =====
|
||||
def _service():
|
||||
return QuotationService(QuotationCRUD())
|
||||
|
||||
|
||||
async def _quotation_policy(engine, qt_id):
|
||||
"""생성된 견적의 낙찰 기준 (mid_action, over_action)."""
|
||||
async with engine.begin() as conn:
|
||||
return (await conn.execute(
|
||||
text("SELECT mid_action, over_action FROM quotations WHERE qt_id = :qt"),
|
||||
{"qt": qt_id},
|
||||
)).one()
|
||||
|
||||
|
||||
async def _seed_item(engine, company_id, *, internet_lowest):
|
||||
"""상품 1건 시드(인터넷최저가만). category_type·internet_lowest_price_yn 은 NOT NULL —
|
||||
ORM default 는 raw INSERT 에 안 먹으므로 명시한다(conftest companies.status 와 같은 이유)."""
|
||||
|
||||
@ -247,11 +247,8 @@ CREATE TABLE IF NOT EXISTS quotation.quotation_settings (
|
||||
qt_setting_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 견적 설정 식별자(PK)
|
||||
user_id uuid NOT NULL, -- 견적 설정을 생성한 유저 아이디(company.users.user_id)
|
||||
target_margin_rate NUMERIC(8,6) NOT NULL, -- 목표 마진율 (정수부 2자리 + 소수 6자리, -99.999999~99.999999)
|
||||
anchoring_value NUMERIC(8,6) NOT NULL DEFAULT 0.01, -- 앵커링 값 (정수부 2자리 + 소수 6자리)
|
||||
card_count INTEGER NOT NULL DEFAULT 3, -- 한개의 협상 안에서 협상카드 사용 횟수
|
||||
mid_action SMALLINT NOT NULL DEFAULT 1, -- 마감 가격정책(PriceGateAction): 앵커링가<투찰가≤목표가 처리(1=낙찰/2=재협상/3=유찰)
|
||||
over_action SMALLINT NOT NULL DEFAULT 1, -- 마감 가격정책(PriceGateAction): 목표가<투찰가 처리(1=낙찰/2=재협상/3=유찰). 투찰가≤앵커링가는 항상 낙찰(설정없음)
|
||||
regen_limit SMALLINT NOT NULL DEFAULT 1, -- 재생성 최대 횟수(체인 전체 총합, 사유 무관: 목표초과/동가/미참여 합산)
|
||||
-- 낙찰 정책(mid/over/regen)은 견적 단위로 이관, 앵커링은 칸 rate(anchoring v1.2)로 대체 → 세팅 컬럼 없음
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
||||
@ -281,7 +278,9 @@ CREATE TABLE IF NOT EXISTS quotation.quotations (
|
||||
preferred_sp_name VARCHAR(20) NULL, -- 선호 공급사명(스냅샷)
|
||||
equal_bid_yn BOOLEAN NULL, -- 동일가 입찰 발생 여부
|
||||
equal_bid_data JSONB NULL, -- 동일가 입찰 상세(JSON)
|
||||
close_reason SMALLINT NULL, -- 마감 사유(CloseReason): 1=낙찰,2=가격재협상,3=동가재입찰,4=미참여재소집,5=가격유찰,6=동가유찰,7=미참여유찰,8=거부유찰. 미마감이면 NULL
|
||||
close_reason SMALLINT NULL, -- 마감 사유(CloseReason): 1=낙찰, 5=가격개찰, 6=동가개찰, 7=미응찰개찰, 8=거부개찰. 미마감이면 NULL
|
||||
mid_action SMALLINT NOT NULL DEFAULT 1, -- 낙찰 기준(PriceGateAction 1=낙찰/2=개찰): 앵커링가<투찰가≤목표가 처리. 1:1 협상만 사용자 선택, 1:N 경매는 AWARD 강제
|
||||
over_action SMALLINT NOT NULL DEFAULT 1, -- 낙찰 기준(PriceGateAction 1=낙찰/2=개찰): 목표가<투찰가 처리(1:1 협상은 항상 개찰). 투찰가≤앵커링가는 항상 낙찰
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
||||
@ -70,3 +70,27 @@ ALTER TABLE quotation.quotation_settings
|
||||
ADD COLUMN IF NOT EXISTS mid_action SMALLINT NOT NULL DEFAULT 1, -- 가격정책(PriceGateAction): 앵커링가<투찰가≤목표가 처리
|
||||
ADD COLUMN IF NOT EXISTS over_action SMALLINT NOT NULL DEFAULT 1, -- 가격정책(PriceGateAction): 목표가<투찰가 처리
|
||||
ADD COLUMN IF NOT EXISTS regen_limit SMALLINT NOT NULL DEFAULT 1; -- 재생성 최대 횟수(체인 전체 총합, 사유 무관)
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────
|
||||
-- [2026-07-06] 낙찰 기준 견적 단위 이관 + 마감 개편(개찰 모델) — quotations(견적 행)에 낙찰 기준 mid/over 미러.
|
||||
-- 마감 판정이 견적 행에서 읽는다. 기준 미달/동가/거부/미응찰은 결렬(유찰)이 아니라 개찰(낙찰자 미정 마감).
|
||||
-- 자동 재협상/재생성 폐지 → regen_limit 제거(설정 템플릿 quotation_settings.regen_limit 은 미사용 잔존).
|
||||
-- (신규 DB 는 01-schema*.sql 에 반영됨. 기존 행은 DEFAULT 1=AWARD 로 백필.)
|
||||
ALTER TABLE quotation.quotations
|
||||
ADD COLUMN IF NOT EXISTS mid_action SMALLINT NOT NULL DEFAULT 1, -- 낙찰 기준(PriceGateAction 1=낙찰/2=개찰): 앵커링가<투찰가≤목표가 처리
|
||||
ADD COLUMN IF NOT EXISTS over_action SMALLINT NOT NULL DEFAULT 1; -- 낙찰 기준(PriceGateAction 1=낙찰/2=개찰): 목표가<투찰가 처리(1:1 협상은 항상 개찰)
|
||||
ALTER TABLE quotation.quotations
|
||||
DROP COLUMN IF EXISTS regen_limit; -- 자동 재생성 폐지로 제거(먼저 추가됐던 dev DB 대비 멱등 DROP)
|
||||
|
||||
-- 폐기된 close_reason 코드(구 REGEN_* 2~4) → 개찰(OPEN_*) 로 이관. 미이관 시 QuotationData(CloseReason enum) 검증 실패로 견적 목록 500.
|
||||
UPDATE quotation.quotations SET close_reason = 5 WHERE close_reason = 2; -- REGEN_PRICE → OPEN_PRICE
|
||||
UPDATE quotation.quotations SET close_reason = 6 WHERE close_reason = 3; -- REGEN_EQUAL → OPEN_EQUAL
|
||||
UPDATE quotation.quotations SET close_reason = 7 WHERE close_reason = 4; -- REGEN_NOSHOW → OPEN_NOSHOW
|
||||
|
||||
-- quotation_settings 정리 — 낙찰 정책(mid/over/regen)은 견적 단위 이관, 앵커링은 칸 rate(v1.2)로 대체 → 세팅 컬럼 제거.
|
||||
-- 미제거 시 QuotationSettingData(PriceGateAction enum, 값 3=옛 FAIL) 검증 실패로 견적 세팅 목록 500.
|
||||
ALTER TABLE quotation.quotation_settings
|
||||
DROP COLUMN IF EXISTS mid_action,
|
||||
DROP COLUMN IF EXISTS over_action,
|
||||
DROP COLUMN IF EXISTS regen_limit,
|
||||
DROP COLUMN IF EXISTS anchoring_value;
|
||||
Loading…
Reference in New Issue
Block a user