From 0e4ddf0f43dc6a6274a574a873d25d4d8315e7c9 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 12 Aug 2026 09:56:04 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20negodata:=20=EB=82=99=EC=B0=B0=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EA=B5=AC=EB=B6=84(=EC=9E=90=EB=8F=99/?= =?UTF-8?q?=EC=A7=81=EC=A0=91)=20+=20=EA=B3=84=EC=95=BD=EA=B0=80=C2=B7?= =?UTF-8?q?=EC=82=AC=EC=9C=A0=20=EC=A0=80=EC=9E=A5=20=EC=9C=84=EC=B9=98=20?= =?UTF-8?q?=EC=9E=AC=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 직접 낙찰(담당자 오프라인 계약가)을 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 적용·화면 확인. --- .../backend/common/database/model/models.py | 3 + negodata/backend/common/enums.py | 9 +++ negodata/backend/crud/quotation_crud.py | 34 +++++++--- negodata/backend/crud/statistics_crud.py | 13 ++-- .../backend/router/v1/quotation/protocol.py | 5 +- .../backend/services/quotation/closing.py | 21 +++--- .../backend/tests/test_quotation_award.py | 26 +++++--- negodata/backend/tests/test_scheduler.py | 5 +- .../src/api/generated/model/awardType.ts | 20 ++++++ .../front/src/api/generated/model/index.ts | 5 ++ .../src/api/generated/model/quotationData.ts | 4 ++ .../generated/model/quotationDataAwardType.ts | 9 +++ .../generated/model/quotationDataCustom.ts | 9 +++ .../model/quotationDataCustomAnyOf.ts | 8 +++ .../src/api/generated/model/sessionData.ts | 2 + .../model/sessionDataContractPrice.ts | 8 +++ .../ResultSummaryBand.tsx | 29 ++++++-- .../SessionsStatusTab.tsx | 37 ++++++++--- .../quotations/components/QuotationTable.tsx | 5 +- .../front/src/features/quotations/types.ts | 34 ++++++---- .../alters/2026-08-11-award-type.sql | 66 +++++++++++++++++++ postgres-init/init-data/init.sql | 3 + 22 files changed, 292 insertions(+), 63 deletions(-) create mode 100644 negodata/front/src/api/generated/model/awardType.ts create mode 100644 negodata/front/src/api/generated/model/quotationDataAwardType.ts create mode 100644 negodata/front/src/api/generated/model/quotationDataCustom.ts create mode 100644 negodata/front/src/api/generated/model/quotationDataCustomAnyOf.ts create mode 100644 negodata/front/src/api/generated/model/sessionDataContractPrice.ts create mode 100644 postgres-init/alters/2026-08-11-award-type.sql diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index f7cb178..6b30810 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -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) diff --git a/negodata/backend/common/enums.py b/negodata/backend/common/enums.py index 4bf0fe7..2789eb8 100644 --- a/negodata/backend/common/enums.py +++ b/negodata/backend/common/enums.py @@ -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) 구간에 적용. (투찰가≤앵커링가 는 항상 낙찰.) 기준 미달이면 개찰(낙찰자 미정 마감) — 재협상·결렬 없음. diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py index 8518f96..e18f956 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -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: diff --git a/negodata/backend/crud/statistics_crud.py b/negodata/backend/crud/statistics_crud.py index b73ae93..22278dc 100644 --- a/negodata/backend/crud/statistics_crud.py +++ b/negodata/backend/crud/statistics_crud.py @@ -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 ), ) diff --git a/negodata/backend/router/v1/quotation/protocol.py b/negodata/backend/router/v1/quotation/protocol.py index 01c450e..371c6a0 100644 --- a/negodata/backend/router/v1/quotation/protocol.py +++ b/negodata/backend/router/v1/quotation/protocol.py @@ -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 로 구성 diff --git a/negodata/backend/services/quotation/closing.py b/negodata/backend/services/quotation/closing.py index 8d7d7b2..5cd426b 100644 --- a/negodata/backend/services/quotation/closing.py +++ b/negodata/backend/services/quotation/closing.py @@ -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( diff --git a/negodata/backend/tests/test_quotation_award.py b/negodata/backend/tests/test_quotation_award.py index 3088ef7..ddbbe75 100644 --- a/negodata/backend/tests/test_quotation_award.py +++ b/negodata/backend/tests/test_quotation_award.py @@ -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() diff --git a/negodata/backend/tests/test_scheduler.py b/negodata/backend/tests/test_scheduler.py index f754446..6da1470 100644 --- a/negodata/backend/tests/test_scheduler.py +++ b/negodata/backend/tests/test_scheduler.py @@ -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() diff --git a/negodata/front/src/api/generated/model/awardType.ts b/negodata/front/src/api/generated/model/awardType.ts new file mode 100644 index 0000000..b0d4d3e --- /dev/null +++ b/negodata/front/src/api/generated/model/awardType.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index d5c9112..28e980f 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -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'; diff --git a/negodata/front/src/api/generated/model/quotationData.ts b/negodata/front/src/api/generated/model/quotationData.ts index 3fedfa2..de82769 100644 --- a/negodata/front/src/api/generated/model/quotationData.ts +++ b/negodata/front/src/api/generated/model/quotationData.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/quotationDataAwardType.ts b/negodata/front/src/api/generated/model/quotationDataAwardType.ts new file mode 100644 index 0000000..f354826 --- /dev/null +++ b/negodata/front/src/api/generated/model/quotationDataAwardType.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/quotationDataCustom.ts b/negodata/front/src/api/generated/model/quotationDataCustom.ts new file mode 100644 index 0000000..4dc56d1 --- /dev/null +++ b/negodata/front/src/api/generated/model/quotationDataCustom.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/quotationDataCustomAnyOf.ts b/negodata/front/src/api/generated/model/quotationDataCustomAnyOf.ts new file mode 100644 index 0000000..78f3d21 --- /dev/null +++ b/negodata/front/src/api/generated/model/quotationDataCustomAnyOf.ts @@ -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 }; diff --git a/negodata/front/src/api/generated/model/sessionData.ts b/negodata/front/src/api/generated/model/sessionData.ts index 695b2ad..854ae70 100644 --- a/negodata/front/src/api/generated/model/sessionData.ts +++ b/negodata/front/src/api/generated/model/sessionData.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/sessionDataContractPrice.ts b/negodata/front/src/api/generated/model/sessionDataContractPrice.ts new file mode 100644 index 0000000..adf5c59 --- /dev/null +++ b/negodata/front/src/api/generated/model/sessionDataContractPrice.ts @@ -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; diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ResultSummaryBand.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ResultSummaryBand.tsx index ff69c20..804b497 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ResultSummaryBand.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ResultSummaryBand.tsx @@ -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]}`} @@ -91,11 +96,27 @@ export function ResultSummaryBand({ ) : ( - {r.closeReason} + {/* 옆의 결과 배지가 이미 '개찰'을 말하므로 사유 텍스트의 '— 개찰' 꼬리는 뗀다(예: '전원 미응찰 — 개찰' → '전원 미응찰'). */} + {r.closeReason.replace(/\s*—\s*개찰.*$/, '')} )} + + {/* 직접 낙찰(담당자 오프라인 계약가) — AI 자동 낙찰과 구분해 표시. 자동 낙찰엔 배지 없음. */} + {showManualBadge && ( + + + 직접 낙찰 + + )} + {/* 직접 낙찰 사유(견적 custom.award.reason) — 왜 이 협력사를 오프라인으로 낙찰했는지 근거. */} + {showManualBadge && reason && ( + + 낙찰 사유 · {reason} + + )} + ; @@ -43,15 +43,15 @@ export function SessionsStatusTab({ const { settings } = useCompanySettings(); const sessionFields = settings.session_fields ?? []; const [extraSession, setExtraSession] = useState(null); + // 부가정보 = 회사가 정의한 필드(session_fields)만. 협상완료 세션이 입력한다. const extraRows = (sv: SessionView | null) => { const custom = sv?.custom as Record | 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 | undefined)?.opinion ?? ''); const [sendingAll, setSendingAll] = useState(false); const [sendingId, setSendingId] = useState(null); const [selectedWinnerId, setSelectedWinnerId] = useState(null); @@ -183,6 +183,7 @@ export function SessionsStatusTab({ 거부가격 거부배송방식 부가정보 + 의견 @@ -332,9 +333,9 @@ export function SessionsStatusTab({ {sess.bid_price ? `₩${sess.bid_price.toLocaleString()}` : '-'} - {offlineAward(sess) && ( + {directAwardPrice(sess) != null && ( - 계약 ₩{offlineAward(sess)!.price.toLocaleString()} + 계약 ₩{directAwardPrice(sess)!.toLocaleString()} )} @@ -350,6 +351,15 @@ export function SessionsStatusTab({ setExtraSession(sess)} /> + + {opinionOf(sess) ? ( + + {opinionOf(sess)} + + ) : ( + '-' + )} + ); })} @@ -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 (
@@ -487,7 +498,7 @@ export function SessionsStatusTab({
{extraSession.supplier_name} - {extraRows(extraSession).length > 0 ? ( + {extraRows(extraSession).length > 0 || opinionOf(extraSession) ? (
{extraRows(extraSession).map((r) => (
@@ -495,6 +506,12 @@ export function SessionsStatusTab({ {String(r.value)}
))} + {opinionOf(extraSession) && ( +
+ 의견 + {opinionOf(extraSession)} +
+ )}
) : ( 입력된 부가정보가 없습니다. diff --git a/negodata/front/src/features/quotations/components/QuotationTable.tsx b/negodata/front/src/features/quotations/components/QuotationTable.tsx index 59e9626..c3440c5 100644 --- a/negodata/front/src/features/quotations/components/QuotationTable.tsx +++ b/negodata/front/src/features/quotations/components/QuotationTable.tsx @@ -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 ( {CHAIN_ROUND_STATE_LABEL[state]} + {manual ? '(직접)' : ''} {winner ? ` - ${winner}` : ''} diff --git a/negodata/front/src/features/quotations/types.ts b/negodata/front/src/features/quotations/types.ts index f67038a..b7a8ade 100644 --- a/negodata/front/src/features/quotations/types.ts +++ b/negodata/front/src/features/quotations/types.ts @@ -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,11 +324,15 @@ 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' ? '낙찰가' - : r.outcome === 'opened' ? '최저 투찰가' - : oneToOne ? '현재 제시가' : '현재 최저 투찰가'; + : noBids ? '결과' + : r.outcome === 'opened' ? '최저 투찰가' + : oneToOne ? '현재 제시가' : '현재 최저 투찰가'; const resultBadge = r.outcome === 'awarded' ? null : r.outcome === 'opened' ? '낙찰자 미정' @@ -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 | 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, diff --git a/postgres-init/alters/2026-08-11-award-type.sql b/postgres-init/alters/2026-08-11-award-type.sql new file mode 100644 index 0000000..63c4c1b --- /dev/null +++ b/postgres-init/alters/2026-08-11-award-type.sql @@ -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 -p -U -d -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'; diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index accb0cb..5d5cfe2 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -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)