diff --git a/app/Console/Commands/PumpTahesabDocSyncCommand.php b/app/Console/Commands/PumpTahesabDocSyncCommand.php new file mode 100644 index 0000000..ef4dadf --- /dev/null +++ b/app/Console/Commands/PumpTahesabDocSyncCommand.php @@ -0,0 +1,21 @@ +runLoop(); + $this->info((string) $count); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Admin/TahesabController.php b/app/Http/Controllers/Admin/TahesabController.php index 9ab03a6..ee6e7cb 100644 --- a/app/Http/Controllers/Admin/TahesabController.php +++ b/app/Http/Controllers/Admin/TahesabController.php @@ -12,6 +12,7 @@ use App\Models\Tahesab; use App\Models\Customer; use App\Services\AccountingProductDocMapper; use App\Services\ConfigService; +use App\Services\TahesabDocSyncService; use App\Services\TahesabInboundWalletService; use App\Services\TahesabSyncService; use Morilog\Jalali\Jalalian; @@ -214,6 +215,7 @@ class TahesabController extends Controller 'phone_conflicts' => $phoneConflicts, 'panel_only_list' => $panelOnlyList, 'tahesab_only_list' => $tahesabOnlyResult, + 'doc_sync' => app(TahesabDocSyncService::class)->snapshot(), ]; // برای تشخیص نام دقیق فیلدها: ?include_tahesab=1&raw=1 @@ -672,11 +674,27 @@ class TahesabController extends Controller public function pendingSummary(Request $request) { + $docs = app(TahesabDocSyncService::class); + $docs->tickIfStale(); $customerId = $request->filled('customer_id') ? (int) $request->input('customer_id') : null; return response()->json($this->sync()->pendingSummary($customerId)); } + public function startDocSync() + { + $result = app(TahesabDocSyncService::class)->start(); + + return response()->json($result, ($result['ok'] ?? false) ? 200 : 422); + } + + public function stopDocSync() + { + $result = app(TahesabDocSyncService::class)->stop(); + + return response()->json($result); + } + public function pendingDocs(Request $request) { $customerId = $request->filled('customer_id') ? (int) $request->input('customer_id') : null; diff --git a/app/Services/TahesabDocSyncService.php b/app/Services/TahesabDocSyncService.php new file mode 100644 index 0000000..0ed7ef1 --- /dev/null +++ b/app/Services/TahesabDocSyncService.php @@ -0,0 +1,373 @@ +read(); + + return $this->publicSnapshot($job); + } + + /** + * @return array{ok:bool,message:string,doc_sync:array} + */ + public function start(): array + { + $sync = app(TahesabSyncService::class); + if (! $sync->isSyncEnabled()) { + return [ + 'ok' => false, + 'message' => 'همگام‌سازی ته‌حساب فعال نیست یا تنظیمات ناقص است', + 'doc_sync' => $this->snapshot(), + ]; + } + + $current = $this->read(); + if (($current['running'] ?? false) === true) { + return [ + 'ok' => true, + 'message' => 'ارسال سندها از قبل در جریان است', + 'doc_sync' => $this->publicSnapshot($current), + ]; + } + + $totals = $sync->pendingTotals(); + $total = (int) ($totals['total'] ?? 0); + if ($total <= 0) { + return [ + 'ok' => false, + 'message' => 'سند جاافتاده‌ای برای ارسال نیست', + 'doc_sync' => $this->emptySnapshot(), + ]; + } + + $job = [ + 'running' => true, + 'stop' => false, + 'stopped' => false, + 'finished' => false, + 'total' => $total, + 'processed' => 0, + 'success' => 0, + 'failed' => 0, + 'current' => '', + 'errors' => [], + 'skip' => ['row' => [], 'withdraw' => [], 'order' => []], + 'started_at' => now()->toDateTimeString(), + 'finished_at' => null, + 'beat_at' => now()->toDateTimeString(), + ]; + $this->write($job, self::TTL_RUNNING); + $this->spawnPump(); + + return [ + 'ok' => true, + 'message' => 'ارسال سندها شروع شد', + 'doc_sync' => $this->publicSnapshot($job), + ]; + } + + /** + * @return array{ok:bool,message:string,doc_sync:array} + */ + public function stop(): array + { + $job = $this->read(); + if (($job['running'] ?? false) !== true) { + return [ + 'ok' => true, + 'message' => 'ارسال در جریان نیست', + 'doc_sync' => $this->publicSnapshot($job), + ]; + } + + $job['stop'] = true; + $this->write($job, self::TTL_RUNNING); + + return [ + 'ok' => true, + 'message' => 'درخواست توقف ثبت شد', + 'doc_sync' => $this->publicSnapshot($job), + ]; + } + + public function tickIfStale(): void + { + $job = $this->read(); + if (($job['running'] ?? false) !== true) { + return; + } + + $beat = $job['beat_at'] ?? null; + if ($beat && now()->diffInSeconds(\Illuminate\Support\Carbon::parse($beat)) < self::STALE_SECONDS) { + return; + } + + $this->pumpOnce(); + } + + public function runLoop(): int + { + $done = 0; + while (true) { + if (! $this->tryLock()) { + return $done; + } + try { + $cont = $this->pumpOnceUnlocked(); + } finally { + $this->releaseLock(); + } + if (! $cont) { + break; + } + $done++; + usleep((int) (self::GAP_SECONDS * 1_000_000)); + } + + return $done; + } + + public function pumpOnce(): bool + { + if (! $this->tryLock()) { + return true; + } + try { + return $this->pumpOnceUnlocked(); + } finally { + $this->releaseLock(); + } + } + + private function pumpOnceUnlocked(): bool + { + $job = $this->read(); + if (($job['running'] ?? false) !== true) { + return false; + } + + if (($job['stop'] ?? false) === true) { + $this->finish($job, true); + + return false; + } + + $sync = app(TahesabSyncService::class); + $next = $sync->peekNextPending($job['skip'] ?? []); + if ($next === null) { + $this->finish($job, false); + + return false; + } + + $job['current'] = (string) ($next['label'] ?? ''); + $job['beat_at'] = now()->toDateTimeString(); + $this->write($job, self::TTL_RUNNING); + + $type = (string) $next['type']; + $id = (int) $next['id']; + $ok = false; + $message = ''; + try { + $result = $sync->syncOne($type, $id); + $ok = (bool) ($result['ok'] ?? false); + $message = (string) ($result['message'] ?? ''); + } catch (\Throwable $e) { + $ok = false; + $message = $e->getMessage(); + Log::warning('Tahesab doc sync item failed', [ + 'type' => $type, + 'id' => $id, + 'error' => $e->getMessage(), + ]); + } + + if (! $ok && $this->looksRateLimited($message)) { + $job = $this->read(); + if (($job['running'] ?? false) !== true) { + return false; + } + $job['current'] = 'محدودیت نرخ ته‌حساب — کمی صبر…'; + $job['beat_at'] = now()->toDateTimeString(); + $this->write($job, self::TTL_RUNNING); + usleep(5_000_000); + + return true; + } + + $job = $this->read(); + if (($job['running'] ?? false) !== true) { + return false; + } + + $job['processed'] = (int) ($job['processed'] ?? 0) + 1; + if ($ok) { + $job['success'] = (int) ($job['success'] ?? 0) + 1; + } else { + $job['failed'] = (int) ($job['failed'] ?? 0) + 1; + $errors = is_array($job['errors'] ?? null) ? $job['errors'] : []; + $errors[] = [ + 'label' => $job['current'] ?: ($type.' #'.$id), + 'error' => $message !== '' ? $message : 'ناموفق', + ]; + $job['errors'] = array_slice($errors, -30); + $skip = is_array($job['skip'] ?? null) ? $job['skip'] : ['row' => [], 'withdraw' => [], 'order' => []]; + $skip[$type] = array_values(array_unique(array_merge($skip[$type] ?? [], [$id]))); + $job['skip'] = $skip; + } + + $job['beat_at'] = now()->toDateTimeString(); + $this->write($job, self::TTL_RUNNING); + + return true; + } + + private function finish(array $job, bool $stopped): void + { + $job['running'] = false; + $job['finished'] = true; + $job['stopped'] = $stopped; + $job['stop'] = false; + $job['current'] = ''; + $job['finished_at'] = now()->toDateTimeString(); + $job['beat_at'] = now()->toDateTimeString(); + $this->write($job, self::TTL_DONE); + } + + private function spawnPump(): void + { + if (\PHP_OS_FAMILY === 'Windows') { + return; + } + + $php = PHP_BINARY; + if (stripos((string) $php, 'fpm') !== false || ! is_executable((string) $php)) { + $php = 'php'; + } + $artisan = base_path('artisan'); + $cmd = 'nohup '.escapeshellarg($php).' '.escapeshellarg($artisan) + .' tahesab:pump-doc-sync >/dev/null 2>&1 &'; + if (! function_exists('exec')) { + Log::warning('Tahesab doc sync spawn skipped: exec disabled'); + + return; + } + try { + exec($cmd); + } catch (\Throwable $e) { + Log::warning('Tahesab doc sync spawn failed', ['error' => $e->getMessage()]); + } + } + + private function tryLock(): bool + { + $path = storage_path('framework/cache/tahesab-doc-sync.lock'); + $dir = dirname($path); + if (! is_dir($dir)) { + @mkdir($dir, 0775, true); + } + $handle = fopen($path, 'c'); + if ($handle === false) { + return false; + } + if (! flock($handle, LOCK_EX | LOCK_NB)) { + fclose($handle); + + return false; + } + $this->lockHandle = $handle; + + return true; + } + + private function releaseLock(): void + { + if (! is_resource($this->lockHandle)) { + $this->lockHandle = null; + + return; + } + flock($this->lockHandle, LOCK_UN); + fclose($this->lockHandle); + $this->lockHandle = null; + } + + private function looksRateLimited(string $message): bool + { + $m = mb_strtolower($message); + + return str_contains($m, '429') + || str_contains($m, 'too many') + || str_contains($m, 'rate limit') + || str_contains($m, 'تعداد درخواست'); + } + + private function read(): array + { + $job = Cache::get(self::CACHE_KEY); + + return is_array($job) ? $job : []; + } + + private function write(array $job, int $ttl): void + { + Cache::put(self::CACHE_KEY, $job, $ttl); + } + + private function emptySnapshot(): array + { + return $this->publicSnapshot([]); + } + + private function publicSnapshot(array $job): array + { + $running = (bool) ($job['running'] ?? false); + $finished = (bool) ($job['finished'] ?? false); + $total = (int) ($job['total'] ?? 0); + $processed = (int) ($job['processed'] ?? 0); + $percent = $total > 0 ? min(100, (int) round(($processed / $total) * 100)) : 0; + if ($finished && ! ($job['stopped'] ?? false)) { + $percent = 100; + } + + return [ + 'running' => $running, + 'finished' => $finished, + 'stopped' => (bool) ($job['stopped'] ?? false), + 'percent' => $percent, + 'total' => $total, + 'processed' => $processed, + 'success' => (int) ($job['success'] ?? 0), + 'failed' => (int) ($job['failed'] ?? 0), + 'current' => (string) ($job['current'] ?? ''), + 'errors' => is_array($job['errors'] ?? null) ? $job['errors'] : [], + 'started_at' => $job['started_at'] ?? null, + 'finished_at' => $job['finished_at'] ?? null, + ]; + } +} diff --git a/app/Services/TahesabSyncService.php b/app/Services/TahesabSyncService.php index baaf5ef..dde4fae 100644 --- a/app/Services/TahesabSyncService.php +++ b/app/Services/TahesabSyncService.php @@ -233,9 +233,75 @@ class TahesabSyncService '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 + { + $failedRows = (int) $this->unsyncedRowsQuery() + ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) + ->count(); + $missingWithdraws = $this->countMissingWithdraws($customerId); + $missingOrders = $this->countMissingOrders($customerId); + + return [ + 'failed_rows' => $failedRows, + 'missing_withdraws' => $missingWithdraws, + 'missing_orders' => $missingOrders, + 'total' => $failedRows + $missingWithdraws + $missingOrders, + ]; + } + + /** + * @param array{row?:list,withdraw?:list,order?:list} $skip + * @return array{type:string,id:int,label:string}|null + */ + public function peekNextPending(array $skip = []): ?array + { + $skipRow = array_values(array_filter($skip['row'] ?? [])); + $row = $this->unsyncedRowsQuery() + ->when($skipRow !== [], fn ($q) => $q->whereNotIn('id', $skipRow)) + ->orderBy('id') + ->first(); + if ($row) { + return [ + 'type' => 'row', + 'id' => (int) $row->id, + 'label' => 'سند محلی #'.$row->id, + ]; + } + + $wIds = $this->missingWithdrawIds(1, null, array_values(array_filter($skip['withdraw'] ?? []))); + if ($wIds !== []) { + $id = (int) $wIds[0]; + + return [ + 'type' => 'withdraw', + 'id' => $id, + 'label' => 'شارژ/برداشت #'.$id, + ]; + } + + $oIds = $this->missingOrderIds(1, null, array_values(array_filter($skip['order'] ?? []))); + if ($oIds !== []) { + $id = (int) $oIds[0]; + + return [ + 'type' => 'order', + 'id' => $id, + 'label' => 'سفارش #'.$id, + ]; + } + + return null; + } + /** * لیست یکپارچهٔ خطا خورده + جاافتاده برای تب ادمین. * @@ -466,17 +532,33 @@ class TahesabSyncService } /** @return list */ - private function missingWithdrawIds(int $limit, ?int $customerId = null): array + private function missingWithdrawIds(int $limit, ?int $customerId = null, array $exclude = []): array { - if ($limit <= 0 || ! Schema::hasTable('withdraws')) { + $query = $this->missingWithdrawQuery($customerId, $exclude); + if ($query === null || $limit <= 0) { return []; } - $linked = Tahesab::query() - ->whereNotNull('withdraw_id') - ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) - ->pluck('withdraw_id') + 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; + } return \App\Models\Withdraw::query() ->where('sts', 4) @@ -488,7 +570,21 @@ class TahesabSyncService ->orWhere('note', 'not like', 'fix:intval-gold-loss%'); }) ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) - ->when($linked !== [], fn ($q) => $q->whereNotIn('id', $linked)) + ->when($exclude !== [], fn ($q) => $q->whereNotIn('id', $exclude)) + ->whereNotExists(function ($sub) { + $sub->from('tahesab')->selectRaw('1')->whereColumn('tahesab.withdraw_id', 'withdraws.id'); + }); + } + + /** @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') @@ -496,30 +592,28 @@ class TahesabSyncService ->all(); } - /** @return list */ - private function missingOrderIds(int $limit, ?int $customerId = null): array + public function countMissingOrders(?int $customerId = null): int { - if ($limit <= 0 || ! Schema::hasTable('orders')) { - return []; - } + $query = $this->missingOrderQuery($customerId); - $linked = Tahesab::query() - ->whereNotNull('order_id') - ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) - ->pluck('order_id') - ->all(); + return $query ? (int) $query->count() : 0; + } + + private function missingOrderQuery(?int $customerId = null, array $exclude = []) + { + if (! Schema::hasTable('orders')) { + return null; + } return \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($linked !== [], fn ($q) => $q->whereNotIn('id', $linked)) - ->orderBy('id') - ->limit($limit) - ->pluck('id') - ->map(fn ($id) => (int) $id) - ->all(); + ->when($exclude !== [], fn ($q) => $q->whereNotIn('id', $exclude)) + ->whereNotExists(function ($sub) { + $sub->from('tahesab')->selectRaw('1')->whereColumn('tahesab.order_id', 'orders.id'); + }); } private function backfillWithdraw(TahesabController $controller, int $withdrawId): bool diff --git a/routes/admin.php b/routes/admin.php index 0f00a0e..63e771b 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -247,6 +247,8 @@ Route::middleware('auth:user')->group(function (){ Route::get('tahesab/pending-docs', [TahesabController::class, 'pendingDocs']); Route::post('tahesab/sync-one', [TahesabController::class, 'syncOne']); Route::post('tahesab/sync-all-pending', [TahesabController::class, 'syncAllPending']); + Route::post('tahesab/doc-sync/start', [TahesabController::class, 'startDocSync']); + Route::post('tahesab/doc-sync/stop', [TahesabController::class, 'stopDocSync']); Route::get('tahesab/customer-sync/status', [TahesabController::class, 'customerSyncStatus']); Route::post('tahesab/customer-sync/panel-to-tahesab', [TahesabController::class, 'syncPanelToTahesab']); Route::post('tahesab/customer-sync/import-from-tahesab', [TahesabController::class, 'importFromTahesab']);