From 6b5b490e2b0f478755783dcfa9b76ba9d902bd3e Mon Sep 17 00:00:00 2001 From: adonis Date: Thu, 27 Aug 2026 16:16:20 +0330 Subject: [PATCH] added accounting failing handles --- .../Controllers/Admin/ProductController.php | 9 + .../Controllers/Admin/TahesabController.php | 52 +++ .../Controllers/Admin/ZarinController.php | 52 +++ app/Models/Product.php | 23 ++ app/Services/TahesabSyncService.php | 387 ++++++++++++++++++ app/Services/ZarinSyncService.php | 378 +++++++++++++++++ .../2026_08_27_000001_add_products_karat.php | 28 ++ database/sql/products_karat_patch.sql | 22 + routes/admin.php | 8 + 9 files changed, 959 insertions(+) create mode 100644 database/migrations/2026_08_27_000001_add_products_karat.php create mode 100644 database/sql/products_karat_patch.sql diff --git a/app/Http/Controllers/Admin/ProductController.php b/app/Http/Controllers/Admin/ProductController.php index fcda7ce..b73f057 100644 --- a/app/Http/Controllers/Admin/ProductController.php +++ b/app/Http/Controllers/Admin/ProductController.php @@ -850,6 +850,15 @@ class ProductController extends SuperController $data['description'] = $desc === '' ? null : $desc; } + if (!Product::hasKaratColumn()) { + unset($data['karat']); + } elseif (array_key_exists('karat', $data)) { + $karat = is_string($data['karat']) || is_numeric($data['karat']) + ? trim((string) $data['karat']) + : ''; + $data['karat'] = $karat === '' ? null : mb_substr($karat, 0, 32); + } + if (!Product::hasOverrideTradingHoursColumn()) { unset($data['override_trading_hours']); } elseif (array_key_exists('override_trading_hours', $data)) { diff --git a/app/Http/Controllers/Admin/TahesabController.php b/app/Http/Controllers/Admin/TahesabController.php index a052b27..2742f0f 100644 --- a/app/Http/Controllers/Admin/TahesabController.php +++ b/app/Http/Controllers/Admin/TahesabController.php @@ -41,7 +41,19 @@ class TahesabController extends Controller public function table(Request $request) { + $view = (string) $request->input('view', 'all'); $query = Tahesab::with('customer')->orderByDesc('id'); + + if ($view === 'ok') { + $query->whereNotNull('tahesab_id')->where('tahesab_id', '!=', ''); + } elseif ($view === 'failed') { + $query->where(function ($q) { + $q->where(function ($inner) { + $inner->whereNull('tahesab_id')->orWhere('tahesab_id', ''); + })->orWhere('sync_status', 'failed'); + }); + } + \App\DB\Te\TableEngine::create($query, $request) ->initSearch(['desc', 'type']) ->select(['*']) @@ -392,6 +404,46 @@ class TahesabController extends Controller return response()->json($result, ($result['ok'] ?? false) ? 200 : 422); } + public function pendingSummary(Request $request) + { + $customerId = $request->filled('customer_id') ? (int) $request->input('customer_id') : null; + + return response()->json($this->sync()->pendingSummary($customerId)); + } + + public function pendingDocs(Request $request) + { + $customerId = $request->filled('customer_id') ? (int) $request->input('customer_id') : null; + $limit = max(1, min(500, (int) $request->input('limit', 200))); + + return response()->json([ + 'rows' => $this->sync()->pendingDocs($customerId, $limit), + 'summary' => $this->sync()->pendingSummary($customerId), + ]); + } + + public function syncOne(Request $request) + { + $type = (string) $request->input('type', ''); + $id = (int) $request->input('id', 0); + if ($id <= 0 || ! in_array($type, ['row', 'withdraw', 'order'], true)) { + return response()->json(['ok' => false, 'message' => 'پارامتر نامعتبر'], 422); + } + + $result = $this->sync()->syncOne($type, $id); + + return response()->json($result, ($result['ok'] ?? false) ? 200 : 422); + } + + public function syncAllPending(Request $request) + { + $limit = max(1, min(200, (int) $request->input('limit', 80))); + $customerId = $request->filled('customer_id') ? (int) $request->input('customer_id') : null; + $result = $this->sync()->syncAllPending($limit, $customerId); + + return response()->json($result, ($result['ok'] ?? false) ? 200 : 422); + } + public function registerCustomer($customerId) { $customer = Customer::findOrFail($customerId); diff --git a/app/Http/Controllers/Admin/ZarinController.php b/app/Http/Controllers/Admin/ZarinController.php index fdfb120..fc00cb7 100644 --- a/app/Http/Controllers/Admin/ZarinController.php +++ b/app/Http/Controllers/Admin/ZarinController.php @@ -861,7 +861,19 @@ class ZarinController extends Controller public function table(Request $request) { + $view = (string) $request->input('view', 'all'); $query = Zarin::with('customer')->orderByDesc('id'); + + if ($view === 'ok') { + $query->whereNotNull('zarin_id')->where('zarin_id', '!=', ''); + } elseif ($view === 'failed') { + $query->where(function ($q) { + $q->where(function ($inner) { + $inner->whereNull('zarin_id')->orWhere('zarin_id', ''); + })->orWhere('sync_status', 'failed'); + }); + } + \App\DB\Te\TableEngine::create($query, $request) ->initSearch(['desc', 'type']) ->select(['*']) @@ -1331,6 +1343,46 @@ class ZarinController extends Controller return response()->json($result, ($result['ok'] ?? false) ? 200 : 422); } + public function pendingSummary(Request $request) + { + $customerId = $request->filled('customer_id') ? (int) $request->input('customer_id') : null; + + return response()->json($this->sync()->pendingSummary($customerId)); + } + + public function pendingDocs(Request $request) + { + $customerId = $request->filled('customer_id') ? (int) $request->input('customer_id') : null; + $limit = max(1, min(500, (int) $request->input('limit', 200))); + + return response()->json([ + 'rows' => $this->sync()->pendingDocs($customerId, $limit), + 'summary' => $this->sync()->pendingSummary($customerId), + ]); + } + + public function syncOne(Request $request) + { + $type = (string) $request->input('type', ''); + $id = (int) $request->input('id', 0); + if ($id <= 0 || ! in_array($type, ['row', 'withdraw', 'order'], true)) { + return response()->json(['ok' => false, 'message' => 'پارامتر نامعتبر'], 422); + } + + $result = $this->sync()->syncOne($type, $id); + + return response()->json($result, ($result['ok'] ?? false) ? 200 : 422); + } + + public function syncAllPending(Request $request) + { + $limit = max(1, min(200, (int) $request->input('limit', 80))); + $customerId = $request->filled('customer_id') ? (int) $request->input('customer_id') : null; + $result = $this->sync()->syncAllPending($limit, $customerId); + + return response()->json($result, ($result['ok'] ?? false) ? 200 : 422); + } + public function registerCustomer($customerId) { $customer = Customer::findOrFail($customerId); diff --git a/app/Models/Product.php b/app/Models/Product.php index e289ce9..e02e42d 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -30,6 +30,7 @@ class Product extends Model private static ?bool $cashFeePercentModeColumnsExist = null; private static ?bool $physicalDeliveryColumnsExist = null; private static ?bool $descriptionColumnExists = null; + private static ?bool $karatColumnExists = null; private static ?bool $tvPriceFormulaColumnsExist = null; private static ?bool $priceFormulaColumnsExist = null; @@ -73,6 +74,9 @@ class Product extends Model if (!static::hasDescriptionColumn()) { unset($product->attributes['description']); } + if (!static::hasKaratColumn()) { + unset($product->attributes['karat']); + } if (!static::hasOverrideTradingHoursColumn()) { unset($product->attributes['override_trading_hours']); } @@ -407,6 +411,24 @@ class Product extends Model return static::$descriptionColumnExists; } + public static function hasKaratColumn(): bool + { + if (static::$karatColumnExists !== null) { + return static::$karatColumnExists; + } + + try { + static::$karatColumnExists = Schema::hasColumn( + (new static)->getTable(), + 'karat' + ); + } catch (\Throwable) { + static::$karatColumnExists = false; + } + + return static::$karatColumnExists; + } + public function orderLimitsEnabled(): bool { if (!static::hasOrderLimitColumns() || !($this->order_limit_enabled ?? false)) { @@ -510,6 +532,7 @@ class Product extends Model 'bubble', 'inventory', 'description', + 'karat', 'direct_price', 'app_price', // روشن: قیمت واحد از قیمت زندهٔ اپ هر گرم × وزن محاسبه می‌شود 'price_formula_enabled', diff --git a/app/Services/TahesabSyncService.php b/app/Services/TahesabSyncService.php index 8c5a82e..baaf5ef 100644 --- a/app/Services/TahesabSyncService.php +++ b/app/Services/TahesabSyncService.php @@ -205,6 +205,393 @@ class TahesabSyncService && trim($config->getString('tahesab_db', '')) !== ''; } + /** ردیف‌های محلی که هنوز در ته‌حساب ثبت نشده‌اند */ + public function unsyncedRowsQuery() + { + return Tahesab::query()->where(function ($q) { + $q->where(function ($inner) { + $inner->whereNull('tahesab_id')->orWhere('tahesab_id', ''); + })->orWhere('sync_status', self::STATUS_FAILED); + }); + } + + /** + * خلاصهٔ سندهای جاافتاده / ناموفق برای بنر ادمین. + * + * @return array{failed_rows:int,missing_withdraws:int,missing_orders:int,total:int} + */ + public function pendingSummary(?int $customerId = null): array + { + $failedRows = (int) $this->unsyncedRowsQuery() + ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) + ->count(); + $missingWithdraws = count($this->missingWithdrawIds(200, $customerId)); + $missingOrders = count($this->missingOrderIds(200, $customerId)); + + return [ + 'failed_rows' => $failedRows, + 'missing_withdraws' => $missingWithdraws, + 'missing_orders' => $missingOrders, + 'total' => $failedRows + $missingWithdraws + $missingOrders, + ]; + } + + /** + * لیست یکپارچهٔ خطا خورده + جاافتاده برای تب ادمین. + * + * @return list> + */ + public function pendingDocs(?int $customerId = null, int $limit = 200): array + { + $limit = max(1, min(500, $limit)); + $rows = []; + + $local = $this->unsyncedRowsQuery() + ->with('customer') + ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) + ->orderByDesc('id') + ->limit($limit) + ->get(); + + foreach ($local as $row) { + $arr = $row->toArray(); + $arr['issue_kind'] = 'failed'; + $arr['sync_target'] = ['type' => 'row', 'id' => (int) $row->id]; + $rows[] = $arr; + } + + $remaining = $limit - count($rows); + if ($remaining > 0) { + $withdrawIds = $this->missingWithdrawIds($remaining, $customerId); + if ($withdrawIds !== []) { + $withdraws = \App\Models\Withdraw::query() + ->with('customer') + ->whereIn('id', $withdrawIds) + ->orderByDesc('id') + ->get(); + foreach ($withdraws as $w) { + $cash = (float) ($w->cash ?? 0); + $gold = (float) ($w->gold ?? 0); + $isGold = abs($gold) > 1e-9; + $rows[] = [ + 'id' => null, + 'customer_id' => $w->customer_id, + 'customer' => $w->customer, + 'amount' => $isGold ? abs($gold) : abs($cash), + 'type' => $isGold ? 'طلا' : 'ريال', + 'desc' => 'شارژ/برداشت بدون سند محلی #'.$w->id.($w->note ? ' — '.$w->note : ''), + 'withdraw_id' => (int) $w->id, + 'order_id' => null, + 'sync_status' => 'missing', + 'sync_log' => 'سند در ته‌حساب ساخته نشده (جاافتاده)', + 'tahesab_id' => null, + 'created_at' => optional($w->created_at)?->toDateTimeString(), + 'issue_kind' => 'missing', + 'sync_target' => ['type' => 'withdraw', 'id' => (int) $w->id], + ]; + } + } + } + + $remaining = $limit - count($rows); + if ($remaining > 0) { + $orderIds = $this->missingOrderIds($remaining, $customerId); + if ($orderIds !== []) { + $orders = \App\Models\Order::query() + ->with(['customer', 'product']) + ->whereIn('id', $orderIds) + ->orderByDesc('id') + ->get(); + foreach ($orders as $order) { + $direction = (int) ($order->direction ?? 0); + $dirLabel = $direction === 1 ? 'خرید' : ($direction === 2 ? 'فروش' : 'سفارش'); + $productTitle = $order->product->title ?? 'محصول'; + $rows[] = [ + 'id' => null, + 'customer_id' => $order->customer_id, + 'customer' => $order->customer, + 'amount' => (float) ($order->amount ?? 0), + 'type' => 'طلا', + 'desc' => $dirLabel.' '.$productTitle.' بدون سند محلی #'.$order->id, + 'withdraw_id' => null, + 'order_id' => (int) $order->id, + 'sync_status' => 'missing', + 'sync_log' => 'سند در ته‌حساب ساخته نشده (جاافتاده)', + 'tahesab_id' => null, + 'created_at' => optional($order->pay_at ?? $order->created_at)?->toDateTimeString(), + 'issue_kind' => 'missing', + 'sync_target' => ['type' => 'order', 'id' => (int) $order->id], + ]; + } + } + } + + usort($rows, function ($a, $b) { + return strcmp((string) ($b['created_at'] ?? ''), (string) ($a['created_at'] ?? '')); + }); + + return $rows; + } + + /** + * ثبت یک مورد: ردیف محلی / شارژ جاافتاده / سفارش جاافتاده. + * + * @return array{ok:bool,message:string} + */ + public function syncOne(string $type, int $id): array + { + if (! $this->isSyncEnabled()) { + return ['ok' => false, 'message' => 'همگام‌سازی ته‌حساب فعال نیست یا تنظیمات ناقص است']; + } + + $controller = app(TahesabController::class); + + try { + if ($type === 'row') { + $row = Tahesab::with('customer')->findOrFail($id); + + return $this->retry($row); + } + if ($type === 'withdraw') { + $ok = $this->backfillWithdraw($controller, $id); + + return ['ok' => $ok, 'message' => $ok ? 'سند شارژ/برداشت ثبت شد' : 'ارسال شارژ/برداشت ناموفق بود']; + } + if ($type === 'order') { + $ok = $this->backfillOrder($controller, $id); + + return ['ok' => $ok, 'message' => $ok ? 'سند سفارش ثبت شد' : 'ارسال سفارش ناموفق بود']; + } + + return ['ok' => false, 'message' => 'نوع نامعتبر است']; + } catch (\Throwable $e) { + report($e); + + return ['ok' => false, 'message' => $e->getMessage()]; + } + } + + /** + * ارسال مجدد همهٔ ناموفق‌ها + ساخت و ارسال سندهای جاافتاده از شارژ/سفارش. + * + * @return array{ok:bool,retried:int,backfilled_withdraws:int,backfilled_orders:int,failed:int,errors:list,summary:array} + */ + public function syncAllPending(int $limit = 80, ?int $customerId = null): array + { + if (! $this->isSyncEnabled()) { + return [ + 'ok' => false, + 'retried' => 0, + 'backfilled_withdraws' => 0, + 'backfilled_orders' => 0, + 'failed' => 0, + 'errors' => ['همگام‌سازی ته‌حساب فعال نیست یا تنظیمات ناقص است'], + 'summary' => $this->pendingSummary($customerId), + ]; + } + + $retried = 0; + $backfilledWithdraws = 0; + $backfilledOrders = 0; + $failed = 0; + $errors = []; + $controller = app(TahesabController::class); + $used = 0; + + $rows = $this->unsyncedRowsQuery() + ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) + ->orderBy('id') + ->limit($limit) + ->get(); + foreach ($rows as $row) { + $used++; + try { + $result = $this->retry($row); + if ($result['ok'] ?? false) { + $retried++; + } else { + $failed++; + $errors[] = 'سند #'.$row->id.': '.($result['message'] ?? 'ناموفق'); + } + } catch (\Throwable $e) { + $failed++; + $errors[] = 'سند #'.$row->id.': '.$e->getMessage(); + report($e); + } + } + + foreach ($this->missingWithdrawIds(max(0, $limit - $used), $customerId) as $withdrawId) { + $used++; + try { + $ok = $this->backfillWithdraw($controller, (int) $withdrawId); + if ($ok) { + $backfilledWithdraws++; + } else { + $failed++; + $errors[] = 'شارژ/برداشت #'.$withdrawId.': ارسال به ته‌حساب ناموفق'; + } + } catch (\Throwable $e) { + $failed++; + $errors[] = 'شارژ/برداشت #'.$withdrawId.': '.$e->getMessage(); + report($e); + } + } + + foreach ($this->missingOrderIds(max(0, $limit - $used), $customerId) as $orderId) { + $used++; + try { + $ok = $this->backfillOrder($controller, (int) $orderId); + if ($ok) { + $backfilledOrders++; + } else { + $failed++; + $errors[] = 'سفارش #'.$orderId.': ارسال به ته‌حساب ناموفق'; + } + } catch (\Throwable $e) { + $failed++; + $errors[] = 'سفارش #'.$orderId.': '.$e->getMessage(); + report($e); + } + } + + return [ + 'ok' => $failed === 0, + 'retried' => $retried, + 'backfilled_withdraws' => $backfilledWithdraws, + 'backfilled_orders' => $backfilledOrders, + 'failed' => $failed, + 'errors' => array_slice($errors, 0, 20), + 'summary' => $this->pendingSummary($customerId), + ]; + } + + /** @return list */ + private function missingWithdrawIds(int $limit, ?int $customerId = null): array + { + if ($limit <= 0 || ! Schema::hasTable('withdraws')) { + return []; + } + + $linked = Tahesab::query() + ->whereNotNull('withdraw_id') + ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) + ->pluck('withdraw_id') + ->all(); + + return \App\Models\Withdraw::query() + ->where('sts', 4) + ->where(function ($q) { + $q->where('cash', '!=', 0)->orWhere('gold', '!=', 0); + }) + ->where(function ($q) { + $q->whereNull('note') + ->orWhere('note', 'not like', 'fix:intval-gold-loss%'); + }) + ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) + ->when($linked !== [], fn ($q) => $q->whereNotIn('id', $linked)) + ->orderBy('id') + ->limit($limit) + ->pluck('id') + ->map(fn ($id) => (int) $id) + ->all(); + } + + /** @return list */ + private function missingOrderIds(int $limit, ?int $customerId = null): array + { + if ($limit <= 0 || ! Schema::hasTable('orders')) { + return []; + } + + $linked = Tahesab::query() + ->whereNotNull('order_id') + ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) + ->pluck('order_id') + ->all(); + + return \App\Models\Order::query() + ->where('sts', 2) + ->where('payment_sts', 4) + ->whereIn('direction', [1, 2]) + ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) + ->when($linked !== [], fn ($q) => $q->whereNotIn('id', $linked)) + ->orderBy('id') + ->limit($limit) + ->pluck('id') + ->map(fn ($id) => (int) $id) + ->all(); + } + + private function backfillWithdraw(TahesabController $controller, int $withdrawId): bool + { + $withdraw = \App\Models\Withdraw::query()->find($withdrawId); + if (! $withdraw || ! $withdraw->customer_id) { + return false; + } + + $cash = (float) ($withdraw->cash ?? 0); + $gold = (float) ($withdraw->gold ?? 0); + + if (abs($cash) > 1e-9) { + $result = $controller->wallet([ + 'customer_id' => (int) $withdraw->customer_id, + 'value' => abs($cash), + 'direction' => $cash >= 0 ? 0 : 1, + 'withdraw_id' => $withdrawId, + 'note' => $withdraw->note, + ]); + + return $result !== false; + } + + if (abs($gold) > 1e-9) { + $result = $controller->walletGold([ + 'customer_id' => (int) $withdraw->customer_id, + 'value' => abs($gold), + 'direction' => $gold >= 0 ? 1 : 0, + 'withdraw_id' => $withdrawId, + 'note' => $withdraw->note, + ]); + + return $result !== false; + } + + return false; + } + + private function backfillOrder(TahesabController $controller, int $orderId): bool + { + $order = \App\Models\Order::query()->with('product')->find($orderId); + if (! $order || ! $order->customer_id) { + return false; + } + + $product = $order->product; + $payload = [ + 'customer_id' => (int) $order->customer_id, + 'amount' => (float) $order->amount, + 'direction' => (int) $order->direction, + 'price' => (float) $order->price, + 'total_price' => (float) $order->total_price, + 'pay_at' => $order->pay_at ?? $order->created_at, + 'product_id' => $order->product_id ? (int) $order->product_id : null, + 'product' => $product ? [ + 'id' => (int) $product->id, + 'title' => $product->title, + 'group_type' => $product->group_type, + 'metal_id' => $product->metal_id, + 'amount' => $product->amount, + 'unit' => $product->unit, + 'trade_by_quantity' => $product->trade_by_quantity, + ] : ['metal_id' => 1], + 'order' => ['id' => $orderId, 'product_id' => $order->product_id], + ]; + + $result = $controller->storeTrade($payload); + + return $result !== false; + } + private function hasSyncColumns(): bool { return Schema::hasTable('tahesab') && Schema::hasColumn('tahesab', 'sync_status'); diff --git a/app/Services/ZarinSyncService.php b/app/Services/ZarinSyncService.php index bb2e9b0..cdf44fc 100644 --- a/app/Services/ZarinSyncService.php +++ b/app/Services/ZarinSyncService.php @@ -258,6 +258,384 @@ class ZarinSyncService && trim($config->getString('zarin_token', '')) !== ''; } + public function unsyncedRowsQuery() + { + return Zarin::query()->where(function ($q) { + $q->where(function ($inner) { + $inner->whereNull('zarin_id')->orWhere('zarin_id', ''); + })->orWhere('sync_status', self::STATUS_FAILED); + }); + } + + /** + * @return array{failed_rows:int,missing_withdraws:int,missing_orders:int,total:int} + */ + public function pendingSummary(?int $customerId = null): array + { + $failedRows = (int) $this->unsyncedRowsQuery() + ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) + ->count(); + $missingWithdraws = count($this->missingWithdrawIds(200, $customerId)); + $missingOrders = count($this->missingOrderIds(200, $customerId)); + + return [ + 'failed_rows' => $failedRows, + 'missing_withdraws' => $missingWithdraws, + 'missing_orders' => $missingOrders, + 'total' => $failedRows + $missingWithdraws + $missingOrders, + ]; + } + + /** + * @return list> + */ + public function pendingDocs(?int $customerId = null, int $limit = 200): array + { + $limit = max(1, min(500, $limit)); + $rows = []; + + $local = $this->unsyncedRowsQuery() + ->with('customer') + ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) + ->orderByDesc('id') + ->limit($limit) + ->get(); + + foreach ($local as $row) { + $arr = $row->toArray(); + $arr['issue_kind'] = 'failed'; + $arr['sync_target'] = ['type' => 'row', 'id' => (int) $row->id]; + $rows[] = $arr; + } + + $remaining = $limit - count($rows); + if ($remaining > 0) { + $withdrawIds = $this->missingWithdrawIds($remaining, $customerId); + if ($withdrawIds !== []) { + $withdraws = \App\Models\Withdraw::query() + ->with('customer') + ->whereIn('id', $withdrawIds) + ->orderByDesc('id') + ->get(); + foreach ($withdraws as $w) { + $cash = (float) ($w->cash ?? 0); + $gold = (float) ($w->gold ?? 0); + $isGold = abs($gold) > 1e-9; + $rows[] = [ + 'id' => null, + 'customer_id' => $w->customer_id, + 'customer' => $w->customer, + 'amount' => $isGold ? abs($gold) : abs($cash), + 'type' => $isGold ? 'طلا' : 'ريال', + 'desc' => 'شارژ/برداشت بدون سند محلی #'.$w->id.($w->note ? ' — '.$w->note : ''), + 'withdraw_id' => (int) $w->id, + 'order_id' => null, + 'sync_status' => 'missing', + 'sync_log' => 'سند در زرین ساخته نشده (جاافتاده)', + 'zarin_id' => null, + 'created_at' => optional($w->created_at)?->toDateTimeString(), + 'issue_kind' => 'missing', + 'sync_target' => ['type' => 'withdraw', 'id' => (int) $w->id], + ]; + } + } + } + + $remaining = $limit - count($rows); + if ($remaining > 0) { + $orderIds = $this->missingOrderIds($remaining, $customerId); + if ($orderIds !== []) { + $orders = \App\Models\Order::query() + ->with(['customer', 'product']) + ->whereIn('id', $orderIds) + ->orderByDesc('id') + ->get(); + foreach ($orders as $order) { + $direction = (int) ($order->direction ?? 0); + $dirLabel = $direction === 1 ? 'خرید' : ($direction === 2 ? 'فروش' : 'سفارش'); + $productTitle = $order->product->title ?? 'محصول'; + $rows[] = [ + 'id' => null, + 'customer_id' => $order->customer_id, + 'customer' => $order->customer, + 'amount' => (float) ($order->amount ?? 0), + 'type' => 'طلا', + 'desc' => $dirLabel.' '.$productTitle.' بدون سند محلی #'.$order->id, + 'withdraw_id' => null, + 'order_id' => (int) $order->id, + 'sync_status' => 'missing', + 'sync_log' => 'سند در زرین ساخته نشده (جاافتاده)', + 'zarin_id' => null, + 'created_at' => optional($order->pay_at ?? $order->created_at)?->toDateTimeString(), + 'issue_kind' => 'missing', + 'sync_target' => ['type' => 'order', 'id' => (int) $order->id], + ]; + } + } + } + + usort($rows, function ($a, $b) { + return strcmp((string) ($b['created_at'] ?? ''), (string) ($a['created_at'] ?? '')); + }); + + return $rows; + } + + /** + * @return array{ok:bool,message:string} + */ + public function syncOne(string $type, int $id): array + { + if (! $this->isSyncEnabled()) { + return ['ok' => false, 'message' => 'همگام‌سازی زرین فعال نیست یا تنظیمات ناقص است']; + } + + $controller = app(ZarinController::class); + + try { + if ($type === 'row') { + $row = Zarin::with('customer')->findOrFail($id); + + return $this->retry($row); + } + if ($type === 'withdraw') { + $ok = $this->backfillWithdraw($controller, $id); + + return ['ok' => $ok, 'message' => $ok ? 'سند شارژ/برداشت ثبت شد' : 'ارسال شارژ/برداشت ناموفق بود']; + } + if ($type === 'order') { + $ok = $this->backfillOrder($controller, $id); + + return ['ok' => $ok, 'message' => $ok ? 'سند سفارش ثبت شد' : 'ارسال سفارش ناموفق بود']; + } + + return ['ok' => false, 'message' => 'نوع نامعتبر است']; + } catch (\Throwable $e) { + report($e); + + return ['ok' => false, 'message' => $e->getMessage()]; + } + } + + /** + * @return array{ok:bool,retried:int,backfilled_withdraws:int,backfilled_orders:int,failed:int,errors:list,summary:array} + */ + public function syncAllPending(int $limit = 80, ?int $customerId = null): array + { + if (! $this->isSyncEnabled()) { + return [ + 'ok' => false, + 'retried' => 0, + 'backfilled_withdraws' => 0, + 'backfilled_orders' => 0, + 'failed' => 0, + 'errors' => ['همگام‌سازی زرین فعال نیست یا تنظیمات ناقص است'], + 'summary' => $this->pendingSummary($customerId), + ]; + } + + $retried = 0; + $backfilledWithdraws = 0; + $backfilledOrders = 0; + $failed = 0; + $errors = []; + $controller = app(ZarinController::class); + $used = 0; + + $rows = $this->unsyncedRowsQuery() + ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) + ->orderBy('id') + ->limit($limit) + ->get(); + foreach ($rows as $row) { + $used++; + try { + $result = $this->retry($row); + if ($result['ok'] ?? false) { + $retried++; + } else { + $failed++; + $errors[] = 'سند #'.$row->id.': '.($result['message'] ?? 'ناموفق'); + } + } catch (\Throwable $e) { + $failed++; + $errors[] = 'سند #'.$row->id.': '.$e->getMessage(); + report($e); + } + } + + foreach ($this->missingWithdrawIds(max(0, $limit - $used), $customerId) as $withdrawId) { + $used++; + try { + $ok = $this->backfillWithdraw($controller, (int) $withdrawId); + if ($ok) { + $backfilledWithdraws++; + } else { + $failed++; + $errors[] = 'شارژ/برداشت #'.$withdrawId.': ارسال به زرین ناموفق'; + } + } catch (\Throwable $e) { + $failed++; + $errors[] = 'شارژ/برداشت #'.$withdrawId.': '.$e->getMessage(); + report($e); + } + } + + foreach ($this->missingOrderIds(max(0, $limit - $used), $customerId) as $orderId) { + $used++; + try { + $ok = $this->backfillOrder($controller, (int) $orderId); + if ($ok) { + $backfilledOrders++; + } else { + $failed++; + $errors[] = 'سفارش #'.$orderId.': ارسال به زرین ناموفق'; + } + } catch (\Throwable $e) { + $failed++; + $errors[] = 'سفارش #'.$orderId.': '.$e->getMessage(); + report($e); + } + } + + return [ + 'ok' => $failed === 0, + 'retried' => $retried, + 'backfilled_withdraws' => $backfilledWithdraws, + 'backfilled_orders' => $backfilledOrders, + 'failed' => $failed, + 'errors' => array_slice($errors, 0, 20), + 'summary' => $this->pendingSummary($customerId), + ]; + } + + /** @return list */ + private function missingWithdrawIds(int $limit, ?int $customerId = null): array + { + if ($limit <= 0 || ! Schema::hasTable('withdraws')) { + return []; + } + + $linked = Zarin::query() + ->whereNotNull('withdraw_id') + ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) + ->pluck('withdraw_id') + ->all(); + + return \App\Models\Withdraw::query() + ->where('sts', 4) + ->where(function ($q) { + $q->where('cash', '!=', 0)->orWhere('gold', '!=', 0); + }) + ->where(function ($q) { + $q->whereNull('note') + ->orWhere('note', 'not like', 'fix:intval-gold-loss%'); + }) + ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) + ->when($linked !== [], fn ($q) => $q->whereNotIn('id', $linked)) + ->orderBy('id') + ->limit($limit) + ->pluck('id') + ->map(fn ($id) => (int) $id) + ->all(); + } + + /** @return list */ + private function missingOrderIds(int $limit, ?int $customerId = null): array + { + if ($limit <= 0 || ! Schema::hasTable('orders')) { + return []; + } + + $linked = Zarin::query() + ->whereNotNull('order_id') + ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) + ->pluck('order_id') + ->all(); + + return \App\Models\Order::query() + ->where('sts', 2) + ->where('payment_sts', 4) + ->whereIn('direction', [1, 2]) + ->when($customerId, fn ($q) => $q->where('customer_id', $customerId)) + ->when($linked !== [], fn ($q) => $q->whereNotIn('id', $linked)) + ->orderBy('id') + ->limit($limit) + ->pluck('id') + ->map(fn ($id) => (int) $id) + ->all(); + } + + private function backfillWithdraw(ZarinController $controller, int $withdrawId): bool + { + $withdraw = \App\Models\Withdraw::query()->find($withdrawId); + if (! $withdraw || ! $withdraw->customer_id) { + return false; + } + + $cash = (float) ($withdraw->cash ?? 0); + $gold = (float) ($withdraw->gold ?? 0); + + if (abs($cash) > 1e-9) { + $result = $controller->wallet([ + 'customer_id' => (int) $withdraw->customer_id, + 'value' => abs($cash), + 'direction' => $cash >= 0 ? 0 : 1, + 'withdraw_id' => $withdrawId, + 'note' => $withdraw->note, + ]); + + return $result !== false; + } + + if (abs($gold) > 1e-9) { + $result = $controller->walletGold([ + 'customer_id' => (int) $withdraw->customer_id, + 'value' => abs($gold), + 'direction' => $gold >= 0 ? 1 : 0, + 'withdraw_id' => $withdrawId, + 'note' => $withdraw->note, + ]); + + return $result !== false; + } + + return false; + } + + private function backfillOrder(ZarinController $controller, int $orderId): bool + { + $order = \App\Models\Order::query()->with('product')->find($orderId); + if (! $order || ! $order->customer_id) { + return false; + } + + $product = $order->product; + $payload = [ + 'customer_id' => (int) $order->customer_id, + 'amount' => (float) $order->amount, + 'direction' => (int) $order->direction, + 'price' => (float) $order->price, + 'total_price' => (float) $order->total_price, + 'pay_at' => $order->pay_at ?? $order->created_at, + 'product_id' => $order->product_id ? (int) $order->product_id : null, + 'product' => $product ? [ + 'id' => (int) $product->id, + 'title' => $product->title, + 'group_type' => $product->group_type, + 'metal_id' => $product->metal_id, + 'amount' => $product->amount, + 'unit' => $product->unit, + 'trade_by_quantity' => $product->trade_by_quantity, + ] : ['metal_id' => 1], + 'order' => ['id' => $orderId, 'product_id' => $order->product_id], + ]; + + $result = $controller->storeTrade($payload); + + return $result !== false; + } + private function flattenResponse(array $response): array { $data = $response['data'] ?? null; diff --git a/database/migrations/2026_08_27_000001_add_products_karat.php b/database/migrations/2026_08_27_000001_add_products_karat.php new file mode 100644 index 0000000..5ea0b02 --- /dev/null +++ b/database/migrations/2026_08_27_000001_add_products_karat.php @@ -0,0 +1,28 @@ +group(function (){ Route::post('wallet/tahesab', [TahesabController::class, 'wallet']); Route::post('tahesab/table', [TahesabController::class, 'table']); + Route::get('tahesab/pending-summary', [TahesabController::class, 'pendingSummary']); + Route::get('tahesab/pending-docs', [TahesabController::class, 'pendingDocs']); + Route::post('tahesab/sync-one', [TahesabController::class, 'syncOne']); + Route::post('tahesab/sync-all-pending', [TahesabController::class, 'syncAllPending']); Route::get('tahesab/customer-sync/status', [TahesabController::class, 'customerSyncStatus']); Route::post('tahesab/customer-sync/panel-to-tahesab', [TahesabController::class, 'syncPanelToTahesab']); Route::post('tahesab/customer-sync/import-from-tahesab', [TahesabController::class, 'importFromTahesab']); @@ -241,6 +245,10 @@ Route::middleware('auth:user')->group(function (){ Route::get('zarin/health', [ZarinController::class, 'healthCheck']); Route::post('wallet/zarin', [ZarinController::class, 'wallet']); Route::post('zarin/table', [ZarinController::class, 'table']); + Route::get('zarin/pending-summary', [ZarinController::class, 'pendingSummary']); + Route::get('zarin/pending-docs', [ZarinController::class, 'pendingDocs']); + Route::post('zarin/sync-one', [ZarinController::class, 'syncOne']); + Route::post('zarin/sync-all-pending', [ZarinController::class, 'syncAllPending']); // status با query روی بعضی سرورها QUERY_STRING خالی است — مسیر جدا برای لیست زرین Route::get('zarin/customer-sync/status-with-zarin', [ZarinController::class, 'customerSyncStatusWithZarin']); Route::match(['get', 'post'], 'zarin/customer-sync/status', [ZarinController::class, 'customerSyncStatus']);