From 0b1e5c687b93731f1b67006ffa3c030f29c69a50 Mon Sep 17 00:00:00 2001 From: adonis Date: Wed, 2 Sep 2026 15:55:03 +0330 Subject: [PATCH] feat(order): wait for colleague callback before settling wallet Keep cover orders pending until colleague sts 2/4/6 via callback or 35s poll. Nightly prices:prune also drops old log tables. Co-authored-by: Cursor --- .../PollPendingColleagueOrdersCommand.php | 23 + .../Commands/PrunePriceSnapshotsCommand.php | 10 +- app/Console/Kernel.php | 10 +- .../ColleagueOrderCallbackController.php | 23 + .../Single/SingleOrderController.php | 459 ++++++++++++++---- app/Models/Order.php | 4 + .../ColleagueOrderCallbackService.php | 334 +++++++++++++ app/Services/DatabaseLogPruneService.php | 140 ++++++ app/Services/MontzarService.php | 72 ++- ..._09_02_000002_colleague_order_callback.php | 70 +++ database/sql/deploy_safe_schema.sql | 25 + database/sql/deploy_settings_const.sql | 18 +- routes/customer.php | 10 +- 13 files changed, 1093 insertions(+), 105 deletions(-) create mode 100644 app/Console/Commands/PollPendingColleagueOrdersCommand.php create mode 100644 app/Http/Controllers/Customer/ColleagueOrderCallbackController.php create mode 100644 app/Services/ColleagueOrderCallbackService.php create mode 100644 app/Services/DatabaseLogPruneService.php create mode 100644 database/migrations/2026_09_02_000002_colleague_order_callback.php diff --git a/app/Console/Commands/PollPendingColleagueOrdersCommand.php b/app/Console/Commands/PollPendingColleagueOrdersCommand.php new file mode 100644 index 0000000..4415ebd --- /dev/null +++ b/app/Console/Commands/PollPendingColleagueOrdersCommand.php @@ -0,0 +1,23 @@ +pollDueOrders(); + sleep(ColleagueOrderCallbackService::POLL_SECONDS); + $second = $service->pollDueOrders(); + $this->info(json_encode(['first' => $first, 'second' => $second], JSON_UNESCAPED_UNICODE)); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/PrunePriceSnapshotsCommand.php b/app/Console/Commands/PrunePriceSnapshotsCommand.php index 751d9c1..31af37c 100644 --- a/app/Console/Commands/PrunePriceSnapshotsCommand.php +++ b/app/Console/Commands/PrunePriceSnapshotsCommand.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Services\DatabaseLogPruneService; use App\Services\PriceSnapshotService; use Illuminate\Console\Command; @@ -9,11 +10,14 @@ class PrunePriceSnapshotsCommand extends Command { protected $signature = 'prices:prune'; - protected $description = 'Downsample and prune price history (daily at midnight)'; + protected $description = 'Nightly prune: price history + bloating logs (31-day retention)'; - public function handle(PriceSnapshotService $snapshots): int + public function handle(PriceSnapshotService $snapshots, DatabaseLogPruneService $logs): int { - $stats = $snapshots->pruneHistory(); + $stats = [ + 'prices' => $snapshots->pruneHistory(), + 'logs' => $logs->pruneHistory(), + ]; $this->info(json_encode($stats, JSON_UNESCAPED_UNICODE)); return self::SUCCESS; diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 535e4f7..058a9ee 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -31,10 +31,11 @@ class Kernel extends ConsoleKernel ->name('refresh_zaarbox_prices') ->withoutOverlapping(); - // پاک‌سازی و downsample تاریخچه — ساعت ۱۲ شب + // پاک‌سازی و downsample تاریخچه قیمت + لاگ‌های حجیم (۳۱ روز) — ساعت ۱۲ شب $schedule->command('prices:prune') ->dailyAt('00:00') - ->name('prune_price_snapshots'); + ->name('prune_price_snapshots') + ->withoutOverlapping(); // ترمیم اسناد ریالی جاافتادهٔ پرگار — سریع برای موارد تازه $schedule->command('pargar:requeue-wallet-documents --execute --days=14') @@ -52,6 +53,11 @@ class Kernel extends ConsoleKernel ->everyMinute() ->name('automations_run') ->withoutOverlapping(50); + + $schedule->command('colleague:poll-pending-orders') + ->everyMinute() + ->name('colleague_poll_pending_orders') + ->withoutOverlapping(58); } protected function commands() diff --git a/app/Http/Controllers/Customer/ColleagueOrderCallbackController.php b/app/Http/Controllers/Customer/ColleagueOrderCallbackController.php new file mode 100644 index 0000000..43ee1cd --- /dev/null +++ b/app/Http/Controllers/Customer/ColleagueOrderCallbackController.php @@ -0,0 +1,23 @@ +all(); + if ($body === []) { + $decoded = json_decode((string) $request->getContent(), true); + $body = is_array($decoded) ? $decoded : []; + } + + $result = app(ColleagueOrderCallbackService::class)->handleCallbackPayload($body, $order); + + return response()->json($result, !empty($result['ok']) ? 200 : 422); + } +} diff --git a/app/Http/Controllers/Single/SingleOrderController.php b/app/Http/Controllers/Single/SingleOrderController.php index 905b861..36ffaca 100644 --- a/app/Http/Controllers/Single/SingleOrderController.php +++ b/app/Http/Controllers/Single/SingleOrderController.php @@ -31,6 +31,8 @@ use App\Services\FormulaPriceService; use App\Services\ReferralGiftService; use App\Services\WalletBalanceService; use App\Services\WalletBroadcastService; +use App\Services\ColleagueOrderCallbackService; +use Illuminate\Support\Facades\Schema; class SingleOrderController extends Controller @@ -115,7 +117,7 @@ class SingleOrderController extends Controller throw new UnprocessableEntityHttpException('کیف پول یافت نشد.'); } $response = []; - + $this->assertNoOpenColleagueOrder((int) ($data['customer_id'] ?? 0)); // Check if the user wants to transact with a product or non-product (gold) if (isset($data['product_id']) and $data['product'] = Product::find($data['product_id'])) { @@ -209,101 +211,12 @@ class SingleOrderController extends Controller $response['sell'] = $this->processSellTransaction($data, $wallet, $wallet_interface); } } - // دفترکل داخلی همیشه ثبت شود — مستقل از اتصال به حسابداری بیرونی - if (isset($data['order'])) { - try { - app(\App\Services\AccountingLedgerService::class)->recordTrade($data['order']); - } catch (\Throwable $e) { - report($e); - } + + if (!empty($data['_awaiting_callback']) && isset($data['order']) && $data['order'] instanceof Order) { + return $this->pendingColleagueStoreResponse($data['order']); } - if ($config->isTahesabAccounting()) { - try { - $data['tahesab'] = app(TahesabController::class)->storeTrade($data); - } catch (\Exception $e) { - report($e); - } - } - - if ($config->isZarinAccounting()) { - try { - $data['zarin'] = app(\App\Http\Controllers\Admin\ZarinController::class)->storeTrade($data); - } catch (\Exception $e) { - report($e); - } - } - - if ($config->isPargarAccounting()) { - try { - $paymentProductId = (int) ($data['payment_product_id'] ?? 0); - $paymentKind = app(\App\Services\PargarSyncService::class) - ->inferPaymentKindFromContext([ - 'payment_product_id' => $paymentProductId, - ]); - // خرید/فروش عادی از کیف ریالی (بدون payment_product_id) = نقدی - if ($paymentProductId <= 0) { - $paymentKind = \App\Services\PargarSyncService::PAYMENT_CASH; - } - $paymentGold = null; - if ($paymentKind === \App\Services\PargarSyncService::PAYMENT_MAIN_GOLD) { - $paymentGold = (float) ($data['total_amount'] ?? $data['amount'] ?? 0); - } - app(\App\Services\PargarSyncService::class)->recordTrade( - $data['order'] ?? null, - false, - [ - 'payment_kind' => $paymentKind, - 'payment_product_id' => $paymentProductId > 0 ? $paymentProductId : null, - 'product_id' => (int) ($data['product_id'] ?? 0) ?: null, - 'number' => isset($data['number']) ? (float) $data['number'] : null, - 'payment_gold' => $paymentGold, - ] - ); - } catch (\Throwable $e) { - report($e); - } - } - - if (isset($data['order'])) { - try { - app(\App\Services\ColleagueAccountingService::class)->recordCoveredTrade($data); - } catch (\Throwable $e) { - report($e); - } - } - - $response['reward'] = $this->give_order_reward($data, $wallet, $wallet_interface); - if ((int) $data['direction'] === 1 && $customer) { - $referrer = Customer::where('referral_id', $customer->referral_to_id)->first(); - if (!empty($referrer) && $referrer->first_buy_order == 1 && $customer->first_buy_order !== 1) { - $customer->first_buy_order = true; - $customer->save(); - $response['bonus_gift'] = $this->bonusGift($customer, $referrer); - } - } - if ($customer && isset($data['order'])) { - try { - app(ReferralGiftService::class)->tryGiveOnFirstOrder($customer); - } catch (\Throwable $e) { - report($e); - } - } - - if (isset($data['order'])) { - try { - $response['Notifications'] = $this->sendTransactionNotifications($data, $transactionType); - } catch (\Throwable $e) { - report($e); - } - } - - if ($customer) { - app(WalletBroadcastService::class)->broadcastForCustomer( - (int) $customer->id, - $ownerType - ); - } + $this->runSuccessfulTradeAftermath($data, $customer, $wallet, $wallet_interface, $transactionType, $ownerType, $response); return response()->json([$response, $data]); } @@ -362,6 +275,24 @@ class SingleOrderController extends Controller $productId = (int) ($data['product_id'] ?? $abshode['id'] ?? 0); $usesProductWallet = $productId > 0 && !$config->isMainWalletProduct($productId); + if (!empty($data['_awaiting_callback'])) { + $this->persistColleagueFinalizePayload($order, [ + 'kind' => 'buy', + 'product_id' => $productId, + 'uses_product_wallet' => $usesProductWallet, + 'payment_amount' => $data['payment_amount'] ?? null, + 'amount' => $data['amount'] ?? null, + 'total_price' => $data['total_price'] ?? null, + 'number' => $data['number'] ?? null, + 'payment_product_id' => $data['payment_product_id'] ?? null, + 'abshode_id' => $data['abshode_id'] ?? null, + 'direction' => $data['direction'] ?? 1, + 'customer_id' => $data['customer_id'] ?? null, + ]); + + return ['pending' => true]; + } + $this->runAfterProviderCover($order, $data, function () use (&$data, $wallet_interface, $productId, $usesProductWallet, $order) { if ($usesProductWallet) { new TransactionInterface($wallet_interface, [ @@ -642,6 +573,25 @@ class SingleOrderController extends Controller $order = $this->openOrderThenCoverWithProvider($data); + if (!empty($data['_awaiting_callback'])) { + $this->persistColleagueFinalizePayload($order, [ + 'kind' => 'sell', + 'product_id' => $productId, + 'uses_product_wallet' => $usesProductWallet, + 'gold_debit' => $goldDebit, + 'payment_amount' => $data['payment_amount'] ?? null, + 'amount' => $data['amount'] ?? null, + 'total_price' => $data['total_price'] ?? null, + 'number' => $data['number'] ?? null, + 'payment_product_id' => $data['payment_product_id'] ?? null, + 'abshode_id' => $data['abshode_id'] ?? null, + 'direction' => $data['direction'] ?? 2, + 'customer_id' => $data['customer_id'] ?? null, + ]); + + return ['pending' => true]; + } + $this->runAfterProviderCover($order, $data, function () use (&$data, $wallet_interface, $wallet_interface_product, $productId, $usesProductWallet, $goldDebit, $order) { if ($usesProductWallet) { $this->normalizeProductWalletGold($wallet_interface_product); @@ -1060,6 +1010,9 @@ class SingleOrderController extends Controller $this->assertProviderOrderAccepted($data); $this->finalizeProviderOrder($data); $order->refresh(); + if (app(ColleagueOrderCallbackService::class)->waitEnabled()) { + $data['_awaiting_callback'] = true; + } } catch (\Throwable $e) { $this->markOrderFailed($order, $e->getMessage()); throw $e instanceof UnprocessableEntityHttpException @@ -2066,4 +2019,322 @@ class SingleOrderController extends Controller return (float) ($data['payment_amount'] ?? $data['total_price'] ?? 0); } + + private function assertNoOpenColleagueOrder(int $customerId): void + { + if ($customerId <= 0) { + return; + } + + $open = Order::query()->where('customer_id', $customerId)->where('sts', 4)->exists(); + if ($open) { + throw new UnprocessableEntityHttpException( + 'سفارش قبلی شما هنوز در انتظار تأیید همکار است.' + ); + } + } + + private function pendingColleagueStoreResponse(Order $order) + { + $sts = (int) $order->sts; + + return response()->json([ + [ + 'pending' => true, + 'sts' => $sts, + 'sts_str' => Order::$STS[$sts] ?? 'در انتظار همکار', + 'order_id' => $order->id, + ], + [ + 'order' => $order, + 'pending' => true, + ], + ]); + } + + public function pendingColleague() + { + $order = app(ColleagueOrderCallbackService::class) + ->pendingForCustomer((int) auth('customer')->id()); + + return response()->json(app(ColleagueOrderCallbackService::class)->publicStatusPayload($order)); + } + + public function colleagueStatus(int $id) + { + $order = Order::query() + ->where('id', $id) + ->where('customer_id', auth('customer')->id()) + ->first(); + + return response()->json(app(ColleagueOrderCallbackService::class)->publicStatusPayload($order)); + } + + /** + * @param array $payload + */ + private function persistColleagueFinalizePayload(Order $order, array $payload): void + { + if (!Schema::hasColumn('orders', 'colleague_callback_payload')) { + return; + } + + $order->colleague_callback_payload = $payload; + $order->save(); + } + + public function rejectColleaguePendingOrder(Order $order, string $message): void + { + $this->markOrderFailed($order, $message); + } + + public function finalizeColleaguePendingOrder(Order $order): void + { + $payload = $order->colleague_callback_payload; + if (!is_array($payload)) { + $payload = []; + } + + $customer = Customer::findOrFail((int) $order->customer_id); + $ownerType = (int) ($customer->type ?? 2); + [$wallet, $wallet_interface] = $this->resolveOrderWallet((int) $customer->id, $customer, $ownerType); + if (!$wallet) { + throw new UnprocessableEntityHttpException('کیف پول یافت نشد.'); + } + + $data = array_merge($order->toArray(), $payload); + $data['order'] = $order; + $data['customer_id'] = (int) $order->customer_id; + $productId = (int) ($payload['product_id'] ?? $order->product_id ?? 0); + if ($productId > 0) { + $data['product'] = Product::find($productId); + $data['product_id'] = $productId; + } + + $kind = (string) ($payload['kind'] ?? (((int) $order->direction === 2) ? 'sell' : 'buy')); + $usesProductWallet = (bool) ($payload['uses_product_wallet'] ?? false); + $goldDebit = (float) ($payload['gold_debit'] ?? $order->amount ?? 0); + + $this->runAfterProviderCover($order, $data, function () use ( + &$data, + $wallet_interface, + $productId, + $usesProductWallet, + $goldDebit, + $order, + $kind + ) { + if ($kind === 'sell') { + $this->applySellWalletAfterColleague($data, $wallet_interface, $productId, $usesProductWallet, $goldDebit, $order); + } else { + $this->applyBuyWalletAfterColleague($data, $wallet_interface, $productId, $usesProductWallet, $order); + } + }); + + $transactionType = ((int) $order->direction === 2) ? 'فروش' : 'خرید'; + $response = []; + $this->runSuccessfulTradeAftermath($data, $customer, $wallet, $wallet_interface, $transactionType, $ownerType, $response); + } + + private function applyBuyWalletAfterColleague( + array &$data, + $wallet_interface, + int $productId, + bool $usesProductWallet, + Order $order + ): void { + if ($usesProductWallet) { + new TransactionInterface($wallet_interface, [ + 'cash' => $data['payment_amount'], + 'product_id' => $productId, + 'expire_at' => now(), + 'active' => true, + 'order_id' => $order->id, + 'start_at' => now(), + 'title' => 'برداشت وجه نقد', + ], 12, false, false); + $wallet_interface_product = $this->productWalletInterface($productId); + $this->normalizeProductWalletGold($wallet_interface_product); + new TransactionInterface($wallet_interface_product, [ + 'gold' => $data['amount'], + 'product_id' => $productId, + 'expire_at' => now(), + 'active' => true, + 'order_id' => $order->id, + 'start_at' => now(), + 'title' => $this->meltedOrProductBuyTitle($data), + ], 1, true, false); + + return; + } + + $buyTransaction = [ + 'cash' => $data['payment_amount'], + 'gold' => $data['amount'], + 'expire_at' => now(), + 'active' => true, + 'order_id' => $order->id, + 'start_at' => now(), + 'title' => $this->meltedOrProductBuyTitle($data), + ]; + if ($productId > 0) { + $buyTransaction['product_id'] = $productId; + $buyTransaction['note'] = 'product_id:' . $productId; + } + new TransactionInterface($wallet_interface, $buyTransaction, 1, true, false); + } + + private function applySellWalletAfterColleague( + array &$data, + $wallet_interface, + int $productId, + bool $usesProductWallet, + float $goldDebit, + Order $order + ): void { + if ($usesProductWallet) { + $wallet_interface_product = $this->productWalletInterface($productId); + $this->normalizeProductWalletGold($wallet_interface_product); + new TransactionInterface($wallet_interface_product, [ + 'gold' => $goldDebit, + 'product_id' => $productId, + 'expire_at' => now(), + 'active' => true, + 'order_id' => $order->id, + 'start_at' => now(), + 'title' => $this->meltedOrProductSellTitle($data), + ], 1, false, false); + new TransactionInterface($wallet_interface, [ + 'cash' => $data['total_price'], + 'product_id' => $productId, + 'expire_at' => now(), + 'active' => true, + 'order_id' => $order->id, + 'start_at' => now(), + 'title' => 'واریز وجه نقد', + ], 12, false, true); + + return; + } + + $sellTransaction = [ + 'gold' => $goldDebit, + 'cash' => $data['total_price'], + 'expire_at' => now(), + 'active' => true, + 'order_id' => $order->id, + 'title' => $this->meltedOrProductSellTitle($data), + ]; + if ($productId > 0) { + $sellTransaction['product_id'] = $productId; + $sellTransaction['note'] = 'product_id:' . $productId; + } + new TransactionInterface($wallet_interface, $sellTransaction, 2, false, true); + } + + private function runSuccessfulTradeAftermath( + array &$data, + $customer, + $wallet, + $wallet_interface, + string $transactionType, + int $ownerType, + array &$response + ): void { + $config = app(ConfigService::class); + + if (isset($data['order'])) { + try { + app(\App\Services\AccountingLedgerService::class)->recordTrade($data['order']); + } catch (\Throwable $e) { + report($e); + } + } + + if ($config->isTahesabAccounting()) { + try { + $data['tahesab'] = app(TahesabController::class)->storeTrade($data); + } catch (\Exception $e) { + report($e); + } + } + + if ($config->isZarinAccounting()) { + try { + $data['zarin'] = app(\App\Http\Controllers\Admin\ZarinController::class)->storeTrade($data); + } catch (\Exception $e) { + report($e); + } + } + + if ($config->isPargarAccounting()) { + try { + $paymentProductId = (int) ($data['payment_product_id'] ?? 0); + $paymentKind = app(\App\Services\PargarSyncService::class) + ->inferPaymentKindFromContext([ + 'payment_product_id' => $paymentProductId, + ]); + if ($paymentProductId <= 0) { + $paymentKind = \App\Services\PargarSyncService::PAYMENT_CASH; + } + $paymentGold = null; + if ($paymentKind === \App\Services\PargarSyncService::PAYMENT_MAIN_GOLD) { + $paymentGold = (float) ($data['total_amount'] ?? $data['amount'] ?? 0); + } + app(\App\Services\PargarSyncService::class)->recordTrade( + $data['order'] ?? null, + false, + [ + 'payment_kind' => $paymentKind, + 'payment_product_id' => $paymentProductId > 0 ? $paymentProductId : null, + 'product_id' => (int) ($data['product_id'] ?? 0) ?: null, + 'number' => isset($data['number']) ? (float) $data['number'] : null, + 'payment_gold' => $paymentGold, + ] + ); + } catch (\Throwable $e) { + report($e); + } + } + + if (isset($data['order'])) { + try { + app(\App\Services\ColleagueAccountingService::class)->recordCoveredTrade($data); + } catch (\Throwable $e) { + report($e); + } + } + + $response['reward'] = $this->give_order_reward($data, $wallet, $wallet_interface); + if ((int) ($data['direction'] ?? 0) === 1 && $customer) { + $referrer = Customer::where('referral_id', $customer->referral_to_id)->first(); + if (!empty($referrer) && $referrer->first_buy_order == 1 && $customer->first_buy_order !== 1) { + $customer->first_buy_order = true; + $customer->save(); + $response['bonus_gift'] = $this->bonusGift($customer, $referrer); + } + } + if ($customer && isset($data['order'])) { + try { + app(ReferralGiftService::class)->tryGiveOnFirstOrder($customer); + } catch (\Throwable $e) { + report($e); + } + } + + if (isset($data['order'])) { + try { + $response['Notifications'] = $this->sendTransactionNotifications($data, $transactionType); + } catch (\Throwable $e) { + report($e); + } + } + + if ($customer) { + app(WalletBroadcastService::class)->broadcastForCustomer( + (int) $customer->id, + $ownerType + ); + } + } } diff --git a/app/Models/Order.php b/app/Models/Order.php index 5a39676..d963d39 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -40,10 +40,14 @@ class Order extends Model 'provider_id', 'total_price_montzar', 'description', + 'colleague_callback_payload', + 'colleague_last_poll_at', ]; protected $casts = [ 'fee_breakdown' => 'array', + 'colleague_callback_payload' => 'array', + 'colleague_last_poll_at' => 'datetime', ]; static $STS = [ diff --git a/app/Services/ColleagueOrderCallbackService.php b/app/Services/ColleagueOrderCallbackService.php new file mode 100644 index 0000000..3db7244 --- /dev/null +++ b/app/Services/ColleagueOrderCallbackService.php @@ -0,0 +1,334 @@ +usesMontzarPricing() && $config->getBool('montzar_order_wait_callback', true); + } + + public function callbackUrl(int $localOrderId): string + { + $origin = rtrim(app(ConfigService::class)->resolvePublicSiteUrl(), '/'); + if ($origin === '') { + $origin = rtrim((string) config('app.url'), '/'); + $origin = (string) preg_replace('#/server/?$#', '', $origin); + } + + return $origin . '/server/customer/colleague/order/callback/' . $localOrderId; + } + + /** + * @param array $body + */ + public function handleCallbackPayload(array $body, ?int $localOrderId = null): array + { + $order = $this->findPendingOrder($body, $localOrderId); + if (!$order) { + return ['ok' => false, 'message' => 'سفارش در انتظار یافت نشد']; + } + + $sts = $this->providerStsFromBody($body); + if ($sts === null) { + return ['ok' => false, 'message' => 'وضعیت نامعتبر']; + } + + $this->rememberProviderId($order, $body); + + return $this->applyProviderSts($order, $sts, $body); + } + + public function pollDueOrders(): int + { + if (!Schema::hasTable('orders') || !Schema::hasColumn('orders', 'sts')) { + return 0; + } + + $cutoff = now()->subSeconds(self::POLL_SECONDS); + $handled = 0; + + $query = Order::query()->where('sts', 4)->whereNotNull('provider_id'); + if (Schema::hasColumn('orders', 'colleague_last_poll_at')) { + $query->where(function ($q) use ($cutoff) { + $q->where(function ($inner) use ($cutoff) { + $inner->whereNull('colleague_last_poll_at') + ->where('created_at', '<=', $cutoff); + })->orWhere('colleague_last_poll_at', '<=', $cutoff); + }); + } else { + $query->where('created_at', '<=', $cutoff); + } + + $orders = $query->orderBy('id')->limit(80)->get(); + foreach ($orders as $order) { + try { + $this->pollOne($order); + $handled++; + } catch (\Throwable $e) { + Log::warning('Colleague pending poll failed', [ + 'order_id' => $order->id, + 'error' => $e->getMessage(), + ]); + $this->touchPollAt($order); + } + } + + return $handled; + } + + public function pollOne(Order $order): array + { + $providerId = (int) ($order->provider_id ?? 0); + if ($providerId <= 0) { + $this->touchPollAt($order); + + return ['ok' => true, 'state' => 'wait']; + } + + $body = app(MontzarService::class)->fetchColleagueOrder($providerId); + $this->touchPollAt($order); + if (!is_array($body)) { + return ['ok' => true, 'state' => 'wait']; + } + + $sts = $this->providerStsFromBody($body); + if ($sts === null) { + return ['ok' => true, 'state' => 'wait']; + } + + return $this->applyProviderSts($order, $sts, $body); + } + + /** + * @param array $body + */ + public function applyProviderSts(Order $order, int $providerSts, array $body = []): array + { + if (in_array($providerSts, [4, 6], true)) { + return $this->rejectPending($order, $body); + } + + if ($providerSts === 2) { + return $this->approvePending($order, $body); + } + + return ['ok' => true, 'state' => 'wait', 'sts' => (int) $order->sts]; + } + + public function pendingForCustomer(int $customerId): ?Order + { + if ($customerId <= 0) { + return null; + } + + return Order::query() + ->where('customer_id', $customerId) + ->where('sts', 4) + ->orderByDesc('id') + ->first(); + } + + public function publicStatusPayload(?Order $order): array + { + if (!$order) { + return [ + 'pending' => false, + 'order' => null, + 'sts' => null, + 'sts_str' => null, + ]; + } + + $sts = (int) $order->sts; + + return [ + 'pending' => $sts === 4, + 'order_id' => $order->id, + 'sts' => $sts, + 'sts_str' => Order::$STS[$sts] ?? null, + 'order' => $order, + ]; + } + + public function broadcast(Order $order): void + { + $customerId = (int) ($order->customer_id ?? 0); + if ($customerId <= 0) { + return; + } + + app(PriceStreamService::class)->publishForCustomer($customerId, self::SSE_EVENT, [ + 'order_id' => $order->id, + 'sts' => (int) $order->sts, + 'sts_str' => Order::$STS[(int) $order->sts] ?? null, + 'pending' => (int) $order->sts === 4, + ]); + } + + /** + * @param array $body + */ + private function findPendingOrder(array $body, ?int $localOrderId): ?Order + { + if ($localOrderId && $localOrderId > 0) { + $order = Order::query()->where('id', $localOrderId)->where('sts', 4)->first(); + if ($order) { + return $order; + } + } + + $providerId = $this->providerIdFromBody($body); + if ($providerId > 0) { + return Order::query() + ->where('provider_id', $providerId) + ->where('sts', 4) + ->orderByDesc('id') + ->first(); + } + + return null; + } + + /** + * @param array $body + */ + private function rememberProviderId(Order $order, array $body): void + { + $providerId = $this->providerIdFromBody($body); + if ($providerId <= 0 || !Schema::hasColumn('orders', 'provider_id')) { + return; + } + + if ((int) ($order->provider_id ?? 0) === $providerId) { + return; + } + + $order->provider_id = $providerId; + $order->save(); + } + + /** + * @param array $body + */ + private function approvePending(Order $order, array $body): array + { + $this->rememberProviderId($order, $body); + + $done = false; + DB::transaction(function () use ($order, &$done) { + $locked = Order::query()->where('id', $order->id)->lockForUpdate()->first(); + if (!$locked || (int) $locked->sts !== 4) { + return; + } + app(SingleOrderController::class)->finalizeColleaguePendingOrder($locked); + $done = true; + }); + + $fresh = Order::find($order->id); + if ($fresh) { + $this->broadcast($fresh); + } + + return [ + 'ok' => true, + 'state' => $done ? 'approved' : 'ignored', + 'sts' => $fresh ? (int) $fresh->sts : 2, + ]; + } + + /** + * @param array $body + */ + private function rejectPending(Order $order, array $body): array + { + DB::transaction(function () use ($order, $body) { + $locked = Order::query()->where('id', $order->id)->lockForUpdate()->first(); + if (!$locked || (int) $locked->sts !== 4) { + return; + } + $message = trim((string) ($body['sts_str'] ?? $body['message'] ?? 'رد شده توسط همکار')); + app(SingleOrderController::class)->rejectColleaguePendingOrder($locked, $message !== '' ? $message : 'رد شده توسط همکار'); + }); + + $fresh = Order::find($order->id); + if ($fresh) { + $this->broadcast($fresh); + } + + return [ + 'ok' => true, + 'state' => 'rejected', + 'sts' => $fresh ? (int) $fresh->sts : 3, + ]; + } + + private function touchPollAt(Order $order): void + { + if (!Schema::hasColumn('orders', 'colleague_last_poll_at')) { + return; + } + + $order->colleague_last_poll_at = now(); + $order->save(); + } + + /** + * @param array $body + */ + public function providerStsFromBody(array $body): ?int + { + $rows = [$body, $body['data'] ?? null, $body['order'] ?? null]; + foreach ($body as $item) { + if (!is_array($item)) { + continue; + } + $rows[] = $item; + $rows[] = $item['order'] ?? null; + $rows[] = $item['data'] ?? null; + } + + foreach ($rows as $row) { + if (!is_array($row) || !array_key_exists('sts', $row) || $row['sts'] === null || $row['sts'] === '') { + continue; + } + + return (int) $row['sts']; + } + + return null; + } + + /** + * @param array $body + */ + private function providerIdFromBody(array $body): int + { + foreach ([$body, $body['data'] ?? null, $body['order'] ?? null] as $row) { + if (!is_array($row)) { + continue; + } + foreach (['order_id', 'id', 'provider_order_id'] as $key) { + $value = (int) ($row[$key] ?? 0); + if ($value > 0) { + return $value; + } + } + } + + return 0; + } +} diff --git a/app/Services/DatabaseLogPruneService.php b/app/Services/DatabaseLogPruneService.php new file mode 100644 index 0000000..c500d96 --- /dev/null +++ b/app/Services/DatabaseLogPruneService.php @@ -0,0 +1,140 @@ +copy()->subDays(self::RETENTION_DAYS); + + return [ + 'retention_days' => self::RETENTION_DAYS, + 'cutoff' => $cutoff->toDateTimeString(), + 'price_sync_logs' => $this->prunePriceSyncLogs($cutoff), + 'logs' => $this->pruneGenericLogs($cutoff), + 'pargar_sync_logs' => $this->pruneSuccessfulRows( + PargarSyncLog::query(), + 'pargar_sync_logs', + PargarSyncService::STATUS_SUCCESS, + $cutoff + ), + 'zarin' => $this->pruneSuccessfulRows( + Zarin::query(), + 'zarin', + ZarinSyncService::STATUS_SUCCESS, + $cutoff + ), + 'tahesab' => $this->pruneSuccessfulRows( + Tahesab::query(), + 'tahesab', + TahesabSyncService::STATUS_SUCCESS, + $cutoff + ), + ]; + } + + /** @return array{deleted: int, items_deleted: int} */ + private function prunePriceSyncLogs(Carbon $cutoff): array + { + if (! Schema::hasTable('price_sync_logs')) { + return ['deleted' => 0, 'items_deleted' => 0]; + } + + $deleted = 0; + $itemsDeleted = 0; + $hasItems = Schema::hasTable('price_sync_log_items'); + + PriceSyncLog::query() + ->where('created_at', '<', $cutoff) + ->orderBy('id') + ->select(['id']) + ->chunkById(self::CHUNK, function ($rows) use (&$deleted, &$itemsDeleted, $hasItems) { + $ids = $rows->pluck('id')->all(); + if ($ids === []) { + return; + } + if ($hasItems) { + $itemsDeleted += (int) PriceSyncLogItem::query()->whereIn('log_id', $ids)->delete(); + } + $deleted += (int) PriceSyncLog::query()->whereIn('id', $ids)->delete(); + }); + + return ['deleted' => $deleted, 'items_deleted' => $itemsDeleted]; + } + + /** @return array{deleted: int} */ + private function pruneGenericLogs(Carbon $cutoff): array + { + if (! Schema::hasTable('logs')) { + return ['deleted' => 0]; + } + + $deleted = 0; + + Log::query() + ->where('created_at', '<', $cutoff) + ->orderBy('id') + ->select(['id']) + ->chunkById(self::CHUNK, function ($rows) use (&$deleted) { + $ids = $rows->pluck('id')->all(); + if ($ids === []) { + return; + } + $deleted += (int) Log::query()->whereIn('id', $ids)->delete(); + }); + + return ['deleted' => $deleted]; + } + + /** + * فقط ردیف‌های موفق حسابداری — pending/failed برای retry می‌مانند. + * + * @return array{deleted: int, skipped: bool} + */ + private function pruneSuccessfulRows(Builder $query, string $table, string $successStatus, Carbon $cutoff): array + { + if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'sync_status')) { + return ['deleted' => 0, 'skipped' => true]; + } + + if (! Schema::hasColumn($table, 'created_at')) { + return ['deleted' => 0, 'skipped' => true]; + } + + $deleted = 0; + + (clone $query) + ->where('sync_status', $successStatus) + ->where('created_at', '<', $cutoff) + ->orderBy('id') + ->select(['id']) + ->chunkById(self::CHUNK, function ($rows) use (&$deleted, $query) { + $ids = $rows->pluck('id')->all(); + if ($ids === []) { + return; + } + $deleted += (int) (clone $query)->whereIn('id', $ids)->delete(); + }); + + return ['deleted' => $deleted, 'skipped' => false]; + } +} diff --git a/app/Services/MontzarService.php b/app/Services/MontzarService.php index 73d40e4..70ce271 100644 --- a/app/Services/MontzarService.php +++ b/app/Services/MontzarService.php @@ -616,7 +616,7 @@ class MontzarService ? auth('customer')->user()->name : ($data['customer']['name'] ?? ''); - return [ + $payload = [ 'mazaneh' => (int) $mazaneh, 'amount' => (float) $data['amount'], 'description' => $this->config()->Config('name_fa') . ' - ' . $customerName, @@ -625,6 +625,56 @@ class MontzarService '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 وضعیت سفارش روی نود همکار — مسیر از setting + شناسه سفارش همکار. + * + * @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'; + } + + $url = $this->endpoint(rtrim($path, '/') . '/' . $providerOrderId); + $token = $this->getToken(); + + try { + $httpResponse = $this->http(15)->withToken($token)->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(); + $httpResponse = $this->http(15)->withToken($token)->get($url); + $body = $this->decodeMontzarHttpBody($httpResponse); + } + + return is_array($body) ? $body : null; } private function postToMontzar(string $url, string $token, array $payload) @@ -755,7 +805,7 @@ class MontzarService ]; } - if (isset($item['sts']) && in_array((int) $item['sts'], [1, 2], true)) { + 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, @@ -764,6 +814,14 @@ class MontzarService } } + if (!empty($body['pending']) || !empty($body[0]['pending'])) { + return [ + 'ok' => true, + 'provider_id' => $this->extractProviderOrderId($body), + 'error' => null, + ]; + } + return [ 'ok' => false, 'provider_id' => null, @@ -777,16 +835,24 @@ class MontzarService 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 ([1, 2] as $index) { + 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']; } diff --git a/database/migrations/2026_09_02_000002_colleague_order_callback.php b/database/migrations/2026_09_02_000002_colleague_order_callback.php new file mode 100644 index 0000000..4dca8f9 --- /dev/null +++ b/database/migrations/2026_09_02_000002_colleague_order_callback.php @@ -0,0 +1,70 @@ +json('colleague_callback_payload')->nullable(); + }); + } + if (! Schema::hasColumn('orders', 'colleague_last_poll_at')) { + Schema::table('orders', function ($table) { + $table->timestamp('colleague_last_poll_at')->nullable(); + }); + } + } + + if (! Schema::hasTable('settings')) { + return; + } + + $this->insertSetting( + 'montzar_order_wait_callback', + 'true', + 'انتظار کالبک سفارش همکار', + 'روشن: سفارش همکار تا دریافت کالبک (یا استعلام مسیر وضعیت) در انتظار می‌ماند و کیف ثبت نمی‌شود. خاموش: مثل قبل بلافاصله با پاسخ HTTP همکار قطعی می‌شود.', + 'switch' + ); + $this->insertSetting( + 'montzar_order_status_path', + '/server/customer/order', + 'مسیر استعلام وضعیت سفارش همکار', + 'مسیر GET روی سرور همکار (montzar_base_url). شماره سفارش همکار به انتها اضافه می‌شود؛ مثلاً /server/customer/order/40000.', + 'text' + ); + } + + public function down(): void + { + // settings and columns stay + } + + private function insertSetting(string $key, string $value, string $title, string $description, string $type): void + { + $exists = DB::table('settings')->where('key', $key)->exists(); + if ($exists) { + return; + } + + $row = [ + 'key' => $key, + 'value' => $value, + 'title_fa' => $title, + 'description' => $description, + 'can_edit' => 1, + 'is_active' => 1, + ]; + if (Schema::hasColumn('settings', 'type')) { + $row['type'] = $type; + } + + DB::table('settings')->insert($row); + } +}; diff --git a/database/sql/deploy_safe_schema.sql b/database/sql/deploy_safe_schema.sql index 64aacec..3e9419a 100644 --- a/database/sql/deploy_safe_schema.sql +++ b/database/sql/deploy_safe_schema.sql @@ -254,6 +254,31 @@ PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; +-- orders — payload/زمان استعلام کالبک همکار +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'orders') > 0 + AND (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'orders' AND COLUMN_NAME = 'colleague_callback_payload') = 0, + 'ALTER TABLE `orders` ADD COLUMN `colleague_callback_payload` JSON NULL', + 'SELECT ''skip orders.colleague_callback_payload'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'orders') > 0 + AND (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'orders' AND COLUMN_NAME = 'colleague_last_poll_at') = 0, + 'ALTER TABLE `orders` ADD COLUMN `colleague_last_poll_at` TIMESTAMP NULL DEFAULT NULL', + 'SELECT ''skip orders.colleague_last_poll_at'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + -- --------------------------------------------------------------------------- -- withdraws — ستون‌های اختیاری (روی بعضی سرورها دستی اضافه شده) -- --------------------------------------------------------------------------- diff --git a/database/sql/deploy_settings_const.sql b/database/sql/deploy_settings_const.sql index e302add..e6abea5 100644 --- a/database/sql/deploy_settings_const.sql +++ b/database/sql/deploy_settings_const.sql @@ -346,6 +346,18 @@ INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `de SELECT 'montzar_open_sell', 'true', 'باز بودن فروش همکار', 1, 1, NULL WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'montzar_open_sell' LIMIT 1); +INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`, `type`) +SELECT 'montzar_order_wait_callback', 'true', 'انتظار کالبک سفارش همکار', 1, 1, + 'روشن: سفارش همکار تا دریافت کالبک (یا استعلام مسیر وضعیت) در انتظار می‌ماند و کیف ثبت نمی‌شود. خاموش: مثل قبل بلافاصله با پاسخ HTTP همکار قطعی می‌شود.', + 'switch' +WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'montzar_order_wait_callback' LIMIT 1); + +INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`, `type`) +SELECT 'montzar_order_status_path', '/server/customer/order', 'مسیر استعلام وضعیت سفارش همکار', 1, 1, + 'مسیر GET روی سرور همکار (montzar_base_url). شماره سفارش همکار به انتها اضافه می‌شود؛ مثلاً /server/customer/order/40000.', + 'text' +WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'montzar_order_status_path' LIMIT 1); + INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`) SELECT 'montzar_diff', '0', 'اختلاف قیمت همکار', 1, 1, 'سیستم پر می‌کند' WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'montzar_diff' LIMIT 1); @@ -1357,7 +1369,7 @@ UPDATE `settings` SET `type` = 'switch' WHERE `key` IN ( 'sms_notif_jibit_admin', 'sms_notif_bank_card_admin', 'sms_notif_bank_card_customer', 'sms_notif_fund_withdraw_admin', 'sms_notif_fund_withdraw_customer', 'sms_notif_price_trigger', 'sms_notif_wallet_alert', 'sms_notif_support_admin', - 'montzar_closed', 'montzar_open_buy', 'montzar_open_sell', + 'montzar_closed', 'montzar_open_buy', 'montzar_open_sell', 'montzar_order_wait_callback', 'payping_fin_sync_enabled', 'external_order_api_enabled', 'is_store', 'wss_enabled', 'tracking_systems_enabled', 'register_referral_sms_to_referrer', 'register_referral_bonus_message', 'referral_gift_on_first_order', 'referral_gift_buy_from_montzar', @@ -1386,6 +1398,7 @@ UPDATE `settings` SET `type` = 'text' WHERE `key` IN ( 'name_fa', 'name_en', 'app_url', 'bace_url', 'logo_login', 'logo_register', 'admin_url', 'title_pwa', 'title_fav', 'server_url', 'key', 'main_product', 'zaarbox_api_url', 'zaarbox_price_key', 'chart_id', 'etehadie_api_url', 'montzar_mobile', 'montzar_password', 'montzar_base_url', + 'montzar_order_status_path', 'montzar_token', 'montzar_diff', 'montzar_live_price', 'montzar_mazaneh', 'montzar_truly_buy_mazaneh', 'montzar_truly_sell_mazaneh', 'montzar_account_zarin', 'montzar_account_tahesab', 'montzar_account_pargar', @@ -2384,7 +2397,8 @@ UPDATE `settings` SET `can_edit` = 1 WHERE `key` IN ( 'jibit_verification', 'main_product_price', 'min_first_buy', 'min_withdraw', 'min_deposit', 'minimum_sell', - 'montzar_mobile', 'montzar_open_buy', 'montzar_open_sell', + 'montzar_mobile', 'montzar_open_buy', 'montzar_open_sell', 'montzar_order_wait_callback', + 'montzar_order_status_path', 'montzar_password', 'montzar_product_id', 'montzar_account_zarin', 'montzar_account_tahesab', 'montzar_account_pargar', 'open_register', diff --git a/routes/customer.php b/routes/customer.php index d0fdb4f..382559c 100644 --- a/routes/customer.php +++ b/routes/customer.php @@ -12,7 +12,8 @@ use App\Http\Controllers\Single\BankAccountController; use \App\Http\Controllers\Customer\FundController; use \App\Http\Controllers\pay\paypingController; use App\Http\Controllers\pay\WalletPaymentController; -use \App\Http\Controllers\Single\SingleOrderController; +use App\Http\Controllers\Single\SingleOrderController; +use App\Http\Controllers\Customer\ColleagueOrderCallbackController; use App\Http\Controllers\Admin\ProductController as AdminProductController; use App\Http\Controllers\Customer\ProductController; use App\Http\Controllers\Admin\TahesabController; @@ -37,6 +38,9 @@ Route::post('/wallet/payment/webhook/{provider}', [WalletPaymentController::clas Route::get('/wallet/external-payment/return', [WalletPaymentController::class, 'returnLegacy']); Route::post('/wallet/external-payment/webhook', [WalletPaymentController::class, 'webhookLegacy']); +// کالبک وضعیت سفارش همکار — بدون لاگین مشتری +Route::post('/colleague/order/callback/{order?}', [ColleagueOrderCallbackController::class, 'callback']); + $isStore = app(ConfigService::class)->isStore(); Route::middleware('validation')->group(function () { @@ -123,10 +127,14 @@ Route::middleware(['auth:customer', 'customer.active'])->group(function () use ( //-----------------order Route::post('order/table', [OrderController::class, 'table']); + Route::get('order/pending', [SingleOrderController::class, 'pendingColleague']); + Route::get('order/{id}/status', [SingleOrderController::class, 'colleagueStatus']); Route::post('order', [OrderController::class, 'store']); //-----------------single order Route::post('single/order/table', [SingleOrderController::class, 'table']); + Route::get('single/order/pending', [SingleOrderController::class, 'pendingColleague']); + Route::get('single/order/{id}/status', [SingleOrderController::class, 'colleagueStatus']); // Route::post('single/order', [SingleOrderController::class, 'storeBackUp']); Route::post('single/order', [SingleOrderController::class, 'store']);