Files
sfera-new/docs/presentation-layer/STATISTICAL_COMPONENTS_RULES.md
Veronika Smirnova 121a4dece1 docs: создать правила для синхронизации данных, layout и статистических компонентов
- DATA_SYNCHRONIZATION_RULES.md - правила синхронизации между компонентами
- GRAPHQL_CACHE_RULES.md - настройки кеширования и fetchPolicy
- CSS_LAYOUT_SCROLL_RULES.md - решение проблем с overflow и scroll
- STATISTICAL_COMPONENTS_RULES.md - правила Master-Detail архитектуры

Документация основана на исправлениях в кабинете фулфилмента

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-27 12:29:00 +03:00

18 KiB
Raw Blame History

📊 ПРАВИЛА СТАТИСТИЧЕСКИХ КОМПОНЕНТОВ

Цель: Обеспечить корректную работу, синхронизацию и консистентность статистических компонентов в системе SFERA

📋 ОСНОВНЫЕ ПРИНЦИПЫ СТАТИСТИЧЕСКИХ КОМПОНЕНТОВ

1. АРХИТЕКТУРА MASTER-DETAIL

// ✅ ПРАВИЛЬНАЯ архитектура связанных статистических компонентов
interface StatisticsHierarchy {
  masterComponent: {
    location: '/fulfillment-warehouse'
    cards: ['РАСХОДНИКИ ФУЛФИЛМЕНТА', 'ТОВАРЫ СЕЛЛЕРОВ', ...]
    role: 'Обзор всего склада'
  }

  detailComponent: {
    location: '/fulfillment-warehouse/supplies'
    cards: ['ВСЕГО ПОЗИЦИЙ', 'ДОСТУПНО', 'ОСТАТОК', 'НЕТ В НАЛИЧИИ', ...]
    role: 'Детализация расходников'
  }

  // ОБЯЗАТЕЛЬНО: masterComponent.cards[X] === sum(detailComponent.items)
  consistency: 'Значения master должны равняться агрегации detail'
}

2. ЕДИНЫЕ ИСТОЧНИКИ ДАННЫХ

// ✅ ПРАВИЛЬНО - один источник данных для связанных статистик
const warehouseQuery = useQuery(GET_WAREHOUSE_STATS, {
  fetchPolicy: 'cache-and-network',
  pollInterval: 30000,
})

const suppliesQuery = useQuery(GET_SUPPLIES_STATS, {
  fetchPolicy: 'cache-and-network', // ← ОБЯЗАТЕЛЬНО то же самое!
  pollInterval: 30000, // ← ОБЯЗАТЕЛЬНО то же самое!
})

// ОБЯЗАТЕЛЬНАЯ проверка консистентности
useEffect(() => {
  const masterValue = warehouseQuery.data?.fulfillmentSupplies?.current
  const detailValue = suppliesQuery.data?.supplies?.reduce((sum, s) => sum + s.currentStock, 0)

  if (masterValue !== detailValue) {
    console.error('🚨 STATISTICS SYNC ERROR:', { masterValue, detailValue })
  }
}, [warehouseQuery.data, suppliesQuery.data])

🎯 ТИПЫ СТАТИСТИЧЕСКИХ КАРТОЧЕК

ТИП 1: Счётчики (Counters)

// Подсчёт количества элементов
;<StatCard title="Всего позиций" value={supplies.length} icon={Package} color="blue" format="number" />

// Логика расчёта
const totalItems = supplies.length
const availableItems = supplies.filter((s) => s.currentStock > 0).length
const outOfStockItems = supplies.filter((s) => s.currentStock === 0).length

ТИП 2: Агрегаторы (Aggregators)

// Суммирование значений
;<StatCard
  title="Остаток"
  value={supplies.reduce((sum, s) => sum + s.currentStock, 0)}
  unit="шт"
  icon={Package}
  color="blue"
  format="number"
/>

// Логика расчёта
const totalStock = supplies.reduce((sum, supply) => sum + supply.currentStock, 0)
const totalValue = supplies.reduce((sum, supply) => sum + supply.price * supply.currentStock, 0)

ТИП 3: Индикаторы состояния (Status Indicators)

// Показатели состояния системы
<StatCard
  title="Доступно"
  value={availableSupplies.length}
  icon={CheckCircle}
  color="green"
  subtitle={`из ${totalSupplies} позиций`}
  trend={{ value: +5, period: 'за неделю' }}
/>

ТИП 4: Финансовые показатели (Financial)

// Денежные значения
<StatCard
  title="Общая стоимость"
  value={totalInventoryValue}
  icon={DollarSign}
  color="purple"
  format="currency"
  currency="RUB"
/>

🏢 СПЕЦИФИЧЕСКИЕ ПРАВИЛА ДЛЯ ФУЛФИЛМЕНТА

