Files
boursecu_server/app/Services/TahesabInboundWalletService.php
T
mohmdsdgandCursor e059a4709e 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 <cursoragent@cursor.com>
2026-08-30 14:16:36 +03:30

403 lines
12 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 → اگر اپ مانده دارد و با ته‌حساب فرق دارد، بدون نوشتن برگردان
*/
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} $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<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 : '';
}
}