Files
boursecu_server/app/Services/TahesabInboundWalletService.php
T

557 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use App\Models\Customer;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Rooyesh\Wallet\interfaces\TransactionInterface;
use Rooyesh\Wallet\interfaces\WalletInterface;
/**
* ماندهٔ طلا/ریال ته‌حساب → کیف اپ.
*
* منبع: GetMandeHesabByCode
* MandeyeKolBeTALA → طلای کیف اصلی
* MandeyeKolBePool → موجودی ریالی
*
* سیاست تصمیم ادمین:
* overwrite=true → اختلاف را اعمال کن
* keep=true → موجودی اپ را دست نزن
* ask_if_conflict → اگر اپ مانده دارد و با ته‌حساب فرق دارد، بدون نوشتن برگردان
* always_ask → هر اختلافی (حتی کیف خالی) بدون نوشتن برای تأیید ادمین برمی‌گردد
* جمع دو مانده هرگز انجام نمی‌شود تا موجودی دو برابر نشود.
*/
class TahesabInboundWalletService
{
public const TX_TYPE_TAHESAB = 23;
private const BATCH_SIZE = 40;
private const EPSILON_GOLD = 0.0005;
private const EPSILON_CASH = 1.0;
public function __construct(
private readonly ConfigService $config,
private readonly WalletBroadcastService $walletBroadcast,
private readonly WalletBalanceService $walletBalance,
) {
}
/**
* @param list<int|string> $codes
* @return array<string, array{gold: float, cash: float, raw: 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, [
'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' => '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' => $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'], [
'applied' => false,
'ok' => true,
'needs_decision' => ! $same,
'message' => $same
? 'مانده با ته‌حساب یکسان است'
: 'موجودی اپ با ته‌حساب فرق دارد — تصمیم ادمین لازم است',
]);
}
/** @param array<string, mixed> $row */
public function mandeFromAccountingRow(array $row): ?array
{
return $this->parseMandeRow($row);
}
/**
* مقایسهٔ کیف اپ با ماندهٔ ته‌حساب برای لیست ادمین — بدون نوشتن.
*
* @param iterable<Customer> $customers
* @return list<array<string, mixed>>
*/
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<Customer> $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<int> $codes
* @return array<string, array{gold: float, cash: float, raw: 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<string, mixed> $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<string, mixed> $row
* @param list<string> $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<string, mixed> $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 : '';
}
}