config()->Config('montzar_base_url') ?: 'https://montpar.ir', '/' ); } private function endpoint(string $path): string { return $this->baseUrl() . '/' . ltrim($path, '/'); } /** Pending HTTP با همان گزینه‌های پایدار (بدون پروکسی / IPv4 / HTTP1.1) */ private function http(float $timeout = 10) { $extraCurl = defined('CURLOPT_TCP_FASTOPEN') ? [(int) constant('CURLOPT_TCP_FASTOPEN') => 1] : []; return Http::timeout($timeout) ->acceptJson() ->withOptions(Controller::httpClientOptions($extraCurl, $timeout)); } public function montzarLogin(): string { try { $response = $this->http(10) ->post($this->endpoint('/server/auth/login'), [ 'mobile' => $this->config()->Config('montzar_mobile'), 'password' => $this->config()->Config('montzar_password'), 'provider' => 'customer', 'type_app' => 'client', ]) ->json(); } catch (\Throwable $e) { Log::error('Montzar login request failed', [ 'base_url' => $this->baseUrl(), 'error' => $e->getMessage(), ]); throw new UnprocessableEntityHttpException( 'سرویس موقتا در دسترس نیست، لطفاً با پشتیبانی تماس بگیرید.' ); } $token = is_array($response) ? ($response['access_token'] ?? $response['token'] ?? null) : null; if (!$token) { Log::error('Montzar login failed', [ 'base_url' => $this->baseUrl(), 'mobile' => $this->config()->Config('montzar_mobile'), 'response' => $response, ]); // 422 تا فرانت toast نشان دهد؛ جزئیات فنی فقط در لاگ می‌ماند throw new UnprocessableEntityHttpException( 'سرویس موقتا در دسترس نیست، لطفاً با پشتیبانی تماس بگیرید.' ); } Setting::updateOrCreate( ['key' => 'montzar_token'], ['value' => $token, 'is_active' => 1, 'can_edit' => 1] ); return $token; } public function getToken(): string { $token = trim((string) $this->config()->Config('montzar_token')); if (!$token) { $token = $this->montzarLogin(); } return $token; } public function productList(): array { $attempts = 3; $lastError = null; for ($attempt = 1; $attempt <= $attempts; $attempt++) { try { return $this->productListOnce(); } catch (\Throwable $e) { $lastError = $e; Log::warning('Montzar productList attempt failed', [ 'attempt' => $attempt, 'max' => $attempts, 'base_url' => $this->baseUrl(), 'error' => $e->getMessage(), ]); if ($attempt < $attempts) { usleep(250_000 * $attempt); } } } throw $lastError ?? new \RuntimeException('Montzar product list failed'); } private function productListOnce(): array { $token = $this->getToken(); try { $response = $this->http(7) ->withToken($token) ->get($this->endpoint('/server/customer/product/list')) ->throw() ->json(); } catch (\Throwable $e) { // توکن مرده / قطع شبکه — یک بار لاگین مجدد $token = $this->montzarLogin(); $response = $this->http(7) ->withToken($token) ->get($this->endpoint('/server/customer/product/list')) ->throw() ->json(); } if ((isset($response['message']) && $response['message'] === 'no authorised') || $response === null) { $token = $this->montzarLogin(); $response = $this->http(7) ->withToken($token) ->get($this->endpoint('/server/customer/product/list')) ->throw() ->json(); } if (!is_array($response)) { throw new \Exception('Montzar product list response is not array'); } return $response; } /** * @return array */ public function listProductOptions(): array { try { $response = $this->productList(); } catch (\Throwable $e) { Log::warning('Montzar product options failed', ['error' => $e->getMessage()]); return []; } if (! is_array($response) || (isset($response['message']) && ! array_is_list($response))) { return []; } return collect($response) ->filter(fn ($row) => is_array($row) && array_key_exists('id', $row)) ->map(function (array $row) { $sub = is_array($row['sub_agent'] ?? null) ? $row['sub_agent'] : []; $buy = (float) ($sub['rate_buy_gram_price'] ?? 0); $sell = (float) ($sub['rate_sell_gram_price'] ?? 0); $price = ($buy > 0 && $sell > 0) ? (int) round(($buy + $sell) / 2) : (int) round(max($buy, $sell)); $id = (int) ($row['id'] ?? 0); $name = trim((string) ( $row['name_fa'] ?? $row['name'] ?? $row['title'] ?? $row['product_name'] ?? '' )); return [ 'id' => (string) $id, 'name' => $name !== '' ? $name : ('محصول #' . $id), 'price' => $price, 'buy' => (int) round($buy), 'sell' => (int) round($sell), ]; }) ->filter(fn (array $row) => $row['id'] !== '' && $row['id'] !== '0') ->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE) ->values() ->all(); } /** * مانده طلا/ریال اکانت اپ روی نود همکار (دفتر پنل همکار — نه ته‌حساب اپ). * * @return list */ public function fetchColleagueLedgerMandeh(): array { $body = $this->requestColleagueLedger('GET', '/server/customer/fin_tahesab/mandeh'); if (!is_array($body)) { throw new UnprocessableEntityHttpException('پاسخ نامعتبر مانده دفتر همکار'); } // بعضی نودها آرایهٔ مستقیم برمی‌گردانند؛ بعضی داخل data if (isset($body[0]) || $body === []) { return array_values($body); } if (isset($body['data']) && is_array($body['data'])) { return array_values($body['data']); } return [$body]; } /** * ریز اسناد دفتر اکانت روی نود همکار در بازهٔ تاریخ میلادی Y-m-d. * * @return list> */ public function fetchColleagueLedgerTable(string $startDate, string $endDate): array { $body = $this->requestColleagueLedger('POST', '/server/customer/fin_tahesab/table', [ 'startDate' => $startDate, 'endDate' => $endDate, ]); if (!is_array($body)) { throw new UnprocessableEntityHttpException('پاسخ نامعتبر ریز دفتر همکار'); } if (isset($body[0]) || $body === []) { return array_values($body); } if (isset($body['data']) && is_array($body['data'])) { return array_values($body['data']); } if (isset($body['rows']) && is_array($body['rows'])) { return array_values($body['rows']); } return []; } /** * @param array|null $payload * @return array|null */ private function requestColleagueLedger(string $method, string $path, ?array $payload = null): ?array { $token = $this->getToken(); $url = $this->endpoint($path); $httpResponse = $this->sendColleagueLedgerHttp($method, $url, $token, $payload); $body = $this->decodeMontzarHttpBody($httpResponse); if (is_array($body) && ($body['message'] ?? null) === 'no authorised') { $token = $this->montzarLogin(); $httpResponse = $this->sendColleagueLedgerHttp($method, $url, $token, $payload); $body = $this->decodeMontzarHttpBody($httpResponse); } if ($httpResponse->failed() && !is_array($body)) { Log::warning('Colleague ledger request failed', [ 'method' => $method, 'url' => $url, 'status' => $httpResponse->status(), 'raw' => $httpResponse->body(), ]); throw new UnprocessableEntityHttpException( $this->formatMontzarErrorMessage(null, $httpResponse) ); } if (is_array($body) && !empty($body['message']) && is_string($body['message']) && !isset($body[0])) { throw new UnprocessableEntityHttpException($body['message']); } return $body; } private function sendColleagueLedgerHttp(string $method, string $url, string $token, ?array $payload) { $pending = $this->http(30)->withToken($token); if (strtoupper($method) === 'GET') { return $pending->get($url, $payload ?? []); } return $pending->post($url, $payload ?? []); } /** * آیا پاسخ همکار به‌خاطر کهنه بودن مظنه رد شده؟ */ public static function isQuoteChangedMessage(?string $message): bool { if ($message === null || trim($message) === '') { return false; } $normalized = str_replace( ['ي', 'ك', 'ة', '!', '?', '؟', '.'], ['ی', 'ک', 'ه', '', '', '', ''], $message ); $normalized = preg_replace('/\s+/u', ' ', $normalized) ?? $normalized; $normalized = trim($normalized); if ($normalized === '') { return false; } if (mb_stripos($normalized, 'تغییر در مظنه') !== false) { return true; } if (mb_stripos($normalized, 'مظنه') !== false && mb_stripos($normalized, 'تغییر') !== false) { return true; } $latin = mb_strtolower($normalized); return str_contains($latin, 'quote') && (str_contains($latin, 'change') || str_contains($latin, 'changed')); } /** * ثبت سفارش در منتظر — قبل از ایجاد سفارش/تراکنش محلی صدا زده می‌شود. * * @return array{ok: bool, provider_id: int|null, error: string|null} */ public function submitOrder(array &$data): array { $attempt = $this->submitOrderOnce($data); if ( !($attempt['parsed']['ok'] ?? false) && self::isQuoteChangedMessage($attempt['parsed']['error'] ?? null) ) { Log::info('Montzar quote changed — refreshing mazaneh and retrying', [ 'error' => $attempt['parsed']['error'] ?? null, 'old_mazaneh' => $attempt['payload']['mazaneh'] ?? null, ]); $refreshed = false; try { $refreshed = $this->syncQuoteSettingsFromProvider(); } catch (\Throwable $e) { Log::warning('Montzar quote refresh failed', [ 'error' => $e->getMessage(), ]); } if (!$refreshed) { $refreshed = $this->applyQuoteFromErrorBody( is_array($attempt['body'] ?? null) ? $attempt['body'] : null, (int) ($attempt['payload']['mazaneh'] ?? 0) ); } if ($refreshed) { $attempt = $this->submitOrderOnce($data); } } $parsed = $attempt['parsed']; if (!$parsed['ok']) { Log::warning('Montzar order rejected', [ 'base_url' => $this->baseUrl(), 'status' => $attempt['http']->status(), 'body' => $attempt['body'], 'raw' => $attempt['http']->body(), 'payload' => $attempt['payload'], 'reason' => $parsed['error'], ]); throw new UnprocessableEntityHttpException( $this->formatMontzarErrorMessage($parsed['error'], $attempt['http']) ); } return [ 'ok' => true, 'provider_id' => $parsed['provider_id'] ?? null, 'error' => null, ]; } public function applyParsedOrder(array &$data, array $parsed): void { $this->syncOrderWithMontzarResponse($data, $parsed); } public function sendOrder(array &$data) { $parsed = $this->submitOrder($data); $this->applyParsedOrder($data, $parsed); return null; } /** * @return array{parsed: array, body: mixed, payload: array, http: \Illuminate\Http\Client\Response} */ private function submitOrderOnce(array &$data): array { $payload = $this->makeOrderPayload($data); $token = $this->getToken(); $url = $this->endpoint('/server/customer/order'); $httpResponse = $this->postToMontzar($url, $token, $payload); $body = $this->decodeMontzarHttpBody($httpResponse); if (is_array($body) && ($body['message'] ?? null) === 'no authorised') { $token = $this->montzarLogin(); $httpResponse = $this->postToMontzar($url, $token, $payload); $body = $this->decodeMontzarHttpBody($httpResponse); } $this->logMontzarOrderResponse($httpResponse, $body); return [ 'parsed' => $this->parseMontzarOrderResponse($body, (int) $data['direction']), 'body' => $body, 'payload' => $payload, 'http' => $httpResponse, ]; } /** * مظنهٔ تازهٔ همکار را از لیست محصول روی settings می‌نویسد. */ public function syncQuoteSettingsFromProvider(): bool { $products = $this->productList(); if (isset($products['message']) && !array_is_list($products)) { Log::warning('Montzar quote refresh got message payload', [ 'message' => $products['message'], ]); return false; } $productId = (int) ($this->config()->Config('montzar_product_id') ?: 25); $product = collect($products)->firstWhere('id', $productId); if (!is_array($product) || !isset($product['sub_agent']) || !is_array($product['sub_agent'])) { Log::warning('Montzar quote refresh: product not found', [ 'product_id' => $productId, ]); return false; } $sub = $product['sub_agent']; $buyGram = (float) ($sub['rate_buy_gram_price'] ?? 0); $sellGram = (float) ($sub['rate_sell_gram_price'] ?? 0); $buyMazaneh = $sub['rate_buy_price'] ?? 0; $sellMazaneh = $sub['rate_sell_price'] ?? 0; if ((int) $buyMazaneh <= 0 && (int) $sellMazaneh <= 0) { return false; } $mid = ($buyGram > 0 && $sellGram > 0) ? (($buyGram + $sellGram) / 2) : 0; $diff = ($buyGram > 0 && $sellGram > 0) ? (($buyGram - $sellGram) / 2) : 0; if ($mid > 0) { $this->config()->UpdateConfig('montzar_live_price', (float) $mid); $this->config()->UpdateConfig('montzar_diff', (float) $diff); $this->config()->UpdateConfig('montzar_mazaneh', ((float) $mid * 4.331802)); } if ((int) $buyMazaneh > 0) { $this->config()->UpdateConfig('montzar_truly_buy_mazaneh', $buyMazaneh); } if ((int) $sellMazaneh > 0) { $this->config()->UpdateConfig('montzar_truly_sell_mazaneh', $sellMazaneh); } $this->config()->UpdateConfig( 'montzar_last_product_log', json_encode($product, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) ); Log::info('Montzar mazaneh refreshed before order retry', [ 'buy_mazaneh' => $buyMazaneh, 'sell_mazaneh' => $sellMazaneh, 'live_price' => $mid, ]); return true; } /** * اگر خودِ خطای همکار مظنهٔ جدید را برگرداند، همان را ذخیره کن. * مظنهٔ اکو‌شدهٔ همان درخواست قبلی را دوباره ذخیره نکن. * * @param array|null $body */ private function applyQuoteFromErrorBody(?array $body, int $previousMazaneh): bool { if ($body === null) { return false; } $data = is_array($body['data'] ?? null) ? $body['data'] : $body; if (!is_array($data)) { return false; } $buyMazaneh = $this->firstPositiveNumber($data, [ 'rate_buy_price', 'buy_mazaneh', 'mazaneh_buy', 'truly_buy_mazaneh', ]); $sellMazaneh = $this->firstPositiveNumber($data, [ 'rate_sell_price', 'sell_mazaneh', 'mazaneh_sell', 'truly_sell_mazaneh', ]); $mazaneh = $this->firstPositiveNumber($data, ['new_mazaneh', 'new_quote']); $updated = false; if ($buyMazaneh !== null && $buyMazaneh !== $previousMazaneh) { $this->config()->UpdateConfig('montzar_truly_buy_mazaneh', $buyMazaneh); $updated = true; } if ($sellMazaneh !== null && $sellMazaneh !== $previousMazaneh) { $this->config()->UpdateConfig('montzar_truly_sell_mazaneh', $sellMazaneh); $updated = true; } if (!$updated && $mazaneh !== null && $mazaneh !== $previousMazaneh) { $this->config()->UpdateConfig('montzar_truly_buy_mazaneh', $mazaneh); $this->config()->UpdateConfig('montzar_truly_sell_mazaneh', $mazaneh); $updated = true; } if ($updated) { Log::info('Montzar mazaneh taken from error body', [ 'buy_mazaneh' => $buyMazaneh, 'sell_mazaneh' => $sellMazaneh, 'mazaneh' => $mazaneh, ]); } return $updated; } /** * @param array $data * @param list $keys */ private function firstPositiveNumber(array $data, array $keys): ?int { foreach ($keys as $key) { if (!isset($data[$key]) || !is_numeric($data[$key])) { continue; } $value = (int) $data[$key]; if ($value > 0) { return $value; } } return null; } private function makeOrderPayload(array $data): array { $mazaneh = ((int) $data['direction'] === 1) ? $this->config()->Config('montzar_truly_buy_mazaneh') : $this->config()->Config('montzar_truly_sell_mazaneh'); $montzarPrice = (float) $this->config()->Config('montzar_live_price'); $cash = (int) round( $data['total_price'] ?? ($montzarPrice * (float) $data['amount']) ); $customerName = auth('customer')->check() ? auth('customer')->user()->name : ($data['customer']['name'] ?? ''); $payload = [ 'mazaneh' => (int) $mazaneh, 'amount' => (float) $data['amount'], 'description' => $this->config()->Config('name_fa') . ' - ' . $customerName, 'direction' => (int) $data['direction'], 'product_id' => (int) ($this->config()->Config('montzar_product_id') ?: 25), 'cash' => $cash, 'input_mode' => 'gold', ]; if ( $this->config()->getBool('montzar_order_wait_callback', true) && isset($data['order']) && $data['order'] instanceof Order ) { $payload['callback'] = app(ColleagueOrderCallbackService::class)->callbackUrl((int) $data['order']->id); } return $payload; } /** * وضعیت سفارش روی نود همکار — اول GET مسیر+شناسه، اگر sts نبود POST جدول همان مسیر. * * @return array|null */ public function fetchColleagueOrder(int $providerOrderId): ?array { if ($providerOrderId <= 0) { return null; } $path = trim($this->config()->getString('montzar_order_status_path', '/server/customer/order')); if ($path === '') { $path = '/server/customer/order'; } $path = rtrim($path, '/'); $got = $this->montzarJson('GET', $this->endpoint($path . '/' . $providerOrderId)); $row = $this->colleagueOrderRowFromBody($got, $providerOrderId); if ($row) { return $row; } foreach ($this->colleagueOrderTableUrls($path) as $url) { $body = $this->montzarJson('POST', $url, [ 'filters' => [[ 'field' => 'id', 'cr' => 'and', 'type' => 'text', 'op' => 'eq', 'value' => (string) $providerOrderId, ]], 'limit' => 12, 'page' => 1, 'search' => '', ]); $row = $this->colleagueOrderRowFromBody($body, $providerOrderId); if ($row) { return $row; } } return is_array($got) ? $got : null; } /** * @return list */ private function colleagueOrderTableUrls(string $path): array { $urls = [$this->endpoint($path . '/table')]; if (str_ends_with($path, '/order') && !str_contains($path, '/single/')) { $urls[] = $this->endpoint(str_replace('/customer/order', '/customer/single/order', $path) . '/table'); } return array_values(array_unique($urls)); } /** * @param array|null $body * @return array|null */ private function colleagueOrderRowFromBody(?array $body, int $providerOrderId): ?array { if (!is_array($body)) { return null; } $candidates = [$body, $body['data'] ?? null, $body['order'] ?? null]; if (isset($body['rows']) && is_array($body['rows'])) { foreach ($body['rows'] as $row) { if (is_array($row)) { $candidates[] = $row; } } } $matched = null; foreach ($candidates as $row) { if (!is_array($row) || !array_key_exists('sts', $row)) { continue; } $id = (int) ($row['id'] ?? $row['order_id'] ?? 0); if ($id === $providerOrderId) { return $row; } if ($id === 0 && $matched === null) { $matched = $row; } } return $matched; } /** * @param array $payload * @return array|null */ private function montzarJson(string $method, string $url, array $payload = []): ?array { $token = $this->getToken(); try { $http = $this->http(15)->withToken($token); $httpResponse = strtoupper($method) === 'POST' ? $http->post($url, $payload) : $http->get($url); } catch (\Throwable $e) { Log::warning('Montzar order status request failed', [ 'url' => $url, 'error' => $e->getMessage(), ]); return null; } $body = $this->decodeMontzarHttpBody($httpResponse); if (is_array($body) && ($body['message'] ?? null) === 'no authorised') { $token = $this->montzarLogin(); $http = $this->http(15)->withToken($token); $httpResponse = strtoupper($method) === 'POST' ? $http->post($url, $payload) : $http->get($url); $body = $this->decodeMontzarHttpBody($httpResponse); } return is_array($body) ? $body : null; } private function postToMontzar(string $url, string $token, array $payload) { return $this->http(15) ->withToken($token) ->post($url, $payload); } private function decodeMontzarHttpBody($httpResponse): ?array { $body = $httpResponse->json(); if (is_array($body)) { return $body; } $raw = trim((string) $httpResponse->body()); if ($raw === '') { return null; } $decoded = json_decode($raw, true); if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { return $decoded; } return null; } private function logMontzarOrderResponse($httpResponse, $body): void { try { $value = $body === null ? $httpResponse->body() : json_encode($body, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); // مربوط به منتزر: settings $this->config()->UpdateConfig('montzar_order_response', $value); // عمومی: اگر در const/Cnt داری همان‌جا بماند Cnt::where('key', 'last_order_log')->update(['value' => $value]); } catch (\Exception $e) { Log::warning('Could not persist montzar order response', [ 'error' => $e->getMessage(), ]); } } private function formatMontzarErrorMessage(?string $error, $httpResponse): string { if ($error && $error !== 'پاسخ نامعتبر از سرور منتظر (اتصال یا فرمت JSON)') { return $error; } $status = $httpResponse->status(); $raw = trim((string) $httpResponse->body()); if ($raw === '') { return "خطا در ارتباط با منتظر (HTTP {$status})"; } if (stripos($raw, ' false, 'provider_id' => null, 'error' => 'پاسخ نامعتبر از سرور منتظر (اتصال یا فرمت JSON)', ]; } if (!empty($body['message']) && is_string($body['message'])) { return [ 'ok' => false, 'provider_id' => null, 'error' => $body['message'], ]; } $providerId = $this->extractProviderOrderId($body); if ($providerId !== null) { return [ 'ok' => true, 'provider_id' => $providerId, 'error' => null, ]; } $sideKey = $direction === 1 ? 'buy' : 'sell'; if (($body[$sideKey] ?? null) === true) { return [ 'ok' => true, 'provider_id' => null, 'error' => null, ]; } foreach ($body as $item) { if (!is_array($item)) { continue; } if (!empty($item['id']) && is_numeric($item['id'])) { return [ 'ok' => true, 'provider_id' => (int) $item['id'], 'error' => null, ]; } if (!empty($item['order']['id'])) { return [ 'ok' => true, 'provider_id' => (int) $item['order']['id'], 'error' => null, ]; } if (isset($item['sts']) && in_array((int) $item['sts'], [1, 2, 4], true)) { return [ 'ok' => true, 'provider_id' => !empty($item['id']) ? (int) $item['id'] : null, 'error' => null, ]; } } if (!empty($body['pending']) || !empty($body[0]['pending'])) { return [ 'ok' => true, 'provider_id' => $this->extractProviderOrderId($body), 'error' => null, ]; } return [ 'ok' => false, 'provider_id' => null, 'error' => 'سفارشات توسط ارائه دهنده بسته شده است.', ]; } private function extractProviderOrderId(array $body): ?int { if (!empty($body['id'])) { return (int) $body['id']; } if (!empty($body['order_id']) && is_numeric($body['order_id'])) { return (int) $body['order_id']; } if (!empty($body['order']['id'])) { return (int) $body['order']['id']; } foreach ([0, 1, 2] as $index) { if (isset($body[$index]) && is_array($body[$index])) { if (!empty($body[$index]['id'])) { return (int) $body[$index]['id']; } if (!empty($body[$index]['order_id']) && is_numeric($body[$index]['order_id'])) { return (int) $body[$index]['order_id']; } if (!empty($body[$index]['order']['id'])) { return (int) $body[$index]['order']['id']; } } } foreach (['buy', 'sell'] as $sideKey) { if (!isset($body[$sideKey]) || !is_array($body[$sideKey])) { continue; } if (!empty($body[$sideKey]['id'])) { return (int) $body[$sideKey]['id']; } if (!empty($body[$sideKey]['order']['id'])) { return (int) $body[$sideKey]['order']['id']; } } return null; } private function syncOrderWithMontzarResponse(array &$data, array $parsed): void { $montzarDiff = (float) $this->config()->Config('montzar_diff'); $montzarPrice = (float) $this->config()->Config('montzar_live_price'); $totalPriceMontzar = ((int) $data['direction'] === 1) ? ($montzarPrice + $montzarDiff) : ($montzarPrice - $montzarDiff); $providerOrderId = $parsed['provider_id'] ?? null; if (isset($data['order']) && $data['order'] instanceof Order) { if ($providerOrderId !== null && Schema::hasColumn('orders', 'provider_id')) { $data['order']->provider_id = $providerOrderId; } if (Schema::hasColumn('orders', 'total_price_montzar')) { $data['order']->total_price_montzar = $totalPriceMontzar; } $data['order']->save(); } $data['total_price_montzar'] = $totalPriceMontzar; if ($providerOrderId !== null) { $data['provider_id'] = $providerOrderId; } } }