CaiWuXiTong/modules/EcommerceFinance/Repositories/SmallPaymentWorkOrderMirrorRepository.php

808 lines
30 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\Repositories;
use Carbon\CarbonInterface;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use RuntimeException;
use Throwable;
/** 只读聚水潭“小额打款”工单镜像;HTTP 请求不得回退直连上游。 */
class SmallPaymentWorkOrderMirrorRepository
{
public const WORK_ORDER_DATASET = 'jiansun.dbo.jst_workOrderInfo';
public const PAYMENT_MAP_DATASET = 'jiansun.dbo.jst_small_payment_order_map';
public const SMALL_PAYMENT_WORK_TYPE_ID = '82D94F5C54544EE3833CD8A3AB8B9F73';
private const QUERY_TIMEOUT_SECONDS = 30;
private const SUMMARY_CACHE_SECONDS = 900;
private const CACHE_LOCK_SECONDS = 180;
private const CACHE_WAIT_SECONDS = 10;
private const FILTER_OPTIONS_CACHE_SECONDS = 900;
private const REQUIRED_QUERY_INDEXES = [
self::WORK_ORDER_DATASET => [
['woTypeId', 'woNo', 'Id'],
['woTypeId', 'completeTime', 'woNo', 'Id'],
],
self::PAYMENT_MAP_DATASET => [['woTypeId', 'woNo']],
];
private const WORK_ORDER_STATUS_LABELS = [
'WAIT_AUDIT' => '待审核',
'REJECT_AUDIT' => '审核驳回',
'WAIT_CONFIRM' => '待确认',
'CONFIRMING' => '确认中',
'CONFIRMED' => '已完成',
'DISABLED' => '已作废',
];
private const COMPLETED_WORK_ORDER_STATUSES = ['CONFIRMED', '已完成', '已确认'];
/** 打款金额在 businessFieldList 中的候选字段名(按优先级) */
private const PAYMENT_AMOUNT_FIELDS = ['打款金额', '付款金额', '小额打款金额', 'amountField'];
private readonly ConnectionInterface $connection;
public function __construct(
private readonly BusinessMirrorDatasetRegistry $datasets,
private readonly GenericBusinessMirrorRepository $mirror,
) {
$this->connection = $mirror->connection();
}
public function available(): bool
{
$schema = Schema::connection($this->connection->getName());
try {
return $schema->hasTable('business_mirror_table_states')
&& $schema->hasTable($this->dataset(self::WORK_ORDER_DATASET)->targetTable())
&& $schema->hasTable($this->dataset(self::PAYMENT_MAP_DATASET)->targetTable());
} catch (Throwable) {
return false;
}
}
public function ready(): bool
{
return $this->mirror->ready($this->dataset(self::WORK_ORDER_DATASET))
&& $this->mirror->ready($this->dataset(self::PAYMENT_MAP_DATASET));
}
public function queryReady(): bool
{
$schema = Schema::connection($this->connection->getName());
try {
foreach (self::REQUIRED_QUERY_INDEXES as $datasetKey => $requiredIndexes) {
$table = $this->dataset($datasetKey)->targetTable();
if (! $schema->hasTable($table)) {
return false;
}
$indexes = collect($schema->getIndexes($table))
->map(static fn (array $index): array => array_map(
'strtolower',
(array) ($index['columns'] ?? []),
));
foreach ($requiredIndexes as $required) {
if (! $indexes->contains(array_map('strtolower', $required))) {
return false;
}
}
}
} catch (Throwable) {
return false;
}
return true;
}
/** @return array<string,array<string,mixed>|null> */
public function states(): array
{
return [
self::WORK_ORDER_DATASET => $this->mirror->state($this->dataset(self::WORK_ORDER_DATASET)),
self::PAYMENT_MAP_DATASET => $this->mirror->state($this->dataset(self::PAYMENT_MAP_DATASET)),
];
}
/**
* @param array<string,array<string,mixed>|null>|null $states
* @return array{code:string,last_succeeded_at:?string}
*/
public function syncStatus(?array $states = null, ?CarbonInterface $at = null): array
{
$states ??= $this->states();
if (count(array_filter($states, 'is_array')) !== 2) {
return ['code' => 'unavailable', 'last_succeeded_at' => null];
}
$initialized = collect($states)->every(
static fn (?array $state): bool => ($state['initial_completed_at'] ?? null) !== null
);
$statuses = collect($states)->pluck('status')->map('strval');
$lastSucceededAt = collect($states)
->pluck('last_succeeded_at')
->filter()
->sort()
->first();
if (! $initialized) {
$code = $statuses->contains('failed') ? 'initial_failed' : 'initializing';
} elseif ($statuses->contains('failed')) {
$code = 'failed';
} elseif ($statuses->contains('running')) {
$code = 'syncing';
} else {
$lastSucceeded = $lastSucceededAt ? Carbon::parse($lastSucceededAt) : null;
$lagMinutes = $lastSucceeded === null
? null
: (int) ceil(max(0, ($at ?? now())->getTimestamp() - $lastSucceeded->getTimestamp()) / 60);
$code = $lagMinutes === null || $lagMinutes > 30 ? 'stale' : 'healthy';
}
return ['code' => $code, 'last_succeeded_at' => $lastSucceededAt];
}
/**
* @param array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} $filters
* @return array{data:list<array<string,mixed>>,total:int,page_payment_amount:string,filtered_payment_amount:string}
*/
public function paginate(array $filters, int $page, int $perPage): array
{
$this->ensureReady();
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
$query = $this->baseQuery();
$this->applyFilters($query, $filters);
$summary = $this->filteredSummary($filters);
$rows = $this->boundedGet($query
->select($this->selectColumns())
->orderByDesc('w.completeTime')
->orderByDesc('p.woNo')
->forPage($page, $perPage))
->map(fn (object $row): array => $this->decorate((array) $row))
->all();
return [
'data' => $rows,
'total' => $summary['count'],
'page_payment_amount' => $this->sumMoney(array_column($rows, 'payment_amount')),
'filtered_payment_amount' => $summary['payment_amount'],
];
}
/**
* 统计当前筛选条件下全部记录的打款金额合计。
*
* 只取金额所需字段,按 Id 做键集分块批量读取:不使用逐行游标(本地/远端库时每次取行都是一次网络往返),
* 也不复用导出用的分页排序查询(会为大结果集重复执行连接与排序)。
*
* @param array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} $filters
*/
public function filteredPaymentAmount(array $filters, int $chunkSize = 1000): string
{
return $this->filteredSummary($filters, $chunkSize)['payment_amount'];
}
/**
* @param array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} $filters
* @return array{count:int,payment_amount:string}
*/
private function filteredSummary(array $filters, int $chunkSize = 1000): array
{
$this->ensureReady();
$key = $this->summaryCacheKey($filters);
return $this->rememberArray(
$key,
self::SUMMARY_CACHE_SECONDS,
fn (): array => $this->calculateFilteredSummary($filters, $chunkSize),
'small_payment_work_order_summary_busy',
);
}
/**
* @param array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} $filters
* @return array{count:int,payment_amount:string}
*/
private function calculateFilteredSummary(array $filters, int $chunkSize): array
{
$this->ensureReady();
$query = $this->baseQuery();
$this->applyFilters($query, $filters);
$chunkSize = min(max($chunkSize, 100), 5000);
$cents = 0;
$count = 0;
$lastId = null;
do {
$chunk = $this->boundedGet((clone $query)
->select(['w.Id AS source_id', 'w.businessFieldList'])
->when($lastId !== null, static fn (Builder $q): Builder => $q->where('w.Id', '>', $lastId))
->orderBy('w.Id')
->limit($chunkSize));
foreach ($chunk as $row) {
$cents += $this->centsFromMoney($this->paymentAmountOf($row->businessFieldList ?? null));
}
$count += $chunk->count();
$lastId = $chunk->isEmpty() ? null : $chunk->last()->source_id;
} while ($chunk->count() === $chunkSize);
return ['count' => $count, 'payment_amount' => $this->formatCents($cents)];
}
private function paymentAmountOf(mixed $businessFieldList): ?string
{
return $this->money($this->field(
$this->businessFields($businessFieldList),
self::PAYMENT_AMOUNT_FIELDS,
));
}
/**
* @param array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} $filters
* @return iterable<int,array<string,mixed>>
*/
public function exportRows(array $filters, int $chunkSize = 100): iterable
{
$this->ensureReady();
$chunkSize = min(max($chunkSize, 1), 100);
$query = $this->baseQuery();
$this->applyFilters($query, $filters);
$page = 1;
do {
$rows = (clone $query)
->select($this->selectColumns())
->orderByDesc('w.completeTime')
->orderByDesc('p.woNo')
->forPage($page, $chunkSize)
->get();
foreach ($rows as $row) {
yield $this->decorate((array) $row);
}
$page++;
} while ($rows->count() === $chunkSize);
}
/**
* 返回当前小额打款镜像中出现过的店铺名称列表,用于筛选下拉/自动补全。
*
* @return list<string>
*/
public function shopNames(): array
{
return $this->filterOptions()['shops'];
}
/**
* 返回当前小额打款镜像中出现过的打款原因列表,用于筛选下拉/自动补全。
*
* @return list<string>
*/
public function paymentReasons(): array
{
return $this->filterOptions()['payment_reasons'];
}
/** @return array{shops:list<string>,payment_reasons:list<string>} */
public function filterOptions(): array
{
if (! $this->available() || ! $this->ready() || ! $this->queryReady()) {
return ['shops' => [], 'payment_reasons' => []];
}
$table = $this->dataset(self::PAYMENT_MAP_DATASET)->targetTable();
try {
return $this->rememberArray(
'small-payment-work-orders:filter-options:v1',
self::FILTER_OPTIONS_CACHE_SECONDS,
function () use ($table): array {
$shops = $this->boundedGet($this->connection->table($table)
->where('woTypeId', self::SMALL_PAYMENT_WORK_TYPE_ID)
->whereNotNull('shop_name')
->where('shop_name', '<>', '')
->distinct()
->select('shop_name')
->orderBy('shop_name')
->limit(1000))
->pluck('shop_name')
->map(static fn (mixed $name): string => (string) $name)
->all();
$reasons = $this->boundedGet($this->connection->table($table)
->where('woTypeId', self::SMALL_PAYMENT_WORK_TYPE_ID)
->whereNotNull('payment_reason')
->where('payment_reason', '<>', '')
->distinct()
->select('payment_reason')
->orderBy('payment_reason')
->limit(200))
->pluck('payment_reason')
->map(static fn (mixed $reason): string => (string) $reason)
->all();
return ['shops' => $shops, 'payment_reasons' => $reasons];
},
'small_payment_work_order_filter_options_busy',
);
} catch (Throwable) {
return ['shops' => [], 'payment_reasons' => []];
}
}
private function ensureReady(): void
{
if (! $this->ready() || ! $this->queryReady()) {
throw new RuntimeException('business_mirror_dataset_not_ready');
}
}
private function baseQuery(): Builder
{
$workOrders = $this->dataset(self::WORK_ORDER_DATASET)->targetTable();
$paymentMap = $this->dataset(self::PAYMENT_MAP_DATASET)->targetTable();
return $this->connection->table($workOrders . ' AS w')
->join($paymentMap . ' AS p', static function ($join): void {
$join->on('p.woTypeId', '=', 'w.woTypeId')
->on('p.woNo', '=', 'w.woNo');
})
->leftJoin($workOrders . ' AS newer_work_order', static function ($join): void {
$join->on('newer_work_order.woTypeId', '=', 'w.woTypeId')
->on('newer_work_order.woNo', '=', 'w.woNo')
->on('newer_work_order.Id', '>', 'w.Id');
})
->where('w.woTypeId', self::SMALL_PAYMENT_WORK_TYPE_ID)
->whereNull('newer_work_order.Id');
}
/** @return list<string> */
private function selectColumns(): array
{
return [
'w.Id AS source_id', 'p.woNo AS work_order_no',
'p.transaction_no AS mapped_online_order_no', 'w.status AS work_order_status',
'p.status AS mapped_status', 'w.title AS work_order_title',
'p.title AS mapped_title', 'w.content AS work_order_content',
'w.grade AS priority', 'w.urging AS urging_count',
'w.createby AS creator', 'w.executors', 'w.completer',
'w.completeTime AS completed_at', 'w.created AS work_order_created_at',
'w.updated AS work_order_updated_at', 'p.shop_name',
'p.payment_reason', 'p.parse_status', 'p.parse_error',
'p.work_order_create_time', 'p.work_order_update_time',
'w.businessFieldList AS business_field_list',
];
}
/** @param array<string,string> $filters */
private function applyFilters(Builder $query, array $filters): void
{
$from = $this->filterDateTime($filters['from'], false);
$to = $this->filterDateTime($filters['to'], true);
$query->where('w.completeTime', '>=', $from)
->where('w.completeTime', '<', $to);
if ($filters['work_order_no'] !== '') {
$query->whereRaw("p.woNo LIKE ? ESCAPE '!'", [$this->contains($filters['work_order_no'])]);
}
if ($filters['shop_name'] !== '') {
$query->whereRaw("p.shop_name LIKE ? ESCAPE '!'", [$this->contains($filters['shop_name'])]);
}
if ($filters['transaction_no'] !== '') {
$query->whereRaw("w.businessFieldList LIKE ? ESCAPE '!'", [$this->contains($filters['transaction_no'])]);
}
if ($filters['online_order_no'] !== '') {
$query->where(function (Builder $onlineOrder) use ($filters): void {
$needle = $this->contains($filters['online_order_no']);
$onlineOrder->whereRaw("p.transaction_no LIKE ? ESCAPE '!'", [$needle])
->orWhereRaw("w.businessFieldList LIKE ? ESCAPE '!'", [$needle]);
});
}
if ($filters['status'] !== '') {
$query->where(function (Builder $status) use ($filters): void {
$statuses = $this->workOrderStatusesForFilter($filters['status']);
$status->whereIn('w.status', $statuses)
->orWhereIn('p.status', $statuses)
->orWhereRaw("w.businessFieldList LIKE ? ESCAPE '!'", [$this->contains($filters['status'])]);
$paymentMethod = $this->paymentMethodForStatus($filters['status']);
if ($paymentMethod !== null || trim($filters['status']) === '已打款') {
$status->orWhere(function (Builder $payment) use ($paymentMethod): void {
$payment->whereIn('w.status', self::COMPLETED_WORK_ORDER_STATUSES);
if ($paymentMethod !== null) {
$this->applyPaymentMethodFilter($payment, $paymentMethod);
}
});
}
});
}
if ($filters['payment_reason'] !== '') {
$query->whereRaw("p.payment_reason LIKE ? ESCAPE '!'", [$this->contains($filters['payment_reason'])]);
}
}
/** @param array<string,mixed> $row @return array<string,mixed> */
private function decorate(array $row): array
{
$fields = $this->businessFields($row['business_field_list'] ?? null);
$sourceWorkOrderStatus = $this->firstFilled(
$row['work_order_status'] ?? null,
$row['mapped_status'] ?? null,
);
$row['work_order_status'] = $this->workOrderStatusLabel($sourceWorkOrderStatus);
$row['work_order_title'] = $this->firstFilled($row['work_order_title'] ?? null, $row['mapped_title'] ?? null);
$row['work_order_type'] = $this->field($fields, ['工单类型']) ?: '小额打款';
$paymentMethod = $this->field($fields, [
'打款方式', '收款方式', '支付方式', 'SELECTField1413856620250724113227',
]);
$row['payment_status'] = $this->firstFilled(
$this->field($fields, ['打款状态', '支付打款状态', '付款状态']),
$this->completedPaymentStatus($sourceWorkOrderStatus, $paymentMethod),
'未提供',
);
$row['alipay_batch_no'] = $this->field($fields, [
'支付宝批次号', '支付宝打款批次号', '打款批次号', '批次号', 'alipayBatchNo', 'batchNo',
]);
$row['payment_amount'] = $this->money($this->field($fields, self::PAYMENT_AMOUNT_FIELDS));
$row['online_order_no'] = $this->firstFilled(
$this->field($fields, ['线上订单号', '线上单号', '订单号', 'orderField']),
$row['mapped_online_order_no'] ?? null,
);
$row['buyer_account'] = $this->field($fields, [
'买家账号', '买家帐号', '买家', 'buyerIdField',
]);
$row['alipay_account'] = $this->field($fields, [
'支付宝账号', '支付宝帐号', 'alipayAccountField',
]);
$row['alipay_name'] = $this->field($fields, [
'支付宝姓名', '支付宝实名', '支付宝账户姓名', 'alipayNameField',
]);
$row['product_code'] = $this->field($fields, [
'商品编码', '商品ID', '商品编号', 'iid',
]);
$row['sku_code'] = $this->field($fields, [
'SKU编码', 'SKU', '规格编码', 'skuId',
]);
$row['platform'] = $this->field($fields, ['平台', '平台名称', 'platform']);
$row['actual_paid_amount'] = $this->money($this->field($fields, [
'实际已付金额', '实付金额', '买家实付金额', 'actualPaidAmountField',
]));
$row['payment_time'] = $this->field($fields, [
'支付时间', '打款时间', '付款时间', 'payDateField',
]);
unset(
$row['business_field_list'],
$row['mapped_online_order_no'],
$row['mapped_status'],
$row['mapped_title'],
);
return $row;
}
/** @return array<string,string> */
private function businessFields(mixed $json): array
{
if (! is_string($json) || trim($json) === '') {
return [];
}
try {
$decoded = json_decode($json, true, 64, JSON_THROW_ON_ERROR);
} catch (Throwable) {
return [];
}
$fields = [];
$this->flattenFields($decoded, $fields);
return $fields;
}
/** @param array<string,string> $fields */
private function flattenFields(mixed $node, array &$fields): void
{
if (! is_array($node)) {
return;
}
$label = $this->arrayValue($node, [
'fieldName', 'field_name', 'label', 'name', 'title', 'fieldId', 'field_id',
]);
$value = $this->arrayValue($node, [
'fieldValue', 'field_value', 'valueLabel', 'value_label', 'value', 'text',
]);
if ($label !== '' && $value !== '') {
$fields[$this->normalizeLabel($label)] = $value;
}
foreach ($node as $key => $child) {
if (is_string($key) && ! is_array($child) && ! is_object($child)) {
$text = trim((string) $child);
if ($text !== '') {
$fields[$this->normalizeLabel($key)] ??= $text;
}
}
if (is_array($child)) {
$this->flattenFields($child, $fields);
}
}
}
/** @param array<mixed> $row @param list<string> $keys */
private function arrayValue(array $row, array $keys): string
{
foreach ($keys as $key) {
if (array_key_exists($key, $row) && is_scalar($row[$key])) {
$value = trim((string) $row[$key]);
if ($value !== '') {
return $value;
}
}
}
return '';
}
/** @param array<string,string> $fields @param list<string> $labels */
private function field(array $fields, array $labels): string
{
foreach ($labels as $label) {
$value = $fields[$this->normalizeLabel($label)] ?? '';
if ($value !== '') {
return $value;
}
}
return '';
}
private function normalizeLabel(string $label): string
{
return mb_strtolower((string) preg_replace('/[\s::()()_\-]+/u', '', trim($label)));
}
private function money(string $value): ?string
{
$normalized = str_replace([',', ',', '¥', '¥', '元', ' '], '', trim($value));
if (preg_match('/^(-?)(\d+)(?:\.(\d{1,6}))?$/', $normalized, $matches) !== 1) {
return null;
}
$fraction = str_pad($matches[3] ?? '', 3, '0');
$cents = ((int) $matches[2] * 100) + (int) substr($fraction, 0, 2);
if ((int) $fraction[2] >= 5) {
$cents++;
}
$sign = $matches[1] === '-' && $cents !== 0 ? '-' : '';
return $sign . intdiv($cents, 100) . '.' . str_pad((string) ($cents % 100), 2, '0', STR_PAD_LEFT);
}
/** @param list<mixed> $amounts */
private function sumMoney(array $amounts): string
{
$cents = 0;
foreach ($amounts as $amount) {
$cents += $this->centsFromMoney($amount);
}
return $this->formatCents($cents);
}
/** 将 '123.45' / '-123.45' 形式的金额字符串转成分(无效值计 0)。 */
private function centsFromMoney(mixed $amount): int
{
if ($amount === null || ! preg_match('/^(-?)(\d+)\.(\d{2})$/', (string) $amount, $matches)) {
return 0;
}
$value = ((int) $matches[2] * 100) + (int) $matches[3];
return $matches[1] === '-' ? -$value : $value;
}
private function formatCents(int $cents): string
{
$sign = $cents < 0 ? '-' : '';
$absolute = abs($cents);
return $sign . intdiv($absolute, 100) . '.' . str_pad((string) ($absolute % 100), 2, '0', STR_PAD_LEFT);
}
private function firstFilled(mixed ...$values): string
{
foreach ($values as $value) {
$text = trim((string) $value);
if ($text !== '') {
return $text;
}
}
return '';
}
private function workOrderStatusLabel(string $status): string
{
$code = strtoupper(trim($status));
return self::WORK_ORDER_STATUS_LABELS[$code] ?? $status;
}
private function completedPaymentStatus(string $workOrderStatus, string $paymentMethod): string
{
$normalizedStatus = strtoupper(trim($workOrderStatus));
if (! in_array($normalizedStatus, self::COMPLETED_WORK_ORDER_STATUSES, true)) {
return '';
}
if (str_contains($paymentMethod, '支付宝')) {
return '支付宝已打款';
}
if (str_contains($paymentMethod, '银行卡')) {
return '银行卡已打款';
}
return '已打款';
}
private function paymentMethodForStatus(string $status): ?string
{
return match (trim($status)) {
'支付宝已打款' => '支付宝账号',
'银行卡已打款' => '银行卡',
default => null,
};
}
private function applyPaymentMethodFilter(Builder $query, string $paymentMethod): void
{
$query->where(function (Builder $fields) use ($paymentMethod): void {
foreach (['value', 'fieldValue', 'valueLabel'] as $key) {
$fields->orWhereRaw(
"w.businessFieldList LIKE ? ESCAPE '!'",
[$this->contains(sprintf('"%s":"%s"', $key, $paymentMethod))],
);
}
});
}
/** @return list<string> */
private function workOrderStatusesForFilter(string $status): array
{
$status = trim($status);
$code = array_search($status, self::WORK_ORDER_STATUS_LABELS, true);
return array_values(array_unique(array_filter([
$status,
is_string($code) ? $code : null,
], static fn (?string $value): bool => $value !== null && $value !== '')));
}
/** @param array<string,string> $filters */
private function summaryCacheKey(array $filters): string
{
$normalized = array_map(static fn (mixed $value): string => trim((string) $value), $filters);
ksort($normalized);
return 'small-payment-work-orders:summary:v1:' . hash(
'sha256',
(string) json_encode($normalized, JSON_UNESCAPED_UNICODE),
);
}
/** @param callable():array<string,mixed> $resolver @return array<string,mixed> */
private function rememberArray(string $key, int $seconds, callable $resolver, string $busyMessage): array
{
$cached = Cache::get($key);
if (is_array($cached)) {
return $cached;
}
try {
return Cache::lock($key . ':lock', self::CACHE_LOCK_SECONDS)
->block(self::CACHE_WAIT_SECONDS, function () use ($key, $seconds, $resolver): array {
$cached = Cache::get($key);
if (is_array($cached)) {
return $cached;
}
$resolved = $resolver();
Cache::put($key, $resolved, $seconds);
return $resolved;
});
} catch (LockTimeoutException $exception) {
$cached = Cache::get($key);
if (is_array($cached)) {
return $cached;
}
throw new RuntimeException($busyMessage, 0, $exception);
}
}
/** @return Collection<int,object> */
private function boundedGet(Builder $query): Collection
{
$connection = $query->getConnection();
if ($connection->getDriverName() !== 'mysql') {
return $query->get();
}
return collect($connection->select(
'SET STATEMENT max_statement_time=' . self::QUERY_TIMEOUT_SECONDS . ' FOR ' . $query->toSql(),
$query->getBindings(),
));
}
/**
* 将前端传来的日期或日期时间字符串统一为 SQL 比较用的日期时间。
* 仅日期时:from 取 00:00:00,to 取次日 00:00:00(半开区间)。
* 含分钟时:to 取下一分钟;含秒时:to 取下一秒,完整包含用户选择的最小时间单位。
*/
private function filterDateTime(string $value, bool $isUpperBound): string
{
$value = trim($value);
$hasTime = str_contains($value, ':') || str_contains($value, 'T');
if (! $hasTime) {
return $isUpperBound
? Carbon::parse($value)->addDay()->format('Y-m-d 00:00:00')
: $value . ' 00:00:00';
}
$carbon = Carbon::parse($value);
if (! $isUpperBound) {
return $carbon->format('Y-m-d H:i:s');
}
return preg_match('/:\d{2}:\d{2}$/', str_replace('T', ' ', $value)) === 1
? $carbon->clone()->addSecond()->format('Y-m-d H:i:s')
: $carbon->clone()->addMinute()->format('Y-m-d H:i:s');
}
private function contains(string $value): string
{
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%';
}
private function dataset(string $key): BusinessMirrorDataset
{
return $this->datasets->get($key);
}
}