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