*/ public function reconcile(string $startDate, string $endDate): array { $start = Carbon::parse($startDate)->startOfDay(); $end = Carbon::parse($endDate)->endOfDay(); $localOrders = $this->loadMainWalletMeltedOrders($start, $end); $localGifts = $this->loadLocalGifts($start, $end); $covered = []; $uncovered = []; foreach ($localOrders as $order) { if (!empty($order['covered'])) { $covered[] = $order; } else { $uncovered[] = $order; } } $localAll = $this->sumLocalOrders($localOrders, 'cash'); $localCovered = $this->sumLocalOrders($covered, 'montzar_cash'); $localUncovered = $this->sumLocalOrders($uncovered, 'cash'); $diffs = []; foreach ($uncovered as $order) { $diffs[] = [ 'code' => 'local_uncovered', 'reason' => 'خرید یا فروش آبشده در اپ ثبت شده ولی نزد همکار ثبت نشده است.', 'colleague' => null, 'local' => $order, ]; } $mandeh = null; $classified = []; $matchedPairs = []; $colleagueError = null; $colleagueSite = $this->emptySiteSum(); $lastRemain = null; $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, '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(), ], ]; } /** * خرید/فروش موفق آبشده روی کیف اصلی (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_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 { $gold = 0.0; $cash = 0.0; $goldUnit = 'گرم'; $cashUnit = 'ریال'; foreach ($raw as $item) { if (!is_array($item)) { continue; } $eng = strtolower((string) ($item['name_eng'] ?? '')); $value = (float) ($item['value'] ?? 0); if ($eng === 'gold' || ($item['name_fa'] ?? '') === 'طلا') { $gold = $value; $goldUnit = (string) ($item['unit'] ?? 'گرم'); } if ($eng === 'cash' || ($item['name_fa'] ?? '') === 'ریال') { $cash = $value; $cashUnit = (string) ($item['unit'] ?? 'ریال'); } } return [ 'gold' => $gold, 'cash' => $cash, 'gold_abs' => abs($gold), 'cash_abs' => abs($cash), 'gold_state' => $gold < 0 ? 'بد' : ($gold > 0 ? 'بس' : 'صفر'), 'rial_state' => $cash < 0 ? 'بد' : ($cash > 0 ? 'بس' : 'صفر'), 'gold_unit' => $goldUnit, 'cash_unit' => $cashUnit, 'items' => $raw, ]; } /** * @param array $row * @return array */ private function classifyColleagueRow(array $row, int $index): array { $desc = trim((string) ($row['desc'] ?? '')); $bedGold = $this->num($row['bedGold'] ?? 0); $besGold = $this->num($row['besGold'] ?? 0); $bedRial = $this->num($row['bedRial'] ?? 0); $besRial = $this->num($row['besRial'] ?? 0); $kind = 'other'; $siteDirection = null; $fromSite = str_contains($desc, 'سايت') || str_contains($desc, 'سایت'); if ($fromSite && (str_contains($desc, 'فروش') || str_starts_with($desc, 'فروش'))) { $kind = 'site_sell'; $siteDirection = 1; } elseif ($fromSite && (str_contains($desc, 'خريد') || str_contains($desc, 'خرید'))) { $kind = 'site_buy'; $siteDirection = 2; } elseif ($desc === '') { $kind = 'settlement'; } elseif (str_contains($desc, 'پرداخت')) { $kind = 'physical'; } elseif (str_contains($desc, 'پاداش') || str_contains($desc, 'ثبت نام') || str_contains($desc, 'معرفی')) { $kind = 'gift'; } return [ 'index' => $index, 'id' => $row['id'] ?? null, 'doc_id' => $row['docId'] ?? null, 'date' => $row['date'] ?? null, 'date_ymd' => $this->colleagueDateToYmd((string) ($row['date'] ?? '')), 'desc' => $desc, 'kind' => $kind, 'site_direction' => $siteDirection, 'bed_gold' => $bedGold, 'bes_gold' => $besGold, 'bed_rial' => $bedRial, 'bes_rial' => $besRial, 'net_gold' => round($besGold - $bedGold, 3), 'net_rial' => round($besRial - $bedRial, 0), 'remain_gold' => $this->num($row['remainGold'] ?? 0), 'remain_rial' => $this->num($row['remainRial'] ?? 0), 'gold_state' => $row['goldState'] ?? null, 'rial_state' => $row['rialState'] ?? null, '_matched' => false, ]; } /** * @return list> */ private function loadLocalGifts(Carbon $start, Carbon $end): array { return TransactionsAdmin::query() ->whereBetween('created_at', [$start, $end]) ->whereIn('note', ['پاداش بابت معرفی', 'پاداش بابت ثبت نام']) ->where('gold', '>', 0) ->orderBy('id') ->get() ->map(function (TransactionsAdmin $row) { return [ 'id' => (int) $row->id, 'type' => 'gift', 'direction' => 1, '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(), 'date_ymd' => optional($row->created_at)?->toDateString(), ]; }) ->all(); } /** * @param list> $classified * @param list> $localOrders * @param list> $localGifts * @return array{0: list>, 1: list>, 2: array, 3: array} */ private function matchSiteRows(array &$classified, array $localOrders, array $localGifts): array { $matchedPairs = []; $diffs = []; $usedOrders = []; $usedGifts = []; $ordersByDirDate = []; foreach ($localOrders as $order) { $key = ((int) $order['direction']) . '|' . ($order['date_ymd'] ?? ''); $ordersByDirDate[$key][] = $order; } $giftsByDate = []; foreach ($localGifts as $gift) { $giftsByDate[$gift['date_ymd'] ?? ''][] = $gift; } foreach ($classified as &$row) { if (($row['kind'] ?? '') === 'gift') { $day = $row['date_ymd'] ?? ''; $candidates = $giftsByDate[$day] ?? []; $found = null; foreach ($candidates as $gift) { if (isset($usedGifts[(int) $gift['id']])) { continue; } if ($this->goldClose((float) $gift['amount'], abs((float) $row['net_gold']))) { $found = $gift; break; } } if ($found) { $usedGifts[(int) $found['id']] = true; $row['_matched'] = true; $matchedPairs[] = [ 'code' => 'matched', 'colleague' => $this->publicColleagueRow($row), 'local' => $found, ]; } continue; } if (($row['kind'] ?? '') !== 'site_buy' && ($row['kind'] ?? '') !== 'site_sell') { continue; } $dir = (int) ($row['site_direction'] ?? 0); $day = $row['date_ymd'] ?? ''; $key = $dir . '|' . $day; $candidates = $ordersByDirDate[$key] ?? []; $targetGold = abs((float) $row['net_gold']); $targetRial = abs((float) $row['net_rial']); $found = null; foreach ($candidates as $order) { 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($orderRial, $targetRial) ) { $found = $order; break; } } if (!$found) { foreach ($candidates as $order) { if (isset($usedOrders[(int) $order['id']])) { continue; } if ($this->goldClose((float) $order['amount'], $targetGold)) { $found = $order; break; } } } if ($found) { $usedOrders[(int) $found['id']] = true; $row['_matched'] = true; $matchedPairs[] = [ 'code' => 'matched', 'colleague' => $this->publicColleagueRow($row), 'local' => $found, ]; } } unset($row); 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; $sellGold = 0.0; $buyRial = 0.0; $sellRial = 0.0; foreach ($classified as $row) { if (($row['kind'] ?? '') === 'site_sell') { $buyGold += abs((float) $row['net_gold']); $buyRial += abs((float) $row['net_rial']); } if (($row['kind'] ?? '') === 'site_buy') { $sellGold += abs((float) $row['net_gold']); $sellRial += abs((float) $row['net_rial']); } } return [ 'buy_gold' => round($buyGold, 3), 'sell_gold' => round($sellGold, 3), 'buy_rial' => (int) round($buyRial), 'sell_rial' => (int) round($sellRial), 'net_gold' => round($buyGold - $sellGold, 3), 'net_rial' => (int) round($sellRial - $buyRial), ]; } /** * @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, string $rialField = 'cash'): array { $buyGold = 0.0; $sellGold = 0.0; $buyRial = 0.0; $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 += $rial; } else { $sellGold += (float) $order['amount']; $sellRial += $rial; } } return [ 'buy_gold' => round($buyGold, 3), 'sell_gold' => round($sellGold, 3), 'buy_rial' => (int) round($buyRial), 'sell_rial' => (int) round($sellRial), 'net_gold' => round($buyGold - $sellGold, 3), 'net_rial' => (int) round($sellRial - $buyRial), ]; } /** * @param array $row * @return array */ private function publicColleagueRow(array $row): array { unset($row['_matched']); return $row; } private function diffCodeForKind(string $kind): string { return match ($kind) { 'settlement' => 'colleague_settlement', 'physical' => 'colleague_physical', 'gift' => 'gift_cover', default => 'other', }; } private function reasonForKind(string $kind, string $desc): string { return match ($kind) { 'settlement' => 'واریز/برداشت یا تعدیل روی دفتر همکار — معمولاً تسویه است و سفارش اپ نیست.', 'physical' => 'پرداخت فیزیکی روی دفتر همکار.', 'gift' => 'سند مرتبط با پاداش روی دفتر همکار.', default => $desc !== '' ? 'سند سایر روی دفتر همکار: ' . $desc : 'سند سایر روی دفتر همکار.', }; } private function colleagueDateToYmd(string $date): ?string { $date = trim($date); if ($date === '') { return null; } 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]) ->toCarbon() ->toDateString(); } catch (\Throwable $e) { return null; } } try { return Carbon::parse($date)->toDateString(); } catch (\Throwable $e) { return null; } } private function num(mixed $v): float { return (float) str_replace(',', '', (string) ($v ?? 0)); } private function goldClose(float $a, float $b): bool { return abs($a - $b) <= self::GOLD_EPS; } private function rialClose(float $a, float $b): bool { return abs($a - $b) <= self::RIAL_EPS; } }