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>
This commit is contained in:
2026-09-05 22:30:56 +03:30
co-authored by Cursor
parent 8cee34bfba
commit 00b23ac404
6 changed files with 176 additions and 15 deletions
@@ -807,7 +807,7 @@ class TahesabController extends Controller
/**
* همگام‌سازی مانده یک مشتری با ته‌حساب.
* body: overwrite=1 | keep=1 | ask_if_conflict=1 (پیش‌فرض ask برای جلوگیری از overwrite ناخواسته)
* body: overwrite=1 | keep=1 | push_to_tahesab=1 | ask_if_conflict=1
*/
public function syncCustomerWallet(Request $request, $customerId)
{
@@ -826,12 +826,26 @@ class TahesabController extends Controller
], 422);
}
$walletService = app(TahesabInboundWalletService::class);
if ($request->boolean('push_to_tahesab')) {
$result = $walletService->pushWalletToTahesab($customer);
$applied = (bool) ($result['applied'] ?? false);
return response()->json([
'ok' => $applied,
'needs_decision' => false,
'message' => $result['message'] ?? '',
'result' => $result,
], $applied ? 200 : 422);
}
$overwrite = $request->boolean('overwrite');
$keep = $request->boolean('keep');
$alwaysAsk = $request->boolean('always_ask');
$askIfConflict = $request->boolean('ask_if_conflict', ! $overwrite && ! $keep && ! $alwaysAsk);
$result = app(TahesabInboundWalletService::class)->syncWalletFromTahesab(
$result = $walletService->syncWalletFromTahesab(
$customer,
null,
[
@@ -32,6 +32,7 @@ use App\Services\ReferralGiftService;
use App\Services\WalletBalanceService;
use App\Services\WalletBroadcastService;
use App\Services\ColleagueOrderCallbackService;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
@@ -1010,9 +1011,8 @@ class SingleOrderController extends Controller
$this->assertProviderOrderAccepted($data);
$this->finalizeProviderOrder($data);
$order->refresh();
if (app(ColleagueOrderCallbackService::class)->waitEnabled()) {
$data['_awaiting_callback'] = true;
}
// همیشه تا کالبک/استعلام همکار در انتظار می‌ماند (حتی اگر سوییچ خاموش باشد)
$data['_awaiting_callback'] = true;
} catch (\Throwable $e) {
$this->markOrderFailed($order, $e->getMessage());
throw $e instanceof UnprocessableEntityHttpException
@@ -2093,6 +2093,44 @@ class SingleOrderController extends Controller
public function rejectColleaguePendingOrder(Order $order, string $message): void
{
$this->markOrderFailed($order, $message);
$this->notifyAdminsColleagueOrderRejected($order, $message);
}
/**
* SMS ادمین وقتی سفارش در اپ ثبت شده ولی همکار رد کرده است.
*/
private function notifyAdminsColleagueOrderRejected(Order $order, string $rejectMessage): void
{
if (! self::isSmsNotifEnabled('sms_notif_order_admin')) {
return;
}
try {
$customer = Customer::query()->find((int) $order->customer_id);
$name = trim((string) ($customer->name ?? '')) ?: 'بدون نام';
$mobile = trim((string) ($customer->mobile ?? '')) ?: '—';
$dir = ((int) ($order->direction ?? 0) === 2) ? 'فروش' : 'خرید';
$amount = self::formatSmsGoldWeight((float) ($order->amount ?? 0));
$company = (string) ($this->Config('name_fa') ?: '');
$reason = trim($rejectMessage);
if ($reason === '') {
$reason = 'رد شده توسط همکار';
}
$message = "سفارش {$dir} کاربر {$name} ({$mobile}) به مقدار {$amount} گرم در اپ ثبت شد ولی از سمت همکار رد شد. علت: {$reason}";
if ($company !== '') {
$message .= " — {$company}";
}
User::where('send_sms', 1)->get()->each(function ($admin) use ($message) {
$this->send($admin->mobile, $message);
});
} catch (\Throwable $e) {
Log::warning('Colleague reject admin SMS failed', [
'order_id' => $order->id,
'error' => $e->getMessage(),
]);
}
}
public function finalizeColleaguePendingOrder(Order $order): void
@@ -16,9 +16,8 @@ class ColleagueOrderCallbackService
public function waitEnabled(): bool
{
$config = app(ConfigService::class);
return $config->usesMontzarPricing() && $config->getBool('montzar_order_wait_callback', true);
// همیشه منتظر کالبک/استعلام همکار — سوییچ montzar_order_wait_callback دیگر مسیر «قطعی بلافاصله» را باز نمی‌کند
return app(ConfigService::class)->usesMontzarPricing();
}
public function callbackUrl(int $localOrderId): string
+1 -2
View File
@@ -627,8 +627,7 @@ class MontzarService
];
if (
$this->config()->getBool('montzar_order_wait_callback', true)
&& isset($data['order'])
isset($data['order'])
&& $data['order'] instanceof Order
) {
$payload['callback'] = app(ColleagueOrderCallbackService::class)->callbackUrl((int) $data['order']->id);
+112 -4
View File
@@ -19,10 +19,11 @@ use Rooyesh\Wallet\models\Wallet;
* MandeyeKolBePool → موجودی ریالی
*
* سیاست تصمیم ادمین:
* overwrite=true → اختلاف را اعمال کن
* keep=true → موجودی اپ را دست نزن
* ask_if_conflict → اگر اپ مانده دارد و با ته‌حساب فرق دارد، بدون نوشتن برگردان
* always_ask → هر اختلافی (حتی کیف خالی) بدون نوشتن برای تأیید ادمین برمی‌گردد
* overwrite=true → اختلاف را روی کیف اپ اعمال کن (ته‌حساب → اپ)
* keep=true → موجودی اپ را دست نزن
* push_to_tahesab → مانده اپ را با سند تعدیل روی ته‌حساب بنویس (حتی اگر اپ صفر باشد)
* ask_if_conflict → اگر اپ مانده دارد و با ته‌حساب فرق دارد، بدون نوشتن برگردان
* always_ask → هر اختلافی (حتی کیف خالی) بدون نوشتن برای تأیید ادمین برمی‌گردد
* جمع دو مانده هرگز انجام نمی‌شود تا موجودی دو برابر نشود.
*/
class TahesabInboundWalletService
@@ -190,6 +191,113 @@ class TahesabInboundWalletService
]);
}
/**
* ماندهٔ اپ → ته‌حساب (سند تعدیل در ته‌حساب؛ کیف اپ دست نخورده).
* حتی اگر اپ صفر باشد، مانده ته‌حساب با سند برداشت/واریز هم‌تراز اپ می‌شود.
*
* @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' => 'مانده اپ روی ته‌حساب اعمال شد (کیف اپ تغییر نکرد)',
]);
}
/**
* مقایسه بدون نوشتن — برای تأیید ادمین قبل از ست کردن کد.
*
+4 -1
View File
@@ -348,7 +348,7 @@ WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'montzar_open_sell' LIM
INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`, `type`)
SELECT 'montzar_order_wait_callback', 'true', 'انتظار کالبک سفارش همکار', 1, 1,
'روشن: سفارش همکار تا دریافت کالبک (یا استعلام مسیر وضعیت) در انتظار می‌ماند و کیف ثبت نمی‌شود. خاموش: مثل قبل بلافاصله با پاسخ HTTP همکار قطعی می‌شود.',
'با منبع قیمت همکار همیشه تا کالبک/استعلام وضعیت منتظر می‌ماند (خاموش بودن سوییچ مسیر قطعی بلافاصله را باز نمی‌کند). رد همکار → SMS به ادمین.',
'switch'
WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'montzar_order_wait_callback' LIMIT 1);
@@ -1948,6 +1948,9 @@ UPDATE `settings` SET `description` = 'مظنه واقعی سمت خرید از
UPDATE `settings` SET `description` = 'مظنه واقعی سمت فروش از همکار. توسط سیستم به‌روز می‌شود.' WHERE `key` = 'montzar_truly_sell_mazaneh';
UPDATE `settings` SET `description` = 'روشن: امکان ثبت سفارش خرید از طریق قیمت همکار باز است (طبق وضعیت سرویس خارجی).' WHERE `key` = 'montzar_open_buy';
UPDATE `settings` SET `description` = 'روشن: امکان ثبت سفارش فروش از طریق قیمت همکار باز است.' WHERE `key` = 'montzar_open_sell';
UPDATE `settings` SET
`description` = 'با منبع قیمت همکار همیشه تا کالبک/استعلام وضعیت منتظر می‌ماند (خاموش بودن سوییچ مسیر قطعی بلافاصله را باز نمی‌کند). رد همکار → SMS به ادمین‌های دارای ارسال پیامک (sms_notif_order_admin).'
WHERE `key` = 'montzar_order_wait_callback';
UPDATE `settings` SET `description` = 'روشن: سرویس همکار در حال حاضر بسته/غیرفعال گزارش شده است.' WHERE `key` = 'montzar_closed';
UPDATE `settings` SET `description` = 'پاسخ JSON آخرین سفارش ارسالی به همکار. برای عیب‌یابی؛ توسط سیستم پر می‌شود.' WHERE `key` = 'montzar_order_response';
UPDATE `settings` SET `description` = 'لاگ JSON آخرین محصول دریافت‌شده از API همکار. توسط سیستم پر می‌شود.' WHERE `key` = 'montzar_last_product_log';