From c802c67e0d21a4b27ad34677c16ca289382d8637 Mon Sep 17 00:00:00 2001 From: adonis Date: Thu, 3 Sep 2026 10:09:54 +0330 Subject: [PATCH] feat(tahesab): register remaining panel customers after document sync After pending docs finish, the same pump writes customers without a code into Tahesab. Inbound import is server-side; the admin only polls status. Co-authored-by: Cursor --- .../Controllers/Admin/TahesabController.php | 56 +++ app/Services/TahesabDocSyncService.php | 371 ++++++++++++++++-- routes/admin.php | 2 + 3 files changed, 399 insertions(+), 30 deletions(-) diff --git a/app/Http/Controllers/Admin/TahesabController.php b/app/Http/Controllers/Admin/TahesabController.php index d36bbd1..dc62322 100644 --- a/app/Http/Controllers/Admin/TahesabController.php +++ b/app/Http/Controllers/Admin/TahesabController.php @@ -100,6 +100,8 @@ class TahesabController extends Controller public function customerSyncStatus(Request $request): \Illuminate\Http\JsonResponse { + app(TahesabDocSyncService::class)->tickIfStale(); + // مشتریان پنل $panelTotal = Customer::whereNull('deleted_at')->count(); $panelRegistered = Customer::whereNull('deleted_at')->whereNotNull('moshtari_code')->count(); @@ -326,6 +328,46 @@ class TahesabController extends Controller return ['by_code' => $byCode, 'by_mobile' => $byMobile]; } + /** + * مشتریان ته‌حساب که در پنل نیستند و شماره دارند — برای ورود تکی‌تکی سمت سرور. + * + * @return list + */ + public function collectImportableTahesabCustomers(): array + { + $tahesabCustomers = $this->fetchAllTahesabCustomers(); + $maps = $this->panelAccountingMaps(); + $out = []; + foreach ($tahesabCustomers as $item) { + if (! is_array($item)) { + continue; + } + $code = $this->extractTahesabCode($item); + $codeKey = $this->accountingCodeKey($code); + $phone = $this->extractTahesabPhone($item); + if (Customer::normalizeMobile($phone) === null) { + continue; + } + if ($codeKey !== null && isset($maps['by_code'][$codeKey])) { + continue; + } + $mobileKey = Customer::normalizeMobile($phone); + if ($mobileKey && isset($maps['by_mobile'][$mobileKey])) { + continue; + } + if ($codeKey === null) { + continue; + } + $out[] = [ + 'code' => (int) $code, + 'name' => trim((string) ($item['Name'] ?? $item['name'] ?? '')), + 'tel' => $phone, + ]; + } + + return $out; + } + /** لینک کد ته‌حساب روی مشتری پنل وقتی شماره یکسان است */ public function linkMatchingPhones(): \Illuminate\Http\JsonResponse { @@ -701,6 +743,20 @@ class TahesabController extends Controller return response()->json($result); } + public function startCustomerOutbound() + { + $result = app(TahesabDocSyncService::class)->startCustomerOutbound(); + + return response()->json($result, ($result['ok'] ?? false) ? 200 : 422); + } + + public function startCustomerInbound() + { + $result = app(TahesabDocSyncService::class)->startCustomerInbound(); + + return response()->json($result, ($result['ok'] ?? false) ? 200 : 422); + } + 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 index 0ed7ef1..5943bed 100644 --- a/app/Services/TahesabDocSyncService.php +++ b/app/Services/TahesabDocSyncService.php @@ -2,18 +2,21 @@ namespace App\Services; +use App\Http\Controllers\Admin\TahesabController; +use App\Models\Customer; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; /** - * ارسال آرام همهٔ سندهای جاافتاده به ته‌حساب — بدون schedule. - * استارت از API؛ حلقه با artisan در پس‌زمینه یا یک تیک روی pending-summary. + * ارسال آرام سندها (و بعد از آن مشتریان) به ته‌حساب — بدون schedule و بدون جاب صف. + * استارت از API؛ ادامه با همان پمپ موجود یا یک تیک روی pending-summary. */ class TahesabDocSyncService { public const CACHE_KEY = 'tahesab_doc_sync_job'; - /** فاصله بین دو سند تا ته‌حساب بن نکند (ثانیه) */ + /** فاصله بین دو آیتم تا ته‌حساب بن نکند (ثانیه) */ public const GAP_SECONDS = 2.5; private const STALE_SECONDS = 8; @@ -50,7 +53,7 @@ class TahesabDocSyncService if (($current['running'] ?? false) === true) { return [ 'ok' => true, - 'message' => 'ارسال سندها از قبل در جریان است', + 'message' => 'ارسال از قبل در جریان است', 'doc_sync' => $this->publicSnapshot($current), ]; } @@ -65,32 +68,85 @@ class TahesabDocSyncService ]; } - $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(), - ]; + $job = $this->newRunningJob('docs', $total); + $job['skip'] = ['row' => [], 'withdraw' => [], 'order' => []]; $this->write($job, self::TTL_RUNNING); $this->spawnPump(); return [ 'ok' => true, - 'message' => 'ارسال سندها شروع شد', + 'message' => 'ارسال سندها شروع شد؛ بعد از سندها مشتریان پنل هم در ته‌حساب ثبت می‌شوند', 'doc_sync' => $this->publicSnapshot($job), ]; } + /** + * ثبت مشتریان پنلِ بدون کد در ته‌حساب — بدون کامند تازه؛ تیک از pending-summary. + * + * @return array{ok:bool,message:string,doc_sync:array} + */ + public function startCustomerOutbound(): array + { + $guard = $this->guardCustomerStart(); + if ($guard !== null) { + return $guard; + } + + $total = (int) Customer::whereNull('deleted_at')->whereNull('moshtari_code')->count(); + if ($total <= 0) { + return [ + 'ok' => false, + 'message' => 'مشتری پنل بدون کد ته‌حساب نیست', + 'doc_sync' => $this->emptySnapshot(), + ]; + } + + $job = $this->newRunningJob('customers_out', $total); + $job['skip_ids'] = []; + $this->write($job, self::TTL_RUNNING); + $this->pumpOnce(); + + return [ + 'ok' => true, + 'message' => 'ثبت مشتریان در ته‌حساب شروع شد', + 'doc_sync' => $this->snapshot(), + ]; + } + + /** + * ورود مشتریان ته‌حساب به پنل — لیست یک‌بار سمت سرور؛ کلاینت فقط وضعیت می‌گیرد. + * + * @return array{ok:bool,message:string,doc_sync:array} + */ + public function startCustomerInbound(): array + { + $guard = $this->guardCustomerStart(); + if ($guard !== null) { + return $guard; + } + + $rows = app(TahesabController::class)->collectImportableTahesabCustomers(); + if ($rows === []) { + return [ + 'ok' => false, + 'message' => 'مشتری جدیدی با شماره برای ورود به پنل نیست', + 'doc_sync' => $this->emptySnapshot(), + ]; + } + + $job = $this->newRunningJob('customers_in', count($rows)); + $job['queue'] = $rows; + $job['skip_codes'] = []; + $job['current'] = 'لیست از ته‌حساب گرفته شد'; + $this->write($job, self::TTL_RUNNING); + + return [ + 'ok' => true, + 'message' => 'ورود مشتریان از ته‌حساب شروع شد', + 'doc_sync' => $this->snapshot(), + ]; + } + /** * @return array{ok:bool,message:string,doc_sync:array} */ @@ -123,7 +179,9 @@ class TahesabDocSyncService } $beat = $job['beat_at'] ?? null; - if ($beat && now()->diffInSeconds(\Illuminate\Support\Carbon::parse($beat)) < self::STALE_SECONDS) { + $stage = (string) ($job['stage'] ?? 'docs'); + $staleAfter = ($stage === 'customers_out' || $stage === 'customers_in') ? 2 : self::STALE_SECONDS; + if ($beat && now()->diffInSeconds(\Illuminate\Support\Carbon::parse($beat)) < $staleAfter) { return; } @@ -177,12 +235,23 @@ class TahesabDocSyncService return false; } + $stage = (string) ($job['stage'] ?? 'docs'); + if ($stage === 'customers_out') { + return $this->pumpOutbound($job); + } + if ($stage === 'customers_in') { + return $this->pumpInbound($job); + } + + return $this->pumpDocs($job); + } + + private function pumpDocs(array $job): bool + { $sync = app(TahesabSyncService::class); $next = $sync->peekNextPending($job['skip'] ?? []); if ($next === null) { - $this->finish($job, false); - - return false; + return $this->advanceToOutboundOrFinish($job); } $job['current'] = (string) ($next['label'] ?? ''); @@ -207,6 +276,167 @@ class TahesabDocSyncService ]); } + return $this->recordItem($ok, $message, $job['current'] ?: ($type.' #'.$id), function (array $job, bool $okNow) use ($type, $id) { + if ($okNow) { + return $job; + } + $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; + + return $job; + }); + } + + private function advanceToOutboundOrFinish(array $job): bool + { + $job['doc_total'] = (int) ($job['total'] ?? 0); + $job['doc_success'] = (int) ($job['success'] ?? 0); + $job['doc_failed'] = (int) ($job['failed'] ?? 0); + + $total = (int) Customer::whereNull('deleted_at')->whereNull('moshtari_code')->count(); + if ($total <= 0) { + $this->finish($job, false); + + return false; + } + + $job['stage'] = 'customers_out'; + $job['total'] = $total; + $job['processed'] = 0; + $job['success'] = 0; + $job['failed'] = 0; + $job['errors'] = []; + $job['skip_ids'] = []; + $job['current'] = 'شروع ثبت مشتریان پنل در ته‌حساب…'; + $job['beat_at'] = now()->toDateTimeString(); + $this->write($job, self::TTL_RUNNING); + + return true; + } + + private function pumpOutbound(array $job): bool + { + $skip = array_values(array_filter($job['skip_ids'] ?? [])); + $customer = Customer::whereNull('deleted_at') + ->whereNull('moshtari_code') + ->when($skip !== [], fn ($q) => $q->whereNotIn('id', $skip)) + ->orderBy('id') + ->first(['id', 'name', 'mobile']); + + if ($customer === null) { + $this->finish($job, false); + + return false; + } + + $label = trim((string) $customer->name.' '.(string) $customer->mobile); + $job['current'] = $label !== '' ? $label : ('مشتری #'.$customer->id); + $job['beat_at'] = now()->toDateTimeString(); + $this->write($job, self::TTL_RUNNING); + + $ok = false; + $message = ''; + try { + $result = app(TahesabSyncService::class)->registerCustomer($customer); + $ok = (bool) ($result['ok'] ?? false); + $message = (string) ($result['message'] ?? ''); + } catch (\Throwable $e) { + $ok = false; + $message = $e->getMessage(); + Log::warning('Tahesab customer outbound failed', [ + 'id' => $customer->id, + 'error' => $e->getMessage(), + ]); + } + + $id = (int) $customer->id; + + return $this->recordItem($ok, $message, $job['current'], function (array $job, bool $okNow) use ($id) { + if ($okNow) { + return $job; + } + $skip = array_values(array_filter($job['skip_ids'] ?? [])); + $skip[] = $id; + $job['skip_ids'] = array_values(array_unique($skip)); + + return $job; + }); + } + + private function pumpInbound(array $job): bool + { + $queue = is_array($job['queue'] ?? null) ? $job['queue'] : []; + $skip = []; + foreach ($job['skip_codes'] ?? [] as $code) { + $skip[(int) $code] = true; + } + + $idx = null; + $row = null; + foreach ($queue as $i => $item) { + if (! is_array($item)) { + continue; + } + $code = (int) ($item['code'] ?? 0); + if ($code > 0 && isset($skip[$code])) { + continue; + } + $idx = $i; + $row = $item; + break; + } + + if ($row === null) { + $this->finish($job, false); + + return false; + } + + $label = trim((string) ($row['name'] ?? '').' '.(string) ($row['code'] ?? '')); + $job['current'] = $label !== '' ? $label : 'مشتری ته‌حساب'; + $job['beat_at'] = now()->toDateTimeString(); + $this->write($job, self::TTL_RUNNING); + + $ok = false; + $message = ''; + try { + $result = $this->importInboundRow($row); + $ok = (bool) ($result['ok'] ?? false); + $message = (string) ($result['message'] ?? ''); + } catch (\Throwable $e) { + $ok = false; + $message = $e->getMessage(); + Log::warning('Tahesab customer inbound failed', [ + 'code' => $row['code'] ?? null, + 'error' => $e->getMessage(), + ]); + } + + $code = (int) ($row['code'] ?? 0); + + return $this->recordItem($ok, $message, $job['current'], function (array $job, bool $okNow) use ($idx, $code) { + $queue = is_array($job['queue'] ?? null) ? $job['queue'] : []; + if ($okNow && $idx !== null) { + array_splice($queue, (int) $idx, 1); + $job['queue'] = array_values($queue); + } elseif (! $okNow) { + $skip = array_values(array_filter($job['skip_codes'] ?? [])); + if ($code > 0) { + $skip[] = $code; + } + $job['skip_codes'] = array_values(array_unique($skip)); + } + + return $job; + }); + } + + /** + * @param callable(array, bool): array $mutate + */ + private function recordItem(bool $ok, string $message, string $label, callable $mutate): bool + { if (! $ok && $this->looksRateLimited($message)) { $job = $this->read(); if (($job['running'] ?? false) !== true) { @@ -232,21 +462,43 @@ class TahesabDocSyncService $job['failed'] = (int) ($job['failed'] ?? 0) + 1; $errors = is_array($job['errors'] ?? null) ? $job['errors'] : []; $errors[] = [ - 'label' => $job['current'] ?: ($type.' #'.$id), + 'label' => $label !== '' ? $label : 'آیتم', '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 = $mutate($job, $ok); $job['beat_at'] = now()->toDateTimeString(); $this->write($job, self::TTL_RUNNING); return true; } + /** + * @param array $row + * @return array{ok:bool,message:string} + */ + private function importInboundRow(array $row): array + { + $request = Request::create('/', 'POST', [ + 'customers' => [[ + 'code' => (int) ($row['code'] ?? 0), + 'name' => (string) ($row['name'] ?? ''), + 'tel' => (string) ($row['tel'] ?? ''), + ]], + ]); + $response = app(TahesabController::class)->importFromTahesab($request); + $data = method_exists($response, 'getData') ? $response->getData(true) : []; + $ok = ((int) ($data['success'] ?? 0)) > 0; + $err = ''; + if (! $ok) { + $errors = is_array($data['errors'] ?? null) ? $data['errors'] : []; + $err = (string) ($errors[0]['error'] ?? 'وارد نشد'); + } + + return ['ok' => $ok, 'message' => $err]; + } + private function finish(array $job, bool $stopped): void { $job['running'] = false; @@ -254,6 +506,7 @@ class TahesabDocSyncService $job['stopped'] = $stopped; $job['stop'] = false; $job['current'] = ''; + $job['queue'] = []; $job['finished_at'] = now()->toDateTimeString(); $job['beat_at'] = now()->toDateTimeString(); $this->write($job, self::TTL_DONE); @@ -327,6 +580,55 @@ class TahesabDocSyncService || str_contains($m, 'تعداد درخواست'); } + /** + * @return array{ok:bool,message:string,doc_sync:array}|null + */ + private function guardCustomerStart(): ?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), + ]; + } + + return null; + } + + /** + * @return array + */ + private function newRunningJob(string $stage, int $total): array + { + return [ + 'running' => true, + 'stop' => false, + 'stopped' => false, + 'finished' => false, + 'stage' => $stage, + 'total' => $total, + 'processed' => 0, + 'success' => 0, + 'failed' => 0, + 'current' => '', + 'errors' => [], + 'started_at' => now()->toDateTimeString(), + 'finished_at' => null, + 'beat_at' => now()->toDateTimeString(), + ]; + } + private function read(): array { $job = Cache::get(self::CACHE_KEY); @@ -355,10 +657,16 @@ class TahesabDocSyncService $percent = 100; } + $stage = (string) ($job['stage'] ?? 'docs'); + if ($stage === '') { + $stage = 'docs'; + } + return [ 'running' => $running, 'finished' => $finished, 'stopped' => (bool) ($job['stopped'] ?? false), + 'stage' => $stage, 'percent' => $percent, 'total' => $total, 'processed' => $processed, @@ -366,6 +674,9 @@ class TahesabDocSyncService 'failed' => (int) ($job['failed'] ?? 0), 'current' => (string) ($job['current'] ?? ''), 'errors' => is_array($job['errors'] ?? null) ? $job['errors'] : [], + 'doc_total' => (int) ($job['doc_total'] ?? 0), + 'doc_success' => (int) ($job['doc_success'] ?? 0), + 'doc_failed' => (int) ($job['doc_failed'] ?? 0), 'started_at' => $job['started_at'] ?? null, 'finished_at' => $job['finished_at'] ?? null, ]; diff --git a/routes/admin.php b/routes/admin.php index 63e771b..74cf2fd 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -249,6 +249,8 @@ Route::middleware('auth:user')->group(function (){ 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::post('tahesab/customer-sync/outbound/start', [TahesabController::class, 'startCustomerOutbound']); + Route::post('tahesab/customer-sync/inbound/start', [TahesabController::class, 'startCustomerInbound']); 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']);