$response['Sh_factor'] ?? null, 'tahesab_id' => $response['OK'], 'factor_number' => $response['Factor_Number'] ?? null, 'tah_id' => $response['ID'] ?? null, 'res' => json_encode($response, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), 'sync_status' => self::STATUS_SUCCESS, 'sync_log' => null, ]; if ($payload !== null) { $update['data'] = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); } if ($this->hasSyncColumns()) { Tahesab::where('id', $tahesab->id)->update($update); } else { unset($update['sync_status'], $update['sync_log']); Tahesab::where('id', $tahesab->id)->update($update); } } public function markFailed(Tahesab $tahesab, mixed $response = null, ?string $extra = null): void { $update = [ 'res' => is_array($response) ? json_encode($response, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) : (is_string($response) ? $response : null), ]; if ($this->hasSyncColumns()) { $update['sync_status'] = self::STATUS_FAILED; $update['sync_log'] = $this->buildLog($response, $extra); } Tahesab::where('id', $tahesab->id)->update($update); } public function isFailed(Tahesab $tahesab): bool { if ($this->hasSyncColumns()) { return ($tahesab->sync_status ?? null) === self::STATUS_FAILED || (empty($tahesab->tahesab_id) && ($tahesab->sync_status ?? null) !== self::STATUS_SUCCESS); } return empty($tahesab->tahesab_id); } public function registerOnCreate(Customer $customer): void { if (! empty($customer->moshtari_code) || ! $this->isSyncEnabled()) { return; } $customerId = (int) $customer->id; dispatch(function () use ($customerId) { $service = app(TahesabSyncService::class); $customer = Customer::find($customerId); if ($customer === null || ! empty($customer->moshtari_code) || ! $service->isSyncEnabled()) { return; } try { $result = $service->registerCustomer($customer); if (! ($result['ok'] ?? false)) { Log::warning('Tahesab auto-register on customer create failed', [ 'customer_id' => $customerId, 'message' => $result['message'] ?? null, ]); } } catch (\Throwable $e) { Log::warning('Tahesab auto-register on customer create threw', [ 'customer_id' => $customerId, 'error' => $e->getMessage(), ]); } })->afterResponse(); } public function registerCustomer(Customer $customer): array { $customer->refresh(); if (! empty($customer->moshtari_code)) { return [ 'ok' => true, 'message' => 'مشتری قبلاً در ته‌حساب ثبت شده است', 'moshtari_code' => $customer->moshtari_code, ]; } $controller = app(TahesabController::class); $result = $controller->createCustomerInTahesab($customer->id); if (is_string($result)) { return ['ok' => false, 'message' => $result]; } $customer->refresh(); if (empty($customer->moshtari_code)) { return ['ok' => false, 'message' => 'ثبت مشتری در ته‌حساب ناموفق بود']; } return [ 'ok' => true, 'message' => 'مشتری با موفقیت در ته‌حساب ثبت شد', 'moshtari_code' => $customer->moshtari_code, ]; } public function retry(Tahesab $tahesab): array { if (! $this->isFailed($tahesab) && ! empty($tahesab->tahesab_id)) { return ['ok' => false, 'message' => 'این سند قبلاً در ته‌حساب ثبت شده است']; } $payload = json_decode((string) $tahesab->data, true) ?: []; $handler = $payload['handler'] ?? $this->inferHandler($tahesab); $controller = app(TahesabController::class); $result = match ($handler) { 'wallet' => $controller->wallet(array_merge($payload, ['_retry_tahesab_id' => $tahesab->id]), $tahesab), 'walletGold' => $controller->walletGold(array_merge($payload, ['_retry_tahesab_id' => $tahesab->id]), $tahesab), 'storeTrade' => $controller->storeTrade(array_merge($payload, ['_retry_tahesab_id' => $tahesab->id]), $tahesab), 'goldReward' => $controller->goldReward(array_merge($payload, ['_retry_tahesab_id' => $tahesab->id]), $tahesab), default => false, }; $tahesab->refresh(); if ($result !== false && ! empty($tahesab->tahesab_id)) { return ['ok' => true, 'message' => 'سند با موفقیت در ته‌حساب ثبت شد', 'tahesab' => $tahesab]; } return [ 'ok' => false, 'message' => $tahesab->sync_log ?: 'ارسال مجدد به ته‌حساب ناموفق بود', 'tahesab' => $tahesab, ]; } private function inferHandler(Tahesab $tahesab): string { if ($tahesab->order_id) { return 'storeTrade'; } if ($tahesab->type === 'ريال') { return 'wallet'; } $desc = (string) $tahesab->desc; if (str_contains($desc, 'شارژ کیف') || str_contains($desc, 'برداشت از کیف')) { return 'walletGold'; } return 'goldReward'; } public function isSyncEnabled(): bool { $config = app(ConfigService::class); return $config->isTahesabAccounting() && trim($config->getString('tahesab_host', '')) !== '' && trim($config->getString('tahesab_port', '')) !== '' && trim($config->getString('tahesab_token', '')) !== '' && trim($config->getString('tahesab_db', '')) !== ''; } /** ردیف‌های محلی که هنوز در ته‌حساب ثبت نشده‌اند */ public function unsyncedRowsQuery() { return Tahesab::query()->where(function ($q) { $q->where(function ($inner) { $inner->whereNull('tahesab_id')->orWhere('tahesab_id', ''); })->orWhere('sync_status', self::STATUS_FAILED); }); } /** * خلاصهٔ سندهای جاافتاده / ناموفق برای بنر ادمین. * * @return array{failed_rows:int,missing_withdraws:int,missing_orders:int,total:int,doc_sync:array} */ public function pendingSummary(?int $customerId = null): array { $hasTable = $this->ensureLocalTable(); $failedRows = $hasTable ? (int) $this->unsyncedRowsQuery() ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) ->count() : 0; $missingWithdraws = count($this->missingWithdrawIds(200, $customerId)); $missingOrders = count($this->missingOrderIds(200, $customerId)); return [ 'failed_rows' => $failedRows, 'missing_withdraws' => $missingWithdraws, 'missing_orders' => $missingOrders, 'total' => $failedRows + $missingWithdraws + $missingOrders, 'doc_sync' => app(TahesabDocSyncService::class)->snapshot(), ]; } /** * تعداد واقعی جاافتاده‌ها بدون سقف ۲۰۰ — برای جاب ارسال همه. * * @return array{failed_rows:int,missing_withdraws:int,missing_orders:int,total:int} */ public function pendingTotals(?int $customerId = null): array { $hasTable = $this->ensureLocalTable(); $failedRows = $hasTable ? (int) $this->unsyncedRowsQuery() ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) ->count() : 0; $missingWithdraws = $this->countMissingWithdraws($customerId); $missingOrders = $this->countMissingOrders($customerId); return [ 'failed_rows' => $failedRows, 'missing_withdraws' => $missingWithdraws, 'missing_orders' => $missingOrders, 'total' => $failedRows + $missingWithdraws + $missingOrders, ]; } /** * ته‌حساب خالی شده ولی ردیف محلی هنوز موفق است — کد ته‌حساب را پاک کن تا دوباره در صف بیاید. * * @return array{ok:bool,message:string,cleared:int,summary:array} */ public function resetLocalSyncedDocs(): array { $pump = app(TahesabDocSyncService::class); if ($pump->isRunning()) { return [ 'ok' => false, 'message' => 'اول ارسال جاری را متوقف کنید', 'cleared' => 0, 'summary' => $this->pendingSummary(), ]; } if (! $this->ensureLocalTable()) { return [ 'ok' => false, 'message' => 'جدول محلی ته‌حساب نیست', 'cleared' => 0, 'summary' => $this->pendingSummary(), ]; } $query = Tahesab::query()->where(function ($q) { $q->where(function ($inner) { $inner->whereNotNull('tahesab_id')->where('tahesab_id', '!=', ''); }); if ($this->hasSyncColumns()) { $q->orWhere('sync_status', self::STATUS_SUCCESS); } }); $cleared = (int) (clone $query)->count(); $update = ['tahesab_id' => null]; if ($this->hasSyncColumns()) { $update['sync_status'] = self::STATUS_PENDING; $update['sync_log'] = 'بازنشانی محلی — سند در ته‌حساب پاک شده'; } if ($cleared > 0) { (clone $query)->update($update); } $pump->clearIdleSnapshot(); return [ 'ok' => true, 'message' => $cleared > 0 ? ($cleared.' سند محلی دوباره در صف ارسال است') : 'سند موفق محلی برای بازنشانی نبود', 'cleared' => $cleared, 'summary' => $this->pendingSummary(), ]; } /** * @param array{row?:list,withdraw?:list,order?:list} $skip * @return array{type:string,id:int,label:string}|null */ public function peekNextPending(array $skip = []): ?array { if ($this->ensureLocalTable()) { $skipRow = array_values(array_filter($skip['row'] ?? [])); $row = $this->unsyncedRowsQuery() ->with('customer') ->when($skipRow !== [], fn ($q) => $q->whereNotIn('id', $skipRow)) ->orderBy('id') ->first(); if ($row) { $name = optional($row->customer)->name; return [ 'type' => 'row', 'id' => (int) $row->id, 'label' => trim('سند محلی #'.$row->id.($name ? ' — '.$name : '')), ]; } } $wIds = $this->missingWithdrawIds(1, null, array_values(array_filter($skip['withdraw'] ?? []))); if ($wIds !== []) { $id = (int) $wIds[0]; $w = \App\Models\Withdraw::query()->with('customer')->find($id); $name = optional(optional($w)->customer)->name; return [ 'type' => 'withdraw', 'id' => $id, 'label' => trim('شارژ/برداشت #'.$id.($name ? ' — '.$name : '')), ]; } $oIds = $this->missingOrderIds(1, null, array_values(array_filter($skip['order'] ?? []))); if ($oIds !== []) { $id = (int) $oIds[0]; $order = \App\Models\Order::query()->with('customer')->find($id); $name = optional(optional($order)->customer)->name; return [ 'type' => 'order', 'id' => $id, 'label' => trim('سفارش #'.$id.($name ? ' — '.$name : '')), ]; } return null; } /** * لیست یکپارچهٔ خطا خورده + جاافتاده برای تب ادمین. * * @return list> */ public function pendingDocs(?int $customerId = null, int $limit = 200): array { $limit = max(1, min(500, $limit)); $rows = []; if ($this->ensureLocalTable()) { $local = $this->unsyncedRowsQuery() ->with('customer') ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) ->orderByDesc('id') ->limit($limit) ->get(); foreach ($local as $row) { $arr = $row->toArray(); $arr['issue_kind'] = 'failed'; $arr['sync_target'] = ['type' => 'row', 'id' => (int) $row->id]; $rows[] = $arr; } } $remaining = $limit - count($rows); if ($remaining > 0) { $withdrawIds = $this->missingWithdrawIds($remaining, $customerId); if ($withdrawIds !== []) { $withdraws = \App\Models\Withdraw::query() ->with('customer') ->whereIn('id', $withdrawIds) ->orderByDesc('id') ->get(); foreach ($withdraws as $w) { $cash = (float) ($w->cash ?? 0); $gold = (float) ($w->gold ?? 0); $isGold = abs($gold) > 1e-9; $rows[] = [ 'id' => null, 'customer_id' => $w->customer_id, 'customer' => $w->customer, 'amount' => $isGold ? abs($gold) : abs($cash), 'type' => $isGold ? 'طلا' : 'ريال', 'desc' => 'شارژ/برداشت بدون سند محلی #'.$w->id.($w->note ? ' — '.$w->note : ''), 'withdraw_id' => (int) $w->id, 'order_id' => null, 'sync_status' => 'missing', 'sync_log' => 'سند در ته‌حساب ساخته نشده (جاافتاده)', 'tahesab_id' => null, 'created_at' => optional($w->created_at)?->toDateTimeString(), 'issue_kind' => 'missing', 'sync_target' => ['type' => 'withdraw', 'id' => (int) $w->id], ]; } } } $remaining = $limit - count($rows); if ($remaining > 0) { $orderIds = $this->missingOrderIds($remaining, $customerId); if ($orderIds !== []) { $orders = \App\Models\Order::query() ->with(['customer', 'product']) ->whereIn('id', $orderIds) ->orderByDesc('id') ->get(); foreach ($orders as $order) { $direction = (int) ($order->direction ?? 0); $dirLabel = $direction === 1 ? 'خرید' : ($direction === 2 ? 'فروش' : 'سفارش'); $productTitle = $order->product->title ?? 'محصول'; $rows[] = [ 'id' => null, 'customer_id' => $order->customer_id, 'customer' => $order->customer, 'amount' => (float) ($order->amount ?? 0), 'type' => 'طلا', 'desc' => $dirLabel.' '.$productTitle.' بدون سند محلی #'.$order->id, 'withdraw_id' => null, 'order_id' => (int) $order->id, 'sync_status' => 'missing', 'sync_log' => 'سند در ته‌حساب ساخته نشده (جاافتاده)', 'tahesab_id' => null, 'created_at' => optional($order->pay_at ?? $order->created_at)?->toDateTimeString(), 'issue_kind' => 'missing', 'sync_target' => ['type' => 'order', 'id' => (int) $order->id], ]; } } } usort($rows, function ($a, $b) { return strcmp((string) ($b['created_at'] ?? ''), (string) ($a['created_at'] ?? '')); }); return $rows; } /** * ثبت یک مورد: ردیف محلی / شارژ جاافتاده / سفارش جاافتاده. * * @return array{ok:bool,message:string} */ public function syncOne(string $type, int $id): array { if (! $this->isSyncEnabled()) { return ['ok' => false, 'message' => 'همگام‌سازی ته‌حساب فعال نیست یا تنظیمات ناقص است']; } $controller = app(TahesabController::class); try { if ($type === 'row') { $row = Tahesab::with('customer')->findOrFail($id); return $this->retry($row); } if ($type === 'withdraw') { return $this->backfillWithdraw($controller, $id); } if ($type === 'order') { return $this->backfillOrder($controller, $id); } return ['ok' => false, 'message' => 'نوع نامعتبر است']; } catch (\Throwable $e) { report($e); return ['ok' => false, 'message' => $e->getMessage()]; } } /** * ارسال مجدد همهٔ ناموفق‌ها + ساخت و ارسال سندهای جاافتاده از شارژ/سفارش. * * @return array{ok:bool,retried:int,backfilled_withdraws:int,backfilled_orders:int,failed:int,errors:list,summary:array} */ public function syncAllPending(int $limit = 80, ?int $customerId = null): array { if (! $this->isSyncEnabled()) { return [ 'ok' => false, 'retried' => 0, 'backfilled_withdraws' => 0, 'backfilled_orders' => 0, 'failed' => 0, 'errors' => ['همگام‌سازی ته‌حساب فعال نیست یا تنظیمات ناقص است'], 'summary' => $this->pendingSummary($customerId), ]; } $retried = 0; $backfilledWithdraws = 0; $backfilledOrders = 0; $failed = 0; $errors = []; $controller = app(TahesabController::class); $used = 0; $rows = $this->unsyncedRowsQuery() ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) ->orderBy('id') ->limit($limit) ->get(); foreach ($rows as $row) { $used++; try { $result = $this->retry($row); if ($result['ok'] ?? false) { $retried++; } else { $failed++; $errors[] = 'سند #'.$row->id.': '.($result['message'] ?? 'ناموفق'); } } catch (\Throwable $e) { $failed++; $errors[] = 'سند #'.$row->id.': '.$e->getMessage(); report($e); } } foreach ($this->missingWithdrawIds(max(0, $limit - $used), $customerId) as $withdrawId) { $used++; try { $sent = $this->backfillWithdraw($controller, (int) $withdrawId); if ($sent['ok'] ?? false) { $backfilledWithdraws++; } else { $failed++; $errors[] = 'شارژ/برداشت #'.$withdrawId.': '.($sent['message'] ?? 'ارسال به ته‌حساب ناموفق'); } } catch (\Throwable $e) { $failed++; $errors[] = 'شارژ/برداشت #'.$withdrawId.': '.$e->getMessage(); report($e); } } foreach ($this->missingOrderIds(max(0, $limit - $used), $customerId) as $orderId) { $used++; try { $sent = $this->backfillOrder($controller, (int) $orderId); if ($sent['ok'] ?? false) { $backfilledOrders++; } else { $failed++; $errors[] = 'سفارش #'.$orderId.': '.($sent['message'] ?? 'ارسال به ته‌حساب ناموفق'); } } catch (\Throwable $e) { $failed++; $errors[] = 'سفارش #'.$orderId.': '.$e->getMessage(); report($e); } } return [ 'ok' => $failed === 0, 'retried' => $retried, 'backfilled_withdraws' => $backfilledWithdraws, 'backfilled_orders' => $backfilledOrders, 'failed' => $failed, 'errors' => array_slice($errors, 0, 20), 'summary' => $this->pendingSummary($customerId), ]; } /** @return list */ private function missingWithdrawIds(int $limit, ?int $customerId = null, array $exclude = []): array { $query = $this->missingWithdrawQuery($customerId, $exclude); if ($query === null || $limit <= 0) { return []; } return $query ->orderBy('id') ->limit($limit) ->pluck('id') ->map(fn ($id) => (int) $id) ->all(); } public function countMissingWithdraws(?int $customerId = null): int { $query = $this->missingWithdrawQuery($customerId); return $query ? (int) $query->count() : 0; } private function missingWithdrawQuery(?int $customerId = null, array $exclude = []) { if (! Schema::hasTable('withdraws')) { return null; } $query = \App\Models\Withdraw::query() ->where('sts', 4) ->where(function ($q) { $q->where('cash', '!=', 0)->orWhere('gold', '!=', 0); }) ->where(function ($q) { $q->whereNull('note') ->orWhere('note', 'not like', 'fix:intval-gold-loss%'); }) ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) ->when($exclude !== [], fn ($q) => $q->whereNotIn('id', $exclude)); if (Schema::hasTable('tahesab')) { $query->whereNotExists(function ($sub) { $sub->from('tahesab')->selectRaw('1')->whereColumn('tahesab.withdraw_id', 'withdraws.id'); }); } return $query; } /** @return list */ private function missingOrderIds(int $limit, ?int $customerId = null, array $exclude = []): array { $query = $this->missingOrderQuery($customerId, $exclude); if ($query === null || $limit <= 0) { return []; } return $query ->orderBy('id') ->limit($limit) ->pluck('id') ->map(fn ($id) => (int) $id) ->all(); } public function countMissingOrders(?int $customerId = null): int { $query = $this->missingOrderQuery($customerId); return $query ? (int) $query->count() : 0; } private function missingOrderQuery(?int $customerId = null, array $exclude = []) { if (! Schema::hasTable('orders')) { return null; } $query = \App\Models\Order::query() ->where('sts', 2) ->where('payment_sts', 4) ->whereIn('direction', [1, 2]) ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) ->when($exclude !== [], fn ($q) => $q->whereNotIn('id', $exclude)); if (Schema::hasTable('tahesab')) { $query->whereNotExists(function ($sub) { $sub->from('tahesab')->selectRaw('1')->whereColumn('tahesab.order_id', 'orders.id'); }); } return $query; } private function outcomeFromController($result, string $okMessage, string $failMessage): array { if (is_array($result)) { return ['ok' => true, 'message' => $okMessage]; } if (is_string($result) && $result !== '') { return ['ok' => false, 'message' => $result]; } return ['ok' => false, 'message' => $failMessage]; } private function backfillWithdraw(TahesabController $controller, int $withdrawId): array { $withdraw = \App\Models\Withdraw::query()->find($withdrawId); if (! $withdraw || ! $withdraw->customer_id) { return ['ok' => false, 'message' => 'شارژ بدون مشتری است']; } $cash = (float) ($withdraw->cash ?? 0); $gold = (float) ($withdraw->gold ?? 0); if (abs($cash) > 1e-9) { return $this->outcomeFromController( $controller->wallet([ 'customer_id' => (int) $withdraw->customer_id, 'value' => abs($cash), 'direction' => $cash >= 0 ? 0 : 1, 'withdraw_id' => $withdrawId, 'note' => $withdraw->note, ]), 'سند شارژ/برداشت ثبت شد', 'ارسال شارژ/برداشت ناموفق بود' ); } if (abs($gold) > 1e-9) { return $this->outcomeFromController( $controller->walletGold([ 'customer_id' => (int) $withdraw->customer_id, 'value' => abs($gold), 'direction' => $gold >= 0 ? 1 : 0, 'withdraw_id' => $withdrawId, 'note' => $withdraw->note, ]), 'سند شارژ/برداشت ثبت شد', 'ارسال شارژ/برداشت ناموفق بود' ); } return ['ok' => false, 'message' => 'مبلغ شارژ صفر است']; } private function backfillOrder(TahesabController $controller, int $orderId): array { $order = \App\Models\Order::query()->with('product')->find($orderId); if (! $order || ! $order->customer_id) { return ['ok' => false, 'message' => 'سفارش بدون مشتری است']; } $product = $order->product; $payload = [ 'customer_id' => (int) $order->customer_id, 'amount' => (float) $order->amount, 'direction' => (int) $order->direction, 'price' => (float) $order->price, 'total_price' => (float) $order->total_price, 'pay_at' => $order->pay_at ?? $order->created_at, 'product_id' => $order->product_id ? (int) $order->product_id : null, 'product' => $product ? [ 'id' => (int) $product->id, 'title' => $product->title, 'group_type' => $product->group_type, 'metal_id' => $product->metal_id, 'amount' => $product->amount, 'unit' => $product->unit, 'trade_by_quantity' => $product->trade_by_quantity, ] : ['metal_id' => 1], 'order' => ['id' => $orderId, 'product_id' => $order->product_id], ]; return $this->outcomeFromController( $controller->storeTrade($payload), 'سند سفارش ثبت شد', 'ارسال سفارش ناموفق بود' ); } public function ensureLocalTable(): bool { if (Schema::hasTable('tahesab')) { return true; } try { Schema::create('tahesab', function (Blueprint $table) { $table->id(); $table->unsignedBigInteger('customer_id')->nullable()->index(); $table->unsignedBigInteger('order_id')->nullable()->index(); $table->unsignedBigInteger('withdraw_id')->nullable()->index(); $table->decimal('amount', 18, 4)->nullable(); $table->string('desc')->nullable(); $table->string('type', 32)->nullable(); $table->timestamp('date')->nullable(); $table->string('tahesab_id')->nullable()->index(); $table->string('sh_factor')->nullable(); $table->string('factor_number')->nullable(); $table->string('tah_id')->nullable(); $table->longText('data')->nullable(); $table->longText('res')->nullable(); $table->string('sync_status', 20)->default('pending'); $table->text('sync_log')->nullable(); $table->timestamps(); }); } catch (\Throwable $e) { Log::warning('Could not create tahesab table', ['error' => $e->getMessage()]); } return Schema::hasTable('tahesab'); } private function hasSyncColumns(): bool { return Schema::hasTable('tahesab') && Schema::hasColumn('tahesab', 'sync_status'); } }