[feat] negodata: 낙찰 방식 구분(자동/직접) + 계약가·사유 저장 위치 재정리
직접 낙찰(담당자 오프라인 계약가)을 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 적용·화면 확인.
This commit is contained in:
parent
6eb4dc26f8
commit
0e4ddf0f43
@ -309,12 +309,14 @@ class quotations(MainTableMixin, MAIN_BASE):
|
|||||||
equal_bid_yn = Column(Boolean, nullable=True)
|
equal_bid_yn = Column(Boolean, nullable=True)
|
||||||
equal_bid_data = Column(JSONB, nullable=True)
|
equal_bid_data = Column(JSONB, nullable=True)
|
||||||
close_reason = Column(SmallInteger, nullable=True) # CloseReason 코드. 마감 시 사유 기록(재생성 한도 카운팅·유찰 사유 구분). 미마감이면 NULL
|
close_reason = Column(SmallInteger, nullable=True) # CloseReason 코드. 마감 시 사유 기록(재생성 한도 카운팅·유찰 사유 구분). 미마감이면 NULL
|
||||||
|
award_type = Column(SmallInteger, nullable=True) # AwardType 코드(1=자동/2=직접). 낙찰 방식 — 통계에서 AI 자동낙찰과 담당자 직접낙찰 구분. 미낙찰이면 NULL
|
||||||
|
|
||||||
# 낙찰 기준(가격게이트) — 견적 단위. 마감 판정(close_and_decide)이 이 행값을 읽는다. 기준 미달이면 개찰(낙찰자 미정 마감).
|
# 낙찰 기준(가격게이트) — 견적 단위. 마감 판정(close_and_decide)이 이 행값을 읽는다. 기준 미달이면 개찰(낙찰자 미정 마감).
|
||||||
# 1:1 협상: over 는 항상 OPEN(목표 초과=개찰), mid 만 앵커/목표 택1. 1:N 경매: mid=over=AWARD 강제(무조건 최저가 낙찰).
|
# 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=개찰)
|
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=개찰)
|
over_action = Column(SmallInteger, nullable=False, server_default=text("1"), default=1) # PriceGateAction: 목표가<투찰가 처리(1=낙찰/2=개찰)
|
||||||
done_ceiling_rate = Column(SmallInteger, nullable=True) # 협상 완료 상한율(‰) 견적별 override. NULL 이면 quotation_settings 값
|
done_ceiling_rate = Column(SmallInteger, nullable=True) # 협상 완료 상한율(‰) 견적별 override. NULL 이면 quotation_settings 값
|
||||||
|
custom = Column(JSONB, nullable=True) # 견적 단위 부가정보. 직접 낙찰 시 award={reason,by,at}(사유·처리자·시각). 표시·감사용(집계 안 함)
|
||||||
|
|
||||||
|
|
||||||
class sessions(MainTableMixin, MAIN_BASE):
|
class sessions(MainTableMixin, MAIN_BASE):
|
||||||
@ -340,6 +342,7 @@ class sessions(MainTableMixin, MAIN_BASE):
|
|||||||
reject_reason = Column(String(255), nullable=True)
|
reject_reason = Column(String(255), nullable=True)
|
||||||
reject_price = Column(BigInteger, nullable=True)
|
reject_price = Column(BigInteger, nullable=True)
|
||||||
reject_delivery_type = Column(SmallInteger, nullable=True) # DeliveryType 코드
|
reject_delivery_type = Column(SmallInteger, nullable=True) # DeliveryType 코드
|
||||||
|
contract_price = Column(BigInteger, nullable=True) # 직접 낙찰 계약가(원). 자동낙찰은 NULL(bid_price 가 계약가). 통계는 coalesce(contract_price, bid_price)
|
||||||
email_sent_at = Column(DateTime(timezone=True), nullable=True) # 협상 초청 메일 발송 시각(NULL=미발송)
|
email_sent_at = Column(DateTime(timezone=True), nullable=True) # 협상 초청 메일 발송 시각(NULL=미발송)
|
||||||
custom = Column(JSONB, nullable=True) # 협상완료 부가정보 값 {key: value} (정의는 companies.settings.session_fields)
|
custom = Column(JSONB, nullable=True) # 협상완료 부가정보 값 {key: value} (정의는 companies.settings.session_fields)
|
||||||
|
|
||||||
|
|||||||
@ -213,6 +213,15 @@ class CloseReason(CodeEnum):
|
|||||||
OPEN_REJECT = 8 # 개찰: 협상거부 존재
|
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):
|
class PriceGateAction(CodeEnum):
|
||||||
"""낙찰 기준(견적 단위) 가격게이트 판정값. '앵커링가<투찰가≤목표가'(mid) / '목표가<투찰가'(over) 구간에 적용.
|
"""낙찰 기준(견적 단위) 가격게이트 판정값. '앵커링가<투찰가≤목표가'(mid) / '목표가<투찰가'(over) 구간에 적용.
|
||||||
(투찰가≤앵커링가 는 항상 낙찰.) 기준 미달이면 개찰(낙찰자 미정 마감) — 재협상·결렬 없음.
|
(투찰가≤앵커링가 는 항상 낙찰.) 기준 미달이면 개찰(낙찰자 미정 마감) — 재협상·결렬 없음.
|
||||||
|
|||||||
@ -11,7 +11,7 @@ from common.database.model.models import (
|
|||||||
quotations, sessions, chats, nego_cards, wild_cards, items, suppliers, quotation_settings,
|
quotations, sessions, chats, nego_cards, wild_cards, items, suppliers, quotation_settings,
|
||||||
version_nego_cards, version_wild_cards, users, supplier_items, companies,
|
version_nego_cards, version_wild_cards, users, supplier_items, companies,
|
||||||
)
|
)
|
||||||
from common.enums import CloseReason, ErrorType, QuotationStatus, SessionStatus
|
from common.enums import AwardType, CloseReason, ErrorType, QuotationStatus, SessionStatus
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
|
|
||||||
@ -81,7 +81,11 @@ class IQuotationCRUD(ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def merge_session_custom(self, cdb: AsyncSession, session_id, patch: dict) -> ErrorType:
|
async def merge_quotation_custom(self, cdb: AsyncSession, qt_id, patch: dict) -> ErrorType:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def set_contract_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@ -547,6 +551,7 @@ class QuotationCRUD(IQuotationCRUD):
|
|||||||
)
|
)
|
||||||
.values(
|
.values(
|
||||||
close_reason=CloseReason.AWARDED.value,
|
close_reason=CloseReason.AWARDED.value,
|
||||||
|
award_type=AwardType.MANUAL.value, # 담당자 직접 낙찰(오프라인 계약가)
|
||||||
preferred_sp_yn=True,
|
preferred_sp_yn=True,
|
||||||
preferred_sp_id=supplier_id,
|
preferred_sp_id=supplier_id,
|
||||||
preferred_sp_name=supplier_name,
|
preferred_sp_name=supplier_name,
|
||||||
@ -576,16 +581,29 @@ class QuotationCRUD(IQuotationCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED
|
return ErrorType.DB_RUN_FAILED
|
||||||
|
|
||||||
async def merge_session_custom(self, cdb: AsyncSession, session_id, patch: dict) -> ErrorType:
|
async def merge_quotation_custom(self, cdb: AsyncSession, qt_id, patch: dict) -> ErrorType:
|
||||||
# sessions.custom 부분 갱신(기존 키 보존). 부가정보·의견·재협상 요청이 같은 컬럼을 쓰므로 덮어쓰면 안 된다.
|
# quotations.custom 부분 갱신(기존 키 보존). 직접 낙찰 사유(award) 등 견적 부가정보가 같은 컬럼을 쓴다.
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
update(quotations)
|
||||||
|
.where(quotations.qt_id == qt_id)
|
||||||
|
.values(
|
||||||
|
custom=func.coalesce(quotations.custom, cast(text("'{}'"), JSONB)).op("||")(cast(patch, JSONB)),
|
||||||
|
updated_at=GTime.UTC(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return await DB_SESSION_MNG.add(cdb, query)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED
|
||||||
|
|
||||||
|
async def set_contract_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType:
|
||||||
|
# 직접 낙찰 계약가를 낙찰 세션 컬럼에 박는다(자동낙찰 bid_price 와 같은 축).
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
update(sessions)
|
update(sessions)
|
||||||
.where(sessions.session_id == session_id)
|
.where(sessions.session_id == session_id)
|
||||||
.values(
|
.values(contract_price=price, updated_at=GTime.UTC())
|
||||||
custom=func.coalesce(sessions.custom, cast(text("'{}'"), JSONB)).op("||")(cast(patch, JSONB)),
|
|
||||||
updated_at=GTime.UTC(),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
return await DB_SESSION_MNG.add(cdb, query)
|
return await DB_SESSION_MNG.add(cdb, query)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
from sqlalchemy import select, func, and_, or_, case, cast, BigInteger
|
from sqlalchemy import select, func, and_, or_, case
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import quotations, sessions, items, chats, users
|
from common.database.model.models import quotations, sessions, items, chats, users
|
||||||
from common.enums import ErrorType, QuotationStatus, CloseReason, SessionStatus, CardType, ChatSender
|
from common.enums import AwardType, ErrorType, QuotationStatus, CloseReason, SessionStatus, CardType, ChatSender
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
|
|
||||||
|
|
||||||
@ -80,8 +80,8 @@ class StatisticsCRUD(IStatisticsCRUD):
|
|||||||
# 결렬·미응찰 건을 오프라인으로 다시 협상하고 직접 낙찰하면 시스템 투찰가가 없거나 실제 계약가와
|
# 결렬·미응찰 건을 오프라인으로 다시 협상하고 직접 낙찰하면 시스템 투찰가가 없거나 실제 계약가와
|
||||||
# 다르기 때문. 컬럼 이름은 bid_price 로 유지해 statistics_service 는 그대로 쓴다.
|
# 다르기 때문. 컬럼 이름은 bid_price 로 유지해 statistics_service 는 그대로 쓴다.
|
||||||
try:
|
try:
|
||||||
offline_price = cast(sessions.custom["offline_award"]["price"].astext, BigInteger)
|
# 계약가 = 직접 낙찰 계약가(있으면) 우선, 없으면 투찰가. 둘 다 세션 컬럼이라 인덱스·타입 안전.
|
||||||
award_price = func.coalesce(offline_price, sessions.bid_price).label("bid_price")
|
award_price = func.coalesce(sessions.contract_price, sessions.bid_price).label("bid_price")
|
||||||
stmt = (
|
stmt = (
|
||||||
select(
|
select(
|
||||||
quotations.updated_at,
|
quotations.updated_at,
|
||||||
@ -90,7 +90,8 @@ class StatisticsCRUD(IStatisticsCRUD):
|
|||||||
sessions.target_price,
|
sessions.target_price,
|
||||||
award_price,
|
award_price,
|
||||||
sessions.anchoring_price,
|
sessions.anchoring_price,
|
||||||
offline_price.isnot(None).label("is_offline"), # 오프라인 협상 반영 건수 표기용
|
# 직접 낙찰 여부는 quotations.award_type(전용 컬럼) 기준. 옛 데이터(백필 전)만 offline_award 폴백.
|
||||||
|
(func.coalesce(quotations.award_type, 0) == AwardType.MANUAL.value).label("is_offline"),
|
||||||
)
|
)
|
||||||
.select_from(quotations)
|
.select_from(quotations)
|
||||||
.join(
|
.join(
|
||||||
@ -98,7 +99,7 @@ class StatisticsCRUD(IStatisticsCRUD):
|
|||||||
and_(
|
and_(
|
||||||
sessions.quotation_id == quotations.qt_id,
|
sessions.quotation_id == quotations.qt_id,
|
||||||
sessions.supplier_id == quotations.preferred_sp_id,
|
sessions.supplier_id == quotations.preferred_sp_id,
|
||||||
or_(sessions.bid_price.isnot(None), offline_price.isnot(None)),
|
or_(sessions.bid_price.isnot(None), sessions.contract_price.isnot(None)),
|
||||||
sessions.deleted == False, # noqa: E712
|
sessions.deleted == False, # noqa: E712
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,7 +4,7 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from pydantic import ConfigDict
|
from pydantic import ConfigDict
|
||||||
|
|
||||||
from common.enums import CardType, ChatSender, CloseReason, DeliveryType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus
|
from common.enums import AwardType, CardType, ChatSender, CloseReason, DeliveryType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus
|
||||||
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
|
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
|
||||||
|
|
||||||
|
|
||||||
@ -77,6 +77,8 @@ class QuotationData(WebPacketProtocol):
|
|||||||
equal_bid_yn: Optional[bool] = None
|
equal_bid_yn: Optional[bool] = None
|
||||||
equal_bid_data: Optional[Any] = None
|
equal_bid_data: Optional[Any] = None
|
||||||
close_reason: Optional[CloseReason] = None # 마감 사유(CloseReason). 미마감이면 None
|
close_reason: Optional[CloseReason] = None # 마감 사유(CloseReason). 미마감이면 None
|
||||||
|
award_type: Optional[AwardType] = None # 낙찰 방식(AwardType 1=자동/2=직접). 미낙찰이면 None
|
||||||
|
custom: Optional[dict] = None # 견적 부가정보. 직접 낙찰 시 award={reason,by,at}(사유·처리자·시각)
|
||||||
mid_action: Optional[int] = None # 낙찰 기준(견적 단위). 상세 드로어 낙찰기준 표시용
|
mid_action: Optional[int] = None # 낙찰 기준(견적 단위). 상세 드로어 낙찰기준 표시용
|
||||||
over_action: Optional[int] = None
|
over_action: Optional[int] = None
|
||||||
done_ceiling_rate: Optional[int] = None # 타결 상한율(‰) 견적 override. None 이면 견적 세팅값을 따름
|
done_ceiling_rate: Optional[int] = None # 타결 상한율(‰) 견적 override. None 이면 견적 세팅값을 따름
|
||||||
@ -120,6 +122,7 @@ class SessionData(WebPacketProtocol):
|
|||||||
reject_reason: Optional[str] = None
|
reject_reason: Optional[str] = None
|
||||||
reject_price: Optional[int] = None
|
reject_price: Optional[int] = None
|
||||||
reject_delivery_type: Optional[DeliveryType] = None
|
reject_delivery_type: Optional[DeliveryType] = None
|
||||||
|
contract_price: Optional[int] = None # 직접 낙찰 계약가(원). 자동낙찰은 None(bid_price 가 계약가)
|
||||||
email_sent_at: Optional[datetime] = None # 협상 초청 메일 발송 시각(None=미발송). 프론트 발송배지/재발송 판단
|
email_sent_at: Optional[datetime] = None # 협상 초청 메일 발송 시각(None=미발송). 프론트 발송배지/재발송 판단
|
||||||
custom: Optional[dict] = None # 협상완료 부가정보 값 {key: value} (공급사가 타결 후 입력, 정의는 companies.settings.session_fields)
|
custom: Optional[dict] = None # 협상완료 부가정보 값 {key: value} (공급사가 타결 후 입력, 정의는 companies.settings.session_fields)
|
||||||
url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성
|
url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from typing import Optional
|
|||||||
from common.authz import is_owner_or_admin
|
from common.authz import is_owner_or_admin
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import quotations, sessions
|
from common.database.model.models import quotations, sessions
|
||||||
from common.enums import CloseOutcome, CloseReason, DBWRType, ErrorType, NotificationType, PriceGateAction, QuotationStatus, SessionStatus
|
from common.enums import AwardType, CloseOutcome, CloseReason, DBWRType, ErrorType, NotificationType, PriceGateAction, QuotationStatus, SessionStatus
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
from router.v1.quotation.protocol import Res_Quotation
|
from router.v1.quotation.protocol import Res_Quotation
|
||||||
@ -116,6 +116,7 @@ class ClosingMixin:
|
|||||||
await self._close(qt_uuid, CloseReason.AWARDED.value, {
|
await self._close(qt_uuid, CloseReason.AWARDED.value, {
|
||||||
"preferred_sp_yn": True, "preferred_sp_id": winner["supplier_id"],
|
"preferred_sp_yn": True, "preferred_sp_id": winner["supplier_id"],
|
||||||
"preferred_sp_name": (winner["name"] or "")[:20], "equal_bid_yn": False,
|
"preferred_sp_name": (winner["name"] or "")[:20], "equal_bid_yn": False,
|
||||||
|
"award_type": AwardType.AUTO.value, # 시스템 자동 낙찰(투찰가 기준)
|
||||||
})
|
})
|
||||||
await create_notification(
|
await create_notification(
|
||||||
original.user_id, NotificationType.SUCCESS,
|
original.user_id, NotificationType.SUCCESS,
|
||||||
@ -225,21 +226,23 @@ class ClosingMixin:
|
|||||||
res.msg = "이미 낙찰 처리된 견적입니다."
|
res.msg = "이미 낙찰 처리된 견적입니다."
|
||||||
return res
|
return res
|
||||||
|
|
||||||
# 계약가를 낙찰 세션에 남긴다 — 통계가 이 값을 계약가로 읽고(투찰가보다 우선), 누가 언제 어떤
|
# 계약가는 낙찰 세션의 contract_price 컬럼에(협력사 가격 — bid_price/reject_price 와 같은 축, 통계 집계 대상).
|
||||||
# 근거로 확정했는지 추적한다. custom 은 부가정보·의견과 같은 컬럼이라 병합(덮어쓰기 금지).
|
# 사유·처리자·시각은 견적의 custom.award 에(견적 단위 결정 — 표시·감사용, 집계 안 함). 둘 다 한 트랜잭션.
|
||||||
offline_award = {
|
award_meta = {
|
||||||
"price": winner_price,
|
"reason": (contract_note or "").strip()[:255],
|
||||||
"note": (contract_note or "").strip()[:255],
|
|
||||||
"by": str(user_id) if user_id else "",
|
"by": str(user_id) if user_id else "",
|
||||||
"at": GTime.UTC().isoformat(timespec="seconds"),
|
"at": GTime.UTC().isoformat(timespec="seconds"),
|
||||||
}
|
}
|
||||||
award_err = await DB_SESSION_MNG.execute_lambda_run(
|
award_err = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
[sessions.DBType()],
|
[sessions.DBType()],
|
||||||
[lambda s: self.quotation_crud.merge_session_custom(s, winner.session_id, {"offline_award": offline_award})],
|
[
|
||||||
|
lambda s: self.quotation_crud.set_contract_price(s, winner.session_id, winner_price),
|
||||||
|
lambda s: self.quotation_crud.merge_quotation_custom(s, qt_uuid, {"award": award_meta}),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
if award_err != ErrorType.SUCCESS:
|
if award_err != ErrorType.SUCCESS:
|
||||||
# 낙찰(견적)은 이미 선점 전이됐다 — 계약가만 못 남긴 상태라 되돌리지 않고 경고로 남긴다.
|
# 낙찰(견적)은 이미 선점 전이됐다 — 계약가·사유만 못 남긴 상태라 되돌리지 않고 경고로 남긴다.
|
||||||
LOG.w(f"[award] 계약가 기록 실패 qt_id={qt_id} session_id={winner.session_id} price={winner_price}")
|
LOG.w(f"[award] 계약가/사유 기록 실패 qt_id={qt_id} session_id={winner.session_id} price={winner_price}")
|
||||||
|
|
||||||
# 작성자 알림 — 자동낙찰과 같은 SUCCESS 코드, manual 플래그로 '직접 낙찰' 구분.
|
# 작성자 알림 — 자동낙찰과 같은 SUCCESS 코드, manual 플래그로 '직접 낙찰' 구분.
|
||||||
await create_notification(
|
await create_notification(
|
||||||
|
|||||||
@ -15,7 +15,7 @@ from datetime import datetime
|
|||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from common.enums import CloseReason, ErrorType, QuotationStatus, QuotationType, SessionStatus, UserRole
|
from common.enums import AwardType, CloseReason, ErrorType, QuotationStatus, QuotationType, SessionStatus, UserRole
|
||||||
from crud.quotation_crud import QuotationCRUD
|
from crud.quotation_crud import QuotationCRUD
|
||||||
from services.quotation import QuotationService
|
from services.quotation import QuotationService
|
||||||
|
|
||||||
@ -44,6 +44,7 @@ async def test_award_opened_sets_winner_and_notifies(clean):
|
|||||||
assert res.result.success is True
|
assert res.result.success is True
|
||||||
row = await _quotation(engine, qt)
|
row = await _quotation(engine, qt)
|
||||||
assert row.close_reason == CloseReason.AWARDED.value
|
assert row.close_reason == CloseReason.AWARDED.value
|
||||||
|
assert row.award_type == AwardType.MANUAL.value # 직접 낙찰 — 통계에서 자동낙찰과 구분
|
||||||
assert row.preferred_sp_yn is True
|
assert row.preferred_sp_yn is True
|
||||||
assert str(row.preferred_sp_id) == str(supplier_a)
|
assert str(row.preferred_sp_id) == str(supplier_a)
|
||||||
assert row.equal_bid_yn is False
|
assert row.equal_bid_yn is False
|
||||||
@ -141,9 +142,9 @@ async def test_award_allows_owner_role(clean):
|
|||||||
assert len(await _notifications(engine, admin)) == 0
|
assert len(await _notifications(engine, admin)) == 0
|
||||||
|
|
||||||
|
|
||||||
async def test_award_records_contract_price_on_session(clean):
|
async def test_award_records_contract_price_and_reason(clean):
|
||||||
"""검증: 협력사 제출가(100)와 다른 계약가(88)로 직접 낙찰 — 오프라인 재협상 결과 반영.
|
"""검증: 협력사 제출가(100)와 다른 계약가(88)로 직접 낙찰 — 오프라인 재협상 결과 반영.
|
||||||
기대결과: 낙찰 세션 custom.offline_award 에 계약가·메모가 남고, 알림 winner_price 도 계약가."""
|
기대결과: 계약가는 낙찰 세션 contract_price 컬럼에, 사유·처리자는 견적 custom.award 에 남고, 알림 winner_price 도 계약가."""
|
||||||
engine = clean
|
engine = clean
|
||||||
user_id, supplier_a = uuid.uuid4(), uuid.uuid4()
|
user_id, supplier_a = uuid.uuid4(), uuid.uuid4()
|
||||||
qt = await _seed_opened(engine, user_id=user_id, number="A-OFFLINE", close_reason=CloseReason.OPEN_PRICE.value)
|
qt = await _seed_opened(engine, user_id=user_id, number="A-OFFLINE", close_reason=CloseReason.OPEN_PRICE.value)
|
||||||
@ -155,13 +156,18 @@ async def test_award_records_contract_price_on_session(clean):
|
|||||||
|
|
||||||
assert res.result.success is True
|
assert res.result.success is True
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
row = (await conn.execute(
|
# 계약가 = 세션 컬럼(협력사 가격 — bid_price/reject_price 와 같은 축)
|
||||||
text("SELECT custom FROM sessions WHERE quotation_id = :qt AND supplier_id = :sp"),
|
sess_price = (await conn.execute(
|
||||||
|
text("SELECT contract_price FROM sessions WHERE quotation_id = :qt AND supplier_id = :sp"),
|
||||||
{"qt": qt, "sp": supplier_a},
|
{"qt": qt, "sp": supplier_a},
|
||||||
)).first()
|
)).scalar()
|
||||||
award = row.custom["offline_award"]
|
# 사유·처리자 = 견적 custom.award(견적 단위 결정)
|
||||||
assert award["price"] == 88
|
qt_custom = (await conn.execute(
|
||||||
assert award["note"] == "오프라인 협상, 8/12 통화 합의"
|
text("SELECT custom FROM quotations WHERE qt_id = :qt"), {"qt": qt},
|
||||||
|
)).scalar()
|
||||||
|
assert sess_price == 88
|
||||||
|
award = qt_custom["award"]
|
||||||
|
assert award["reason"] == "오프라인 협상, 8/12 통화 합의"
|
||||||
assert award["by"] == str(user_id)
|
assert award["by"] == str(user_id)
|
||||||
notis = await _notifications(engine, user_id)
|
notis = await _notifications(engine, user_id)
|
||||||
assert notis[0][1]["winner_price"] == 88
|
assert notis[0][1]["winner_price"] == 88
|
||||||
@ -245,7 +251,7 @@ async def _quotation(engine, qt_id):
|
|||||||
"""견적 1행(마감 결과 컬럼 확인용)."""
|
"""견적 1행(마감 결과 컬럼 확인용)."""
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
return (await conn.execute(
|
return (await conn.execute(
|
||||||
text("SELECT close_reason, preferred_sp_yn, preferred_sp_id, equal_bid_yn "
|
text("SELECT close_reason, award_type, preferred_sp_yn, preferred_sp_id, equal_bid_yn "
|
||||||
"FROM quotations WHERE qt_id = :qt_id"),
|
"FROM quotations WHERE qt_id = :qt_id"),
|
||||||
{"qt_id": qt_id},
|
{"qt_id": qt_id},
|
||||||
)).one()
|
)).one()
|
||||||
|
|||||||
@ -20,7 +20,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|||||||
from apscheduler.triggers.interval import IntervalTrigger
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from common.enums import QuotationStatus, QuotationType, SessionStatus
|
from common.enums import AwardType, QuotationStatus, QuotationType, SessionStatus
|
||||||
from scheduler import jobs
|
from scheduler import jobs
|
||||||
|
|
||||||
PAST = datetime(2020, 1, 1) # 마감시각이 이미 지난 시점(잡①의 마감 대상)
|
PAST = datetime(2020, 1, 1) # 마감시각이 이미 지난 시점(잡①의 마감 대상)
|
||||||
@ -95,6 +95,7 @@ async def test_award_single_lowest(clean):
|
|||||||
assert row.status == QuotationStatus.CLOSED.value
|
assert row.status == QuotationStatus.CLOSED.value
|
||||||
assert row.preferred_sp_yn is True # 낙찰자 있음
|
assert row.preferred_sp_yn is True # 낙찰자 있음
|
||||||
assert str(row.preferred_sp_id) == str(winner) # 최저가가 단독이라 그 공급사로 확정
|
assert str(row.preferred_sp_id) == str(winner) # 최저가가 단독이라 그 공급사로 확정
|
||||||
|
assert row.award_type == AwardType.AUTO.value # 자동 낙찰(투찰가 기준)
|
||||||
|
|
||||||
|
|
||||||
async def test_rejected_just_closes(clean):
|
async def test_rejected_just_closes(clean):
|
||||||
@ -205,6 +206,6 @@ async def _quotation_row(engine, qt_id):
|
|||||||
"""견적 1건을 다시 읽어온다(마감 후 status·낙찰자 확인용)."""
|
"""견적 1건을 다시 읽어온다(마감 후 status·낙찰자 확인용)."""
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
return (await conn.execute(
|
return (await conn.execute(
|
||||||
text("SELECT status, preferred_sp_yn, preferred_sp_id FROM quotations WHERE qt_id = :id"),
|
text("SELECT status, preferred_sp_yn, preferred_sp_id, award_type FROM quotations WHERE qt_id = :id"),
|
||||||
{"id": qt_id},
|
{"id": qt_id},
|
||||||
)).first()
|
)).first()
|
||||||
|
|||||||
20
negodata/front/src/api/generated/model/awardType.ts
Normal file
20
negodata/front/src/api/generated/model/awardType.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* quotations.award_type 코드값(SMALLINT). 낙찰이 어떻게 확정됐는지 — 통계에서 AI 협상 성과와
|
||||||
|
담당자 오프라인 낙찰을 나눈다. 미마감·미낙찰(개찰)이면 NULL.
|
||||||
|
AUTO=시스템이 투찰가로 자동 낙찰 / MANUAL=담당자가 개찰 건을 오프라인 협상해 계약가로 직접 낙찰.
|
||||||
|
*/
|
||||||
|
export type AwardType = typeof AwardType[keyof typeof AwardType];
|
||||||
|
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||||
|
export const AwardType = {
|
||||||
|
AUTO: 1,
|
||||||
|
MANUAL: 2,
|
||||||
|
} as const;
|
||||||
@ -9,6 +9,7 @@ export * from './anchoringCell';
|
|||||||
export * from './anchoringCellLastAdjustedAt';
|
export * from './anchoringCellLastAdjustedAt';
|
||||||
export * from './anchoringHistoryRow';
|
export * from './anchoringHistoryRow';
|
||||||
export * from './anchoringHistoryRowCreatedAt';
|
export * from './anchoringHistoryRowCreatedAt';
|
||||||
|
export * from './awardType';
|
||||||
export * from './bodyUploadItemImageV1ItemImagePost';
|
export * from './bodyUploadItemImageV1ItemImagePost';
|
||||||
export * from './cardData';
|
export * from './cardData';
|
||||||
export * from './cardDataCondition';
|
export * from './cardDataCondition';
|
||||||
@ -126,9 +127,12 @@ export * from './quotationCardDataScript';
|
|||||||
export * from './quotationCardDataType';
|
export * from './quotationCardDataType';
|
||||||
export * from './quotationCardDataWildCardId';
|
export * from './quotationCardDataWildCardId';
|
||||||
export * from './quotationData';
|
export * from './quotationData';
|
||||||
|
export * from './quotationDataAwardType';
|
||||||
export * from './quotationDataCloseReason';
|
export * from './quotationDataCloseReason';
|
||||||
export * from './quotationDataCreatedAt';
|
export * from './quotationDataCreatedAt';
|
||||||
export * from './quotationDataCreatorName';
|
export * from './quotationDataCreatorName';
|
||||||
|
export * from './quotationDataCustom';
|
||||||
|
export * from './quotationDataCustomAnyOf';
|
||||||
export * from './quotationDataDoneCeilingRate';
|
export * from './quotationDataDoneCeilingRate';
|
||||||
export * from './quotationDataEqualBidData';
|
export * from './quotationDataEqualBidData';
|
||||||
export * from './quotationDataEqualBidYn';
|
export * from './quotationDataEqualBidYn';
|
||||||
@ -434,6 +438,7 @@ export * from './sessionData';
|
|||||||
export * from './sessionDataAnchoringPrice';
|
export * from './sessionDataAnchoringPrice';
|
||||||
export * from './sessionDataBidAt';
|
export * from './sessionDataBidAt';
|
||||||
export * from './sessionDataBidPrice';
|
export * from './sessionDataBidPrice';
|
||||||
|
export * from './sessionDataContractPrice';
|
||||||
export * from './sessionDataCustom';
|
export * from './sessionDataCustom';
|
||||||
export * from './sessionDataCustomAnyOf';
|
export * from './sessionDataCustomAnyOf';
|
||||||
export * from './sessionDataDoneCeilingPrice';
|
export * from './sessionDataDoneCeilingPrice';
|
||||||
|
|||||||
@ -17,6 +17,8 @@ import type { QuotationDataPreferredSpName } from './quotationDataPreferredSpNam
|
|||||||
import type { QuotationDataEqualBidYn } from './quotationDataEqualBidYn';
|
import type { QuotationDataEqualBidYn } from './quotationDataEqualBidYn';
|
||||||
import type { QuotationDataEqualBidData } from './quotationDataEqualBidData';
|
import type { QuotationDataEqualBidData } from './quotationDataEqualBidData';
|
||||||
import type { QuotationDataCloseReason } from './quotationDataCloseReason';
|
import type { QuotationDataCloseReason } from './quotationDataCloseReason';
|
||||||
|
import type { QuotationDataAwardType } from './quotationDataAwardType';
|
||||||
|
import type { QuotationDataCustom } from './quotationDataCustom';
|
||||||
import type { QuotationDataMidAction } from './quotationDataMidAction';
|
import type { QuotationDataMidAction } from './quotationDataMidAction';
|
||||||
import type { QuotationDataOverAction } from './quotationDataOverAction';
|
import type { QuotationDataOverAction } from './quotationDataOverAction';
|
||||||
import type { QuotationDataDoneCeilingRate } from './quotationDataDoneCeilingRate';
|
import type { QuotationDataDoneCeilingRate } from './quotationDataDoneCeilingRate';
|
||||||
@ -50,6 +52,8 @@ export interface QuotationData {
|
|||||||
equal_bid_yn?: QuotationDataEqualBidYn;
|
equal_bid_yn?: QuotationDataEqualBidYn;
|
||||||
equal_bid_data?: QuotationDataEqualBidData;
|
equal_bid_data?: QuotationDataEqualBidData;
|
||||||
close_reason?: QuotationDataCloseReason;
|
close_reason?: QuotationDataCloseReason;
|
||||||
|
award_type?: QuotationDataAwardType;
|
||||||
|
custom?: QuotationDataCustom;
|
||||||
mid_action?: QuotationDataMidAction;
|
mid_action?: QuotationDataMidAction;
|
||||||
over_action?: QuotationDataOverAction;
|
over_action?: QuotationDataOverAction;
|
||||||
done_ceiling_rate?: QuotationDataDoneCeilingRate;
|
done_ceiling_rate?: QuotationDataDoneCeilingRate;
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { AwardType } from './awardType';
|
||||||
|
|
||||||
|
export type QuotationDataAwardType = AwardType | null;
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { QuotationDataCustomAnyOf } from './quotationDataCustomAnyOf';
|
||||||
|
|
||||||
|
export type QuotationDataCustom = QuotationDataCustomAnyOf | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type QuotationDataCustomAnyOf = { [key: string]: unknown };
|
||||||
@ -13,6 +13,7 @@ import type { SessionDataBidAt } from './sessionDataBidAt';
|
|||||||
import type { SessionDataRejectReason } from './sessionDataRejectReason';
|
import type { SessionDataRejectReason } from './sessionDataRejectReason';
|
||||||
import type { SessionDataRejectPrice } from './sessionDataRejectPrice';
|
import type { SessionDataRejectPrice } from './sessionDataRejectPrice';
|
||||||
import type { SessionDataRejectDeliveryType } from './sessionDataRejectDeliveryType';
|
import type { SessionDataRejectDeliveryType } from './sessionDataRejectDeliveryType';
|
||||||
|
import type { SessionDataContractPrice } from './sessionDataContractPrice';
|
||||||
import type { SessionDataEmailSentAt } from './sessionDataEmailSentAt';
|
import type { SessionDataEmailSentAt } from './sessionDataEmailSentAt';
|
||||||
import type { SessionDataCustom } from './sessionDataCustom';
|
import type { SessionDataCustom } from './sessionDataCustom';
|
||||||
|
|
||||||
@ -34,6 +35,7 @@ export interface SessionData {
|
|||||||
reject_reason?: SessionDataRejectReason;
|
reject_reason?: SessionDataRejectReason;
|
||||||
reject_price?: SessionDataRejectPrice;
|
reject_price?: SessionDataRejectPrice;
|
||||||
reject_delivery_type?: SessionDataRejectDeliveryType;
|
reject_delivery_type?: SessionDataRejectDeliveryType;
|
||||||
|
contract_price?: SessionDataContractPrice;
|
||||||
email_sent_at?: SessionDataEmailSentAt;
|
email_sent_at?: SessionDataEmailSentAt;
|
||||||
custom?: SessionDataCustom;
|
custom?: SessionDataCustom;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SessionDataContractPrice = number | null;
|
||||||
@ -1,10 +1,11 @@
|
|||||||
import { BadgeCheck } from 'lucide-react';
|
import { BadgeCheck, Gavel } from 'lucide-react';
|
||||||
import { Typography } from '@/components/ui/typography';
|
import { Typography } from '@/components/ui/typography';
|
||||||
import type { QuotationData } from '@/api/generated/model/quotationData';
|
import type { QuotationData } from '@/api/generated/model/quotationData';
|
||||||
import { StatusPill, sessionStatusTone, type PillTone } from './StatusPill';
|
import { StatusPill, sessionStatusTone, type PillTone } from './StatusPill';
|
||||||
import { PricingSpectrum, type SpectrumBid } from './PricingSpectrum';
|
import { PricingSpectrum, type SpectrumBid } from './PricingSpectrum';
|
||||||
import { SessionStatus } from '@/api/generated/model';
|
import { AwardType, SessionStatus } from '@/api/generated/model';
|
||||||
import {
|
import {
|
||||||
|
awardMeta,
|
||||||
awardPrice,
|
awardPrice,
|
||||||
buildQuotationResult,
|
buildQuotationResult,
|
||||||
ceilingPriceOf,
|
ceilingPriceOf,
|
||||||
@ -42,6 +43,9 @@ export function ResultSummaryBand({
|
|||||||
// 1:1 은 협력사가 하나라 그 세션이 곧 이 협상이다(세션이 여럿이면 대표를 세우지 않는다).
|
// 1:1 은 협력사가 하나라 그 세션이 곧 이 협상이다(세션이 여럿이면 대표를 세우지 않는다).
|
||||||
const oneToOne = is1v1(quotation.type);
|
const oneToOne = is1v1(quotation.type);
|
||||||
const solo = oneToOne && sessionViews.length === 1 ? sessionViews[0] : null;
|
const solo = oneToOne && sessionViews.length === 1 ? sessionViews[0] : null;
|
||||||
|
// 직접 낙찰 배지(담당자 오프라인 계약가)는 낙찰 방식이 MANUAL 이면 항상 보여준다 — 이게 핵심 정보다.
|
||||||
|
const showManualBadge = quotation.award_type === AwardType.MANUAL;
|
||||||
|
const reason = awardMeta(quotation)?.reason ?? '';
|
||||||
|
|
||||||
// 가격을 제출한 협력사만 점으로. 낙찰자는 강조.
|
// 가격을 제출한 협력사만 점으로. 낙찰자는 강조.
|
||||||
const bids: SpectrumBid[] = sessionViews.flatMap((s) => {
|
const bids: SpectrumBid[] = sessionViews.flatMap((s) => {
|
||||||
@ -71,7 +75,8 @@ export function ResultSummaryBand({
|
|||||||
: OUTCOME_TONE[r.outcome]
|
: OUTCOME_TONE[r.outcome]
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{r.outcome === 'active'
|
{/* 직접 낙찰 배지가 따로 '낙찰'을 말하므로, 그때는 '— 낙찰' 꼬리를 떼고 세션 상태만 둔다(중복 방지). */}
|
||||||
|
{r.outcome === 'active' || showManualBadge
|
||||||
? sessionStatusLabel(solo.status)
|
? sessionStatusLabel(solo.status)
|
||||||
: `${sessionStatusLabel(solo.status)} — ${CHAIN_ROUND_STATE_LABEL[r.outcome]}`}
|
: `${sessionStatusLabel(solo.status)} — ${CHAIN_ROUND_STATE_LABEL[r.outcome]}`}
|
||||||
</StatusPill>
|
</StatusPill>
|
||||||
@ -91,11 +96,27 @@ export function ResultSummaryBand({
|
|||||||
</Typography>
|
</Typography>
|
||||||
) : (
|
) : (
|
||||||
<Typography as="p" variant="small" className="min-w-0 truncate text-[12px] font-bold">
|
<Typography as="p" variant="small" className="min-w-0 truncate text-[12px] font-bold">
|
||||||
{r.closeReason}
|
{/* 옆의 결과 배지가 이미 '개찰'을 말하므로 사유 텍스트의 '— 개찰' 꼬리는 뗀다(예: '전원 미응찰 — 개찰' → '전원 미응찰'). */}
|
||||||
|
{r.closeReason.replace(/\s*—\s*개찰.*$/, '')}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 직접 낙찰(담당자 오프라인 계약가) — AI 자동 낙찰과 구분해 표시. 자동 낙찰엔 배지 없음. */}
|
||||||
|
{showManualBadge && (
|
||||||
|
<StatusPill tone="blue" className="gap-1">
|
||||||
|
<Gavel size={11} />
|
||||||
|
직접 낙찰
|
||||||
|
</StatusPill>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 직접 낙찰 사유(견적 custom.award.reason) — 왜 이 협력사를 오프라인으로 낙찰했는지 근거. */}
|
||||||
|
{showManualBadge && reason && (
|
||||||
|
<Typography as="p" variant="caption" className="text-[11px] text-muted-foreground">
|
||||||
|
낙찰 사유 · {reason}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
<PricingSpectrum
|
<PricingSpectrum
|
||||||
targetPrice={r.targetPrice}
|
targetPrice={r.targetPrice}
|
||||||
ceilingPrice={ceilingPriceOf(rep, ceilingRate)}
|
ceilingPrice={ceilingPriceOf(rep, ceilingRate)}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { SessionStatus } from '@/api/generated/model';
|
|||||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||||
import { StatusPill, sessionStatusTone } from './StatusPill';
|
import { StatusPill, sessionStatusTone } from './StatusPill';
|
||||||
import { AwardModal } from './AwardModal';
|
import { AwardModal } from './AwardModal';
|
||||||
import { awardPrice, offlineAward, mapServerSessionView, sessionStatusLabel } from '../../types';
|
import { awardPrice, directAwardPrice, mapServerSessionView, sessionStatusLabel } from '../../types';
|
||||||
import { useCompanySettings } from '@/features/settings/useCompanySettings';
|
import { useCompanySettings } from '@/features/settings/useCompanySettings';
|
||||||
|
|
||||||
type SessionView = ReturnType<typeof mapServerSessionView>;
|
type SessionView = ReturnType<typeof mapServerSessionView>;
|
||||||
@ -43,15 +43,15 @@ export function SessionsStatusTab({
|
|||||||
const { settings } = useCompanySettings();
|
const { settings } = useCompanySettings();
|
||||||
const sessionFields = settings.session_fields ?? [];
|
const sessionFields = settings.session_fields ?? [];
|
||||||
const [extraSession, setExtraSession] = useState<SessionView | null>(null);
|
const [extraSession, setExtraSession] = useState<SessionView | null>(null);
|
||||||
|
// 부가정보 = 회사가 정의한 필드(session_fields)만. 협상완료 세션이 입력한다.
|
||||||
const extraRows = (sv: SessionView | null) => {
|
const extraRows = (sv: SessionView | null) => {
|
||||||
const custom = sv?.custom as Record<string, unknown> | undefined;
|
const custom = sv?.custom as Record<string, unknown> | undefined;
|
||||||
const rows = sessionFields
|
return sessionFields
|
||||||
.map((f) => ({ label: f.label, value: custom?.[f.key] }))
|
.map((f) => ({ label: f.label, value: custom?.[f.key] }))
|
||||||
.filter((r) => r.value !== undefined && r.value !== null && r.value !== '');
|
.filter((r) => r.value !== undefined && r.value !== null && r.value !== '');
|
||||||
// 의견은 회사설정과 무관한 내장 공통 필드(custom.opinion) — 값 있으면 항상 표시
|
|
||||||
if (custom?.opinion) rows.push({ label: '의견', value: custom.opinion });
|
|
||||||
return rows;
|
|
||||||
};
|
};
|
||||||
|
// 의견은 회사설정과 무관한 공통 필드(custom.opinion) — 완료·거부 어느 쪽이든 남긴다. 부가정보와 별개 컬럼.
|
||||||
|
const opinionOf = (sv: SessionView | null): string => String((sv?.custom as Record<string, unknown> | undefined)?.opinion ?? '');
|
||||||
const [sendingAll, setSendingAll] = useState(false);
|
const [sendingAll, setSendingAll] = useState(false);
|
||||||
const [sendingId, setSendingId] = useState<string | null>(null);
|
const [sendingId, setSendingId] = useState<string | null>(null);
|
||||||
const [selectedWinnerId, setSelectedWinnerId] = useState<string | null>(null);
|
const [selectedWinnerId, setSelectedWinnerId] = useState<string | null>(null);
|
||||||
@ -183,6 +183,7 @@ export function SessionsStatusTab({
|
|||||||
<TableHead className="p-3 font-semibold text-right">거부가격</TableHead>
|
<TableHead className="p-3 font-semibold text-right">거부가격</TableHead>
|
||||||
<TableHead className="p-3 font-semibold font-sans">거부배송방식</TableHead>
|
<TableHead className="p-3 font-semibold font-sans">거부배송방식</TableHead>
|
||||||
<TableHead className="p-3 font-semibold font-sans">부가정보</TableHead>
|
<TableHead className="p-3 font-semibold font-sans">부가정보</TableHead>
|
||||||
|
<TableHead className="p-3 font-semibold font-sans">의견</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody className="divide-y divide-border">
|
<TableBody className="divide-y divide-border">
|
||||||
@ -332,9 +333,9 @@ export function SessionsStatusTab({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="p-3 text-right font-bold text-foreground">
|
<TableCell className="p-3 text-right font-bold text-foreground">
|
||||||
{sess.bid_price ? `₩${sess.bid_price.toLocaleString()}` : '-'}
|
{sess.bid_price ? `₩${sess.bid_price.toLocaleString()}` : '-'}
|
||||||
{offlineAward(sess) && (
|
{directAwardPrice(sess) != null && (
|
||||||
<Typography as="span" variant="caption" className="block text-[10px] font-bold text-success">
|
<Typography as="span" variant="caption" className="block text-[10px] font-bold text-success">
|
||||||
계약 ₩{offlineAward(sess)!.price.toLocaleString()}
|
계약 ₩{directAwardPrice(sess)!.toLocaleString()}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@ -350,6 +351,15 @@ export function SessionsStatusTab({
|
|||||||
<TableCell className="p-3 font-sans">
|
<TableCell className="p-3 font-sans">
|
||||||
<ExtraInfoCell rows={extraRows(sess)} onOpen={() => setExtraSession(sess)} />
|
<ExtraInfoCell rows={extraRows(sess)} onOpen={() => setExtraSession(sess)} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="p-3 font-sans text-muted-foreground">
|
||||||
|
{opinionOf(sess) ? (
|
||||||
|
<span className="block max-w-[220px] truncate" title={opinionOf(sess)}>
|
||||||
|
{opinionOf(sess)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -380,13 +390,14 @@ export function SessionsStatusTab({
|
|||||||
if (sess.reject_reason) rows.push({ label: '거부사유', value: sess.reject_reason });
|
if (sess.reject_reason) rows.push({ label: '거부사유', value: sess.reject_reason });
|
||||||
if (sess.reject_price) rows.push({ label: '거부가격', value: `₩${sess.reject_price.toLocaleString()}` });
|
if (sess.reject_price) rows.push({ label: '거부가격', value: `₩${sess.reject_price.toLocaleString()}` });
|
||||||
if (sess.reject_delivery_type) rows.push({ label: '거부배송방식', value: sess.reject_delivery_type });
|
if (sess.reject_delivery_type) rows.push({ label: '거부배송방식', value: sess.reject_delivery_type });
|
||||||
const offline = offlineAward(sess);
|
const contract = directAwardPrice(sess);
|
||||||
if (offline) rows.push({ label: '계약가(오프라인)', value: `₩${offline.price.toLocaleString()}` });
|
if (contract != null) rows.push({ label: '계약가(오프라인)', value: `₩${contract.toLocaleString()}` });
|
||||||
if (showEndTimeCol) rows.push({ label: '마감시각', value: sess.end_time || '-' });
|
if (showEndTimeCol) rows.push({ label: '마감시각', value: sess.end_time || '-' });
|
||||||
const extras = extraRows(sess);
|
const extras = extraRows(sess);
|
||||||
if (extras.length > 0) {
|
if (extras.length > 0) {
|
||||||
rows.push({ label: '부가정보', value: extras.map((r) => `${r.label} ${String(r.value)}`).join(' · ') });
|
rows.push({ label: '부가정보', value: extras.map((r) => `${r.label} ${String(r.value)}`).join(' · ') });
|
||||||
}
|
}
|
||||||
|
if (opinionOf(sess)) rows.push({ label: '의견', value: opinionOf(sess) });
|
||||||
return (
|
return (
|
||||||
<div key={sess.session_id} className={cn('p-3', isWinner && 'bg-success/10')}>
|
<div key={sess.session_id} className={cn('p-3', isWinner && 'bg-success/10')}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -487,7 +498,7 @@ export function SessionsStatusTab({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Typography as="p" variant="muted" className="text-[11px] mb-3">{extraSession.supplier_name}</Typography>
|
<Typography as="p" variant="muted" className="text-[11px] mb-3">{extraSession.supplier_name}</Typography>
|
||||||
{extraRows(extraSession).length > 0 ? (
|
{extraRows(extraSession).length > 0 || opinionOf(extraSession) ? (
|
||||||
<div className="divide-y divide-border/60">
|
<div className="divide-y divide-border/60">
|
||||||
{extraRows(extraSession).map((r) => (
|
{extraRows(extraSession).map((r) => (
|
||||||
<div key={r.label} className="flex items-center justify-between py-2 text-xs">
|
<div key={r.label} className="flex items-center justify-between py-2 text-xs">
|
||||||
@ -495,6 +506,12 @@ export function SessionsStatusTab({
|
|||||||
<span className="font-semibold text-foreground">{String(r.value)}</span>
|
<span className="font-semibold text-foreground">{String(r.value)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{opinionOf(extraSession) && (
|
||||||
|
<div className="flex items-start justify-between gap-3 py-2 text-xs">
|
||||||
|
<span className="shrink-0 text-muted-foreground">의견</span>
|
||||||
|
<span className="whitespace-pre-wrap text-right font-semibold text-foreground">{opinionOf(extraSession)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Typography as="p" variant="small" className="py-4 text-center text-muted-foreground text-xs">입력된 부가정보가 없습니다.</Typography>
|
<Typography as="p" variant="small" className="py-4 text-center text-muted-foreground text-xs">입력된 부가정보가 없습니다.</Typography>
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import {
|
|||||||
is1v1,
|
is1v1,
|
||||||
CHAIN_ROUND_STATE_LABEL,
|
CHAIN_ROUND_STATE_LABEL,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { QuotationStatus } from '@/api/generated/model';
|
import { AwardType, QuotationStatus } from '@/api/generated/model';
|
||||||
|
|
||||||
type QuotationTableProps = {
|
type QuotationTableProps = {
|
||||||
data: Estimate[];
|
data: Estimate[];
|
||||||
@ -153,6 +153,8 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, se
|
|||||||
cell: (est) => {
|
cell: (est) => {
|
||||||
const state = chainRoundState(est);
|
const state = chainRoundState(est);
|
||||||
const winner = state === 'awarded' ? est.preferred_sp_name : null;
|
const winner = state === 'awarded' ? est.preferred_sp_name : null;
|
||||||
|
// 담당자 오프라인 직접 낙찰이면 표시 — 목록에서 AI 자동낙찰과 한눈에 갈린다.
|
||||||
|
const manual = state === 'awarded' && est.award_type === AwardType.MANUAL;
|
||||||
return (
|
return (
|
||||||
<Typography
|
<Typography
|
||||||
as="span"
|
as="span"
|
||||||
@ -162,6 +164,7 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, se
|
|||||||
>
|
>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{CHAIN_ROUND_STATE_LABEL[state]}
|
{CHAIN_ROUND_STATE_LABEL[state]}
|
||||||
|
{manual ? '(직접)' : ''}
|
||||||
{winner ? ` - ${winner}` : ''}
|
{winner ? ` - ${winner}` : ''}
|
||||||
</span>
|
</span>
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@ -249,6 +249,7 @@ export type SessionView = {
|
|||||||
bid_at: string;
|
bid_at: string;
|
||||||
reject_reason: string | null;
|
reject_reason: string | null;
|
||||||
reject_price: number | null;
|
reject_price: number | null;
|
||||||
|
contract_price: number | null; // 직접 낙찰 계약가(원). 자동낙찰은 null(bid_price 가 계약가)
|
||||||
reject_delivery_type: string | null;
|
reject_delivery_type: string | null;
|
||||||
end_time: string;
|
end_time: string;
|
||||||
url: string; // 세션 chat 실행 URL(공급사 협상 프론트)
|
url: string; // 세션 chat 실행 URL(공급사 협상 프론트)
|
||||||
@ -323,9 +324,13 @@ export function buildPriceRail(
|
|||||||
// 목표가·상한가는 상품 단위(1견적=1상품)라 대표 세션 하나로 읽는다. 앵커는 공급사 단위라 1:1 에서만.
|
// 목표가·상한가는 상품 단위(1견적=1상품)라 대표 세션 하나로 읽는다. 앵커는 공급사 단위라 1:1 에서만.
|
||||||
const rep = sessions.find((s) => s.target_price > 0) ?? sessions[0];
|
const rep = sessions.find((s) => s.target_price > 0) ?? sessions[0];
|
||||||
const priced = sessions.map(awardPrice).filter((v): v is number => v != null);
|
const priced = sessions.map(awardPrice).filter((v): v is number => v != null);
|
||||||
|
const resultPrice = r.outcome === 'awarded' ? r.winnerPrice : r.lowestBid;
|
||||||
|
// 가격을 낸 협력사가 하나도 없으면 '투찰가' 라벨은 거짓말이 된다 — 값 유무로 라벨을 가른다.
|
||||||
|
const noBids = resultPrice == null;
|
||||||
|
|
||||||
const resultLabel =
|
const resultLabel =
|
||||||
r.outcome === 'awarded' ? '낙찰가'
|
r.outcome === 'awarded' ? '낙찰가'
|
||||||
|
: noBids ? '결과'
|
||||||
: r.outcome === 'opened' ? '최저 투찰가'
|
: r.outcome === 'opened' ? '최저 투찰가'
|
||||||
: oneToOne ? '현재 제시가' : '현재 최저 투찰가';
|
: oneToOne ? '현재 제시가' : '현재 최저 투찰가';
|
||||||
const resultBadge =
|
const resultBadge =
|
||||||
@ -342,7 +347,7 @@ export function buildPriceRail(
|
|||||||
ceilingPrice: ceilingPriceOf(rep, ceilingRate),
|
ceilingPrice: ceilingPriceOf(rep, ceilingRate),
|
||||||
ceilingRate,
|
ceilingRate,
|
||||||
resultLabel,
|
resultLabel,
|
||||||
resultPrice: r.outcome === 'awarded' ? r.winnerPrice : r.lowestBid,
|
resultPrice,
|
||||||
resultBadge,
|
resultBadge,
|
||||||
// 절감은 낙찰 확정 건만 — 진행 중 잠정 최저가로 절감을 말하면 나중에 뒤집힌다.
|
// 절감은 낙찰 확정 건만 — 진행 중 잠정 최저가로 절감을 말하면 나중에 뒤집힌다.
|
||||||
savings: r.outcome === 'awarded' ? r.savings : null,
|
savings: r.outcome === 'awarded' ? r.savings : null,
|
||||||
@ -359,18 +364,22 @@ export function awardPrice(s: SessionView): number | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 오프라인 협상 결과로 담당자가 확정한 계약가(sessions.custom.offline_award).
|
// 직접 낙찰 계약가(sessions.contract_price). 결렬·미응찰 건을 오프라인으로 다시 협상해 낙찰한 값이라
|
||||||
// 결렬·미응찰 건을 오프라인으로 다시 협상해 낙찰시킨 경우라 협력사 제출가와 다를 수 있다.
|
// 협력사 제출가(투찰가·거부가)와 다를 수 있다. 없으면 null(자동 낙찰 등).
|
||||||
export function offlineAward(s: SessionView): { price: number; note: string; at: string } | null {
|
export function directAwardPrice(s: SessionView): number | null {
|
||||||
const raw = s.custom?.offline_award as { price?: unknown; note?: unknown; at?: unknown } | undefined;
|
return s.contract_price != null && s.contract_price > 0 ? s.contract_price : null;
|
||||||
const price = Number(raw?.price);
|
|
||||||
if (!raw || !Number.isFinite(price) || price <= 0) return null;
|
|
||||||
return { price, note: String(raw.note ?? ''), at: String(raw.at ?? '') };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 확정 계약가 — 담당자가 넣은 값이 있으면 그것, 없으면 협력사 제출가. 결과 표기·절감 계산의 기준.
|
// 직접 낙찰 사유·처리자·시각(quotations.custom.award). 견적 단위 결정이라 견적에서 읽는다. 없으면 null.
|
||||||
|
export function awardMeta(q: { custom?: Record<string, unknown> | null }): { reason: string; by: string; at: string } | null {
|
||||||
|
const raw = (q.custom?.award ?? null) as { reason?: unknown; by?: unknown; at?: unknown } | null;
|
||||||
|
if (!raw) return null;
|
||||||
|
return { reason: String(raw.reason ?? ''), by: String(raw.by ?? ''), at: String(raw.at ?? '') };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 확정 계약가 — 직접 낙찰가가 있으면 그것, 없으면 협력사 제출가. 결과 표기·절감 계산의 기준.
|
||||||
export function contractPrice(s: SessionView): number | null {
|
export function contractPrice(s: SessionView): number | null {
|
||||||
return offlineAward(s)?.price ?? awardPrice(s);
|
return directAwardPrice(s) ?? awardPrice(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildQuotationResult(q: QuotationData, sessions: SessionView[]): QuotationResultView {
|
export function buildQuotationResult(q: QuotationData, sessions: SessionView[]): QuotationResultView {
|
||||||
@ -480,6 +489,7 @@ export function mapServerSessionView(sd: SessionData, partners: Partner[], produ
|
|||||||
bid_at: sd.bid_at ? fmtDateTime(sd.bid_at) : '-',
|
bid_at: sd.bid_at ? fmtDateTime(sd.bid_at) : '-',
|
||||||
reject_reason: sd.reject_reason ?? null,
|
reject_reason: sd.reject_reason ?? null,
|
||||||
reject_price: sd.reject_price ?? null,
|
reject_price: sd.reject_price ?? null,
|
||||||
|
contract_price: sd.contract_price ?? null,
|
||||||
reject_delivery_type: sd.reject_delivery_type
|
reject_delivery_type: sd.reject_delivery_type
|
||||||
? DELIVERY_TYPE_LABEL[sd.reject_delivery_type] || String(sd.reject_delivery_type)
|
? DELIVERY_TYPE_LABEL[sd.reject_delivery_type] || String(sd.reject_delivery_type)
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
66
postgres-init/alters/2026-08-11-award-type.sql
Normal file
66
postgres-init/alters/2026-08-11-award-type.sql
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
-- 2026-08-11 · 직접 낙찰(오프라인 재협상) 데이터 정착 (기존 DB 보정)
|
||||||
|
-- 요구: 통계에서 'AI 협상 자동 낙찰' 과 '담당자 오프라인 직접 낙찰' 을 나눠 집계 + 계약가·사유를 성격대로 배치.
|
||||||
|
-- 처음엔 계약가·사유를 sessions.custom.offline_award(JSONB) 한 뭉치에 넣었으나:
|
||||||
|
-- · 계약가는 통계가 집계하는 값 → JSONB 캐스팅은 인덱스 안 걸리고 타입 불안 → 컬럼이라야 한다.
|
||||||
|
-- 위치는 세션이 맞다(협력사별 값 — bid_price/reject_price 와 같은 축). → sessions.contract_price 로 승격.
|
||||||
|
-- · 사유·처리자·시각은 '이 견적을 이렇게 낙찰했다'는 견적 단위 결정이고 표시·감사만 함 → quotations.custom 으로.
|
||||||
|
-- · 낙찰 방식(자동/직접)은 통계 조회축 → quotations.award_type 코드값(1=자동/2=직접). 미낙찰이면 NULL.
|
||||||
|
-- 정본은 init-data/init.sql(신규 설치). 이 파일은 동일 최종본을 기존 DB 에 반영한다.
|
||||||
|
-- 멱등: ADD COLUMN IF NOT EXISTS + 조건부 UPDATE — 여러 번 실행해도 안전.
|
||||||
|
-- 적용: psql -h <host> -p <port> -U <user> -d <db> -f postgres-init/alters/2026-08-11-award-type.sql
|
||||||
|
|
||||||
|
\connect negosium_db
|
||||||
|
|
||||||
|
-- 컬럼 신설.
|
||||||
|
ALTER TABLE quotation.quotations
|
||||||
|
ADD COLUMN IF NOT EXISTS award_type SMALLINT NULL; -- AwardType 1=자동/2=직접
|
||||||
|
ALTER TABLE quotation.quotations
|
||||||
|
ADD COLUMN IF NOT EXISTS custom JSONB NULL; -- 직접 낙찰 사유·처리자·시각(award={reason,by,at})
|
||||||
|
ALTER TABLE negotiation.sessions
|
||||||
|
ADD COLUMN IF NOT EXISTS contract_price BIGINT NULL; -- 직접 낙찰 계약가(자동낙찰은 NULL, bid_price 가 계약가)
|
||||||
|
|
||||||
|
-- 기존 offline_award(JSONB) 데이터 이관 — 이미 직접 낙찰한 낙찰 세션이 있으면 성격대로 옮긴다.
|
||||||
|
-- (1) 계약가 → 낙찰 세션 contract_price 컬럼.
|
||||||
|
UPDATE negotiation.sessions s
|
||||||
|
SET contract_price = (s.custom -> 'offline_award' ->> 'price')::bigint
|
||||||
|
FROM quotation.quotations q
|
||||||
|
WHERE q.qt_id = s.quotation_id
|
||||||
|
AND q.preferred_sp_id = s.supplier_id
|
||||||
|
AND s.deleted = FALSE
|
||||||
|
AND s.contract_price IS NULL
|
||||||
|
AND (s.custom -> 'offline_award' ->> 'price') IS NOT NULL;
|
||||||
|
|
||||||
|
-- (2) 사유·처리자·시각 → 견적 custom.award.
|
||||||
|
UPDATE quotation.quotations q
|
||||||
|
SET custom = coalesce(q.custom, '{}'::jsonb) || jsonb_build_object(
|
||||||
|
'award', jsonb_build_object(
|
||||||
|
'reason', s.custom -> 'offline_award' ->> 'note',
|
||||||
|
'by', s.custom -> 'offline_award' ->> 'by',
|
||||||
|
'at', s.custom -> 'offline_award' ->> 'at'
|
||||||
|
))
|
||||||
|
FROM negotiation.sessions s
|
||||||
|
WHERE s.quotation_id = q.qt_id
|
||||||
|
AND s.supplier_id = q.preferred_sp_id
|
||||||
|
AND s.deleted = FALSE
|
||||||
|
AND (q.custom -> 'award') IS NULL
|
||||||
|
AND (s.custom -> 'offline_award') IS NOT NULL;
|
||||||
|
|
||||||
|
-- (3) 낙찰 방식 백필. 낙찰(AWARDED) 건만 — contract_price 있으면 직접(2), 없으면 자동(1). 개찰은 NULL 유지.
|
||||||
|
UPDATE quotation.quotations q
|
||||||
|
SET award_type = CASE
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM negotiation.sessions s
|
||||||
|
WHERE s.quotation_id = q.qt_id
|
||||||
|
AND s.supplier_id = q.preferred_sp_id
|
||||||
|
AND s.deleted = FALSE
|
||||||
|
AND s.contract_price IS NOT NULL
|
||||||
|
) THEN 2
|
||||||
|
ELSE 1
|
||||||
|
END
|
||||||
|
WHERE q.close_reason = 1
|
||||||
|
AND q.award_type IS NULL;
|
||||||
|
|
||||||
|
-- (4) 이관 끝난 세션에서 낡은 offline_award 키 제거(멱등 — 없으면 no-op).
|
||||||
|
UPDATE negotiation.sessions s
|
||||||
|
SET custom = s.custom - 'offline_award'
|
||||||
|
WHERE s.custom ? 'offline_award';
|
||||||
@ -308,9 +308,11 @@ CREATE TABLE IF NOT EXISTS quotation.quotations (
|
|||||||
equal_bid_yn BOOLEAN NULL, -- 동일가 입찰 발생 여부
|
equal_bid_yn BOOLEAN NULL, -- 동일가 입찰 발생 여부
|
||||||
equal_bid_data JSONB NULL, -- 동일가 입찰 상세(JSON)
|
equal_bid_data JSONB NULL, -- 동일가 입찰 상세(JSON)
|
||||||
close_reason SMALLINT NULL, -- 마감 사유(CloseReason): 1=낙찰, 5=가격개찰, 6=동가개찰, 7=미응찰개찰, 8=거부개찰. 미마감이면 NULL
|
close_reason SMALLINT NULL, -- 마감 사유(CloseReason): 1=낙찰, 5=가격개찰, 6=동가개찰, 7=미응찰개찰, 8=거부개찰. 미마감이면 NULL
|
||||||
|
award_type SMALLINT NULL, -- 낙찰 방식(AwardType): 1=자동(투찰가), 2=직접(담당자 오프라인 계약가). 미낙찰이면 NULL
|
||||||
mid_action SMALLINT NOT NULL DEFAULT 1, -- 낙찰 기준(PriceGateAction 1=낙찰/2=개찰): 앵커링가<투찰가≤목표가 처리. 1:1 협상만 사용자 선택, 1:N 경매는 AWARD 강제
|
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 협상은 항상 개찰). 투찰가≤앵커링가는 항상 낙찰
|
over_action SMALLINT NOT NULL DEFAULT 1, -- 낙찰 기준(PriceGateAction 1=낙찰/2=개찰): 목표가<투찰가 처리(1:1 협상은 항상 개찰). 투찰가≤앵커링가는 항상 낙찰
|
||||||
done_ceiling_rate SMALLINT NULL, -- 협상 완료 상한율(‰) 견적별 override. NULL 이면 quotation_settings 값 사용
|
done_ceiling_rate SMALLINT NULL, -- 협상 완료 상한율(‰) 견적별 override. NULL 이면 quotation_settings 값 사용
|
||||||
|
custom JSONB NULL, -- 견적 단위 부가정보. 직접 낙찰 시 award={reason,by,at} (사유·처리자·시각) 저장. 표시·감사용(집계 안 함)
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||||
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
||||||
@ -340,6 +342,7 @@ CREATE TABLE IF NOT EXISTS negotiation.sessions (
|
|||||||
reject_reason VARCHAR(255) NULL, -- 거절 사유
|
reject_reason VARCHAR(255) NULL, -- 거절 사유
|
||||||
reject_price BIGINT NULL, -- 거절 시 제시가(원)
|
reject_price BIGINT NULL, -- 거절 시 제시가(원)
|
||||||
reject_delivery_type SMALLINT NULL, -- 거절 시 배송 유형(DeliveryType): 1=supplier(협력사배송), 2=courier(지정택배배송), 3=pickup(픽업배송)
|
reject_delivery_type SMALLINT NULL, -- 거절 시 배송 유형(DeliveryType): 1=supplier(협력사배송), 2=courier(지정택배배송), 3=pickup(픽업배송)
|
||||||
|
contract_price BIGINT NULL, -- 직접 낙찰(오프라인 재협상) 계약가(원). 자동낙찰은 NULL(bid_price 가 계약가). 통계는 coalesce(contract_price, bid_price)
|
||||||
email_sent_at TIMESTAMPTZ NULL, -- 협상 초청 메일 발송 시각(NULL=미발송). 수동 발송 버튼이 채움
|
email_sent_at TIMESTAMPTZ NULL, -- 협상 초청 메일 발송 시각(NULL=미발송). 수동 발송 버튼이 채움
|
||||||
custom JSONB NULL, -- 협상완료 부가정보 값 {key: value} (정의는 companies.settings.session_fields: 표준납기/MOQ/발주배수/배송유형)
|
custom JSONB NULL, -- 협상완료 부가정보 값 {key: value} (정의는 companies.settings.session_fields: 표준납기/MOQ/발주배수/배송유형)
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user