修复生产报表查询超时 #255

Merged
WuYongJie merged 1 commits from hotfix/WuYongJie-production-query-timeouts into main 2026-09-23 19:08:47 +08:00
4 changed files with 345 additions and 117 deletions

View File

@ -3,6 +3,8 @@
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
@ -11,6 +13,8 @@ use RuntimeException;
/** 店铺收款账单·淘系支付宝账户只读经营分析副本镜像。 */
class StoreReceivingAlipayBillMirrorRepository
{
private const PAGE_QUERY_TIMEOUT_SECONDS = 30;
/** 淘系支付宝页面不展示其他平台店铺;头条放心购是镜像中的抖音平台名称。 */
private const EXCLUDED_TAOBAO_SHOP_SITE_PREFIXES = [
'拼多多', '京东', '抖音', '头条放心购', '得物', '唯品会',
@ -83,8 +87,20 @@ class StoreReceivingAlipayBillMirrorRepository
&& $this->channelCoversDataset($channel, $dataset->fixedFilters()) && ! $hasFilters
&& (($state['status'] ?? null) === 'succeeded' || (bool) ($state['mirror_row_count_is_exact'] ?? false))
&& $recordedCount !== null && ctype_digit((string) $recordedCount);
$total = $useRecordedCount ? (int) $recordedCount : (clone $query)->count();
$summary = $this->summary($query, $filters, $channel);
$summary = Cache::remember(
$this->aggregateCacheKey(
$datasetKey,
$filters,
$channel,
$excludeNonTaobaoPlatforms,
$state,
),
120,
fn (): array => $this->summary($query, $filters, $channel),
);
$total = $useRecordedCount
? (int) $recordedCount
: ($summary['count'] ?? $this->boundedCount(clone $query));
$billRows = $this->pageRows(
$query, $sortColumn, $direction, $page, $perPage, $filters, $channel, $targetTable,
@ -268,29 +284,32 @@ class StoreReceivingAlipayBillMirrorRepository
string $targetTable,
): array {
if ($channel === null || ($filters['bill_item'] ?? '') === '') {
return $query->select('b.*')
$pageQuery = $query->select('b.*')
->orderBy($sortColumn, $direction)
->orderBy('b.Id', $direction)
->forPage($page, $perPage)
->get()
->all();
->forPage($page, $perPage);
return $this->boundedGet($pageQuery)->all();
}
$idQuery = clone $query;
if ($targetTable === 'ops_jst_original_bill_info' && $idQuery->getConnection()->getDriverName() === 'mysql') {
$idQuery->forceIndex('idx_original_bill_channel_shop');
}
$ids = $idQuery->select('b.Id')
$idQuery->select('b.Id')
->orderBy($sortColumn, $direction)
->orderBy('b.Id', $direction)
->forPage($page, $perPage)
->pluck('b.Id')
->forPage($page, $perPage);
$ids = $this->boundedGet($idQuery)
->pluck('Id')
->all();
if ($ids === []) {
return [];
}
$rowsById = $this->billQuery($targetTable)->whereIn('b.Id', $ids)->select('b.*')->get()
$rowsById = $this->boundedGet(
$this->billQuery($targetTable)->whereIn('b.Id', $ids)->select('b.*'),
)
->keyBy(static fn (object $row): string => (string) $row->Id);
return collect($ids)
@ -300,7 +319,7 @@ class StoreReceivingAlipayBillMirrorRepository
->all();
}
/** @return array{income:float,expense:float} */
/** @return array{income:float,expense:float,count:?int} */
private function summary(
Builder $billQuery,
array $filters,
@ -310,18 +329,21 @@ class StoreReceivingAlipayBillMirrorRepository
if ($channel === null && $this->dailySummarySupports($filters)) {
$summary = $this->dailySummary($filters);
if ($summary !== null) {
return $summary;
return $summary + ['count' => null];
}
}
$row = (clone $billQuery)->selectRaw(
"COALESCE(SUM(CASE WHEN b.inout_type = '1' THEN ABS(COALESCE(b.amount, 0)) ELSE 0 END), 0) AS income, "
$aggregate = (clone $billQuery)->selectRaw(
"COUNT(*) AS aggregate_count, "
. "COALESCE(SUM(CASE WHEN b.inout_type = '1' THEN ABS(COALESCE(b.amount, 0)) ELSE 0 END), 0) AS income, "
. "COALESCE(SUM(CASE WHEN b.inout_type = '-1' THEN ABS(COALESCE(b.amount, 0)) ELSE 0 END), 0) AS expense"
)->first();
);
$row = $this->boundedGet($aggregate)->first();
return [
'income' => round((float) ($row->income ?? 0), 2),
'expense' => round((float) ($row->expense ?? 0), 2),
'count' => (int) ($row->aggregate_count ?? 0),
];
}
@ -758,6 +780,62 @@ class StoreReceivingAlipayBillMirrorRepository
return $value === null || $value === '' ? '' : date('Y-m-d H:i:s', strtotime((string) $value));
}
/** @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::PAGE_QUERY_TIMEOUT_SECONDS . ' FOR ' . $query->toSql(),
$query->getBindings(),
));
}
private function boundedCount(Builder $query): int
{
$count = $query->getConnection()->query()
->fromSub($query, 'bounded_count')
->selectRaw('COUNT(*) AS aggregate');
return (int) ($this->boundedGet($count)->first()->aggregate ?? 0);
}
/** @param array<string,mixed> $filters @param array<string,mixed> $state */
private function aggregateCacheKey(
string $datasetKey,
array $filters,
string|array|null $channel,
bool $excludeNonTaobaoPlatforms,
array $state,
): string {
$normalizedFilters = array_filter(
array_map(
static fn (mixed $value): string => trim((string) $value),
$filters,
),
static fn (string $value): bool => $value !== '',
);
ksort($normalizedFilters);
$normalizedChannel = is_array($channel) ? array_map('strval', $channel) : $channel;
if (is_array($normalizedChannel)) {
sort($normalizedChannel);
}
return 'store-receiving-bills:aggregate:v2:' . hash('sha256', (string) json_encode([
'dataset' => $datasetKey,
'filters' => $normalizedFilters,
'channel' => $normalizedChannel,
'exclude_non_taobao' => $excludeNonTaobaoPlatforms,
'status' => $state['status'] ?? null,
'last_succeeded_at' => $state['last_succeeded_at'] ?? null,
'mirror_row_count' => $state['mirror_row_count'] ?? null,
'mirror_row_count_is_exact' => $state['mirror_row_count_is_exact'] ?? null,
], JSON_UNESCAPED_UNICODE));
}
private function escapeLike(string $value): string
{
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value);

View File

@ -8,6 +8,8 @@ use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
use Throwable;
@ -20,6 +22,8 @@ use Throwable;
*/
class ThirdPartyWarehouseStorageMirrorRepository
{
private const REPORT_QUERY_TIMEOUT_SECONDS = 30;
public const DATASET_KEY = 'jiansun.third-party-warehouse-storage';
public const WAREHOUSE_ID = 15045364;
@ -305,13 +309,13 @@ class ThirdPartyWarehouseStorageMirrorRepository
$period = $this->inventoryReportPeriod((string) ($filters['month'] ?? ''));
$authoritative = $this->monthlyInventoryAuthoritativeQuery($period, $filters);
if ($authoritative !== null) {
$total = $includeTotal ? (clone $authoritative)->count() : 0;
$rows = (clone $authoritative)
$total = $includeTotal ? $this->boundedCount(clone $authoritative) : 0;
$authoritativePage = (clone $authoritative)
->orderBy('warehouse_provider')
->orderBy('warehouse')
->orderBy('product_code')
->forPage($page, $perPage)
->get()
->forPage($page, $perPage);
$rows = $this->boundedGet($authoritativePage)
->map(static fn (object $row): object => (object) [
'owner' => $row->owner,
'month' => $row->report_month,
@ -351,17 +355,38 @@ class ThirdPartyWarehouseStorageMirrorRepository
throw new RuntimeException('inventory_report_mirror_not_ready');
}
$rolledForward = $this->monthlyInventoryRollForward(
$period,
$filters,
$headerGeneration,
$itemGeneration,
$inventoryGeneration,
$page,
$perPage,
$includeTotal,
$state,
);
$authority = $this->previousMonthlyInventoryAuthority($period);
$resolveRollForward = fn (): ?array => $authority === null
? null
: $this->monthlyInventoryRollForward(
$period,
$filters,
$authority,
$headerGeneration,
$itemGeneration,
$inventoryGeneration,
$page,
$perPage,
$includeTotal,
$state,
);
$rolledForward = $includeTotal && $perPage <= 100 && $authority !== null
? Cache::remember(
$this->monthlyInventoryReportCacheKey(
$period,
$filters,
$authority,
$headerGeneration,
$itemGeneration,
$inventoryGeneration,
$page,
$perPage,
$state,
),
120,
$resolveRollForward,
)
: $resolveRollForward();
if ($rolledForward !== null) {
return $rolledForward;
}
@ -412,11 +437,11 @@ class ThirdPartyWarehouseStorageMirrorRepository
$this->containsFilter($candidates, 's.category', $categoryFilter);
}
$total = $includeTotal ? (clone $candidates)->count() : 0;
$total = $includeTotal ? $this->boundedCount(clone $candidates) : 0;
$pagePairs = (clone $candidates)
->orderBy('pairs.wms_co_id')->orderBy('pairs.wh_id')->orderBy('s.sku_id')
->forPage($page, $perPage);
$pairRows = (clone $pagePairs)->get();
$pairRows = $this->boundedGet(clone $pagePairs);
if ($pairRows->isEmpty()) {
return [
'data' => [],
@ -492,8 +517,9 @@ class ThirdPartyWarehouseStorageMirrorRepository
}
$monthly->selectRaw('0 AS opening_aux');
$rows = $this->connection->query()->fromSub($monthly, 'm')
->select('m.*')->orderBy('m.warehouse_name')->orderBy('m.wh_id')->orderBy('m.sku_code')->get()
$monthlyRows = $this->connection->query()->fromSub($monthly, 'm')
->select('m.*')->orderBy('m.warehouse_name')->orderBy('m.wh_id')->orderBy('m.sku_code');
$rows = $this->boundedGet($monthlyRows)
->map(function (object $row) use ($period): object {
$warehouse = trim((string) ($row->warehouse_name ?? ''));
$subwarehouseId = (int) ($row->wh_id ?? 0);
@ -607,7 +633,7 @@ class ThirdPartyWarehouseStorageMirrorRepository
$state = $this->connection->table(self::INVENTORY_REPORT_AUTHORITATIVE_STATE_TABLE)
->where('dataset_key', self::INVENTORY_REPORT_AUTHORITATIVE_DATASET_KEY)
->whereNotNull('active_generation')
->whereDate('source_month', $month)
->where('source_month', $month)
->first();
if ($state === null || trim((string) $state->active_generation) === '') {
return null;
@ -615,7 +641,7 @@ class ThirdPartyWarehouseStorageMirrorRepository
$query = $this->connection->table(self::INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE)
->where('generation', (string) $state->active_generation)
->whereDate('report_month', $month)
->where('report_month', $month)
->select([
'owner', 'report_month', 'warehouse_provider', 'warehouse',
'product_code', 'category', 'opening_quantity', 'closing_quantity',
@ -644,12 +670,14 @@ class ThirdPartyWarehouseStorageMirrorRepository
*
* @param array{start:string,end:string} $period
* @param array<string,mixed> $filters
* @param array{generation:string,source_month:string} $authority
* @param array<string,mixed> $state
* @return array{data:list<object>,total:int,state:array<string,mixed>,sync:array<string,mixed>}|null
*/
private function monthlyInventoryRollForward(
array $period,
array $filters,
array $authority,
string $headerGeneration,
string $itemGeneration,
string $inventoryGeneration,
@ -659,11 +687,6 @@ class ThirdPartyWarehouseStorageMirrorRepository
array $state,
): ?array {
try {
$authority = $this->previousMonthlyInventoryAuthority($period);
if ($authority === null) {
return null;
}
$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');
@ -674,7 +697,7 @@ class ThirdPartyWarehouseStorageMirrorRepository
$authorityRows = $this->connection
->table(self::INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE . ' as authority')
->where('authority.generation', $authority['generation'])
->whereDate('authority.report_month', $authority['source_month']);
->where('authority.report_month', $authority['source_month']);
$productCode = trim((string) ($filters['product_code'] ?? ''));
if ($productCode !== '') {
$authorityRows->where('authority.product_code', $productCode);
@ -734,7 +757,7 @@ class ThirdPartyWarehouseStorageMirrorRepository
$prior->selectRaw('1')
->from(self::INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE . ' as prior')
->where('prior.generation', $authority['generation'])
->whereDate('prior.report_month', $authority['source_month'])
->where('prior.report_month', $authority['source_month'])
->whereColumn('prior.product_code', 'inventory.sku_code')
->whereColumn('prior.warehouse_provider', 'w.name')
->whereRaw('prior.warehouse = ' . $inventoryWarehouseSql, $inventoryWarehouseBindings);
@ -751,12 +774,20 @@ class ThirdPartyWarehouseStorageMirrorRepository
$candidateUnion = $authorityCandidates->unionAll($inventoryDimensions);
$candidates = $this->connection->query()->fromSub($candidateUnion, 'candidate_rows');
$total = $includeTotal ? $this->connection->query()->fromSub(clone $candidates, 'counted')->count() : 0;
$pageCandidates = (clone $candidates)
->orderBy('warehouse_provider')->orderBy('wh_id')->orderBy('sku_code')
->forPage($page, $perPage);
$pairRows = (clone $pageCandidates)->get();
if ($includeTotal) {
$pageCandidates->select('candidate_rows.*')->selectRaw('COUNT(*) OVER() AS report_total');
}
$pairRows = $this->boundedGet(clone $pageCandidates);
$total = $includeTotal ? (int) ($pairRows->first()->report_total ?? 0) : 0;
if ($pairRows->isEmpty()) {
if ($includeTotal && $page > 1) {
$total = $this->boundedCount(clone $authorityRows)
+ $this->boundedCount(clone $inventoryDimensions);
}
return [
'data' => [],
'total' => $total,
@ -767,77 +798,91 @@ class ThirdPartyWarehouseStorageMirrorRepository
$authorityEnd = Carbon::parse($authority['source_month'])
->addMonth()->startOfMonth()->format('Y-m-d 00:00:00');
$movements = $this->confirmedMovements($headerGeneration, $itemGeneration, null)
->where('h.occurred_at', '>=', $authorityEnd)
->where('h.occurred_at', '<', $period['end'])
->whereIn('h.wms_co_id', $pairRows->pluck('wms_co_id')->filter()->unique()->values()->all())
->whereIn('i.sku_code', $pairRows->pluck('sku_code')->unique()->values()->all())
->select([
'h.wms_co_id', 'h.io_type', 'h.occurred_at', 'i.sku_code',
'i.qty as movement_qty', 'i.qty_type as movement_qty_type',
])->selectRaw('COALESCE(NULLIF(h.wh_id, 0), 1) AS wh_id');
$signed = '(COALESCE(mv.movement_qty, 0) * COALESCE(mv.movement_qty_type, 0))';
$monthly = $this->connection->query()->fromSub($pageCandidates, 'a')
->leftJoinSub($movements, 'mv', function ($join): void {
$join->on('mv.wms_co_id', '=', 'a.wms_co_id')
->on('mv.wh_id', '=', 'a.wh_id')
->on('mv.sku_code', '=', 'a.sku_code');
})->groupBy([
'a.owner', 'a.warehouse_provider', 'a.source_warehouse', 'a.wms_co_id',
'a.wh_id', 'a.sku_code', 'a.category', 'a.opening_qty',
])->select([
'a.owner', 'a.warehouse_provider', 'a.source_warehouse', 'a.wms_co_id',
'a.wh_id', 'a.sku_code', 'a.category', 'a.opening_qty',
]);
$monthly->selectRaw(
'a.opening_qty + COALESCE(SUM(CASE WHEN mv.occurred_at >= ? AND mv.occurred_at < ? '
. 'THEN ' . $signed . ' ELSE 0 END), 0) AS rolled_opening_qty',
[$authorityEnd, $period['start']],
);
foreach (self::MOVEMENT_COLUMNS as $column => $type) {
$expression = 'COALESCE(SUM(CASE WHEN mv.occurred_at >= ? AND mv.occurred_at < ? '
. 'AND mv.io_type = ? THEN ' . $signed . ' ELSE 0 END), 0)';
$monthly->selectRaw($expression . ' AS ' . $column, [$period['start'], $period['end'], $type]);
$movementPairs = $pairRows->filter(
static fn (object $row): bool => $row->wms_co_id !== null && trim((string) $row->sku_code) !== '',
)->unique(static fn (object $row): string => implode(':', [
(string) $row->wms_co_id,
(string) $row->wh_id,
(string) $row->sku_code,
]))->values();
$movementRows = collect();
if ($movementPairs->isNotEmpty()) {
$movements = $this->confirmedMovements($headerGeneration, $itemGeneration, null)
->where('h.occurred_at', '>=', $authorityEnd)
->where('h.occurred_at', '<', $period['end'])
->where(function (Builder $pairs) use ($movementPairs): void {
foreach ($movementPairs as $pair) {
$pairs->orWhere(function (Builder $candidate) use ($pair): void {
$candidate->where('h.wms_co_id', $pair->wms_co_id)
->whereRaw('COALESCE(NULLIF(h.wh_id, 0), 1) = ?', [(int) $pair->wh_id])
->where('i.sku_code', $pair->sku_code);
});
}
})
->groupBy(['h.wms_co_id', 'i.sku_code'])
->groupByRaw('COALESCE(NULLIF(h.wh_id, 0), 1)')
->select(['h.wms_co_id', 'i.sku_code'])
->selectRaw('COALESCE(NULLIF(h.wh_id, 0), 1) AS wh_id');
$signed = '(COALESCE(i.qty, 0) * COALESCE(i.qty_type, 0))';
$movements->selectRaw(
'COALESCE(SUM(CASE WHEN h.occurred_at >= ? AND h.occurred_at < ? '
. 'THEN ' . $signed . ' ELSE 0 END), 0) AS rolled_opening_delta',
[$authorityEnd, $period['start']],
);
foreach (self::MOVEMENT_COLUMNS as $column => $type) {
$expression = 'COALESCE(SUM(CASE WHEN h.occurred_at >= ? AND h.occurred_at < ? '
. 'AND h.io_type = ? THEN ' . $signed . ' ELSE 0 END), 0)';
$movements->selectRaw($expression . ' AS ' . $column, [$period['start'], $period['end'], $type]);
}
$movementRows = $this->boundedGet($movements)->keyBy(
static fn (object $row): string => implode(':', [
(string) $row->wms_co_id,
(string) $row->wh_id,
(string) $row->sku_code,
]),
);
}
$rows = $monthly->orderBy('a.warehouse_provider')->orderBy('a.wh_id')->orderBy('a.sku_code')
->get()->map(function (object $row) use ($period): object {
$sourceWarehouse = trim((string) ($row->source_warehouse ?? ''));
$warehouse = $sourceWarehouse !== ''
? $sourceWarehouse
: $this->subwarehouseName(
trim((string) $row->warehouse_provider),
(int) $row->wh_id,
);
$closingQuantity = (float) $row->rolled_opening_qty;
foreach (array_keys(self::MOVEMENT_COLUMNS) as $column) {
$closingQuantity += (float) $row->{$column};
}
$rows = $pairRows->map(function (object $row) use ($movementRows, $period): object {
$key = implode(':', [(string) $row->wms_co_id, (string) $row->wh_id, (string) $row->sku_code]);
$movement = $movementRows->get($key);
$sourceWarehouse = trim((string) ($row->source_warehouse ?? ''));
$warehouse = $sourceWarehouse !== ''
? $sourceWarehouse
: $this->subwarehouseName(
trim((string) $row->warehouse_provider),
(int) $row->wh_id,
);
$rolledOpening = (float) $row->opening_qty + (float) ($movement->rolled_opening_delta ?? 0);
$closingQuantity = $rolledOpening;
foreach (array_keys(self::MOVEMENT_COLUMNS) as $column) {
$closingQuantity += (float) ($movement->{$column} ?? 0);
}
return (object) [
'owner' => $row->owner,
'month' => Carbon::parse($period['start'])->toDateString(),
'warehouse_provider' => $row->warehouse_provider,
'warehouse' => $warehouse,
'product_code' => $row->sku_code,
'category' => $row->category,
'opening_quantity' => $row->rolled_opening_qty,
'closing_quantity' => $closingQuantity,
'processing_in' => $row->processing_in,
'processing_out' => $row->processing_out,
'purchase_in' => $row->purchase_in,
'purchase_return' => $row->purchase_return,
'other_in' => $row->other_in,
'other_return' => $row->other_return,
'sales_out' => $row->sales_out,
'sales_return' => $row->sales_return,
'stocktake' => $row->stocktake,
'transfer_out' => $row->transfer_out,
'transfer_in' => $row->transfer_in,
'opening' => 0,
'other_out' => $row->other_out,
];
})->all();
return (object) [
'owner' => $row->owner,
'month' => Carbon::parse($period['start'])->toDateString(),
'warehouse_provider' => $row->warehouse_provider,
'warehouse' => $warehouse,
'product_code' => $row->sku_code,
'category' => $row->category,
'opening_quantity' => $rolledOpening,
'closing_quantity' => $closingQuantity,
'processing_in' => $movement->processing_in ?? 0,
'processing_out' => $movement->processing_out ?? 0,
'purchase_in' => $movement->purchase_in ?? 0,
'purchase_return' => $movement->purchase_return ?? 0,
'other_in' => $movement->other_in ?? 0,
'other_return' => $movement->other_return ?? 0,
'sales_out' => $movement->sales_out ?? 0,
'sales_return' => $movement->sales_return ?? 0,
'stocktake' => $movement->stocktake ?? 0,
'transfer_out' => $movement->transfer_out ?? 0,
'transfer_in' => $movement->transfer_in ?? 0,
'opening' => 0,
'other_out' => $movement->other_out ?? 0,
];
})->all();
return [
'data' => $rows,
@ -846,12 +891,52 @@ class ThirdPartyWarehouseStorageMirrorRepository
'sync' => $this->syncStatus($state),
];
} catch (Throwable $exception) {
if ($this->isStatementTimeout($exception)) {
throw $exception;
}
report($exception);
return null;
}
}
/**
* @param array{start:string,end:string} $period
* @param array<string,mixed> $filters
* @param array{generation:string,source_month:string} $authority
* @param array<string,mixed> $state
*/
private function monthlyInventoryReportCacheKey(
array $period,
array $filters,
array $authority,
string $headerGeneration,
string $itemGeneration,
string $inventoryGeneration,
int $page,
int $perPage,
array $state,
): string {
$normalizedFilters = [
'product_code' => trim((string) ($filters['product_code'] ?? '')),
'warehouse_provider' => trim((string) ($filters['warehouse_provider'] ?? '')),
'category' => trim((string) ($filters['category'] ?? '')),
];
return 'inventory-report:monthly-page:v2:' . hash('sha256', (string) json_encode([
'period' => $period,
'filters' => $normalizedFilters,
'authority' => $authority,
'header_generation' => $headerGeneration,
'item_generation' => $itemGeneration,
'inventory_generation' => $inventoryGeneration,
'status' => $state['status'] ?? null,
'last_succeeded_at' => $state['last_succeeded_at'] ?? null,
'page' => $page,
'per_page' => $perPage,
], JSON_UNESCAPED_UNICODE));
}
/** @param array{start:string,end:string} $period */
private function previousMonthlyInventoryAuthority(array $period): ?array
{
@ -1609,4 +1694,34 @@ class ThirdPartyWarehouseStorageMirrorRepository
{
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%';
}
/** @return Collection<int,object> */
private function boundedGet(Builder $query): Collection
{
if ($this->connection->getDriverName() !== 'mysql') {
return $query->get();
}
return collect($this->connection->select(
'SET STATEMENT max_statement_time=' . self::REPORT_QUERY_TIMEOUT_SECONDS . ' FOR ' . $query->toSql(),
$query->getBindings(),
));
}
private function boundedCount(Builder $query): int
{
$count = $this->connection->query()
->fromSub($query, 'bounded_count')
->selectRaw('COUNT(*) AS aggregate');
return (int) ($this->boundedGet($count)->first()->aggregate ?? 0);
}
private function isStatementTimeout(Throwable $exception): bool
{
$message = strtolower($exception->getMessage());
return str_contains($message, 'max_statement_time')
|| str_contains($message, 'query execution was interrupted');
}
}

View File

@ -349,7 +349,14 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase
$this->assertSame(2, $first['total']);
$this->assertSame(2, $second['total']);
$this->assertNotSame($first['data'][0]['id'], $second['data'][0]['id']);
$this->assertCount(2, array_filter($db->getQueryLog(), fn ($q) => str_contains(strtolower($q['query']), 'count(*)')));
$aggregateQueries = array_values(array_filter(
$db->getQueryLog(),
static fn (array $query): bool => str_contains(strtolower($query['query']), 'count(*)'),
));
$this->assertCount(1, $aggregateQueries);
foreach ($aggregateQueries as $query) {
$this->assertStringContainsString('sum(case', strtolower($query['query']));
}
$db->disableQueryLog();
$db->table('ops_jst_original_bill_info')->insert($this->row(3, 7, '2026-08-03 09:00:00', '1', 4));
@ -484,7 +491,7 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase
static fn (array $query): bool => str_contains($query['query'], '"b"."channel" = ?')
&& in_array('CommonBillTGCPay', $query['bindings'], true),
);
$this->assertGreaterThanOrEqual(6, count($channelQueries));
$this->assertGreaterThanOrEqual(5, count($channelQueries));
$db->disableQueryLog();
}

View File

@ -222,12 +222,18 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
'warehouse_id' => 15038426,
]);
$result = $this->repository->paginateMonthlyInventoryReport([
$db = DB::connection('business_mirror');
$db->flushQueryLog();
$db->enableQueryLog();
$filters = [
'month' => '2026-09',
'product_code' => 'SKU-ROLL-FORWARD',
'warehouse_provider' => '',
'category' => '',
], 1, 25);
];
$result = $this->repository->paginateMonthlyInventoryReport($filters, 1, 25);
$queries = $db->getQueryLog();
$db->disableQueryLog();
$rows = collect($result['data'])->keyBy('warehouse');
$this->assertSame(2, $result['total']);
@ -237,6 +243,28 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
$this->assertSame(0.0, (float) $rows['简尚-崔桥工厂仓主仓']->opening_quantity);
$this->assertSame(5.0, (float) $rows['简尚-崔桥工厂仓主仓']->closing_quantity);
$this->assertSame(5.0, (float) $rows['简尚-崔桥工厂仓主仓']->transfer_in);
$countQueries = array_values(array_filter(
$queries,
static fn (array $query): bool => str_contains(strtolower($query['query']), 'count(*)'),
));
$this->assertCount(1, $countQueries);
$this->assertStringContainsString('count(*) over()', strtolower($countQueries[0]['query']));
$candidatePage = collect($queries)
->first(static fn (array $query): bool => str_contains($query['query'], '"prior"."report_month"'));
$this->assertNotNull($candidatePage);
$this->assertStringNotContainsString('strftime', strtolower($candidatePage['query']));
$db->flushQueryLog();
$db->enableQueryLog();
$cached = $this->repository->paginateMonthlyInventoryReport($filters, 1, 25);
$cachedQueries = $db->getQueryLog();
$db->disableQueryLog();
$this->assertSame($result['total'], $cached['total']);
$this->assertCount(0, array_filter(
$cachedQueries,
static fn (array $query): bool => str_contains(strtolower($query['query']), 'report_total'),
));
}
public function test_inventory_report_rolls_multiple_months_from_the_july_authoritative_baseline(): void