Files
boursecu_server/app/Services/MontzarService.php
T

622 lines
20 KiB
PHP
Raw Normal View History

2026-05-24 13:56:33 +03:30
<?php
namespace App\Services;
2026-06-27 13:07:15 +03:30
use App\Http\Controllers\Controller;
2026-05-24 22:29:43 +03:30
use App\Models\Cnt;
use App\Models\Order;
2026-06-14 12:01:20 +03:30
use App\Models\Setting;
2026-05-24 13:56:33 +03:30
use Illuminate\Support\Facades\Http;
2026-05-24 22:29:43 +03:30
use Illuminate\Support\Facades\Log;
2026-05-25 12:47:04 +03:30
use Illuminate\Support\Facades\Schema;
2026-05-24 22:29:43 +03:30
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
2026-05-24 13:56:33 +03:30
2026-05-25 12:47:04 +03:30
class MontzarService
2026-05-24 13:56:33 +03:30
{
2026-05-25 12:47:04 +03:30
private function config(): ConfigService
{
return app(ConfigService::class);
}
2026-05-24 22:29:43 +03:30
private function baseUrl(): string
{
return rtrim(
2026-05-25 12:47:04 +03:30
$this->config()->Config('montzar_base_url') ?: 'https://montpar.ir',
2026-05-24 22:29:43 +03:30
'/'
);
}
private function endpoint(string $path): string
{
return $this->baseUrl() . '/' . ltrim($path, '/');
}
2026-05-25 12:47:04 +03:30
2026-08-26 21:47:09 +03:30
/** 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));
}
2026-05-25 12:47:04 +03:30
public function montzarLogin(): string
2026-05-24 19:22:09 +03:30
{
2026-08-01 14:04:01 +03:30
try {
2026-08-26 21:47:09 +03:30
$response = $this->http(10)
2026-08-01 14:04:01 +03:30
->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(
'سرویس موقتا در دسترس نیست، لطفاً با پشتیبانی تماس بگیرید.'
);
}
2026-05-24 13:56:33 +03:30
2026-08-01 14:04:01 +03:30
$token = is_array($response)
? ($response['access_token'] ?? $response['token'] ?? null)
: null;
2026-05-24 19:22:09 +03:30
if (!$token) {
2026-06-14 12:01:20 +03:30
Log::error('Montzar login failed', [
'base_url' => $this->baseUrl(),
'mobile' => $this->config()->Config('montzar_mobile'),
'response' => $response,
]);
2026-08-01 14:04:01 +03:30
// 422 تا فرانت toast نشان دهد؛ جزئیات فنی فقط در لاگ می‌ماند
throw new UnprocessableEntityHttpException(
'سرویس موقتا در دسترس نیست، لطفاً با پشتیبانی تماس بگیرید.'
);
2026-05-24 19:22:09 +03:30
}
2026-06-14 12:01:20 +03:30
Setting::updateOrCreate(
['key' => 'montzar_token'],
['value' => $token, 'is_active' => 1, 'can_edit' => 1]
2026-06-14 12:01:20 +03:30
);
2026-05-24 19:22:09 +03:30
return $token;
2026-05-24 13:56:33 +03:30
}
2026-05-24 22:29:43 +03:30
public function getToken(): string
{
2026-05-25 12:47:04 +03:30
$token = trim((string) $this->config()->Config('montzar_token'));
2026-05-24 22:29:43 +03:30
if (!$token) {
2026-05-25 12:47:04 +03:30
$token = $this->montzarLogin();
2026-05-24 22:29:43 +03:30
}
return $token;
}
public function productList(): array
2026-08-26 21:47:09 +03:30
{
$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
2026-05-24 22:29:43 +03:30
{
$token = $this->getToken();
2026-08-26 21:47:09 +03:30
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();
}
2026-05-24 22:29:43 +03:30
if ((isset($response['message']) && $response['message'] === 'no authorised') || $response === null) {
2026-05-25 12:47:04 +03:30
$token = $this->montzarLogin();
2026-05-24 22:29:43 +03:30
2026-08-26 21:47:09 +03:30
$response = $this->http(7)
2026-05-24 22:29:43 +03:30
->withToken($token)
->get($this->endpoint('/server/customer/product/list'))
2026-08-26 21:47:09 +03:30
->throw()
2026-05-24 22:29:43 +03:30
->json();
}
if (!is_array($response)) {
2026-05-25 12:47:04 +03:30
throw new \Exception('Montzar product list response is not array');
2026-05-24 22:29:43 +03:30
}
return $response;
}
/**
* @return array<int, array{id: string, name: string, price: int, buy: int, sell: int}>
*/
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();
}
2026-08-03 18:44:27 +03:30
/**
* مانده طلا/ریال اکانت اپ روی نود همکار (دفتر پنل همکار — نه ته‌حساب اپ).
*
* @return list<array{name_eng?: string, name_fa?: string, value?: float|int, unit?: string, isKol?: bool}>
*/
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<array<string, mixed>>
*/
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<string, mixed>|null $payload
* @return array<mixed>|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)
{
2026-08-26 21:47:09 +03:30
$pending = $this->http(30)->withToken($token);
2026-08-03 18:44:27 +03:30
if (strtoupper($method) === 'GET') {
return $pending->get($url, $payload ?? []);
}
return $pending->post($url, $payload ?? []);
}
2026-07-02 21:24:26 +03:30
/**
* ثبت سفارش در منتظر — قبل از ایجاد سفارش/تراکنش محلی صدا زده می‌شود.
*
* @return array{ok: bool, provider_id: int|null, error: string|null}
*/
public function submitOrder(array &$data): array
2026-05-24 22:29:43 +03:30
{
$payload = $this->makeOrderPayload($data);
$token = $this->getToken();
2026-05-25 12:47:04 +03:30
$httpResponse = $this->postToMontzar(
2026-05-24 22:29:43 +03:30
$this->endpoint('/server/customer/order'),
$token,
$payload
);
2026-05-25 12:47:04 +03:30
$body = $this->decodeMontzarHttpBody($httpResponse);
2026-05-24 22:29:43 +03:30
if (is_array($body) && ($body['message'] ?? null) === 'no authorised') {
2026-05-25 12:47:04 +03:30
$token = $this->montzarLogin();
2026-05-24 22:29:43 +03:30
2026-05-25 12:47:04 +03:30
$httpResponse = $this->postToMontzar(
2026-05-24 22:29:43 +03:30
$this->endpoint('/server/customer/order'),
$token,
$payload
);
2026-05-25 12:47:04 +03:30
$body = $this->decodeMontzarHttpBody($httpResponse);
2026-05-24 22:29:43 +03:30
}
2026-05-25 12:47:04 +03:30
$this->logMontzarOrderResponse($httpResponse, $body);
2026-05-24 22:29:43 +03:30
2026-05-25 12:47:04 +03:30
$parsed = $this->parseMontzarOrderResponse($body, (int) $data['direction']);
2026-05-24 22:29:43 +03:30
if (!$parsed['ok']) {
2026-05-25 12:47:04 +03:30
Log::warning('Montzar order rejected', [
2026-05-24 22:29:43 +03:30
'base_url' => $this->baseUrl(),
'status' => $httpResponse->status(),
'body' => $body,
'raw' => $httpResponse->body(),
'payload' => $payload,
'reason' => $parsed['error'],
]);
throw new UnprocessableEntityHttpException(
2026-05-25 12:47:04 +03:30
$this->formatMontzarErrorMessage($parsed['error'], $httpResponse)
2026-05-24 22:29:43 +03:30
);
}
2026-07-02 21:24:26 +03:30
return $parsed;
}
2026-05-24 22:29:43 +03:30
2026-07-02 21:24:26 +03:30
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;
2026-05-24 22:29:43 +03:30
}
private function makeOrderPayload(array $data): array
{
$mazaneh = ((int) $data['direction'] === 1)
2026-05-25 12:47:04 +03:30
? $this->config()->Config('montzar_truly_buy_mazaneh')
: $this->config()->Config('montzar_truly_sell_mazaneh');
2026-05-24 22:29:43 +03:30
2026-05-25 16:17:24 +03:30
$montzarPrice = (float) $this->config()->Config('montzar_live_price');
2026-05-24 22:29:43 +03:30
$cash = (int) round(
2026-05-25 12:47:04 +03:30
$data['total_price'] ?? ($montzarPrice * (float) $data['amount'])
2026-05-24 22:29:43 +03:30
);
$customerName = auth('customer')->check()
? auth('customer')->user()->name
: ($data['customer']['name'] ?? '');
return [
'mazaneh' => (int) $mazaneh,
'amount' => (float) $data['amount'],
2026-05-25 12:47:04 +03:30
'description' => $this->config()->Config('name_fa') . ' - ' . $customerName,
2026-05-24 22:29:43 +03:30
'direction' => (int) $data['direction'],
2026-05-25 12:47:04 +03:30
'product_id' => (int) ($this->config()->Config('montzar_product_id') ?: 25),
2026-05-24 22:29:43 +03:30
'cash' => $cash,
'input_mode' => 'gold',
];
}
2026-05-25 12:47:04 +03:30
private function postToMontzar(string $url, string $token, array $payload)
2026-05-24 22:29:43 +03:30
{
2026-08-26 21:47:09 +03:30
return $this->http(15)
2026-05-24 22:29:43 +03:30
->withToken($token)
->post($url, $payload);
}
2026-05-25 12:47:04 +03:30
private function decodeMontzarHttpBody($httpResponse): ?array
2026-05-24 22:29:43 +03:30
{
$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;
}
2026-05-25 12:47:04 +03:30
private function logMontzarOrderResponse($httpResponse, $body): void
2026-05-24 22:29:43 +03:30
{
try {
$value = $body === null
? $httpResponse->body()
: json_encode($body, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
2026-05-25 12:47:04 +03:30
// مربوط به منتزر: settings
$this->config()->UpdateConfig('montzar_order_response', $value);
// عمومی: اگر در const/Cnt داری همان‌جا بماند
2026-05-24 22:29:43 +03:30
Cnt::where('key', 'last_order_log')->update(['value' => $value]);
} catch (\Exception $e) {
2026-05-25 12:47:04 +03:30
Log::warning('Could not persist montzar order response', [
2026-05-24 22:29:43 +03:30
'error' => $e->getMessage(),
]);
}
}
2026-05-25 12:47:04 +03:30
private function formatMontzarErrorMessage(?string $error, $httpResponse): string
2026-05-24 22:29:43 +03:30
{
2026-05-25 12:47:04 +03:30
if ($error && $error !== 'پاسخ نامعتبر از سرور منتظر (اتصال یا فرمت JSON)') {
2026-05-24 22:29:43 +03:30
return $error;
}
$status = $httpResponse->status();
$raw = trim((string) $httpResponse->body());
if ($raw === '') {
2026-05-25 12:47:04 +03:30
return "خطا در ارتباط با منتظر (HTTP {$status})";
2026-05-24 22:29:43 +03:30
}
if (stripos($raw, '<!DOCTYPE') !== false || stripos($raw, '<html') !== false) {
2026-05-25 12:47:04 +03:30
return "خطا از سرویس منتظر (HTTP {$status}). پاسخ JSON دریافت نشد.";
2026-05-24 22:29:43 +03:30
}
return mb_substr($raw, 0, 300);
}
2026-05-25 12:47:04 +03:30
private function parseMontzarOrderResponse($body, int $direction): array
2026-05-24 22:29:43 +03:30
{
if (!is_array($body)) {
return [
'ok' => false,
'provider_id' => null,
2026-05-25 12:47:04 +03:30
'error' => 'پاسخ نامعتبر از سرور منتظر (اتصال یا فرمت JSON)',
2026-05-24 22:29:43 +03:30
];
}
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], true)) {
return [
'ok' => true,
'provider_id' => !empty($item['id']) ? (int) $item['id'] : null,
'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'])) {
return (int) $body['order']['id'];
}
2026-05-25 12:47:04 +03:30
foreach ([1, 2] as $index) {
if (isset($body[$index]) && is_array($body[$index])) {
if (!empty($body[$index]['id'])) {
return (int) $body[$index]['id'];
}
2026-05-24 22:29:43 +03:30
2026-05-25 12:47:04 +03:30
if (!empty($body[$index]['order']['id'])) {
return (int) $body[$index]['order']['id'];
}
2026-05-24 22:29:43 +03:30
}
}
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;
}
2026-05-25 12:47:04 +03:30
private function syncOrderWithMontzarResponse(array &$data, array $parsed): void
2026-05-24 22:29:43 +03:30
{
2026-05-25 12:47:04 +03:30
$montzarDiff = (float) $this->config()->Config('montzar_diff');
2026-05-25 16:17:24 +03:30
$montzarPrice = (float) $this->config()->Config('montzar_live_price');
2026-05-24 22:29:43 +03:30
2026-05-25 12:47:04 +03:30
$totalPriceMontzar = ((int) $data['direction'] === 1)
? ($montzarPrice + $montzarDiff)
: ($montzarPrice - $montzarDiff);
2026-05-24 22:29:43 +03:30
$providerOrderId = $parsed['provider_id'] ?? null;
if (isset($data['order']) && $data['order'] instanceof Order) {
2026-05-28 13:13:06 +03:30
if ($providerOrderId !== null && Schema::hasColumn('orders', 'provider_id')) {
2026-05-24 22:29:43 +03:30
$data['order']->provider_id = $providerOrderId;
}
2026-05-25 12:47:04 +03:30
if (Schema::hasColumn('orders', 'total_price_montzar')) {
$data['order']->total_price_montzar = $totalPriceMontzar;
}
2026-05-24 22:29:43 +03:30
$data['order']->save();
}
2026-05-25 12:47:04 +03:30
$data['total_price_montzar'] = $totalPriceMontzar;
2026-05-24 22:29:43 +03:30
if ($providerOrderId !== null) {
$data['provider_id'] = $providerOrderId;
}
}
2026-05-24 19:22:09 +03:30
}