Retry Montzar orders after refreshing a stale quote.

When the partner rejects with a mazaneh-changed error, pull the live quote and submit once more so the customer buy can succeed without a page refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-31 16:14:02 +03:30
co-authored by Cursor
parent c5fd6608bf
commit 5537cd7086
2 changed files with 271 additions and 26 deletions
+246 -26
View File
@@ -315,6 +315,41 @@ class MontzarService
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'));
}
/**
* ثبت سفارش در منتظر — قبل از ایجاد سفارش/تراکنش محلی صدا زده می‌شود.
*
@@ -322,49 +357,60 @@ class MontzarService
*/
public function submitOrder(array &$data): array
{
$payload = $this->makeOrderPayload($data);
$token = $this->getToken();
$attempt = $this->submitOrderOnce($data);
$httpResponse = $this->postToMontzar(
$this->endpoint('/server/customer/order'),
$token,
$payload
);
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,
]);
$body = $this->decodeMontzarHttpBody($httpResponse);
$refreshed = false;
try {
$refreshed = $this->syncQuoteSettingsFromProvider();
} catch (\Throwable $e) {
Log::warning('Montzar quote refresh failed', [
'error' => $e->getMessage(),
]);
}
if (is_array($body) && ($body['message'] ?? null) === 'no authorised') {
$token = $this->montzarLogin();
if (!$refreshed) {
$refreshed = $this->applyQuoteFromErrorBody(
is_array($attempt['body'] ?? null) ? $attempt['body'] : null,
(int) ($attempt['payload']['mazaneh'] ?? 0)
);
}
$httpResponse = $this->postToMontzar(
$this->endpoint('/server/customer/order'),
$token,
$payload
);
$body = $this->decodeMontzarHttpBody($httpResponse);
if ($refreshed) {
$attempt = $this->submitOrderOnce($data);
}
}
$this->logMontzarOrderResponse($httpResponse, $body);
$parsed = $this->parseMontzarOrderResponse($body, (int) $data['direction']);
$parsed = $attempt['parsed'];
if (!$parsed['ok']) {
Log::warning('Montzar order rejected', [
'base_url' => $this->baseUrl(),
'status' => $httpResponse->status(),
'body' => $body,
'raw' => $httpResponse->body(),
'payload' => $payload,
'status' => $attempt['http']->status(),
'body' => $attempt['body'],
'raw' => $attempt['http']->body(),
'payload' => $attempt['payload'],
'reason' => $parsed['error'],
]);
throw new UnprocessableEntityHttpException(
$this->formatMontzarErrorMessage($parsed['error'], $httpResponse)
$this->formatMontzarErrorMessage($parsed['error'], $attempt['http'])
);
}
return $parsed;
return [
'ok' => true,
'provider_id' => $parsed['provider_id'] ?? null,
'error' => null,
];
}
public function applyParsedOrder(array &$data, array $parsed): void
@@ -380,6 +426,180 @@ class MontzarService
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<string, mixed>|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<string, mixed> $data
* @param list<string> $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)
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Tests\Unit;
use App\Services\MontzarService;
use PHPUnit\Framework\TestCase;
class MontzarQuoteChangedTest extends TestCase
{
public function test_detects_persian_quote_changed_toast(): void
{
$this->assertTrue(MontzarService::isQuoteChangedMessage('تغییر در مظنه !'));
$this->assertTrue(MontzarService::isQuoteChangedMessage('تغيير در مظنه'));
$this->assertTrue(MontzarService::isQuoteChangedMessage('مظنه تغییر کرده است'));
$this->assertTrue(MontzarService::isQuoteChangedMessage('Quote changed'));
}
public function test_ignores_unrelated_messages(): void
{
$this->assertFalse(MontzarService::isQuoteChangedMessage(null));
$this->assertFalse(MontzarService::isQuoteChangedMessage(''));
$this->assertFalse(MontzarService::isQuoteChangedMessage('موجودی شما کمتر از میزان سفارش است'));
$this->assertFalse(MontzarService::isQuoteChangedMessage('سفارشات توسط ارائه دهنده بسته شده است.'));
}
}