feat: Implement comprehensive Forex Trading Journal platform with Myfxbook analytics
This commit is contained in:
+542
@@ -0,0 +1,542 @@
|
||||
<?php
|
||||
/**
|
||||
* Forex Trading Analytics Engine (Myfxbook Grade Calculations)
|
||||
* Precision metrics, risk ratios, drawdown curves, and performance breakdowns.
|
||||
*/
|
||||
|
||||
class AnalyticsEngine {
|
||||
private PDO $db;
|
||||
|
||||
public function __construct(PDO $db) {
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
public function getFullAnalytics(int $accountId, ?string $from = null, ?string $to = null, ?string $symbol = null, ?string $strategy = null): array {
|
||||
// 1. Fetch Account Details
|
||||
$stmtAcc = $this->db->prepare("SELECT * FROM accounts WHERE id = ?");
|
||||
$stmtAcc->execute([$accountId]);
|
||||
$account = $stmtAcc->fetch();
|
||||
|
||||
if (!$account) {
|
||||
return ['error' => 'Account not found'];
|
||||
}
|
||||
|
||||
$initialBalance = (float)$account['initial_balance'];
|
||||
|
||||
// 2. Fetch Trades Query Filtered
|
||||
$sql = "SELECT * FROM trades WHERE account_id = ?";
|
||||
$params = [$accountId];
|
||||
|
||||
if ($from) {
|
||||
$sql .= " AND open_time >= ?";
|
||||
$params[] = $from . ' 00:00:00';
|
||||
}
|
||||
if ($to) {
|
||||
$sql .= " AND open_time <= ?";
|
||||
$params[] = $to . ' 23:59:59';
|
||||
}
|
||||
if ($symbol && $symbol !== 'ALL') {
|
||||
$sql .= " AND symbol = ?";
|
||||
$params[] = $symbol;
|
||||
}
|
||||
if ($strategy && $strategy !== 'ALL') {
|
||||
$sql .= " AND strategy = ?";
|
||||
$params[] = $strategy;
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY open_time ASC, id ASC";
|
||||
$stmtTrades = $this->db->prepare($sql);
|
||||
$stmtTrades->execute($params);
|
||||
$trades = $stmtTrades->fetchAll();
|
||||
|
||||
// 3. Process Metrics & Aggregations
|
||||
$totalTrades = count($trades);
|
||||
$closedTrades = [];
|
||||
$openTrades = [];
|
||||
|
||||
$grossProfit = 0.0;
|
||||
$grossLoss = 0.0;
|
||||
$totalCommission = 0.0;
|
||||
$totalSwap = 0.0;
|
||||
$totalPips = 0.0;
|
||||
$wonTradesCount = 0;
|
||||
$lostTradesCount = 0;
|
||||
$beTradesCount = 0;
|
||||
|
||||
$longTradesCount = 0;
|
||||
$longWonCount = 0;
|
||||
$longProfit = 0.0;
|
||||
|
||||
$shortTradesCount = 0;
|
||||
$shortWonCount = 0;
|
||||
$shortProfit = 0.0;
|
||||
|
||||
$bestTradeProfit = 0.0;
|
||||
$worstTradeProfit = 0.0;
|
||||
$bestTradePips = 0.0;
|
||||
$worstTradePips = 0.0;
|
||||
|
||||
$winPipsSum = 0.0;
|
||||
$lossPipsSum = 0.0;
|
||||
|
||||
$currentBalance = $initialBalance;
|
||||
$peakBalance = $initialBalance;
|
||||
$maxDrawdownAmount = 0.0;
|
||||
$maxDrawdownPercent = 0.0;
|
||||
|
||||
$growthCurve = [];
|
||||
$drawdownCurve = [];
|
||||
|
||||
$consecutiveWins = 0;
|
||||
$consecutiveLosses = 0;
|
||||
$maxConsecutiveWins = 0;
|
||||
$maxConsecutiveLosses = 0;
|
||||
|
||||
$totalDurationSec = 0;
|
||||
$winDurationSec = 0;
|
||||
$lossDurationSec = 0;
|
||||
|
||||
$dailyReturns = []; // date => pnl
|
||||
$symbolStats = [];
|
||||
$strategyStats = [];
|
||||
$emotionStats = [];
|
||||
$sessionStats = ['London' => ['count' => 0, 'profit' => 0, 'wins' => 0], 'New York' => ['count' => 0, 'profit' => 0, 'wins' => 0], 'Asian' => ['count' => 0, 'profit' => 0, 'wins' => 0], 'Overlap' => ['count' => 0, 'profit' => 0, 'wins' => 0]];
|
||||
$dayOfWeekStats = [
|
||||
1 => ['name' => 'Monday', 'fa' => 'دوشنبه', 'count' => 0, 'profit' => 0, 'wins' => 0],
|
||||
2 => ['name' => 'Tuesday', 'fa' => 'سهشنبه', 'count' => 0, 'profit' => 0, 'wins' => 0],
|
||||
3 => ['name' => 'Wednesday', 'fa' => 'چهارشنبه', 'count' => 0, 'profit' => 0, 'wins' => 0],
|
||||
4 => ['name' => 'Thursday', 'fa' => 'پنجشنبه', 'count' => 0, 'profit' => 0, 'wins' => 0],
|
||||
5 => ['name' => 'Friday', 'fa' => 'جمعه', 'count' => 0, 'profit' => 0, 'wins' => 0],
|
||||
6 => ['name' => 'Saturday', 'fa' => 'شنبه', 'count' => 0, 'profit' => 0, 'wins' => 0],
|
||||
7 => ['name' => 'Sunday', 'fa' => 'یکشنبه', 'count' => 0, 'profit' => 0, 'wins' => 0],
|
||||
];
|
||||
$hourlyStats = [];
|
||||
for ($h = 0; $h < 24; $h++) {
|
||||
$hourlyStats[$h] = ['hour' => $h, 'count' => 0, 'profit' => 0, 'wins' => 0];
|
||||
}
|
||||
|
||||
// Add starting point to growth curve
|
||||
$growthCurve[] = [
|
||||
'date' => date('Y-m-d H:i', strtotime($account['created_at'] ?? 'now')),
|
||||
'trade_index' => 0,
|
||||
'balance' => round($initialBalance, 2),
|
||||
'equity' => round($initialBalance, 2),
|
||||
'profit' => 0.0,
|
||||
'gain_percent' => 0.0,
|
||||
'symbol' => 'START',
|
||||
'ticket' => '-'
|
||||
];
|
||||
|
||||
$tradeIndex = 0;
|
||||
|
||||
foreach ($trades as $t) {
|
||||
$profit = (float)$t['profit'];
|
||||
$pips = (float)$t['pips'];
|
||||
$comm = (float)$t['commission'];
|
||||
$swap = (float)$t['swap'];
|
||||
$sym = $t['symbol'];
|
||||
$strat = $t['strategy'] ?: 'Price Action';
|
||||
$emo = $t['emotion'] ?: 'Disciplined';
|
||||
$sess = $t['session'] ?: 'London';
|
||||
|
||||
if ($t['status'] === 'open') {
|
||||
$openTrades[] = $t;
|
||||
continue;
|
||||
}
|
||||
|
||||
$closedTrades[] = $t;
|
||||
$tradeIndex++;
|
||||
|
||||
$totalCommission += $comm;
|
||||
$totalSwap += $swap;
|
||||
$totalPips += $pips;
|
||||
|
||||
// Balance & Equity Tracking
|
||||
$netTradeProfit = $profit + $comm + $swap;
|
||||
$currentBalance += $netTradeProfit;
|
||||
|
||||
if ($currentBalance > $peakBalance) {
|
||||
$peakBalance = $currentBalance;
|
||||
}
|
||||
$currentDDAmount = $peakBalance - $currentBalance;
|
||||
$currentDDPercent = $peakBalance > 0 ? ($currentDDAmount / $peakBalance) * 100 : 0;
|
||||
|
||||
if ($currentDDAmount > $maxDrawdownAmount) {
|
||||
$maxDrawdownAmount = $currentDDAmount;
|
||||
}
|
||||
if ($currentDDPercent > $maxDrawdownPercent) {
|
||||
$maxDrawdownPercent = $currentDDPercent;
|
||||
}
|
||||
|
||||
$gainPercent = $initialBalance > 0 ? (($currentBalance - $initialBalance) / $initialBalance) * 100 : 0;
|
||||
|
||||
$closeDate = $t['close_time'] ?: $t['open_time'];
|
||||
$growthCurve[] = [
|
||||
'date' => date('Y-m-d H:i', strtotime($closeDate)),
|
||||
'trade_index' => $tradeIndex,
|
||||
'balance' => round($currentBalance, 2),
|
||||
'equity' => round($currentBalance, 2),
|
||||
'profit' => round($netTradeProfit, 2),
|
||||
'gain_percent' => round($gainPercent, 2),
|
||||
'symbol' => $sym,
|
||||
'ticket' => $t['ticket'] ?: "#$tradeIndex"
|
||||
];
|
||||
|
||||
$drawdownCurve[] = [
|
||||
'date' => date('Y-m-d H:i', strtotime($closeDate)),
|
||||
'trade_index' => $tradeIndex,
|
||||
'drawdown_percent' => round(-$currentDDPercent, 2),
|
||||
'drawdown_amount' => round($currentDDAmount, 2)
|
||||
];
|
||||
|
||||
// Win / Loss classification
|
||||
if ($profit > 0) {
|
||||
$wonTradesCount++;
|
||||
$grossProfit += $profit;
|
||||
$winPipsSum += $pips;
|
||||
$consecutiveWins++;
|
||||
$consecutiveLosses = 0;
|
||||
if ($consecutiveWins > $maxConsecutiveWins) {
|
||||
$maxConsecutiveWins = $consecutiveWins;
|
||||
}
|
||||
if ($profit > $bestTradeProfit) {
|
||||
$bestTradeProfit = $profit;
|
||||
}
|
||||
if ($pips > $bestTradePips) {
|
||||
$bestTradePips = $pips;
|
||||
}
|
||||
} elseif ($profit < 0) {
|
||||
$lostTradesCount++;
|
||||
$grossLoss += abs($profit);
|
||||
$lossPipsSum += abs($pips);
|
||||
$consecutiveLosses++;
|
||||
$consecutiveWins = 0;
|
||||
if ($consecutiveLosses > $maxConsecutiveLosses) {
|
||||
$maxConsecutiveLosses = $consecutiveLosses;
|
||||
}
|
||||
if ($profit < $worstTradeProfit) {
|
||||
$worstTradeProfit = $profit;
|
||||
}
|
||||
if ($pips < $worstTradePips) {
|
||||
$worstTradePips = $pips;
|
||||
}
|
||||
} else {
|
||||
$beTradesCount++;
|
||||
}
|
||||
|
||||
// Long / Short stats
|
||||
if (strtolower($t['trade_type']) === 'buy') {
|
||||
$longTradesCount++;
|
||||
$longProfit += $netTradeProfit;
|
||||
if ($profit > 0) $longWonCount++;
|
||||
} else {
|
||||
$shortTradesCount++;
|
||||
$shortProfit += $netTradeProfit;
|
||||
if ($profit > 0) $shortWonCount++;
|
||||
}
|
||||
|
||||
// Duration calculation
|
||||
if ($t['open_time'] && $t['close_time']) {
|
||||
$dur = max(0, strtotime($t['close_time']) - strtotime($t['open_time']));
|
||||
$totalDurationSec += $dur;
|
||||
if ($profit > 0) $winDurationSec += $dur;
|
||||
elseif ($profit < 0) $lossDurationSec += $dur;
|
||||
}
|
||||
|
||||
// Daily return aggregation
|
||||
$dayKey = date('Y-m-d', strtotime($t['open_time']));
|
||||
if (!isset($dailyReturns[$dayKey])) {
|
||||
$dailyReturns[$dayKey] = ['profit' => 0.0, 'pips' => 0.0, 'trades' => 0, 'wins' => 0, 'losses' => 0];
|
||||
}
|
||||
$dailyReturns[$dayKey]['profit'] += $netTradeProfit;
|
||||
$dailyReturns[$dayKey]['pips'] += $pips;
|
||||
$dailyReturns[$dayKey]['trades']++;
|
||||
if ($profit > 0) $dailyReturns[$dayKey]['wins']++;
|
||||
elseif ($profit < 0) $dailyReturns[$dayKey]['losses']++;
|
||||
|
||||
// By Symbol Breakdown
|
||||
if (!isset($symbolStats[$sym])) {
|
||||
$symbolStats[$sym] = ['symbol' => $sym, 'count' => 0, 'wins' => 0, 'losses' => 0, 'profit' => 0.0, 'pips' => 0.0, 'longs' => 0, 'shorts' => 0];
|
||||
}
|
||||
$symbolStats[$sym]['count']++;
|
||||
$symbolStats[$sym]['profit'] += $netTradeProfit;
|
||||
$symbolStats[$sym]['pips'] += $pips;
|
||||
if ($profit > 0) $symbolStats[$sym]['wins']++;
|
||||
elseif ($profit < 0) $symbolStats[$sym]['losses']++;
|
||||
if (strtolower($t['trade_type']) === 'buy') $symbolStats[$sym]['longs']++;
|
||||
else $symbolStats[$sym]['shorts']++;
|
||||
|
||||
// By Strategy Breakdown
|
||||
if (!isset($strategyStats[$strat])) {
|
||||
$strategyStats[$strat] = ['strategy' => $strat, 'count' => 0, 'wins' => 0, 'losses' => 0, 'profit' => 0.0, 'gross_profit' => 0.0, 'gross_loss' => 0.0];
|
||||
}
|
||||
$strategyStats[$strat]['count']++;
|
||||
$strategyStats[$strat]['profit'] += $netTradeProfit;
|
||||
if ($profit > 0) {
|
||||
$strategyStats[$strat]['wins']++;
|
||||
$strategyStats[$strat]['gross_profit'] += $profit;
|
||||
} elseif ($profit < 0) {
|
||||
$strategyStats[$strat]['losses']++;
|
||||
$strategyStats[$strat]['gross_loss'] += abs($profit);
|
||||
}
|
||||
|
||||
// By Emotion Breakdown
|
||||
if (!isset($emotionStats[$emo])) {
|
||||
$emotionStats[$emo] = ['emotion' => $emo, 'count' => 0, 'wins' => 0, 'profit' => 0.0, 'pips' => 0.0];
|
||||
}
|
||||
$emotionStats[$emo]['count']++;
|
||||
$emotionStats[$emo]['profit'] += $netTradeProfit;
|
||||
$emotionStats[$emo]['pips'] += $pips;
|
||||
if ($profit > 0) $emotionStats[$emo]['wins']++;
|
||||
|
||||
// By Session Breakdown
|
||||
if (isset($sessionStats[$sess])) {
|
||||
$sessionStats[$sess]['count']++;
|
||||
$sessionStats[$sess]['profit'] += $netTradeProfit;
|
||||
if ($profit > 0) $sessionStats[$sess]['wins']++;
|
||||
}
|
||||
|
||||
// By Day of Week & Hour
|
||||
$openTs = strtotime($t['open_time']);
|
||||
$dow = (int)date('N', $openTs); // 1 = Monday .. 7 = Sunday
|
||||
if (isset($dayOfWeekStats[$dow])) {
|
||||
$dayOfWeekStats[$dow]['count']++;
|
||||
$dayOfWeekStats[$dow]['profit'] += $netTradeProfit;
|
||||
if ($profit > 0) $dayOfWeekStats[$dow]['wins']++;
|
||||
}
|
||||
|
||||
$hr = (int)date('G', $openTs); // 0 .. 23
|
||||
if (isset($hourlyStats[$hr])) {
|
||||
$hourlyStats[$hr]['count']++;
|
||||
$hourlyStats[$hr]['profit'] += $netTradeProfit;
|
||||
if ($profit > 0) $hourlyStats[$hr]['wins']++;
|
||||
}
|
||||
}
|
||||
|
||||
// Open floating calculation
|
||||
$openFloatingProfit = 0.0;
|
||||
foreach ($openTrades as $ot) {
|
||||
$openFloatingProfit += (float)$ot['profit'] + (float)$ot['commission'] + (float)$ot['swap'];
|
||||
}
|
||||
$currentEquity = $currentBalance + $openFloatingProfit;
|
||||
|
||||
// Total Net Closed Profit
|
||||
$totalClosedProfit = $currentBalance - $initialBalance;
|
||||
$totalGainPercent = $initialBalance > 0 ? (($currentEquity - $initialBalance) / $initialBalance) * 100 : 0;
|
||||
$closedGainPercent = $initialBalance > 0 ? ($totalClosedProfit / $initialBalance) * 100 : 0;
|
||||
|
||||
$closedCount = count($closedTrades);
|
||||
$winRate = $closedCount > 0 ? ($wonTradesCount / $closedCount) * 100 : 0;
|
||||
$lossRate = $closedCount > 0 ? ($lostTradesCount / $closedCount) * 100 : 0;
|
||||
|
||||
$profitFactor = $grossLoss > 0 ? round($grossProfit / $grossLoss, 2) : ($grossProfit > 0 ? 99.9 : 0.0);
|
||||
$avgWin = $wonTradesCount > 0 ? $grossProfit / $wonTradesCount : 0.0;
|
||||
$avgLoss = $lostTradesCount > 0 ? $grossLoss / $lostTradesCount : 0.0;
|
||||
$winLossRatio = $avgLoss > 0 ? round($avgWin / $avgLoss, 2) : 0.0;
|
||||
|
||||
$expectancy = ($winRate / 100 * $avgWin) - ($lossRate / 100 * $avgLoss);
|
||||
|
||||
$avgPipsWin = $wonTradesCount > 0 ? $winPipsSum / $wonTradesCount : 0.0;
|
||||
$avgPipsLoss = $lostTradesCount > 0 ? $lossPipsSum / $lostTradesCount : 0.0;
|
||||
|
||||
$longWinRate = $longTradesCount > 0 ? ($longWonCount / $longTradesCount) * 100 : 0;
|
||||
$shortWinRate = $shortTradesCount > 0 ? ($shortWonCount / $shortTradesCount) * 100 : 0;
|
||||
|
||||
$avgTradeDuration = $closedCount > 0 ? round($totalDurationSec / $closedCount) : 0;
|
||||
$avgWinDuration = $wonTradesCount > 0 ? round($winDurationSec / $wonTradesCount) : 0;
|
||||
$avgLossDuration = $lostTradesCount > 0 ? round($lossDurationSec / $lostTradesCount) : 0;
|
||||
|
||||
// Sharpe & Sortino Ratio estimation
|
||||
$dailyPnLValues = array_column($dailyReturns, 'profit');
|
||||
$sharpeRatio = 0.0;
|
||||
$sortinoRatio = 0.0;
|
||||
if (count($dailyPnLValues) > 2) {
|
||||
$meanDaily = array_sum($dailyPnLValues) / count($dailyPnLValues);
|
||||
$variance = 0.0;
|
||||
$downsideVariance = 0.0;
|
||||
foreach ($dailyPnLValues as $dp) {
|
||||
$diff = $dp - $meanDaily;
|
||||
$variance += ($diff * $diff);
|
||||
if ($dp < 0) {
|
||||
$downsideVariance += ($dp * $dp);
|
||||
}
|
||||
}
|
||||
$stdDev = sqrt($variance / count($dailyPnLValues));
|
||||
$downsideStdDev = sqrt($downsideVariance / count($dailyPnLValues));
|
||||
|
||||
if ($stdDev > 0) {
|
||||
$sharpeRatio = round(($meanDaily / $stdDev) * sqrt(252), 2); // Annualized (252 trading days)
|
||||
}
|
||||
if ($downsideStdDev > 0) {
|
||||
$sortinoRatio = round(($meanDaily / $downsideStdDev) * sqrt(252), 2);
|
||||
}
|
||||
}
|
||||
|
||||
// Today, This Week, This Month profit
|
||||
$todayStr = date('Y-m-d');
|
||||
$thisWeekStart = date('Y-m-d', strtotime('monday this week'));
|
||||
$thisMonthStart = date('Y-m-01');
|
||||
|
||||
$todayProfit = $dailyReturns[$todayStr]['profit'] ?? 0.0;
|
||||
$thisWeekProfit = 0.0;
|
||||
$thisMonthProfit = 0.0;
|
||||
|
||||
foreach ($dailyReturns as $dDate => $dData) {
|
||||
if ($dDate >= $thisWeekStart) {
|
||||
$thisWeekProfit += $dData['profit'];
|
||||
}
|
||||
if ($dDate >= $thisMonthStart) {
|
||||
$thisMonthProfit += $dData['profit'];
|
||||
}
|
||||
}
|
||||
|
||||
// Monthly Returns Table (Year x Months Heatmap)
|
||||
$monthlyReturns = [];
|
||||
foreach ($dailyReturns as $dDate => $dData) {
|
||||
$yr = (int)date('Y', strtotime($dDate));
|
||||
$mo = (int)date('n', strtotime($dDate));
|
||||
if (!isset($monthlyReturns[$yr])) {
|
||||
$monthlyReturns[$yr] = [
|
||||
'year' => $yr,
|
||||
'months' => array_fill(1, 12, ['profit' => 0.0, 'gain_percent' => 0.0, 'trades' => 0]),
|
||||
'total_profit' => 0.0,
|
||||
'total_gain_percent' => 0.0,
|
||||
'total_trades' => 0
|
||||
];
|
||||
}
|
||||
$monthlyReturns[$yr]['months'][$mo]['profit'] += $dData['profit'];
|
||||
$monthlyReturns[$yr]['months'][$mo]['trades'] += $dData['trades'];
|
||||
$monthlyReturns[$yr]['total_profit'] += $dData['profit'];
|
||||
$monthlyReturns[$yr]['total_trades'] += $dData['trades'];
|
||||
}
|
||||
|
||||
foreach ($monthlyReturns as $yr => &$yData) {
|
||||
foreach ($yData['months'] as $mo => &$mVal) {
|
||||
if ($initialBalance > 0) {
|
||||
$mVal['gain_percent'] = round(($mVal['profit'] / $initialBalance) * 100, 2);
|
||||
}
|
||||
}
|
||||
if ($initialBalance > 0) {
|
||||
$yData['total_gain_percent'] = round(($yData['total_profit'] / $initialBalance) * 100, 2);
|
||||
}
|
||||
}
|
||||
unset($yData, $mVal);
|
||||
|
||||
// Format Breakdown Lists
|
||||
$symbolsList = array_values($symbolStats);
|
||||
usort($symbolsList, fn($a, $b) => $b['profit'] <=> $a['profit']);
|
||||
foreach ($symbolsList as &$sItem) {
|
||||
$sItem['win_rate'] = $sItem['count'] > 0 ? round(($sItem['wins'] / $sItem['count']) * 100, 1) : 0;
|
||||
$sItem['avg_profit'] = $sItem['count'] > 0 ? round($sItem['profit'] / $sItem['count'], 2) : 0;
|
||||
}
|
||||
|
||||
$strategiesList = array_values($strategyStats);
|
||||
usort($strategiesList, fn($a, $b) => $b['profit'] <=> $a['profit']);
|
||||
foreach ($strategiesList as &$stItem) {
|
||||
$stItem['win_rate'] = $stItem['count'] > 0 ? round(($stItem['wins'] / $stItem['count']) * 100, 1) : 0;
|
||||
$stItem['profit_factor'] = $stItem['gross_loss'] > 0 ? round($stItem['gross_profit'] / $stItem['gross_loss'], 2) : ($stItem['gross_profit'] > 0 ? 99.9 : 0.0);
|
||||
}
|
||||
|
||||
$emotionsList = array_values($emotionStats);
|
||||
usort($emotionsList, fn($a, $b) => $b['profit'] <=> $a['profit']);
|
||||
foreach ($emotionsList as &$emItem) {
|
||||
$emItem['win_rate'] = $emItem['count'] > 0 ? round(($emItem['wins'] / $emItem['count']) * 100, 1) : 0;
|
||||
}
|
||||
|
||||
$sessionsList = [];
|
||||
foreach ($sessionStats as $sessKey => $sessData) {
|
||||
$sessionsList[] = [
|
||||
'session' => $sessKey,
|
||||
'count' => $sessData['count'],
|
||||
'profit' => round($sessData['profit'], 2),
|
||||
'win_rate' => $sessData['count'] > 0 ? round(($sessData['wins'] / $sessData['count']) * 100, 1) : 0
|
||||
];
|
||||
}
|
||||
|
||||
$daysOfWeekList = [];
|
||||
foreach ($dayOfWeekStats as $dowKey => $dowData) {
|
||||
$daysOfWeekList[] = [
|
||||
'day_num' => $dowKey,
|
||||
'name' => $dowData['name'],
|
||||
'name_fa' => $dowData['fa'],
|
||||
'count' => $dowData['count'],
|
||||
'profit' => round($dowData['profit'], 2),
|
||||
'win_rate' => $dowData['count'] > 0 ? round(($dowData['wins'] / $dowData['count']) * 100, 1) : 0
|
||||
];
|
||||
}
|
||||
|
||||
$hourlyList = array_values($hourlyStats);
|
||||
foreach ($hourlyList as &$hrItem) {
|
||||
$hrItem['profit'] = round($hrItem['profit'], 2);
|
||||
$hrItem['win_rate'] = $hrItem['count'] > 0 ? round(($hrItem['wins'] / $hrItem['count']) * 100, 1) : 0;
|
||||
}
|
||||
|
||||
return [
|
||||
'account' => $account,
|
||||
'summary' => [
|
||||
'initial_balance' => round($initialBalance, 2),
|
||||
'current_balance' => round($currentBalance, 2),
|
||||
'current_equity' => round($currentEquity, 2),
|
||||
'open_floating_profit' => round($openFloatingProfit, 2),
|
||||
'total_closed_profit' => round($totalClosedProfit, 2),
|
||||
'total_gain_percent' => round($totalGainPercent, 2),
|
||||
'closed_gain_percent' => round($closedGainPercent, 2),
|
||||
'today_profit' => round($todayProfit, 2),
|
||||
'this_week_profit' => round($thisWeekProfit, 2),
|
||||
'this_month_profit' => round($thisMonthProfit, 2),
|
||||
'total_trades' => $totalTrades,
|
||||
'closed_trades' => $closedCount,
|
||||
'open_trades' => count($openTrades),
|
||||
'won_trades' => $wonTradesCount,
|
||||
'lost_trades' => $lostTradesCount,
|
||||
'breakeven_trades' => $beTradesCount,
|
||||
'win_rate' => round($winRate, 1),
|
||||
'loss_rate' => round($lossRate, 1),
|
||||
'profit_factor' => $profitFactor,
|
||||
'gross_profit' => round($grossProfit, 2),
|
||||
'gross_loss' => round($grossLoss, 2),
|
||||
'avg_win' => round($avgWin, 2),
|
||||
'avg_loss' => round($avgLoss, 2),
|
||||
'win_loss_ratio' => $winLossRatio,
|
||||
'expectancy' => round($expectancy, 2),
|
||||
'total_pips' => round($totalPips, 1),
|
||||
'avg_pips_win' => round($avgPipsWin, 1),
|
||||
'avg_pips_loss' => round($avgPipsLoss, 1),
|
||||
'best_trade_profit' => round($bestTradeProfit, 2),
|
||||
'worst_trade_profit' => round($worstTradeProfit, 2),
|
||||
'best_trade_pips' => round($bestTradePips, 1),
|
||||
'worst_trade_pips' => round($worstTradePips, 1),
|
||||
'max_consecutive_wins' => $maxConsecutiveWins,
|
||||
'max_consecutive_losses' => $maxConsecutiveLosses,
|
||||
'max_drawdown_amount' => round($maxDrawdownAmount, 2),
|
||||
'max_drawdown_percent' => round($maxDrawdownPercent, 2),
|
||||
'sharpe_ratio' => $sharpeRatio,
|
||||
'sortino_ratio' => $sortinoRatio,
|
||||
'total_commission' => round($totalCommission, 2),
|
||||
'total_swap' => round($totalSwap, 2),
|
||||
'long_trades' => $longTradesCount,
|
||||
'long_won' => $longWonCount,
|
||||
'long_win_rate' => round($longWinRate, 1),
|
||||
'long_profit' => round($longProfit, 2),
|
||||
'short_trades' => $shortTradesCount,
|
||||
'short_won' => $shortWonCount,
|
||||
'short_win_rate' => round($shortWinRate, 1),
|
||||
'short_profit' => round($shortProfit, 2),
|
||||
'avg_trade_duration_seconds' => $avgTradeDuration,
|
||||
'avg_win_duration_seconds' => $avgWinDuration,
|
||||
'avg_loss_duration_seconds' => $avgLossDuration,
|
||||
],
|
||||
'growth_curve' => $growthCurve,
|
||||
'drawdown_curve' => $drawdownCurve,
|
||||
'monthly_returns' => array_values($monthlyReturns),
|
||||
'daily_pnl' => $dailyReturns,
|
||||
'by_symbol' => $symbolsList,
|
||||
'by_strategy' => $strategiesList,
|
||||
'by_emotion' => $emotionsList,
|
||||
'by_session' => $sessionsList,
|
||||
'by_day_of_week' => $daysOfWeekList,
|
||||
'by_hour' => $hourlyList,
|
||||
'open_trades' => $openTrades
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
<?php
|
||||
// api.php - RESTful API handlers for ShahrNegar
|
||||
/**
|
||||
* Forex Trading Journal REST API
|
||||
* Clean JSON API for accounts, trades, analytics, chart uploads, MT4/MT5 statement import, and exports.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
// Set response headers
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PATCH, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
@@ -14,321 +14,611 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
exit;
|
||||
}
|
||||
|
||||
initDB();
|
||||
$pdo = getDB();
|
||||
require_once __DIR__ . '/db.php';
|
||||
require_once __DIR__ . '/analytics.php';
|
||||
|
||||
$requestUri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
$pdo = Database::getConnection();
|
||||
$analyticsEngine = new AnalyticsEngine($pdo);
|
||||
|
||||
// Parse request path
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// Helper JSON response
|
||||
function jsonResponse($data, int $statusCode = 200) {
|
||||
function sendJson(array $data, int $statusCode = 200): void {
|
||||
http_response_code($statusCode);
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Helper to get raw JSON payload
|
||||
function getJsonInput(): array {
|
||||
$input = file_get_contents('php://input');
|
||||
return json_decode($input, true) ?? [];
|
||||
$raw = file_get_contents('php://input');
|
||||
$decoded = json_decode($raw, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
// 1. GET /api/stats
|
||||
if ($requestUri === '/api/stats' && $method === 'GET') {
|
||||
$total = (int)$pdo->query("SELECT COUNT(*) FROM issues")->fetchColumn();
|
||||
$pending = (int)$pdo->query("SELECT COUNT(*) FROM issues WHERE status = 'pending'")->fetchColumn();
|
||||
$reviewing = (int)$pdo->query("SELECT COUNT(*) FROM issues WHERE status = 'reviewing'")->fetchColumn();
|
||||
$inProgress = (int)$pdo->query("SELECT COUNT(*) FROM issues WHERE status = 'in_progress'")->fetchColumn();
|
||||
$resolved = (int)$pdo->query("SELECT COUNT(*) FROM issues WHERE status = 'resolved'")->fetchColumn();
|
||||
$upvotes = (int)$pdo->query("SELECT COALESCE(SUM(upvotes), 0) FROM issues")->fetchColumn();
|
||||
// Helper Pip Calculator
|
||||
function calculatePips(string $symbol, string $type, float $openPrice, float $closePrice): float {
|
||||
$sym = strtoupper(trim($symbol));
|
||||
$type = strtolower(trim($type));
|
||||
$diff = ($type === 'buy') ? ($closePrice - $openPrice) : ($openPrice - $closePrice);
|
||||
|
||||
$catStmt = $pdo->query("SELECT category, COUNT(*) as count FROM issues GROUP BY category ORDER BY count DESC");
|
||||
$categories = $catStmt->fetchAll();
|
||||
|
||||
$distStmt = $pdo->query("SELECT district, COUNT(*) as count FROM issues GROUP BY district ORDER BY district ASC");
|
||||
$districts = $distStmt->fetchAll();
|
||||
|
||||
$prioStmt = $pdo->query("SELECT priority, COUNT(*) as count FROM issues GROUP BY priority");
|
||||
$priorities = [];
|
||||
foreach ($prioStmt->fetchAll() as $row) {
|
||||
$priorities[$row['priority']] = (int)$row['count'];
|
||||
}
|
||||
|
||||
$resolutionRate = $total > 0 ? round(($resolved / $total) * 100, 1) : 0;
|
||||
|
||||
jsonResponse([
|
||||
'total' => $total,
|
||||
'pending' => $pending,
|
||||
'reviewing' => $reviewing,
|
||||
'in_progress' => $inProgress,
|
||||
'resolved' => $resolved,
|
||||
'total_upvotes' => $upvotes,
|
||||
'categories' => $categories,
|
||||
'districts' => $districts,
|
||||
'priorities' => $priorities,
|
||||
'resolution_rate' => $resolutionRate
|
||||
]);
|
||||
}
|
||||
|
||||
// 2. GET /api/issues
|
||||
if ($requestUri === '/api/issues' && $method === 'GET') {
|
||||
$category = $_GET['category'] ?? null;
|
||||
$status = $_GET['status'] ?? null;
|
||||
$district = isset($_GET['district']) ? (int)$_GET['district'] : 0;
|
||||
$priority = $_GET['priority'] ?? null;
|
||||
$search = $_GET['search'] ?? null;
|
||||
$sortBy = $_GET['sort_by'] ?? 'newest';
|
||||
|
||||
$sql = "SELECT * FROM issues WHERE 1=1";
|
||||
$params = [];
|
||||
|
||||
if ($category && $category !== 'all') {
|
||||
$sql .= " AND category = ?";
|
||||
$params[] = $category;
|
||||
}
|
||||
|
||||
if ($status && $status !== 'all') {
|
||||
$sql .= " AND status = ?";
|
||||
$params[] = $status;
|
||||
}
|
||||
|
||||
if ($district > 0) {
|
||||
$sql .= " AND district = ?";
|
||||
$params[] = $district;
|
||||
}
|
||||
|
||||
if ($priority && $priority !== 'all') {
|
||||
$sql .= " AND priority = ?";
|
||||
$params[] = $priority;
|
||||
}
|
||||
|
||||
if ($search) {
|
||||
$sql .= " AND (title LIKE ? OR description LIKE ? OR address LIKE ? OR tracking_code LIKE ?)";
|
||||
$searchParam = "%{$search}%";
|
||||
$params[] = $searchParam;
|
||||
$params[] = $searchParam;
|
||||
$params[] = $searchParam;
|
||||
$params[] = $searchParam;
|
||||
}
|
||||
|
||||
if ($sortBy === 'upvotes') {
|
||||
$sql .= " ORDER BY upvotes DESC, id DESC";
|
||||
} elseif ($sortBy === 'oldest') {
|
||||
$sql .= " ORDER BY id ASC";
|
||||
} elseif ($sortBy === 'emergency') {
|
||||
$sql .= " ORDER BY CASE priority WHEN 'emergency' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END, id DESC";
|
||||
if (strpos($sym, 'JPY') !== false) {
|
||||
return round($diff * 100, 1);
|
||||
} elseif (strpos($sym, 'XAU') !== false || strpos($sym, 'GOLD') !== false) {
|
||||
return round($diff * 10, 1); // 10 pips per $1.00 move
|
||||
} elseif (strpos($sym, 'BTC') !== false || strpos($sym, 'ETH') !== false || strpos($sym, 'US30') !== false || strpos($sym, 'NAS') !== false) {
|
||||
return round($diff, 1); // 1 point
|
||||
} else {
|
||||
$sql .= " ORDER BY id DESC";
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
$commentCountStmt = $pdo->prepare("SELECT COUNT(*) FROM comments WHERE issue_id = ?");
|
||||
|
||||
$issues = [];
|
||||
foreach ($rows as $row) {
|
||||
$commentCountStmt->execute([$row['id']]);
|
||||
$row['comment_count'] = (int)$commentCountStmt->fetchColumn();
|
||||
$issues[] = $row;
|
||||
}
|
||||
|
||||
jsonResponse($issues);
|
||||
}
|
||||
|
||||
// 3. POST /api/issues (Create new issue)
|
||||
if ($requestUri === '/api/issues' && $method === 'POST') {
|
||||
$data = getJsonInput();
|
||||
|
||||
if (empty($data['title']) || empty($data['category']) || empty($data['address'])) {
|
||||
jsonResponse(['error' => 'اطلاعات ضروری تکمیل نشده است.'], 400);
|
||||
}
|
||||
|
||||
$trackingCode = 'SHR-' . rand(10000, 99999);
|
||||
$now = date('Y-m-d H:i');
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO issues (
|
||||
tracking_code, title, category, description, address, district, priority, status,
|
||||
lat, lng, image_url, reporter_name, reporter_phone, upvotes, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, 1, ?)");
|
||||
|
||||
$stmt->execute([
|
||||
$trackingCode,
|
||||
$data['title'],
|
||||
$data['category'],
|
||||
$data['description'] ?? '',
|
||||
$data['address'],
|
||||
(int)($data['district'] ?? 1),
|
||||
$data['priority'] ?? 'medium',
|
||||
(float)($data['lat'] ?? 35.7538),
|
||||
(float)($data['lng'] ?? 51.4172),
|
||||
$data['image_url'] ?? 'https://images.unsplash.com/photo-1515162816999-a0c47dc192f7?auto=format&fit=crop&w=800&q=80',
|
||||
$data['reporter_name'] ?? 'شهروند مسئول',
|
||||
$data['reporter_phone'] ?? '',
|
||||
$now
|
||||
]);
|
||||
|
||||
$issueId = $pdo->lastInsertId();
|
||||
|
||||
// Timeline initial record
|
||||
$tlStmt = $pdo->prepare("INSERT INTO timeline (issue_id, status, title, description, created_at) VALUES (?, 'pending', 'ثبت اولیه گزارش در سامانه', 'گزارش شما با موفقیت در سامانه شهرنگار ثبت گردید و در نوبت بررسی کارشناسی قرار گرفت.', ?)");
|
||||
$tlStmt->execute([$issueId, $now]);
|
||||
|
||||
$fetchStmt = $pdo->prepare("SELECT * FROM issues WHERE id = ?");
|
||||
$fetchStmt->execute([$issueId]);
|
||||
jsonResponse($fetchStmt->fetch(), 201);
|
||||
}
|
||||
|
||||
// 4. GET /api/track/{code}
|
||||
if (preg_match('#^/api/track/([^/]+)$#', $requestUri, $matches) && $method === 'GET') {
|
||||
$code = strtoupper(trim(urldecode($matches[1])));
|
||||
$stmt = $pdo->prepare("SELECT * FROM issues WHERE UPPER(tracking_code) = ?");
|
||||
$stmt->execute([$code]);
|
||||
$issue = $stmt->fetch();
|
||||
|
||||
if (!$issue) {
|
||||
jsonResponse(['error' => "هیچ گزارشی با کد رهگیری «{$code}» یافت نشد."], 404);
|
||||
}
|
||||
|
||||
$tlStmt = $pdo->prepare("SELECT * FROM timeline WHERE issue_id = ? ORDER BY id ASC");
|
||||
$tlStmt->execute([$issue['id']]);
|
||||
$issue['timeline'] = $tlStmt->fetchAll();
|
||||
|
||||
$cmStmt = $pdo->prepare("SELECT * FROM comments WHERE issue_id = ? ORDER BY id DESC");
|
||||
$cmStmt->execute([$issue['id']]);
|
||||
$issue['comments'] = $cmStmt->fetchAll();
|
||||
|
||||
jsonResponse($issue);
|
||||
}
|
||||
|
||||
// 5. GET /api/issues/{id}
|
||||
if (preg_match('#^/api/issues/(\d+)$#', $requestUri, $matches) && $method === 'GET') {
|
||||
$issueId = (int)$matches[1];
|
||||
$stmt = $pdo->prepare("SELECT * FROM issues WHERE id = ?");
|
||||
$stmt->execute([$issueId]);
|
||||
$issue = $stmt->fetch();
|
||||
|
||||
if (!$issue) {
|
||||
jsonResponse(['error' => 'گزارش مورد نظر یافت نشد.'], 404);
|
||||
}
|
||||
|
||||
$tlStmt = $pdo->prepare("SELECT * FROM timeline WHERE issue_id = ? ORDER BY id ASC");
|
||||
$tlStmt->execute([$issueId]);
|
||||
$issue['timeline'] = $tlStmt->fetchAll();
|
||||
|
||||
$cmStmt = $pdo->prepare("SELECT * FROM comments WHERE issue_id = ? ORDER BY id DESC");
|
||||
$cmStmt->execute([$issueId]);
|
||||
$issue['comments'] = $cmStmt->fetchAll();
|
||||
|
||||
jsonResponse($issue);
|
||||
}
|
||||
|
||||
// 6. POST /api/issues/{id}/upvote
|
||||
if (preg_match('#^/api/issues/(\d+)/upvote$#', $requestUri, $matches) && $method === 'POST') {
|
||||
$issueId = (int)$matches[1];
|
||||
$stmt = $pdo->prepare("UPDATE issues SET upvotes = upvotes + 1 WHERE id = ?");
|
||||
$stmt->execute([$issueId]);
|
||||
|
||||
if ($stmt->rowCount() === 0) {
|
||||
jsonResponse(['error' => 'گزارش یافت نشد.'], 404);
|
||||
}
|
||||
|
||||
$fetch = $pdo->prepare("SELECT upvotes FROM issues WHERE id = ?");
|
||||
$fetch->execute([$issueId]);
|
||||
$newUpvotes = (int)$fetch->fetchColumn();
|
||||
|
||||
jsonResponse(['id' => $issueId, 'upvotes' => $newUpvotes]);
|
||||
}
|
||||
|
||||
// 7. POST /api/issues/{id}/comment
|
||||
if (preg_match('#^/api/issues/(\d+)/comment$#', $requestUri, $matches) && $method === 'POST') {
|
||||
$issueId = (int)$matches[1];
|
||||
$data = getJsonInput();
|
||||
|
||||
if (empty($data['content'])) {
|
||||
jsonResponse(['error' => 'متن نظر نمیتواند خالی باشد.'], 400);
|
||||
}
|
||||
|
||||
$check = $pdo->prepare("SELECT id FROM issues WHERE id = ?");
|
||||
$check->execute([$issueId]);
|
||||
if (!$check->fetch()) {
|
||||
jsonResponse(['error' => 'گزارش یافت نشد.'], 404);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i');
|
||||
$author = !empty($data['author_name']) ? trim($data['author_name']) : 'شهروند محترم';
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO comments (issue_id, author_name, content, created_at) VALUES (?, ?, ?, ?)");
|
||||
$stmt->execute([$issueId, $author, trim($data['content']), $now]);
|
||||
$commentId = $pdo->lastInsertId();
|
||||
|
||||
$cm = $pdo->prepare("SELECT * FROM comments WHERE id = ?");
|
||||
$cm->execute([$commentId]);
|
||||
jsonResponse($cm->fetch(), 201);
|
||||
}
|
||||
|
||||
// 8. PATCH / POST /api/issues/{id}/status (Admin Status Update)
|
||||
if (preg_match('#^/api/issues/(\d+)/status$#', $requestUri, $matches) && in_array($method, ['PATCH', 'POST'])) {
|
||||
$issueId = (int)$matches[1];
|
||||
$data = getJsonInput();
|
||||
|
||||
$check = $pdo->prepare("SELECT * FROM issues WHERE id = ?");
|
||||
$check->execute([$issueId]);
|
||||
$current = $check->fetch();
|
||||
if (!$current) {
|
||||
jsonResponse(['error' => 'گزارش یافت نشد.'], 404);
|
||||
}
|
||||
|
||||
$newStatus = $data['status'] ?? $current['status'];
|
||||
$officialResponse = $data['official_response'] ?? $current['official_response'];
|
||||
$resolvedImageUrl = $data['resolved_image_url'] ?? $current['resolved_image_url'];
|
||||
|
||||
$now = date('Y-m-d H:i');
|
||||
$resolvedAt = ($newStatus === 'resolved') ? $now : $current['resolved_at'];
|
||||
|
||||
$upd = $pdo->prepare("UPDATE issues SET status = ?, official_response = ?, resolved_image_url = ?, resolved_at = ? WHERE id = ?");
|
||||
$upd->execute([$newStatus, $officialResponse, $resolvedImageUrl, $resolvedAt, $issueId]);
|
||||
|
||||
$statusTitles = [
|
||||
'pending' => 'در صف بررسی مجدد',
|
||||
'reviewing' => 'بررسی کارشناسی و ارجاع به معاونت مربوطه',
|
||||
'in_progress' => 'اعزام اکیپ اجرایی و آغاز عملیات میدانی',
|
||||
'resolved' => 'اتمام عملیات و رفع کامل مسئله',
|
||||
'rejected' => 'عدم احراز یا خارج از حیطه اختیارات شهرداری'
|
||||
];
|
||||
|
||||
$tlTitle = !empty($data['timeline_title']) ? $data['timeline_title'] : ($statusTitles[$newStatus] ?? "تغییر وضعیت به {$newStatus}");
|
||||
$tlDesc = !empty($data['timeline_desc']) ? $data['timeline_desc'] : ($officialResponse ?: 'وضعیت پرونده توسط مدیریت سامانه بهروزرسانی شد.');
|
||||
|
||||
$tlStmt = $pdo->prepare("INSERT INTO timeline (issue_id, status, title, description, created_at) VALUES (?, ?, ?, ?, ?)");
|
||||
$tlStmt->execute([$issueId, $newStatus, $tlTitle, $tlDesc, $now]);
|
||||
|
||||
$updated = $pdo->prepare("SELECT * FROM issues WHERE id = ?");
|
||||
$updated->execute([$issueId]);
|
||||
jsonResponse($updated->fetch());
|
||||
}
|
||||
|
||||
// 9. POST /api/upload
|
||||
if ($requestUri === '/api/upload' && $method === 'POST') {
|
||||
if (!isset($_FILES['file'])) {
|
||||
jsonResponse(['error' => 'فایلی ارسال نشده است.'], 400);
|
||||
}
|
||||
|
||||
$uploadDir = __DIR__ . '/static/uploads/';
|
||||
if (!is_dir($uploadDir)) {
|
||||
mkdir($uploadDir, 0777, true);
|
||||
}
|
||||
|
||||
$ext = pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION);
|
||||
$filename = uniqid('img_', true) . '.' . $ext;
|
||||
$target = $uploadDir . $filename;
|
||||
|
||||
if (move_uploaded_file($_FILES['file']['tmp_name'], $target)) {
|
||||
jsonResponse(['url' => '/static/uploads/' . $filename]);
|
||||
} else {
|
||||
jsonResponse(['error' => 'خطا در ذخیرهسازی فایل.'], 500);
|
||||
return round($diff * 10000, 1); // Standard forex 4/5 digits
|
||||
}
|
||||
}
|
||||
|
||||
// Default 404 for API
|
||||
jsonResponse(['error' => 'مسیر مورد نظر در API یافت نشد.'], 404);
|
||||
try {
|
||||
// -------------------------------------------------------------
|
||||
// 1. ACCOUNTS ENDPOINTS
|
||||
// -------------------------------------------------------------
|
||||
if (preg_match('#^/api/accounts/?$#', $uri)) {
|
||||
if ($method === 'GET') {
|
||||
$stmt = $pdo->query("
|
||||
SELECT a.*,
|
||||
(SELECT COUNT(*) FROM trades t WHERE t.account_id = a.id) as total_trades,
|
||||
(SELECT COALESCE(SUM(profit + commission + swap), 0) FROM trades t WHERE t.account_id = a.id AND t.status = 'closed') as net_closed_profit,
|
||||
(SELECT COALESCE(SUM(profit + commission + swap), 0) FROM trades t WHERE t.account_id = a.id AND t.status = 'open') as net_open_profit
|
||||
FROM accounts a ORDER BY a.is_default DESC, a.id ASC
|
||||
");
|
||||
$accounts = $stmt->fetchAll();
|
||||
sendJson(['success' => true, 'accounts' => $accounts]);
|
||||
} elseif ($method === 'POST') {
|
||||
$input = getJsonInput();
|
||||
$name = trim($input['name'] ?? 'حساب معاملاتی جدید');
|
||||
$broker = trim($input['broker'] ?? 'IC Markets');
|
||||
$accNum = trim($input['account_number'] ?? '');
|
||||
$currency = trim($input['currency'] ?? 'USD');
|
||||
$initBal = (float)($input['initial_balance'] ?? 10000.0);
|
||||
$leverage = (float)($input['leverage'] ?? 100.0);
|
||||
$accType = trim($input['account_type'] ?? 'Real');
|
||||
$isDefault = !empty($input['is_default']) ? 1 : 0;
|
||||
|
||||
if ($isDefault) {
|
||||
$pdo->exec("UPDATE accounts SET is_default = 0");
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO accounts (name, broker, account_number, currency, initial_balance, leverage, account_type, is_default)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$stmt->execute([$name, $broker, $accNum, $currency, $initBal, $leverage, $accType, $isDefault]);
|
||||
$newId = (int)$pdo->lastInsertId();
|
||||
|
||||
// Optionally generate sample data
|
||||
if (!empty($input['with_sample_data'])) {
|
||||
Database::generateSampleTrades($newId, $initBal);
|
||||
}
|
||||
|
||||
sendJson(['success' => true, 'id' => $newId, 'message' => 'حساب معاملاتی با موفقیت ایجاد شد']);
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('#^/api/accounts/(\d+)$#', $uri, $matches)) {
|
||||
$accId = (int)$matches[1];
|
||||
|
||||
if ($method === 'GET') {
|
||||
$stmt = $pdo->prepare("SELECT * FROM accounts WHERE id = ?");
|
||||
$stmt->execute([$accId]);
|
||||
$acc = $stmt->fetch();
|
||||
if (!$acc) sendJson(['success' => false, 'error' => 'حساب یافت نشد'], 404);
|
||||
sendJson(['success' => true, 'account' => $acc]);
|
||||
} elseif ($method === 'PUT') {
|
||||
$input = getJsonInput();
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE accounts SET
|
||||
name = COALESCE(?, name),
|
||||
broker = COALESCE(?, broker),
|
||||
account_number = COALESCE(?, account_number),
|
||||
currency = COALESCE(?, currency),
|
||||
initial_balance = COALESCE(?, initial_balance),
|
||||
leverage = COALESCE(?, leverage),
|
||||
account_type = COALESCE(?, account_type),
|
||||
is_default = COALESCE(?, is_default),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
");
|
||||
$stmt->execute([
|
||||
$input['name'] ?? null,
|
||||
$input['broker'] ?? null,
|
||||
$input['account_number'] ?? null,
|
||||
$input['currency'] ?? null,
|
||||
isset($input['initial_balance']) ? (float)$input['initial_balance'] : null,
|
||||
isset($input['leverage']) ? (float)$input['leverage'] : null,
|
||||
$input['account_type'] ?? null,
|
||||
isset($input['is_default']) ? (int)$input['is_default'] : null,
|
||||
$accId
|
||||
]);
|
||||
sendJson(['success' => true, 'message' => 'حساب بهروزرسانی شد']);
|
||||
} elseif ($method === 'DELETE') {
|
||||
$stmt = $pdo->prepare("DELETE FROM accounts WHERE id = ?");
|
||||
$stmt->execute([$accId]);
|
||||
sendJson(['success' => true, 'message' => 'حساب و معاملات آن حذف شدند']);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 2. ANALYTICS ENDPOINT (Myfxbook Style)
|
||||
// -------------------------------------------------------------
|
||||
if (preg_match('#^/api/analytics/?$#', $uri)) {
|
||||
if ($method === 'GET') {
|
||||
$accountId = (int)($_GET['account_id'] ?? 0);
|
||||
if ($accountId <= 0) {
|
||||
// Get default account
|
||||
$stmtDef = $pdo->query("SELECT id FROM accounts ORDER BY is_default DESC, id ASC LIMIT 1");
|
||||
$accountId = (int)($stmtDef->fetchColumn() ?: 1);
|
||||
}
|
||||
|
||||
$from = $_GET['from'] ?? null;
|
||||
$to = $_GET['to'] ?? null;
|
||||
$symbol = $_GET['symbol'] ?? null;
|
||||
$strategy = $_GET['strategy'] ?? null;
|
||||
|
||||
$data = $analyticsEngine->getFullAnalytics($accountId, $from, $to, $symbol, $strategy);
|
||||
sendJson(['success' => true, 'data' => $data]);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 3. TRADES CRUD ENDPOINTS
|
||||
// -------------------------------------------------------------
|
||||
if (preg_match('#^/api/trades/?$#', $uri)) {
|
||||
if ($method === 'GET') {
|
||||
$accountId = (int)($_GET['account_id'] ?? 0);
|
||||
if ($accountId <= 0) {
|
||||
$stmtDef = $pdo->query("SELECT id FROM accounts ORDER BY is_default DESC, id ASC LIMIT 1");
|
||||
$accountId = (int)($stmtDef->fetchColumn() ?: 1);
|
||||
}
|
||||
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
$limit = max(5, min(100, (int)($_GET['limit'] ?? 50)));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$status = $_GET['status'] ?? 'all';
|
||||
$symbol = $_GET['symbol'] ?? 'all';
|
||||
$strategy = $_GET['strategy'] ?? 'all';
|
||||
$search = trim($_GET['search'] ?? '');
|
||||
$sortBy = $_GET['sort_by'] ?? 'open_time';
|
||||
$sortDir = strtolower($_GET['sort_dir'] ?? 'desc') === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
$allowedSorts = ['open_time', 'close_time', 'profit', 'pips', 'lot_size', 'symbol', 'id'];
|
||||
if (!in_array($sortBy, $allowedSorts)) $sortBy = 'open_time';
|
||||
|
||||
$where = ["account_id = ?"];
|
||||
$params = [$accountId];
|
||||
|
||||
if ($status !== 'all') {
|
||||
$where[] = "status = ?";
|
||||
$params[] = $status;
|
||||
}
|
||||
if ($symbol !== 'all' && !empty($symbol)) {
|
||||
$where[] = "symbol = ?";
|
||||
$params[] = $symbol;
|
||||
}
|
||||
if ($strategy !== 'all' && !empty($strategy)) {
|
||||
$where[] = "strategy = ?";
|
||||
$params[] = $strategy;
|
||||
}
|
||||
if (!empty($search)) {
|
||||
$where[] = "(symbol LIKE ? OR ticket LIKE ? OR entry_notes LIKE ? OR exit_notes LIKE ? OR tags LIKE ?)";
|
||||
$sParam = "%$search%";
|
||||
$params[] = $sParam;
|
||||
$params[] = $sParam;
|
||||
$params[] = $sParam;
|
||||
$params[] = $sParam;
|
||||
$params[] = $sParam;
|
||||
}
|
||||
|
||||
$whereClause = implode(" AND ", $where);
|
||||
|
||||
// Total count
|
||||
$stmtCount = $pdo->prepare("SELECT COUNT(*) FROM trades WHERE $whereClause");
|
||||
$stmtCount->execute($params);
|
||||
$totalCount = (int)$stmtCount->fetchColumn();
|
||||
|
||||
// Fetch records
|
||||
$sql = "SELECT * FROM trades WHERE $whereClause ORDER BY $sortBy $sortDir LIMIT $limit OFFSET $offset";
|
||||
$stmtList = $pdo->prepare($sql);
|
||||
$stmtList->execute($params);
|
||||
$trades = $stmtList->fetchAll();
|
||||
|
||||
sendJson([
|
||||
'success' => true,
|
||||
'trades' => $trades,
|
||||
'pagination' => [
|
||||
'total' => $totalCount,
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'total_pages' => ceil($totalCount / $limit)
|
||||
]
|
||||
]);
|
||||
} elseif ($method === 'POST') {
|
||||
$input = getJsonInput();
|
||||
$accountId = (int)($input['account_id'] ?? 1);
|
||||
$symbol = strtoupper(trim($input['symbol'] ?? 'EURUSD'));
|
||||
$tradeType = strtolower(trim($input['trade_type'] ?? 'buy'));
|
||||
$lotSize = (float)($input['lot_size'] ?? 0.1);
|
||||
$openPrice = (float)($input['open_price'] ?? 0.0);
|
||||
$closePrice = isset($input['close_price']) && $input['close_price'] !== '' ? (float)$input['close_price'] : null;
|
||||
$stopLoss = isset($input['stop_loss']) && $input['stop_loss'] !== '' ? (float)$input['stop_loss'] : null;
|
||||
$takeProfit = isset($input['take_profit']) && $input['take_profit'] !== '' ? (float)$input['take_profit'] : null;
|
||||
$openTime = !empty($input['open_time']) ? date('Y-m-d H:i:s', strtotime($input['open_time'])) : date('Y-m-d H:i:s');
|
||||
$closeTime = !empty($input['close_time']) ? date('Y-m-d H:i:s', strtotime($input['close_time'])) : null;
|
||||
$status = trim($input['status'] ?? ($closePrice !== null ? 'closed' : 'open'));
|
||||
$strategy = trim($input['strategy'] ?? 'Price Action');
|
||||
$timeframe = trim($input['timeframe'] ?? 'M15');
|
||||
$session = trim($input['session'] ?? 'London');
|
||||
$emotion = trim($input['emotion'] ?? 'Disciplined');
|
||||
$comm = (float)($input['commission'] ?? 0.0);
|
||||
$swap = (float)($input['swap'] ?? 0.0);
|
||||
$profit = isset($input['profit']) ? (float)$input['profit'] : 0.0;
|
||||
$pips = isset($input['pips']) ? (float)$input['pips'] : 0.0;
|
||||
$rr = isset($input['risk_reward']) ? (float)$input['risk_reward'] : null;
|
||||
|
||||
// Auto calculate pips if not provided and closed
|
||||
if ($closePrice !== null && empty($input['pips'])) {
|
||||
$pips = calculatePips($symbol, $tradeType, $openPrice, $closePrice);
|
||||
}
|
||||
|
||||
// Auto calculate profit if not provided and closed
|
||||
if ($closePrice !== null && !isset($input['profit'])) {
|
||||
// Rough standard lot pip value estimation ($10 per lot per pip for EURUSD)
|
||||
$pipVal = 10.0 * $lotSize;
|
||||
$profit = round($pips * $pipVal, 2);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO trades (
|
||||
account_id, ticket, symbol, trade_type, lot_size,
|
||||
open_price, close_price, stop_loss, take_profit,
|
||||
open_time, close_time, pips, profit, commission, swap,
|
||||
status, strategy, timeframe, session, risk_reward, emotion,
|
||||
entry_notes, exit_notes, lessons, screenshot_entry, screenshot_exit, tags
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$stmt->execute([
|
||||
$accountId,
|
||||
trim($input['ticket'] ?? ''),
|
||||
$symbol,
|
||||
$tradeType,
|
||||
$lotSize,
|
||||
$openPrice,
|
||||
$closePrice,
|
||||
$stopLoss,
|
||||
$takeProfit,
|
||||
$openTime,
|
||||
$closeTime,
|
||||
$pips,
|
||||
$profit,
|
||||
$comm,
|
||||
$swap,
|
||||
$status,
|
||||
$strategy,
|
||||
$timeframe,
|
||||
$session,
|
||||
$rr,
|
||||
$emotion,
|
||||
trim($input['entry_notes'] ?? ''),
|
||||
trim($input['exit_notes'] ?? ''),
|
||||
trim($input['lessons'] ?? ''),
|
||||
trim($input['screenshot_entry'] ?? ''),
|
||||
trim($input['screenshot_exit'] ?? ''),
|
||||
trim($input['tags'] ?? '')
|
||||
]);
|
||||
|
||||
$tradeId = (int)$pdo->lastInsertId();
|
||||
sendJson(['success' => true, 'id' => $tradeId, 'message' => 'معامله با موفقیت ثبت شد']);
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('#^/api/trades/(\d+)$#', $uri, $matches)) {
|
||||
$tradeId = (int)$matches[1];
|
||||
|
||||
if ($method === 'GET') {
|
||||
$stmt = $pdo->prepare("SELECT * FROM trades WHERE id = ?");
|
||||
$stmt->execute([$tradeId]);
|
||||
$trade = $stmt->fetch();
|
||||
if (!$trade) sendJson(['success' => false, 'error' => 'معامله یافت نشد'], 404);
|
||||
sendJson(['success' => true, 'trade' => $trade]);
|
||||
} elseif ($method === 'PUT') {
|
||||
$input = getJsonInput();
|
||||
$stmtGet = $pdo->prepare("SELECT * FROM trades WHERE id = ?");
|
||||
$stmtGet->execute([$tradeId]);
|
||||
$curr = $stmtGet->fetch();
|
||||
if (!$curr) sendJson(['success' => false, 'error' => 'معامله یافت نشد'], 404);
|
||||
|
||||
$symbol = strtoupper(trim($input['symbol'] ?? $curr['symbol']));
|
||||
$tradeType = strtolower(trim($input['trade_type'] ?? $curr['trade_type']));
|
||||
$openPrice = isset($input['open_price']) ? (float)$input['open_price'] : (float)$curr['open_price'];
|
||||
$closePrice = array_key_exists('close_price', $input) ? ($input['close_price'] !== null && $input['close_price'] !== '' ? (float)$input['close_price'] : null) : $curr['close_price'];
|
||||
|
||||
$pips = isset($input['pips']) ? (float)$input['pips'] : $curr['pips'];
|
||||
$profit = isset($input['profit']) ? (float)$input['profit'] : $curr['profit'];
|
||||
|
||||
if ($closePrice !== null && (!isset($input['pips']) || $input['pips'] === '')) {
|
||||
$pips = calculatePips($symbol, $tradeType, $openPrice, (float)$closePrice);
|
||||
}
|
||||
|
||||
$status = $input['status'] ?? ($closePrice !== null ? 'closed' : $curr['status']);
|
||||
$closeTime = array_key_exists('close_time', $input) ? (!empty($input['close_time']) ? date('Y-m-d H:i:s', strtotime($input['close_time'])) : null) : $curr['close_time'];
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE trades SET
|
||||
ticket = COALESCE(?, ticket),
|
||||
symbol = ?,
|
||||
trade_type = ?,
|
||||
lot_size = COALESCE(?, lot_size),
|
||||
open_price = ?,
|
||||
close_price = ?,
|
||||
stop_loss = ?,
|
||||
take_profit = ?,
|
||||
open_time = COALESCE(?, open_time),
|
||||
close_time = ?,
|
||||
pips = ?,
|
||||
profit = ?,
|
||||
commission = COALESCE(?, commission),
|
||||
swap = COALESCE(?, swap),
|
||||
status = ?,
|
||||
strategy = COALESCE(?, strategy),
|
||||
timeframe = COALESCE(?, timeframe),
|
||||
session = COALESCE(?, session),
|
||||
risk_reward = ?,
|
||||
emotion = COALESCE(?, emotion),
|
||||
entry_notes = COALESCE(?, entry_notes),
|
||||
exit_notes = COALESCE(?, exit_notes),
|
||||
lessons = COALESCE(?, lessons),
|
||||
screenshot_entry = COALESCE(?, screenshot_entry),
|
||||
screenshot_exit = COALESCE(?, screenshot_exit),
|
||||
tags = COALESCE(?, tags),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
");
|
||||
$stmt->execute([
|
||||
$input['ticket'] ?? null,
|
||||
$symbol,
|
||||
$tradeType,
|
||||
isset($input['lot_size']) ? (float)$input['lot_size'] : null,
|
||||
$openPrice,
|
||||
$closePrice,
|
||||
isset($input['stop_loss']) && $input['stop_loss'] !== '' ? (float)$input['stop_loss'] : null,
|
||||
isset($input['take_profit']) && $input['take_profit'] !== '' ? (float)$input['take_profit'] : null,
|
||||
!empty($input['open_time']) ? date('Y-m-d H:i:s', strtotime($input['open_time'])) : null,
|
||||
$closeTime,
|
||||
$pips,
|
||||
$profit,
|
||||
isset($input['commission']) ? (float)$input['commission'] : null,
|
||||
isset($input['swap']) ? (float)$input['swap'] : null,
|
||||
$status,
|
||||
$input['strategy'] ?? null,
|
||||
$input['timeframe'] ?? null,
|
||||
$input['session'] ?? null,
|
||||
isset($input['risk_reward']) ? (float)$input['risk_reward'] : null,
|
||||
$input['emotion'] ?? null,
|
||||
$input['entry_notes'] ?? null,
|
||||
$input['exit_notes'] ?? null,
|
||||
$input['lessons'] ?? null,
|
||||
$input['screenshot_entry'] ?? null,
|
||||
$input['screenshot_exit'] ?? null,
|
||||
$input['tags'] ?? null,
|
||||
$tradeId
|
||||
]);
|
||||
sendJson(['success' => true, 'message' => 'معامله با موفقیت ویرایش شد']);
|
||||
} elseif ($method === 'DELETE') {
|
||||
$stmt = $pdo->prepare("DELETE FROM trades WHERE id = ?");
|
||||
$stmt->execute([$tradeId]);
|
||||
sendJson(['success' => true, 'message' => 'معامله حذف شد']);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 4. MT4 / MT5 STATEMENT IMPORTER (CSV & HTML)
|
||||
// -------------------------------------------------------------
|
||||
if (preg_match('#^/api/trades/import/?$#', $uri) && $method === 'POST') {
|
||||
$accountId = (int)($_POST['account_id'] ?? 1);
|
||||
|
||||
if (!isset($_FILES['statement']) || $_FILES['statement']['error'] !== UPLOAD_ERR_OK) {
|
||||
sendJson(['success' => false, 'error' => 'فایل گزارش انتخاب نشده یا آپلود ناموفق بود'], 400);
|
||||
}
|
||||
|
||||
$fileTmp = $_FILES['statement']['tmp_name'];
|
||||
$fileName = $_FILES['statement']['name'];
|
||||
$content = file_get_contents($fileTmp);
|
||||
$importedCount = 0;
|
||||
|
||||
// Check if file is CSV
|
||||
if (str_ends_with(strtolower($fileName), '.csv') || strpos($content, ',') !== false && strpos($content, '<html') === false) {
|
||||
$lines = explode("\n", $content);
|
||||
$header = [];
|
||||
foreach ($lines as $lineNum => $line) {
|
||||
$line = trim($line);
|
||||
if (empty($line)) continue;
|
||||
$row = str_getcsv($line);
|
||||
|
||||
if ($lineNum === 0 || empty($header)) {
|
||||
$header = array_map('strtolower', array_map('trim', $row));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (count($row) < 5) continue;
|
||||
$rowAssoc = [];
|
||||
foreach ($header as $idx => $colName) {
|
||||
$rowAssoc[$colName] = $row[$idx] ?? '';
|
||||
}
|
||||
|
||||
$ticket = $rowAssoc['ticket'] ?? $rowAssoc['order'] ?? (string)rand(1000000, 9999999);
|
||||
$symbol = strtoupper($rowAssoc['symbol'] ?? $rowAssoc['item'] ?? 'EURUSD');
|
||||
$type = strtolower($rowAssoc['type'] ?? $rowAssoc['cmd'] ?? 'buy');
|
||||
if (!in_array($type, ['buy', 'sell'])) continue;
|
||||
|
||||
$size = (float)($rowAssoc['size'] ?? $rowAssoc['volume'] ?? $rowAssoc['lots'] ?? 0.1);
|
||||
$openPrice = (float)($rowAssoc['open price'] ?? $rowAssoc['price'] ?? $rowAssoc['open_price'] ?? 0.0);
|
||||
$closePrice = (float)($rowAssoc['close price'] ?? $rowAssoc['close_price'] ?? 0.0);
|
||||
$profit = (float)($rowAssoc['profit'] ?? $rowAssoc['p/l'] ?? 0.0);
|
||||
$comm = (float)($rowAssoc['commission'] ?? $rowAssoc['taxes'] ?? 0.0);
|
||||
$swap = (float)($rowAssoc['swap'] ?? 0.0);
|
||||
$openTime = $rowAssoc['open time'] ?? $rowAssoc['time'] ?? date('Y-m-d H:i:s');
|
||||
$closeTime = $rowAssoc['close time'] ?? $rowAssoc['time'] ?? date('Y-m-d H:i:s');
|
||||
|
||||
$pips = calculatePips($symbol, $type, $openPrice, $closePrice);
|
||||
|
||||
$stmtIns = $pdo->prepare("
|
||||
INSERT INTO trades (
|
||||
account_id, ticket, symbol, trade_type, lot_size,
|
||||
open_price, close_price, open_time, close_time, pips, profit, commission, swap, status, strategy, emotion
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'closed', 'Imported MT4/MT5', 'Disciplined')
|
||||
");
|
||||
$stmtIns->execute([$accountId, $ticket, $symbol, $type, $size, $openPrice, $closePrice, $openTime, $closeTime, $pips, $profit, $comm, $swap]);
|
||||
$importedCount++;
|
||||
}
|
||||
} else {
|
||||
// HTML Statement Parser (MT4 / MT5 Standard HTML Reports)
|
||||
preg_match_all('#<tr[^>]*>(.*?)</tr>#is', $content, $trMatches);
|
||||
foreach ($trMatches[1] as $trHtml) {
|
||||
preg_match_all('#<td[^>]*>(.*?)</td>#is', $trHtml, $tdMatches);
|
||||
$tds = array_map(fn($t) => trim(strip_tags($t)), $tdMatches[1] ?? []);
|
||||
if (count($tds) >= 10 && (strtolower($tds[2] ?? '') === 'buy' || strtolower($tds[2] ?? '') === 'sell')) {
|
||||
$ticket = $tds[0];
|
||||
$openTime = date('Y-m-d H:i:s', strtotime($tds[1]));
|
||||
$type = strtolower($tds[2]);
|
||||
$size = (float)$tds[3];
|
||||
$symbol = strtoupper($tds[4]);
|
||||
$openPrice = (float)$tds[5];
|
||||
$sl = (float)($tds[6] ?? 0);
|
||||
$tp = (float)($tds[7] ?? 0);
|
||||
$closeTime = isset($tds[8]) ? date('Y-m-d H:i:s', strtotime($tds[8])) : $openTime;
|
||||
$closePrice = (float)($tds[9] ?? $openPrice);
|
||||
$comm = (float)($tds[10] ?? 0);
|
||||
$swap = (float)($tds[11] ?? 0);
|
||||
$profit = (float)($tds[12] ?? 0);
|
||||
|
||||
$pips = calculatePips($symbol, $type, $openPrice, $closePrice);
|
||||
|
||||
$stmtIns = $pdo->prepare("
|
||||
INSERT INTO trades (
|
||||
account_id, ticket, symbol, trade_type, lot_size,
|
||||
open_price, close_price, stop_loss, take_profit,
|
||||
open_time, close_time, pips, profit, commission, swap, status, strategy, emotion
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'closed', 'MT4/MT5 Statement', 'Disciplined')
|
||||
");
|
||||
$stmtIns->execute([$accountId, $ticket, $symbol, $type, $size, $openPrice, $closePrice, $sl, $tp, $openTime, $closeTime, $pips, $profit, $comm, $swap]);
|
||||
$importedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendJson(['success' => true, 'imported_count' => $importedCount, 'message' => "تعداد $importedCount معامله با موفقیت ایمپورت شد"]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 5. EXPORT TRADES (CSV)
|
||||
// -------------------------------------------------------------
|
||||
if (preg_match('#^/api/trades/export/?$#', $uri) && $method === 'GET') {
|
||||
$accountId = (int)($_GET['account_id'] ?? 1);
|
||||
$stmt = $pdo->prepare("SELECT * FROM trades WHERE account_id = ? ORDER BY open_time ASC");
|
||||
$stmt->execute([$accountId]);
|
||||
$trades = $stmt->fetchAll();
|
||||
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="forex_trades_' . date('Ymd_His') . '.csv"');
|
||||
|
||||
$out = fopen('php://output', 'w');
|
||||
// UTF-8 BOM for Excel
|
||||
fputs($out, "\xEF\xBB\xBF");
|
||||
fputcsv($out, ['ID', 'Ticket', 'Symbol', 'Type', 'Volume', 'Open Price', 'Close Price', 'S/L', 'T/P', 'Open Time', 'Close Time', 'Pips', 'Profit ($)', 'Commission', 'Swap', 'Status', 'Strategy', 'Timeframe', 'Session', 'Emotion', 'Notes']);
|
||||
|
||||
foreach ($trades as $t) {
|
||||
fputcsv($out, [
|
||||
$t['id'],
|
||||
$t['ticket'],
|
||||
$t['symbol'],
|
||||
$t['trade_type'],
|
||||
$t['lot_size'],
|
||||
$t['open_price'],
|
||||
$t['close_price'],
|
||||
$t['stop_loss'],
|
||||
$t['take_profit'],
|
||||
$t['open_time'],
|
||||
$t['close_time'],
|
||||
$t['pips'],
|
||||
$t['profit'],
|
||||
$t['commission'],
|
||||
$t['swap'],
|
||||
$t['status'],
|
||||
$t['strategy'],
|
||||
$t['timeframe'],
|
||||
$t['session'],
|
||||
$t['emotion'],
|
||||
$t['entry_notes']
|
||||
]);
|
||||
}
|
||||
fclose($out);
|
||||
exit;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 6. SCREENSHOT IMAGE UPLOAD
|
||||
// -------------------------------------------------------------
|
||||
if (preg_match('#^/api/upload/?$#', $uri) && $method === 'POST') {
|
||||
if (!isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) {
|
||||
sendJson(['success' => false, 'error' => 'تصویری ارسال نشد یا خطایی رخ داد'], 400);
|
||||
}
|
||||
|
||||
$file = $_FILES['image'];
|
||||
$allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
||||
if (!in_array($file['type'], $allowed)) {
|
||||
sendJson(['success' => false, 'error' => 'فرمت تصویر نامعتبر است (تنها JPG, PNG, WEBP مجاز است)'], 400);
|
||||
}
|
||||
|
||||
$uploadDir = __DIR__ . '/uploads';
|
||||
if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true);
|
||||
|
||||
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
|
||||
$newFilename = 'chart_' . uniqid() . '.' . $ext;
|
||||
$dest = $uploadDir . '/' . $newFilename;
|
||||
|
||||
if (move_uploaded_file($file['tmp_name'], $dest)) {
|
||||
sendJson(['success' => true, 'url' => '/uploads/' . $newFilename]);
|
||||
} else {
|
||||
sendJson(['success' => false, 'error' => 'ذخیره تصویر با خطا مواجه شد'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 7. STRATEGIES & DAILY LOGS
|
||||
// -------------------------------------------------------------
|
||||
if (preg_match('#^/api/strategies/?$#', $uri)) {
|
||||
if ($method === 'GET') {
|
||||
$stmt = $pdo->query("SELECT * FROM strategies ORDER BY name ASC");
|
||||
sendJson(['success' => true, 'strategies' => $stmt->fetchAll()]);
|
||||
} elseif ($method === 'POST') {
|
||||
$input = getJsonInput();
|
||||
$name = trim($input['name'] ?? '');
|
||||
if (empty($name)) sendJson(['success' => false, 'error' => 'نام استراتژی الزامی است'], 400);
|
||||
$stmt = $pdo->prepare("INSERT OR IGNORE INTO strategies (name, description, color) VALUES (?, ?, ?)");
|
||||
$stmt->execute([$name, $input['description'] ?? '', $input['color'] ?? '#3b82f6']);
|
||||
sendJson(['success' => true, 'message' => 'استراتژی ثبت شد']);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 8. RESET & SEED SAMPLE DATA
|
||||
// -------------------------------------------------------------
|
||||
if (preg_match('#^/api/reset_sample/?$#', $uri) && $method === 'POST') {
|
||||
$accountId = (int)($_GET['account_id'] ?? 1);
|
||||
$pdo->exec("DELETE FROM trades WHERE account_id = $accountId");
|
||||
Database::generateSampleTrades($accountId, 10000.0);
|
||||
sendJson(['success' => true, 'message' => 'دادههای نمونه مایافایکسبوک با موفقیت بازیابی شدند']);
|
||||
}
|
||||
|
||||
sendJson(['success' => false, 'error' => 'مسیر مورد نظر یافت نشد (API Not Found)'], 404);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
sendJson(['success' => false, 'error' => 'Server Error: ' . $e->getMessage()], 500);
|
||||
}
|
||||
|
||||
-280
@@ -1,280 +0,0 @@
|
||||
import sqlite3
|
||||
import os
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), "urban_issues.db")
|
||||
|
||||
def get_db_connection():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def init_db():
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Issues Table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS issues (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tracking_code TEXT UNIQUE NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
district INTEGER NOT NULL,
|
||||
priority TEXT NOT NULL, -- 'low', 'medium', 'high', 'emergency'
|
||||
status TEXT NOT NULL, -- 'pending', 'reviewing', 'in_progress', 'resolved', 'rejected'
|
||||
lat REAL NOT NULL,
|
||||
lng REAL NOT NULL,
|
||||
image_url TEXT,
|
||||
resolved_image_url TEXT,
|
||||
reporter_name TEXT,
|
||||
reporter_phone TEXT,
|
||||
official_response TEXT,
|
||||
upvotes INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
resolved_at TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Comments Table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
issue_id INTEGER NOT NULL,
|
||||
author_name TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# Timeline Table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS timeline (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
issue_id INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# Check if empty, then seed initial realistic data
|
||||
cursor.execute("SELECT COUNT(*) FROM issues")
|
||||
count = cursor.fetchone()[0]
|
||||
|
||||
if count == 0:
|
||||
seed_data(cursor)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def seed_data(cursor):
|
||||
now = datetime.now()
|
||||
|
||||
sample_issues = [
|
||||
{
|
||||
"tracking_code": "SHR-78214",
|
||||
"title": "فرونشست و چاله عمیق در لاین کندرو",
|
||||
"category": "آسفالت و معابر",
|
||||
"description": "وجود چاله عمیق با قطر حدود ۱ متر پس از بارندگی اخیر که باعث خسارت به لاستیک خودروها و ترمزهای ناگهانی خطرناک میشود.",
|
||||
"address": "بزرگراه شهید همت، نرسیده به خروجی گاندی، لاین کندرو",
|
||||
"district": 3,
|
||||
"priority": "emergency",
|
||||
"status": "in_progress",
|
||||
"lat": 35.7538,
|
||||
"lng": 51.4172,
|
||||
"image_url": "https://images.unsplash.com/photo-1515162816999-a0c47dc192f7?auto=format&fit=crop&w=800&q=80",
|
||||
"resolved_image_url": None,
|
||||
"reporter_name": "علی رضایی",
|
||||
"reporter_phone": "09121112233",
|
||||
"official_response": "اکیپ آسفالتریزی ناحیه ۲ شهرداری منطقه ۳ اعزام گردیده و عملیات زیرسازی و لکهگیری در حال انجام است.",
|
||||
"upvotes": 42,
|
||||
"days_ago": 2,
|
||||
"resolved_at": None,
|
||||
"timeline": [
|
||||
("pending", "ثبت گزارش توسط شهروند", "گزارش در سامانه ۱۳۷ با موفقیت ثبت شد."),
|
||||
("reviewing", "تایید کارشناس و ارجاع به معاونت فنی و عمران", "موضوع به ناحیه ۲ ارجاع داده شد."),
|
||||
("in_progress", "اعزام اکیپ عملیاتی لکهگیری", "تیم فنی با تجهیزات در محل مستقر شد.")
|
||||
],
|
||||
"comments": [
|
||||
("سارا محمدی", "دیروز لاستیک ماشین من هم اینجا آسیب دید، ممنون که پیگیری میکنید."),
|
||||
("حسین کاظمی", "امیدوارم امشب تا قبل از ترافیک صبحگاهی تموم بشه.")
|
||||
]
|
||||
},
|
||||
{
|
||||
"tracking_code": "SHR-65109",
|
||||
"title": "خاموشی کامل پایه چراغهای روشنایی بوستان",
|
||||
"category": "روشنایی و برق",
|
||||
"description": "کل مسیر پیادهروی شرقی بوستان ملت در تاریکی مطلق است که باعث کاهش امنیت خانوادهها و ورزشکاران شبانه شده است.",
|
||||
"address": "خیابان ولیعصر، بوستان ملت، ضلع شرقی جنب دریاچه",
|
||||
"district": 3,
|
||||
"priority": "high",
|
||||
"status": "resolved",
|
||||
"lat": 35.7801,
|
||||
"lng": 51.4116,
|
||||
"image_url": "https://images.unsplash.com/photo-1509114397022-ed747cca3f65?auto=format&fit=crop&w=800&q=80",
|
||||
"resolved_image_url": "https://images.unsplash.com/photo-1517457373958-b7bdd4587205?auto=format&fit=crop&w=800&q=80",
|
||||
"reporter_name": "مریم احمدی",
|
||||
"reporter_phone": "09123334455",
|
||||
"official_response": "کابلکشی زیرزمینی و تعویض پروژکتورهای معیوب توسط اداره زیباسازی و تاسیسات منطقه انجام و روشنایی کامل برقرار گردید.",
|
||||
"upvotes": 68,
|
||||
"days_ago": 5,
|
||||
"resolved_at": (now - timedelta(days=1)).strftime("%Y-%m-%d %H:%M"),
|
||||
"timeline": [
|
||||
("pending", "ثبت گزارش توسط شهروند", "گزارش دریافت شد."),
|
||||
("reviewing", "بررسی میدانی اداره تاسیسات", "نقص کابل اصلی تایید شد."),
|
||||
("in_progress", "تعمیرات و تعویض کابل", "تیم تاسیسات در حال اجرای خط جدید."),
|
||||
("resolved", "تکمیل و رفع نقص روشنایی", "چراغها روشن و مدار به طور کامل تست شد.")
|
||||
],
|
||||
"comments": [
|
||||
("امیر رستمی", "خیلی سریع درستش کردن، واقعا تشکر از عوامل شهرداری."),
|
||||
("نگار توکلی", "دیشب رفتم پارک، همه چراغها درست شده بود.")
|
||||
]
|
||||
},
|
||||
{
|
||||
"tracking_code": "SHR-91203",
|
||||
"title": "انباشت پسماند و سرریز مخزن زباله شهری",
|
||||
"category": "نظافت و پسماند",
|
||||
"description": "مخزن مکانیزه زباله شکسته شده و زبالهها در پیادهرو پخش شده که باعث بوی نامطبوع و تجمع حشرات شده است.",
|
||||
"address": "خیابان آزادی، تقاطع خیابان استاد معین، پلاک ۴۲",
|
||||
"district": 9,
|
||||
"priority": "medium",
|
||||
"status": "pending",
|
||||
"lat": 35.6997,
|
||||
"lng": 51.3486,
|
||||
"image_url": "https://images.unsplash.com/photo-1532996122724-e3c354a0b15b?auto=format&fit=crop&w=800&q=80",
|
||||
"resolved_image_url": None,
|
||||
"reporter_name": "سعید کریمی",
|
||||
"reporter_phone": "09355556677",
|
||||
"official_response": "گزارش در نوبت بررسی ناظر پسماند منطقه قرار گرفته است.",
|
||||
"upvotes": 19,
|
||||
"days_ago": 1,
|
||||
"resolved_at": None,
|
||||
"timeline": [
|
||||
("pending", "ثبت گزارش در سامانه", "منتظر بازرسی و تخصیص سطل جدید مکانیزه.")
|
||||
],
|
||||
"comments": [
|
||||
("مهدی بهرامی", "لطفا سطلهای پدالدار بگذارید که درش بسته بمونه.")
|
||||
]
|
||||
},
|
||||
{
|
||||
"tracking_code": "SHR-44390",
|
||||
"title": "آسیب دیدگی و شکستن شاخههای درخت کهنسال بر اثر طوفان",
|
||||
"category": "فضای سبز و بوستانها",
|
||||
"description": "شاخههای سنگین درخت چنار شکسته و روی سیمهای برق و پیادهرو معلق مانده است، احتمال سقوط روی عابرین وجود دارد.",
|
||||
"address": "خیابان شریعتی، بالاتر از پل رومی، نبش کوچه یاس",
|
||||
"district": 1,
|
||||
"priority": "emergency",
|
||||
"status": "in_progress",
|
||||
"lat": 35.7985,
|
||||
"lng": 51.4332,
|
||||
"image_url": "https://images.unsplash.com/photo-1542601906990-b4d3fb778b09?auto=format&fit=crop&w=800&q=80",
|
||||
"resolved_image_url": None,
|
||||
"reporter_name": "پویا سرمدی",
|
||||
"reporter_phone": "09124445566",
|
||||
"official_response": "اکیپ سازمان بوستانها و آتشنشانی با جرثقیل در حال هرس ایمن و رفع خطر هستند.",
|
||||
"upvotes": 54,
|
||||
"days_ago": 1,
|
||||
"resolved_at": None,
|
||||
"timeline": [
|
||||
("pending", "ثبت فوری گزارش خطر", "هشدار سقوط شاخه دریافت شد."),
|
||||
("reviewing", "هماهنگی با سازمان آتشنشانی و فضای سبز", "اعلام وضعیت اضطراری."),
|
||||
("in_progress", "عملیات رفع خطر و هرس", "حصارکشی پیادهرو و استقرار بالابر.")
|
||||
],
|
||||
"comments": [
|
||||
("رویا صبوری", "خدا رو شکر سریع اومدن نوار خطر کشیدن.")
|
||||
]
|
||||
},
|
||||
{
|
||||
"tracking_code": "SHR-33219",
|
||||
"title": "سد معبر طولانی مصالح ساختمانی و تخریب پیادهرو",
|
||||
"category": "ساختمانسازی و سد معبر",
|
||||
"description": "یک پروژه ساختمانی بیش از سه هفته است که تمام پیادهرو و بخشی از خیابان را با نخاله و داربست بدون راه عبور ایمن مسدود کرده است.",
|
||||
"address": "سعادتآباد، میدان کاج، خیابان مروارید، پلاک ۱۸",
|
||||
"district": 2,
|
||||
"priority": "high",
|
||||
"status": "reviewing",
|
||||
"lat": 35.7794,
|
||||
"lng": 51.3758,
|
||||
"image_url": "https://images.unsplash.com/photo-1541888946425-d0fbb18086f6?auto=format&fit=crop&w=800&q=80",
|
||||
"resolved_image_url": None,
|
||||
"reporter_name": "فرشید نادری",
|
||||
"reporter_phone": "09127778899",
|
||||
"official_response": "اخطاریه ماده ۱۰۰ و رفع سد معبر برای مالک صادر شد. مهلت رفع ۴۸ ساعت میباشد.",
|
||||
"upvotes": 37,
|
||||
"days_ago": 3,
|
||||
"resolved_at": None,
|
||||
"timeline": [
|
||||
("pending", "ثبت گزارش تخلف ساختمانی", "ثبت شد."),
|
||||
("reviewing", "بازدید مامور اجرای احکام شهرداری", "صدور اخطاریه رسمی رفع انسداد معبر.")
|
||||
],
|
||||
"comments": [
|
||||
("کامران شمس", "مادر من با ویلچر مجبور شد بره توی خیابان که خیلی خطرناک بود!")
|
||||
]
|
||||
},
|
||||
{
|
||||
"tracking_code": "SHR-55821",
|
||||
"title": "خرابی و چشمکزن ماندن چراغ راهنمایی تقاطع پرتردد",
|
||||
"category": "ترافیک و حملونقل",
|
||||
"description": "چراغ راهنمایی تقاطع به مدت دو روز چشمکزن زرد مانده که باعث گره کور ترافیکی و تصادفات مکرر شده است.",
|
||||
"address": "میدان فاطمی (جهاد)، تقاطع خیابان جویبار و شهید گمنام",
|
||||
"district": 6,
|
||||
"priority": "high",
|
||||
"status": "resolved",
|
||||
"lat": 35.7208,
|
||||
"lng": 51.4082,
|
||||
"image_url": "https://images.unsplash.com/photo-1517649763962-0c623266ddc0?auto=format&fit=crop&w=800&q=80",
|
||||
"resolved_image_url": "https://images.unsplash.com/photo-1508873696983-2df5703bc275?auto=format&fit=crop&w=800&q=80",
|
||||
"reporter_name": "ندا زمانی",
|
||||
"reporter_phone": "09191113355",
|
||||
"official_response": "برد الکترونیکی کنترلر چراغ توسط شرکت کنترل ترافیک تهران تعویض و زمانبندی هوشمند مجدداً فعال گردید.",
|
||||
"upvotes": 85,
|
||||
"days_ago": 4,
|
||||
"resolved_at": (now - timedelta(days=2)).strftime("%Y-%m-%d %H:%M"),
|
||||
"timeline": [
|
||||
("pending", "ثبت گزارش خرابی علائم ترافیکی", "گزارش دریافت گردید."),
|
||||
("reviewing", "ارجاع به شرکت کنترل ترافیک", "تایید خرابی سختافزاری کنترلر."),
|
||||
("in_progress", "تعویض برد فرمان و سنسورها", "عملیات فنی در محل انجام شد."),
|
||||
("resolved", "راهاندازی و اتصال به مرکز کنترل", "چراغ طبق برنامه زمانبندی هوشمند شروع به کار کرد.")
|
||||
],
|
||||
"comments": [
|
||||
("سامان یوسفی", "دست مریزاد، امروز صبح ترافیک خیلی روانتر بود.")
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
for item in sample_issues:
|
||||
created_time = (now - timedelta(days=item["days_ago"], hours=random.randint(1, 10))).strftime("%Y-%m-%d %H:%M")
|
||||
cursor.execute("""
|
||||
INSERT INTO issues (
|
||||
tracking_code, title, category, description, address, district, priority, status,
|
||||
lat, lng, image_url, resolved_image_url, reporter_name, reporter_phone,
|
||||
official_response, upvotes, created_at, resolved_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
item["tracking_code"], item["title"], item["category"], item["description"],
|
||||
item["address"], item["district"], item["priority"], item["status"],
|
||||
item["lat"], item["lng"], item["image_url"], item["resolved_image_url"],
|
||||
item["reporter_name"], item["reporter_phone"], item["official_response"],
|
||||
item["upvotes"], created_time, item["resolved_at"]
|
||||
))
|
||||
|
||||
issue_id = cursor.lastrowid
|
||||
|
||||
for tl_status, tl_title, tl_desc in item["timeline"]:
|
||||
cursor.execute("""
|
||||
INSERT INTO timeline (issue_id, status, title, description, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (issue_id, tl_status, tl_title, tl_desc, created_time))
|
||||
|
||||
for author, comment_text in item["comments"]:
|
||||
cursor.execute("""
|
||||
INSERT INTO comments (issue_id, author_name, content, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (issue_id, author, comment_text, created_time))
|
||||
@@ -1,273 +1,299 @@
|
||||
<?php
|
||||
// db.php - Database initialization and PDO helper for ShahrNegar
|
||||
/**
|
||||
* Forex Trading Journal Database Layer (SQLite)
|
||||
* High-performance, robust SQLite storage for Forex accounts, trades, and analytics.
|
||||
*/
|
||||
|
||||
$dbPath = __DIR__ . '/urban_issues.db';
|
||||
class Database {
|
||||
private static ?PDO $pdo = null;
|
||||
private static string $dbFile = __DIR__ . '/forex_journal.db';
|
||||
|
||||
function getDB(): PDO {
|
||||
global $dbPath;
|
||||
$pdo = new PDO('sqlite:' . $dbPath);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
return $pdo;
|
||||
}
|
||||
public static function getConnection(): PDO {
|
||||
if (self::$pdo === null) {
|
||||
$isNew = !file_exists(self::$dbFile);
|
||||
self::$pdo = new PDO('sqlite:' . self::$dbFile);
|
||||
self::$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
self::$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
|
||||
// Enable WAL mode for high concurrency and performance
|
||||
self::$pdo->exec('PRAGMA journal_mode = WAL;');
|
||||
self::$pdo->exec('PRAGMA foreign_keys = ON;');
|
||||
|
||||
function initDB() {
|
||||
$pdo = getDB();
|
||||
self::initializeSchema();
|
||||
|
||||
// Create Issues Table
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS issues (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tracking_code TEXT UNIQUE NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
district INTEGER NOT NULL,
|
||||
priority TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
lat REAL NOT NULL,
|
||||
lng REAL NOT NULL,
|
||||
image_url TEXT,
|
||||
resolved_image_url TEXT,
|
||||
reporter_name TEXT,
|
||||
reporter_phone TEXT,
|
||||
official_response TEXT,
|
||||
upvotes INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
resolved_at TEXT
|
||||
)");
|
||||
|
||||
// Create Comments Table
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
issue_id INTEGER NOT NULL,
|
||||
author_name TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
|
||||
)");
|
||||
|
||||
// Create Timeline Table
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS timeline (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
issue_id INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
|
||||
)");
|
||||
|
||||
// Check count and seed if empty
|
||||
$stmt = $pdo->query("SELECT COUNT(*) FROM issues");
|
||||
$count = (int)$stmt->fetchColumn();
|
||||
|
||||
if ($count === 0) {
|
||||
seedInitialData($pdo);
|
||||
if ($isNew || self::isAccountTableEmpty()) {
|
||||
self::seedInitialData();
|
||||
}
|
||||
}
|
||||
return self::$pdo;
|
||||
}
|
||||
}
|
||||
|
||||
function seedInitialData(PDO $pdo) {
|
||||
$now = new DateTime();
|
||||
private static function initializeSchema(): void {
|
||||
$db = self::$pdo;
|
||||
|
||||
$sampleIssues = [
|
||||
[
|
||||
'tracking_code' => 'SHR-78214',
|
||||
'title' => 'فرونشست و چاله عمیق در لاین کندرو',
|
||||
'category' => 'آسفالت و معابر',
|
||||
'description' => 'وجود چاله عمیق با قطر حدود ۱ متر پس از بارندگی اخیر که باعث خسارت به لاستیک خودروها و ترمزهای ناگهانی خطرناک میشود.',
|
||||
'address' => 'بزرگراه شهید همت، نرسیده به خروجی گاندی، لاین کندرو',
|
||||
'district' => 3,
|
||||
'priority' => 'emergency',
|
||||
'status' => 'in_progress',
|
||||
'lat' => 35.7538,
|
||||
'lng' => 51.4172,
|
||||
'image_url' => 'https://images.unsplash.com/photo-1515162816999-a0c47dc192f7?auto=format&fit=crop&w=800&q=80',
|
||||
'resolved_image_url' => null,
|
||||
'reporter_name' => 'علی رضایی',
|
||||
'reporter_phone' => '09121112233',
|
||||
'official_response' => 'اکیپ آسفالتریزی ناحیه ۲ شهرداری منطقه ۳ اعزام گردیده و عملیات زیرسازی و لکهگیری در حال انجام است.',
|
||||
'upvotes' => 42,
|
||||
'days_ago' => 2,
|
||||
'resolved_at' => null,
|
||||
'timeline' => [
|
||||
['pending', 'ثبت گزارش توسط شهروند', 'گزارش در سامانه ۱۳۷ با موفقیت ثبت شد.'],
|
||||
['reviewing', 'تایید کارشناس و ارجاع به معاونت فنی و عمران', 'موضوع به ناحیه ۲ ارجاع داده شد.'],
|
||||
['in_progress', 'اعزام اکیپ عملیاتی لکهگیری', 'تیم فنی با تجهیزات در محل مستقر شد.']
|
||||
],
|
||||
'comments' => [
|
||||
['سارا محمدی', 'دیروز لاستیک ماشین من هم اینجا آسیب دید، ممنون که پیگیری میکنید.'],
|
||||
['حسین کاظمی', 'امیدوارم امشب تا قبل از ترافیک صبحگاهی تموم بشه.']
|
||||
]
|
||||
],
|
||||
[
|
||||
'tracking_code' => 'SHR-65109',
|
||||
'title' => 'خاموشی کامل پایه چراغهای روشنایی بوستان',
|
||||
'category' => 'روشنایی و برق',
|
||||
'description' => 'کل مسیر پیادهروی شرقی بوستان ملت در تاریکی مطلق است که باعث کاهش امنیت خانوادهها و ورزشکاران شبانه شده است.',
|
||||
'address' => 'خیابان ولیعصر، بوستان ملت، ضلع شرقی جنب دریاچه',
|
||||
'district' => 3,
|
||||
'priority' => 'high',
|
||||
'status' => 'resolved',
|
||||
'lat' => 35.7801,
|
||||
'lng' => 51.4116,
|
||||
'image_url' => 'https://images.unsplash.com/photo-1509114397022-ed747cca3f65?auto=format&fit=crop&w=800&q=80',
|
||||
'resolved_image_url' => 'https://images.unsplash.com/photo-1517457373958-b7bdd4587205?auto=format&fit=crop&w=800&q=80',
|
||||
'reporter_name' => 'مریم احمدی',
|
||||
'reporter_phone' => '09123334455',
|
||||
'official_response' => 'کابلکشی زیرزمینی و تعویض پروژکتورهای معیوب توسط اداره زیباسازی و تاسیسات منطقه انجام و روشنایی کامل برقرار گردید.',
|
||||
'upvotes' => 68,
|
||||
'days_ago' => 5,
|
||||
'resolved_at' => (clone $now)->modify('-1 day')->format('Y-m-d H:i'),
|
||||
'timeline' => [
|
||||
['pending', 'ثبت گزارش توسط شهروند', 'گزارش دریافت شد.'],
|
||||
['reviewing', 'بررسی میدانی اداره تاسیسات', 'نقص کابل اصلی تایید شد.'],
|
||||
['in_progress', 'تعمیرات و تعویض کابل', 'تیم تاسیسات در حال اجرای خط جدید.'],
|
||||
['resolved', 'تکمیل و رفع نقص روشنایی', 'چراغها روشن و مدار به طور کامل تست شد.']
|
||||
],
|
||||
'comments' => [
|
||||
['امیر رستمی', 'خیلی سریع درستش کردن، واقعا تشکر از عوامل شهرداری.'],
|
||||
['نگار توکلی', 'دیشب رفتم پارک، همه چراغها درست شده بود.']
|
||||
]
|
||||
],
|
||||
[
|
||||
'tracking_code' => 'SHR-91203',
|
||||
'title' => 'انباشت پسماند و سرریز مخزن زباله شهری',
|
||||
'category' => 'نظافت و پسماند',
|
||||
'description' => 'مخزن مکانیزه زباله شکسته شده و زبالهها در پیادهرو پخش شده که باعث بوی نامطبوع و تجمع حشرات شده است.',
|
||||
'address' => 'خیابان آزادی، تقاطع خیابان استاد معین، پلاک ۴۲',
|
||||
'district' => 9,
|
||||
'priority' => 'medium',
|
||||
'status' => 'pending',
|
||||
'lat' => 35.6997,
|
||||
'lng' => 51.3486,
|
||||
'image_url' => 'https://images.unsplash.com/photo-1532996122724-e3c354a0b15b?auto=format&fit=crop&w=800&q=80',
|
||||
'resolved_image_url' => null,
|
||||
'reporter_name' => 'سعید کریمی',
|
||||
'reporter_phone' => '09355556677',
|
||||
'official_response' => 'گزارش در نوبت بررسی ناظر پسماند منطقه قرار گرفته است.',
|
||||
'upvotes' => 19,
|
||||
'days_ago' => 1,
|
||||
'resolved_at' => null,
|
||||
'timeline' => [
|
||||
['pending', 'ثبت گزارش در سامانه', 'منتظر بازرسی و تخصیص سطل جدید مکانیزه.']
|
||||
],
|
||||
'comments' => [
|
||||
['مهدی بهرامی', 'لطفا سطلهای پدالدار بگذارید که درش بسته بمونه.']
|
||||
]
|
||||
],
|
||||
[
|
||||
'tracking_code' => 'SHR-44390',
|
||||
'title' => 'آسیب دیدگی و شکستن شاخههای درخت کهنسال بر اثر طوفان',
|
||||
'category' => 'فضای سبز و بوستانها',
|
||||
'description' => 'شاخههای سنگین درخت چنار شکسته و روی سیمهای برق و پیادهرو معلق مانده است، احتمال سقوط روی عابرین وجود دارد.',
|
||||
'address' => 'خیابان شریعتی، بالاتر از پل رومی، نبش کوچه یاس',
|
||||
'district' => 1,
|
||||
'priority' => 'emergency',
|
||||
'status' => 'in_progress',
|
||||
'lat' => 35.7985,
|
||||
'lng' => 51.4332,
|
||||
'image_url' => 'https://images.unsplash.com/photo-1542601906990-b4d3fb778b09?auto=format&fit=crop&w=800&q=80',
|
||||
'resolved_image_url' => null,
|
||||
'reporter_name' => 'پویا سرمدی',
|
||||
'reporter_phone' => '09124445566',
|
||||
'official_response' => 'اکیپ سازمان بوستانها و آتشنشانی با جرثقیل در حال هرس ایمن و رفع خطر هستند.',
|
||||
'upvotes' => 54,
|
||||
'days_ago' => 1,
|
||||
'resolved_at' => null,
|
||||
'timeline' => [
|
||||
['pending', 'ثبت فوری گزارش خطر', 'هشدار سقوط شاخه دریافت شد.'],
|
||||
['reviewing', 'هماهنگی با سازمان آتشنشانی و فضای سبز', 'اعلام وضعیت اضطراری.'],
|
||||
['in_progress', 'عملیات رفع خطر و هرس', 'حصارکشی پیادهرو و استقرار بالابر.']
|
||||
],
|
||||
'comments' => [
|
||||
['رویا صبوری', 'خدا رو شکر سریع اومدن نوار خطر کشیدن.']
|
||||
]
|
||||
],
|
||||
[
|
||||
'tracking_code' => 'SHR-33219',
|
||||
'title' => 'سد معبر طولانی مصالح ساختمانی و تخریب پیادهرو',
|
||||
'category' => 'ساختمانسازی و سد معبر',
|
||||
'description' => 'یک پروژه ساختمانی بیش از سه هفته است که تمام پیادهرو و بخشی از خیابان را با نخاله و داربست بدون راه عبور ایمن مسدود کرده است.',
|
||||
'address' => 'سعادتآباد، میدان کاج، خیابان مروارید، پلاک ۱۸',
|
||||
'district' => 2,
|
||||
'priority' => 'high',
|
||||
'status' => 'reviewing',
|
||||
'lat' => 35.7794,
|
||||
'lng' => 51.3758,
|
||||
'image_url' => 'https://images.unsplash.com/photo-1541888946425-d0fbb18086f6?auto=format&fit=crop&w=800&q=80',
|
||||
'resolved_image_url' => null,
|
||||
'reporter_name' => 'فرشید نادری',
|
||||
'reporter_phone' => '09127778899',
|
||||
'official_response' => 'اخطاریه ماده ۱۰۰ و رفع سد معبر برای مالک صادر شد. مهلت رفع ۴۸ ساعت میباشد.',
|
||||
'upvotes' => 37,
|
||||
'days_ago' => 3,
|
||||
'resolved_at' => null,
|
||||
'timeline' => [
|
||||
['pending', 'ثبت گزارش تخلف ساختمانی', 'ثبت شد.'],
|
||||
['reviewing', 'بازدید مامور اجرای احکام شهرداری', 'صدور اخطاریه رسمی رفع انسداد معبر.']
|
||||
],
|
||||
'comments' => [
|
||||
['کامران شمس', 'مادر من با ویلچر مجبور شد بره توی خیابان که خیلی خطرناک بود!']
|
||||
]
|
||||
],
|
||||
[
|
||||
'tracking_code' => 'SHR-55821',
|
||||
'title' => 'خرابی و چشمکزن ماندن چراغ راهنمایی تقاطع پرتردد',
|
||||
'category' => 'ترافیک و حملونقل',
|
||||
'description' => 'چراغ راهنمایی تقاطع به مدت دو روز چشمکزن زرد مانده که باعث گره کور ترافیکی و تصادفات مکرر شده است.',
|
||||
'address' => 'میدان فاطمی (جهاد)، تقاطع خیابان جویبار و شهید گمنام',
|
||||
'district' => 6,
|
||||
'priority' => 'high',
|
||||
'status' => 'resolved',
|
||||
'lat' => 35.7208,
|
||||
'lng' => 51.4082,
|
||||
'image_url' => 'https://images.unsplash.com/photo-1517649763962-0c623266ddc0?auto=format&fit=crop&w=800&q=80',
|
||||
'resolved_image_url' => 'https://images.unsplash.com/photo-1508873696983-2df5703bc275?auto=format&fit=crop&w=800&q=80',
|
||||
'reporter_name' => 'ندا زمانی',
|
||||
'reporter_phone' => '09191113355',
|
||||
'official_response' => 'برد الکترونیکی کنترلر چراغ توسط شرکت کنترل ترافیک تهران تعویض و زمانبندی هوشمند مجدداً فعال گردید.',
|
||||
'upvotes' => 85,
|
||||
'days_ago' => 4,
|
||||
'resolved_at' => (clone $now)->modify('-2 days')->format('Y-m-d H:i'),
|
||||
'timeline' => [
|
||||
['pending', 'ثبت گزارش خرابی علائم ترافیکی', 'گزارش دریافت گردید.'],
|
||||
['reviewing', 'ارجاع به شرکت کنترل ترافیک', 'تایید خرابی سختافزاری کنترلر.'],
|
||||
['in_progress', 'تعویض برد فرمان و سنسورها', 'عملیات فنی در محل انجام شد.'],
|
||||
['resolved', 'راهاندازی و اتصال به مرکز کنترل', 'چراغ طبق برنامه زمانبندی هوشمند شروع به کار کرد.']
|
||||
],
|
||||
'comments' => [
|
||||
['سامان یوسفی', 'دست مریزاد، امروز صبح ترافیک خیلی روانتر بود.']
|
||||
]
|
||||
]
|
||||
];
|
||||
// 1. Trading Accounts Table
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
broker TEXT DEFAULT 'IC Markets',
|
||||
account_number TEXT DEFAULT '',
|
||||
currency TEXT DEFAULT 'USD',
|
||||
initial_balance REAL NOT NULL DEFAULT 10000.0,
|
||||
leverage REAL DEFAULT 100.0,
|
||||
account_type TEXT DEFAULT 'Real', -- Real, Demo, PropFirm
|
||||
is_default INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
");
|
||||
|
||||
$insertIssue = $pdo->prepare("INSERT INTO issues (
|
||||
tracking_code, title, category, description, address, district, priority, status,
|
||||
lat, lng, image_url, resolved_image_url, reporter_name, reporter_phone,
|
||||
official_response, upvotes, created_at, resolved_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
// 2. Trades Table
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS trades (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
ticket TEXT DEFAULT '',
|
||||
symbol TEXT NOT NULL, -- EURUSD, XAUUSD, GBPUSD, etc.
|
||||
trade_type TEXT NOT NULL, -- buy, sell
|
||||
lot_size REAL NOT NULL DEFAULT 0.1,
|
||||
open_price REAL NOT NULL,
|
||||
close_price REAL DEFAULT NULL,
|
||||
stop_loss REAL DEFAULT NULL,
|
||||
take_profit REAL DEFAULT NULL,
|
||||
open_time DATETIME NOT NULL,
|
||||
close_time DATETIME DEFAULT NULL,
|
||||
pips REAL DEFAULT 0.0,
|
||||
profit REAL DEFAULT 0.0, -- Net P&L in currency ($)
|
||||
commission REAL DEFAULT 0.0,
|
||||
swap REAL DEFAULT 0.0,
|
||||
status TEXT DEFAULT 'closed', -- open, closed
|
||||
strategy TEXT DEFAULT 'Price Action',
|
||||
timeframe TEXT DEFAULT 'M15',
|
||||
session TEXT DEFAULT 'London', -- London, New York, Asian, Overlap
|
||||
risk_reward REAL DEFAULT NULL,
|
||||
risk_percent REAL DEFAULT 1.0,
|
||||
emotion TEXT DEFAULT 'Disciplined', -- Disciplined, Greedy, Fearful, Revenge, Confident, Impatient, Neutral
|
||||
entry_notes TEXT DEFAULT '',
|
||||
exit_notes TEXT DEFAULT '',
|
||||
lessons TEXT DEFAULT '',
|
||||
screenshot_entry TEXT DEFAULT '',
|
||||
screenshot_exit TEXT DEFAULT '',
|
||||
tags TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
|
||||
);
|
||||
");
|
||||
|
||||
$insertTimeline = $pdo->prepare("INSERT INTO timeline (issue_id, status, title, description, created_at) VALUES (?, ?, ?, ?, ?)");
|
||||
$insertComment = $pdo->prepare("INSERT INTO comments (issue_id, author_name, content, created_at) VALUES (?, ?, ?, ?)");
|
||||
// 3. Daily Notes / Journal Mindset Log
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS daily_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
log_date DATE NOT NULL,
|
||||
notes TEXT DEFAULT '',
|
||||
mindset_score INTEGER DEFAULT 5, -- 1 to 5
|
||||
market_condition TEXT DEFAULT 'Normal',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
UNIQUE(account_id, log_date)
|
||||
);
|
||||
");
|
||||
|
||||
foreach ($sampleIssues as $item) {
|
||||
$createdTime = (clone $now)->modify("-{$item['days_ago']} days")->format('Y-m-d H:i');
|
||||
$insertIssue->execute([
|
||||
$item['tracking_code'], $item['title'], $item['category'], $item['description'],
|
||||
$item['address'], $item['district'], $item['priority'], $item['status'],
|
||||
$item['lat'], $item['lng'], $item['image_url'], $item['resolved_image_url'],
|
||||
$item['reporter_name'], $item['reporter_phone'], $item['official_response'],
|
||||
$item['upvotes'], $createdTime, $item['resolved_at']
|
||||
// 4. Strategies / Setups Table
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS strategies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT DEFAULT '',
|
||||
color TEXT DEFAULT '#3b82f6',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
");
|
||||
|
||||
// Create indexes for fast analytical aggregations
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_trades_account ON trades(account_id);");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_trades_open_time ON trades(open_time);");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_trades_close_time ON trades(close_time);");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_trades_symbol ON trades(symbol);");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_trades_status ON trades(status);");
|
||||
}
|
||||
|
||||
private static function isAccountTableEmpty(): bool {
|
||||
$stmt = self::$pdo->query("SELECT COUNT(*) FROM accounts");
|
||||
return ((int)$stmt->fetchColumn()) === 0;
|
||||
}
|
||||
|
||||
public static function seedInitialData(): void {
|
||||
$db = self::$pdo;
|
||||
|
||||
// Default Strategies
|
||||
$strategies = [
|
||||
['Price Action', 'Break of structure, support & resistance', '#3b82f6'],
|
||||
['ICT / SMC', 'Order blocks, Fair Value Gaps (FVG) and liquidity sweeps', '#8b5cf6'],
|
||||
['Trend Following', 'Moving average pullbacks and trend continuation', '#10b981'],
|
||||
['Scalping', 'Fast momentum moves on M1/M5 charts', '#f59e0b'],
|
||||
['Reversal / Divergence', 'RSI/MACD divergence at key supply/demand levels', '#ec4899'],
|
||||
['News Trading', 'High-impact economic news releases', '#ef4444'],
|
||||
];
|
||||
|
||||
$stmtStrat = $db->prepare("INSERT OR IGNORE INTO strategies (name, description, color) VALUES (?, ?, ?)");
|
||||
foreach ($strategies as $strat) {
|
||||
$stmtStrat->execute($strat);
|
||||
}
|
||||
|
||||
// Default Accounts
|
||||
$stmtAcc = $db->prepare("
|
||||
INSERT INTO accounts (name, broker, account_number, currency, initial_balance, leverage, account_type, is_default)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$stmtAcc->execute(['حساب معاملاتی اصلی (Main Live)', 'IC Markets', '8841920', 'USD', 10000.0, 100.0, 'Real', 1]);
|
||||
$mainAccountId = (int)$db->lastInsertId();
|
||||
|
||||
$stmtAcc->execute(['چالش پراپ فرم ۱۰۰ هزار دلاری (FTMO Prop)', 'FTMO', '2948173', 'USD', 100000.0, 100.0, 'PropFirm', 0]);
|
||||
|
||||
// Seed rich realistic trading history for main account
|
||||
self::generateSampleTrades($mainAccountId, 10000.0);
|
||||
}
|
||||
|
||||
public static function generateSampleTrades(int $accountId, float $startBalance = 10000.0): void {
|
||||
$db = self::$pdo;
|
||||
|
||||
// Trade definition array:
|
||||
// [0:symbol, 1:type, 2:lot, 3:openPrice, 4:closePrice, 5:sl, 6:tp, 7:pips, 8:profit, 9:comm, 10:swap, 11:strat, 12:tf, 13:session, 14:rr, 15:emotion, 16:entryNotes, 17:exitNotes, 18:lessons]
|
||||
$tradeDataList = [
|
||||
['EURUSD', 'buy', 1.0, 1.08200, 1.08650, 1.07950, 1.08700, 45.0, 450.0, -7.0, 0.0, 'ICT / SMC', 'H1', 'London', 1.8, 'Disciplined', 'جاروب نقدینگی لندن و واکنش به FVG', 'رسیدن به هدف نقدینگی سقف روز قبل', 'پایبندی به استراتژی'],
|
||||
['XAUUSD', 'buy', 0.5, 2350.20, 2368.50, 2342.00, 2370.00, 183.0, 915.0, -5.0, -2.1, 'Price Action', 'M15', 'New York', 2.2, 'Confident', 'تست مجدد سطح حمایتی ۲۳۵۰ با کندل تاییدیه پین بار', 'خروج در نزدیکی تارگت با سود عالی', 'صبر برای پولبک کلید موفقیت بود'],
|
||||
['GBPUSD', 'sell', 1.2, 1.27600, 1.27150, 1.27850, 1.27000, 45.0, 540.0, -8.4, 1.5, 'Trend Following', 'M15', 'London', 1.8, 'Disciplined', 'شکست خط روند نزولی و پولبک به EMA50', 'ریسک فری شد و در تارگت ۲ بسته شد', 'مدیریت ترید عالی'],
|
||||
['USDJPY', 'buy', 1.0, 154.800, 154.450, 154.400, 155.600, -35.0, -225.0, -7.0, 0.0, 'Scalping', 'M5', 'Asian', 0.0, 'Fearful', 'ورود با مومنتوم صعودی ضعیف در نشست آسیا', 'اصابت به استاپ لاس به دلیل اسپرد بالا', 'در سشن آسیا نباید روی جفتهای کمنوسان بدون کاتالیزور معامله کرد'],
|
||||
['EURUSD', 'sell', 1.5, 1.08900, 1.08350, 1.09150, 1.08200, 55.0, 825.0, -10.5, 0.0, 'ICT / SMC', 'M15', 'New York', 2.2, 'Disciplined', 'شکار نقدینگی سشن نیویورک و تایید با اردر بلاک خرسی', 'کست کامل سود در کف سشن لندن', 'ورود دقیق روی اوردربلاک'],
|
||||
['XAUUSD', 'sell', 0.8, 2385.00, 2392.50, 2392.00, 2370.00, -75.0, -600.0, -8.0, 0.0, 'Reversal / Divergence', 'M15', 'New York', 0.0, 'Greedy', 'ورود برخلاف روند قدرتمند بدون واگرایی قطعی', 'استاپ خورد، طلا شتاب شدید صعودی داشت', 'هرگز نباید جلوی قطار سریعالسیر ایستاد!'],
|
||||
['BTCUSD', 'buy', 0.2, 63200.0, 65100.0, 62400.0, 65500.0, 1900.0, 380.0, -3.0, -0.5, 'Trend Following', 'H4', 'London', 2.4, 'Confident', 'حفظ حمایت رند ۶۳۰۰۰ و تشکیل الگوی دابل باتم', 'خروج پلهای در ۶۵۱۰۰', 'معامله سوینگی تمیز'],
|
||||
['US30', 'buy', 0.5, 39200.0, 39650.0, 39000.0, 39700.0, 450.0, 225.0, -5.0, 0.0, 'Scalping', 'M5', 'New York', 2.2, 'Disciplined', 'اوپن سشن نیویورک و جهش شاخص داوجونز', 'خروج سریع با تارگت تعیین شده', 'مدیریت ریسک عالی'],
|
||||
['GBPUSD', 'buy', 1.0, 1.26800, 1.27400, 1.26500, 1.27500, 60.0, 600.0, -7.0, 0.0, 'ICT / SMC', 'M15', 'London', 2.0, 'Disciplined', 'برگشت قیمت از ناحیه تقاضا و جاروب کف آسیا', 'تارگت لمس شد', 'برنامه معاملاتی مو به مو اجرا شد'],
|
||||
['EURUSD', 'sell', 1.0, 1.08500, 1.08800, 1.08800, 1.08000, -30.0, -300.0, -7.0, 0.0, 'Price Action', 'M15', 'London', 0.0, 'Impatient', 'ورود عجولانه قبل از بسته شدن کندل ۱۵ دقیقه', 'برگشت قیمت به بالا و استاپ', 'صبر برای کلوز کندل ضروری است'],
|
||||
['XAUUSD', 'buy', 0.7, 2360.00, 2378.00, 2353.00, 2380.00, 180.0, 1260.0, -7.0, -1.8, 'ICT / SMC', 'H1', 'Overlap', 2.5, 'Disciplined', 'تلاقی FVG با لول ۵۰٪ فیبوناچی در اورلپ لندن/نیویورک', 'تارگت کامل محقق شد', 'یکی از بهترین معاملات ماه'],
|
||||
['AUDUSD', 'sell', 1.0, 0.66800, 0.66350, 0.67050, 0.66200, 45.0, 450.0, -7.0, 1.2, 'Trend Following', 'H1', 'Asian', 1.8, 'Neutral', 'شکست رنج قیمتی و تاییدیه با دادههای اشتغال استرالیا', 'خروج با سود خوب', 'انطباق خوب با تحلیل فاندامنتال'],
|
||||
['USDJPY', 'sell', 1.0, 156.200, 155.300, 156.600, 155.000, 90.0, 580.0, -7.0, 0.0, 'Price Action', 'H1', 'New York', 2.2, 'Disciplined', 'سقف دو قلو در تایم فریم یک ساعته با کندل اینگالف نزولی', 'خروج در حمایت کلیدی ۱۵۵.۳۰', 'معامله بسیار روان و بدون استرس'],
|
||||
['EURUSD', 'buy', 1.2, 1.08300, 1.08100, 1.08050, 1.08900, -20.0, -240.0, -8.4, 0.0, 'Scalping', 'M5', 'London', 0.0, 'Neutral', 'تلاش برای گرفتن بریک اوت سریع که فیک اوت شد', 'حد ضرر نجات داد', 'ضرر کوچک و کنترلشده'],
|
||||
['XAUUSD', 'buy', 0.6, 2375.00, 2394.00, 2368.00, 2395.00, 190.0, 1140.0, -6.0, 0.0, 'ICT / SMC', 'M15', 'New York', 2.7, 'Confident', 'سوییپ نقدینگی کف روزانه در اخبار CPI و واکنش انفجاری', 'خروج در تارگت ۲۳۹۴', 'مدیریت پوزیشن عالی حین خبر'],
|
||||
['GBPUSD', 'sell', 1.0, 1.27500, 1.27100, 1.27800, 1.27000, 40.0, 400.0, -7.0, 0.0, 'Price Action', 'M15', 'London', 1.3, 'Disciplined', 'برخورد به سقف کانال نزولی', 'برداشت سود مطمئن', 'معامله استاندارد طبق پلن'],
|
||||
['BTCUSD', 'sell', 0.25, 66500.0, 64200.0, 67200.0, 64000.0, 2300.0, 575.0, -3.5, 0.0, 'Reversal / Divergence', 'H4', 'New York', 3.2, 'Disciplined', 'واگرایی منفی مشهود در RSI تایم ۴ ساعته', 'ریزش عالی بیتکوین و سیو سود', 'نسبت ریسک به ریوارد بالای ۳'],
|
||||
['US30', 'sell', 0.4, 39800.0, 39950.0, 39950.0, 39400.0, -150.0, -60.0, -4.0, 0.0, 'Scalping', 'M5', 'New York', 0.0, 'Neutral', 'ترید خلاف ترند در اوپن بازار', 'استاپ کوچک خورد', 'مدیریت ریسک عالی'],
|
||||
['EURUSD', 'buy', 1.0, 1.08400, 1.08920, 1.08150, 1.09000, 52.0, 520.0, -7.0, 0.0, 'ICT / SMC', 'H1', 'London', 2.1, 'Disciplined', 'پر شدن FVG سشن لندن و چرخش روند', 'تارگت لمس شد', 'آرامش و صبر کامل'],
|
||||
['XAUUSD', 'buy', 0.5, 2390.00, 2415.00, 2382.00, 2420.00, 250.0, 1250.0, -5.0, -2.5, 'Trend Following', 'H4', 'New York', 3.1, 'Confident', 'شکست سقف تاریخی قبلی و پولبک استاندارد', 'سود فوقالعاده با تارگت بالای ۲۴۱۵', 'رعایت تمام اصول استراتژی'],
|
||||
['USDJPY', 'buy', 1.0, 155.100, 155.650, 154.750, 155.800, 55.0, 355.0, -7.0, 0.0, 'Price Action', 'M15', 'Asian', 1.5, 'Disciplined', 'حمایت معتبر در سشن توکیو', 'خروج با سود خوب در شروع سشن لندن', 'ترید تمیز'],
|
||||
['GBPUSD', 'buy', 1.0, 1.26900, 1.26600, 1.26600, 1.27600, -30.0, -300.0, -7.0, 0.0, 'Price Action', 'M15', 'London', 0.0, 'Fearful', 'انتشار دادههای ضعیف بریتانیا و استاپ خوردن', 'استاپ رعایت شد', 'پایبندی به استاپ لاس نجاتبخش بود'],
|
||||
['EURUSD', 'sell', 1.0, 1.08800, 1.08300, 1.09050, 1.08200, 50.0, 500.0, -7.0, 0.0, 'ICT / SMC', 'M15', 'New York', 2.0, 'Disciplined', 'شکار نقدینگی خریداران بالای ۱.۰۸۸۰ و ریزش سریع', 'خروج در کف روز', 'ستاپ کاملاً مکانیکی و تست شده']
|
||||
];
|
||||
|
||||
$stmtTrade = $db->prepare("
|
||||
INSERT INTO trades (
|
||||
account_id, ticket, symbol, trade_type, lot_size,
|
||||
open_price, close_price, stop_loss, take_profit,
|
||||
open_time, close_time, pips, profit, commission, swap,
|
||||
status, strategy, timeframe, session, risk_reward, emotion,
|
||||
entry_notes, exit_notes, lessons, tags
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
|
||||
$baseTime = time() - (count($tradeDataList) * 86400 * 0.9);
|
||||
$ticketBase = 7418290;
|
||||
|
||||
foreach ($tradeDataList as $i => $t) {
|
||||
$ticket = (string)($ticketBase + $i * 17);
|
||||
$openTs = $baseTime + ($i * 75000) + rand(1000, 15000);
|
||||
$durationSec = rand(1800, 28800);
|
||||
$closeTs = $openTs + $durationSec;
|
||||
|
||||
$openTimeStr = date('Y-m-d H:i:s', $openTs);
|
||||
$closeTimeStr = date('Y-m-d H:i:s', $closeTs);
|
||||
|
||||
$stmtTrade->execute([
|
||||
$accountId,
|
||||
$ticket,
|
||||
$t[0],
|
||||
$t[1],
|
||||
$t[2],
|
||||
$t[3],
|
||||
$t[4],
|
||||
$t[5],
|
||||
$t[6],
|
||||
$openTimeStr,
|
||||
$closeTimeStr,
|
||||
$t[7],
|
||||
$t[8],
|
||||
$t[9],
|
||||
$t[10],
|
||||
'closed',
|
||||
$t[11],
|
||||
$t[12],
|
||||
$t[13],
|
||||
$t[14],
|
||||
$t[15],
|
||||
$t[16],
|
||||
$t[17],
|
||||
$t[18],
|
||||
'Live,DayTrade,Forex'
|
||||
]);
|
||||
}
|
||||
|
||||
// Add 2 open trades
|
||||
$stmtTrade->execute([
|
||||
$accountId,
|
||||
(string)($ticketBase + 991),
|
||||
'XAUUSD',
|
||||
'buy',
|
||||
0.5,
|
||||
2405.50,
|
||||
null,
|
||||
2396.00,
|
||||
2425.00,
|
||||
date('Y-m-d H:i:s', time() - 3600),
|
||||
null,
|
||||
45.0,
|
||||
225.0,
|
||||
-5.0,
|
||||
0.0,
|
||||
'open',
|
||||
'ICT / SMC',
|
||||
'H1',
|
||||
'London',
|
||||
2.0,
|
||||
'Disciplined',
|
||||
'ورود پس از شکست مقاومت ۲۴۰۰ و تثبیت بالای آن',
|
||||
'',
|
||||
'',
|
||||
'OpenTrade,Swing'
|
||||
]);
|
||||
$issueId = $pdo->lastInsertId();
|
||||
|
||||
foreach ($item['timeline'] as $tl) {
|
||||
$insertTimeline->execute([$issueId, $tl[0], $tl[1], $tl[2], $createdTime]);
|
||||
}
|
||||
|
||||
foreach ($item['comments'] as $cm) {
|
||||
$insertComment->execute([$issueId, $cm[0], $cm[1], $createdTime]);
|
||||
}
|
||||
$stmtTrade->execute([
|
||||
$accountId,
|
||||
(string)($ticketBase + 992),
|
||||
'EURUSD',
|
||||
'sell',
|
||||
1.0,
|
||||
1.08620,
|
||||
null,
|
||||
1.08850,
|
||||
1.08100,
|
||||
date('Y-m-d H:i:s', time() - 7200),
|
||||
null,
|
||||
12.0,
|
||||
120.0,
|
||||
-7.0,
|
||||
0.0,
|
||||
'open',
|
||||
'Price Action',
|
||||
'M15',
|
||||
'London',
|
||||
2.2,
|
||||
'Confident',
|
||||
'تشکیل الگوی پوشای نزولی در لول مقاومت روزانه',
|
||||
'',
|
||||
'',
|
||||
'OpenTrade,Intraday'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -1,3 +1,3 @@
|
||||
<?php
|
||||
// index.php - Main entry point
|
||||
// Main entry fallback
|
||||
require __DIR__ . '/router.php';
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
import os
|
||||
import uuid
|
||||
import random
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Query
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
import shutil
|
||||
|
||||
from database import get_db_connection, init_db
|
||||
|
||||
app = FastAPI(title="سامانه هوشمند مدیریت مسائل شهری - شهرنگار")
|
||||
|
||||
# CORS setup
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
UPLOAD_DIR = os.path.join(os.path.dirname(__file__), "static", "uploads")
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
# Initialize database on startup
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
init_db()
|
||||
|
||||
# Pydantic models
|
||||
class IssueCreate(BaseModel):
|
||||
title: str
|
||||
category: str
|
||||
description: str
|
||||
address: str
|
||||
district: int
|
||||
priority: str = "medium"
|
||||
lat: float = 35.6892
|
||||
lng: float = 51.3890
|
||||
image_url: Optional[str] = None
|
||||
reporter_name: Optional[str] = "شهروند ناشناس"
|
||||
reporter_phone: Optional[str] = None
|
||||
|
||||
class CommentCreate(BaseModel):
|
||||
author_name: str
|
||||
content: str
|
||||
|
||||
class StatusUpdate(BaseModel):
|
||||
status: str
|
||||
official_response: Optional[str] = None
|
||||
timeline_title: Optional[str] = None
|
||||
timeline_desc: Optional[str] = None
|
||||
resolved_image_url: Optional[str] = None
|
||||
|
||||
# Routes
|
||||
@app.get("/api/stats")
|
||||
def get_stats():
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
total = cursor.execute("SELECT COUNT(*) FROM issues").fetchone()[0]
|
||||
pending = cursor.execute("SELECT COUNT(*) FROM issues WHERE status = 'pending'").fetchone()[0]
|
||||
reviewing = cursor.execute("SELECT COUNT(*) FROM issues WHERE status = 'reviewing'").fetchone()[0]
|
||||
in_progress = cursor.execute("SELECT COUNT(*) FROM issues WHERE status = 'in_progress'").fetchone()[0]
|
||||
resolved = cursor.execute("SELECT COUNT(*) FROM issues WHERE status = 'resolved'").fetchone()[0]
|
||||
upvotes = cursor.execute("SELECT COALESCE(SUM(upvotes), 0) FROM issues").fetchone()[0]
|
||||
|
||||
# Category counts
|
||||
cat_rows = cursor.execute("SELECT category, COUNT(*) as cnt FROM issues GROUP BY category ORDER BY cnt DESC").fetchall()
|
||||
categories = [{"category": row["category"], "count": row["cnt"]} for row in cat_rows]
|
||||
|
||||
# District counts
|
||||
dist_rows = cursor.execute("SELECT district, COUNT(*) as cnt FROM issues GROUP BY district ORDER BY district ASC").fetchall()
|
||||
districts = [{"district": row["district"], "count": row["cnt"]} for row in dist_rows]
|
||||
|
||||
# Priority counts
|
||||
prio_rows = cursor.execute("SELECT priority, COUNT(*) as cnt FROM issues GROUP BY priority").fetchall()
|
||||
priorities = {row["priority"]: row["cnt"] for row in prio_rows}
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"pending": pending,
|
||||
"reviewing": reviewing,
|
||||
"in_progress": in_progress,
|
||||
"resolved": resolved,
|
||||
"total_upvotes": upvotes,
|
||||
"categories": categories,
|
||||
"districts": districts,
|
||||
"priorities": priorities,
|
||||
"resolution_rate": round((resolved / total * 100) if total > 0 else 0, 1)
|
||||
}
|
||||
|
||||
@app.get("/api/issues")
|
||||
def list_issues(
|
||||
category: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
district: Optional[int] = None,
|
||||
priority: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
sort_by: Optional[str] = "newest"
|
||||
):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT * FROM issues WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if category and category != "all":
|
||||
query += " AND category = ?"
|
||||
params.append(category)
|
||||
|
||||
if status and status != "all":
|
||||
query += " AND status = ?"
|
||||
params.append(status)
|
||||
|
||||
if district and district > 0:
|
||||
query += " AND district = ?"
|
||||
params.append(district)
|
||||
|
||||
if priority and priority != "all":
|
||||
query += " AND priority = ?"
|
||||
params.append(priority)
|
||||
|
||||
if search:
|
||||
query += " AND (title LIKE ? OR description LIKE ? OR address LIKE ? OR tracking_code LIKE ?)"
|
||||
s_pattern = f"%{search}%"
|
||||
params.extend([s_pattern, s_pattern, s_pattern, s_pattern])
|
||||
|
||||
if sort_by == "upvotes":
|
||||
query += " ORDER BY upvotes DESC, id DESC"
|
||||
elif sort_by == "oldest":
|
||||
query += " ORDER BY id ASC"
|
||||
elif sort_by == "emergency":
|
||||
query += " ORDER BY CASE priority WHEN 'emergency' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END, id DESC"
|
||||
else: # newest
|
||||
query += " ORDER BY id DESC"
|
||||
|
||||
rows = cursor.execute(query, params).fetchall()
|
||||
|
||||
issues = []
|
||||
for r in rows:
|
||||
item = dict(r)
|
||||
# Fetch comment count
|
||||
c_count = cursor.execute("SELECT COUNT(*) FROM comments WHERE issue_id = ?", (r["id"],)).fetchone()[0]
|
||||
item["comment_count"] = c_count
|
||||
issues.append(item)
|
||||
|
||||
conn.close()
|
||||
return issues
|
||||
|
||||
@app.get("/api/issues/{issue_id}")
|
||||
def get_issue(issue_id: int):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
issue_row = cursor.execute("SELECT * FROM issues WHERE id = ?", (issue_id,)).fetchone()
|
||||
if not issue_row:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="گزارش مورد نظر یافت نشد.")
|
||||
|
||||
issue = dict(issue_row)
|
||||
|
||||
# Timeline
|
||||
timeline_rows = cursor.execute("SELECT * FROM timeline WHERE issue_id = ? ORDER BY id ASC", (issue_id,)).fetchall()
|
||||
issue["timeline"] = [dict(t) for t in timeline_rows]
|
||||
|
||||
# Comments
|
||||
comment_rows = cursor.execute("SELECT * FROM comments WHERE issue_id = ? ORDER BY id DESC", (issue_id,)).fetchall()
|
||||
issue["comments"] = [dict(c) for c in comment_rows]
|
||||
|
||||
conn.close()
|
||||
return issue
|
||||
|
||||
@app.get("/api/track/{tracking_code}")
|
||||
def track_issue(tracking_code: str):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
code = tracking_code.strip().upper()
|
||||
issue_row = cursor.execute("SELECT * FROM issues WHERE UPPER(tracking_code) = ?", (code,)).fetchone()
|
||||
if not issue_row:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail=f"هیچ گزارشی با کد رهگیری «{tracking_code}» یافت نشد.")
|
||||
|
||||
issue_id = issue_row["id"]
|
||||
issue = dict(issue_row)
|
||||
|
||||
timeline_rows = cursor.execute("SELECT * FROM timeline WHERE issue_id = ? ORDER BY id ASC", (issue_id,)).fetchall()
|
||||
issue["timeline"] = [dict(t) for t in timeline_rows]
|
||||
|
||||
comment_rows = cursor.execute("SELECT * FROM comments WHERE issue_id = ? ORDER BY id DESC", (issue_id,)).fetchall()
|
||||
issue["comments"] = [dict(c) for c in comment_rows]
|
||||
|
||||
conn.close()
|
||||
return issue
|
||||
|
||||
@app.post("/api/issues")
|
||||
def create_issue(payload: IssueCreate):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Generate unique tracking code
|
||||
rand_num = random.randint(10000, 99999)
|
||||
tracking_code = f"SHR-{rand_num}"
|
||||
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO issues (
|
||||
tracking_code, title, category, description, address, district, priority, status,
|
||||
lat, lng, image_url, reporter_name, reporter_phone, upvotes, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, 1, ?)
|
||||
""", (
|
||||
tracking_code, payload.title, payload.category, payload.description, payload.address,
|
||||
payload.district, payload.priority, payload.lat, payload.lng, payload.image_url,
|
||||
payload.reporter_name or "شهروند", payload.reporter_phone or "", now_str
|
||||
))
|
||||
|
||||
issue_id = cursor.lastrowid
|
||||
|
||||
# Add initial timeline event
|
||||
cursor.execute("""
|
||||
INSERT INTO timeline (issue_id, status, title, description, created_at)
|
||||
VALUES (?, 'pending', 'ثبت اولیه گزارش در سامانه', 'گزارش شما با موفقیت در سامانه شهرنگار ثبت گردید و در نوبت بررسی کارشناسی قرار گرفت.', ?)
|
||||
""", (issue_id, now_str))
|
||||
|
||||
conn.commit()
|
||||
|
||||
created = cursor.execute("SELECT * FROM issues WHERE id = ?", (issue_id,)).fetchone()
|
||||
res = dict(created)
|
||||
conn.close()
|
||||
|
||||
return res
|
||||
|
||||
@app.post("/api/issues/{issue_id}/upvote")
|
||||
def upvote_issue(issue_id: int):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("UPDATE issues SET upvotes = upvotes + 1 WHERE id = ?", (issue_id,))
|
||||
if cursor.rowcount == 0:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="گزارش یافت نشد.")
|
||||
|
||||
conn.commit()
|
||||
new_upvotes = cursor.execute("SELECT upvotes FROM issues WHERE id = ?", (issue_id,)).fetchone()[0]
|
||||
conn.close()
|
||||
return {"id": issue_id, "upvotes": new_upvotes}
|
||||
|
||||
@app.post("/api/issues/{issue_id}/comment")
|
||||
def add_comment(issue_id: int, payload: CommentCreate):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
check = cursor.execute("SELECT id FROM issues WHERE id = ?", (issue_id,)).fetchone()
|
||||
if not check:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="گزارش یافت نشد.")
|
||||
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
cursor.execute("""
|
||||
INSERT INTO comments (issue_id, author_name, content, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (issue_id, payload.author_name or "شهروند محترم", payload.content, now_str))
|
||||
|
||||
conn.commit()
|
||||
comment_id = cursor.lastrowid
|
||||
comment = cursor.execute("SELECT * FROM comments WHERE id = ?", (comment_id,)).fetchone()
|
||||
conn.close()
|
||||
return dict(comment)
|
||||
|
||||
@app.patch("/api/issues/{issue_id}/status")
|
||||
def update_issue_status(issue_id: int, payload: StatusUpdate):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
check = cursor.execute("SELECT * FROM issues WHERE id = ?", (issue_id,)).fetchone()
|
||||
if not check:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="گزارش یافت نشد.")
|
||||
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
resolved_at = now_str if payload.status == "resolved" else check["resolved_at"]
|
||||
|
||||
# Update issue
|
||||
cursor.execute("""
|
||||
UPDATE issues
|
||||
SET status = ?,
|
||||
official_response = COALESCE(?, official_response),
|
||||
resolved_image_url = COALESCE(?, resolved_image_url),
|
||||
resolved_at = ?
|
||||
WHERE id = ?
|
||||
""", (payload.status, payload.official_response, payload.resolved_image_url, resolved_at, issue_id))
|
||||
|
||||
# Add timeline entry if requested or automatically
|
||||
status_titles = {
|
||||
"pending": "در صف بررسی مجدد",
|
||||
"reviewing": "بررسی کارشناسی و ارجاع به معاونت مربوطه",
|
||||
"in_progress": "اعزام اکیپ اجرایی و آغاز عملیات میدانی",
|
||||
"resolved": "اتمام عملیات و رفع کامل مسئله",
|
||||
"rejected": "عدم احراز یا خارج از حیطه اختیارات شهرداری"
|
||||
}
|
||||
|
||||
tl_title = payload.timeline_title or status_titles.get(payload.status, f"تغییر وضعیت به {payload.status}")
|
||||
tl_desc = payload.timeline_desc or payload.official_response or "وضعیت پرونده توسط مدیریت سامانه بهروزرسانی شد."
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO timeline (issue_id, status, title, description, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (issue_id, payload.status, tl_title, tl_desc, now_str))
|
||||
|
||||
conn.commit()
|
||||
|
||||
updated = cursor.execute("SELECT * FROM issues WHERE id = ?", (issue_id,)).fetchone()
|
||||
res = dict(updated)
|
||||
conn.close()
|
||||
return res
|
||||
|
||||
@app.post("/api/upload")
|
||||
async def upload_file(file: UploadFile = File(...)):
|
||||
filename = f"{uuid.uuid4().hex[:10]}_{file.filename}"
|
||||
filepath = os.path.join(UPLOAD_DIR, filename)
|
||||
with open(filepath, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
return {"url": f"/static/uploads/{filename}"}
|
||||
|
||||
# Serve frontend static files
|
||||
app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "static")), name="static")
|
||||
|
||||
@app.get("/")
|
||||
def serve_index():
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse(os.path.join(os.path.dirname(__file__), "static", "index.html"))
|
||||
+42
-25
@@ -1,45 +1,62 @@
|
||||
<?php
|
||||
// router.php - Front controller & router for PHP built-in server
|
||||
/**
|
||||
* Built-in PHP Router for Forex Trading Journal
|
||||
*/
|
||||
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
$uri = urldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
|
||||
|
||||
// 1. Route API requests
|
||||
if (strpos($uri, '/api/') === 0) {
|
||||
// API Router
|
||||
if (strpos($uri, '/api/') === 0 || $uri === '/api') {
|
||||
require __DIR__ . '/api.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. Serve static files if they exist
|
||||
$filePath = __DIR__ . $uri;
|
||||
if ($uri !== '/' && file_exists($filePath) && !is_dir($filePath)) {
|
||||
$ext = pathinfo($filePath, PATHINFO_EXTENSION);
|
||||
// Uploaded Images
|
||||
if (strpos($uri, '/uploads/') === 0) {
|
||||
$filePath = __DIR__ . $uri;
|
||||
if (file_exists($filePath) && is_file($filePath)) {
|
||||
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
$mimes = [
|
||||
'jpg' => 'image/jpeg',
|
||||
'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'webp' => 'image/webp',
|
||||
'gif' => 'image/gif'
|
||||
];
|
||||
$mime = $mimes[$ext] ?? 'application/octet-stream';
|
||||
header('Content-Type: ' . $mime);
|
||||
readfile($filePath);
|
||||
exit;
|
||||
}
|
||||
http_response_code(404);
|
||||
echo "File not found";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Static Files
|
||||
$staticFile = __DIR__ . '/static' . $uri;
|
||||
if (file_exists($staticFile) && is_file($staticFile)) {
|
||||
$ext = strtolower(pathinfo($staticFile, PATHINFO_EXTENSION));
|
||||
$mimes = [
|
||||
'css' => 'text/css',
|
||||
'js' => 'application/javascript',
|
||||
'json' => 'application/json',
|
||||
'css' => 'text/css; charset=utf-8',
|
||||
'js' => 'application/javascript; charset=utf-8',
|
||||
'json' => 'application/json; charset=utf-8',
|
||||
'png' => 'image/png',
|
||||
'jpg' => 'image/jpeg',
|
||||
'jpeg' => 'image/jpeg',
|
||||
'gif' => 'image/gif',
|
||||
'svg' => 'image/svg+xml',
|
||||
'ico' => 'image/x-icon',
|
||||
'woff' => 'font/woff',
|
||||
'woff2'=> 'font/woff2',
|
||||
'ttf' => 'font/ttf',
|
||||
'html' => 'text/html; charset=utf-8'
|
||||
];
|
||||
|
||||
if (isset($mimes[$ext])) {
|
||||
header('Content-Type: ' . $mimes[$ext]);
|
||||
}
|
||||
readfile($filePath);
|
||||
$mime = $mimes[$ext] ?? 'text/plain; charset=utf-8';
|
||||
header('Content-Type: ' . $mime);
|
||||
readfile($staticFile);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3. Serve Frontend Application
|
||||
if (file_exists(__DIR__ . '/static/index.html')) {
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
readfile(__DIR__ . '/static/index.html');
|
||||
exit;
|
||||
}
|
||||
|
||||
echo "سامانه مدیریت مسائل شهری شهرنگار فعال است.";
|
||||
// Default Single Page Application Entry
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
readfile(__DIR__ . '/static/index.html');
|
||||
|
||||
+765
-725
File diff suppressed because it is too large
Load Diff
+1101
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* Forex Trading Journal Pro • Myfxbook Style Theme
|
||||
*/
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;700&display=swap');
|
||||
|
||||
:root {
|
||||
--font-main: 'Vazirmatn', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
/* Dark Theme Default */
|
||||
--bg-main: #0B0F19;
|
||||
--bg-card: #111827;
|
||||
--bg-card-hover: #1F2937;
|
||||
--bg-input: #1A2234;
|
||||
--border-color: #1F293D;
|
||||
--text-primary: #F3F4F6;
|
||||
--text-secondary: #9CA3AF;
|
||||
--text-muted: #6B7280;
|
||||
|
||||
--color-green: #10B981;
|
||||
--color-green-soft: rgba(16, 185, 129, 0.15);
|
||||
--color-red: #EF4444;
|
||||
--color-red-soft: rgba(239, 68, 68, 0.15);
|
||||
--color-blue: #3B82F6;
|
||||
--color-blue-soft: rgba(59, 130, 246, 0.15);
|
||||
--color-purple: #8B5CF6;
|
||||
--color-amber: #F59E0B;
|
||||
}
|
||||
|
||||
body.light-mode {
|
||||
--bg-main: #F4F6F9;
|
||||
--bg-card: #FFFFFF;
|
||||
--bg-card-hover: #F8FAFC;
|
||||
--bg-input: #EDF2F7;
|
||||
--border-color: #E2E8F0;
|
||||
--text-primary: #0F172A;
|
||||
--text-secondary: #475569;
|
||||
--text-muted: #94A3B8;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-main);
|
||||
background-color: var(--bg-main);
|
||||
color: var(--text-primary);
|
||||
direction: rtl;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
transition: background-color 0.25s ease, color 0.25s ease;
|
||||
}
|
||||
|
||||
.num-font {
|
||||
font-family: var(--font-mono);
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-main);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #374151;
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #4B5563;
|
||||
}
|
||||
|
||||
/* Cards & Glassmorphism */
|
||||
.glass-card {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.25);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, var(--color-blue), var(--color-purple));
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.nav-tab {
|
||||
padding: 10px 18px;
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-tab:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-input);
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
color: #FFFFFF;
|
||||
background: linear-gradient(135deg, #2563EB, #1D4ED8);
|
||||
box-shadow: 0 4px 14px rgba(37, 99, 235, 0.35);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge-buy {
|
||||
background-color: var(--color-green-soft);
|
||||
color: var(--color-green);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge-sell {
|
||||
background-color: var(--color-red-soft);
|
||||
color: var(--color-red);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.profit-pos {
|
||||
color: var(--color-green);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.profit-neg {
|
||||
color: var(--color-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Heatmap Grid Cell */
|
||||
.heatmap-cell {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.heatmap-cell:hover {
|
||||
transform: scale(1.05);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Modal Animations */
|
||||
.modal-backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(4px);
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
|
||||
animation: modalIn 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes modalIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Table styles */
|
||||
.custom-table th {
|
||||
background-color: rgba(17, 24, 39, 0.6);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.custom-table td {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid rgba(31, 41, 61, 0.4);
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.custom-table tr:hover td {
|
||||
background-color: var(--bg-card-hover);
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user