$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, 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, * 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); $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, $this->listPreviewDocuments($customer), [ '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' => $this->customerVisibleAdjustNote($code, $goldDiff, $rialDiff, $goldSame, $rialSame), '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' => $comparison['tahesab_gold'], 'cash_target' => $comparison['tahesab_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, * 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'], $this->listPreviewDocuments($customer), [ 'applied' => false, 'ok' => true, 'needs_decision' => ! $same, 'message' => $same ? 'مانده با ته‌حساب یکسان است' : 'موجودی اپ با ته‌حساب فرق دارد — تصمیم ادمین لازم است', ]); } /** * آخرین سندهای کیف اپ و ته‌حساب — برای تصمیم ادمین روی اختلاف مانده. * * @return array{wallet_docs: list>, tahesab_docs: list>} */ private function listPreviewDocuments(Customer $customer): array { $ownerType = (int) ($customer->type ?? 2); $wallet = Wallet::query() ->where('owner', (int) $customer->id) ->where('owner_type', $ownerType) ->whereNull('fund_id') ->whereNull('product_id') ->first(); $walletDocs = []; if ($wallet) { $txs = Transaction::query() ->where('wallet', $wallet->id) ->orderByDesc('id') ->limit(8) ->get(['id', 'title', 'type', 'gold', 'cash', 'created_at']); foreach ($txs as $tx) { $typeKey = (int) $tx->getRawOriginal('type'); $typeLabel = Transaction::$TYPES[$typeKey] ?? ''; $walletDocs[] = [ 'id' => (int) $tx->id, 'title' => (string) ($tx->title ?: $typeLabel ?: 'تراکنش کیف'), 'gold' => (float) ($tx->gold ?? 0), 'cash' => (float) ($tx->cash ?? 0), 'created_at' => optional($tx->created_at)->toDateTimeString(), ]; } } $tahesabDocs = []; $rows = Tahesab::query() ->where('customer_id', (int) $customer->id) ->orderByDesc('id') ->limit(8) ->get(['id', 'desc', 'amount', 'type', 'factor_number', 'date', 'created_at']); foreach ($rows as $row) { $title = trim((string) ($row->desc ?? '')); if ($title === '' && $row->factor_number) { $title = 'فاکتور '.$row->factor_number; } $tahesabDocs[] = [ 'id' => (int) $row->id, 'title' => $title !== '' ? $title : 'سند ته‌حساب', 'amount' => (float) ($row->amount ?? 0), 'type' => (string) ($row->type ?? ''), 'date' => (string) ($row->date ?? ''), 'created_at' => optional($row->created_at)->toDateTimeString(), ]; } return [ 'wallet_docs' => $walletDocs, 'tahesab_docs' => $tahesabDocs, ]; } private function customerVisibleAdjustNote( int $code, float $goldDiff, float $rialDiff, bool $goldSame, bool $rialSame ): string { $parts = ['تعدیل مانده با ته‌حساب، کد '.$code]; if (! $goldSame) { $sign = $goldDiff > 0 ? '+' : ''; $gold = rtrim(rtrim(sprintf('%.3f', $goldDiff), '0'), '.'); $parts[] = 'طلا '.$sign.$gold; } if (! $rialSame) { $sign = $rialDiff > 0 ? '+' : ''; $parts[] = 'ریال '.$sign.(int) round($rialDiff); } return implode('، ', $parts); } /** @param array $row */ public function mandeFromAccountingRow(array $row): ?array { return $this->parseMandeRow($row); } /** * مقایسهٔ کیف اپ با ماندهٔ ته‌حساب برای لیست ادمین — بدون نوشتن. * * @param iterable $customers * @return list> */ public function compareCustomers(iterable $customers): array { $list = []; foreach ($customers as $customer) { if ($customer instanceof Customer) { $list[] = $customer; } } if ($list === []) { return []; } $codes = []; foreach ($list as $customer) { $code = (int) ($customer->moshtari_code ?? 0); if ($code > 0) { $codes[] = $code; } } $mandes = $this->fetchMandeByCodes($codes); $rows = []; foreach ($list as $customer) { $code = (int) ($customer->moshtari_code ?? 0); $mande = $code > 0 ? ($mandes[(string) $code] ?? null) : null; $base = [ 'id' => (int) $customer->id, 'name' => (string) ($customer->name ?? ''), 'mobile' => (string) ($customer->mobile ?? ''), 'moshtari_code' => $code > 0 ? $code : null, ]; if ($mande === null) { $ownerType = (int) ($customer->type ?? 2); $wallet = (new WalletInterface((int) $customer->id, $ownerType))->wallet; $appGold = $this->walletBalance ->accountingVisibleMainWalletGold((int) $customer->id, $ownerType); $appCash = (float) ($wallet->cash ?? 0); $rows[] = array_merge($base, [ 'app_gold' => $appGold, 'app_cash' => $appCash, 'tahesab_gold' => null, 'tahesab_cash' => null, 'gold_delta' => null, 'rial_delta' => null, 'status' => 'missing', 'message' => 'مانده از ته‌حساب دریافت نشد', ]); continue; } $compared = $this->compareWithWallet($customer, $mande); $same = $compared['gold_same'] && $compared['rial_same']; $status = 'conflict'; if ($same) { $status = 'same'; } elseif (! $compared['app_has_balance']) { $status = 'ready'; } $rows[] = array_merge($base, $compared['comparison'], [ 'status' => $status, 'message' => $same ? 'مانده با ته‌حساب یکسان است' : ($status === 'ready' ? 'کیف خالی است؛ همگام‌سازی مانده ته‌حساب را می‌نشاند' : 'موجودی اپ با ته‌حساب فرق دارد'), ]); } return $rows; } /** * @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: اگر کیف خالی باشد مانده را بنشان؛ اگر مانده دارد صبر کن تا ادمین تصمیم بگیرد. * * @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 : ''; } }