КАРТОЧКИ ГЛАВНОГО DASHBOARD (/fulfillment-warehouse)

interface WarehouseMasterStats {
  'РАСХОДНИКИ ФУЛФИЛМЕНТА': {
    source: 'fulfillmentConsumableInventory'
    calculation: 'SUM(currentStock)'
    syncWith: '/fulfillment-warehouse/supplies - карточка ОСТАТОК'
  }

  'ТОВАРЫ СЕЛЛЕРОВ': {
    source: 'sellerInventoryOnWarehouse'
    calculation: 'SUM(currentStock)'
    syncWith: 'Таблица товаров селлеров'
  }

  'АКТИВНЫЕ ЗАКАЗЫ': {
    source: 'supplyOrders'
    calculation: 'COUNT where status IN [PENDING, SHIPPED]'
    syncWith: 'Раздел входящих заказов'
  }
}

КАРТОЧКИ ПОДРАЗДЕЛА РАСХОДНИКОВ (/fulfillment-warehouse/supplies)

interface SuppliesDetailStats {
  'ВСЕГО ПОЗИЦИЙ': {
    calculation: 'COUNT(DISTINCT productId)'
    description: 'Количество уникальных товаров'
  }

  ДОСТУПНО: {
    calculation: 'COUNT WHERE currentStock > 0'
    description: 'Товары в наличии'
  }

  ОСТАТОК: {
    calculation: 'SUM(currentStock)'
    description: 'Общее количество единиц товара'
    syncWith: 'Master: РАСХОДНИКИ ФУЛФИЛМЕНТА' // ← КРИТИЧЕСКАЯ СИНХРОНИЗАЦИЯ
  }

  'НЕТ В НАЛИЧИИ': {
    calculation: 'COUNT WHERE currentStock = 0'
    description: 'Товары с нулевыми остатками'
  }

  'ОБЩАЯ СТОИМОСТЬ': {
    calculation: 'SUM(price * currentStock)'
    format: 'currency'
  }

  'В ПУТИ': {
    calculation: 'COUNT WHERE status = "shipped"'
    description: 'Товары в транспортировке'
  }
}

🔄 ПРАВИЛА СИНХРОНИЗАЦИИ СТАТИСТИК

1. ОБЯЗАТЕЛЬНАЯ КОНСИСТЕНТНОСТЬ MASTER-DETAIL

// ✅ ПРАВИЛЬНАЯ реализация проверки синхронизации
const validateStatisticsConsistency = (masterData: any, detailData: any[]) => {
  const masterValue = masterData?.fulfillmentSupplies?.current || 0
  const detailValue = detailData.reduce((sum, item) => sum + item.currentStock, 0)

  const tolerance = 0 // Для inventory данных - точное соответствие!
  const isConsistent = Math.abs(masterValue - detailValue) <= tolerance

  if (!isConsistent) {
    console.error('🚨 CRITICAL STATISTICS INCONSISTENCY:', {
      component: 'FulfillmentStatistics',
      masterValue,
      detailValue,
      difference: masterValue - detailValue,
      timestamp: new Date().toISOString(),
      requiresAttention: true,
    })

    // Уведомление пользователя о проблеме
    toast.error('Обнаружена несогласованность данных статистики')

    // Попытка принудительного обновления
    refetchMasterData()
    refetchDetailData()
  }

  return isConsistent
}

// Использование в компонентах
useEffect(() => {
  validateStatisticsConsistency(warehouseMasterData, suppliesDetailData)
}, [warehouseMasterData, suppliesDetailData])

2. АВТОМАТИЧЕСКОЕ ОБНОВЛЕНИЕ СВЯЗАННЫХ СТАТИСТИК

// При изменении данных - все связанные статистики обновляются
const handleInventoryUpdate = async (inventoryChange: InventoryChange) => {
  // 1. Обновляем основные данные
  await updateInventoryRecord(inventoryChange)

  // 2. Принудительное обновление всех связанных статистик
  await Promise.all([
    refetchWarehouseStats(), // Master dashboard
    refetchSuppliesStats(), // Detail dashboard
    refetchServiceStats(), // Services section
  ])

  // 3. Проверяем консистентность после обновления
  setTimeout(() => validateStatisticsConsistency(), 1000)
}

🎨 ДИЗАЙН И UI ПРАВИЛА

СТАНДАРТНАЯ СТРУКТУРА КАРТОЧКИ

