[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:
Mina Choi 2026-08-12 09:56:04 +09:00
parent 6eb4dc26f8
commit 0e4ddf0f43
22 changed files with 292 additions and 63 deletions

View File

@ -309,12 +309,14 @@ class quotations(MainTableMixin, MAIN_BASE):
equal_bid_yn = Column(Boolean, nullable=True)
equal_bid_data = Column(JSONB, nullable=True)
close_reason = Column(SmallInteger, nullable=True) # CloseReason 코드. 마감 시 사유 기록(재생성 한도 카운팅·유찰 사유 구분). 미마감이면 NULL
award_type = Column(SmallInteger, nullable=True) # AwardType 코드(1=자동/2=직접). 낙찰 방식 — 통계에서 AI 자동낙찰과 담당자 직접낙찰 구분. 미낙찰이면 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=개찰)
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):
@ -340,6 +342,7 @@ class sessions(MainTableMixin, MAIN_BASE):
reject_reason = Column(String(255), nullable=True)
reject_price = Column(BigInteger, nullable=True)
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=미발송)
custom = Column(JSONB, nullable=True) # 협상완료 부가정보 값 {key: value} (정의는 companies.settings.session_fields)

View File

@ -213,6 +213,15 @@ class CloseReason(CodeEnum):
OPEN_REJECT = 8 # 개찰: 협상거부 존재
class AwardType(CodeEnum):
"""quotations.award_type 코드값(SMALLINT). 낙찰이 어떻게 확정됐는지 — 통계에서 AI 협상 성과와
담당자 오프라인 낙찰을 나눈다. 미마감·미낙찰(개찰)이면 NULL.
AUTO=시스템이 투찰가로 자동 낙찰 / MANUAL=담당자가 개찰 건을 오프라인 협상해 계약가로 직접 낙찰."""
AUTO = 1 # 자동 낙찰(close_and_decide, 투찰가 기준)
MANUAL = 2 # 직접 낙찰(담당자 계약가 입력 — sessions.custom.offline_award 와 짝)
class PriceGateAction(CodeEnum):
"""낙찰 기준(견적 단위) 가격게이트 판정값. '앵커링가<투찰가≤목표가'(mid) / '목표가<투찰가'(over) 구간에 적용.
(투찰가앵커링가 항상 낙찰.) 기준 미달이면 개찰(낙찰자 미정 마감) 재협상·결렬 없음.

View File

@ -11,7 +11,7 @@ from common.database.model.models import (
quotations, sessions, chats, nego_cards, wild_cards, items, suppliers, quotation_settings,
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.utils.gtime import GTime
@ -81,7 +81,11 @@ class IQuotationCRUD(ABC):
pass
@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
@abstractmethod
@ -547,6 +551,7 @@ class QuotationCRUD(IQuotationCRUD):
)
.values(
close_reason=CloseReason.AWARDED.value,
award_type=AwardType.MANUAL.value, # 담당자 직접 낙찰(오프라인 계약가)
preferred_sp_yn=True,
preferred_sp_id=supplier_id,
preferred_sp_name=supplier_name,
@ -576,16 +581,29 @@ class QuotationCRUD(IQuotationCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def merge_session_custom(self, cdb: AsyncSession, session_id, patch: dict) -> ErrorType:
# sessions.custom 부분 갱신(기존 키 보존). 부가정보·의견·재협상 요청이 같은 컬럼을 쓰므로 덮어쓰면 안 된다.
async def merge_quotation_custom(self, cdb: AsyncSession, qt_id, patch: dict) -> ErrorType:
# 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:
query = (
update(sessions)
.where(sessions.session_id == session_id)
.values(
custom=func.coalesce(sessions.custom, cast(text("'{}'"), JSONB)).op("||")(cast(patch, JSONB)),
updated_at=GTime.UTC(),
)
.values(contract_price=price, updated_at=GTime.UTC())
)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:

View File

@ -1,13 +1,13 @@
from abc import ABC, abstractmethod
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.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
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
@ -80,8 +80,8 @@ class StatisticsCRUD(IStatisticsCRUD):
# 결렬·미응찰 건을 오프라인으로 다시 협상하고 직접 낙찰하면 시스템 투찰가가 없거나 실제 계약가와
# 다르기 때문. 컬럼 이름은 bid_price 로 유지해 statistics_service 는 그대로 쓴다.
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 = (
select(
quotations.updated_at,
@ -90,7 +90,8 @@ class StatisticsCRUD(IStatisticsCRUD):
sessions.target_price,
award_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)
.join(
@ -98,7 +99,7 @@ class StatisticsCRUD(IStatisticsCRUD):
and_(
sessions.quotation_id == quotations.qt_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
),
)

View File

@ -4,7 +4,7 @@ from typing import Any, Optional
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
@ -77,6 +77,8 @@ class QuotationData(WebPacketProtocol):
equal_bid_yn: Optional[bool] = None
equal_bid_data: Optional[Any] = 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 # 낙찰 기준(견적 단위). 상세 드로어 낙찰기준 표시용
over_action: Optional[int] = None
done_ceiling_rate: Optional[int] = None # 타결 상한율(‰) 견적 override. None 이면 견적 세팅값을 따름
@ -120,6 +122,7 @@ class SessionData(WebPacketProtocol):
reject_reason: Optional[str] = None
reject_price: Optional[int] = None
reject_delivery_type: Optional[DeliveryType] = None
contract_price: Optional[int] = None # 직접 낙찰 계약가(원). 자동낙찰은 None(bid_price 가 계약가)
email_sent_at: Optional[datetime] = None # 협상 초청 메일 발송 시각(None=미발송). 프론트 발송배지/재발송 판단
custom: Optional[dict] = None # 협상완료 부가정보 값 {key: value} (공급사가 타결 후 입력, 정의는 companies.settings.session_fields)
url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성

View File

@ -5,7 +5,7 @@ from typing import Optional
from common.authz import is_owner_or_admin
from common.database.db_session_manager import DB_SESSION_MNG
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.utils.gtime import GTime
from router.v1.quotation.protocol import Res_Quotation
@ -116,6 +116,7 @@ class ClosingMixin:
await self._close(qt_uuid, CloseReason.AWARDED.value, {
"preferred_sp_yn": True, "preferred_sp_id": winner["supplier_id"],
"preferred_sp_name": (winner["name"] or "")[:20], "equal_bid_yn": False,
"award_type": AwardType.AUTO.value, # 시스템 자동 낙찰(투찰가 기준)
})
await create_notification(
original.user_id, NotificationType.SUCCESS,
@ -225,21 +226,23 @@ class ClosingMixin:
res.msg = "이미 낙찰 처리된 견적입니다."
return res
# 계약가를 낙찰 세션에 남긴다 — 통계가 이 값을 계약가로 읽고(투찰가보다 우선), 누가 언제 어떤
# 근거로 확정했는지 추적한다. custom 은 부가정보·의견과 같은 컬럼이라 병합(덮어쓰기 금지).
offline_award = {
"price": winner_price,
"note": (contract_note or "").strip()[:255],
# 계약가는 낙찰 세션의 contract_price 컬럼에(협력사 가격 — bid_price/reject_price 와 같은 축, 통계 집계 대상).
# 사유·처리자·시각은 견적의 custom.award 에(견적 단위 결정 — 표시·감사용, 집계 안 함). 둘 다 한 트랜잭션.
award_meta = {
"reason": (contract_note or "").strip()[:255],
"by": str(user_id) if user_id else "",
"at": GTime.UTC().isoformat(timespec="seconds"),
}
award_err = await DB_SESSION_MNG.execute_lambda_run(
[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:
# 낙찰(견적)은 이미 선점 전이됐다 — 계약가만 못 남긴 상태라 되돌리지 않고 경고로 남긴다.
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 플래그로 '직접 낙찰' 구분.
await create_notification(

View File

@ -15,7 +15,7 @@ from datetime import datetime
import pytest_asyncio
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 services.quotation import QuotationService
@ -44,6 +44,7 @@ async def test_award_opened_sets_winner_and_notifies(clean):
assert res.result.success is True
row = await _quotation(engine, qt)
assert row.close_reason == CloseReason.AWARDED.value
assert row.award_type == AwardType.MANUAL.value # 직접 낙찰 — 통계에서 자동낙찰과 구분
assert row.preferred_sp_yn is True
assert str(row.preferred_sp_id) == str(supplier_a)
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
async def test_award_records_contract_price_on_session(clean):
async def test_award_records_contract_price_and_reason(clean):
"""검증: 협력사 제출가(100)와 다른 계약가(88)로 직접 낙찰 — 오프라인 재협상 결과 반영.
기대결과: 낙찰 세션 custom.offline_award 계약가·메모가 남고, 알림 winner_price 계약가."""
기대결과: 계약가는 낙찰 세션 contract_price 컬럼에, 사유·처리자는 견적 custom.award 남고, 알림 winner_price 계약가."""
engine = clean
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)
@ -155,13 +156,18 @@ async def test_award_records_contract_price_on_session(clean):
assert res.result.success is True
async with engine.begin() as conn:
row = (await conn.execute(
text("SELECT custom FROM sessions WHERE quotation_id = :qt AND supplier_id = :sp"),
# 계약가 = 세션 컬럼(협력사 가격 — bid_price/reject_price 와 같은 축)
sess_price = (await conn.execute(
text("SELECT contract_price FROM sessions WHERE quotation_id = :qt AND supplier_id = :sp"),
{"qt": qt, "sp": supplier_a},
)).first()
award = row.custom["offline_award"]
assert award["price"] == 88
assert award["note"] == "오프라인 협상, 8/12 통화 합의"
)).scalar()
# 사유·처리자 = 견적 custom.award(견적 단위 결정)
qt_custom = (await conn.execute(
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)
notis = await _notifications(engine, user_id)
assert notis[0][1]["winner_price"] == 88
@ -245,7 +251,7 @@ async def _quotation(engine, qt_id):
"""견적 1행(마감 결과 컬럼 확인용)."""
async with engine.begin() as conn:
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"),
{"qt_id": qt_id},
)).one()

