From 283f17561cbfd1426839e5dae9c433f501765a5c Mon Sep 17 00:00:00 2001 From: adonis Date: Sun, 30 Aug 2026 20:18:48 +0330 Subject: [PATCH] added some feature for accounting and enhance performance for dashboard --- .../Controllers/Admin/TahesabController.php | 302 ++++++++++-- .../Controllers/Admin/ZarinController.php | 403 +++++++++++++++- app/Services/TahesabInboundWalletService.php | 142 ++++-- app/Services/ZarinInboundWalletService.php | 438 ++++++++++++++++++ database/sql/deploy_settings_const.sql | 43 ++ routes/admin.php | 7 + 6 files changed, 1251 insertions(+), 84 deletions(-) create mode 100644 app/Services/ZarinInboundWalletService.php diff --git a/app/Http/Controllers/Admin/TahesabController.php b/app/Http/Controllers/Admin/TahesabController.php index a2bccce..2a960f1 100644 --- a/app/Http/Controllers/Admin/TahesabController.php +++ b/app/Http/Controllers/Admin/TahesabController.php @@ -84,7 +84,8 @@ class TahesabController extends Controller ])->json(); if (! is_array($response)) return []; - return $response; + + return $this->tahesabCustomerRows($response); } catch (\Exception $e) { return []; } @@ -113,44 +114,83 @@ class TahesabController extends Controller $tahesabOnlyCount = null; $tahesabOnlyResult = ['data' => [], 'total' => 0, 'current_page' => 1, 'last_page' => 1]; + $tahesabWithPhoneCount = null; + $tahesabWithoutPhoneCount = null; + $tahesabImportableCount = null; + $phoneMatchCount = null; + $phoneConflictCount = null; + $phoneMatches = []; + $phoneConflicts = []; + if ($request->boolean('include_tahesab')) { $tahesabCustomers = $this->fetchAllTahesabCustomers(); $tahesabTotal = count($tahesabCustomers); - - // موبایل‌های پنل بدون صفر ابتدایی - $panelMobiles = Customer::whereNull('deleted_at') - ->whereNotNull('mobile') - ->pluck('mobile') - ->mapWithKeys(fn ($m) => [ltrim((string) $m, '0') => true]) - ->toArray(); - - // کدهای مشتریِ قبلاً ثبت‌شده در پنل (برای جلوگیری از تکرار) - $panelCodes = Customer::whereNull('deleted_at') - ->whereNotNull('moshtari_code') - ->pluck('moshtari_code') - ->mapWithKeys(fn ($c) => [(string) $c => true]) - ->toArray(); + $walletService = app(TahesabInboundWalletService::class); + $maps = $this->panelAccountingMaps(); $tahesabOnlyList = []; $debugSample = []; + $withPhone = 0; + $withoutPhone = 0; foreach ($tahesabCustomers as $item) { if (! is_array($item)) continue; - if (count($debugSample) < 3) $debugSample[] = $item; // نمونهٔ خام برای تشخیص نام فیلدها - $code = $item['Code'] ?? null; - if ($code !== null && isset($panelCodes[(string) $code])) continue; + if (count($debugSample) < 3) $debugSample[] = $item; + $code = $this->extractTahesabCode($item); + $codeKey = $this->accountingCodeKey($code); $phone = $this->extractTahesabPhone($item); - $tel = ltrim($phone, '0'); - if ($tel && isset($panelMobiles[$tel])) continue; + $hasPhone = Customer::normalizeMobile($phone) !== null; + if ($hasPhone) { + $withPhone++; + } else { + $withoutPhone++; + } + + if ($codeKey !== null && isset($maps['by_code'][$codeKey])) { + continue; + } + + $mobileKey = Customer::normalizeMobile($phone); + if ($mobileKey && isset($maps['by_mobile'][$mobileKey])) { + $panel = $maps['by_mobile'][$mobileKey]; + $panelCodeKey = $this->accountingCodeKey($panel->moshtari_code); + $row = [ + 'code' => $code !== null ? (int) $code : null, + 'name' => trim((string) ($item['Name'] ?? $item['name'] ?? '')), + 'tel' => $phone, + 'panel_id' => (int) $panel->id, + 'panel_name' => $panel->name, + 'panel_code' => $panelCodeKey, + ]; + if ($panelCodeKey && $panelCodeKey !== $codeKey) { + $phoneConflicts[] = $row; + } else { + $phoneMatches[] = $row; + } + continue; + } + + if ($codeKey === null) { + continue; + } + + $mande = $walletService->mandeFromAccountingRow($item); $tahesabOnlyList[] = [ - 'code' => $item['Code'] ?? null, - 'name' => $item['Name'] ?? '', + 'code' => $code !== null ? (int) $code : null, + 'name' => trim((string) ($item['Name'] ?? $item['name'] ?? '')), 'tel' => $phone, - 'gid' => $item['GID'] ?? null, - 'group' => $item['GoroupName'] ?? '', + 'gid' => $item['GID'] ?? $item['gid'] ?? null, + 'group' => $item['GoroupName'] ?? $item['group'] ?? '', + 'has_phone' => $hasPhone, + 'gold' => $mande['gold'] ?? null, + 'cash' => $mande['cash'] ?? null, ]; } $tahesabOnlyCount = count($tahesabOnlyList); - // همه رو برمی‌گردونیم — frontend خودش paginate می‌کنه + $tahesabWithPhoneCount = $withPhone; + $tahesabWithoutPhoneCount = $withoutPhone; + $tahesabImportableCount = count(array_filter($tahesabOnlyList, fn ($r) => ! empty($r['has_phone']))); + $phoneMatchCount = count($phoneMatches); + $phoneConflictCount = count($phoneConflicts); $tahesabOnlyResult = [ 'data' => array_values($tahesabOnlyList), 'total' => $tahesabOnlyCount, @@ -165,6 +205,13 @@ class TahesabController extends Controller 'panel_only' => $panelOnly, 'tahesab_total' => $tahesabTotal, 'tahesab_only_count' => $tahesabOnlyCount, + 'tahesab_with_phone_count' => $tahesabWithPhoneCount, + 'tahesab_without_phone_count' => $tahesabWithoutPhoneCount, + 'tahesab_importable_count' => $tahesabImportableCount, + 'phone_match_count' => $phoneMatchCount, + 'phone_conflict_count' => $phoneConflictCount, + 'phone_matches' => $phoneMatches, + 'phone_conflicts' => $phoneConflicts, 'panel_only_list' => $panelOnlyList, 'tahesab_only_list' => $tahesabOnlyResult, ]; @@ -198,6 +245,206 @@ class TahesabController extends Controller return ''; } + /** + * @param array $response + * @return list> + */ + private function tahesabCustomerRows(array $response): array + { + if ($response === []) { + return []; + } + if (isset($response[0]) && is_array($response[0])) { + return array_values(array_filter($response, 'is_array')); + } + foreach (['Moshtari', 'ListMoshtari', 'data', 'Data'] as $key) { + if (isset($response[$key]) && is_array($response[$key])) { + return array_values(array_filter($response[$key], 'is_array')); + } + } + + return array_values(array_filter($response, 'is_array')); + } + + /** @param array $item */ + private function extractTahesabCode(array $item): mixed + { + foreach (['Code', 'code', 'MoshtariCode', 'moshtari_code'] as $key) { + if (isset($item[$key]) && $item[$key] !== '' && $item[$key] !== null) { + return $item[$key]; + } + } + + return null; + } + + private function accountingCodeKey(mixed $code): ?string + { + if ($code === null || $code === '' || $code === false) { + return null; + } + $s = trim((string) $code); + if ($s === '' || $s === '0' || $s === '-') { + return null; + } + if (is_numeric($s)) { + $n = (int) $s; + + return $n > 0 ? (string) $n : null; + } + + return $s; + } + + /** + * @return array{by_code: array, by_mobile: array} + */ + private function panelAccountingMaps(): array + { + $byCode = []; + $byMobile = []; + $rows = Customer::whereNull('deleted_at')->get(['id', 'name', 'mobile', 'moshtari_code', 'type']); + foreach ($rows as $row) { + $codeKey = $this->accountingCodeKey($row->moshtari_code); + if ($codeKey !== null) { + $byCode[$codeKey] = $row; + } + $mobile = Customer::normalizeMobile((string) ($row->mobile ?? '')); + if ($mobile !== null) { + $byMobile[$mobile] = $row; + } + } + + return ['by_code' => $byCode, 'by_mobile' => $byMobile]; + } + + /** لینک کد ته‌حساب روی مشتری پنل وقتی شماره یکسان است */ + public function linkMatchingPhones(): \Illuminate\Http\JsonResponse + { + $tahesabCustomers = $this->fetchAllTahesabCustomers(); + $maps = $this->panelAccountingMaps(); + $linked = []; + $conflicts = []; + $toSync = []; + + foreach ($tahesabCustomers as $item) { + if (! is_array($item)) { + continue; + } + $code = $this->extractTahesabCode($item); + $codeKey = $this->accountingCodeKey($code); + if ($codeKey === null) { + continue; + } + $phone = $this->extractTahesabPhone($item); + $mobileKey = Customer::normalizeMobile($phone); + if ($mobileKey === null || ! isset($maps['by_mobile'][$mobileKey])) { + continue; + } + $panel = $maps['by_mobile'][$mobileKey]; + $panelCodeKey = $this->accountingCodeKey($panel->moshtari_code); + + if (isset($maps['by_code'][$codeKey]) && (int) $maps['by_code'][$codeKey]->id !== (int) $panel->id) { + $conflicts[] = [ + 'code' => (int) $codeKey, + 'name' => trim((string) ($item['Name'] ?? '')), + 'tel' => $phone, + 'panel_id' => (int) $panel->id, + 'panel_name' => $panel->name, + 'error' => 'این کد ته‌حساب روی مشتری دیگری در پنل است', + ]; + continue; + } + + if ($panelCodeKey === $codeKey) { + continue; + } + if ($panelCodeKey) { + $conflicts[] = [ + 'code' => (int) $codeKey, + 'name' => trim((string) ($item['Name'] ?? '')), + 'tel' => $phone, + 'panel_id' => (int) $panel->id, + 'panel_name' => $panel->name, + 'panel_code' => $panelCodeKey, + 'error' => 'مشتری پنل کد دیگری دارد — دستی تصمیم بگیرید', + ]; + continue; + } + + $panel->moshtari_code = (int) $codeKey; + $panel->save(); + $maps['by_code'][$codeKey] = $panel; + $toSync[] = $panel; + $linked[] = [ + 'code' => (int) $codeKey, + 'name' => $panel->name, + 'tel' => $phone, + 'panel_id' => (int) $panel->id, + ]; + } + + if ($toSync !== []) { + try { + app(TahesabInboundWalletService::class)->syncAfterImport($toSync); + } catch (\Throwable $e) { + \Log::warning('Tahesab link-by-phone wallet sync failed', ['error' => $e->getMessage()]); + } + } + + return response()->json([ + 'ok' => true, + 'linked' => count($linked), + 'conflicts' => count($conflicts), + 'linked_rows' => $linked, + 'conflict_rows' => $conflicts, + 'message' => count($linked) + ? (count($linked).' کد روی مشتری پنل با شماره یکسان نشست') + : (count($conflicts) ? 'کد یکسانی برای لینک خودکار نبود' : 'شماره یکسانی برای لینک پیدا نشد'), + ]); + } + + public function previewCustomerWallet(Request $request, $customerId): \Illuminate\Http\JsonResponse + { + if (! app(ConfigService::class)->isTahesabAccounting()) { + return response()->json(['ok' => false, 'message' => 'حسابداری ته‌حساب فعال نیست'], 422); + } + + $customer = Customer::findOrFail($customerId); + $code = (int) $request->input('code', $customer->moshtari_code); + $result = app(TahesabInboundWalletService::class)->previewForCustomer($customer, $code); + $needsDecision = (bool) ($result['needs_decision'] ?? false); + + return response()->json([ + 'ok' => (bool) ($result['ok'] ?? false), + 'needs_decision' => $needsDecision, + 'message' => $result['message'] ?? '', + 'result' => $result, + ]); + } + + public function mandeByCodes(Request $request): \Illuminate\Http\JsonResponse + { + $codes = $request->input('codes', []); + if (! is_array($codes) || $codes === []) { + return response()->json(['ok' => true, 'mandes' => []]); + } + if (count($codes) > 50) { + return response()->json(['ok' => false, 'message' => 'حداکثر ۵۰ کد در هر درخواست'], 422); + } + + $mandes = app(TahesabInboundWalletService::class)->fetchMandeByCodes($codes); + $out = []; + foreach ($mandes as $code => $row) { + $out[(string) $code] = [ + 'gold' => $row['gold'] ?? 0, + 'cash' => $row['cash'] ?? 0, + ]; + } + + return response()->json(['ok' => true, 'mandes' => $out]); + } + public function syncPanelToTahesab(): \Illuminate\Http\JsonResponse { $customers = Customer::whereNull('deleted_at') @@ -494,8 +741,8 @@ class TahesabController extends Controller $overwrite = $request->boolean('overwrite'); $keep = $request->boolean('keep'); - // اگر صراحتاً overwrite/keep نباشد → از ادمین بپرس وقتی تداخل هست - $askIfConflict = $request->boolean('ask_if_conflict', ! $overwrite && ! $keep); + $alwaysAsk = $request->boolean('always_ask'); + $askIfConflict = $request->boolean('ask_if_conflict', ! $overwrite && ! $keep && ! $alwaysAsk); $result = app(TahesabInboundWalletService::class)->syncWalletFromTahesab( $customer, @@ -504,6 +751,7 @@ class TahesabController extends Controller 'overwrite' => $overwrite, 'keep' => $keep, 'ask_if_conflict' => $askIfConflict, + 'always_ask' => $alwaysAsk, ] ); diff --git a/app/Http/Controllers/Admin/ZarinController.php b/app/Http/Controllers/Admin/ZarinController.php index 27393b6..bab890e 100644 --- a/app/Http/Controllers/Admin/ZarinController.php +++ b/app/Http/Controllers/Admin/ZarinController.php @@ -9,6 +9,8 @@ use App\Models\Order; use App\Models\Product; use App\Models\Zarin; use App\Services\AccountingProductDocMapper; +use App\Services\ConfigService; +use App\Services\ZarinInboundWalletService; use App\Services\ZarinSyncService; use Carbon\Carbon; use Illuminate\Contracts\Cache\LockTimeoutException; @@ -1056,20 +1058,14 @@ class ZarinController extends Controller } $zarinTotal = count($zarinCustomers); - - $panelMobiles = Customer::whereNull('deleted_at') - ->whereNotNull('mobile') - ->pluck('mobile') - ->mapWithKeys(fn ($m) => [ltrim((string) $m, '0') => true]) - ->toArray(); - - $panelCodes = Customer::whereNull('deleted_at') - ->whereNotNull('moshtari_code') - ->pluck('moshtari_code') - ->mapWithKeys(fn ($c) => [(string) $c => true]) - ->toArray(); + $maps = $this->panelAccountingMaps(); + $walletService = app(ZarinInboundWalletService::class); $zarinOnlyList = []; + $phoneMatches = []; + $phoneConflicts = []; + $withPhone = 0; + $withoutPhone = 0; foreach ($zarinCustomers as $item) { if (!is_array($item)) { continue; @@ -1077,21 +1073,54 @@ class ZarinController extends Controller if (count($debugSample) < 3) { $debugSample[] = $item; } - $code = $item['idHesab'] ?? $item['Code'] ?? $item['code'] ?? $item['id'] ?? null; - if ($code !== null && isset($panelCodes[(string) $code])) { - continue; - } + $code = $this->extractZarinHesabId($item) ?? ($item['idHesab'] ?? $item['Code'] ?? $item['code'] ?? $item['id'] ?? null); + $codeKey = $this->accountingCodeKey($code); $phone = $this->extractZarinPhone($item); - $tel = ltrim($phone, '0'); - if ($tel && isset($panelMobiles[$tel])) { + $hasPhone = Customer::normalizeMobile($phone) !== null; + if ($hasPhone) { + $withPhone++; + } else { + $withoutPhone++; + } + + if ($codeKey !== null && isset($maps['by_code'][$codeKey])) { continue; } + + $mobileKey = Customer::normalizeMobile($phone); + if ($mobileKey && isset($maps['by_mobile'][$mobileKey])) { + $panel = $maps['by_mobile'][$mobileKey]; + $panelCodeKey = $this->accountingCodeKey($panel->moshtari_code); + $row = [ + 'code' => $codeKey !== null ? (int) $codeKey : null, + 'name' => $this->extractZarinName($item), + 'tel' => $phone, + 'panel_id' => (int) $panel->id, + 'panel_name' => $panel->name, + 'panel_code' => $panelCodeKey, + ]; + if ($panelCodeKey && $panelCodeKey !== $codeKey) { + $phoneConflicts[] = $row; + } else { + $phoneMatches[] = $row; + } + continue; + } + + if ($codeKey === null) { + continue; + } + + $mande = $walletService->mandeFromAccountingRow($item); $zarinOnlyList[] = [ - 'code' => $code, + 'code' => (int) $codeKey, 'name' => $this->extractZarinName($item), 'tel' => $phone, 'gid' => $item['GID'] ?? null, 'group' => $this->extractZarinGroup($item), + 'has_phone' => $hasPhone, + 'gold' => $mande['gold'] ?? null, + 'cash' => $mande['cash'] ?? null, ]; } $zarinOnlyCount = count($zarinOnlyList); @@ -1109,6 +1138,15 @@ class ZarinController extends Controller 'panel_only' => $panelOnly, 'zarin_total' => $zarinTotal, 'zarin_only_count' => $zarinOnlyCount, + 'zarin_with_phone_count' => $include ? ($withPhone ?? 0) : null, + 'zarin_without_phone_count' => $include ? ($withoutPhone ?? 0) : null, + 'zarin_importable_count' => $include + ? count(array_filter($zarinOnlyList ?? [], fn ($r) => ! empty($r['has_phone']))) + : null, + 'phone_match_count' => $include ? count($phoneMatches ?? []) : null, + 'phone_conflict_count' => $include ? count($phoneConflicts ?? []) : null, + 'phone_matches' => $phoneMatches ?? [], + 'phone_conflicts' => $phoneConflicts ?? [], 'zarin_fetch_error' => $zarinFetchError, 'zarin_http_status' => $zarinHttpStatus, 'zarin_group_filter' => $zarinGroupFilter, @@ -1166,6 +1204,46 @@ class ZarinController extends Controller return null; } + private function accountingCodeKey(mixed $code): ?string + { + if ($code === null || $code === '' || $code === false) { + return null; + } + $s = trim((string) $code); + if ($s === '' || $s === '0' || $s === '-') { + return null; + } + if (is_numeric($s)) { + $n = (int) $s; + + return $n > 0 ? (string) $n : null; + } + + return $s; + } + + /** + * @return array{by_code: array, by_mobile: array} + */ + private function panelAccountingMaps(): array + { + $byCode = []; + $byMobile = []; + $rows = Customer::whereNull('deleted_at')->get(['id', 'name', 'mobile', 'moshtari_code', 'type']); + foreach ($rows as $row) { + $codeKey = $this->accountingCodeKey($row->moshtari_code); + if ($codeKey !== null) { + $byCode[$codeKey] = $row; + } + $mobile = Customer::normalizeMobile((string) ($row->mobile ?? '')); + if ($mobile !== null) { + $byMobile[$mobile] = $row; + } + } + + return ['by_code' => $byCode, 'by_mobile' => $byMobile]; + } + /** حساب از قبل ساخته‌شده در زرین — با موبایل، تا addhesab دوم نسازیم */ private function findExistingZarinHesabId(?string $mobile): ?int { @@ -1261,6 +1339,8 @@ class ZarinController extends Controller $success = 0; $failed = 0; $errors = []; + /** @var list $importedForWallet */ + $importedForWallet = []; foreach ($validated['customers'] as $item) { $mobile = Customer::normalizeMobile($item['tel'] ?? '') ?? ''; @@ -1271,7 +1351,16 @@ class ZarinController extends Controller $fullName = 'مشتری زرین ' . $code; } - // موبایل برای خیلی از حساب‌های زرین خالی است (مثل تستی هوم) — مانع import نیست + if ($mobile === '') { + $failed++; + $errors[] = [ + 'name' => $item['name'], + 'code' => $item['code'], + 'error' => 'شماره موبایل خالی است — وارد نشد', + ]; + continue; + } + $existing = Customer::whereNull('deleted_at')->where('moshtari_code', $code)->first(); if ($existing) { $changed = false; @@ -1300,6 +1389,7 @@ class ZarinController extends Controller if ($changed) { $existing->save(); } + $importedForWallet[] = $existing; $success++; continue; } @@ -1318,6 +1408,7 @@ class ZarinController extends Controller $byMobile->password = $mobile; } $byMobile->save(); + $importedForWallet[] = $byMobile; $success++; continue; } @@ -1345,15 +1436,14 @@ class ZarinController extends Controller $customer->fname = $fullName; $customer->lname = ''; $customer->name = $fullName; - $customer->mobile = $mobile !== '' ? $mobile : null; + $customer->mobile = $mobile; $customer->active = true; $customer->moshtari_code = $item['code']; $customer->referral_id = $referralId; $customer->type = 2; - if ($mobile !== '') { - $customer->password = $mobile; - } + $customer->password = $mobile; $customer->save(); + $importedForWallet[] = $customer; $success++; } catch (\Throwable $e) { $failed++; @@ -1367,6 +1457,17 @@ class ZarinController extends Controller } } + if ($importedForWallet !== []) { + try { + app(ZarinInboundWalletService::class)->syncAfterImport($importedForWallet); + } catch (\Throwable $e) { + Log::warning('Zarin import wallet sync batch failed', [ + 'count' => count($importedForWallet), + 'error' => $e->getMessage(), + ]); + } + } + return response()->json([ 'ok' => true, 'success' => $success, @@ -1375,6 +1476,260 @@ class ZarinController extends Controller ]); } + public function linkMatchingPhones(): \Illuminate\Http\JsonResponse + { + $fetched = $this->fetchAllZarinCustomers(); + $rows = $fetched['rows'] ?? []; + $groupFilter = $this->zarinCustomerGroupName(); + if ($groupFilter !== '' && $rows !== []) { + $rows = array_values(array_filter($rows, function ($item) use ($groupFilter) { + if (! is_array($item)) { + return false; + } + $g = $this->extractZarinGroup($item); + + return $g !== '' && $this->zarinGroupNamesMatch($g, $groupFilter); + })); + } + + $maps = $this->panelAccountingMaps(); + $linked = []; + $conflicts = []; + $toSync = []; + + foreach ($rows as $item) { + if (! is_array($item)) { + continue; + } + $code = $this->extractZarinHesabId($item); + $codeKey = $this->accountingCodeKey($code); + if ($codeKey === null) { + continue; + } + $phone = $this->extractZarinPhone($item); + $mobileKey = Customer::normalizeMobile($phone); + if ($mobileKey === null || ! isset($maps['by_mobile'][$mobileKey])) { + continue; + } + $panel = $maps['by_mobile'][$mobileKey]; + $panelCodeKey = $this->accountingCodeKey($panel->moshtari_code); + + if (isset($maps['by_code'][$codeKey]) && (int) $maps['by_code'][$codeKey]->id !== (int) $panel->id) { + $conflicts[] = [ + 'code' => (int) $codeKey, + 'name' => $this->extractZarinName($item), + 'tel' => $phone, + 'panel_id' => (int) $panel->id, + 'panel_name' => $panel->name, + 'error' => 'این کد زرین روی مشتری دیگری در پنل است', + ]; + continue; + } + if ($panelCodeKey === $codeKey) { + continue; + } + if ($panelCodeKey) { + $conflicts[] = [ + 'code' => (int) $codeKey, + 'name' => $this->extractZarinName($item), + 'tel' => $phone, + 'panel_id' => (int) $panel->id, + 'panel_name' => $panel->name, + 'panel_code' => $panelCodeKey, + 'error' => 'مشتری پنل کد دیگری دارد — دستی تصمیم بگیرید', + ]; + continue; + } + + $panel->moshtari_code = (int) $codeKey; + $panel->save(); + $maps['by_code'][$codeKey] = $panel; + $toSync[] = $panel; + $linked[] = [ + 'code' => (int) $codeKey, + 'name' => $panel->name, + 'tel' => $phone, + 'panel_id' => (int) $panel->id, + ]; + } + + if ($toSync !== []) { + try { + app(ZarinInboundWalletService::class)->syncAfterImport($toSync); + } catch (\Throwable $e) { + Log::warning('Zarin link-by-phone wallet sync failed', ['error' => $e->getMessage()]); + } + } + + return response()->json([ + 'ok' => true, + 'linked' => count($linked), + 'conflicts' => count($conflicts), + 'linked_rows' => $linked, + 'conflict_rows' => $conflicts, + 'message' => count($linked) + ? (count($linked).' کد روی مشتری پنل با شماره یکسان نشست') + : (count($conflicts) ? 'کد یکسانی برای لینک خودکار نبود' : 'شماره یکسانی برای لینک پیدا نشد'), + ]); + } + + public function previewCustomerWallet(Request $request, $customerId): \Illuminate\Http\JsonResponse + { + if (! app(ConfigService::class)->isZarinAccounting()) { + return response()->json(['ok' => false, 'message' => 'حسابداری زرین فعال نیست'], 422); + } + + $customer = Customer::findOrFail($customerId); + $code = (int) $request->input('code', $customer->moshtari_code); + $result = app(ZarinInboundWalletService::class)->previewForCustomer($customer, $code); + + return response()->json([ + 'ok' => (bool) ($result['ok'] ?? false), + 'needs_decision' => (bool) ($result['needs_decision'] ?? false), + 'message' => $result['message'] ?? '', + 'result' => $result, + ]); + } + + public function syncCustomerWallet(Request $request, $customerId) + { + if (! app(ConfigService::class)->isZarinAccounting()) { + 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'); + $alwaysAsk = $request->boolean('always_ask'); + $askIfConflict = $request->boolean('ask_if_conflict', ! $overwrite && ! $keep && ! $alwaysAsk); + + $result = app(ZarinInboundWalletService::class)->syncWalletFromZarin( + $customer, + null, + [ + 'overwrite' => $overwrite, + 'keep' => $keep, + 'ask_if_conflict' => $askIfConflict, + 'always_ask' => $alwaysAsk, + ] + ); + + $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, + ]); + } + + public function syncAllWallets(Request $request) + { + if (! app(ConfigService::class)->isZarinAccounting()) { + 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(ZarinInboundWalletService::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->syncWalletFromZarin( + $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['zarin_gold'] ?? $result['tahesab_gold'] ?? null, + 'tahesab_cash' => $result['zarin_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, + ]); + } + /** * حذف مشتریانِ خرابِ واردشده از زرین (نام خالی) برای پاک‌سازی import ناقص. * با dry_run=1 فقط تعداد و نمونه را نشان می‌دهد و چیزی حذف نمی‌کند. diff --git a/app/Services/TahesabInboundWalletService.php b/app/Services/TahesabInboundWalletService.php index e763530..a9511d2 100644 --- a/app/Services/TahesabInboundWalletService.php +++ b/app/Services/TahesabInboundWalletService.php @@ -19,6 +19,8 @@ use Rooyesh\Wallet\interfaces\WalletInterface; * overwrite=true → اختلاف را اعمال کن * keep=true → موجودی اپ را دست نزن * ask_if_conflict → اگر اپ مانده دارد و با ته‌حساب فرق دارد، بدون نوشتن برگردان + * always_ask → هر اختلافی (حتی کیف خالی) بدون نوشتن برای تأیید ادمین برمی‌گردد + * جمع دو مانده هرگز انجام نمی‌شود تا موجودی دو برابر نشود. */ class TahesabInboundWalletService { @@ -67,7 +69,7 @@ class TahesabInboundWalletService /** * @param array{gold?: float, cash?: float}|null $mande - * @param array{overwrite?: bool, keep?: bool, ask_if_conflict?: bool} $options + * @param array{overwrite?: bool, keep?: bool, ask_if_conflict?: bool, always_ask?: bool} $options * @return array{ * applied: bool, * needs_decision?: bool, @@ -86,6 +88,7 @@ class TahesabInboundWalletService $overwrite = (bool) ($options['overwrite'] ?? false); $keep = (bool) ($options['keep'] ?? false); $askIfConflict = (bool) ($options['ask_if_conflict'] ?? false); + $alwaysAsk = (bool) ($options['always_ask'] ?? false); $code = (int) ($customer->moshtari_code ?? 0); if ($code <= 0) { @@ -101,31 +104,11 @@ class TahesabInboundWalletService 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, - ]; + $compared = $this->compareWithWallet($customer, $mande); + $comparison = $compared['comparison']; + $goldSame = $compared['gold_same']; + $rialSame = $compared['rial_same']; + $appHasBalance = $compared['app_has_balance']; if ($goldSame && $rialSame) { return array_merge($comparison, [ @@ -142,11 +125,11 @@ class TahesabInboundWalletService ]); } - $appHasBalance = abs($mainGold) >= self::EPSILON_GOLD - || abs($mainCash) >= self::EPSILON_CASH; - - // اگر اپ مانده دارد و با ته‌حساب فرق دارد و ادمین هنوز تصمیم نگرفته - if ($askIfConflict && ! $overwrite && $appHasBalance) { + $needsAsk = ! $overwrite && ( + ($alwaysAsk) + || ($askIfConflict && $appHasBalance) + ); + if ($needsAsk) { return array_merge($comparison, [ 'applied' => false, 'needs_decision' => true, @@ -154,6 +137,11 @@ class TahesabInboundWalletService ]); } + $goldDiff = (float) $comparison['gold_delta']; + $rialDiff = (float) $comparison['rial_delta']; + $ownerType = (int) ($customer->type ?? 2); + $walletInterface = new WalletInterface((int) $customer->id, $ownerType); + $input = [ 'title' => 'تطبیق ماندهٔ ته‌حساب', 'note' => 'tahesab-mande:'.$code, @@ -186,8 +174,8 @@ class TahesabInboundWalletService Log::info('Tahesab mande applied to wallet', [ 'customer_id' => (int) $customer->id, 'moshtari_code' => $code, - 'gold_target' => $goldInv, - 'cash_target' => $rialInv, + 'gold_target' => $comparison['tahesab_gold'], + 'cash_target' => $comparison['tahesab_cash'], 'gold_delta' => $goldDiff, 'rial_delta' => $rialDiff, 'overwrite' => $overwrite, @@ -199,6 +187,94 @@ class TahesabInboundWalletService ]); } + /** + * مقایسه بدون نوشتن — برای تأیید ادمین قبل از ست کردن کد. + * + * @return array{ + * applied: bool, + * needs_decision?: bool, + * ok?: bool, + * app_gold?: float, + * app_cash?: float, + * tahesab_gold?: float, + * tahesab_cash?: float, + * gold_delta?: float, + * rial_delta?: float, + * message: string + * } + */ + public function previewForCustomer(Customer $customer, int $code): array + { + if ($code <= 0) { + return ['applied' => false, 'ok' => false, 'message' => 'کد ته‌حساب نامعتبر است']; + } + + $all = $this->fetchMandeByCodes([$code]); + $mande = $all[(string) $code] ?? null; + if ($mande === null) { + return ['applied' => false, 'ok' => false, 'message' => 'مانده از ته‌حساب دریافت نشد']; + } + + $compared = $this->compareWithWallet($customer, $mande); + $same = $compared['gold_same'] && $compared['rial_same']; + + return array_merge($compared['comparison'], [ + 'applied' => false, + 'ok' => true, + 'needs_decision' => ! $same, + 'message' => $same + ? 'مانده با ته‌حساب یکسان است' + : 'موجودی اپ با ته‌حساب فرق دارد — تصمیم ادمین لازم است', + ]); + } + + /** @param array $row */ + public function mandeFromAccountingRow(array $row): ?array + { + return $this->parseMandeRow($row); + } + + /** + * @param array{gold?: float, cash?: float} $mande + * @return array{ + * comparison: array{app_gold: float, app_cash: float, tahesab_gold: float, tahesab_cash: float, gold_delta: float, rial_delta: float}, + * gold_same: bool, + * rial_same: bool, + * app_has_balance: bool + * } + */ + private function compareWithWallet(Customer $customer, array $mande): array + { + $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; + + return [ + 'comparison' => [ + 'app_gold' => $mainGold, + 'app_cash' => $mainCash, + 'tahesab_gold' => $goldInv, + 'tahesab_cash' => $rialInv, + 'gold_delta' => $goldDiff, + 'rial_delta' => $rialDiff, + ], + 'gold_same' => abs($goldDiff) < self::EPSILON_GOLD, + 'rial_same' => abs($rialDiff) < self::EPSILON_CASH, + 'app_has_balance' => abs($mainGold) >= self::EPSILON_GOLD + || abs($mainCash) >= self::EPSILON_CASH, + ]; + } + /** * بعد از import: اگر کیف خالی باشد مانده را بنشان؛ اگر مانده دارد صبر کن تا ادمین تصمیم بگیرد. * diff --git a/app/Services/ZarinInboundWalletService.php b/app/Services/ZarinInboundWalletService.php new file mode 100644 index 0000000..5307a86 --- /dev/null +++ b/app/Services/ZarinInboundWalletService.php @@ -0,0 +1,438 @@ +|null */ + private ?array $mandeIndex = null; + + public function __construct( + private readonly ConfigService $config, + private readonly WalletBroadcastService $walletBroadcast, + private readonly WalletBalanceService $walletBalance, + ) { + } + + /** + * @param list $codes + * @return array + */ + public function fetchMandeByCodes(array $codes): array + { + $index = $this->mandeIndex(); + $out = []; + foreach ($codes as $code) { + $key = (string) ((int) $code); + if ($key !== '0' && isset($index[$key])) { + $out[$key] = $index[$key]; + } + } + + return $out; + } + + /** + * @param array{overwrite?: bool, keep?: bool, ask_if_conflict?: bool, always_ask?: bool} $options + * @return array{ + * applied: bool, + * needs_decision?: bool, + * skipped?: bool, + * gold_delta?: float, + * rial_delta?: float, + * app_gold?: float, + * app_cash?: float, + * zarin_gold?: float, + * zarin_cash?: float, + * tahesab_gold?: float, + * tahesab_cash?: float, + * message: string + * } + */ + public function syncWalletFromZarin(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); + $alwaysAsk = (bool) ($options['always_ask'] ?? 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' => 'مانده طلا/ریال زرین برای این حساب خوانده نشد']; + } + + $compared = $this->compareWithWallet($customer, $mande); + $comparison = $compared['comparison']; + $goldSame = $compared['gold_same']; + $rialSame = $compared['rial_same']; + $appHasBalance = $compared['app_has_balance']; + + if ($goldSame && $rialSame) { + return array_merge($comparison, [ + 'applied' => false, + 'message' => 'مانده با زرین یکسان است', + ]); + } + + if ($keep) { + return array_merge($comparison, [ + 'applied' => false, + 'skipped' => true, + 'message' => 'موجودی اپ حفظ شد — مانده زرین اعمال نشد', + ]); + } + + $needsAsk = ! $overwrite && ($alwaysAsk || ($askIfConflict && $appHasBalance)); + if ($needsAsk) { + return array_merge($comparison, [ + 'applied' => false, + 'needs_decision' => true, + 'message' => 'موجودی اپ با زرین فرق دارد — تصمیم ادمین لازم است', + ]); + } + + $goldDiff = (float) $comparison['gold_delta']; + $rialDiff = (float) $comparison['rial_delta']; + $ownerType = (int) ($customer->type ?? 2); + $walletInterface = new WalletInterface((int) $customer->id, $ownerType); + + $input = [ + 'title' => 'تطبیق ماندهٔ زرین', + 'note' => 'zarin-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_ZARIN, + $goldDiff > 0, + $rialDiff > 0, + 0, + [], + true + ); + + $this->walletBroadcast->broadcastForCustomer((int) $customer->id, $ownerType); + + Log::info('Zarin mande applied to wallet', [ + 'customer_id' => (int) $customer->id, + 'moshtari_code' => $code, + 'gold_target' => $comparison['zarin_gold'], + 'cash_target' => $comparison['zarin_cash'], + 'gold_delta' => $goldDiff, + 'rial_delta' => $rialDiff, + 'overwrite' => $overwrite, + ]); + + return array_merge($comparison, [ + 'applied' => true, + 'message' => 'مانده با زرین هم‌تراز شد', + ]); + } + + /** + * @return array{ + * applied: bool, + * needs_decision?: bool, + * ok?: bool, + * app_gold?: float, + * app_cash?: float, + * zarin_gold?: float, + * zarin_cash?: float, + * tahesab_gold?: float, + * tahesab_cash?: float, + * message: string + * } + */ + public function previewForCustomer(Customer $customer, int $code): array + { + if ($code <= 0) { + return ['applied' => false, 'ok' => false, 'message' => 'کد زرین نامعتبر است']; + } + + $mande = $this->fetchMandeByCodes([$code])[(string) $code] ?? null; + if ($mande === null) { + return ['applied' => false, 'ok' => false, 'message' => 'مانده طلا/ریال زرین برای این حساب خوانده نشد']; + } + + $compared = $this->compareWithWallet($customer, $mande); + $same = $compared['gold_same'] && $compared['rial_same']; + + return array_merge($compared['comparison'], [ + 'applied' => false, + 'ok' => true, + 'needs_decision' => ! $same, + 'message' => $same + ? 'مانده با زرین یکسان است' + : 'موجودی اپ با زرین فرق دارد — تصمیم ادمین لازم است', + ]); + } + + /** @param array $row */ + public function mandeFromAccountingRow(array $row): ?array + { + return $this->parseMandeRow($row); + } + + /** + * @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->syncWalletFromZarin( + $customer, + $mandes[$code] ?? null, + ['ask_if_conflict' => true] + ); + } catch (\Throwable $e) { + Log::warning('Zarin wallet sync after import failed', [ + 'customer_id' => $customer->id, + 'moshtari_code' => $customer->moshtari_code, + 'error' => $e->getMessage(), + ]); + } + } + } + + /** + * @param array{gold?: float, cash?: float} $mande + * @return array{ + * comparison: array{app_gold: float, app_cash: float, zarin_gold: float, zarin_cash: float, tahesab_gold: float, tahesab_cash: float, gold_delta: float, rial_delta: float}, + * gold_same: bool, + * rial_same: bool, + * app_has_balance: bool + * } + */ + private function compareWithWallet(Customer $customer, array $mande): array + { + $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; + + return [ + 'comparison' => [ + 'app_gold' => $mainGold, + 'app_cash' => $mainCash, + 'zarin_gold' => $goldInv, + 'zarin_cash' => $rialInv, + 'tahesab_gold' => $goldInv, + 'tahesab_cash' => $rialInv, + 'gold_delta' => $goldDiff, + 'rial_delta' => $rialDiff, + ], + 'gold_same' => abs($goldDiff) < self::EPSILON_GOLD, + 'rial_same' => abs($rialDiff) < self::EPSILON_CASH, + 'app_has_balance' => abs($mainGold) >= self::EPSILON_GOLD + || abs($mainCash) >= self::EPSILON_CASH, + ]; + } + + /** + * @return array + */ + private function mandeIndex(): array + { + if ($this->mandeIndex !== null) { + return $this->mandeIndex; + } + + $this->mandeIndex = []; + foreach ($this->fetchListHesabha() as $item) { + if (! is_array($item)) { + continue; + } + $id = $this->extractHesabId($item); + if ($id === null) { + continue; + } + $parsed = $this->parseMandeRow($item); + if ($parsed !== null) { + $this->mandeIndex[(string) $id] = $parsed; + } + } + + return $this->mandeIndex; + } + + /** @return list> */ + private function fetchListHesabha(): array + { + $api = rtrim($this->config->getString('zarin_api', ''), '/'); + $token = trim($this->config->getString('zarin_token', '')); + if ($api === '' || $token === '') { + return []; + } + + $url = $api.'/ZarrinService/accounting'; + + try { + $response = Http::withoutVerifying()->timeout(45)->get($url, [ + 'kind' => 'listhesabha', + 'token' => $token, + ]); + } catch (\Throwable $e) { + Log::warning('Zarin listhesabha for mande failed', ['error' => $e->getMessage()]); + + return []; + } + + if (! $response->successful()) { + return []; + } + + $json = $response->json(); + if (! is_array($json)) { + return []; + } + + $data = $json['data'] ?? $json['Data'] ?? []; + if (! is_array($data)) { + return []; + } + + return array_is_list($data) ? $data : [$data]; + } + + /** @param array $item */ + private function extractHesabId(array $item): ?int + { + foreach (['idHesab', 'idhesab', 'Code', 'code', 'OK', 'id'] as $key) { + if (! array_key_exists($key, $item)) { + continue; + } + $value = $item[$key]; + if (is_numeric($value) && (int) $value > 0) { + return (int) $value; + } + } + + return null; + } + + /** + * فقط کلیدهای صریح طلا و ریال — فیلد مبهم mande را طلا فرض نمی‌کنیم. + * + * @param array $row + * @return array{gold: float, cash: float}|null + */ + private function parseMandeRow(array $row): ?array + { + $gold = $this->pickNumber($row, [ + 'GoldInv', 'goldInv', 'tala', 'Tala', 'mandeTala', 'MandeTala', + 'mojodiTala', 'MojodiTala', 'vaznTala', 'VaznTala', + ]); + $cash = $this->pickNumber($row, [ + 'RialInv', 'rialInv', 'pool', 'Pool', 'mandeRial', 'MandeRial', + 'mojodiRial', 'MojodiRial', 'mandePool', 'MandePool', + ]); + + $hasGoldKey = $this->hasAnyKey($row, [ + 'GoldInv', 'goldInv', 'tala', 'Tala', 'mandeTala', 'MandeTala', + 'mojodiTala', 'MojodiTala', 'vaznTala', 'VaznTala', + ]); + $hasCashKey = $this->hasAnyKey($row, [ + 'RialInv', 'rialInv', 'pool', 'Pool', 'mandeRial', 'MandeRial', + 'mojodiRial', 'MojodiRial', 'mandePool', 'MandePool', + ]); + + if (! $hasGoldKey && ! $hasCashKey) { + return null; + } + + return [ + 'gold' => $gold, + 'cash' => $cash, + ]; + } + + /** @param list $keys */ + private function hasAnyKey(array $row, array $keys): bool + { + foreach ($keys as $key) { + if (array_key_exists($key, $row) && $row[$key] !== null && $row[$key] !== '') { + return true; + } + } + + return false; + } + + /** @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; + } + $s = str_replace([',', ' ', '٫', '/'], ['', '', '.', '.'], trim((string) $row[$key])); + if (is_numeric($s)) { + return (float) $s; + } + } + + return 0.0; + } +} diff --git a/database/sql/deploy_settings_const.sql b/database/sql/deploy_settings_const.sql index 2cfc286..b0a4450 100644 --- a/database/sql/deploy_settings_const.sql +++ b/database/sql/deploy_settings_const.sql @@ -202,6 +202,37 @@ INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `de SELECT 'montzar_last_product_log', '{}', 'لاگ محصول همکار', 1, 1, NULL, 'longtext' WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'montzar_last_product_log' LIMIT 1); +-- هیرو لاگین دسکتاپ (تم VIP) — متن‌های ستون چپ +INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`, `type`) +SELECT 'login_hero_eyebrow', 'سرمایه‌گذاری هوشمند در', 'خط اول هیرو لاگین', 1, 1, + 'متن بالای عنوان طلا در ستون چپ صفحه ورود دسکتاپ (تم VIP).', 'text' +WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'login_hero_eyebrow' LIMIT 1); + +INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`, `type`) +SELECT 'login_hero_title', 'طلای جهانی', 'عنوان هیرو لاگین', 1, 1, + 'عنوان طلایی ستون چپ صفحه ورود دسکتاپ (تم VIP).', 'text' +WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'login_hero_title' LIMIT 1); + +INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`, `type`) +SELECT 'login_hero_feature_1', 'خرید و فروش آنلاین طلا', 'ویژگی ۱ هیرو لاگین', 1, 1, + 'اولین آیتم لیست ویژگی‌های ستون چپ لاگین دسکتاپ.', 'text' +WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'login_hero_feature_1' LIMIT 1); + +INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`, `type`) +SELECT 'login_hero_feature_2', 'قیمت لحظه‌ای و دقیق', 'ویژگی ۲ هیرو لاگین', 1, 1, + 'دومین آیتم لیست ویژگی‌های ستون چپ لاگین دسکتاپ.', 'text' +WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'login_hero_feature_2' LIMIT 1); + +INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`, `type`) +SELECT 'login_hero_feature_3', 'امنیت بالا و قابل اطمینان', 'ویژگی ۳ هیرو لاگین', 1, 1, + 'سومین آیتم لیست ویژگی‌های ستون چپ لاگین دسکتاپ.', 'text' +WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'login_hero_feature_3' LIMIT 1); + +INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`, `type`) +SELECT 'login_hero_feature_4', 'پشتیبانی ۲۴/۷', 'ویژگی ۴ هیرو لاگین', 1, 1, + 'چهارمین آیتم لیست ویژگی‌های ستون چپ لاگین دسکتاپ.', 'text' +WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'login_hero_feature_4' LIMIT 1); + -- قیمت zaarbox (اگر از قبل دارید value دست نخورده) INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`, `type`) SELECT 'chart_use_local_data', 'false', 'نمودار از داده خود سایت', 1, 1, @@ -1708,6 +1739,12 @@ UPDATE `settings` SET `description` = 'تنظیم legacy — از price_provider UPDATE `settings` SET `description` = 'متن کامل قوانین و مقررات استفاده از اپ که در صفحه قوانین به کاربر نشان داده می‌شود. HTML ساده مجاز است.' WHERE `key` = 'text_rule'; UPDATE `settings` SET `description` = 'متن «درباره ما» شامل معرفی مجموعه، راه‌های ارتباطی و توضیح خدمات. در صفحه مربوطه در اپ کاربر نمایش داده می‌شود.' WHERE `key` = 'text_about'; +UPDATE `settings` SET `description` = 'متن بالای عنوان طلا در ستون چپ صفحه ورود دسکتاپ (تم VIP).' WHERE `key` = 'login_hero_eyebrow'; +UPDATE `settings` SET `description` = 'عنوان طلایی ستون چپ صفحه ورود دسکتاپ (تم VIP).' WHERE `key` = 'login_hero_title'; +UPDATE `settings` SET `description` = 'اولین آیتم لیست ویژگی‌های ستون چپ لاگین دسکتاپ.' WHERE `key` = 'login_hero_feature_1'; +UPDATE `settings` SET `description` = 'دومین آیتم لیست ویژگی‌های ستون چپ لاگین دسکتاپ.' WHERE `key` = 'login_hero_feature_2'; +UPDATE `settings` SET `description` = 'سومین آیتم لیست ویژگی‌های ستون چپ لاگین دسکتاپ.' WHERE `key` = 'login_hero_feature_3'; +UPDATE `settings` SET `description` = 'چهارمین آیتم لیست ویژگی‌های ستون چپ لاگین دسکتاپ.' WHERE `key` = 'login_hero_feature_4'; UPDATE `settings` SET `description` = 'متن معرفی نرم‌افزار — در تنظیمات پنل ذخیره می‌شود (HTML یا چند خط متن).' WHERE `key` = 'about_software'; UPDATE `settings` SET `description` = 'لینک اعتماد اینماد (مثلاً trustseal.enamad.ir/?id=...&Code=...).' WHERE `key` = 'enamad_url'; UPDATE `settings` SET `description` = 'آدرس تصویر لوگوی اینماد (مثلاً trustseal.enamad.ir/logo.aspx?id=...&Code=...).' WHERE `key` = 'enamad_logo'; @@ -2046,6 +2083,12 @@ UPDATE `settings` SET `title_fa` = 'متن دعوتنامه اشتراک‌گذ -- UPDATE `settings` SET `title_fa` = 'حالت فروشگاهی' WHERE `key` = 'is_store'; UPDATE `settings` SET `title_fa` = 'تأیید کارت بانکی با جیبیت' WHERE `key` = 'jibit_verification'; UPDATE `settings` SET `title_fa` = 'کلید داخلی اپ' WHERE `key` = 'key'; +UPDATE `settings` SET `title_fa` = 'خط اول هیرو لاگین' WHERE `key` = 'login_hero_eyebrow'; +UPDATE `settings` SET `title_fa` = 'عنوان هیرو لاگین' WHERE `key` = 'login_hero_title'; +UPDATE `settings` SET `title_fa` = 'ویژگی ۱ هیرو لاگین' WHERE `key` = 'login_hero_feature_1'; +UPDATE `settings` SET `title_fa` = 'ویژگی ۲ هیرو لاگین' WHERE `key` = 'login_hero_feature_2'; +UPDATE `settings` SET `title_fa` = 'ویژگی ۳ هیرو لاگین' WHERE `key` = 'login_hero_feature_3'; +UPDATE `settings` SET `title_fa` = 'ویژگی ۴ هیرو لاگین' WHERE `key` = 'login_hero_feature_4'; -- [super-admin can_edit=0] -- UPDATE `settings` SET `title_fa` = 'لوگوی صفحه ورود' WHERE `key` = 'logo_login'; -- [super-admin can_edit=0] diff --git a/routes/admin.php b/routes/admin.php index 1e6099c..f51c882 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -250,12 +250,15 @@ Route::middleware('auth:user')->group(function (){ 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']); + Route::post('tahesab/customer-sync/link-by-phone', [TahesabController::class, 'linkMatchingPhones']); Route::post('tahesab/customer-sync/rollback-import', [TahesabController::class, 'rollbackImport']); + Route::post('tahesab/mande-by-codes', [TahesabController::class, 'mandeByCodes']); 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::post('customer/{id}/tahesab/preview-wallet', [TahesabController::class, 'previewCustomerWallet']); Route::get('zarin/health', [ZarinController::class, 'healthCheck']); Route::post('wallet/zarin', [ZarinController::class, 'wallet']); @@ -269,10 +272,14 @@ Route::middleware('auth:user')->group(function (){ Route::match(['get', 'post'], 'zarin/customer-sync/status', [ZarinController::class, 'customerSyncStatus']); Route::post('zarin/customer-sync/panel-to-zarin', [ZarinController::class, 'syncPanelToZarin']); Route::post('zarin/customer-sync/import-from-zarin', [ZarinController::class, 'importFromZarin']); + Route::post('zarin/customer-sync/link-by-phone', [ZarinController::class, 'linkMatchingPhones']); Route::post('zarin/customer-sync/rollback-import', [ZarinController::class, 'rollbackImport']); + Route::post('zarin/wallet-sync/all', [ZarinController::class, 'syncAllWallets']); Route::get('zarin/{id}', [ZarinController::class, 'show']); Route::post('zarin/{id}/retry', [ZarinController::class, 'retrySanad']); Route::post('customer/{id}/zarin/register', [ZarinController::class, 'registerCustomer']); + Route::post('customer/{id}/zarin/sync-wallet', [ZarinController::class, 'syncCustomerWallet']); + Route::post('customer/{id}/zarin/preview-wallet', [ZarinController::class, 'previewCustomerWallet']); Route::post('pargar/table', [PargarController::class, 'table']); Route::get('pargar/customer-sync/status', [PargarController::class, 'customerSyncStatus']);