diff --git a/app/Http/Controllers/Admin/DashboardController.php b/app/Http/Controllers/Admin/DashboardController.php index bb78c10..28c970b 100644 --- a/app/Http/Controllers/Admin/DashboardController.php +++ b/app/Http/Controllers/Admin/DashboardController.php @@ -746,4 +746,37 @@ class DashboardController extends Controller return response()->json(['deleted' => true]); } + + /** + * تطبیق دفتر اکانت روی نود همکار (Montzar) با سفارش‌های مچ‌شده — نه ته‌حساب. + */ + public function montzarReconcile(Request $request) + { + $config = app(ConfigService::class); + if (!$config->usesMontzarPricing()) { + return response()->json([ + 'message' => 'این گزارش فقط وقتی منبع قیمت montzar است در دسترس است.', + ], 422); + } + + $startDate = $request->input('startDate') + ?: now()->subDays(30)->toDateString(); + $endDate = $request->input('endDate') + ?: now()->toDateString(); + + try { + $payload = app(\App\Services\MontzarReconcileService::class) + ->reconcile((string) $startDate, (string) $endDate); + } catch (\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e) { + throw $e; + } catch (\Throwable $e) { + report($e); + + return response()->json([ + 'message' => 'خواندن دفتر همکار ناموفق بود: ' . $e->getMessage(), + ], 422); + } + + return response()->json($payload); + } } diff --git a/app/Services/MontzarReconcileService.php b/app/Services/MontzarReconcileService.php new file mode 100644 index 0000000..756816e --- /dev/null +++ b/app/Services/MontzarReconcileService.php @@ -0,0 +1,560 @@ + + */ + 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; + } +} diff --git a/app/Services/MontzarService.php b/app/Services/MontzarService.php index 1afea23..ba5fa66 100644 --- a/app/Services/MontzarService.php +++ b/app/Services/MontzarService.php @@ -118,6 +118,112 @@ class MontzarService return $response; } + /** + * مانده طلا/ریال اکانت اپ روی نود همکار (دفتر پنل همکار — نه ته‌حساب اپ). + * + * @return list + */ + public function fetchColleagueLedgerMandeh(): array + { + $body = $this->requestColleagueLedger('GET', '/server/customer/fin_tahesab/mandeh'); + + if (!is_array($body)) { + throw new UnprocessableEntityHttpException('پاسخ نامعتبر مانده دفتر همکار'); + } + + // بعضی نودها آرایهٔ مستقیم برمی‌گردانند؛ بعضی داخل data + if (isset($body[0]) || $body === []) { + return array_values($body); + } + + if (isset($body['data']) && is_array($body['data'])) { + return array_values($body['data']); + } + + return [$body]; + } + + /** + * ریز اسناد دفتر اکانت روی نود همکار در بازهٔ تاریخ میلادی Y-m-d. + * + * @return list> + */ + public function fetchColleagueLedgerTable(string $startDate, string $endDate): array + { + $body = $this->requestColleagueLedger('POST', '/server/customer/fin_tahesab/table', [ + 'startDate' => $startDate, + 'endDate' => $endDate, + ]); + + if (!is_array($body)) { + throw new UnprocessableEntityHttpException('پاسخ نامعتبر ریز دفتر همکار'); + } + + if (isset($body[0]) || $body === []) { + return array_values($body); + } + + if (isset($body['data']) && is_array($body['data'])) { + return array_values($body['data']); + } + + if (isset($body['rows']) && is_array($body['rows'])) { + return array_values($body['rows']); + } + + return []; + } + + /** + * @param array|null $payload + * @return array|null + */ + private function requestColleagueLedger(string $method, string $path, ?array $payload = null): ?array + { + $token = $this->getToken(); + $url = $this->endpoint($path); + + $httpResponse = $this->sendColleagueLedgerHttp($method, $url, $token, $payload); + $body = $this->decodeMontzarHttpBody($httpResponse); + + if (is_array($body) && ($body['message'] ?? null) === 'no authorised') { + $token = $this->montzarLogin(); + $httpResponse = $this->sendColleagueLedgerHttp($method, $url, $token, $payload); + $body = $this->decodeMontzarHttpBody($httpResponse); + } + + if ($httpResponse->failed() && !is_array($body)) { + Log::warning('Colleague ledger request failed', [ + 'method' => $method, + 'url' => $url, + 'status' => $httpResponse->status(), + 'raw' => $httpResponse->body(), + ]); + throw new UnprocessableEntityHttpException( + $this->formatMontzarErrorMessage(null, $httpResponse) + ); + } + + if (is_array($body) && !empty($body['message']) && is_string($body['message']) && !isset($body[0])) { + throw new UnprocessableEntityHttpException($body['message']); + } + + return $body; + } + + private function sendColleagueLedgerHttp(string $method, string $url, string $token, ?array $payload) + { + $pending = Http::timeout(30) + ->acceptJson() + ->withToken($token); + + if (strtoupper($method) === 'GET') { + return $pending->get($url, $payload ?? []); + } + + return $pending->post($url, $payload ?? []); + } + /** * ثبت سفارش در منتظر — قبل از ایجاد سفارش/تراکنش محلی صدا زده می‌شود. * diff --git a/routes/admin.php b/routes/admin.php index 80430ea..2675987 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -190,6 +190,7 @@ Route::middleware('auth:user')->group(function (){ Route::post('dashboard/profit-conversions', [DashboardController::class, 'profitConversions']); Route::post('dashboard/profit-conversions/store', [DashboardController::class, 'storeProfitConversion']); Route::delete('dashboard/profit-conversions/{id}', [DashboardController::class, 'deleteProfitConversion']); + Route::post('dashboard/montzar-reconcile', [DashboardController::class, 'montzarReconcile']); //bank account Route::post('account/table', [BankAccountController::class, 'table']);