diff --git a/negodata/front/src/api/generated/model/closeReason.ts b/negodata/front/src/api/generated/model/closeReason.ts index 477629c..ed560b2 100644 --- a/negodata/front/src/api/generated/model/closeReason.ts +++ b/negodata/front/src/api/generated/model/closeReason.ts @@ -6,8 +6,9 @@ */ /** - * quotations.close_reason 코드값(SMALLINT). 마감 사유 — 재생성 한도 카운팅(REGEN_*)과 유찰 사유 구분에 쓴다. -기존 preferred_sp_yn/equal_bid_yn 2플래그로는 4상태만 표현돼 '목표초과 재협상'이 미참여와 충돌하고 유찰 사유가 뭉개짐 → 이 컬럼으로 명시. + * quotations.close_reason 코드값(SMALLINT). 마감 사유 — 낙찰(AWARDED) 또는 개찰(OPEN_*)로 가른다. +개찰=결렬(유찰)이 아니라 '낙찰자 미정으로 마감' — 자동 재협상/재생성 없이 담당자가 수동 처리(수동 재생성 등)한다. +(구 자동재협상 사유 REGEN_*(2~4)·유찰 개념은 폐지. 사유 플래그 값 5~8은 보존해 OPEN_* 로 재명명.) */ export type CloseReason = typeof CloseReason[keyof typeof CloseReason]; @@ -15,11 +16,8 @@ export type CloseReason = typeof CloseReason[keyof typeof CloseReason]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const CloseReason = { AWARDED: 1, - REGEN_PRICE: 2, - REGEN_EQUAL: 3, - REGEN_NOSHOW: 4, - FAIL_PRICE: 5, - FAIL_EQUAL: 6, - FAIL_NOSHOW: 7, - FAIL_REJECT: 8, + OPEN_PRICE: 5, + OPEN_EQUAL: 6, + OPEN_NOSHOW: 7, + OPEN_REJECT: 8, } as const; diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 15890bd..ef93c03 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -82,7 +82,6 @@ export * from './notificationDataReadAt'; export * from './notificationDataRefQtId'; export * from './notificationDataRefSessionId'; export * from './notificationType'; -export * from './priceGateAction'; export * from './quotationCardData'; export * from './quotationCardDataCondition'; export * from './quotationCardDataEditScript'; @@ -107,6 +106,8 @@ export * from './quotationDataManagerEmail'; export * from './quotationDataManagerName'; export * from './quotationDataMdPrice'; export * from './quotationDataMemo'; +export * from './quotationDataMidAction'; +export * from './quotationDataOverAction'; export * from './quotationDataPreferredSpId'; export * from './quotationDataPreferredSpName'; export * from './quotationDataPreferredSpYn'; @@ -151,6 +152,8 @@ export * from './reqCreateQuotationManagerEmail'; export * from './reqCreateQuotationManagerName'; export * from './reqCreateQuotationMdPrice'; export * from './reqCreateQuotationMemo'; +export * from './reqCreateQuotationMidAction'; +export * from './reqCreateQuotationOverAction'; export * from './reqCreateQuotationSetting'; export * from './reqCreateQuotationStartTime'; export * from './reqCreateQuotationSupplierType'; @@ -205,11 +208,7 @@ export * from './reqUpdateMeEmail'; export * from './reqUpdateMeName'; export * from './reqUpdateMePassword'; export * from './reqUpdateQuotationSetting'; -export * from './reqUpdateQuotationSettingAnchoringValue'; export * from './reqUpdateQuotationSettingCardCount'; -export * from './reqUpdateQuotationSettingMidAction'; -export * from './reqUpdateQuotationSettingOverAction'; -export * from './reqUpdateQuotationSettingRegenLimit'; export * from './reqUpdateQuotationSettingTargetMarginRate'; export * from './reqUpdateSupplier'; export * from './reqUpdateSupplierCode'; diff --git a/negodata/front/src/api/generated/model/priceGateAction.ts b/negodata/front/src/api/generated/model/priceGateAction.ts deleted file mode 100644 index 0a3287e..0000000 --- a/negodata/front/src/api/generated/model/priceGateAction.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * Negodata Api Server - * OpenAPI spec version: 0.1.0 - */ - -/** - * quotation_settings 의 가격 구간별 처리 정책. '앵커링가<투찰가≤목표가'(mid) / '목표가<투찰가'(over) 구간에 적용. -(투찰가≤앵커링가 는 항상 낙찰이라 설정 없음.) - */ -export type PriceGateAction = typeof PriceGateAction[keyof typeof PriceGateAction]; - - -// eslint-disable-next-line @typescript-eslint/no-redeclare -export const PriceGateAction = { - AWARD: 1, - RENEGO: 2, - FAIL: 3, -} as const; diff --git a/negodata/front/src/api/generated/model/quotationData.ts b/negodata/front/src/api/generated/model/quotationData.ts index d2d1770..3b3f5fd 100644 --- a/negodata/front/src/api/generated/model/quotationData.ts +++ b/negodata/front/src/api/generated/model/quotationData.ts @@ -18,6 +18,8 @@ import type { QuotationDataPreferredSpName } from './quotationDataPreferredSpNam import type { QuotationDataEqualBidYn } from './quotationDataEqualBidYn'; import type { QuotationDataEqualBidData } from './quotationDataEqualBidData'; import type { QuotationDataCloseReason } from './quotationDataCloseReason'; +import type { QuotationDataMidAction } from './quotationDataMidAction'; +import type { QuotationDataOverAction } from './quotationDataOverAction'; import type { QuotationDataItemId } from './quotationDataItemId'; import type { QuotationDataItemName } from './quotationDataItemName'; import type { QuotationDataCreatorName } from './quotationDataCreatorName'; @@ -49,6 +51,8 @@ export interface QuotationData { equal_bid_yn?: QuotationDataEqualBidYn; equal_bid_data?: QuotationDataEqualBidData; close_reason?: QuotationDataCloseReason; + mid_action?: QuotationDataMidAction; + over_action?: QuotationDataOverAction; participation_count?: number; item_id?: QuotationDataItemId; item_name?: QuotationDataItemName; diff --git a/negodata/front/src/api/generated/model/reqUpdateQuotationSettingRegenLimit.ts b/negodata/front/src/api/generated/model/quotationDataMidAction.ts similarity index 65% rename from negodata/front/src/api/generated/model/reqUpdateQuotationSettingRegenLimit.ts rename to negodata/front/src/api/generated/model/quotationDataMidAction.ts index 6790922..e187563 100644 --- a/negodata/front/src/api/generated/model/reqUpdateQuotationSettingRegenLimit.ts +++ b/negodata/front/src/api/generated/model/quotationDataMidAction.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ReqUpdateQuotationSettingRegenLimit = number | null; +export type QuotationDataMidAction = number | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateQuotationSettingAnchoringValue.ts b/negodata/front/src/api/generated/model/quotationDataOverAction.ts similarity index 64% rename from negodata/front/src/api/generated/model/reqUpdateQuotationSettingAnchoringValue.ts rename to negodata/front/src/api/generated/model/quotationDataOverAction.ts index 75e7046..90751ca 100644 --- a/negodata/front/src/api/generated/model/reqUpdateQuotationSettingAnchoringValue.ts +++ b/negodata/front/src/api/generated/model/quotationDataOverAction.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ReqUpdateQuotationSettingAnchoringValue = number | null; +export type QuotationDataOverAction = number | null; diff --git a/negodata/front/src/api/generated/model/quotationSettingData.ts b/negodata/front/src/api/generated/model/quotationSettingData.ts index f6aab9a..b8980ce 100644 --- a/negodata/front/src/api/generated/model/quotationSettingData.ts +++ b/negodata/front/src/api/generated/model/quotationSettingData.ts @@ -5,7 +5,6 @@ * OpenAPI spec version: 0.1.0 */ import type { QuotationSettingDataUserId } from './quotationSettingDataUserId'; -import type { PriceGateAction } from './priceGateAction'; import type { QuotationSettingDataCreatedAt } from './quotationSettingDataCreatedAt'; import type { QuotationSettingDataUpdatedAt } from './quotationSettingDataUpdatedAt'; @@ -13,11 +12,7 @@ export interface QuotationSettingData { qt_setting_id: string; user_id?: QuotationSettingDataUserId; target_margin_rate: number; - anchoring_value: number; card_count: number; - mid_action?: PriceGateAction; - over_action?: PriceGateAction; - regen_limit?: number; created_at?: QuotationSettingDataCreatedAt; updated_at?: QuotationSettingDataUpdatedAt; } diff --git a/negodata/front/src/api/generated/model/reqCreateQuotation.ts b/negodata/front/src/api/generated/model/reqCreateQuotation.ts index e51bce6..c750921 100644 --- a/negodata/front/src/api/generated/model/reqCreateQuotation.ts +++ b/negodata/front/src/api/generated/model/reqCreateQuotation.ts @@ -12,6 +12,8 @@ import type { ReqCreateQuotationManagerContactNumber } from './reqCreateQuotatio import type { ReqCreateQuotationMemo } from './reqCreateQuotationMemo'; import type { ReqCreateQuotationMdPrice } from './reqCreateQuotationMdPrice'; import type { ReqCreateQuotationSupplierType } from './reqCreateQuotationSupplierType'; +import type { ReqCreateQuotationMidAction } from './reqCreateQuotationMidAction'; +import type { ReqCreateQuotationOverAction } from './reqCreateQuotationOverAction'; export interface ReqCreateQuotation { qt_setting_id: string; @@ -31,4 +33,6 @@ export interface ReqCreateQuotation { item_ids?: string[]; supplier_ids?: string[]; card_ids?: string[]; + mid_action?: ReqCreateQuotationMidAction; + over_action?: ReqCreateQuotationOverAction; } diff --git a/negodata/front/src/api/generated/model/reqCreateQuotationMidAction.ts b/negodata/front/src/api/generated/model/reqCreateQuotationMidAction.ts new file mode 100644 index 0000000..151a51c --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateQuotationMidAction.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 ReqCreateQuotationMidAction = number | null; diff --git a/negodata/front/src/api/generated/model/reqCreateQuotationOverAction.ts b/negodata/front/src/api/generated/model/reqCreateQuotationOverAction.ts new file mode 100644 index 0000000..553582d --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateQuotationOverAction.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 ReqCreateQuotationOverAction = number | null; diff --git a/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts b/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts index f3f39f0..31a2d7e 100644 --- a/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts +++ b/negodata/front/src/api/generated/model/reqCreateQuotationSetting.ts @@ -4,13 +4,8 @@ * Negodata Api Server * OpenAPI spec version: 0.1.0 */ -import type { PriceGateAction } from './priceGateAction'; export interface ReqCreateQuotationSetting { target_margin_rate: number; - anchoring_value?: number; card_count?: number; - mid_action?: PriceGateAction; - over_action?: PriceGateAction; - regen_limit?: number; } diff --git a/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts b/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts index 32ec724..0070c42 100644 --- a/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts +++ b/negodata/front/src/api/generated/model/reqUpdateQuotationSetting.ts @@ -5,17 +5,9 @@ * OpenAPI spec version: 0.1.0 */ import type { ReqUpdateQuotationSettingTargetMarginRate } from './reqUpdateQuotationSettingTargetMarginRate'; -import type { ReqUpdateQuotationSettingAnchoringValue } from './reqUpdateQuotationSettingAnchoringValue'; import type { ReqUpdateQuotationSettingCardCount } from './reqUpdateQuotationSettingCardCount'; -import type { ReqUpdateQuotationSettingMidAction } from './reqUpdateQuotationSettingMidAction'; -import type { ReqUpdateQuotationSettingOverAction } from './reqUpdateQuotationSettingOverAction'; -import type { ReqUpdateQuotationSettingRegenLimit } from './reqUpdateQuotationSettingRegenLimit'; export interface ReqUpdateQuotationSetting { target_margin_rate?: ReqUpdateQuotationSettingTargetMarginRate; - anchoring_value?: ReqUpdateQuotationSettingAnchoringValue; card_count?: ReqUpdateQuotationSettingCardCount; - mid_action?: ReqUpdateQuotationSettingMidAction; - over_action?: ReqUpdateQuotationSettingOverAction; - regen_limit?: ReqUpdateQuotationSettingRegenLimit; } diff --git a/negodata/front/src/api/generated/model/reqUpdateQuotationSettingMidAction.ts b/negodata/front/src/api/generated/model/reqUpdateQuotationSettingMidAction.ts deleted file mode 100644 index 34712e8..0000000 --- a/negodata/front/src/api/generated/model/reqUpdateQuotationSettingMidAction.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * Negodata Api Server - * OpenAPI spec version: 0.1.0 - */ -import type { PriceGateAction } from './priceGateAction'; - -export type ReqUpdateQuotationSettingMidAction = PriceGateAction | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateQuotationSettingOverAction.ts b/negodata/front/src/api/generated/model/reqUpdateQuotationSettingOverAction.ts deleted file mode 100644 index 5c525f6..0000000 --- a/negodata/front/src/api/generated/model/reqUpdateQuotationSettingOverAction.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * Negodata Api Server - * OpenAPI spec version: 0.1.0 - */ -import type { PriceGateAction } from './priceGateAction'; - -export type ReqUpdateQuotationSettingOverAction = PriceGateAction | null; diff --git a/negodata/front/src/api/generated/model/resTargetBreakdown.ts b/negodata/front/src/api/generated/model/resTargetBreakdown.ts index 4697eed..64f1044 100644 --- a/negodata/front/src/api/generated/model/resTargetBreakdown.ts +++ b/negodata/front/src/api/generated/model/resTargetBreakdown.ts @@ -25,7 +25,6 @@ export interface ResTargetBreakdown { selling?: ResTargetBreakdownSelling; fee?: number; margin?: number; - anchoring_value?: number; candidates?: TargetCandidate[]; chosen_basis?: ResTargetBreakdownChosenBasis; target_price?: number; diff --git a/negodata/front/src/features/dashboard/components/ScopeSection.tsx b/negodata/front/src/features/dashboard/components/ScopeSection.tsx index f6666c0..4994350 100644 --- a/negodata/front/src/features/dashboard/components/ScopeSection.tsx +++ b/negodata/front/src/features/dashboard/components/ScopeSection.tsx @@ -35,7 +35,7 @@ export function ScopeSection({
- +
); diff --git a/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx b/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx index baeec92..dd74866 100644 --- a/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx +++ b/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx @@ -13,7 +13,7 @@ import { Badge } from '@/components/ui/badge'; import { Typography } from '@/components/ui/typography'; import { cn } from '@/lib/utils'; -// 신규 유저용 프로세스 안내. '흐름 설명'(6단계)·'견적 유형'(4종)·'마감 판정'(가격게이트+재생성 한도 결정표) 세 뷰. +// 신규 유저용 프로세스 안내. '흐름 설명'(6단계)·'견적 유형'(4종)·'마감 판정'(가격게이트+개찰 결정표) 세 뷰. // 읽기 전용 정적 안내(테이블/서버 없음). 첫 방문 자동 노출 + 대시보드 버튼으로 재오픈은 호출부(대시보드)에서 제어. type Actor = 'user' | 'partner' | 'system'; @@ -92,9 +92,9 @@ const STEPS: Step[] = [ actor: '시스템', actorType: 'system', icon: Award, - short: '단독최저+정책통과=낙찰 / 목표초과·동가·미참여=재생성 / 결렬', + short: '단독최저+기준통과=낙찰 / 그 외(목표가 초과·동가·미응찰·거부)=개찰', detail: - '낙찰 후보는 협상완료한 협력사 중 최저가입니다. 다만 최저가라고 바로 낙찰이 아니라, 그 최저가가 단독(동점 아님)이고 회사 가격정책의 가격게이트를 낙찰로 통과해야 낙찰됩니다. 최저가가 목표가 구간을 넘어 정책이 재협상이면 다음 차수로 더 깎고, 동가·전원 미참여도 다음 차수를 재생성합니다(모두 체인 전체 재생성 한도 안에서, 사유 무관 합산·기본 1회). 정책이 결렬이거나 한도를 소진하면, 또는 완료 투찰 없이 거부만 있으면 결렬됩니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.', + '낙찰 후보는 협상완료한 협력사 중 최저가입니다. 다만 최저가라고 바로 낙찰이 아니라, 그 최저가가 단독(동점 아님)이고 견적의 낙찰 기준(가격게이트)을 통과해야 낙찰됩니다. 낙찰 기준을 못 넘기거나(목표가 초과 등), 동가·전원 미응찰·협상거부인 경우는 결렬이 아니라 개찰 — 낙찰자 미정으로 마감됩니다. 개찰은 자동 재협상/재생성 없이 담당자가 상세에서 수동으로 다음 라운드를 생성하거나 처리합니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.', }, ]; @@ -104,14 +104,16 @@ const ACTOR_CLASS: Record = { system: 'text-muted-foreground', }; -// ----- 견적 마감 판정(close_and_decide 기준): 낙찰 후보=협상완료 최저가 → (1)단독/동가/미완료 (2)가격게이트(mid/over_action) (3)재생성 한도(체인 전체 총·사유무관, regen_limit)로 낙찰/재생성/결렬. 아래 결정표 8행 = close_reason 8종 ----- -type Tone = 'win' | 'tie' | 'fail'; +// ----- 견적 마감 판정(close_and_decide): 낙찰 후보=협상완료 최저가 → 단독 최저가가 낙찰 기준(가격게이트) 통과면 낙찰, 아니면 개찰. +// 동가·협상거부·전원 미응찰도 결렬이 아니라 개찰(낙찰자 미정 마감). 자동 재협상/재생성 없음 — 다음 라운드는 담당자가 수동 재생성. +// 결정표 = close_reason 5종(낙찰 + 개찰 4: 가격/동가/미응찰/거부). ----- +type Tone = 'win' | 'open'; -// (1) 가격게이트: 투찰가가 앵커링가/목표가 대비 어느 구간이냐로 회사 정책(mid/over_action) 적용. +// (1) 가격게이트(1:1 협상): 투찰가가 앵커링가/목표가 대비 어느 구간이냐로 견적 낙찰 기준 적용. 미달이면 개찰. const GATE_ZONES: { range: string; action: string; note: string }[] = [ { range: '투찰가 ≤ 앵커링가', action: '무조건 낙찰', note: '고정 · 설정 불가' }, - { range: '앵커링가 < 투찰가 ≤ 목표가', action: '견적세팅 mid_action 대로', note: '기본값 = 낙찰' }, - { range: '목표가 < 투찰가', action: '견적세팅 over_action 대로', note: '기본값 = 낙찰' }, + { range: '앵커링가 < 투찰가 ≤ 목표가', action: '낙찰 기준대로 (낙찰 / 개찰)', note: '목표가까지 낙찰이면 낙찰 · 앵커링가까지면 개찰' }, + { range: '목표가 < 투찰가', action: '개찰', note: '항상 개찰(목표가 초과)' }, ]; // (2) 완료 양상별 결정. rows 는 위에서부터 순서대로 판정(close_and_decide 분기 순서와 동일). @@ -131,34 +133,30 @@ const DECISION_TREE: DecisionGroup[] = [ group: '단독 최저가', desc: '협상완료 협력사 중 최저가가 한 곳', rows: [ - { cond: '가격게이트 = 낙찰', result: '낙찰', tone: 'win' }, - { cond: '가격게이트 = 재협상 · 재생성 한도 남음', result: '재협상 · 더 깎기', tone: 'tie' }, - { cond: '가격게이트 = 결렬, 또는 재협상인데 한도 소진', result: '결렬', tone: 'fail' }, + { cond: '낙찰 기준 통과(앵커 이하 · 또는 목표가 이내·목표가까지 낙찰)', result: '낙찰', tone: 'win' }, + { cond: '낙찰 기준 미달(목표가 초과 등)', result: '개찰 · 낙찰자 미정', tone: 'open' }, ], }, { group: '동가', desc: '최저가가 2곳 이상 동일 → 단독 낙찰 불가', rows: [ - { cond: '가격게이트 ≠ 결렬 · 한도 남음', result: '재입찰 · 동가끼리', tone: 'tie' }, - { cond: '가격게이트 = 결렬, 또는 한도 소진', result: '결렬', tone: 'fail' }, + { cond: '항상', result: '개찰 · 낙찰자 미정', tone: 'open' }, ], }, { group: '완료한 투찰 없음', desc: '아무도 협상을 완료(투찰 확정)하지 않음', rows: [ - { cond: '거부한 협력사가 있음', result: '결렬', tone: 'fail' }, - { cond: '전원 미참여 · 한도 남음', result: '재소집 · 공급사 전체', tone: 'tie' }, - { cond: '전원 미참여 · 한도 소진', result: '결렬', tone: 'fail' }, + { cond: '거부한 협력사가 있음', result: '개찰 · 거부', tone: 'open' }, + { cond: '전원 미응찰', result: '개찰 · 미응찰', tone: 'open' }, ], }, ]; const TONE_CHIP: Record = { win: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', - tie: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', - fail: 'bg-destructive/10 text-destructive', + open: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', }; // ----- 견적 유형(4종): 두 축으로 갈림 — 신규/재(목표가 산정 후보) × 협상 1:1 / 견적 1:N(부르는 협력사 수). @@ -434,16 +432,16 @@ export function OnboardingGuideModal({ 자동 - 낙찰 후보는 협상완료한 협력사 중 최저가입니다. 최저가라고 곧 낙찰이 아니라, 그 가격을 견적세팅의 가격정책(가격게이트)에 통과시켜 낙찰 · 재협상 · 결렬이 갈립니다. + 낙찰 후보는 협상완료한 협력사 중 최저가입니다. 최저가라고 곧 낙찰이 아니라, 그 가격을 견적의 낙찰 기준(가격게이트)에 통과시켜 낙찰 · 개찰이 갈립니다. - {/* 핵심: 처리 방식은 견적세팅이 정하고, 기본은 낙찰 */} + {/* 핵심: 낙찰 기준은 견적 생성 시 정하고, 미달은 개찰 */}
- 낙찰 · 재협상 · 결렬은 견적세팅에서 정합니다 + 낙찰 기준은 견적 생성 시 정합니다 (1:1 협상) - 각 가격 구간을 어떻게 처리할지는 견적세팅의 가격정책(mid_action · over_action)에서 지정합니다. 기본값은 전 구간 낙찰이라, 따로 바꾸지 않으면 목표가를 초과해도 낙찰됩니다. + 1:1 협상은 낙찰선을 앵커링가까지 / 목표가까지 중 택1합니다. 앵커링가 이하는 항상 낙찰, 목표가 초과는 항상 개찰이며, 그 사이 구간만 이 선택으로 갈립니다. 낙찰 기준을 못 넘기면 결렬이 아니라 개찰(낙찰자 미정 마감)이고, 자동 재협상/재생성은 없습니다. 1:N 견적은 무조건 최저가 낙찰.
@@ -509,13 +507,13 @@ export function OnboardingGuideModal({ ))} - {/* 3) 재생성 한도 */} + {/* 3) 개찰 처리 */}
- 3) 재생성 한도 + 3) 개찰(낙찰자 미정 마감) - 재협상 · 재입찰 · 재소집은 모두 다음 차수를 자동 생성합니다. 한도(regen_limit)는 체인(같은 견적번호) 전체에서 사유 무관 합산한 총 횟수이며 기본 1회이고, 소진하면 결렬됩니다. 모든 결과는 알림함으로 통지됩니다. + 낙찰이 아닌 마감(기준 미달·동가·미응찰·거부)은 결렬이 아니라 개찰입니다. 자동 재협상/재생성은 없고, 다음 라운드가 필요하면 담당자가 상세 화면에서 수동으로 만듭니다. 모든 결과는 알림함으로 통지됩니다.
diff --git a/negodata/front/src/features/onboarding/steps.ts b/negodata/front/src/features/onboarding/steps.ts index 5761836..e6e6c61 100644 --- a/negodata/front/src/features/onboarding/steps.ts +++ b/negodata/front/src/features/onboarding/steps.ts @@ -74,9 +74,9 @@ export const STEPS: Step[] = [ actor: '시스템', actorType: 'system', icon: Award, - short: '단독최저=낙찰 / 동가·미참여=재생성 / 결렬', + short: '단독최저+기준통과=낙찰 / 그 외=개찰(낙찰자 미정)', detail: - '단독 최저가면 그 협력사로 낙찰됩니다. 최저가가 둘 이상 같은 동가이거나 전원 미참여면 다음 차수가 자동 재생성되고, 거절·한도 등으로 낙찰자가 없으면 결렬 처리됩니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.', + '단독 최저가가 견적의 낙찰 기준을 통과하면 그 협력사로 낙찰됩니다. 기준을 못 넘기거나 동가·전원 미응찰·협상거부인 경우는 결렬이 아니라 개찰 — 낙찰자 미정으로 마감되고, 다음 라운드는 담당자가 상세에서 수동으로 만듭니다. 결과는 알림함으로 통지되고 대시보드에서 현황을 확인합니다.', }, ]; diff --git a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx index ac53594..380f7e1 100644 --- a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { X, PlusSquare, ArrowRight, Loader2 } from 'lucide-react'; +import { X, PlusSquare, ArrowRight, Loader2, Gavel } from 'lucide-react'; import { useNavigate } from 'react-router'; import { useGetSupplierLastType } from '@/api/generated/quotation/quotation'; import { Button } from '@/components/ui/button'; @@ -10,7 +10,16 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types'; import type { CreateQuotationInput } from '../hooks/useQuotations'; import { QuotationType } from '@/api/generated/model'; -import { QUOTATION_TYPE_OPTIONS, supplierTypeOptions, isNewQuotationType } from '../types'; +import { + supplierTypeOptions, + is1v1, + toQuotationType, + awardStrategySummary, + PriceGateAction, + DEFAULT_MID_ACTION, + DEFAULT_OVER_ACTION, + type QuotationMode, +} from '../types'; import { supplierTypeLabel } from '@/lib/enumLabels'; import { showToast } from '@/lib/notify'; @@ -42,7 +51,9 @@ export function QuotationCreateModal({ }: QuotationCreateModalProps) { const [step, setStep] = useState(1); const [title, setTitle] = useState(''); - const [type, setType] = useState(QuotationType.REQUOTE); + // 유형은 '진행 방식(협상/경매) × 대상(신규/후속)' 2축으로 받아 제출 직전 4코드로 합성한다. + const [mode, setMode] = useState('nego'); // 1:1 협상 / 1:N 경매 + const [isNew, setIsNew] = useState(true); // 신규 / 후속(재) const [productId, setProductId] = useState(''); const [selectedPartnerIds, setSelectedPartnerIds] = useState([]); const [dueDate, setDueDate] = useState(nowKstLocalInput); @@ -51,8 +62,18 @@ export function QuotationCreateModal({ const [memo, setMemo] = useState(''); const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 상품값으로 목표가 산정 const [supplierType, setSupplierType] = useState(''); // 협력사 유형(SupplierType). 전 유형에서 입력 → 견적에 기록 + const [midAction, setMidAction] = useState(DEFAULT_MID_ACTION); // 앵커~목표가 구간: 낙찰/개찰 (1:1 전용) + const [overAction, setOverAction] = useState(DEFAULT_OVER_ACTION); // 목표가 초과 구간: 낙찰/개찰 (1:1 전용) const [submitting, setSubmitting] = useState(false); - const typeOptions = QUOTATION_TYPE_OPTIONS; + + const type = toQuotationType(mode, isNew); // 4코드 합성값 + const oneToOne = is1v1(type); // 1:1 협상 여부 — 협력사 단일선택·낙찰기준·협상카드 노출을 가른다 + const isReType = !isNew; + // 스텝 구성 — 1:1 협상만 협상카드 스텝 추가(작은 화면 과밀 방지). 경매는 3스텝. + const steps = oneToOne + ? ['기본 정보', '협력사 초청', '낙찰 기준', '협상카드'] + : ['기본 정보', '협력사 초청', '확인·완료']; + const totalSteps = steps.length; // 협력사 유형은 전 유형에서 입력받되, 재협상(1:1)이면 선택 협력사의 직전 견적 supplier_type 을 조회해 디폴트로 채운다. const renegoSupplierId = type === QuotationType.RENEGO ? (selectedPartnerIds[0] ?? '') : ''; @@ -69,7 +90,6 @@ export function QuotationCreateModal({ const navigate = useNavigate(); // 인터넷최저가·매입가·판매가는 상품 속성 — 모달에선 읽기전용으로만 보여주고, 수정은 상품 상세에서 한다. const selectedProduct = products.find((p) => p.id === productId); - const isReType = !isNewQuotationType(type); const internetLowest = selectedProduct?.internet_lowest_price ?? null; const purchase = selectedProduct?.purchase_price ?? null; const selling = selectedProduct?.selling_price ?? null; @@ -82,16 +102,25 @@ export function QuotationCreateModal({ if (!open) return null; + const selectMode = (next: QuotationMode) => { + setMode(next); + // 경매→협상 전환 시 다중선택으로 담긴 협력사를 1곳으로 줄인다(1:1 단일선택). + if (next === 'nego') setSelectedPartnerIds((prev) => prev.slice(0, 1)); + // 경매는 3스텝뿐 — 협상카드 스텝(4)에 있던 상태면 마지막(3)으로 당긴다. + if (next === 'auction') setStep((s) => Math.min(s, 3)); + }; const togglePartner = (id: string) => setSelectedPartnerIds((prev) => - type === QuotationType.RENEGO - ? prev.includes(id) ? [] : [id] - : prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id], + oneToOne + ? prev.includes(id) + ? [] + : [id] + : prev.includes(id) + ? prev.filter((p) => p !== id) + : [...prev, id], ); const toggleCard = (id: string) => - setSelectedCardIds((prev) => - prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id], - ); + setSelectedCardIds((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id])); const handleSubmit = async () => { if (submitting) return; @@ -102,7 +131,7 @@ export function QuotationCreateModal({ showToast('목표가 산정에 쓸 값이 없습니다 — MD 제시가를 입력하거나, 상품 상세에서 인터넷최저가·매입가를 채워주세요.', 'error'); return; // finally 에서 submitting 해제 } - // 서버가 견적+세션 생성을 끝내고 응답할 때까지 기다린 뒤에 완료(닫기) 처리한다. + // 낙찰 기준은 1:1 협상만 전송(경매는 미전송 → 서버가 mid=over=AWARD 강제). 카드도 1:1 전용. const ok = await onCreate({ title, type, @@ -110,10 +139,12 @@ export function QuotationCreateModal({ partnerIds: selectedPartnerIds, dueDate, settingId, - cardIds: selectedCardIds, + cardIds: oneToOne ? selectedCardIds : [], memo, mdPrice: mdPrice ? Number(mdPrice) : null, supplierType: supplierType ? Number(supplierType) : null, + midAction: oneToOne ? midAction : undefined, + overAction: oneToOne ? overAction : undefined, }); if (ok) onClose(); } finally { @@ -138,26 +169,26 @@ export function QuotationCreateModal({
- 신규 견적 등록 (단계 {step}/3) + 신규 견적 등록 (단계 {step}/{totalSteps})
- {/* Steps indicator */} + {/* Steps indicator — 스텝 수는 유형에 따라 3(경매)/4(협상) */}
- - - - - + {steps.map((label, i) => { + const n = i + 1; + return ( +
+ {i > 0 && } + +
+ ); + })}
{/* Step content */} @@ -165,41 +196,41 @@ export function QuotationCreateModal({ {step === 1 && (
- {/* 견적 유형이 맨 위 — 신규/재 여부가 아래 매입가 필수 여부까지 결정한다 */} + {/* 진행 방식 × 대상 2축 — 협상/경매 갈림이 아래 카드·낙찰기준 노출까지 결정한다 */}
- 견적 유형 - -
- -
- 마감기한 - setDueDate(e.target.value)} + 진행 방식 + selectMode(v as QuotationMode)} />
+
+ 대상 + setIsNew(v === 'new')} + /> +
+
+ +
+ 마감기한 + setDueDate(e.target.value)} + />
@@ -216,7 +247,7 @@ export function QuotationCreateModal({
상품 - setProductId(v ?? '')}> {(value) => { @@ -296,7 +327,7 @@ export function QuotationCreateModal({ {step === 2 && (
- 협력사 초청 ({type === QuotationType.RENEGO ? '단일선택' : '다중선택'}) + 협력사 초청 ({oneToOne ? '단일선택' : '다중선택'})
{partners.map((part) => { const isChecked = selectedPartnerIds.includes(part.id ?? ''); @@ -357,13 +388,13 @@ export function QuotationCreateModal({
적용할 견적 세팅 지정 - setSettingId(v ?? '')}> {(value) => { const qs = quotationSettings.find((s) => s.qt_setting_id === value); return qs - ? `[목표 마진: ${qs.target_margin}] ${qs.anchoring_value} (${qs.card_use_count})` + ? `[목표 마진: ${qs.target_margin}] 카드 ${qs.card_use_count}` : ''; }} @@ -371,45 +402,42 @@ export function QuotationCreateModal({ {quotationSettings.map((qs) => ( - [목표 마진: {qs.target_margin}] {qs.anchoring_value} ({qs.card_use_count}) + [목표 마진: {qs.target_margin}] 카드 {qs.card_use_count} ))}
-
- 협상카드 및 와일드카드 선택 -
- {cards.filter((c) => !c.isWildcard || c.status === 'ACTIVE').map((card) => { - const isChecked = selectedCardIds.includes(card.id); - return ( -
toggleCard(card.id)} - className={`p-2.5 rounded border cursor-pointer transition-all flex items-start gap-2 ${ - isChecked ? 'bg-primary/5 border-primary font-bold' : 'bg-background border-border hover:bg-muted/10' - }`} - > - -
-
- {card.code} - - {card.isWildcard ? '와일드' : '협상'} - -
- {card.title} -
-
- ); - })} + {oneToOne ? ( + <> + {/* 낙찰 기준(1:1 전용) — 스펙트럼 = 선택. 낙찰선을 앵커/목표가 중 택1, 목표가 초과는 항상 개찰. */} + { + setMidAction(v); + if (v === PriceGateAction.OPEN) setOverAction(PriceGateAction.OPEN); + }} + onOver={(v) => { + setOverAction(v); + if (v === PriceGateAction.AWARD) setMidAction(PriceGateAction.AWARD); + }} + /> + + ) : ( + /* 경매(1:N) — 낙찰 기준·협상카드 없음. 최저가 자동 낙찰 안내만. */ +
+ +
+ 최저가 자동 낙찰 + + 1:N 견적은 가장 낮은 투찰가가 자동 낙찰됩니다. 낙찰 기준·협상카드 설정이 없습니다. + +
-
+ )}
메모 (선택) @@ -426,6 +454,45 @@ export function QuotationCreateModal({
)} + {/* Step 4 — 협상카드(1:1 협상 전용, 별도 스텝으로 분리해 과밀 방지) */} + {step === 4 && oneToOne && ( +
+ 협상카드 및 와일드카드 선택 (선택) + + 1:1 협상에서 AI 협상봇이 발동할 카드입니다. + +
+ {cards.filter((c) => !c.isWildcard || c.status === 'ACTIVE').map((card) => { + const isChecked = selectedCardIds.includes(card.id); + return ( +
toggleCard(card.id)} + className={`p-2.5 rounded border cursor-pointer transition-all flex items-start gap-2 ${ + isChecked ? 'bg-primary/5 border-primary font-bold' : 'bg-background border-border hover:bg-muted/10' + }`} + > + +
+
+ {card.code} + + {card.isWildcard ? '와일드' : '협상'} + +
+ {card.title} +
+
+ ); + })} +
+
+ )} +
{/* Footer nav */} @@ -440,7 +507,7 @@ export function QuotationCreateModal({
- {step < 3 ? ( + {step < totalSteps ? ( + ); + })} +
+ ); +} + +// 낙찰 기준 컨트롤 — 스펙트럼 선(線) 위 구간을 눌러 낙찰↔개찰 전환. 앵커 이하는 항상 낙찰(고정, 표시만) — +// '앵커~목표가'(mid_action)·'목표가 초과'(over_action) 두 구간만 사용자가 각각 낙찰/개찰로 정한다. +function AwardLinePicker({ + mid, over, onMid, onOver, +}: { + mid: number; + over: number; + onMid: (v: number) => void; + onOver: (v: number) => void; +}) { + const A = PriceGateAction.AWARD; + const O = PriceGateAction.OPEN; + const zones = [ + { label: '앵커링가 이하', win: true, locked: true, toggle: undefined }, + { label: '앵커링가~목표가', win: mid === A, locked: false, toggle: () => onMid(mid === A ? O : A) }, + { label: '목표가 초과', win: over === A, locked: false, toggle: () => onOver(over === A ? O : A) }, + ]; + return ( +
+
+ 낙찰 기준 + 구간을 눌러 낙찰↔개찰 · 싸다◀▶비싸다 +
+ + 협력사 최저 투찰가가 어느 구간에 오느냐로 낙찰/개찰이 정해집니다. + + {/* 스펙트럼 선: 구간이 곧 선택 버튼 */} +
+ {zones.map((z, i) => { + const body = ( + <> + {z.label} + + {z.win ? '낙찰' : '개찰'}{z.locked ? ' 🔒' : ''} + + + ); + const cls = cn('flex-1 px-1 py-2', i > 0 && 'border-l border-border', z.win ? 'bg-emerald-50 dark:bg-emerald-950/30' : 'bg-muted'); + return z.locked ? ( +
{body}
+ ) : ( + + ); + })} +
+ {/* 경계 마커 — 구간 경계(1/3·2/3)에 ▲ 중앙 정렬(앵커링가·목표가) */} +
+ {[ + { left: '33.3333%', label: '앵커링가' }, + { left: '66.6667%', label: '목표가' }, + ].map((mk) => ( + + ▲ + {mk.label} + + ))} +
+ {/* 전략 한 줄 요약(관대/기본/엄격 전략) — 기본 전략(목표가까지 낙찰·초과 개찰)일 때만 경계가 애매하니 '목표가 포함' 부기 */} + {(() => { + const t = awardStrategySummary(mid, over); + const isBasic = mid === PriceGateAction.AWARD && over === PriceGateAction.OPEN; + return ( + + {t.strategy} + · {t.desc} + {isBasic && ( + · 목표가 포함 + )} + + ); + })()} +
+ ); +} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx index 2a64ae0..f68c713 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx @@ -13,7 +13,7 @@ import { type QuotationSetting, type SessionView, quotationTypeLabel, - priceGateActionLabel, + awardCriterionLabel, fmtDateTime, } from '../../types'; @@ -89,6 +89,7 @@ export function DrawerHeaderCards({ + {repSessionId ? (
) : (
적용된 견적 세팅이 비어있습니다.
diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ResultSummaryBand.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ResultSummaryBand.tsx index 5fbf282..d79ee36 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ResultSummaryBand.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ResultSummaryBand.tsx @@ -15,11 +15,10 @@ import { const won = (n?: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-'); -// 결과 상태별 배지 톤(협상현황 pill 팔레트 재사용). +// 결과 상태별 배지 톤(협상현황 pill 팔레트 재사용). 개찰=주황(낙찰자 미정, 수동 처리 필요). const OUTCOME_TONE: Record = { awarded: 'emerald', - regenerated: 'amber', - failed: 'blue', + opened: 'amber', active: 'zinc', }; diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx index 8e5b3c9..d680f61 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx @@ -122,16 +122,15 @@ export function TargetPriceModal({ })}
- {/* 앵커링 (negodata: 설정 anchoring_value 고정율) */} + {/* 앵커링 (negodata: 앵커링가는 칸 rate v1.2로 산정·박제, 세팅 고정율 폐지) */}
앵커링 {category && ( 서비스 카테고리: {category} )} 공급 업체 유형: {supplierTypeLabel} - 앵커링 값: {bd.anchoring_value} - 앵커링가: {won(bd.target_anchoring_price)} = 목표가×(1−{bd.anchoring_value}) + 앵커링가: {won(bd.target_anchoring_price)} = 목표가 × (1 − 앵커링율)
diff --git a/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx b/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx index a67b33a..940a3ec 100644 --- a/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx @@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'; import { Typography } from '@/components/ui/typography'; import { Input } from '@/components/ui/input'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; -import { type QuotationSetting, priceGateActionLabel, PRICE_GATE_ACTION_OPTIONS } from '../types'; +import { type QuotationSetting } from '../types'; import type { SettingInput } from '../hooks/useQuotations'; type QuotationSettingsModalProps = { @@ -23,27 +23,17 @@ export function QuotationSettingsModal({ onClose, }: QuotationSettingsModalProps) { const [targetMargin, setTargetMargin] = useState(''); - const [anchoringValue, setAnchoringValue] = useState(''); const [cardUseCount, setCardUseCount] = useState(''); - const [midAction, setMidAction] = useState(1); // PriceGateAction 기본 낙찰 - const [overAction, setOverAction] = useState(1); - const [regenLimit, setRegenLimit] = useState('1'); if (!open) return null; + // 세팅은 목표 마진율·카드 사용 횟수만. 낙찰 정책은 견적 생성으로 이관, 앵커링은 칸 rate(v1.2)로 대체. const handleAdd = (e: React.FormEvent) => { e.preventDefault(); - const ok = onAdd({ - targetMargin, anchoringValue, cardUseCount, - midAction, overAction, regenLimit: parseInt(regenLimit, 10), - }); + const ok = onAdd({ targetMargin, cardUseCount }); if (ok) { setTargetMargin(''); - setAnchoringValue(''); setCardUseCount(''); - setMidAction(1); - setOverAction(1); - setRegenLimit('1'); } }; @@ -70,18 +60,14 @@ export function QuotationSettingsModal({ 목표 마진율 - 앵커링 값 카드 사용 횟수 - 앵커~목표 - 목표초과 - 재생성 삭제 {settings.length === 0 && ( - + 등록된 견적 세팅이 없습니다. (리스트가 비어 있습니다) @@ -89,11 +75,7 @@ export function QuotationSettingsModal({ {settings.map((qs) => ( {qs.target_margin} - {qs.anchoring_value} {qs.card_use_count} - {priceGateActionLabel(qs.mid_action)} - {priceGateActionLabel(qs.over_action)} - {qs.regen_limit}회
-
- 앵커링 값 - setAnchoringValue(e.target.value)} placeholder="예: 0.01" /> -
카드 사용 횟수 setCardUseCount(e.target.value)} placeholder="예: 3" />
-
- 앵커링가~목표가 마감처리 - -
-
- 목표가초과 마감처리 - -
-
- 재생성 최대 횟수 (체인 전체) - setRegenLimit(e.target.value)} placeholder="예: 1" /> -
diff --git a/negodata/front/src/features/quotations/components/QuotationTable.tsx b/negodata/front/src/features/quotations/components/QuotationTable.tsx index 69fc3b4..c07913c 100644 --- a/negodata/front/src/features/quotations/components/QuotationTable.tsx +++ b/negodata/front/src/features/quotations/components/QuotationTable.tsx @@ -11,9 +11,10 @@ import { quotationStatusLabel, quotationTypeLabel, chainRoundState, + is1v1, CHAIN_ROUND_STATE_LABEL, } from '../types'; -import { QuotationType, QuotationStatus } from '@/api/generated/model'; +import { QuotationStatus } from '@/api/generated/model'; type QuotationTableProps = { data: Estimate[]; @@ -37,15 +38,13 @@ const statusBadgeClass = (status?: number | null) => { } }; -// 마감결과 배지 색. 낙찰=초록/재생성=주황/결렬=로즈/진행중=회색(아직 마감 전). +// 마감결과 배지 색. 낙찰=초록 / 개찰(낙찰자 미정, 수동 처리)=주황 / 진행중=회색(아직 마감 전). const outcomeBadgeClass = (state: ChainRoundState) => { switch (state) { case 'awarded': return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/25 dark:text-emerald-400 border-emerald-300/50'; - case 'regenerated': + case 'opened': return 'bg-amber-50 text-amber-700 dark:bg-amber-950/25 dark:text-amber-400 border-amber-300/50'; - case 'failed': - return 'bg-rose-50 text-rose-700 dark:bg-rose-950/25 dark:text-rose-400 border-rose-300/50'; default: return 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800/40 dark:text-zinc-400 border-zinc-300/40'; } @@ -104,15 +103,12 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo { header: '유형', align: 'center', + // 유형은 고정 속성이라 pill 대신 평문 — 협상(1:1)만 살짝 진하게, 경매(1:N)는 연하게. cell: (est) => ( {quotationTypeLabel(est.type)} diff --git a/negodata/front/src/features/quotations/hooks/useQuotations.ts b/negodata/front/src/features/quotations/hooks/useQuotations.ts index 6e0b505..114ac47 100644 --- a/negodata/front/src/features/quotations/hooks/useQuotations.ts +++ b/negodata/front/src/features/quotations/hooks/useQuotations.ts @@ -27,7 +27,7 @@ import { confirm } from '@/lib/confirm'; import { useAuthStore } from '@/stores/auth'; import type { Estimate } from '../types'; import { mapItem, mapSupplier, mapSetting, mapQuotation } from '../types'; -import { QuotationStatus, type PriceGateAction } from '@/api/generated/model'; +import { QuotationStatus } from '@/api/generated/model'; export type CreateQuotationInput = { title: string; @@ -40,15 +40,14 @@ export type CreateQuotationInput = { memo: string; mdPrice?: number | null; // MD 제시가(원). 비우면 미전송 → 서버가 기존 마진식으로 목표가 산정 supplierType?: number | null; // 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록 + // 낙찰 기준 — 1:1 협상만 전송(경매는 미전송 → 서버가 mid=over=AWARD 강제). 2전략을 mid/over 로 전개해 담는다(over 항상 OPEN). + midAction?: number; // PriceGateAction (앵커~목표가 처리: 낙찰/개찰) + overAction?: number; // PriceGateAction (목표가 초과 처리: 협상은 항상 개찰) }; export type SettingInput = { targetMargin: string; - anchoringValue: string; cardUseCount: string; - midAction?: number; // PriceGateAction (앵커~목표 마감처리). 미지정=1(낙찰) - overAction?: number; // PriceGateAction (목표초과 마감처리). 미지정=1(낙찰) - regenLimit?: number; // 재생성 최대 횟수(체인 전체, 사유 무관). 미지정=1 }; // 견적 화면 데이터 허브. @@ -124,18 +123,15 @@ export function useQuotations(params: ListQuotationsParams) { // 백엔드는 target_margin_rate 를 비율(0.12)로 저장하므로 % 입력을 100 으로 나눠 보낸다. const addSetting = (input: SettingInput): boolean => { const marginPct = Number(String(input.targetMargin).replace('%', '').trim()); - const anchoring = Number(String(input.anchoringValue).trim()); const cardCount = parseInt(String(input.cardUseCount).replace(/[^0-9-]/g, ''), 10); - if (!Number.isFinite(marginPct) || !Number.isFinite(anchoring) || !Number.isInteger(cardCount)) { - showToast('목표 마진율·앵커링 값·카드 사용 횟수를 숫자로 입력해야 합니다.', 'error'); + if (!Number.isFinite(marginPct) || !Number.isInteger(cardCount)) { + showToast('목표 마진율·카드 사용 횟수를 숫자로 입력해야 합니다.', 'error'); return false; } createSettingMutation.mutate( + // 낙찰 정책은 견적 생성으로 이관, 앵커링은 칸 rate(v1.2) → 세팅은 목표 마진율·카드 사용 횟수만. { data: { - target_margin_rate: marginPct / 100, anchoring_value: anchoring, card_count: cardCount, - mid_action: (input.midAction ?? 1) as PriceGateAction, - over_action: (input.overAction ?? 1) as PriceGateAction, - regen_limit: Number.isInteger(input.regenLimit) ? (input.regenLimit as number) : 1, + target_margin_rate: marginPct / 100, card_count: cardCount, } }, { onSuccess: () => { @@ -199,6 +195,9 @@ export function useQuotations(params: ListQuotationsParams) { memo: input.memo.trim() || undefined, md_price: input.mdPrice && input.mdPrice > 0 ? input.mdPrice : undefined, supplier_type: input.supplierType ?? undefined, + // 낙찰 기준은 1:1 협상만 전송(모달이 미리 걸러 담음) — 경매면 미전송 → 서버가 AWARD 강제. + mid_action: input.midAction ?? undefined, + over_action: input.overAction ?? undefined, }; try { diff --git a/negodata/front/src/features/quotations/types.ts b/negodata/front/src/features/quotations/types.ts index b7be156..69db4e5 100644 --- a/negodata/front/src/features/quotations/types.ts +++ b/negodata/front/src/features/quotations/types.ts @@ -4,12 +4,17 @@ import type { QuotationSettingData } from '@/api/generated/model/quotationSettin import type { QuotationData } from '@/api/generated/model/quotationData'; import type { SessionData } from '@/api/generated/model/sessionData'; import type { QuotationCardData } from '@/api/generated/model/quotationCardData'; -import { QuotationType, QuotationStatus, SessionStatus, CardType, CloseReason, PriceGateAction } from '@/api/generated/model'; +import { QuotationType, QuotationStatus, SessionStatus, CardType, CloseReason } from '@/api/generated/model'; import { DELIVERY_TYPE_LABEL } from '@/lib/enumLabels'; import type { Product, Partner, NegotiationCard } from '@/types'; export type { Product, Partner, NegotiationCard } from '@/types'; +// 낙찰 기준 가격게이트 값 — 백엔드 mid_action/over_action(SMALLINT)에 그대로 저장. +// (백엔드 스키마가 int 라 orval 이 enum 을 안 만들어 → 프론트 로컬 정의.) AWARD=낙찰, OPEN=개찰(낙찰자 미정 마감). +export const PriceGateAction = { AWARD: 1, OPEN: 2 } as const; +export type PriceGateAction = (typeof PriceGateAction)[keyof typeof PriceGateAction]; + export type Estimate = Partial & { id?: string; dueDate?: string; @@ -31,30 +36,12 @@ export interface QuotationSetting { qt_setting_id: string; user_id: string; target_margin: string; - anchoring_value: string; card_use_count: string; - mid_action: PriceGateAction; // 앵커링가<투찰가≤목표가 마감 처리 - over_action: PriceGateAction; // 목표가<투찰가 마감 처리 - regen_limit: number; // 재생성 최대 횟수(체인 전체, 사유 무관) created_at: string; updated_at: string; deleted: boolean; } -// 마감 가격정책 처리 라벨(견적세팅 표시·선택). -export const PRICE_GATE_ACTION_LABEL: Record = { - [PriceGateAction.AWARD]: '낙찰', - [PriceGateAction.RENEGO]: '재협상', - [PriceGateAction.FAIL]: '결렬', -}; -export const priceGateActionLabel = (a?: number | null): string => - a != null ? PRICE_GATE_ACTION_LABEL[a as PriceGateAction] ?? String(a) : ''; -export const PRICE_GATE_ACTION_OPTIONS = [ - PriceGateAction.AWARD, - PriceGateAction.RENEGO, - PriceGateAction.FAIL, -].map((value) => ({ value, label: PRICE_GATE_ACTION_LABEL[value] })); - // ── 서버 응답 → UI 모델 매퍼 ───────────────────────────────────────────── export function mapItem(it: ItemData): Product { @@ -80,11 +67,7 @@ export function mapSetting(s: QuotationSettingData): QuotationSetting { qt_setting_id: s.qt_setting_id, user_id: s.user_id || '', target_margin: `${Number.isFinite(ratePct) ? +ratePct.toFixed(2) : 0}%`, - anchoring_value: String(s.anchoring_value ?? ''), card_use_count: `${s.card_count ?? 0}회`, - mid_action: s.mid_action ?? PriceGateAction.AWARD, - over_action: s.over_action ?? PriceGateAction.AWARD, - regen_limit: s.regen_limit ?? 1, created_at: s.created_at ?? '', updated_at: s.updated_at ?? '', deleted: false, @@ -174,36 +157,77 @@ export const QUOTATION_TYPE_OPTIONS = [ export const isNewQuotationType = (t?: number | null): boolean => t === QuotationType.NEW_NEGO || t === QuotationType.NEW_QUOTE; +// 1:1 협상(RENEGO/NEW_NEGO) 여부. 협상카드·낙찰기준 노출, 협력사 단일선택이 이 분기에 의존. +// 나머지(REQUOTE/NEW_QUOTE)는 1:N 경매 — 무조건 최저가 낙찰. +export const is1v1 = (t?: number | null): boolean => + t === QuotationType.RENEGO || t === QuotationType.NEW_NEGO; + +// 견적 생성 폼의 2축(진행 방식 × 대상) → 4코드 유형 합성. +export type QuotationMode = 'nego' | 'auction'; // 1:1 협상 / 1:N 경매 +export const toQuotationType = (mode: QuotationMode, isNew: boolean): QuotationType => + mode === 'nego' + ? isNew + ? QuotationType.NEW_NEGO + : QuotationType.RENEGO + : isNew + ? QuotationType.NEW_QUOTE + : QuotationType.REQUOTE; + +// ── 낙찰 기준(1:1 협상 전용) ────────────────────────────────────────────── +// 앵커링가 이하는 항상 낙찰(고정·선택 불가, 표시만). 사용자는 '앵커~목표가'(mid_action)와 '목표가 초과'(over_action) +// 두 구간만 각각 낙찰(AWARD)/개찰(OPEN)로 정한다. 개찰 = 낙찰자 미정으로 마감(결렬 아님, 담당자 수동 처리). +export const DEFAULT_MID_ACTION: PriceGateAction = PriceGateAction.AWARD; // 앵커~목표가 기본 낙찰 +export const DEFAULT_OVER_ACTION: PriceGateAction = PriceGateAction.OPEN; // 목표가 초과 기본 개찰 + +// 견적의 낙찰 기준 한 줄 라벨(상세 드로어). 경매(1:N)=최저가 자동 낙찰. 협상=두 구간 조합 요약. +export function awardCriterionLabel(type?: number | null, mid?: number | null, over?: number | null): string { + if (type != null && !is1v1(type)) return '최저가 자동 낙찰'; + const m = (mid ?? PriceGateAction.AWARD) === PriceGateAction.AWARD; + const o = (over ?? PriceGateAction.AWARD) === PriceGateAction.AWARD; + if (m && o) return '목표가 초과도 낙찰'; + if (m && !o) return '목표가까지 낙찰'; + if (!m && !o) return '앵커링가까지 낙찰'; + return '목표가 초과만 낙찰'; // 비정상 조합(앵커~목표가 개찰인데 초과 낙찰) +} + +// 낙찰 기준(mid, over) 조합 → 한 줄 전략 요약(생성 모달 안내). 낙찰 범위가 넓을수록 관대. +export function awardStrategySummary(mid?: number | null, over?: number | null): { strategy: string; desc: string } { + const m = (mid ?? PriceGateAction.AWARD) === PriceGateAction.AWARD; + const o = (over ?? PriceGateAction.AWARD) === PriceGateAction.AWARD; + if (m && o) return { strategy: '관대 전략', desc: '목표가를 초과해도 최저가면 낙찰' }; + if (m && !o) return { strategy: '균형 전략', desc: '목표가 이내면 낙찰 · 초과는 개찰' }; + if (!m && !o) return { strategy: '엄격 전략', desc: '앵커링가 이하만 낙찰 · 그 위는 개찰' }; + return { strategy: '혼합 전략', desc: '앵커~목표가는 개찰인데 초과만 낙찰 · 권장 안 함' }; +} + // 협력사 유형 선택지(견적생성 모달) — 라벨은 lib/enumLabels.ts 의 SUPPLIER_TYPE 단일 출처에서 파생. export { SUPPLIER_TYPE_OPTIONS as supplierTypeOptions } from '@/lib/enumLabels'; // ── 라운드 체인(같은 견적번호) ─────────────────────────────────────────── -// 한 라운드(견적)의 결과를 한 단어로. 낙찰=종료, 동가/마감=후속 라운드 가능, 진행중=아직 안 닫힘. -export type ChainRoundState = 'awarded' | 'regenerated' | 'failed' | 'active'; +// 한 라운드(견적)의 결과를 한 단어로. 낙찰=종료 / 개찰=낙찰자 미정 마감(수동 재생성 가능) / 진행중=아직 안 닫힘. +export type ChainRoundState = 'awarded' | 'opened' | 'active'; export const CHAIN_ROUND_STATE_LABEL: Record = { awarded: '낙찰', - regenerated: '재생성', - failed: '결렬', + opened: '개찰', active: '진행중', }; -const REGEN_CLOSE_REASONS: CloseReason[] = [CloseReason.REGEN_PRICE, CloseReason.REGEN_EQUAL, CloseReason.REGEN_NOSHOW]; -const FAIL_CLOSE_REASONS: CloseReason[] = [CloseReason.FAIL_PRICE, CloseReason.FAIL_EQUAL, CloseReason.FAIL_NOSHOW, CloseReason.FAIL_REJECT]; +const OPEN_CLOSE_REASONS: CloseReason[] = [ + CloseReason.OPEN_PRICE, CloseReason.OPEN_EQUAL, CloseReason.OPEN_NOSHOW, CloseReason.OPEN_REJECT, +]; // 마감결과 한 단어. 서버 close_reason(CloseReason) 을 '단일 근거'로 판정한다. -// close_reason 이 아직 없는 옛 데이터만 preferred_sp_id/equal_bid_yn 플래그로 폴백. +// close_reason 이 아직 없는 옛 데이터만 preferred_sp_id 플래그로 폴백(마감됐는데 낙찰자 없으면 개찰). // QuotationData(전체)·Estimate(Partial) 둘 다 받도록 필요한 필드만 optional. export function chainRoundState( - q: { status?: number | null; close_reason?: number | null; preferred_sp_id?: string | null; equal_bid_yn?: boolean | null }, + q: { status?: number | null; close_reason?: number | null; preferred_sp_id?: string | null }, ): ChainRoundState { if (q.close_reason != null) { if (q.close_reason === CloseReason.AWARDED) return 'awarded'; - if (REGEN_CLOSE_REASONS.includes(q.close_reason as CloseReason)) return 'regenerated'; - if (FAIL_CLOSE_REASONS.includes(q.close_reason as CloseReason)) return 'failed'; + if (OPEN_CLOSE_REASONS.includes(q.close_reason as CloseReason)) return 'opened'; } if (q.preferred_sp_id) return 'awarded'; - if (q.equal_bid_yn) return 'regenerated'; - if (q.status === QuotationStatus.CLOSED) return 'failed'; + if (q.status === QuotationStatus.CLOSED) return 'opened'; return 'active'; } @@ -240,7 +264,7 @@ export type QuotationCardView = { // DB 저장값이 아니라 이미 로드된 quotation + 세션들로 파생한다(별도 컬럼·엔드포인트 없음). // · 목표가 = md_price 우선(없으면 세션 목표가) // · 낙찰가 = 우선협상자 세션의 투찰가(미낙찰이면 현재 최저 투찰가 = 잠정) -// · 절감 = 목표가 − 낙찰가 (양수 = 목표보다 저렴하게 낙찰) +// · 절감 = 목표가 − 낙찰가 (양수 = 목표가보다 저렴하게 낙찰) export type QuotationResultView = { outcome: ChainRoundState; closeReason: string; @@ -290,16 +314,13 @@ export function buildQuotationResult(q: QuotationData, sessions: SessionView[]): }; } -// 마감 사유 라벨. 서버 close_reason(CloseReason) 단일 근거로 표기. +// 마감 사유 라벨. 서버 close_reason(CloseReason) 단일 근거로 표기. 개찰 = 낙찰자 미정으로 마감(결렬 아님). export const CLOSE_REASON_LABEL: Record = { [CloseReason.AWARDED]: '낙찰', - [CloseReason.REGEN_PRICE]: '목표초과 — 재협상 진행', - [CloseReason.REGEN_EQUAL]: '동가 — 재입찰 진행', - [CloseReason.REGEN_NOSHOW]: '전원 미참여 — 재소집 진행', - [CloseReason.FAIL_PRICE]: '목표초과로 결렬', - [CloseReason.FAIL_EQUAL]: '동가로 결렬', - [CloseReason.FAIL_NOSHOW]: '미참여로 결렬', - [CloseReason.FAIL_REJECT]: '협상 거부로 결렬', + [CloseReason.OPEN_PRICE]: '목표가 초과 — 개찰(낙찰자 미정)', + [CloseReason.OPEN_EQUAL]: '동가 — 개찰(낙찰자 미정)', + [CloseReason.OPEN_NOSHOW]: '전원 미응찰 — 개찰', + [CloseReason.OPEN_REJECT]: '협상 거부 — 개찰', }; // 마감결과 텍스트(정밀). 마감결과 배지·라벨은 이 값을 쓴다 — close_reason(CloseReason) 을 그대로 표기. @@ -318,10 +339,9 @@ function quotationCloseReason(q: QuotationData, sessions: SessionView[]): string if (q.status !== QuotationStatus.CLOSED) return '협상 진행중'; if (q.close_reason != null) return CLOSE_REASON_LABEL[q.close_reason as CloseReason] ?? '마감'; if (q.preferred_sp_yn) return '낙찰'; - if (q.equal_bid_yn) return '동가 — 다음 라운드 재생성'; - if (q.preferred_sp_yn === false && q.equal_bid_yn === false) return '전원 미참여 — 다음 라운드 재생성'; - if (sessions.some((s) => s.status === SessionStatus.REJECTED)) return '협상 거부로 마감'; - return '마감'; + if (q.equal_bid_yn) return '동가 — 개찰(낙찰자 미정)'; + if (sessions.some((s) => s.status === SessionStatus.REJECTED)) return '협상 거부 — 개찰'; + return '개찰(낙찰자 미정)'; } // ── 서버 연동 매퍼(negotiation.sessions / chats / 사용 카드) ────────────── diff --git a/negodata/front/src/pages/notifications.tsx b/negodata/front/src/pages/notifications.tsx index 797b36f..7071945 100644 --- a/negodata/front/src/pages/notifications.tsx +++ b/negodata/front/src/pages/notifications.tsx @@ -177,9 +177,14 @@ function render(n: NotificationData): { icon: ReactNode; tone: string; event: st number, }; case NotificationType.FAILURE: + // 결렬 폐지 → '개찰'(낙찰자 미정으로 마감). reason 으로 사유만 부기. return { - icon: , tone: 'text-rose-600', event: '견적 결렬', - line: `${name} — 낙찰 없이 마감`, + icon: , tone: 'text-amber-600', event: '견적 개찰', + line: `${name} — 낙찰자 미정 (${ + ({ price: '목표가 초과', equal: '동가', rejected: '협상거부', no_show: '전원 미응찰' } as Record)[ + String(d.reason) + ] ?? '마감' + })`, number, }; default: