= self::WALLET_DOC_EPSILON; } public function buildLog(mixed $response = null, ?string $extra = null): string { $parts = array_filter([ $extra, is_array($response) ? json_encode($response, JSON_UNESCAPED_UNICODE) : (is_string($response) ? $response : null), ]); return implode("\n", $parts); } public function markSuccess(PargarSyncLog $log, ?string $ref = null, mixed $response = null, ?array $payload = null): void { $update = [ 'pargar_ref' => $ref, 'res' => is_array($response) ? json_encode($response, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) : (is_string($response) ? $response : null), 'sync_status' => self::STATUS_SUCCESS, 'sync_log' => null, ]; if ($payload !== null) { $update['data'] = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); } // لحظهٔ ثبت سند در پرگار: ماندهٔ قدیمی‌تر از این لحظه دیگر معتبر نیست if (PargarSyncLog::hasConfirmedAtColumn()) { $update['confirmed_at'] = now(); } PargarSyncLog::where('id', $log->id)->update($update); } public function markFailed(PargarSyncLog $log, mixed $response = null, ?string $extra = null): void { PargarSyncLog::where('id', $log->id)->update([ 'res' => is_array($response) ? json_encode($response, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) : (is_string($response) ? $response : null), 'sync_status' => self::STATUS_FAILED, 'sync_log' => $this->buildLog($response, $extra), ]); } public function isFailed(PargarSyncLog $log): bool { return ($log->sync_status ?? null) === self::STATUS_FAILED || (empty($log->pargar_ref) && ($log->sync_status ?? null) !== self::STATUS_SUCCESS); } public function registerOnCreate(Customer $customer): void { if ($customer->hasValidPargarAccountId() || ! $this->isSyncEnabled()) { return; } $customerId = (int) $customer->id; dispatch(function () use ($customerId) { $service = app(self::class); if (! $service->isSyncEnabled()) { return; } $customer = Customer::find($customerId); if ($customer === null || $customer->hasValidPargarAccountId()) { return; } try { $service->queueCustomerExport($customer); } catch (\Throwable $e) { Log::warning('Pargar auto-queue on customer create failed', [ 'customer_id' => $customerId, 'error' => $e->getMessage(), ]); } })->afterResponse(); } public function queueCustomerExport(Customer $customer): PargarSyncLog { $existing = PargarSyncLog::query() ->where('customer_id', $customer->id) ->where('type', self::TYPE_CUSTOMER) ->where('sync_status', self::STATUS_PENDING) ->latest('id') ->first(); if ($existing) { return $existing; } return PargarSyncLog::create([ 'customer_id' => $customer->id, 'desc' => 'در انتظار دریافت توسط پرگار', 'type' => self::TYPE_CUSTOMER, 'sync_status' => self::STATUS_PENDING, 'data' => json_encode(['handler' => 'exportCustomer'], JSON_UNESCAPED_UNICODE), ]); } public function registerCustomer(Customer $customer): array { $customer->refresh(); if ($customer->hasValidPargarAccountId()) { return [ 'ok' => true, 'message' => 'مشتری قبلاً در پرگار ثبت شده است', 'pargar_account_id' => $customer->pargar_account_id, ]; } $this->queueCustomerExport($customer); return [ 'ok' => true, 'message' => 'مشتری در صف دریافت پرگار قرار گرفت', 'pargar_account_id' => null, ]; } /** * سقف تاریخی ستون amount وقتی هنوز DOUBLE(8,2) است (~۹۹۹٬۹۹۹٫۹۹). * مبلغ واقعی همیشه در data.signed_amount نگه داشته می‌شود. */ private const AMOUNT_COLUMN_SAFE_MAX = 999999.99; /** * صف سند کیف برای سینک مانده پرگار با اپ. * * سند pending باید حتماً ساخته شود؛ اگر ستون amount مبلغ بزرگ را نپذیرد، * بدون amount ثبت می‌شود تا حرکت کیف بی‌سندی نماند و مانده قفل نشود. * * @param 'cash'|'gold' $kind * @param array{withdraw_id?:int|null,note?:string|null,product_id?:int|null,source?:string|null} $meta */ public function queueWalletDocument( Customer $customer, string $kind, float $signedAmount, array $meta = [] ): ?PargarSyncLog { if (! $this->isSyncEnabled() || ! $this->hasSyncTable()) { return null; } if (! self::isWalletDocAmount($signedAmount)) { return null; } if ($kind === 'gold') { $productId = (int) ($meta['product_id'] ?? 0); if ($productId > 0 && ! app(ConfigService::class)->isMainWalletProduct($productId)) { return null; } } elseif ($kind !== 'cash') { return null; } $withdrawId = isset($meta['withdraw_id']) ? (int) $meta['withdraw_id'] : null; $type = $kind === 'cash' ? self::TYPE_WALLET_CASH : self::TYPE_WALLET_GOLD; if ($withdrawId) { $existing = PargarSyncLog::query() ->where('withdraw_id', $withdrawId) ->where('type', $type) ->latest('id') ->first(); if ($existing) { return $existing; } } $isDeposit = $signedAmount > 0; $note = isset($meta['note']) ? trim((string) $meta['note']) : ''; $docDesc = \App\Http\Controllers\Controller::accountingWalletDesc($isDeposit, $note !== '' ? $note : null); $desc = $docDesc . ' — ارسال به پرگار'; $absAmount = abs($signedAmount); // اگر هنوز مایگریشن widen اجرا نشده، مبلغ بزرگ را از همان اول در ستون نگذار // تا insert نترکد؛ خروجی/مانده از signed_amount می‌خوانند. $columnAmount = $absAmount <= self::AMOUNT_COLUMN_SAFE_MAX ? $absAmount : null; $attributes = [ 'customer_id' => $customer->id, 'amount' => $columnAmount, 'desc' => $desc, 'withdraw_id' => $withdrawId, 'date' => now(), 'type' => $type, 'sync_status' => self::STATUS_PENDING, 'data' => json_encode([ 'handler' => 'exportWallet', 'kind' => $kind, 'signed_amount' => $signedAmount, 'note' => $meta['note'] ?? null, 'product_id' => $meta['product_id'] ?? null, 'source' => $meta['source'] ?? null, ], JSON_UNESCAPED_UNICODE), ]; if ($columnAmount === null) { $attributes['sync_log'] = 'amount بزرگ‌تر از سقف ستون بود؛ مبلغ در data.signed_amount است'; } return $this->createWalletSyncLogSafely($attributes, $absAmount); } /** * ساخت لاگ سند کیف بدون امکان گم‌شدن به‌خاطر سقف ستون amount. * * @param array $attributes */ private function createWalletSyncLogSafely(array $attributes, float $absAmount): PargarSyncLog { try { // اگر ستون widen شده، مبلغ کامل را هم در amount بنویس (نمایش ادمین) if (($attributes['amount'] ?? null) === null && $absAmount > self::AMOUNT_COLUMN_SAFE_MAX) { $withAmount = $attributes; $withAmount['amount'] = $absAmount; unset($withAmount['sync_log']); try { return PargarSyncLog::create($withAmount); } catch (QueryException $e) { report($e); $attributes['sync_log'] = mb_substr( 'amount ثبت نشد: ' . $e->getMessage(), 0, 500 ); } } return PargarSyncLog::create($attributes); } catch (QueryException $e) { report($e); $attributes['amount'] = null; $attributes['sync_log'] = mb_substr('amount ثبت نشد: ' . $e->getMessage(), 0, 500); try { return PargarSyncLog::create($attributes); } catch (\Throwable $e2) { report($e2); return $this->insertWalletSyncLogRaw($attributes, $e2); } } } /** * آخرین پناه: insert خام تا سند pending حتماً در DB بماند. * * @param array $attributes */ private function insertWalletSyncLogRaw(array $attributes, \Throwable $previous): PargarSyncLog { $now = now(); $row = [ 'customer_id' => $attributes['customer_id'] ?? null, 'amount' => null, 'desc' => $attributes['desc'] ?? null, 'order_id' => $attributes['order_id'] ?? null, 'withdraw_id' => $attributes['withdraw_id'] ?? null, 'date' => $attributes['date'] ?? $now, 'type' => $attributes['type'] ?? null, 'sync_status' => $attributes['sync_status'] ?? self::STATUS_PENDING, 'sync_log' => mb_substr( (string) ($attributes['sync_log'] ?? '') . ' | raw insert: ' . $previous->getMessage(), 0, 500 ), 'data' => $attributes['data'] ?? null, 'created_at' => $now, 'updated_at' => $now, ]; try { $id = DB::table('pargar_sync_logs')->insertGetId($row); $log = PargarSyncLog::find($id); if ($log !== null) { Log::warning('Pargar wallet document saved via raw insert', [ 'pargar_sync_log_id' => $id, 'withdraw_id' => $row['withdraw_id'], 'customer_id' => $row['customer_id'], ]); return $log; } } catch (\Throwable $e) { report($e); Log::critical('Pargar wallet document queue failed completely', [ 'customer_id' => $row['customer_id'], 'withdraw_id' => $row['withdraw_id'], 'type' => $row['type'], 'error' => $e->getMessage(), ]); throw $e; } throw new \RuntimeException( 'ساخت سند کیف پرگار ناموفق بود (withdraw_id=' . ($row['withdraw_id'] ?? 'null') . ')' ); } /** * صف‌کردن سند برای واریز/برداشت‌هایی که لاگ پرگار ندارند. * * @param iterable $rows * @return int تعداد سند تازه‌ساخته یا موجود */ public function queueMissingCashDocuments(Customer $customer, iterable $rows, string $source = 'auto_missing'): int { if (! $this->isSyncEnabled() || ! $this->hasSyncTable()) { return 0; } $queued = 0; foreach ($rows as $row) { $withdrawId = (int) ($row->withdraw_id ?? 0); $cash = (float) ($row->cash ?? 0); if ($withdrawId <= 0 || ! self::isWalletDocAmount($cash)) { continue; } $log = $this->queueWalletDocument($customer, 'cash', $cash, [ 'withdraw_id' => $withdrawId, 'note' => $row->note ?? null, 'source' => $source, ]); if ($log !== null) { $queued++; } } return $queued; } /** * صف‌کردن سند طلا برای حرکت‌هایی که لاگ پرگار ندارند (جایزه ریز و …). * * @param iterable $rows */ public function queueMissingGoldDocuments(Customer $customer, iterable $rows, string $source = 'auto_missing_gold'): int { if (! $this->isSyncEnabled() || ! $this->hasSyncTable()) { return 0; } $queued = 0; foreach ($rows as $row) { $withdrawId = (int) ($row->withdraw_id ?? 0); $gold = (float) ($row->gold ?? 0); if ($withdrawId <= 0 || ! self::isWalletDocAmount($gold)) { continue; } $log = $this->queueWalletDocument($customer, 'gold', $gold, [ 'withdraw_id' => $withdrawId, 'note' => $row->note ?? null, 'source' => $source, ]); if ($log !== null) { $queued++; } } return $queued; } /** * @param array{ * payment_kind?: string, * payment_product_id?: int|null, * product_id?: int|null, * number?: float|null * } $context */ public function recordTrade(?Order $order, bool $fromCart = false, array $context = []): ?PargarSyncLog { if (! $order || ! $order->id || ! $this->isSyncEnabled()) { return null; } $existing = PargarSyncLog::where('order_id', $order->id)->first(); if ($existing) { return $existing; } $direction = (int) ($order->direction ?? 0); $desc = \App\Http\Controllers\Controller::accountingShopTradeDesc($direction, $fromCart); $paymentKind = $context['payment_kind'] ?? $this->inferPaymentKindFromContext($context); $payload = [ 'handler' => 'exportOrder', 'direction' => $direction, 'price' => $order->price, 'total_price' => $order->total_price, 'payment_kind' => $paymentKind, 'payment_product_id' => $context['payment_product_id'] ?? null, 'product_id' => $context['product_id'] ?? $order->product_id ?? null, 'number' => $context['number'] ?? null, 'payment_gold' => $context['payment_gold'] ?? null, 'from_cart' => $fromCart, ]; // تهاتر بین دو محصول غیر اصلی — روی ریال/طلای پرگار اثر ندارد if ($paymentKind === self::PAYMENT_OTHER_PRODUCT) { $order->pargar_confirmed_at = now(); $order->save(); return PargarSyncLog::create([ 'customer_id' => $order->customer_id, 'amount' => $order->amount, 'desc' => $desc . ' (تهاتر محصول — بدون سند ریال/طلای پرگار)', 'order_id' => $order->id, 'date' => $order->pay_at ?? $order->created_at, 'type' => self::TYPE_ORDER, 'sync_status' => self::STATUS_SUCCESS, 'pargar_ref' => 'skip:barter', 'data' => json_encode($payload, JSON_UNESCAPED_UNICODE), ]); } return PargarSyncLog::create([ 'customer_id' => $order->customer_id, 'amount' => $order->amount, 'desc' => $desc, 'order_id' => $order->id, 'date' => $order->pay_at ?? $order->created_at, 'type' => self::TYPE_ORDER, 'sync_status' => self::STATUS_PENDING, 'data' => json_encode($payload, JSON_UNESCAPED_UNICODE), ]); } /** * @param array{payment_kind?: string, payment_product_id?: int|null} $context */ public function inferPaymentKindFromContext(array $context): string { if (! empty($context['payment_kind'])) { return (string) $context['payment_kind']; } $paymentProductId = (int) ($context['payment_product_id'] ?? 0); if ($paymentProductId <= 0) { return self::PAYMENT_CASH; } return app(ConfigService::class)->isMainWalletProduct($paymentProductId) ? self::PAYMENT_MAIN_GOLD : self::PAYMENT_OTHER_PRODUCT; } public function confirmOrders(array $orderIds): array { $confirmed = 0; $customerIds = []; foreach ($orderIds as $orderId) { $id = trim((string) $orderId); if ($id === '') { continue; } if (str_starts_with(strtoupper($id), 'W-')) { $withdrawId = (int) substr($id, 2); if ($withdrawId <= 0) { continue; } $log = PargarSyncLog::query() ->where('withdraw_id', $withdrawId) ->whereIn('type', [self::TYPE_WALLET_CASH, self::TYPE_WALLET_GOLD]) ->where('sync_status', self::STATUS_PENDING) ->latest('id') ->first(); if ($log) { $this->markSuccess($log, $id, ['OrderId' => $id]); if ($log->customer_id) { $customerIds[(int) $log->customer_id] = true; } $confirmed++; } continue; } if (str_starts_with(strtoupper($id), 'WLOG-')) { $logId = (int) substr($id, 5); if ($logId <= 0) { continue; } $log = PargarSyncLog::query() ->where('id', $logId) ->whereIn('type', [self::TYPE_WALLET_CASH, self::TYPE_WALLET_GOLD]) ->where('sync_status', self::STATUS_PENDING) ->first(); if ($log) { $this->markSuccess($log, $id, ['OrderId' => $id]); if ($log->customer_id) { $customerIds[(int) $log->customer_id] = true; } $confirmed++; } continue; } $order = Order::find($id); if (! $order) { continue; } $order->pargar_confirmed_at = now(); $order->save(); $log = PargarSyncLog::where('order_id', $order->id)->latest('id')->first(); if ($log) { $this->markSuccess($log, (string) $order->id, ['OrderId' => (string) $order->id]); } if ($order->customer_id) { $customerIds[(int) $order->customer_id] = true; } elseif ($log?->customer_id) { $customerIds[(int) $log->customer_id] = true; } $confirmed++; } return [ 'confirmed' => $confirmed, 'customer_ids' => array_keys($customerIds), ]; } /** * شناسه‌ای که سند با آن در GET /pargar/orders بیرون می‌رود. */ public function outboundExportId(PargarSyncLog $log): string { if ($log->type === self::TYPE_ORDER) { return (string) ($log->order_id ?? $log->id); } $withdrawId = (int) ($log->withdraw_id ?? 0); return $withdrawId > 0 ? 'W-'.$withdrawId : 'WLOG-'.$log->id; } /** * اسنادی که در نوبت GET /pargar/orders هستند و پرگار هنوز تأییدشان نکرده است. * * تا وقتی این‌ها معلق‌اند، ماندهٔ پرگارِ همان مشتری روی کیف اعمال نمی‌شود؛ پس باید * در پنل دیده شوند، نه اینکه با شکایت مشتری کشف شوند. * * @return array{ * count: int, * customers: int, * waited_minutes: int, * oldest_at: string|null, * last_confirmed_at: string|null, * threshold_minutes: int, * rows: list> * } */ public function stuckOutboundDocuments(int $olderThanMinutes = 15, int $limit = 50): array { $threshold = now()->subMinutes(max(0, $olderThanMinutes)); $logs = PargarSyncLog::with('customer') ->whereIn('type', [self::TYPE_ORDER, self::TYPE_WALLET_CASH, self::TYPE_WALLET_GOLD]) ->where('sync_status', self::STATUS_PENDING) ->where('created_at', '<=', $threshold) ->where(function ($q) { // سند کیف تا وقتی pending است در هر fetch فرستاده می‌شود؛ سفارش فقط // وقتی هنوز در نوبت خروجی است (sts=2 و تأییدنشده). $q->whereIn('type', [self::TYPE_WALLET_CASH, self::TYPE_WALLET_GOLD]) ->orWhereExists(function ($sub) { $sub->select(DB::raw(1)) ->from('orders') ->whereColumn('orders.id', 'pargar_sync_logs.order_id') ->where('orders.sts', 2) ->whereNull('orders.pargar_confirmed_at'); }); }) ->orderBy('id') ->get(); $oldest = $logs->first()?->created_at; return [ 'count' => $logs->count(), 'customers' => $logs->pluck('customer_id')->filter()->unique()->count(), 'waited_minutes' => $oldest ? (int) $oldest->diffInMinutes(now()) : 0, 'oldest_at' => $oldest?->toDateTimeString(), 'last_confirmed_at' => $this->lastOutboundConfirmedAt(), 'threshold_minutes' => max(0, $olderThanMinutes), 'rows' => $logs->take($limit)->map(fn (PargarSyncLog $log) => [ 'id' => (int) $log->id, 'export_id' => $this->outboundExportId($log), 'type' => (string) $log->type, 'amount' => $log->amount === null ? null : (float) $log->amount, 'desc' => (string) ($log->desc ?? ''), 'order_id' => $log->order_id ? (int) $log->order_id : null, 'withdraw_id' => $log->withdraw_id ? (int) $log->withdraw_id : null, 'customer_id' => $log->customer_id ? (int) $log->customer_id : null, 'customer_name' => $log->customer?->name, 'customer_mobile' => $log->customer?->mobile, 'pargar_account_id' => $log->customer?->pargar_account_id, 'created_at' => $log->created_at?->toDateTimeString(), 'waited_minutes' => $log->created_at ? (int) $log->created_at->diffInMinutes(now()) : 0, ])->values()->all(), ]; } /** آخرین باری که پرگار سندی از پنل را تأیید کرده است */ private function lastOutboundConfirmedAt(): ?string { $query = PargarSyncLog::query() ->whereIn('type', [self::TYPE_ORDER, self::TYPE_WALLET_CASH, self::TYPE_WALLET_GOLD]) ->where('sync_status', self::STATUS_SUCCESS); $confirmedAt = PargarSyncLog::hasConfirmedAtColumn() ? $query->clone()->max('confirmed_at') : null; return $confirmedAt ?: $query->max('updated_at'); } public function retry(PargarSyncLog $log): array { if (! $this->isFailed($log) && ! empty($log->pargar_ref)) { return ['ok' => false, 'message' => 'این سند قبلاً در پرگار ثبت شده است']; } $payload = json_decode((string) $log->data, true) ?: []; $handler = $payload['handler'] ?? $this->inferHandler($log); if ($handler === 'exportOrder' && $log->order_id) { Order::where('id', $log->order_id)->update(['pargar_confirmed_at' => null]); PargarSyncLog::where('id', $log->id)->update([ 'sync_status' => self::STATUS_PENDING, 'sync_log' => null, 'pargar_ref' => null, ]); $log->refresh(); return [ 'ok' => true, 'message' => 'سفارش دوباره در صف ارسال به پرگار قرار گرفت', 'pargar' => $log, ]; } if ($handler === 'exportCustomer' && $log->customer_id) { PargarSyncLog::where('id', $log->id)->update([ 'sync_status' => self::STATUS_PENDING, 'sync_log' => null, 'pargar_ref' => null, ]); $log->refresh(); return [ 'ok' => true, 'message' => 'مشتری دوباره در صف دریافت پرگار قرار گرفت', 'pargar' => $log, ]; } if ($handler === 'exportWallet') { PargarSyncLog::where('id', $log->id)->update([ 'sync_status' => self::STATUS_PENDING, 'sync_log' => null, 'pargar_ref' => null, ]); $log->refresh(); return [ 'ok' => true, 'message' => 'سند کیف دوباره در صف ارسال به پرگار قرار گرفت', 'pargar' => $log, ]; } return [ 'ok' => false, 'message' => $log->sync_log ?: 'ارسال مجدد به پرگار ناموفق بود', 'pargar' => $log, ]; } private function inferHandler(PargarSyncLog $log): string { if ($log->order_id) { return 'exportOrder'; } if ($log->type === self::TYPE_CUSTOMER) { return 'exportCustomer'; } if (in_array($log->type, [self::TYPE_WALLET_CASH, self::TYPE_WALLET_GOLD], true)) { return 'exportWallet'; } if ($log->type === self::TYPE_MANDE) { return 'importMande'; } return 'importDocument'; } public function isSyncEnabled(): bool { $config = app(ConfigService::class); return $config->isPargarAccounting() && trim($config->getString('pargar_username', '')) !== '' && trim($config->getString('pargar_password', '')) !== ''; } public function hasSyncTable(): bool { return Schema::hasTable('pargar_sync_logs'); } public function pargarItemIdRial(): int { $raw = trim(app(ConfigService::class)->getString('pargar_item_id_rial', '0')); return max(0, (int) $raw); } public function pargarItemIdGold(): int { $raw = trim(app(ConfigService::class)->getString('pargar_item_id_gold', '11')); $id = (int) $raw; return $id > 0 ? $id : 11; } public function mainWalletGoldItemName(): string { $product = app(ConfigService::class)->mainWalletRepresentativeProduct(); $title = trim((string) ($product?->title ?? '')); return $title !== '' ? $title : 'آبشده'; } /** * خلاصهٔ اسناد ورودی از پرگار برای نمایش در جدول ادمین. * * @param array $data * @return array{ * desc: string, * amount: float|null, * amount_unit: 'gold'|'rial'|null, * tx_count: int, * account_name: string|null * } */ public function summarizeInboundDocument(array $data): array { $accountName = trim((string) ($data['accountName'] ?? '')); $transactions = is_array($data['transactions'] ?? null) ? $data['transactions'] : []; $txLines = []; $maxGold = 0.0; $maxRial = 0.0; $txCount = 0; foreach ($transactions as $tx) { if (! is_array($tx)) { continue; } $txCount++; $desc = trim((string) ($tx['desc'] ?? '')); $docType = trim((string) ($tx['docType'] ?? '')); $line = $desc !== '' ? $desc : ($docType !== '' ? $docType : null); if ($line !== null) { $txLines[] = $line; } $maxGold = max( $maxGold, abs($this->parsePargarNumber($tx['bedGold'] ?? 0)), abs($this->parsePargarNumber($tx['besGold'] ?? 0)) ); $maxRial = max( $maxRial, abs($this->parsePargarNumber($tx['bedRial'] ?? 0)), abs($this->parsePargarNumber($tx['besRial'] ?? 0)) ); } if ($txLines !== []) { $unique = array_values(array_unique($txLines)); $desc = implode(' | ', array_slice($unique, 0, 3)); if ($txCount > count(array_slice($unique, 0, 3))) { $desc .= ' (+' . ($txCount - min(3, count($unique))) . ' سند دیگر)'; } } elseif ($accountName !== '') { $desc = "اسناد {$accountName} از پرگار"; } else { $desc = $txCount > 0 ? "دریافت {$txCount} سند از پرگار" : 'دریافت اسناد از پرگار'; } $amount = null; $amountUnit = null; if ($maxGold >= self::WALLET_DOC_EPSILON) { $amount = $maxGold; $amountUnit = 'gold'; } elseif ($maxRial >= self::WALLET_DOC_EPSILON) { $amount = $maxRial; $amountUnit = 'rial'; } return [ 'desc' => $desc, 'amount' => $amount, 'amount_unit' => $amountUnit, 'tx_count' => $txCount, 'account_name' => $accountName !== '' ? $accountName : null, ]; } private function parsePargarNumber(mixed $value): float { if ($value === null || $value === '') { return 0.0; } return (float) str_replace('/', '.', (string) $value); } public function resolveOrderProduct(?Order $order, ?array $payload = null): ?Product { $productId = (int) ($payload['product_id'] ?? $order?->product_id ?? 0); if ($productId <= 0) { return null; } return Product::find($productId); } }