修复生产报表查询超时 #255
@ -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);
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user