<Card className="glass-card p-4">
  <div className="flex items-center justify-between">
    <div>
      {/* Основная информация */}
      <p className="text-xs font-medium text-white/60 uppercase tracking-wider">{title}</p>
      <p className="text-2xl font-bold text-{color}-300 mt-1">
        {formattedValue} {unit && <span className="text-sm">{unit}</span>}
      </p>

      {/* Дополнительная информация */}
      {subtitle && <p className="text-xs text-white/40 mt-1">{subtitle}</p>}
    </div>

    {/* Иконка */}
    <div className="p-2 bg-{color}-500/20 rounded-lg">
      <Icon className="h-5 w-5 text-{color}-300" />
    </div>
  </div>
</Card>

ЦВЕТОВЫЕ СХЕМЫ ПО ТИПАМ

const StatCardColors = {
  // Позитивные показатели (наличие, доступность)
  positive: {
    background: 'bg-green-500/20',
    text: 'text-green-300',
    icon: 'text-green-300',
  },

  // Нейтральные показатели (общие счётчики)
  neutral: {
    background: 'bg-blue-500/20',
    text: 'text-blue-300',
    icon: 'text-blue-300',
  },

  // Внимание требующие (мало на складе)
  warning: {
    background: 'bg-yellow-500/20',
    text: 'text-yellow-300',
    icon: 'text-yellow-300',
  },

  // Негативные показатели (отсутствие, проблемы)
  negative: {
    background: 'bg-red-500/20',
    text: 'text-red-300',
    icon: 'text-red-300',
  },

  // Финансовые показатели
  financial: {
    background: 'bg-purple-500/20',
    text: 'text-purple-300',
    icon: 'text-purple-300',
  },

  // Процессы и активность
  process: {
    background: 'bg-orange-500/20',
    text: 'text-orange-300',
    icon: 'text-orange-300',
  },
}

📈 ФОРМАТИРОВАНИЕ ДАННЫХ

ЧИСЛОВЫЕ ФОРМАТЫ

const formatStatValue = (value: number, type: StatType) => {
  switch (type) {
    case 'currency':
      return new Intl.NumberFormat('ru-RU', {
        style: 'currency',
        currency: 'RUB',
        minimumFractionDigits: 0,
      }).format(value)

    case 'number':
      return new Intl.NumberFormat('ru-RU').format(value)

    case 'percentage':
      return new Intl.NumberFormat('ru-RU', {
        style: 'percent',
        minimumFractionDigits: 1,
      }).format(value / 100)

    case 'compact':
      return new Intl.NumberFormat('ru-RU', {
        notation: 'compact',
        compactDisplay: 'short',
      }).format(value)

    default:
      return String(value)
  }
}

// Примеры использования
formatStatValue(1500, 'number') // "1 500"
formatStatValue(25000, 'currency') // "25 000 ₽"
formatStatValue(1500000, 'compact') // "1,5 млн"

ЕДИНИЦЫ ИЗМЕРЕНИЯ

const StatUnits = {
  quantity: 'шт',
  weight: 'кг',
  volume: 'л',
  currency: '₽',
  percentage: '%',
  count: '', // без единицы для простых счётчиков
  days: 'дн.',
  hours: 'ч',
}

🚨 АНТИ-ПАТТЕРНЫ И ТИПИЧНЫЕ ОШИБКИ

НИКОГДА НЕ ДЕЛАЙТЕ:

1. Разные источники данных для связанных статистик

// ❌ ПЛОХО - создаёт несогласованность
const masterStats = useQuery(GET_OLD_WAREHOUSE_DATA) // legacy таблица
const detailStats = useQuery(GET_NEW_SUPPLIES_DATA) // V2 таблица

// Результат: разные значения в связанных карточках

2. Игнорирование ошибок в статистике

// ❌ ПЛОХО - скрывает проблемы с данными
const { data, error } = useQuery(STATS_QUERY, {
  errorPolicy: 'ignore', // Ошибки не видны!
})

// ✅ ПРАВИЛЬНО
const { data, error } = useQuery(STATS_QUERY, {
  errorPolicy: 'all', // Показываем ошибки
  onError: (error) => {
    console.error('Stats error:', error)
    toast.error('Ошибка загрузки статистики')
  },
})

3. Вычисления на фронтенде вместо БД

// ❌ ПЛОХО - медленно и неточно
const totalValue = supplies.map(s => s.price * s.quantity).reduce((a, b) => a + b, 0)

// ✅ ПРАВИЛЬНО - вычисления в GraphQL resolver
query GetSuppliesStats {
  suppliesStats {
    totalValue    # Рассчитано на сервере
    totalStock    # Рассчитано на сервере
  }
}

4. Отсутствие проверки синхронизации

// ❌ ПЛОХО - не контролируем консистентность
const Stats = () => {
  const master = useQuery(MASTER_QUERY)
  const detail = useQuery(DETAIL_QUERY)

  return <div>{master.value} vs {detail.sum}</div>  // Может не совпадать!
}

