[feat] negodata: 통계 인상억제율(재협상)·월별 차트 — 직전 라운드 투찰가 대비 인하율 파생 집계
This commit is contained in:
parent
e7a51b1b75
commit
e8d2acaade
@ -2,6 +2,7 @@ from abc import ABC, abstractmethod
|
||||
from typing import Tuple
|
||||
|
||||
from sqlalchemy import select, func, and_, case
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
@ -54,6 +55,14 @@ class IStatisticsCRUD(ABC):
|
||||
async def regen_avg_round(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def markup_suppression(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def markup_suppression_monthly(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||
pass
|
||||
@ -187,6 +196,87 @@ class StatisticsCRUD(IStatisticsCRUD):
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, 0.0
|
||||
|
||||
async def markup_suppression(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]:
|
||||
# 인상억제율(재협상 전용): 같은 견적번호(qt_number)의 직전 라운드 투찰가 대비 이번 라운드 투찰가가
|
||||
# 얼마나 안 올랐나 = avg((직전투찰 − 이번투찰) / 직전투찰). 양수=인하(억제 성공), 음수=인상 허용.
|
||||
# 직전·이번 둘 다 유효 투찰(bid_price)이 있는 재협상 쌍만 대상(직전이 개찰/거부면 비교 불가 → 제외).
|
||||
# 새 컬럼 없이 sessions.qt_number+qt_round+bid_price 로만 파생.
|
||||
try:
|
||||
prev = aliased(sessions)
|
||||
stmt = (
|
||||
select(func.avg((prev.bid_price - sessions.bid_price) * 1.0 / prev.bid_price))
|
||||
.select_from(sessions)
|
||||
.join(
|
||||
prev,
|
||||
and_(
|
||||
prev.qt_number == sessions.qt_number,
|
||||
prev.item_id == sessions.item_id,
|
||||
prev.supplier_id == sessions.supplier_id,
|
||||
prev.qt_round == sessions.qt_round - 1,
|
||||
prev.bid_price.isnot(None),
|
||||
prev.bid_price > 0,
|
||||
prev.deleted == False, # noqa: E712
|
||||
),
|
||||
)
|
||||
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
||||
.where(
|
||||
and_(
|
||||
*_company_scope(company_id, owner),
|
||||
sessions.bid_price.isnot(None),
|
||||
sessions.qt_round >= 2,
|
||||
sessions.deleted == False, # noqa: E712
|
||||
quotations.updated_at >= since,
|
||||
)
|
||||
)
|
||||
)
|
||||
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
|
||||
if err != ErrorType.SUCCESS:
|
||||
return err, 0.0
|
||||
val = rows[0] if rows else None
|
||||
return ErrorType.SUCCESS, float(val) if val is not None else 0.0
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, 0.0
|
||||
|
||||
async def markup_suppression_monthly(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||
# 월별 인상억제율: 이번 라운드 마감월(quotations.updated_at)별 avg((직전투찰 − 이번투찰)/직전투찰).
|
||||
try:
|
||||
prev = aliased(sessions)
|
||||
month = func.to_char(quotations.updated_at, "YYYY-MM")
|
||||
stmt = (
|
||||
select(month.label("m"), func.avg((prev.bid_price - sessions.bid_price) * 1.0 / prev.bid_price))
|
||||
.select_from(sessions)
|
||||
.join(
|
||||
prev,
|
||||
and_(
|
||||
prev.qt_number == sessions.qt_number,
|
||||
prev.item_id == sessions.item_id,
|
||||
prev.supplier_id == sessions.supplier_id,
|
||||
prev.qt_round == sessions.qt_round - 1,
|
||||
prev.bid_price.isnot(None),
|
||||
prev.bid_price > 0,
|
||||
prev.deleted == False, # noqa: E712
|
||||
),
|
||||
)
|
||||
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
||||
.where(
|
||||
and_(
|
||||
*_company_scope(company_id, owner),
|
||||
sessions.bid_price.isnot(None),
|
||||
sessions.qt_round >= 2,
|
||||
sessions.deleted == False, # noqa: E712
|
||||
quotations.updated_at >= since,
|
||||
)
|
||||
)
|
||||
.group_by(month)
|
||||
.order_by(month)
|
||||
)
|
||||
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
|
||||
return (err, list(rows) if err == ErrorType.SUCCESS else [])
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, []
|
||||
|
||||
async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||
# 카드 유형별 사용 빈도: card_used_yn=True 채팅을 card_type 별 집계(협상형 견적에서만 채팅 생성).
|
||||
try:
|
||||
|
||||
@ -15,12 +15,18 @@ class StatKpi(WebPacketProtocol):
|
||||
savings_delta_mom: int = 0 # 전월 대비 절감액 증감
|
||||
closed_count: int = 0 # 마감 견적 수(창)
|
||||
regen_avg_round: float = 0.0 # 평균 재견적 라운드
|
||||
markup_suppression_rate: float = 0.0 # 인상억제율(재협상: 직전 라운드 투찰가 대비 이번 투찰가 인하율, 파생)
|
||||
|
||||
|
||||
class StatMonthPoint(WebPacketProtocol):
|
||||
month: str # 'YYYY-MM'
|
||||
savings: int = 0
|
||||
rate: float = 0.0
|
||||
rate: float = 0.0 # 절감률
|
||||
|
||||
|
||||
class StatMarkupPoint(WebPacketProtocol):
|
||||
month: str # 'YYYY-MM'
|
||||
rate: float = 0.0 # 인상억제율(직전 라운드 투찰가 대비 인하율)
|
||||
|
||||
|
||||
class StatOutcome(WebPacketProtocol):
|
||||
@ -60,6 +66,7 @@ class StatCardUsage(WebPacketProtocol):
|
||||
class StatScope(WebPacketProtocol):
|
||||
kpi: StatKpi = Field(default_factory=StatKpi)
|
||||
trend: list[StatMonthPoint] = []
|
||||
markup_trend: list[StatMarkupPoint] = [] # 월별 인상억제율(재협상)
|
||||
outcome: StatOutcome = Field(default_factory=StatOutcome)
|
||||
participation: StatParticipation = Field(default_factory=StatParticipation)
|
||||
type_split: list[StatTypeRow] = []
|
||||
|
||||
@ -12,6 +12,7 @@ from router.v1.statistics.protocol import (
|
||||
StatScope,
|
||||
StatKpi,
|
||||
StatMonthPoint,
|
||||
StatMarkupPoint,
|
||||
StatOutcome,
|
||||
StatParticipation,
|
||||
StatTypeRow,
|
||||
@ -51,19 +52,22 @@ class StatisticsService:
|
||||
type_rows = await self._read(lambda s: self.stat_crud.type_counts(s, company_uuid, owner_uuid, since))
|
||||
part_rows = await self._read(lambda s: self.stat_crud.participation_counts(s, company_uuid, owner_uuid, since))
|
||||
regen = await self._read_scalar(lambda s: self.stat_crud.regen_avg_round(s, company_uuid, owner_uuid, since))
|
||||
markup = await self._read_scalar(lambda s: self.stat_crud.markup_suppression(s, company_uuid, owner_uuid, since))
|
||||
markup_rows = await self._read(lambda s: self.stat_crud.markup_suppression_monthly(s, company_uuid, owner_uuid, since))
|
||||
card_rows = await self._read(lambda s: self.stat_crud.card_usage(s, company_uuid, owner_uuid, since))
|
||||
|
||||
scope.trend = self._trend(win_rows, labels)
|
||||
scope.markup_trend = [StatMarkupPoint(month=r[0], rate=float(r[1] or 0.0)) for r in markup_rows]
|
||||
scope.categories = self._categories(win_rows)
|
||||
scope.outcome = self._outcome(outcome_rows)
|
||||
scope.participation = self._participation(part_rows)
|
||||
scope.type_split = self._type_split(type_rows, win_rows)
|
||||
scope.cards = self._cards(card_rows)
|
||||
scope.kpi = self._kpi(win_rows, scope.trend, scope.outcome, regen)
|
||||
scope.kpi = self._kpi(win_rows, scope.trend, scope.outcome, regen, markup)
|
||||
return scope
|
||||
|
||||
# ── 파생 계산 ───────────────────────────────────────────────
|
||||
def _kpi(self, win_rows, trend, outcome, regen) -> StatKpi:
|
||||
def _kpi(self, win_rows, trend, outcome, regen, markup) -> StatKpi:
|
||||
k = StatKpi()
|
||||
total_saving = sum(int(r.target_price) - int(r.bid_price) for r in win_rows)
|
||||
total_target = sum(int(r.target_price) for r in win_rows)
|
||||
@ -75,6 +79,7 @@ class StatisticsService:
|
||||
k.closed_count = closed
|
||||
k.award_rate = (outcome.awarded / closed) if closed else 0.0
|
||||
k.regen_avg_round = round(regen, 2)
|
||||
k.markup_suppression_rate = round(markup, 4) # 인상억제율(재협상 직전 라운드 투찰가 대비, 파생)
|
||||
# 전월 대비: 마지막 두 달 절감액 차(창에 2개월 미만이면 0).
|
||||
k.savings_delta_mom = (trend[-1].savings - trend[-2].savings) if len(trend) >= 2 else 0
|
||||
return k
|
||||
|
||||
@ -13,4 +13,5 @@ export interface StatKpi {
|
||||
savings_delta_mom?: number;
|
||||
closed_count?: number;
|
||||
regen_avg_round?: number;
|
||||
markup_suppression_rate?: number;
|
||||
}
|
||||
|
||||
11
negodata/front/src/api/generated/model/statMarkupPoint.ts
Normal file
11
negodata/front/src/api/generated/model/statMarkupPoint.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export interface StatMarkupPoint {
|
||||
month: string;
|
||||
rate?: number;
|
||||
}
|
||||
@ -6,6 +6,7 @@
|
||||
*/
|
||||
import type { StatKpi } from './statKpi';
|
||||
import type { StatMonthPoint } from './statMonthPoint';
|
||||
import type { StatMarkupPoint } from './statMarkupPoint';
|
||||
import type { StatOutcome } from './statOutcome';
|
||||
import type { StatParticipation } from './statParticipation';
|
||||
import type { StatTypeRow } from './statTypeRow';
|
||||
@ -15,6 +16,7 @@ import type { StatCardUsage } from './statCardUsage';
|
||||
export interface StatScope {
|
||||
kpi?: StatKpi;
|
||||
trend?: StatMonthPoint[];
|
||||
markup_trend?: StatMarkupPoint[];
|
||||
outcome?: StatOutcome;
|
||||
participation?: StatParticipation;
|
||||
type_split?: StatTypeRow[];
|
||||
|
||||
@ -2,6 +2,7 @@ import { Award, Percent, RefreshCw, Target, TrendingDown, CircleCheckBig } from
|
||||
import { Panel } from './components/Panel';
|
||||
import { StatTile } from './components/StatTile';
|
||||
import { SavingsTrendChart } from './components/SavingsTrendChart';
|
||||
import { MarkupTrendChart } from './components/MarkupTrendChart';
|
||||
import { OutcomeChart } from './components/OutcomeChart';
|
||||
import { ParticipationChart } from './components/ParticipationChart';
|
||||
import { TypeSplitChart } from './components/TypeSplitChart';
|
||||
@ -9,9 +10,11 @@ import { CategoryChart } from './components/CategoryChart';
|
||||
import { CardEffectChart } from './components/CardEffectChart';
|
||||
import { wonCompact, pct, signedWonCompact } from './fmt';
|
||||
import type { StatData } from './types';
|
||||
import { useLabels } from '@/features/settings/useCompanySettings';
|
||||
|
||||
// 통계 본문. KPI 요약 + 절감 분석 + 성사/프로세스 + 카드 효과. scope(회사/내견적)별로 동일 레이아웃.
|
||||
export function StatisticsView({ data }: { data: StatData }) {
|
||||
const label = useLabels(); // 회사 설정 용어(카테고리 등)
|
||||
const k = data.kpi;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@ -40,6 +43,10 @@ export function StatisticsView({ data }: { data: StatData }) {
|
||||
>
|
||||
<SavingsTrendChart data={data.trend} />
|
||||
</Panel>
|
||||
|
||||
<Panel title="월별 인상억제율" subtitle="재협상: 직전 라운드 투찰가 대비 인하율">
|
||||
<MarkupTrendChart data={data.markupTrend} />
|
||||
</Panel>
|
||||
<Panel title="마감 결과" subtitle="낙찰 vs 개찰 사유 4종">
|
||||
<OutcomeChart data={data.outcome} />
|
||||
</Panel>
|
||||
@ -57,7 +64,7 @@ export function StatisticsView({ data }: { data: StatData }) {
|
||||
|
||||
{/* 카테고리 · 카드 */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Panel title="카테고리별 절감" subtitle="어디서 절감이 났나">
|
||||
<Panel title={`${label('category')}별 절감`} subtitle="어디서 절감이 났나">
|
||||
<CategoryChart data={data.categories} />
|
||||
</Panel>
|
||||
<Panel title="협상카드 효과" subtitle="유형별 사용빈도 + 사용 직후 평균 제시가 하락">
|
||||
|
||||
@ -17,8 +17,10 @@ function mapScope(s?: ApiScope): StatData {
|
||||
savingsDeltaMoM: k.savings_delta_mom ?? 0,
|
||||
closedCount: k.closed_count ?? 0,
|
||||
regenAvgRound: k.regen_avg_round ?? 0,
|
||||
markupSuppressionRate: k.markup_suppression_rate ?? 0,
|
||||
},
|
||||
trend: (s?.trend ?? []).map((t) => ({ month: t.month, savings: t.savings ?? 0, rate: t.rate ?? 0 })),
|
||||
markupTrend: (s?.markup_trend ?? []).map((t) => ({ month: t.month, rate: t.rate ?? 0 })),
|
||||
outcome: {
|
||||
awarded: o.awarded ?? 0,
|
||||
openPrice: o.open_price ?? 0,
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts';
|
||||
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/chart';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { themeOf } from '../palette';
|
||||
import { pct, monthLabel } from '../fmt';
|
||||
import type { MarkupPoint } from '../types';
|
||||
|
||||
// 월별 인상억제율(재협상: 직전 라운드 투찰가 대비 이번 투찰가 인하율).
|
||||
const config = {
|
||||
rate: { label: '인상억제율', theme: themeOf('rose') },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function MarkupTrendChart({ data }: { data: MarkupPoint[] }) {
|
||||
return (
|
||||
<ChartContainer config={config} className="aspect-auto h-56 w-full">
|
||||
<BarChart data={data} margin={{ top: 8, right: 8, left: 4, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} tickFormatter={monthLabel} />
|
||||
<YAxis tickLine={false} axisLine={false} width={44} tickFormatter={(v) => pct(Number(v))} />
|
||||
<ChartTooltip cursor={false} content={<MarkupTooltip />} />
|
||||
<Bar dataKey="rate" fill="var(--color-rate)" radius={[4, 4, 0, 0]} maxBarSize={48} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkupTooltip({ active, payload }: { active?: boolean; payload?: { payload: MarkupPoint }[] }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const p = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
|
||||
<Typography as="p" variant="caption" className="mb-0.5 font-medium text-foreground">{monthLabel(p.month)}</Typography>
|
||||
<Typography as="p" variant="caption" className="font-mono text-foreground">인상억제율 {pct(p.rate)}</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -11,6 +11,7 @@ export interface StatKpi {
|
||||
savingsDeltaMoM: number; // 전월 대비 절감액 증감
|
||||
closedCount: number; // 마감 견적 수(창)
|
||||
regenAvgRound: number; // 평균 재견적 라운드(1=재견적 없음)
|
||||
markupSuppressionRate: number; // 인상억제율(재협상: 직전 라운드 투찰가 대비 이번 투찰가 인하율)
|
||||
}
|
||||
|
||||
export interface MonthPoint {
|
||||
@ -19,6 +20,11 @@ export interface MonthPoint {
|
||||
rate: number;
|
||||
}
|
||||
|
||||
export interface MarkupPoint {
|
||||
month: string; // 'YYYY-MM'
|
||||
rate: number; // 인상억제율
|
||||
}
|
||||
|
||||
export interface OutcomeBreakdown {
|
||||
awarded: number;
|
||||
openPrice: number; // 가격 미달
|
||||
@ -57,6 +63,7 @@ export interface CardTypeUsage {
|
||||
export interface StatData {
|
||||
kpi: StatKpi;
|
||||
trend: MonthPoint[];
|
||||
markupTrend: MarkupPoint[];
|
||||
outcome: OutcomeBreakdown;
|
||||
participation: ParticipationBreakdown;
|
||||
typeSplit: TypeSplitRow[];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user