Improve AI logic
This commit is contained in:
593
services/forecasting/app/ml/pattern_detector.py
Normal file
593
services/forecasting/app/ml/pattern_detector.py
Normal file
@@ -0,0 +1,593 @@
|
||||
"""
|
||||
Pattern Detection Engine for Sales Data
|
||||
Automatically identifies patterns and generates insights
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
import structlog
|
||||
from scipy import stats
|
||||
from collections import defaultdict
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class SalesPatternDetector:
|
||||
"""
|
||||
Detect sales patterns and generate actionable insights.
|
||||
|
||||
Patterns detected:
|
||||
- Time-of-day patterns (hourly peaks)
|
||||
- Day-of-week patterns (weekend spikes)
|
||||
- Weekly seasonality patterns
|
||||
- Monthly patterns
|
||||
- Holiday impact patterns
|
||||
- Weather correlation patterns
|
||||
"""
|
||||
|
||||
def __init__(self, significance_threshold: float = 0.15):
|
||||
"""
|
||||
Initialize pattern detector.
|
||||
|
||||
Args:
|
||||
significance_threshold: Minimum percentage difference to consider significant (default 15%)
|
||||
"""
|
||||
self.significance_threshold = significance_threshold
|
||||
self.detected_patterns = []
|
||||
|
||||
async def detect_all_patterns(
|
||||
self,
|
||||
tenant_id: str,
|
||||
inventory_product_id: str,
|
||||
sales_data: pd.DataFrame,
|
||||
min_confidence: int = 70
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Detect all patterns in sales data and generate insights.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
inventory_product_id: Product identifier
|
||||
sales_data: Sales data with columns: date, quantity, (optional: hour, temperature, etc.)
|
||||
min_confidence: Minimum confidence score for insights
|
||||
|
||||
Returns:
|
||||
List of insight dictionaries ready for AI Insights Service
|
||||
"""
|
||||
logger.info(
|
||||
"Starting pattern detection",
|
||||
tenant_id=tenant_id,
|
||||
product_id=inventory_product_id,
|
||||
data_points=len(sales_data)
|
||||
)
|
||||
|
||||
insights = []
|
||||
|
||||
# Ensure date column is datetime
|
||||
if 'date' in sales_data.columns:
|
||||
sales_data['date'] = pd.to_datetime(sales_data['date'])
|
||||
|
||||
# 1. Day-of-week patterns
|
||||
dow_insights = await self._detect_day_of_week_patterns(
|
||||
tenant_id, inventory_product_id, sales_data, min_confidence
|
||||
)
|
||||
insights.extend(dow_insights)
|
||||
|
||||
# 2. Weekend vs weekday patterns
|
||||
weekend_insights = await self._detect_weekend_patterns(
|
||||
tenant_id, inventory_product_id, sales_data, min_confidence
|
||||
)
|
||||
insights.extend(weekend_insights)
|
||||
|
||||
# 3. Month-end patterns
|
||||
month_end_insights = await self._detect_month_end_patterns(
|
||||
tenant_id, inventory_product_id, sales_data, min_confidence
|
||||
)
|
||||
insights.extend(month_end_insights)
|
||||
|
||||
# 4. Hourly patterns (if hour data available)
|
||||
if 'hour' in sales_data.columns:
|
||||
hourly_insights = await self._detect_hourly_patterns(
|
||||
tenant_id, inventory_product_id, sales_data, min_confidence
|
||||
)
|
||||
insights.extend(hourly_insights)
|
||||
|
||||
# 5. Weather correlation (if temperature data available)
|
||||
if 'temperature' in sales_data.columns:
|
||||
weather_insights = await self._detect_weather_correlations(
|
||||
tenant_id, inventory_product_id, sales_data, min_confidence
|
||||
)
|
||||
insights.extend(weather_insights)
|
||||
|
||||
# 6. Trend detection
|
||||
trend_insights = await self._detect_trends(
|
||||
tenant_id, inventory_product_id, sales_data, min_confidence
|
||||
)
|
||||
insights.extend(trend_insights)
|
||||
|
||||
logger.info(
|
||||
"Pattern detection complete",
|
||||
total_insights=len(insights),
|
||||
product_id=inventory_product_id
|
||||
)
|
||||
|
||||
return insights
|
||||
|
||||
async def _detect_day_of_week_patterns(
|
||||
self,
|
||||
tenant_id: str,
|
||||
inventory_product_id: str,
|
||||
sales_data: pd.DataFrame,
|
||||
min_confidence: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Detect day-of-week patterns (e.g., Friday sales spike)."""
|
||||
insights = []
|
||||
|
||||
if 'date' not in sales_data.columns or 'quantity' not in sales_data.columns:
|
||||
return insights
|
||||
|
||||
# Add day of week
|
||||
sales_data['day_of_week'] = sales_data['date'].dt.dayofweek
|
||||
sales_data['day_name'] = sales_data['date'].dt.day_name()
|
||||
|
||||
# Calculate average sales per day of week
|
||||
dow_avg = sales_data.groupby(['day_of_week', 'day_name'])['quantity'].agg(['mean', 'count']).reset_index()
|
||||
|
||||
# Only consider days with sufficient data (at least 4 observations)
|
||||
dow_avg = dow_avg[dow_avg['count'] >= 4]
|
||||
|
||||
if len(dow_avg) < 2:
|
||||
return insights
|
||||
|
||||
overall_avg = sales_data['quantity'].mean()
|
||||
|
||||
# Find days significantly above average
|
||||
for _, row in dow_avg.iterrows():
|
||||
day_avg = row['mean']
|
||||
pct_diff = ((day_avg - overall_avg) / overall_avg) * 100
|
||||
|
||||
if abs(pct_diff) > self.significance_threshold * 100:
|
||||
# Calculate confidence based on sample size and consistency
|
||||
confidence = self._calculate_pattern_confidence(
|
||||
sample_size=int(row['count']),
|
||||
effect_size=abs(pct_diff) / 100,
|
||||
variability=sales_data['quantity'].std()
|
||||
)
|
||||
|
||||
if confidence >= min_confidence:
|
||||
if pct_diff > 0:
|
||||
insight = self._create_insight(
|
||||
tenant_id=tenant_id,
|
||||
inventory_product_id=inventory_product_id,
|
||||
insight_type='pattern',
|
||||
category='sales',
|
||||
priority='medium' if pct_diff > 20 else 'low',
|
||||
title=f'{row["day_name"]} Sales Pattern Detected',
|
||||
description=f'Sales on {row["day_name"]} are {abs(pct_diff):.1f}% {"higher" if pct_diff > 0 else "lower"} than average ({day_avg:.1f} vs {overall_avg:.1f} units).',
|
||||
confidence=confidence,
|
||||
metrics={
|
||||
'day_of_week': row['day_name'],
|
||||
'avg_sales': float(day_avg),
|
||||
'overall_avg': float(overall_avg),
|
||||
'difference_pct': float(pct_diff),
|
||||
'sample_size': int(row['count'])
|
||||
},
|
||||
actionable=True,
|
||||
actions=[
|
||||
{'label': 'Adjust Production', 'action': 'adjust_daily_production'},
|
||||
{'label': 'Review Schedule', 'action': 'review_production_schedule'}
|
||||
]
|
||||
)
|
||||
insights.append(insight)
|
||||
|
||||
return insights
|
||||
|
||||
async def _detect_weekend_patterns(
|
||||
self,
|
||||
tenant_id: str,
|
||||
inventory_product_id: str,
|
||||
sales_data: pd.DataFrame,
|
||||
min_confidence: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Detect weekend vs weekday patterns."""
|
||||
insights = []
|
||||
|
||||
if 'date' not in sales_data.columns or 'quantity' not in sales_data.columns:
|
||||
return insights
|
||||
|
||||
# Classify weekend vs weekday
|
||||
sales_data['is_weekend'] = sales_data['date'].dt.dayofweek.isin([5, 6])
|
||||
|
||||
# Calculate averages
|
||||
weekend_avg = sales_data[sales_data['is_weekend']]['quantity'].mean()
|
||||
weekday_avg = sales_data[~sales_data['is_weekend']]['quantity'].mean()
|
||||
|
||||
weekend_count = sales_data[sales_data['is_weekend']]['quantity'].count()
|
||||
weekday_count = sales_data[~sales_data['is_weekend']]['quantity'].count()
|
||||
|
||||
if weekend_count < 4 or weekday_count < 4:
|
||||
return insights
|
||||
|
||||
pct_diff = ((weekend_avg - weekday_avg) / weekday_avg) * 100
|
||||
|
||||
if abs(pct_diff) > self.significance_threshold * 100:
|
||||
confidence = self._calculate_pattern_confidence(
|
||||
sample_size=min(weekend_count, weekday_count),
|
||||
effect_size=abs(pct_diff) / 100,
|
||||
variability=sales_data['quantity'].std()
|
||||
)
|
||||
|
||||
if confidence >= min_confidence:
|
||||
# Estimate revenue impact
|
||||
impact_value = abs(weekend_avg - weekday_avg) * 8 * 4 # 8 weekend days per month
|
||||
|
||||
insight = self._create_insight(
|
||||
tenant_id=tenant_id,
|
||||
inventory_product_id=inventory_product_id,
|
||||
insight_type='recommendation',
|
||||
category='forecasting',
|
||||
priority='high' if abs(pct_diff) > 25 else 'medium',
|
||||
title=f'Weekend Demand Pattern: {abs(pct_diff):.0f}% {"Higher" if pct_diff > 0 else "Lower"}',
|
||||
description=f'Weekend sales average {weekend_avg:.1f} units vs {weekday_avg:.1f} on weekdays ({abs(pct_diff):.0f}% {"increase" if pct_diff > 0 else "decrease"}). Recommend adjusting weekend production targets.',
|
||||
confidence=confidence,
|
||||
impact_type='revenue_increase' if pct_diff > 0 else 'cost_savings',
|
||||
impact_value=float(impact_value),
|
||||
impact_unit='units/month',
|
||||
metrics={
|
||||
'weekend_avg': float(weekend_avg),
|
||||
'weekday_avg': float(weekday_avg),
|
||||
'difference_pct': float(pct_diff),
|
||||
'weekend_samples': int(weekend_count),
|
||||
'weekday_samples': int(weekday_count)
|
||||
},
|
||||
actionable=True,
|
||||
actions=[
|
||||
{'label': 'Increase Weekend Production', 'action': 'adjust_weekend_production'},
|
||||
{'label': 'Update Forecast Multiplier', 'action': 'update_forecast_rule'}
|
||||
]
|
||||
)
|
||||
insights.append(insight)
|
||||
|
||||
return insights
|
||||
|
||||
async def _detect_month_end_patterns(
|
||||
self,
|
||||
tenant_id: str,
|
||||
inventory_product_id: str,
|
||||
sales_data: pd.DataFrame,
|
||||
min_confidence: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Detect month-end and payday patterns."""
|
||||
insights = []
|
||||
|
||||
if 'date' not in sales_data.columns or 'quantity' not in sales_data.columns:
|
||||
return insights
|
||||
|
||||
# Identify payday periods (15th and last 3 days of month)
|
||||
sales_data['day_of_month'] = sales_data['date'].dt.day
|
||||
sales_data['is_payday'] = (
|
||||
(sales_data['day_of_month'] == 15) |
|
||||
(sales_data['date'].dt.is_month_end) |
|
||||
(sales_data['day_of_month'] >= sales_data['date'].dt.days_in_month - 2)
|
||||
)
|
||||
|
||||
payday_avg = sales_data[sales_data['is_payday']]['quantity'].mean()
|
||||
regular_avg = sales_data[~sales_data['is_payday']]['quantity'].mean()
|
||||
|
||||
payday_count = sales_data[sales_data['is_payday']]['quantity'].count()
|
||||
|
||||
if payday_count < 4:
|
||||
return insights
|
||||
|
||||
pct_diff = ((payday_avg - regular_avg) / regular_avg) * 100
|
||||
|
||||
if abs(pct_diff) > self.significance_threshold * 100:
|
||||
confidence = self._calculate_pattern_confidence(
|
||||
sample_size=payday_count,
|
||||
effect_size=abs(pct_diff) / 100,
|
||||
variability=sales_data['quantity'].std()
|
||||
)
|
||||
|
||||
if confidence >= min_confidence and pct_diff > 0:
|
||||
insight = self._create_insight(
|
||||
tenant_id=tenant_id,
|
||||
inventory_product_id=inventory_product_id,
|
||||
insight_type='pattern',
|
||||
category='sales',
|
||||
priority='medium',
|
||||
title=f'Payday Shopping Pattern Detected',
|
||||
description=f'Sales increase {pct_diff:.0f}% during payday periods (15th and month-end). Average {payday_avg:.1f} vs {regular_avg:.1f} units.',
|
||||
confidence=confidence,
|
||||
metrics={
|
||||
'payday_avg': float(payday_avg),
|
||||
'regular_avg': float(regular_avg),
|
||||
'difference_pct': float(pct_diff)
|
||||
},
|
||||
actionable=True,
|
||||
actions=[
|
||||
{'label': 'Increase Payday Stock', 'action': 'adjust_payday_production'}
|
||||
]
|
||||
)
|
||||
insights.append(insight)
|
||||
|
||||
return insights
|
||||
|
||||
async def _detect_hourly_patterns(
|
||||
self,
|
||||
tenant_id: str,
|
||||
inventory_product_id: str,
|
||||
sales_data: pd.DataFrame,
|
||||
min_confidence: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Detect hourly sales patterns (if POS data available)."""
|
||||
insights = []
|
||||
|
||||
if 'hour' not in sales_data.columns or 'quantity' not in sales_data.columns:
|
||||
return insights
|
||||
|
||||
hourly_avg = sales_data.groupby('hour')['quantity'].agg(['mean', 'count']).reset_index()
|
||||
hourly_avg = hourly_avg[hourly_avg['count'] >= 3] # At least 3 observations
|
||||
|
||||
if len(hourly_avg) < 3:
|
||||
return insights
|
||||
|
||||
overall_avg = sales_data['quantity'].mean()
|
||||
|
||||
# Find peak hours (top 3)
|
||||
top_hours = hourly_avg.nlargest(3, 'mean')
|
||||
|
||||
for _, row in top_hours.iterrows():
|
||||
hour_avg = row['mean']
|
||||
pct_diff = ((hour_avg - overall_avg) / overall_avg) * 100
|
||||
|
||||
if pct_diff > self.significance_threshold * 100:
|
||||
confidence = self._calculate_pattern_confidence(
|
||||
sample_size=int(row['count']),
|
||||
effect_size=pct_diff / 100,
|
||||
variability=sales_data['quantity'].std()
|
||||
)
|
||||
|
||||
if confidence >= min_confidence:
|
||||
hour = int(row['hour'])
|
||||
time_label = f"{hour:02d}:00-{(hour+1):02d}:00"
|
||||
|
||||
insight = self._create_insight(
|
||||
tenant_id=tenant_id,
|
||||
inventory_product_id=inventory_product_id,
|
||||
insight_type='pattern',
|
||||
category='sales',
|
||||
priority='low',
|
||||
title=f'Peak Sales Hour: {time_label}',
|
||||
description=f'Sales peak during {time_label} with {hour_avg:.1f} units ({pct_diff:.0f}% above average).',
|
||||
confidence=confidence,
|
||||
metrics={
|
||||
'peak_hour': hour,
|
||||
'avg_sales': float(hour_avg),
|
||||
'overall_avg': float(overall_avg),
|
||||
'difference_pct': float(pct_diff)
|
||||
},
|
||||
actionable=True,
|
||||
actions=[
|
||||
{'label': 'Ensure Fresh Stock', 'action': 'schedule_production'},
|
||||
{'label': 'Increase Staffing', 'action': 'adjust_staffing'}
|
||||
]
|
||||
)
|
||||
insights.append(insight)
|
||||
|
||||
return insights
|
||||
|
||||
async def _detect_weather_correlations(
|
||||
self,
|
||||
tenant_id: str,
|
||||
inventory_product_id: str,
|
||||
sales_data: pd.DataFrame,
|
||||
min_confidence: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Detect weather-sales correlations."""
|
||||
insights = []
|
||||
|
||||
if 'temperature' not in sales_data.columns or 'quantity' not in sales_data.columns:
|
||||
return insights
|
||||
|
||||
# Remove NaN values
|
||||
clean_data = sales_data[['temperature', 'quantity']].dropna()
|
||||
|
||||
if len(clean_data) < 30: # Need sufficient data
|
||||
return insights
|
||||
|
||||
# Calculate correlation
|
||||
correlation, p_value = stats.pearsonr(clean_data['temperature'], clean_data['quantity'])
|
||||
|
||||
if abs(correlation) > 0.3 and p_value < 0.05: # Moderate correlation and significant
|
||||
confidence = self._calculate_correlation_confidence(correlation, p_value, len(clean_data))
|
||||
|
||||
if confidence >= min_confidence:
|
||||
direction = 'increase' if correlation > 0 else 'decrease'
|
||||
|
||||
insight = self._create_insight(
|
||||
tenant_id=tenant_id,
|
||||
inventory_product_id=inventory_product_id,
|
||||
insight_type='insight',
|
||||
category='forecasting',
|
||||
priority='medium' if abs(correlation) > 0.5 else 'low',
|
||||
title=f'Temperature Impact on Sales: {abs(correlation):.0%} Correlation',
|
||||
description=f'Sales {direction} with temperature (correlation: {correlation:.2f}). {"Warmer" if correlation > 0 else "Colder"} weather associated with {"higher" if correlation > 0 else "lower"} sales.',
|
||||
confidence=confidence,
|
||||
metrics={
|
||||
'correlation': float(correlation),
|
||||
'p_value': float(p_value),
|
||||
'sample_size': len(clean_data),
|
||||
'direction': direction
|
||||
},
|
||||
actionable=False
|
||||
)
|
||||
insights.append(insight)
|
||||
|
||||
return insights
|
||||
|
||||
async def _detect_trends(
|
||||
self,
|
||||
tenant_id: str,
|
||||
inventory_product_id: str,
|
||||
sales_data: pd.DataFrame,
|
||||
min_confidence: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Detect overall trends (growing, declining, stable)."""
|
||||
insights = []
|
||||
|
||||
if 'date' not in sales_data.columns or 'quantity' not in sales_data.columns or len(sales_data) < 60:
|
||||
return insights
|
||||
|
||||
# Sort by date
|
||||
sales_data = sales_data.sort_values('date')
|
||||
|
||||
# Calculate 30-day rolling average
|
||||
sales_data['rolling_30d'] = sales_data['quantity'].rolling(window=30, min_periods=15).mean()
|
||||
|
||||
# Compare first and last 30-day averages
|
||||
first_30_avg = sales_data['rolling_30d'].iloc[:30].mean()
|
||||
last_30_avg = sales_data['rolling_30d'].iloc[-30:].mean()
|
||||
|
||||
if pd.isna(first_30_avg) or pd.isna(last_30_avg):
|
||||
return insights
|
||||
|
||||
pct_change = ((last_30_avg - first_30_avg) / first_30_avg) * 100
|
||||
|
||||
if abs(pct_change) > 10: # 10% change is significant
|
||||
confidence = min(95, 70 + int(abs(pct_change))) # Higher change = higher confidence
|
||||
|
||||
trend_type = 'growing' if pct_change > 0 else 'declining'
|
||||
|
||||
insight = self._create_insight(
|
||||
tenant_id=tenant_id,
|
||||
inventory_product_id=inventory_product_id,
|
||||
insight_type='prediction',
|
||||
category='forecasting',
|
||||
priority='high' if abs(pct_change) > 20 else 'medium',
|
||||
title=f'Sales Trend: {trend_type.title()} {abs(pct_change):.0f}%',
|
||||
description=f'Sales show a {trend_type} trend over the period. Current 30-day average: {last_30_avg:.1f} vs earlier: {first_30_avg:.1f} ({pct_change:+.0f}%).',
|
||||
confidence=confidence,
|
||||
metrics={
|
||||
'current_avg': float(last_30_avg),
|
||||
'previous_avg': float(first_30_avg),
|
||||
'change_pct': float(pct_change),
|
||||
'trend': trend_type
|
||||
},
|
||||
actionable=True,
|
||||
actions=[
|
||||
{'label': 'Adjust Forecast Model', 'action': 'update_forecast'},
|
||||
{'label': 'Review Capacity', 'action': 'review_production_capacity'}
|
||||
]
|
||||
)
|
||||
insights.append(insight)
|
||||
|
||||
return insights
|
||||
|
||||
def _calculate_pattern_confidence(
|
||||
self,
|
||||
sample_size: int,
|
||||
effect_size: float,
|
||||
variability: float
|
||||
) -> int:
|
||||
"""
|
||||
Calculate confidence score for detected pattern.
|
||||
|
||||
Args:
|
||||
sample_size: Number of observations
|
||||
effect_size: Size of the effect (e.g., 0.25 for 25% difference)
|
||||
variability: Standard deviation of data
|
||||
|
||||
Returns:
|
||||
Confidence score (0-100)
|
||||
"""
|
||||
# Base confidence from sample size
|
||||
if sample_size < 4:
|
||||
base = 50
|
||||
elif sample_size < 10:
|
||||
base = 65
|
||||
elif sample_size < 30:
|
||||
base = 75
|
||||
elif sample_size < 100:
|
||||
base = 85
|
||||
else:
|
||||
base = 90
|
||||
|
||||
# Adjust for effect size
|
||||
effect_boost = min(15, effect_size * 30)
|
||||
|
||||
# Adjust for variability (penalize high variability)
|
||||
variability_penalty = min(10, variability / 10)
|
||||
|
||||
confidence = base + effect_boost - variability_penalty
|
||||
|
||||
return int(max(0, min(100, confidence)))
|
||||
|
||||
def _calculate_correlation_confidence(
|
||||
self,
|
||||
correlation: float,
|
||||
p_value: float,
|
||||
sample_size: int
|
||||
) -> int:
|
||||
"""Calculate confidence for correlation insights."""
|
||||
# Base confidence from correlation strength
|
||||
base = abs(correlation) * 100
|
||||
|
||||
# Boost for significance
|
||||
if p_value < 0.001:
|
||||
significance_boost = 15
|
||||
elif p_value < 0.01:
|
||||
significance_boost = 10
|
||||
elif p_value < 0.05:
|
||||
significance_boost = 5
|
||||
else:
|
||||
significance_boost = 0
|
||||
|
||||
# Boost for sample size
|
||||
if sample_size > 100:
|
||||
sample_boost = 10
|
||||
elif sample_size > 50:
|
||||
sample_boost = 5
|
||||
else:
|
||||
sample_boost = 0
|
||||
|
||||
confidence = base + significance_boost + sample_boost
|
||||
|
||||
return int(max(0, min(100, confidence)))
|
||||
|
||||
def _create_insight(
|
||||
self,
|
||||
tenant_id: str,
|
||||
inventory_product_id: str,
|
||||
insight_type: str,
|
||||
category: str,
|
||||
priority: str,
|
||||
title: str,
|
||||
description: str,
|
||||
confidence: int,
|
||||
metrics: Dict[str, Any],
|
||||
actionable: bool,
|
||||
actions: List[Dict[str, str]] = None,
|
||||
impact_type: str = None,
|
||||
impact_value: float = None,
|
||||
impact_unit: str = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Create an insight dictionary for AI Insights Service."""
|
||||
return {
|
||||
'tenant_id': tenant_id,
|
||||
'type': insight_type,
|
||||
'priority': priority,
|
||||
'category': category,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'impact_type': impact_type,
|
||||
'impact_value': impact_value,
|
||||
'impact_unit': impact_unit,
|
||||
'confidence': confidence,
|
||||
'metrics_json': metrics,
|
||||
'actionable': actionable,
|
||||
'recommendation_actions': actions or [],
|
||||
'source_service': 'forecasting',
|
||||
'source_data_id': f'pattern_detection_{inventory_product_id}_{datetime.utcnow().strftime("%Y%m%d")}'
|
||||
}
|
||||
Reference in New Issue
Block a user