'Đặt lặp trong thời gian ngắn', self::REASON_SAME_SERVICE => 'Cùng khách, sản phẩm và tên dịch vụ', self::REASON_DOMAIN_TAKEN => 'Tên miền đã có ở đơn khác', self::REASON_SAME_PERIOD => 'Cùng đơn, loại và kỳ thanh toán', ]; private static ?array $orders = null; private static ?array $invoices = null; public static function flush(): void { self::$orders = self::$invoices = null; } public static function forOrder(int $id): ?array { return self::orders()[$id] ?? null; } public static function forInvoice(int $id): ?array { return self::invoices()[$id] ?? null; } /** @return array */ public static function orders(): array { return self::$orders ??= self::detectOrders(); } /** @return array */ public static function invoices(): array { return self::$invoices ??= self::detectInvoices(); } private static function detectOrders(): array { $lines = DB::table('orders as o') ->join('order_items as oi', 'oi.order_id', '=', 'o.id') ->whereNull('o.deleted_at') ->where('o.status', '!=', OrderStatus::CANCELLED->value) ->get([ 'o.id', 'o.order_num', 'o.customer_id', 'o.total', 'o.created_at', 'oi.product_id', 'oi.billing_cycle', 'oi.domain_name', 'oi.server_name', ]); $orders = $lines->groupBy('id')->map(function (Collection $items) { $first = $items->first(); return (object) [ 'id' => $first->id, 'ref' => $first->order_num, 'customer' => $first->customer_id, 'total' => $first->total, 'at' => Carbon::parse($first->created_at), 'fingerprint' => $items->map(fn ($i) => $i->product_id . ':' . $i->billing_cycle . ':' . self::normalize($i->server_name ?: $i->domain_name))->sort()->implode('|'), 'service_keys' => $items->map(fn ($i) => $first->customer_id . '|' . $i->product_id . '|' . self::normalize($i->server_name ?: $i->domain_name)) ->filter(fn ($key) => !str_ends_with($key, '|'))->unique()->values(), 'domains' => $items->map(fn ($i) => self::normalize($i->domain_name))->filter()->unique()->values(), ]; }); $found = self::resubmits($orders->groupBy(fn ($o) => $o->customer . '|' . $o->total . '|' . $o->fingerprint)); $found += self::sharedKeys($orders, 'service_keys', self::REASON_SAME_SERVICE, $found); $found += self::sharedKeys($orders, 'domains', self::REASON_DOMAIN_TAKEN, $found); return $found; } private static function detectInvoices(): array { $invoices = DB::table('invoices') ->whereNull('deleted_at') ->where('status', '!=', InvoiceStatus::CANCELLED->value) ->get(['id', 'invoice_number', 'order_id', 'customer_id', 'invoice_type', 'total', 'billing_period_start', 'created_at']); $found = []; $samePeriod = $invoices->filter(fn ($i) => $i->order_id && in_array((int) $i->invoice_type, [1, 2], true)) ->groupBy(fn ($i) => $i->order_id . '|' . $i->invoice_type . '|' . ($i->billing_period_start ?? '')) ->filter(fn (Collection $group) => $group->count() > 1); foreach ($samePeriod as $group) { self::link($found, $group->pluck('id', 'invoice_number')->flip()->all(), self::REASON_SAME_PERIOD); } $candidates = $invoices->groupBy(fn ($i) => $i->customer_id . '|' . $i->invoice_type . '|' . $i->total) ->filter(fn (Collection $group) => $group->count() > 1) ->flatten(1); $itemsByInvoice = DB::table('invoice_items') ->whereIn('invoice_id', $candidates->pluck('id')) ->get(['invoice_id', 'product_id', 'description', 'total_price']) ->groupBy('invoice_id'); $fingerprinted = $candidates->map(fn ($i) => (object) [ 'id' => $i->id, 'ref' => $i->invoice_number, 'at' => Carbon::parse($i->created_at), 'group' => $i->customer_id . '|' . $i->invoice_type . '|' . $i->total . '|' . ($i->billing_period_start ?? '') . '|' . ($itemsByInvoice[$i->id] ?? collect()) ->map(fn ($item) => $item->product_id . ':' . $item->total_price . ':' . self::normalize($item->description))->sort()->implode('|'), ]); $resubmits = self::resubmits($fingerprinted->groupBy('group')); return $found + array_diff_key($resubmits, $found); } private static function resubmits(Collection $groups): array { $found = []; foreach ($groups->filter(fn (Collection $group) => $group->count() > 1) as $group) { $sorted = $group->sortBy('at')->values(); $cluster = [$sorted->first()]; foreach ($sorted->slice(1) as $row) { if (end($cluster)->at->diffInHours($row->at, true) <= self::RESUBMIT_WINDOW_HOURS) { $cluster[] = $row; continue; } self::linkCluster($found, $cluster); $cluster = [$row]; } self::linkCluster($found, $cluster); } return $found; } private static function linkCluster(array &$found, array $cluster): void { if (count($cluster) > 1) { self::link($found, collect($cluster)->pluck('id', 'ref')->flip()->all(), self::REASON_RESUBMIT); } } private static function sharedKeys(Collection $orders, string $field, string $reason, array $already): array { $found = []; $byKey = []; foreach ($orders as $order) { if (isset($already[$order->id])) { continue; } foreach ($order->{$field} as $key) { $byKey[$key][] = $order; } } foreach ($byKey as $group) { $group = collect($group)->unique('id'); $distinctCustomers = $group->pluck('customer')->unique()->count(); $qualifies = $reason === self::REASON_DOMAIN_TAKEN ? $distinctCustomers > 1 : $group->count() > 1; if ($qualifies) { self::link($found, $group->pluck('id', 'ref')->flip()->all(), $reason); } } return $found; } /** @param array $members id => ref */ private static function link(array &$found, array $members, string $reason): void { foreach ($members as $id => $ref) { $found[$id] = [ 'reason' => $reason, 'label' => self::LABELS[$reason], 'with' => array_values(array_map('strval', array_diff_key($members, [$id => true]))), ]; } } private static function normalize(?string $value): string { return mb_strtolower(trim((string) $value)); } }