// ✅ ПРАВИЛЬНО - с проверкой синхронизации
const Stats = () => {
  const master = useQuery(MASTER_QUERY)
  const detail = useQuery(DETAIL_QUERY)

  useEffect(() => {
    validateConsistency(master.data, detail.data)
  }, [master.data, detail.data])

  return <ValidatedStatsView />
}

🔧 ИНСТРУМЕНТЫ ОТЛАДКИ И МОНИТОРИНГА

DEBUG КОМПОНЕНТ ДЛЯ СТАТИСТИКИ

const StatisticsDebugPanel = ({ masterData, detailData }) => {
  if (process.env.NODE_ENV !== 'development') return null

  return (
    <div className="fixed bottom-4 right-4 bg-black/80 p-4 rounded-lg text-xs text-white">
      <h3>Statistics Debug</h3>
      <div>Master: {masterData?.value}</div>
      <div>Detail Sum: {detailData?.reduce((sum, item) => sum + item.value, 0)}</div>
      <div>Sync: {masterData?.value === detailSum ? '✅' : '❌'}</div>
      <div>Last Update: {new Date().toLocaleTimeString()}</div>
    </div>
  )
}

АВТОМАТИЧЕСКИЕ ТЕСТЫ СИНХРОНИЗАЦИИ

// Тест для проверки синхронизации статистик
describe('Statistics Synchronization', () => {
  it('должен синхронизировать master и detail значения', async () => {
    const { masterValue } = await fetchMasterStats()
    const detailItems = await fetchDetailItems()
    const detailSum = detailItems.reduce((sum, item) => sum + item.value, 0)

    expect(masterValue).toBe(detailSum)
  })

  it('должен обновлять связанные статистики при изменении данных', async () => {
    const initialStats = await fetchStats()
    await updateInventory({ productId: '1', change: +10 })

    // Ждём обновления
    await new Promise((resolve) => setTimeout(resolve, 1000))

    const updatedStats = await fetchStats()
    expect(updatedStats.totalStock).toBe(initialStats.totalStock + 10)
  })
})

📊 ШАБЛОНЫ КОМПОНЕНТОВ

БАЗОВЫЙ СТАТИСТИЧЕСКИЙ КОМПОНЕНТ

interface StatCardProps {
  title: string
  value: number | string
  icon: React.ComponentType
  color: 'blue' | 'green' | 'red' | 'yellow' | 'purple' | 'orange'
  unit?: string
  subtitle?: string
  trend?: { value: number; period: string }
  format?: 'number' | 'currency' | 'percentage' | 'compact'
  loading?: boolean
  error?: string
}

export const StatCard: React.FC<StatCardProps> = ({
  title,
  value,
  icon: Icon,
  color,
  unit,
  subtitle,
  trend,
  format = 'number',
  loading,
  error,
}) => {
  const formattedValue = useMemo(() => {
    if (loading) return '...'
    if (error) return 'Ошибка'
    return formatStatValue(Number(value), format)
  }, [value, format, loading, error])

  return (
    <Card className="glass-card p-4">
      <div className="flex items-center justify-between">
        <div>
          <p className="text-xs font-medium text-white/60 uppercase tracking-wider">{title}</p>
          <p className={`text-2xl font-bold text-${color}-300 mt-1`}>
            {formattedValue}
            {unit && <span className="text-sm ml-1">{unit}</span>}
          </p>
          {subtitle && <p className="text-xs text-white/40 mt-1">{subtitle}</p>}
          {trend && (
            <p className="text-xs text-white/50 mt-1">
              {trend.value > 0 ? '+' : ''}
              {trend.value} {trend.period}
            </p>
          )}
        </div>
        <div className={`p-2 bg-${color}-500/20 rounded-lg`}>
          <Icon className={`h-5 w-5 text-${color}-300`} />
        </div>
      </div>
    </Card>
  )
}

КОМПОНЕНТ ГРУППЫ СТАТИСТИК

interface StatsGridProps {
  stats: Array<StatCardProps & { id: string }>
  columns?: 1 | 2 | 3 | 4 | 6
  loading?: boolean
  error?: string
}

export const StatsGrid: React.FC<StatsGridProps> = ({ stats, columns = 6, loading, error }) => {
  if (error) {
    return (
      <Card className="glass-card p-4">
        <div className="text-red-300 text-center">Ошибка загрузки статистики: {error}</div>
      </Card>
    )
  }

  return (
    <div
      className={`
      grid grid-cols-1 
      md:grid-cols-2 
      lg:grid-cols-${Math.min(columns, 4)} 
      xl:grid-cols-${columns} 
      gap-4
    `}
    >
      {stats.map((stat) => (
        <StatCard key={stat.id} {...stat} loading={loading} />
      ))}
    </div>
  )
}

Следование этим правилам обеспечит надёжную работу статистических компонентов! 🚀