diff --git a/negodata/backend/crud/statistics_crud.py b/negodata/backend/crud/statistics_crud.py index 2e07174..f6b73ab 100644 --- a/negodata/backend/crud/statistics_crud.py +++ b/negodata/backend/crud/statistics_crud.py @@ -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: diff --git a/negodata/backend/router/v1/statistics/protocol.py b/negodata/backend/router/v1/statistics/protocol.py index b4c22c9..b67eca2 100644 --- a/negodata/backend/router/v1/statistics/protocol.py +++ b/negodata/backend/router/v1/statistics/protocol.py @@ -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] = [] diff --git a/negodata/backend/services/statistics_service.py b/negodata/backend/services/statistics_service.py index b612651..b0f64b3 100644 --- a/negodata/backend/services/statistics_service.py +++ b/negodata/backend/services/statistics_service.py @@ -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 diff --git a/negodata/front/src/api/generated/model/statKpi.ts b/negodata/front/src/api/generated/model/statKpi.ts index df2e526..36c46a9 100644 --- a/negodata/front/src/api/generated/model/statKpi.ts +++ b/negodata/front/src/api/generated/model/statKpi.ts @@ -13,4 +13,5 @@ export interface StatKpi { savings_delta_mom?: number; closed_count?: number; regen_avg_round?: number; + markup_suppression_rate?: number; } diff --git a/negodata/front/src/api/generated/model/statMarkupPoint.ts b/negodata/front/src/api/generated/model/statMarkupPoint.ts new file mode 100644 index 0000000..9dc4b6f --- /dev/null +++ b/negodata/front/src/api/generated/model/statMarkupPoint.ts @@ -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; +} diff --git a/negodata/front/src/api/generated/model/statScope.ts b/negodata/front/src/api/generated/model/statScope.ts index 0d5a4af..ce425f5 100644 --- a/negodata/front/src/api/generated/model/statScope.ts +++ b/negodata/front/src/api/generated/model/statScope.ts @@ -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[]; diff --git a/negodata/front/src/features/statistics/StatisticsView.tsx b/negodata/front/src/features/statistics/StatisticsView.tsx index 3c26cf0..349f80a 100644 --- a/negodata/front/src/features/statistics/StatisticsView.tsx +++ b/negodata/front/src/features/statistics/StatisticsView.tsx @@ -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 (