CaiWuXiTong/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php

677 lines
27 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Repositories;
use Carbon\CarbonInterface;
use Generator;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
use Throwable;
/**
* 三方仓仓储数据的共享业务镜像只读查询。
*
* 当前库存是每个 SKU 独立的时间锚点。历史期初、期末均通过已确认
* 流水的 qty * qty_type 从该锚点回推或前推,页面请求不直连上游。
*/
class ThirdPartyWarehouseStorageMirrorRepository
{
public const DATASET_KEY = 'jiansun.third-party-warehouse-storage';
public const WAREHOUSE_ID = 15045364;
private const STATE_TABLE = 'third_party_warehouse_sync_states';
private const HEADER_TABLE = 'third_party_warehouse_inout_headers';
private const ITEM_TABLE = 'third_party_warehouse_inout_items';
private const INVENTORY_TABLE = 'third_party_warehouse_inventory_snapshots';
private const PRODUCT_TABLE = 'ops_jst_sku';
private const PRICE_CHANGE_TABLE = 'js_pm_price_adjustment_log';
private const GENERIC_STATE_TABLE = 'business_mirror_table_states';
private const PRODUCT_DATASET_KEY = 'operations_replica.rpt.jst_sku';
private const PRICE_CHANGE_DATASET_KEY = 'jiansun.dbo.pm_priceAdjustment_log';
/** @var array<string,string> */
private const MOVEMENT_COLUMNS = [
'processing_in' => '加工进仓',
'processing_out' => '加工出仓',
'purchase_in' => '采购进仓',
'purchase_return' => '采购退货',
'other_in' => '其它进仓',
'other_return' => '其它退货',
'sales_out' => '销售出仓',
'sales_return' => '销售退货',
'stocktake' => '盘点',
'transfer_out' => '调拨出',
'transfer_in' => '调拨入',
'other_out' => '其它出库',
];
/** @var list<string> */
private const OUTPUT_COLUMNS = [
'owner', 'sku_code', 'image_url', 'product_name', 'short_name',
'supplier', 'category', 'virtual_category', 'supplier_style_no',
'color_spec', 'brand', 'unit', 'opening_qty', 'closing_qty',
'processing_in', 'processing_out', 'purchase_in', 'purchase_return',
'other_in', 'other_return', 'sales_out', 'sales_return', 'stocktake',
'transfer_out', 'transfer_in', 'opening_aux', 'other_out',
'turnover_days', 'opening_amount', 'closing_amount', 'price_type',
'order_type', 'other_price_3', 'other_price_4', 'package_count',
'model_spec', 'board_type', 'purchase_type', 'other_attribute_4',
'production_status', 'other_attribute_6', 'item_no',
'single_double_package', 'drawing_id', 'material',
];
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $databases)
{
$this->connection = $databases->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
public function available(): bool
{
try {
$schema = Schema::connection($this->connection->getName());
foreach ([
self::STATE_TABLE,
self::HEADER_TABLE,
self::ITEM_TABLE,
self::INVENTORY_TABLE,
self::PRODUCT_TABLE,
self::PRICE_CHANGE_TABLE,
self::GENERIC_STATE_TABLE,
] as $table) {
if (! $schema->hasTable($table)) {
return false;
}
}
return $schema->hasColumn(self::ITEM_TABLE, 'qty_type')
&& $schema->hasColumn(self::HEADER_TABLE, 'source_modified_at')
&& $schema->hasColumn(self::ITEM_TABLE, 'source_modified_at')
&& $schema->hasColumn(self::INVENTORY_TABLE, 'source_modified_at')
&& $schema->hasColumn(self::INVENTORY_TABLE, 'captured_at');
} catch (Throwable) {
return false;
}
}
public function ready(): bool
{
$state = $this->state();
return is_array($state)
&& ($state['initial_completed_at'] ?? null) !== null
&& $this->generation($state, 'active_header_generation') !== null
&& $this->generation($state, 'active_item_generation') !== null
&& $this->generation($state, 'active_inventory_generation') !== null
&& $this->dependenciesReady();
}
/** @return array<string,mixed>|null */
public function state(): ?array
{
if (! $this->available()) {
return null;
}
try {
$row = $this->connection->table(self::STATE_TABLE)
->where('dataset_key', self::DATASET_KEY)
->first();
return $row === null ? null : (array) $row;
} catch (Throwable) {
return null;
}
}
/**
* @param array<string,mixed>|null $state
* @return array{code:string,last_succeeded_at:?string,lag_minutes:?int}
*/
public function syncStatus(?array $state = null, ?CarbonInterface $at = null): array
{
$state ??= $this->state();
if (! is_array($state)) {
return ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null];
}
$lastSucceededAt = $this->timestamp($state['last_succeeded_at'] ?? null);
$lastSucceeded = $lastSucceededAt === null
? null
: Carbon::createFromTimestamp($lastSucceededAt)->toDateTimeString();
$lagMinutes = $lastSucceededAt === null
? null
: (int) ceil(max(0, ($at ?? now())->getTimestamp() - $lastSucceededAt) / 60);
$status = (string) ($state['status'] ?? 'pending');
if (($state['initial_completed_at'] ?? null) === null) {
$code = $status === 'failed'
? 'initial_failed'
: ($status === 'running' ? 'initializing' : 'pending');
} elseif (! $this->dependenciesReady()) {
$code = 'dependency_pending';
} elseif ($status === 'failed') {
$code = 'failed';
} elseif ($status === 'running') {
$code = ($state['run_mode'] ?? null) === 'full' ? 'reconciling' : 'syncing';
} elseif ($lagMinutes === null
|| $lagMinutes > max((int) config(
'upstream_mirrors.third_party_warehouse_storage.stale_after_minutes',
1560,
), 1)) {
$code = 'delayed';
} else {
$code = 'healthy';
}
return [
'code' => $code,
'last_succeeded_at' => $lastSucceeded,
'lag_minutes' => $lagMinutes,
];
}
/**
* @param array<string,mixed> $filters
* @return array{data:list<array<string,mixed>>,total:int,summary:array<string,int|float>,state:array<string,mixed>,sync:array<string,mixed>}
*/
public function paginate(array $filters, int $page, int $perPage): array
{
$state = $this->requireReadyState();
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
$query = $this->filteredReportQuery($filters, $state);
$summary = $this->summarizeQuery(clone $query);
$rows = $query->orderBy('sku_code')
->forPage($page, $perPage)
->get()
->map(fn (object $row): array => $this->mapRow($row))
->all();
return [
'data' => $rows,
'total' => $summary['count'],
'summary' => $summary,
'state' => $state,
'sync' => $this->syncStatus($state),
];
}
/**
* @param array<string,mixed> $filters
* @return array{count:int,opening_qty:float,closing_qty:float,opening_amount:float,closing_amount:float}
*/
public function summary(array $filters): array
{
$state = $this->requireReadyState();
return $this->summarizeQuery($this->filteredReportQuery($filters, $state));
}
/**
* @param array<string,mixed> $filters
* @return array{suppliers:list<string>,categories:list<string>}
*/
public function filterOptions(array $filters = []): array
{
$state = $this->requireReadyState();
$query = $this->filteredReportQuery($filters, $state);
$suppliers = (clone $query)->whereNotNull('supplier')->where('supplier', '!=', '')
->distinct()->orderBy('supplier')->pluck('supplier')
->map(static fn (mixed $value): string => (string) $value)->all();
$categories = (clone $query)->whereNotNull('category')->where('category', '!=', '')
->distinct()->orderBy('category')->pluck('category')
->map(static fn (mixed $value): string => (string) $value)->all();
return ['suppliers' => $suppliers, 'categories' => $categories];
}
/**
* @param array<string,mixed> $filters
* @return Generator<int,array<string,mixed>>
*/
public function exportRows(array $filters, int $batchSize = 500): Generator
{
$state = $this->requireReadyState();
$batchSize = min(max($batchSize, 1), 5000);
$lastSku = null;
while (true) {
$query = $this->filteredReportQuery($filters, $state);
if ($lastSku !== null) {
$query->where('sku_code', '>', $lastSku);
}
$rows = $query->orderBy('sku_code')->limit($batchSize)->get();
if ($rows->isEmpty()) {
return;
}
foreach ($rows as $row) {
yield $this->mapRow($row);
}
$lastSku = (string) $rows->last()->sku_code;
if ($rows->count() < $batchSize) {
return;
}
}
}
/** @param array<string,mixed> $state */
private function filteredReportQuery(array $filters, array $state): Builder
{
$query = $this->connection->query()->fromSub(
$this->reportQuery($filters, $state),
'report',
);
$this->containsFilter($query, 'sku_code', $filters['sku_code'] ?? '');
$this->containsFilter($query, 'product_name', $filters['product_name'] ?? '');
$this->containsFilter($query, 'supplier', $filters['supplier'] ?? '');
$this->containsFilter($query, 'category', $filters['category'] ?? '');
$nonZero = ['opening_qty', 'closing_qty', ...array_keys(self::MOVEMENT_COLUMNS)];
$query->where(function (Builder $values) use ($nonZero): void {
foreach ($nonZero as $index => $column) {
$method = $index === 0 ? 'where' : 'orWhere';
$values->{$method}($column, '<>', 0);
}
});
return $query;
}
/** @param array<string,mixed> $state */
private function reportQuery(array $filters, array $state): Builder
{
$period = $this->period($filters);
$headerGeneration = $this->generation($state, 'active_header_generation');
$itemGeneration = $this->generation($state, 'active_item_generation');
$inventoryGeneration = $this->generation($state, 'active_inventory_generation');
$anchorAt = $this->stateAnchor($state);
if ($headerGeneration === null || $itemGeneration === null || $inventoryGeneration === null) {
throw new RuntimeException('third_party_warehouse_mirror_not_ready');
}
$skuRangeStart = min($period['start'], $anchorAt);
$skuRangeEnd = max($period['end'], $anchorAt);
$movementSkus = $this->confirmedMovements($headerGeneration, $itemGeneration)
->where('h.occurred_at', '>=', $skuRangeStart)
->where('h.occurred_at', '<', $skuRangeEnd)
->select('i.sku_code');
$skuUnion = $this->connection->table(self::INVENTORY_TABLE . ' as inventory_skus')
->where('inventory_skus.generation', $inventoryGeneration)
->where('inventory_skus.wms_co_id', self::WAREHOUSE_ID)
->whereNotNull('inventory_skus.sku_code')
->where('inventory_skus.sku_code', '!=', '')
->select('inventory_skus.sku_code')
->union($movementSkus);
$anchors = $this->connection->query()->fromSub($skuUnion, 'sku_set')
->leftJoin(self::INVENTORY_TABLE . ' as inventory', function ($join) use ($inventoryGeneration): void {
$join->on('inventory.sku_code', '=', 'sku_set.sku_code')
->where('inventory.generation', '=', $inventoryGeneration)
->where('inventory.wms_co_id', '=', self::WAREHOUSE_ID);
})
->select('sku_set.sku_code')
->selectRaw('COALESCE(inventory.qty, 0) AS snapshot_qty')
->selectRaw('COALESCE(inventory.captured_at, ?) AS snapshot_at', [$anchorAt]);
$signedQuantity = '(COALESCE(movement_qty, 0) * COALESCE(movement_qty_type, 0))';
$closingExpression = 'a.snapshot_qty + COALESCE(SUM(CASE '
. 'WHEN a.snapshot_at < ? AND mv.occurred_at > a.snapshot_at AND mv.occurred_at < ? THEN ' . $signedQuantity . ' '
. 'WHEN a.snapshot_at >= ? AND mv.occurred_at >= ? AND mv.occurred_at < a.snapshot_at THEN -' . $signedQuantity . ' '
. 'ELSE 0 END), 0)';
$allMovements = $this->confirmedMovements($headerGeneration, $itemGeneration)
->select([
'i.sku_code',
'i.qty as movement_qty',
'i.qty_type as movement_qty_type',
'h.occurred_at',
]);
$balances = $this->connection->query()->fromSub($anchors, 'a')
->leftJoinSub($allMovements, 'mv', 'mv.sku_code', '=', 'a.sku_code')
->groupBy('a.sku_code', 'a.snapshot_qty', 'a.snapshot_at')
->select('a.sku_code')
->selectRaw($closingExpression . ' AS closing_qty', [
$period['end'], $period['end'], $period['end'], $period['end'],
]);
$periodMovements = $this->confirmedMovements($headerGeneration, $itemGeneration)
->where('h.occurred_at', '>=', $period['start'])
->where('h.occurred_at', '<', $period['end'])
->groupBy('i.sku_code')
->select('i.sku_code');
foreach (self::MOVEMENT_COLUMNS as $column => $type) {
$periodMovements->selectRaw(
'COALESCE(SUM(CASE WHEN h.io_type = ? THEN '
. '(COALESCE(i.qty, 0) * COALESCE(i.qty_type, 0)) ELSE 0 END), 0) AS ' . $column,
[$type],
);
}
$latestProducts = $this->connection->table(self::PRODUCT_TABLE)
->where('sku_type', 'normal')
->whereNotNull('sku_id')->where('sku_id', '!=', '')
->groupBy('sku_id')->select('sku_id')->selectRaw('MAX(autoid) AS autoid');
$report = $this->connection->query()->fromSub($balances, 'b')
->leftJoinSub($periodMovements, 'm', 'm.sku_code', '=', 'b.sku_code')
->leftJoinSub($latestProducts, 'latest_product', 'latest_product.sku_id', '=', 'b.sku_code')
->leftJoin(self::PRODUCT_TABLE . ' as s', function ($join): void {
$join->on('s.sku_id', '=', 'latest_product.sku_id')
->on('s.autoid', '=', 'latest_product.autoid');
});
$movementSelects = [];
foreach (array_keys(self::MOVEMENT_COLUMNS) as $column) {
$movementSelects[$column] = 'COALESCE(m.' . $column . ', 0)';
}
$netMovement = implode(' + ', array_values($movementSelects));
$openingQuantity = '(b.closing_qty - (' . $netMovement . '))';
$openingPrice = $this->priceAtExpression($period['start'], true);
$closingPrice = $this->priceAtExpression($period['end'], false);
$owner = (string) config(
'upstream_mirrors.third_party_warehouse_storage.owner_name',
'常州简尚家居有限公司',
);
$report->selectRaw('? AS owner', [$owner])
->addSelect([
'b.sku_code',
$this->connection->raw('COALESCE(s.pic_big, s.pic) AS image_url'),
$this->connection->raw('s.name AS product_name'),
$this->connection->raw('COALESCE(s.short_name, s.name) AS short_name'),
$this->connection->raw('s.supplier_name AS supplier'),
$this->connection->raw('s.category AS category'),
$this->connection->raw('s.vc_name AS virtual_category'),
$this->connection->raw('s.supplier_sku_id AS supplier_style_no'),
$this->connection->raw('COALESCE(s.properties_value, s.color) AS color_spec'),
$this->connection->raw('s.brand AS brand'),
$this->connection->raw('s.unit AS unit'),
])
->selectRaw($openingQuantity . ' AS opening_qty')
->selectRaw('b.closing_qty AS closing_qty');
foreach ($movementSelects as $column => $expression) {
$report->selectRaw($expression . ' AS ' . $column);
}
$report->selectRaw('0 AS opening_aux')
->selectRaw(
'CASE WHEN COALESCE(m.sales_out, 0) = 0 THEN NULL '
. 'ELSE ROUND((1.0 * b.closing_qty / ABS(m.sales_out)) * ?, 0) END AS turnover_days',
[$period['days']],
)
->selectRaw(
'ROUND(' . $openingQuantity . ' * (' . $openingPrice['sql'] . '), 2) AS opening_amount',
$openingPrice['bindings'],
)
->selectRaw(
'ROUND(b.closing_qty * (' . $closingPrice['sql'] . '), 2) AS closing_amount',
$closingPrice['bindings'],
)
->addSelect([
$this->connection->raw('s.other_price_1 AS price_type'),
$this->connection->raw('s.other_price_2 AS order_type'),
$this->connection->raw('s.other_price_3 AS other_price_3'),
$this->connection->raw('s.other_price_4 AS other_price_4'),
$this->connection->raw('s.pack_qty AS package_count'),
$this->connection->raw('s.other_1 AS model_spec'),
$this->connection->raw('s.other_2 AS board_type'),
$this->connection->raw('s.other_3 AS purchase_type'),
$this->connection->raw('s.other_4 AS other_attribute_4'),
$this->connection->raw('s.other_5 AS production_status'),
$this->connection->raw('s.other_6 AS other_attribute_6'),
$this->connection->raw('s.other_7 AS item_no'),
$this->connection->raw('s.other_8 AS single_double_package'),
$this->connection->raw('s.other_9 AS drawing_id'),
$this->connection->raw('s.other_10 AS material'),
]);
return $report;
}
private function confirmedMovements(string $headerGeneration, string $itemGeneration): Builder
{
return $this->connection->table(self::ITEM_TABLE . ' as i')
->join(self::HEADER_TABLE . ' as h', function ($join) use ($headerGeneration): void {
$join->on('h.io_id', '=', 'i.io_id')
->where('h.generation', '=', $headerGeneration);
})
->where('i.generation', $itemGeneration)
->where('h.wms_co_id', self::WAREHOUSE_ID)
->where('h.status', 'Confirmed')
->whereIn('h.io_type', array_values(self::MOVEMENT_COLUMNS))
->whereNotExists(function (Builder $newer) use ($headerGeneration): void {
$newer->selectRaw('1')->from(self::HEADER_TABLE . ' as newer')
->where('newer.generation', $headerGeneration)
->whereColumn('newer.io_id', 'h.io_id')
->whereColumn('newer.id', '>', 'h.id');
})
->whereNotNull('i.sku_code')->where('i.sku_code', '!=', '');
}
/**
* @param array<string,mixed> $filters
* @return array{dimension:string,value:string,start:string,end:string,days:int}
*/
private function period(array $filters): array
{
$dimension = ($filters['dimension'] ?? 'month') === 'day' ? 'day' : 'month';
$candidate = trim((string) ($filters['period'] ?? ''));
if ($dimension === 'day') {
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $candidate) !== 1) {
$candidate = trim((string) ($filters['date'] ?? ''));
}
try {
$start = preg_match('/^\d{4}-\d{2}-\d{2}$/', $candidate) === 1
? Carbon::createFromFormat('!Y-m-d', $candidate)
: now()->startOfDay();
} catch (Throwable) {
$start = now()->startOfDay();
}
return [
'dimension' => 'day',
'value' => $start->format('Y-m-d'),
'start' => $start->format('Y-m-d 00:00:00'),
'end' => $start->copy()->addDay()->format('Y-m-d 00:00:00'),
'days' => 1,
];
}
if (preg_match('/^\d{4}-\d{2}$/', $candidate) !== 1) {
$candidate = trim((string) ($filters['month'] ?? ''));
}
try {
$start = preg_match('/^\d{4}-\d{2}$/', $candidate) === 1
? Carbon::createFromFormat('!Y-m', $candidate)->startOfMonth()
: now()->startOfMonth();
} catch (Throwable) {
$start = now()->startOfMonth();
}
return [
'dimension' => 'month',
'value' => $start->format('Y-m'),
'start' => $start->format('Y-m-d 00:00:00'),
'end' => $start->copy()->addMonth()->format('Y-m-d 00:00:00'),
'days' => $start->daysInMonth,
];
}
/** @param array<string,mixed> $state */
private function stateAnchor(array $state): string
{
$timestamp = $this->timestamp($state['last_succeeded_at'] ?? null);
return $timestamp === null
? now()->toDateTimeString()
: Carbon::createFromTimestamp($timestamp)->toDateTimeString();
}
/** @return array<string,mixed> */
private function requireReadyState(): array
{
$state = $this->state();
if (! is_array($state)
|| ($state['initial_completed_at'] ?? null) === null
|| $this->generation($state, 'active_header_generation') === null
|| $this->generation($state, 'active_item_generation') === null
|| $this->generation($state, 'active_inventory_generation') === null
|| ! $this->dependenciesReady()) {
throw new RuntimeException('third_party_warehouse_mirror_not_ready');
}
return $state;
}
private function dependenciesReady(): bool
{
try {
$required = [self::PRODUCT_DATASET_KEY, self::PRICE_CHANGE_DATASET_KEY];
$ready = $this->connection->table(self::GENERIC_STATE_TABLE)
->whereIn('dataset_key', $required)
->whereNotNull('initial_completed_at')
->pluck('dataset_key')
->map(static fn (mixed $dataset): string => (string) $dataset)
->all();
return array_diff($required, $ready) === [];
} catch (Throwable) {
return false;
}
}
/** @return array{sql:string,bindings:list<mixed>} */
private function priceAtExpression(string $boundary, bool $opening): array
{
$table = self::PRICE_CHANGE_TABLE;
$parts = [];
$bindings = [];
if ($opening) {
$parts[] = "(SELECT exact_price.originalPrice FROM {$table} AS exact_price "
. 'WHERE exact_price.skucode = b.sku_code AND exact_price.priceType = ? '
. 'AND exact_price.effectStartTime = ? ORDER BY exact_price.Id DESC LIMIT 1)';
array_push($bindings, 1, $boundary);
}
$parts[] = "(SELECT prior_price.afterPrice FROM {$table} AS prior_price "
. 'WHERE prior_price.skucode = b.sku_code AND prior_price.priceType = ? '
. 'AND prior_price.effectStartTime < ? '
. 'ORDER BY prior_price.effectStartTime DESC, prior_price.Id DESC LIMIT 1)';
array_push($bindings, 1, $boundary);
$parts[] = "(SELECT future_price.originalPrice FROM {$table} AS future_price "
. 'WHERE future_price.skucode = b.sku_code AND future_price.priceType = ? '
. 'AND future_price.effectStartTime >= ? '
. 'ORDER BY future_price.effectStartTime ASC, future_price.Id ASC LIMIT 1)';
array_push($bindings, 1, $boundary);
$parts[] = 's.purchase_price';
return [
'sql' => 'COALESCE(' . implode(', ', $parts) . ')',
'bindings' => $bindings,
];
}
/** @param array<string,mixed> $state */
private function generation(array $state, string $key): ?string
{
$value = trim((string) ($state[$key] ?? ''));
return $value === '' ? null : $value;
}
private function timestamp(mixed $value): ?int
{
if ($value instanceof CarbonInterface) {
return $value->getTimestamp();
}
if (! is_string($value) || trim($value) === '') {
return null;
}
try {
return Carbon::parse($value)->getTimestamp();
} catch (Throwable) {
return null;
}
}
/**
* @return array{count:int,opening_qty:float,closing_qty:float,opening_amount:float,closing_amount:float}
*/
private function summarizeQuery(Builder $query): array
{
$row = $query->selectRaw(
'COUNT(*) AS row_count, '
. 'COALESCE(SUM(opening_qty), 0) AS opening_qty, '
. 'COALESCE(SUM(closing_qty), 0) AS closing_qty, '
. 'COALESCE(SUM(opening_amount), 0) AS opening_amount, '
. 'COALESCE(SUM(closing_amount), 0) AS closing_amount',
)->first();
return [
'count' => (int) ($row->row_count ?? 0),
'opening_qty' => round((float) ($row->opening_qty ?? 0), 4),
'closing_qty' => round((float) ($row->closing_qty ?? 0), 4),
'opening_amount' => round((float) ($row->opening_amount ?? 0), 2),
'closing_amount' => round((float) ($row->closing_amount ?? 0), 2),
];
}
/** @return array<string,mixed> */
private function mapRow(object $row): array
{
$mapped = [];
foreach (self::OUTPUT_COLUMNS as $column) {
$mapped[$column] = $row->{$column} ?? null;
}
foreach (['opening_qty', 'closing_qty', ...array_keys(self::MOVEMENT_COLUMNS), 'opening_aux'] as $column) {
$mapped[$column] = round((float) ($mapped[$column] ?? 0), 4);
}
foreach (['opening_amount', 'closing_amount'] as $column) {
$mapped[$column] = $mapped[$column] === null ? null : round((float) $mapped[$column], 2);
}
$mapped['turnover_days'] = $mapped['turnover_days'] === null
? null
: (int) round((float) $mapped['turnover_days']);
return $mapped;
}
private function containsFilter(Builder $query, string $column, mixed $value): void
{
$value = trim((string) $value);
if ($value !== '') {
$query->whereRaw($column . " LIKE ? ESCAPE '!'", [$this->contains($value)]);
}
}
private function contains(string $value): string
{
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%';
}
}