Files
portal-api/app/Support/Billing/BillingDuplicateDetector.php
T

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));
}
}