|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; } }