203 lines
7.6 KiB
PHP
203 lines
7.6 KiB
PHP
<?php
|
|
|
|
namespace App\Support\Billing;
|
|
|
|
use App\Enums\Invoice\InvoiceStatus;
|
|
use App\Enums\Order\OrderStatus;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class BillingDuplicateDetector
|
|
{
|
|
public const RESUBMIT_WINDOW_HOURS = 24;
|
|
|
|
public const REASON_RESUBMIT = 'rapid_resubmit';
|
|
public const REASON_SAME_SERVICE = 'same_service';
|
|
public const REASON_DOMAIN_TAKEN = 'domain_taken';
|
|
public const REASON_SAME_PERIOD = 'same_period';
|
|
|
|
public const LABELS = [
|
|
self::REASON_RESUBMIT => 'Đặ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<int, array{reason: string, label: string, with: string[]}> */
|
|
public static function orders(): array
|
|
{
|
|
return self::$orders ??= self::detectOrders();
|
|
}
|
|
|
|
/** @return array<int, array{reason: string, label: string, with: string[]}> */
|
|
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<string|int, int|string> $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));
|
|
}
|
|
}
|