View File

@ -20,7 +20,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from sqlalchemy import text
from common.enums import QuotationStatus, QuotationType, SessionStatus
from common.enums import AwardType, QuotationStatus, QuotationType, SessionStatus
from scheduler import jobs
PAST = datetime(2020, 1, 1) # 마감시각이 이미 지난 시점(잡①의 마감 대상)
@ -95,6 +95,7 @@ async def test_award_single_lowest(clean):
assert row.status == QuotationStatus.CLOSED.value
assert row.preferred_sp_yn is True # 낙찰자 있음
assert str(row.preferred_sp_id) == str(winner) # 최저가가 단독이라 그 공급사로 확정
assert row.award_type == AwardType.AUTO.value # 자동 낙찰(투찰가 기준)
async def test_rejected_just_closes(clean):
@ -205,6 +206,6 @@ async def _quotation_row(engine, qt_id):
"""견적 1건을 다시 읽어온다(마감 후 status·낙찰자 확인용)."""
async with engine.begin() as conn:
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},
)).first()

View 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;

View File

@ -9,6 +9,7 @@ export * from './anchoringCell';
export * from './anchoringCellLastAdjustedAt';
export * from './anchoringHistoryRow';
export * from './anchoringHistoryRowCreatedAt';
export * from './awardType';
export * from './bodyUploadItemImageV1ItemImagePost';
export * from './cardData';
export * from './cardDataCondition';
@ -126,9 +127,12 @@ export * from './quotationCardDataScript';
export * from './quotationCardDataType';
export * from './quotationCardDataWildCardId';
export * from './quotationData';
export * from './quotationDataAwardType';
export * from './quotationDataCloseReason';
export * from './quotationDataCreatedAt';
export * from './quotationDataCreatorName';
export * from './quotationDataCustom';
export * from './quotationDataCustomAnyOf';
export * from './quotationDataDoneCeilingRate';
export * from './quotationDataEqualBidData';
export * from './quotationDataEqualBidYn';
@ -434,6 +438,7 @@ export * from './sessionData';
export * from './sessionDataAnchoringPrice';
export * from './sessionDataBidAt';
export * from './sessionDataBidPrice';
export * from './sessionDataContractPrice';
export * from './sessionDataCustom';
export * from './sessionDataCustomAnyOf';
export * from './sessionDataDoneCeilingPrice';

