diff --git a/app/Http/Controllers/Admin/DashboardController.php b/app/Http/Controllers/Admin/DashboardController.php index 28c970b..473b764 100644 --- a/app/Http/Controllers/Admin/DashboardController.php +++ b/app/Http/Controllers/Admin/DashboardController.php @@ -773,7 +773,7 @@ class DashboardController extends Controller report($e); return response()->json([ - 'message' => 'خواندن دفتر همکار ناموفق بود: ' . $e->getMessage(), + 'message' => 'محاسبه پوشش همکار ناموفق بود: ' . $e->getMessage(), ], 422); } diff --git a/app/Services/MontzarReconcileService.php b/app/Services/MontzarReconcileService.php index 756816e..84d236d 100644 --- a/app/Services/MontzarReconcileService.php +++ b/app/Services/MontzarReconcileService.php @@ -9,7 +9,8 @@ use Hekmatinasser\Verta\Verta; use Illuminate\Support\Facades\Schema; /** - * تطبیق دفتر اکانت روی نود همکار (Montzar) با سفارش‌های مچ‌شدهٔ اپ. + * حساب پوشش همکار برای خرید/فروش آبشدهٔ کیف اصلی — اول از روی سفارش‌های اپ، + * و در صورت دسترسی، مقایسهٔ اختیاری با دفتر زندهٔ نود Montzar. * ربطی به ته‌حساب / زرین / پرگار ندارد. */ class MontzarReconcileService @@ -31,118 +32,166 @@ class MontzarReconcileService $start = Carbon::parse($startDate)->startOfDay(); $end = Carbon::parse($endDate)->endOfDay(); - $mandehRaw = $this->montzar->fetchColleagueLedgerMandeh(); - $mandeh = $this->normalizeMandeh($mandehRaw); - - $colleagueRows = $this->montzar->fetchColleagueLedgerTable( - $start->toDateString(), - $end->toDateString() - ); - - $classified = []; - foreach ($colleagueRows as $index => $row) { - if (!is_array($row)) { - continue; - } - $classified[] = $this->classifyColleagueRow($row, $index); - } - - $localOrders = $this->loadMatchedLocalOrders($start, $end); + $localOrders = $this->loadMainWalletMeltedOrders($start, $end); $localGifts = $this->loadLocalGifts($start, $end); - [$matchedPairs, $diffs, $usedOrderIds, $usedGiftIds] = $this->matchSiteRows( - $classified, - $localOrders, - $localGifts - ); - - foreach ($classified as $row) { - if (($row['kind'] ?? '') === 'site_buy' || ($row['kind'] ?? '') === 'site_sell') { - if (!empty($row['_matched'])) { - continue; - } - $diffs[] = [ - 'code' => 'colleague_site_only', - 'reason' => 'سند «از سایت» روی دفتر همکار هست ولی سفارش مچ‌شدهٔ متناظر در اپ پیدا نشد.', - 'colleague' => $this->publicColleagueRow($row), - 'local' => null, - ]; - continue; - } - - if (!empty($row['_matched'])) { - continue; - } - - if (in_array($row['kind'], ['settlement', 'physical', 'gift', 'other'], true)) { - $diffs[] = [ - 'code' => $this->diffCodeForKind($row['kind']), - 'reason' => $this->reasonForKind($row['kind'], $row['desc'] ?? ''), - 'colleague' => $this->publicColleagueRow($row), - 'local' => null, - ]; + $covered = []; + $uncovered = []; + foreach ($localOrders as $order) { + if (!empty($order['covered'])) { + $covered[] = $order; + } else { + $uncovered[] = $order; } } - foreach ($localOrders as $order) { - if (isset($usedOrderIds[(int) $order['id']])) { - continue; - } + $localAll = $this->sumLocalOrders($localOrders, 'cash'); + $localCovered = $this->sumLocalOrders($covered, 'montzar_cash'); + $localUncovered = $this->sumLocalOrders($uncovered, 'cash'); + + $diffs = []; + foreach ($uncovered as $order) { $diffs[] = [ - 'code' => 'local_order_only', - 'reason' => 'سفارش مچ‌شده در اپ هست ولی در ریز دفتر همکار (بازه) پیدا نشد.', + 'code' => 'local_uncovered', + 'reason' => 'خرید/فروش آبشده کیف اصلی در اپ هست ولی به همکار مچ نشده (بدون provider_id / مبلغ منتظر).', 'colleague' => null, 'local' => $order, ]; } - foreach ($localGifts as $gift) { - if (isset($usedGiftIds[(int) $gift['id']])) { - continue; - } - $diffs[] = [ - 'code' => 'gift_cover', - 'reason' => 'پاداش در اپ ثبت شده؛ سند متناظر پوشش روی دفتر همکار در بازه جور نشد (یا خرید پوشش خاموش بوده).', - 'colleague' => null, - 'local' => $gift, - ]; - } - - $colleagueSite = $this->sumColleagueSite($classified); - $localSite = $this->sumLocalOrders($localOrders); - + $mandeh = null; + $classified = []; + $matchedPairs = []; + $colleagueError = null; + $colleagueSite = $this->emptySiteSum(); $lastRemain = null; - if ($classified !== []) { - $last = $classified[array_key_last($classified)]; - $lastRemain = [ - 'gold' => (float) ($last['remain_gold'] ?? 0), - 'rial' => (float) ($last['remain_rial'] ?? 0), - ]; + $colleagueOther = []; + $diffSiteGold = 0.0; + $diffSiteRial = 0; + + try { + $mandehRaw = $this->montzar->fetchColleagueLedgerMandeh(); + $mandeh = $this->normalizeMandeh($mandehRaw); + $colleagueRows = $this->montzar->fetchColleagueLedgerTable( + $start->toDateString(), + $end->toDateString() + ); + foreach ($colleagueRows as $index => $row) { + if (!is_array($row)) { + continue; + } + $classified[] = $this->classifyColleagueRow($row, $index); + } + + [$matchedPairs, , $usedOrderIds, $usedGiftIds] = $this->matchSiteRows( + $classified, + $covered, + $localGifts + ); + + foreach ($classified as $row) { + if (($row['kind'] ?? '') === 'site_buy' || ($row['kind'] ?? '') === 'site_sell') { + if (!empty($row['_matched'])) { + continue; + } + $diffs[] = [ + 'code' => 'colleague_site_only', + 'reason' => 'سند «از سایت» روی دفتر همکار هست ولی سفارش پوشش‌شدهٔ متناظر در اپ پیدا نشد.', + 'colleague' => $this->publicColleagueRow($row), + 'local' => null, + ]; + } + } + + foreach ($covered as $order) { + if (isset($usedOrderIds[(int) $order['id']])) { + continue; + } + $diffs[] = [ + 'code' => 'local_covered_not_in_ledger', + 'reason' => 'سفارش در اپ پوشش همکار دارد ولی در ریز دفتر همکار (بازه) پیدا نشد.', + 'colleague' => null, + 'local' => $order, + ]; + } + + foreach ($localGifts as $gift) { + if (isset($usedGiftIds[(int) $gift['id']])) { + continue; + } + $diffs[] = [ + 'code' => 'gift_uncovered', + 'reason' => 'پاداش در اپ ثبت شده؛ سند پوشش روی دفتر همکار در بازه جور نشد.', + 'colleague' => null, + 'local' => $gift, + ]; + } + + foreach ($classified as $row) { + if (!empty($row['_matched'])) { + continue; + } + if (in_array($row['kind'], ['settlement', 'physical', 'gift', 'other'], true)) { + $colleagueOther[] = [ + 'code' => $this->diffCodeForKind($row['kind']), + 'reason' => $this->reasonForKind($row['kind'], $row['desc'] ?? ''), + 'colleague' => $this->publicColleagueRow($row), + 'local' => null, + ]; + } + } + + $colleagueSite = $this->sumColleagueSite($classified); + if ($classified !== []) { + $last = $classified[array_key_last($classified)]; + $lastRemain = [ + 'gold' => (float) ($last['remain_gold'] ?? 0), + 'rial' => (float) ($last['remain_rial'] ?? 0), + ]; + } + + // مقایسهٔ ریال فقط با مبلغ منتظر (نه قیمت مشتری با اسپرد/کارمزد) + $diffSiteGold = round( + ($colleagueSite['net_gold'] ?? 0) - ($localCovered['net_gold'] ?? 0), + 3 + ); + $diffSiteRial = (int) round( + ($colleagueSite['net_rial'] ?? 0) - ($localCovered['net_rial'] ?? 0) + ); + } catch (\Throwable $e) { + report($e); + $colleagueError = $e->getMessage(); } return [ 'mandeh' => $mandeh, + 'colleague_available' => $mandeh !== null && $colleagueError === null, + 'colleague_error' => $colleagueError, 'last_table_remain' => $lastRemain, 'summary' => [ + 'local_all' => $localAll, + 'local_covered' => $localCovered, + 'local_uncovered' => $localUncovered, + 'local_matched' => $localCovered, 'colleague_site' => $colleagueSite, - 'local_matched' => $localSite, - 'diff_site_gold' => round( - ($colleagueSite['net_gold'] ?? 0) - ($localSite['net_gold'] ?? 0), - 3 - ), - 'diff_site_rial' => (int) round( - ($colleagueSite['net_rial'] ?? 0) - ($localSite['net_rial'] ?? 0) - ), + 'diff_site_gold' => $diffSiteGold, + 'diff_site_rial' => $diffSiteRial, + 'order_count' => count($localOrders), + 'covered_count' => count($covered), + 'uncovered_count' => count($uncovered), 'matched_count' => count($matchedPairs), 'diff_count' => count($diffs), + 'gift_count' => count($localGifts), 'colleague_row_count' => count($classified), 'local_order_count' => count($localOrders), ], 'colleague_rows' => array_map(fn ($r) => $this->publicColleagueRow($r), $classified), 'local_orders' => array_values($localOrders), + 'local_uncovered_orders' => array_values($uncovered), 'local_gifts' => array_values($localGifts), 'matched' => $matchedPairs, 'diffs' => $diffs, + 'colleague_other' => $colleagueOther, 'range' => [ 'start' => $start->toDateTimeString(), 'end' => $end->toDateTimeString(), @@ -150,9 +199,82 @@ class MontzarReconcileService ]; } + /** + * خرید/فروش موفق آبشده روی کیف اصلی (main_wallet_products / main_product). + * + * @return list> + */ + private function loadMainWalletMeltedOrders(Carbon $start, Carbon $end): array + { + $productIds = $this->mainMeltedProductIds(); + + $query = Order::query() + ->with(['customer:id,name,mobile', 'product:id,title,group_type']) + ->where('sts', 2) + ->whereBetween('created_at', [$start, $end]) + ->orderBy('id'); + + if (Schema::hasColumn('orders', 'product_id')) { + $query->where(function ($q) use ($productIds) { + if ($productIds !== []) { + $q->whereIn('product_id', $productIds); + } + $q->orWhereNull('product_id'); + $q->orWhereHas('product', function ($pq) { + $pq->where('group_type', 'آبشده'); + }); + }); + } + + $hasProvider = Schema::hasColumn('orders', 'provider_id'); + $hasMontzarPrice = Schema::hasColumn('orders', 'total_price_montzar'); + + return $query->get()->map(function (Order $order) use ($hasProvider, $hasMontzarPrice) { + $direction = (int) $order->direction; + $amount = (float) $order->amount; + $customerCash = (float) ($order->total_price ?? $order->payment_amount ?? 0); + $montzarCash = $hasMontzarPrice ? (float) ($order->total_price_montzar ?? 0) : 0.0; + $providerId = $hasProvider ? ($order->provider_id ?? null) : null; + $covered = ($providerId !== null && $providerId !== '') + || ($hasMontzarPrice && $order->total_price_montzar !== null); + + return [ + 'id' => (int) $order->id, + 'type' => 'order', + 'direction' => $direction, + 'direction_label' => Order::$DIRECTION[$direction] ?? (string) $direction, + 'amount' => round($amount, 3), + 'cash' => (int) round($customerCash), + 'montzar_cash' => (int) round($montzarCash), + 'covered' => $covered, + 'provider_id' => $providerId, + 'product_id' => $order->product_id, + 'product_title' => $order->product->title ?? null, + 'customer_id' => (int) $order->customer_id, + 'customer_name' => $order->customer->name ?? null, + 'created_at' => optional($order->created_at)?->toDateTimeString(), + 'date_ymd' => optional($order->created_at)?->toDateString(), + ]; + })->all(); + } + + /** + * @return list + */ + private function mainMeltedProductIds(): array + { + $ids = $this->config->mainWalletProductIds(); + $main = $this->config->mainProductId(); + if ($main) { + $ids[] = (int) $main; + } + + return array_values(array_unique(array_filter($ids))); + } + /** * @param list> $raw - * @return array{gold: float, cash: float, gold_unit: string, cash_unit: string, items: list>} + * @return array{gold: float, cash: float, gold_abs: float, cash_abs: float, gold_state: string, rial_state: string, gold_unit: string, cash_unit: string, items: list>} */ private function normalizeMandeh(array $raw): array { @@ -203,7 +325,7 @@ class MontzarReconcileService $besRial = $this->num($row['besRial'] ?? 0); $kind = 'other'; - $siteDirection = null; // 1=فروش همکار به اپ (=خرید مشتری)، 2=خرید همکار از اپ (=فروش مشتری) + $siteDirection = null; $fromSite = str_contains($desc, 'سايت') || str_contains($desc, 'سایت'); if ($fromSite && (str_contains($desc, 'فروش') || str_starts_with($desc, 'فروش'))) { @@ -220,14 +342,12 @@ class MontzarReconcileService $kind = 'gift'; } - $gregorianDate = $this->colleagueDateToYmd((string) ($row['date'] ?? '')); - return [ 'index' => $index, 'id' => $row['id'] ?? null, 'doc_id' => $row['docId'] ?? null, 'date' => $row['date'] ?? null, - 'date_ymd' => $gregorianDate, + 'date_ymd' => $this->colleagueDateToYmd((string) ($row['date'] ?? '')), 'desc' => $desc, 'kind' => $kind, 'site_direction' => $siteDirection, @@ -245,51 +365,6 @@ class MontzarReconcileService ]; } - /** - * @return list> - */ - private function loadMatchedLocalOrders(Carbon $start, Carbon $end): array - { - $query = Order::query() - ->with(['customer:id,name,mobile']) - ->where('sts', 2) - ->whereBetween('created_at', [$start, $end]) - ->orderBy('id'); - - if (Schema::hasColumn('orders', 'provider_id')) { - $query->where(function ($q) { - $q->whereNotNull('provider_id'); - if (Schema::hasColumn('orders', 'total_price_montzar')) { - $q->orWhereNotNull('total_price_montzar'); - } - }); - } elseif (Schema::hasColumn('orders', 'total_price_montzar')) { - $query->whereNotNull('total_price_montzar'); - } else { - return []; - } - - return $query->get()->map(function (Order $order) { - $direction = (int) $order->direction; - $amount = (float) $order->amount; - $cash = (float) ($order->total_price ?? $order->payment_amount ?? 0); - - return [ - 'id' => (int) $order->id, - 'type' => 'order', - 'direction' => $direction, - 'direction_label' => Order::$DIRECTION[$direction] ?? (string) $direction, - 'amount' => round($amount, 3), - 'cash' => (int) round($cash), - 'provider_id' => $order->provider_id ?? null, - 'customer_id' => (int) $order->customer_id, - 'customer_name' => $order->customer->name ?? null, - 'created_at' => optional($order->created_at)?->toDateTimeString(), - 'date_ymd' => optional($order->created_at)?->toDateString(), - ]; - })->all(); - } - /** * @return list> */ @@ -309,6 +384,8 @@ class MontzarReconcileService 'direction_label' => 'خرید پوشش پاداش', 'amount' => round((float) $row->gold, 3), 'cash' => 0, + 'montzar_cash' => 0, + 'covered' => false, 'note' => $row->note, 'customer_id' => (int) ($row->customer_to_id ?: $row->customer_id), 'created_at' => optional($row->created_at)?->toDateTimeString(), @@ -385,16 +462,18 @@ class MontzarReconcileService if (isset($usedOrders[(int) $order['id']])) { continue; } + $orderRial = (float) (($order['montzar_cash'] ?? 0) > 0 + ? $order['montzar_cash'] + : $order['cash']); if ( $this->goldClose((float) $order['amount'], $targetGold) - && $this->rialClose((float) $order['cash'], $targetRial) + && $this->rialClose($orderRial, $targetRial) ) { $found = $order; break; } } - // fallback: فقط گرم در همان روز if (!$found) { foreach ($candidates as $order) { if (isset($usedOrders[(int) $order['id']])) { @@ -422,13 +501,28 @@ class MontzarReconcileService return [$matchedPairs, $diffs, $usedOrders, $usedGifts]; } + /** + * @return array{buy_gold: float, sell_gold: float, buy_rial: float, sell_rial: float, net_gold: float, net_rial: float} + */ + private function emptySiteSum(): array + { + return [ + 'buy_gold' => 0.0, + 'sell_gold' => 0.0, + 'buy_rial' => 0, + 'sell_rial' => 0, + 'net_gold' => 0.0, + 'net_rial' => 0, + ]; + } + /** * @param list> $classified * @return array{buy_gold: float, sell_gold: float, buy_rial: float, sell_rial: float, net_gold: float, net_rial: float} */ private function sumColleagueSite(array $classified): array { - $buyGold = 0.0; // فروش همکار به اپ + $buyGold = 0.0; $sellGold = 0.0; $buyRial = 0.0; $sellRial = 0.0; @@ -456,9 +550,10 @@ class MontzarReconcileService /** * @param list> $orders + * @param 'cash'|'montzar_cash' $rialField * @return array{buy_gold: float, sell_gold: float, buy_rial: float, sell_rial: float, net_gold: float, net_rial: float} */ - private function sumLocalOrders(array $orders): array + private function sumLocalOrders(array $orders, string $rialField = 'cash'): array { $buyGold = 0.0; $sellGold = 0.0; @@ -466,12 +561,16 @@ class MontzarReconcileService $sellRial = 0.0; foreach ($orders as $order) { + $rial = (float) ($order[$rialField] ?? 0); + if ($rialField === 'montzar_cash' && $rial <= 0) { + $rial = (float) ($order['cash'] ?? 0); + } if ((int) $order['direction'] === 1) { $buyGold += (float) $order['amount']; - $buyRial += (float) $order['cash']; + $buyRial += $rial; } else { $sellGold += (float) $order['amount']; - $sellRial += (float) $order['cash']; + $sellRial += $rial; } } @@ -509,8 +608,8 @@ class MontzarReconcileService private function reasonForKind(string $kind, string $desc): string { return match ($kind) { - 'settlement' => 'واریز/برداشت یا تعدیل روی دفتر همکار (بدون توضیح سایت) — معمولاً تسویه بانکی است و در اپ سفارش نیست.', - 'physical' => 'پرداخت فیزیکی روی دفتر همکار — در کیف مشتری لزوماً همان لحظه کم نمی‌شود.', + 'settlement' => 'واریز/برداشت یا تعدیل روی دفتر همکار — معمولاً تسویه است و سفارش اپ نیست.', + 'physical' => 'پرداخت فیزیکی روی دفتر همکار.', 'gift' => 'سند مرتبط با پاداش روی دفتر همکار.', default => $desc !== '' ? 'سند سایر روی دفتر همکار: ' . $desc @@ -525,7 +624,6 @@ class MontzarReconcileService return null; } - // جلالی شبیه 1405-05-11 (سال جلالی معمولاً ۱۳xx/۱۴xx) if (preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $date, $m) && (int) $m[1] >= 1300 && (int) $m[1] < 1600) { try { return Verta::createJalaliDate((int) $m[1], (int) $m[2], (int) $m[3])