From e059a4709e51f775f1198b834ee2134096e41ff4 Mon Sep 17 00:00:00 2001 From: mohammad sedighi Date: Sun, 30 Aug 2026 14:16:36 +0330 Subject: [PATCH] Apply Tahesab account balances to wallets with admin overwrite control. Add inbound mande sync on customer import plus per-customer and bulk wallet-sync endpoints so previously linked accounts can catch up. Co-authored-by: Cursor --- .../Controllers/Admin/TahesabController.php | 174 ++++++++ .../Controllers/Customer/WalletController.php | 7 + app/Services/TahesabInboundWalletService.php | 402 ++++++++++++++++++ routes/admin.php | 2 + 4 files changed, 585 insertions(+) create mode 100644 app/Services/TahesabInboundWalletService.php diff --git a/app/Http/Controllers/Admin/TahesabController.php b/app/Http/Controllers/Admin/TahesabController.php index 2742f0f..a2bccce 100644 --- a/app/Http/Controllers/Admin/TahesabController.php +++ b/app/Http/Controllers/Admin/TahesabController.php @@ -11,6 +11,8 @@ use Illuminate\Support\Facades\Cache; use App\Models\Tahesab; use App\Models\Customer; use App\Services\AccountingProductDocMapper; +use App\Services\ConfigService; +use App\Services\TahesabInboundWalletService; use App\Services\TahesabSyncService; use Morilog\Jalali\Jalalian; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; @@ -237,6 +239,8 @@ class TahesabController extends Controller $success = 0; $failed = 0; $errors = []; + /** @var list $importedForWallet */ + $importedForWallet = []; foreach ($validated['customers'] as $item) { $mobile = Customer::normalizeMobile($item['tel'] ?? '') ?? ''; @@ -282,6 +286,7 @@ class TahesabController extends Controller if ($changed) { $existing->save(); } + $importedForWallet[] = $existing; $success++; continue; } @@ -300,6 +305,7 @@ class TahesabController extends Controller $byMobile->password = $mobile; } $byMobile->save(); + $importedForWallet[] = $byMobile; $success++; continue; } @@ -340,6 +346,7 @@ class TahesabController extends Controller $customer->password = $mobile; } $customer->save(); + $importedForWallet[] = $customer; $success++; } catch (\Throwable $e) { $failed++; @@ -353,6 +360,18 @@ class TahesabController extends Controller } } + // مانده طلا/ریال ته‌حساب را روی کیف مشتریان واردشده بنشان + if ($importedForWallet !== []) { + try { + app(TahesabInboundWalletService::class)->syncAfterImport($importedForWallet); + } catch (\Throwable $e) { + \Log::warning('Tahesab import wallet sync batch failed', [ + 'count' => count($importedForWallet), + 'error' => $e->getMessage(), + ]); + } + } + return response()->json([ 'ok' => true, 'success' => $success, @@ -452,6 +471,161 @@ class TahesabController extends Controller return response()->json($result, ($result['ok'] ?? false) ? 200 : 422); } + /** + * همگام‌سازی مانده یک مشتری با ته‌حساب. + * body: overwrite=1 | keep=1 | ask_if_conflict=1 (پیش‌فرض ask برای جلوگیری از overwrite ناخواسته) + */ + public function syncCustomerWallet(Request $request, $customerId) + { + if (! app(ConfigService::class)->isTahesabAccounting()) { + return response()->json([ + 'ok' => false, + 'message' => 'حسابداری ته‌حساب فعال نیست', + ], 422); + } + + $customer = Customer::findOrFail($customerId); + if ((int) ($customer->moshtari_code ?? 0) <= 0) { + return response()->json([ + 'ok' => false, + 'message' => 'این مشتری کد ته‌حساب ندارد', + ], 422); + } + + $overwrite = $request->boolean('overwrite'); + $keep = $request->boolean('keep'); + // اگر صراحتاً overwrite/keep نباشد → از ادمین بپرس وقتی تداخل هست + $askIfConflict = $request->boolean('ask_if_conflict', ! $overwrite && ! $keep); + + $result = app(TahesabInboundWalletService::class)->syncWalletFromTahesab( + $customer, + null, + [ + 'overwrite' => $overwrite, + 'keep' => $keep, + 'ask_if_conflict' => $askIfConflict, + ] + ); + + $needsDecision = (bool) ($result['needs_decision'] ?? false); + $applied = (bool) ($result['applied'] ?? false); + $skipped = (bool) ($result['skipped'] ?? false); + $same = ! $applied && ! $needsDecision && ! $skipped + && str_contains((string) ($result['message'] ?? ''), 'یکسان'); + + return response()->json([ + 'ok' => $applied || $skipped || $same, + 'needs_decision' => $needsDecision, + 'message' => $result['message'] ?? '', + 'result' => $result, + ]); + } + + /** + * همگام‌سازی مانده همه مشتریان دارای کد ته‌حساب — تکه‌تکه. + * body: overwrite=1 برای اعمال اجباری اختلاف‌ها؛ بدون آن فقط کیف خالی یا یکسان پردازش می‌شود و تداخل‌ها در needs_decision می‌آیند. + */ + public function syncAllWallets(Request $request) + { + if (! app(ConfigService::class)->isTahesabAccounting()) { + return response()->json([ + 'ok' => false, + 'message' => 'حسابداری ته‌حساب فعال نیست', + ], 422); + } + + $limit = max(1, min(50, (int) $request->input('limit', 20))); + $offset = max(0, (int) $request->input('offset', 0)); + $overwrite = $request->boolean('overwrite'); + + $base = Customer::query() + ->whereNull('deleted_at') + ->whereNotNull('moshtari_code') + ->where('moshtari_code', '!=', '') + ->where('moshtari_code', '!=', '0'); + + $total = (clone $base)->count(); + $customers = (clone $base) + ->orderBy('id') + ->offset($offset) + ->limit($limit) + ->get(['id', 'name', 'mobile', 'moshtari_code', 'type']); + + $walletService = app(TahesabInboundWalletService::class); + $codes = $customers->pluck('moshtari_code')->all(); + $mandes = $walletService->fetchMandeByCodes($codes); + + $success = 0; + $changed = 0; + $needsDecision = 0; + $failed = 0; + $errors = []; + $decisions = []; + + foreach ($customers as $customer) { + try { + $code = (string) ((int) $customer->moshtari_code); + $result = $walletService->syncWalletFromTahesab( + $customer, + $mandes[$code] ?? null, + [ + 'overwrite' => $overwrite, + 'ask_if_conflict' => ! $overwrite, + ] + ); + + if ($result['needs_decision'] ?? false) { + $needsDecision++; + $decisions[] = [ + 'id' => $customer->id, + 'name' => $customer->name, + 'mobile' => $customer->mobile, + 'app_gold' => $result['app_gold'] ?? null, + 'app_cash' => $result['app_cash'] ?? null, + 'tahesab_gold' => $result['tahesab_gold'] ?? null, + 'tahesab_cash' => $result['tahesab_cash'] ?? null, + 'message' => $result['message'] ?? '', + ]; + continue; + } + + $success++; + if ($result['applied'] ?? false) { + $changed++; + } + } catch (\Throwable $e) { + $failed++; + $errors[] = [ + 'id' => $customer->id, + 'name' => $customer->name, + 'mobile' => $customer->mobile, + 'error' => $e->getMessage() !== '' ? $e->getMessage() : 'خطای ناشناخته', + ]; + } + } + + $processed = $customers->count(); + $nextOffset = $offset + $processed; + + return response()->json([ + 'ok' => true, + 'message' => $total === 0 + ? 'مشتری دارای کد ته‌حساب یافت نشد' + : "دستهٔ {$offset} تا {$nextOffset} از {$total}", + 'total' => $total, + 'offset' => $offset, + 'processed' => $processed, + 'next_offset' => $nextOffset, + 'done' => $nextOffset >= $total, + 'success' => $success, + 'changed' => $changed, + 'needs_decision' => $needsDecision, + 'failed' => $failed, + 'errors' => $errors, + 'decisions' => $decisions, + ]); + } + private function tahesabApiUrl(): string { $host = trim((string) $this->Config('tahesab_host')); diff --git a/app/Http/Controllers/Customer/WalletController.php b/app/Http/Controllers/Customer/WalletController.php index 017eacc..c096195 100644 --- a/app/Http/Controllers/Customer/WalletController.php +++ b/app/Http/Controllers/Customer/WalletController.php @@ -184,6 +184,9 @@ class WalletController extends Controller if ((int) ($record->type ?? 0) === 22) { $record->title = 'تعدیل حسابداری پرگار'; $record->unit = 'گرم'; + } elseif ((int) ($record->type ?? 0) === 23) { + $record->title = 'تعدیل حسابداری ته‌حساب'; + $record->unit = 'گرم'; } elseif ((int) ($record->type ?? 0) === 12) { $record->title = 'برداشت وجه نقد'; $record->unit = 'ریال'; @@ -883,6 +886,10 @@ class WalletController extends Controller return 'تعدیل حسابداری پرگار'; } + if ((int) $record->type === 23) { + return 'تعدیل حسابداری ته‌حساب'; + } + return $record->type_str; } diff --git a/app/Services/TahesabInboundWalletService.php b/app/Services/TahesabInboundWalletService.php new file mode 100644 index 0000000..e763530 --- /dev/null +++ b/app/Services/TahesabInboundWalletService.php @@ -0,0 +1,402 @@ + $codes + * @return array + */ + public function fetchMandeByCodes(array $codes): array + { + $normalized = []; + foreach ($codes as $code) { + $c = (int) $code; + if ($c > 0) { + $normalized[(string) $c] = $c; + } + } + if ($normalized === []) { + return []; + } + + $out = []; + foreach (array_chunk(array_values($normalized), self::BATCH_SIZE) as $chunk) { + $batch = $this->requestMandeChunk($chunk); + foreach ($batch as $code => $row) { + $out[(string) $code] = $row; + } + } + + return $out; + } + + /** + * @param array{gold?: float, cash?: float}|null $mande + * @param array{overwrite?: bool, keep?: bool, ask_if_conflict?: bool} $options + * @return array{ + * applied: bool, + * needs_decision?: bool, + * skipped?: bool, + * gold_delta?: float, + * rial_delta?: float, + * app_gold?: float, + * app_cash?: float, + * tahesab_gold?: float, + * tahesab_cash?: float, + * message: string + * } + */ + public function syncWalletFromTahesab(Customer $customer, ?array $mande = null, array $options = []): array + { + $overwrite = (bool) ($options['overwrite'] ?? false); + $keep = (bool) ($options['keep'] ?? false); + $askIfConflict = (bool) ($options['ask_if_conflict'] ?? false); + + $code = (int) ($customer->moshtari_code ?? 0); + if ($code <= 0) { + return ['applied' => false, 'message' => 'کد ته‌حساب ندارد']; + } + + if ($mande === null) { + $all = $this->fetchMandeByCodes([$code]); + $mande = $all[(string) $code] ?? null; + } + + if ($mande === null) { + return ['applied' => false, 'message' => 'مانده از ته‌حساب دریافت نشد']; + } + + $goldInv = (float) ($mande['gold'] ?? 0); + $rialInv = (float) ($mande['cash'] ?? 0); + + $ownerType = (int) ($customer->type ?? 2); + $walletInterface = new WalletInterface((int) $customer->id, $ownerType); + $wallet = $walletInterface->wallet; + + $mainGold = $this->walletBalance + ->accountingVisibleMainWalletGold((int) $customer->id, $ownerType); + $mainCash = (float) ($wallet->cash ?? 0); + + $goldDiff = $goldInv - $mainGold; + $rialDiff = $rialInv - $mainCash; + + $goldSame = abs($goldDiff) < self::EPSILON_GOLD; + $rialSame = abs($rialDiff) < self::EPSILON_CASH; + + $comparison = [ + 'app_gold' => $mainGold, + 'app_cash' => $mainCash, + 'tahesab_gold' => $goldInv, + 'tahesab_cash' => $rialInv, + 'gold_delta' => $goldDiff, + 'rial_delta' => $rialDiff, + ]; + + if ($goldSame && $rialSame) { + return array_merge($comparison, [ + 'applied' => false, + 'message' => 'مانده با ته‌حساب یکسان است', + ]); + } + + if ($keep) { + return array_merge($comparison, [ + 'applied' => false, + 'skipped' => true, + 'message' => 'موجودی اپ حفظ شد — مانده ته‌حساب اعمال نشد', + ]); + } + + $appHasBalance = abs($mainGold) >= self::EPSILON_GOLD + || abs($mainCash) >= self::EPSILON_CASH; + + // اگر اپ مانده دارد و با ته‌حساب فرق دارد و ادمین هنوز تصمیم نگرفته + if ($askIfConflict && ! $overwrite && $appHasBalance) { + return array_merge($comparison, [ + 'applied' => false, + 'needs_decision' => true, + 'message' => 'موجودی اپ با ته‌حساب فرق دارد — تصمیم ادمین لازم است', + ]); + } + + $input = [ + 'title' => 'تطبیق ماندهٔ ته‌حساب', + 'note' => 'tahesab-mande:'.$code, + 'expire_at' => now(), + 'active' => true, + ]; + if (! $goldSame) { + $input['gold'] = abs($goldDiff); + } + if (! $rialSame) { + $input['cash'] = (int) round(abs($rialDiff)); + } + + new TransactionInterface( + $walletInterface, + $input, + self::TX_TYPE_TAHESAB, + $goldDiff > 0, + $rialDiff > 0, + 0, + [], + true + ); + + $this->walletBroadcast->broadcastForCustomer( + (int) $customer->id, + $ownerType + ); + + Log::info('Tahesab mande applied to wallet', [ + 'customer_id' => (int) $customer->id, + 'moshtari_code' => $code, + 'gold_target' => $goldInv, + 'cash_target' => $rialInv, + 'gold_delta' => $goldDiff, + 'rial_delta' => $rialDiff, + 'overwrite' => $overwrite, + ]); + + return array_merge($comparison, [ + 'applied' => true, + 'message' => 'مانده با ته‌حساب هم‌تراز شد', + ]); + } + + /** + * بعد از import: اگر کیف خالی باشد مانده را بنشان؛ اگر مانده دارد صبر کن تا ادمین تصمیم بگیرد. + * + * @param list $customers + */ + public function syncAfterImport(array $customers): void + { + $customers = array_values(array_filter($customers, fn ($c) => $c instanceof Customer)); + if ($customers === []) { + return; + } + + $codes = []; + foreach ($customers as $customer) { + $code = (int) ($customer->moshtari_code ?? 0); + if ($code > 0) { + $codes[] = $code; + } + } + + $mandes = $this->fetchMandeByCodes($codes); + + foreach ($customers as $customer) { + try { + $code = (string) ((int) ($customer->moshtari_code ?? 0)); + $this->syncWalletFromTahesab( + $customer, + $mandes[$code] ?? null, + ['ask_if_conflict' => true] + ); + } catch (\Throwable $e) { + Log::warning('Tahesab wallet sync after import failed', [ + 'customer_id' => $customer->id, + 'moshtari_code' => $customer->moshtari_code, + 'error' => $e->getMessage(), + ]); + } + } + } + + /** + * @param list $codes + * @return array + */ + private function requestMandeChunk(array $codes): array + { + $url = $this->apiUrl(); + if ($url === '' || $codes === []) { + return []; + } + + try { + $response = Http::withoutVerifying()->timeout(45)->withHeaders([ + 'DBName' => $this->config->getString('tahesab_db', ''), + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer '.$this->config->getString('tahesab_token', ''), + ])->post($url, [ + 'GetMandeHesabByCode' => array_map('strval', $codes), + ])->json(); + } catch (\Throwable $e) { + Log::warning('Tahesab GetMandeHesabByCode failed', [ + 'codes' => $codes, + 'error' => $e->getMessage(), + ]); + + return []; + } + + if (! is_array($response)) { + return []; + } + + $rows = $response['MandeHesab'] ?? null; + if (! is_array($rows)) { + if ($this->looksLikeMandeRow($response) && count($codes) === 1) { + $rows = [array_merge($response, ['Code' => (string) $codes[0]])]; + } else { + $rows = array_is_list($response) ? $response : array_values($response); + } + } + + $out = []; + foreach ($rows as $row) { + if (! is_array($row)) { + continue; + } + $code = (string) ($row['Code'] ?? $row['code'] ?? ''); + if ($code === '' || $code === '0') { + continue; + } + $parsed = $this->parseMandeRow($row); + if ($parsed !== null) { + $out[$code] = $parsed; + } + } + + if ($out === [] && count($codes) === 1 && $this->looksLikeMandeRow($response)) { + $parsed = $this->parseMandeRow($response); + if ($parsed !== null) { + $out[(string) $codes[0]] = $parsed; + } + } + + return $out; + } + + /** + * @param array $row + * @return array{gold: float, cash: float, raw: array}|null + */ + private function parseMandeRow(array $row): ?array + { + $gold = $this->pickNumber($row, [ + 'MandeyeKolBeTALA', + 'MandeyeKolBeTala', + 'MandeyeVazni', + 'MandeMenhayeSekeBeTala', + 'GoldInv', + ]); + $cash = $this->pickNumber($row, [ + 'MandeyeKolBePool', + 'MandeyeMali', + 'MandeMenhayeSekeBePool', + 'RialInv', + ]); + + $hasAny = false; + foreach ([ + 'MandeyeKolBeTALA', 'MandeyeKolBeTala', 'MandeyeVazni', 'MandeyeKolBePool', 'MandeyeMali', + ] as $key) { + if (array_key_exists($key, $row)) { + $hasAny = true; + break; + } + } + if (! $hasAny) { + return null; + } + + return [ + 'gold' => $gold, + 'cash' => $cash, + 'raw' => $row, + ]; + } + + /** + * @param array $row + * @param list $keys + */ + private function pickNumber(array $row, array $keys): float + { + foreach ($keys as $key) { + if (! array_key_exists($key, $row) || $row[$key] === null || $row[$key] === '') { + continue; + } + + return $this->parseNumber($row[$key]); + } + + return 0.0; + } + + private function parseNumber(mixed $value): float + { + if (is_int($value) || is_float($value)) { + return (float) $value; + } + $s = str_replace([',', ' ', '٫', '/'], ['', '', '.', '.'], trim((string) $value)); + + return is_numeric($s) ? (float) $s : 0.0; + } + + /** @param array $row */ + private function looksLikeMandeRow(array $row): bool + { + foreach (['MandeyeKolBeTALA', 'MandeyeKolBeTala', 'MandeyeVazni', 'MandeyeKolBePool', 'MandeyeMali'] as $key) { + if (array_key_exists($key, $row)) { + return true; + } + } + + return false; + } + + private function apiUrl(): string + { + $host = trim($this->config->getString('tahesab_host', '')); + $port = trim($this->config->getString('tahesab_port', '')); + + if ($host !== '' && $port !== '') { + $host = rtrim(preg_replace('#^https?://#i', '', $host), '/'); + + return 'httpS://'.$host.':'.$port.'/'; + } + + $legacy = trim((string) env('TAHESAB', '')); + + return $legacy !== '' ? $legacy : ''; + } +} diff --git a/routes/admin.php b/routes/admin.php index df2b3a2..1e6099c 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -251,9 +251,11 @@ Route::middleware('auth:user')->group(function (){ Route::post('tahesab/customer-sync/panel-to-tahesab', [TahesabController::class, 'syncPanelToTahesab']); Route::post('tahesab/customer-sync/import-from-tahesab', [TahesabController::class, 'importFromTahesab']); Route::post('tahesab/customer-sync/rollback-import', [TahesabController::class, 'rollbackImport']); + Route::post('tahesab/wallet-sync/all', [TahesabController::class, 'syncAllWallets']); Route::get('tahesab/{id}', [TahesabController::class, 'show']); Route::post('tahesab/{id}/retry', [TahesabController::class, 'retrySanad']); Route::post('customer/{id}/tahesab/register', [TahesabController::class, 'registerCustomer']); + Route::post('customer/{id}/tahesab/sync-wallet', [TahesabController::class, 'syncCustomerWallet']); Route::get('zarin/health', [ZarinController::class, 'healthCheck']); Route::post('wallet/zarin', [ZarinController::class, 'wallet']);