Files
boursecu_server/app/Services/TahesabInboundWalletService.php
mohmdsdgandCursor 00b23ac404 feat(tahesab,colleague): push app balance to Tahesab and always wait callback
Add admin decision to write app wallet onto Tahesab even when zero, always await colleague callback regardless of the wait switch, and SMS admins on colleague reject.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 22:30:56 +03:30

751 lines
26 KiB
PHP
Raw Permalink 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 App\Models\Tahesab;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Rooyesh\Wallet\interfaces\TransactionInterface;
use Rooyesh\Wallet\interfaces\WalletInterface;
use Rooyesh\Wallet\models\Transaction;
use Rooyesh\Wallet\models\Wallet;
/**
* ماندهٔ طلا/ریال ته‌حساب → کیف اپ.
*
* منبع: GetMandeHesabByCode
* MandeyeKolBeTALA → طلای کیف اصلی
* MandeyeKolBePool → موجودی ریالی
*
* سیاست تصمیم ادمین:
* overwrite=true → اختلاف را روی کیف اپ اعمال کن (ته‌حساب → اپ)
* keep=true → موجودی اپ را دست نزن
* push_to_tahesab → مانده اپ را با سند تعدیل روی ته‌حساب بنویس (حتی اگر اپ صفر باشد)
* 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, $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' => 'مانده با ته‌حساب هم‌تراز شد',
]);
}
/**
* ماندهٔ اپ → ته‌حساب (سند تعدیل در ته‌حساب؛ کیف اپ دست نخورده).
* حتی اگر اپ صفر باشد، مانده ته‌حساب با سند برداشت/واریز هم‌تراز اپ می‌شود.
*
* @param array{gold?: float, cash?: float}|null $mande
* @return array{
* applied: bool,
* gold_delta?: float,
* rial_delta?: float,
* app_gold?: float,
* app_cash?: float,
* tahesab_gold?: float,
* tahesab_cash?: float,
* message: string
* }
*/
public function pushWalletToTahesab(Customer $customer, ?array $mande = null): array
{
$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'];
if ($goldSame && $rialSame) {
return array_merge($comparison, [
'applied' => false,
'message' => 'مانده با ته‌حساب یکسان است',
]);
}
$controller = app(\App\Http\Controllers\Admin\TahesabController::class);
$note = 'تعدیل مانده از اپ به ته‌حساب';
$errors = [];
// gold_delta در مقایسه = ته‌حساب − اپ؛ برای رساندن ته‌حساب به اپ باید معکوس اعمال شود
if (! $goldSame) {
$goldToAdd = (float) $comparison['app_gold'] - (float) $comparison['tahesab_gold'];
$goldOk = $controller->walletGold([
'customer_id' => (int) $customer->id,
'value' => abs($goldToAdd),
'direction' => $goldToAdd > 0 ? 1 : 0,
'note' => $note,
]);
if ($goldOk === false || is_string($goldOk)) {
$errors[] = is_string($goldOk) && $goldOk !== ''
? $goldOk
: 'ثبت سند طلایی تعدیل در ته‌حساب ناموفق بود';
}
}
if (! $rialSame) {
$cashToAdd = (float) $comparison['app_cash'] - (float) $comparison['tahesab_cash'];
$cashOk = $controller->wallet([
'customer_id' => (int) $customer->id,
'value' => (int) round(abs($cashToAdd)),
'direction' => $cashToAdd > 0 ? 0 : 1,
'note' => $note,
]);
if ($cashOk === false || is_string($cashOk)) {
$errors[] = is_string($cashOk) && $cashOk !== ''
? $cashOk
: 'ثبت سند ریالی تعدیل در ته‌حساب ناموفق بود';
}
}
if ($errors !== []) {
Log::warning('Tahesab push wallet from app failed', [
'customer_id' => (int) $customer->id,
'moshtari_code' => $code,
'errors' => $errors,
]);
return array_merge($comparison, [
'applied' => false,
'message' => implode('؛ ', $errors),
]);
}
Log::info('Tahesab wallet pushed from app', [
'customer_id' => (int) $customer->id,
'moshtari_code' => $code,
'app_gold' => $comparison['app_gold'],
'app_cash' => $comparison['app_cash'],
'tahesab_gold_before' => $comparison['tahesab_gold'],
'tahesab_cash_before' => $comparison['tahesab_cash'],
]);
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<array<string, mixed>>, tahesab_docs: list<array<string, mixed>>}
*/
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<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 : '';
}
}