*/ public function reconcile(string $startDate, string $endDate): array { $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); $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, ]; } } foreach ($localOrders as $order) { if (isset($usedOrderIds[(int) $order['id']])) { continue; } $diffs[] = [ 'code' => 'local_order_only', 'reason' => 'سفارش مچ‌شده در اپ هست ولی در ریز دفتر همکار (بازه) پیدا نشد.', '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); $lastRemain = null; if ($classified !== []) { $last = $classified[array_key_last($classified)]; $lastRemain = [ 'gold' => (float) ($last['remain_gold'] ?? 0), 'rial' => (float) ($last['remain_rial'] ?? 0), ]; } return [ 'mandeh' => $mandeh, 'last_table_remain' => $lastRemain, 'summary' => [ '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) ), 'matched_count' => count($matchedPairs), 'diff_count' => count($diffs), '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_gifts' => array_values($localGifts), 'matched' => $matchedPairs, 'diffs' => $diffs, 'range' => [ 'start' => $start->toDateTimeString(), 'end' => $end->toDateTimeString(), ], ]; } /** * @param list> $raw * @return array{gold: float, cash: float, 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; // 1=فروش همکار به اپ (=خرید مشتری)، 2=خرید همکار از اپ (=فروش مشتری) $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'; } $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, '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 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> */ 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, '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; } if ( $this->goldClose((float) $order['amount'], $targetGold) && $this->rialClose((float) $order['cash'], $targetRial) ) { $found = $order; break; } } // fallback: فقط گرم در همان روز 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]; } /** * @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 * @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 { $buyGold = 0.0; $sellGold = 0.0; $buyRial = 0.0; $sellRial = 0.0; foreach ($orders as $order) { if ((int) $order['direction'] === 1) { $buyGold += (float) $order['amount']; $buyRial += (float) $order['cash']; } else { $sellGold += (float) $order['amount']; $sellRial += (float) $order['cash']; } } 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; } // جلالی شبیه 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]) ->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; } }