View File

@ -17,6 +17,8 @@ import type { QuotationDataPreferredSpName } from './quotationDataPreferredSpNam
import type { QuotationDataEqualBidYn } from './quotationDataEqualBidYn';
import type { QuotationDataEqualBidData } from './quotationDataEqualBidData';
import type { QuotationDataCloseReason } from './quotationDataCloseReason';
import type { QuotationDataAwardType } from './quotationDataAwardType';
import type { QuotationDataCustom } from './quotationDataCustom';
import type { QuotationDataMidAction } from './quotationDataMidAction';
import type { QuotationDataOverAction } from './quotationDataOverAction';
import type { QuotationDataDoneCeilingRate } from './quotationDataDoneCeilingRate';
@ -50,6 +52,8 @@ export interface QuotationData {
equal_bid_yn?: QuotationDataEqualBidYn;
equal_bid_data?: QuotationDataEqualBidData;
close_reason?: QuotationDataCloseReason;
award_type?: QuotationDataAwardType;
custom?: QuotationDataCustom;
mid_action?: QuotationDataMidAction;
over_action?: QuotationDataOverAction;
done_ceiling_rate?: QuotationDataDoneCeilingRate;

View File

@ -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;

View File

@ -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;

View File

@ -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 };

View File

@ -13,6 +13,7 @@ import type { SessionDataBidAt } from './sessionDataBidAt';
import type { SessionDataRejectReason } from './sessionDataRejectReason';
import type { SessionDataRejectPrice } from './sessionDataRejectPrice';
import type { SessionDataRejectDeliveryType } from './sessionDataRejectDeliveryType';
import type { SessionDataContractPrice } from './sessionDataContractPrice';
import type { SessionDataEmailSentAt } from './sessionDataEmailSentAt';
import type { SessionDataCustom } from './sessionDataCustom';
@ -34,6 +35,7 @@ export interface SessionData {
reject_reason?: SessionDataRejectReason;
reject_price?: SessionDataRejectPrice;
reject_delivery_type?: SessionDataRejectDeliveryType;
contract_price?: SessionDataContractPrice;
email_sent_at?: SessionDataEmailSentAt;
custom?: SessionDataCustom;
url?: string;

View File

@ -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;

View File

@ -1,10 +1,11 @@
import { BadgeCheck } from 'lucide-react';
import { BadgeCheck, Gavel } from 'lucide-react';
import { Typography } from '@/components/ui/typography';
import type { QuotationData } from '@/api/generated/model/quotationData';
import { StatusPill, sessionStatusTone, type PillTone } from './StatusPill';
import { PricingSpectrum, type SpectrumBid } from './PricingSpectrum';
import { SessionStatus } from '@/api/generated/model';
import { AwardType, SessionStatus } from '@/api/generated/model';
import {
awardMeta,
awardPrice,
buildQuotationResult,
ceilingPriceOf,
@ -42,6 +43,9 @@ export function ResultSummaryBand({
// 1:1 은 협력사가 하나라 그 세션이 곧 이 협상이다(세션이 여럿이면 대표를 세우지 않는다).
const oneToOne = is1v1(quotation.type);
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) => {
@ -71,7 +75,8 @@ export function ResultSummaryBand({
: OUTCOME_TONE[r.outcome]
}
>
{r.outcome === 'active'
{/* 직접 낙찰 배지가 따로 '낙찰'을 말하므로, 그때는 '— 낙찰' 꼬리를 떼고 세션 상태만 둔다(중복 방지). */}
{r.outcome === 'active' || showManualBadge
? sessionStatusLabel(solo.status)
: `${sessionStatusLabel(solo.status)}${CHAIN_ROUND_STATE_LABEL[r.outcome]}`}
</StatusPill>
@ -91,11 +96,27 @@ export function ResultSummaryBand({
</Typography>
) : (
<Typography as="p" variant="small" className="min-w-0 truncate text-[12px] font-bold">
{r.closeReason}
{/* 옆의 결과 배지가 이미 '개찰'을 말하므로 사유 텍스트의 '— 개찰' 꼬리는 뗀다(예: '전원 미응찰 — 개찰' → '전원 미응찰'). */}
{r.closeReason.replace(/\s*—\s*개찰.*$/, '')}
</Typography>
)}
{/* 직접 낙찰(담당자 오프라인 계약가) — AI 자동 낙찰과 구분해 표시. 자동 낙찰엔 배지 없음. */}
{showManualBadge && (
<StatusPill tone="blue" className="gap-1">
<Gavel size={11} />
</StatusPill>
)}
</div>
{/* 직접 낙찰 사유(견적 custom.award.reason) — 왜 이 협력사를 오프라인으로 낙찰했는지 근거. */}
{showManualBadge && reason && (
<Typography as="p" variant="caption" className="text-[11px] text-muted-foreground">
· {reason}
</Typography>
)}
<PricingSpectrum
targetPrice={r.targetPrice}
ceilingPrice={ceilingPriceOf(rep, ceilingRate)}

View File

@ -10,7 +10,7 @@ import { SessionStatus } from '@/api/generated/model';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { StatusPill, sessionStatusTone } from './StatusPill';
import { AwardModal } from './AwardModal';
import { awardPrice, offlineAward, mapServerSessionView, sessionStatusLabel } from '../../types';
import { awardPrice, directAwardPrice, mapServerSessionView, sessionStatusLabel } from '../../types';
import { useCompanySettings } from '@/features/settings/useCompanySettings';
type SessionView = ReturnType<typeof mapServerSessionView>;
@ -43,15 +43,15 @@ export function SessionsStatusTab({
const { settings } = useCompanySettings();
const sessionFields = settings.session_fields ?? [];
const [extraSession, setExtraSession] = useState<SessionView | null>(null);
// 부가정보 = 회사가 정의한 필드(session_fields)만. 협상완료 세션이 입력한다.
const extraRows = (sv: SessionView | null) => {
const custom = sv?.custom as Record<string, unknown> | undefined;
const rows = sessionFields
return sessionFields
.map((f) => ({ label: f.label, value: custom?.[f.key] }))
.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 [sendingId, setSendingId] = 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 font-sans"></TableHead>
<TableHead className="p-3 font-semibold font-sans"></TableHead>
<TableHead className="p-3 font-semibold font-sans"></TableHead>
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-border">
@ -332,9 +333,9 @@ export function SessionsStatusTab({
</TableCell>
<TableCell className="p-3 text-right font-bold text-foreground">
{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">
{offlineAward(sess)!.price.toLocaleString()}
{directAwardPrice(sess)!.toLocaleString()}
</Typography>
)}
</TableCell>
@ -350,6 +351,15 @@ export function SessionsStatusTab({
<TableCell className="p-3 font-sans">
<ExtraInfoCell rows={extraRows(sess)} onOpen={() => setExtraSession(sess)} />
</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>
);
})}
@ -380,13 +390,14 @@ export function SessionsStatusTab({
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_delivery_type) rows.push({ label: '거부배송방식', value: sess.reject_delivery_type });
const offline = offlineAward(sess);
if (offline) rows.push({ label: '계약가(오프라인)', value: `${offline.price.toLocaleString()}` });
const contract = directAwardPrice(sess);
if (contract != null) rows.push({ label: '계약가(오프라인)', value: `${contract.toLocaleString()}` });
if (showEndTimeCol) rows.push({ label: '마감시각', value: sess.end_time || '-' });
const extras = extraRows(sess);
if (extras.length > 0) {
rows.push({ label: '부가정보', value: extras.map((r) => `${r.label} ${String(r.value)}`).join(' · ') });
}
if (opinionOf(sess)) rows.push({ label: '의견', value: opinionOf(sess) });
return (
<div key={sess.session_id} className={cn('p-3', isWinner && 'bg-success/10')}>
<div className="flex items-center gap-2">
@ -487,7 +498,7 @@ export function SessionsStatusTab({
</button>
</div>
<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">
{extraRows(extraSession).map((r) => (
<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>
</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>
) : (
<Typography as="p" variant="small" className="py-4 text-center text-muted-foreground text-xs"> .</Typography>

View File

@ -15,7 +15,7 @@ import {
is1v1,
CHAIN_ROUND_STATE_LABEL,
} from '../types';
import { QuotationStatus } from '@/api/generated/model';
import { AwardType, QuotationStatus } from '@/api/generated/model';
type QuotationTableProps = {
data: Estimate[];
@ -153,6 +153,8 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, se
cell: (est) => {
const state = chainRoundState(est);
const winner = state === 'awarded' ? est.preferred_sp_name : null;
// 담당자 오프라인 직접 낙찰이면 표시 — 목록에서 AI 자동낙찰과 한눈에 갈린다.
const manual = state === 'awarded' && est.award_type === AwardType.MANUAL;
return (
<Typography
as="span"
@ -162,6 +164,7 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, se
>
<span className="truncate">
{CHAIN_ROUND_STATE_LABEL[state]}
{manual ? '(직접)' : ''}
{winner ? ` - ${winner}` : ''}
</span>
</Typography>

View File

@ -249,6 +249,7 @@ export type SessionView = {
bid_at: string;
reject_reason: string | null;
reject_price: number | null;
contract_price: number | null; // 직접 낙찰 계약가(원). 자동낙찰은 null(bid_price 가 계약가)
reject_delivery_type: string | null;
end_time: string;
url: string; // 세션 chat 실행 URL(공급사 협상 프론트)
@ -323,9 +324,13 @@ export function buildPriceRail(
// 목표가·상한가는 상품 단위(1견적=1상품)라 대표 세션 하나로 읽는다. 앵커는 공급사 단위라 1:1 에서만.
const rep = sessions.find((s) => s.target_price > 0) ?? sessions[0];
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 =
r.outcome === 'awarded' ? '낙찰가'
: noBids ? '결과'
: r.outcome === 'opened' ? '최저 투찰가'
: oneToOne ? '현재 제시가' : '현재 최저 투찰가';
const resultBadge =
@ -342,7 +347,7 @@ export function buildPriceRail(
ceilingPrice: ceilingPriceOf(rep, ceilingRate),
ceilingRate,
resultLabel,
resultPrice: r.outcome === 'awarded' ? r.winnerPrice : r.lowestBid,
resultPrice,
resultBadge,
// 절감은 낙찰 확정 건만 — 진행 중 잠정 최저가로 절감을 말하면 나중에 뒤집힌다.
savings: r.outcome === 'awarded' ? r.savings : null,
@ -359,18 +364,22 @@ export function awardPrice(s: SessionView): number | null {
return null;
}
// 오프라인 협상 결과로 담당자가 확정한 계약가(sessions.custom.offline_award).
// 결렬·미응찰 건을 오프라인으로 다시 협상해 낙찰시킨 경우라 협력사 제출가와 다를 수 있다.
export function offlineAward(s: SessionView): { price: number; note: string; at: string } | null {
const raw = s.custom?.offline_award as { price?: unknown; note?: unknown; at?: unknown } | undefined;
const price = Number(raw?.price);
if (!raw || !Number.isFinite(price) || price <= 0) return null;
return { price, note: String(raw.note ?? ''), at: String(raw.at ?? '') };
// 직접 낙찰 계약가(sessions.contract_price). 결렬·미응찰 건을 오프라인으로 다시 협상해 낙찰한 값이라
// 협력사 제출가(투찰가·거부가)와 다를 수 있다. 없으면 null(자동 낙찰 등).
export function directAwardPrice(s: SessionView): number | null {
return s.contract_price != null && s.contract_price > 0 ? s.contract_price : null;
}
// 확정 계약가 — 담당자가 넣은 값이 있으면 그것, 없으면 협력사 제출가. 결과 표기·절감 계산의 기준.
// 직접 낙찰 사유·처리자·시각(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 {
return offlineAward(s)?.price ?? awardPrice(s);
return directAwardPrice(s) ?? awardPrice(s);
}
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) : '-',
reject_reason: sd.reject_reason ?? null,
reject_price: sd.reject_price ?? null,
contract_price: sd.contract_price ?? null,
reject_delivery_type: sd.reject_delivery_type
? DELIVERY_TYPE_LABEL[sd.reject_delivery_type] || String(sd.reject_delivery_type)
: null,

View 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';

View File

@ -308,9 +308,11 @@ CREATE TABLE IF NOT EXISTS quotation.quotations (
equal_bid_yn BOOLEAN NULL, -- 동일가 입찰 발생 여부
equal_bid_data JSONB NULL, -- 동일가 입찰 상세(JSON)
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 강제
over_action SMALLINT NOT NULL DEFAULT 1, -- 낙찰 기준(PriceGateAction 1=낙찰/2=개찰): 목표가<투찰가 처리(1:1 협상은 항상 개찰). 투찰가≤앵커링가는 항상 낙찰
done_ceiling_rate SMALLINT NULL, -- 협상 완료 상한율(‰) 견적별 override. NULL 이면 quotation_settings 값 사용
custom JSONB NULL, -- 견적 단위 부가정보. 직접 낙찰 시 award={reason,by,at} (사유·처리자·시각) 저장. 표시·감사용(집계 안 함)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
@ -340,6 +342,7 @@ CREATE TABLE IF NOT EXISTS negotiation.sessions (
reject_reason VARCHAR(255) NULL, -- 거절 사유
reject_price BIGINT NULL, -- 거절 시 제시가(원)
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=미발송). 수동 발송 버튼이 채움
custom JSONB NULL, -- 협상완료 부가정보 값 {key: value} (정의는 companies.settings.session_fields: 표준납기/MOQ/발주배수/배송유형)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)