快递应付:修复三方仓进销存加载超时

This commit is contained in:
limeng 2026-10-12 17:22:17 +08:00
parent 26f945ba29
commit e883d262f0
5 changed files with 323 additions and 10 deletions

View File

@ -4248,7 +4248,12 @@ class Pages extends Controller
}
} catch (Throwable $exception) {
report($exception);
$error = '三方仓进销存暂时无法读取,请稍后重试。';
$message = strtolower($exception->getMessage());
$error = str_contains($message, 'max_statement_time') || str_contains($message, 'query execution was interrupted')
? '三方仓进销存查询超时,请稍后重新查询。'
: (str_contains($message, 'inventory_report_generation_busy')
? '三方仓进销存正在计算,请稍后重新查询。'
: '三方仓进销存暂时无法读取,请稍后重试。');
}
return [

View File

@ -279,6 +279,18 @@ class ThirdPartyWarehouseStorageMirrorRepository
$state = $this->requireReadyState();
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
$calculated = $this->calculatedFlowRows($filters, $state);
if ($calculated !== null) {
$summary = $this->summarizeRows($calculated);
return [
'data' => array_slice($calculated, ($page - 1) * $perPage, $perPage),
'total' => $summary['count'],
'summary' => $summary,
'state' => $state,
'sync' => $this->syncStatus($state),
];
}
$query = $this->filteredReportQuery($filters, $state);
$summary = $this->summarizeQuery($this->filteredReportQuery(
$filters,
@ -1227,7 +1239,7 @@ class ThirdPartyWarehouseStorageMirrorRepository
}
/** @param callable():?array $resolver */
private function rememberMonthlyInventoryReport(string $key, callable $resolver): ?array
private function rememberMonthlyInventoryReport(string $key, callable $resolver, int $waitSeconds = self::REPORT_CACHE_WAIT_SECONDS): ?array
{
$cached = Cache::get($key);
if (is_array($cached)) {
@ -1236,7 +1248,7 @@ class ThirdPartyWarehouseStorageMirrorRepository
try {
return Cache::lock($key . ':lock', self::REPORT_CACHE_LOCK_SECONDS)
->block(self::REPORT_CACHE_WAIT_SECONDS, function () use ($key, $resolver): ?array {
->block($waitSeconds, function () use ($key, $resolver): ?array {
$cached = Cache::get($key);
if (is_array($cached)) {
return $cached;
@ -1382,6 +1394,10 @@ class ThirdPartyWarehouseStorageMirrorRepository
public function summary(array $filters): array
{
$state = $this->requireReadyState();
$calculated = $this->calculatedFlowRows($filters, $state, $this->hasProductFilters($filters));
if ($calculated !== null) {
return $this->summarizeRows($calculated);
}
return $this->summarizeQuery($this->filteredReportQuery(
$filters,
@ -1397,6 +1413,17 @@ class ThirdPartyWarehouseStorageMirrorRepository
public function filterOptions(array $filters = []): array
{
$state = $this->requireReadyState();
$calculated = $this->calculatedFlowRows($filters, $state);
if ($calculated !== null) {
$options = [];
foreach (['suppliers' => 'supplier', 'categories' => 'category'] as $key => $field) {
$options[$key] = collect($calculated)->pluck($field)->filter(
static fn (mixed $value): bool => $value !== null && $value !== '',
)->unique()->sort()->values()->all();
}
return $options;
}
$query = $this->filteredReportQuery($filters, $state);
$suppliers = (clone $query)->whereNotNull('supplier')->where('supplier', '!=', '')
@ -1420,6 +1447,12 @@ class ThirdPartyWarehouseStorageMirrorRepository
): Generator
{
$state = $this->requireReadyState();
$calculated = $this->calculatedFlowRows($filters, $state, $includeProductDetails);
if ($calculated !== null) {
yield from $calculated;
return;
}
$batchSize = min(max($batchSize, 1), 5000);
$lastDate = null;
$lastSku = null;
@ -1456,6 +1489,184 @@ class ThirdPartyWarehouseStorageMirrorRepository
}
}
/**
* Historical imports stay on the indexed SQL path. Later periods aggregate
* confirmed movements once, then expand only the compact SKU/day balances.
* Cache quantities by published generations, independently of page/filters;
* product details remain live and are restricted to these report SKUs.
*
* @return list<array<string,mixed>>|null
*/
private function calculatedFlowRows(array $filters, array $state, bool $includeProductDetails = true): ?array
{
$period = $this->period($filters);
$authority = $this->authoritativeAnchor();
if ($this->authoritativeReportQuery($period, $authority, false) !== null) {
return null;
}
$key = 'third-party-warehouse:daily-quantities:v3:' . hash('sha256', (string) json_encode([
'database' => $this->connection->getDatabaseName(),
'period' => $period,
'authority' => $authority,
'state' => array_intersect_key($state, array_flip([
'active_header_generation', 'active_item_generation', 'active_inventory_generation', 'last_succeeded_at',
])),
]));
$rows = $this->rememberMonthlyInventoryReport(
$key,
fn (): array => $this->calculateFlowQuantities($period, $state, $authority),
5,
) ?? [];
$owner = (string) config('upstream_mirrors.third_party_warehouse_storage.owner_name', '常州简尚家居有限公司');
foreach ($rows as &$row) {
$row['owner'] = $owner;
}
unset($row);
$skuFilter = trim((string) ($filters['sku_code'] ?? ''));
if ($skuFilter !== '') {
$rows = array_values(array_filter($rows, static fn (array $row): bool =>
mb_stripos((string) $row['sku_code'], $skuFilter) !== false));
}
if ($rows === [] || (! $includeProductDetails && ! $this->hasProductFilters($filters))) {
return $rows;
}
$skus = array_values(array_unique(array_column($rows, 'sku_code')));
$latest = $this->connection->table(self::PRODUCT_TABLE)
->where('sku_type', 'normal')->whereIn('sku_id', $skus)
->groupBy('sku_id')->select('sku_id')->selectRaw('MAX(autoid) AS autoid');
$products = $this->connection->query()->fromSub($latest, 'latest')
->join(self::PRODUCT_TABLE . ' as s', function ($join): void {
$join->on('s.sku_id', '=', 'latest.sku_id')->on('s.autoid', '=', 'latest.autoid')
->where('s.sku_type', '=', 'normal');
});
foreach (['product_name' => 's.name', 'supplier' => 's.supplier_name', 'category' => 's.category'] as $filter => $column) {
$this->containsFilter($products, $column, $filters[$filter] ?? '');
}
$products->select([
's.sku_id', 's.name as product_name', 's.supplier_name as supplier', 's.category',
's.vc_name as virtual_category', 's.supplier_sku_id as supplier_style_no', 's.brand', 's.unit',
])->selectRaw('COALESCE(s.pic_big, s.pic) AS image_url')
->selectRaw('COALESCE(s.short_name, s.name) AS short_name')
->selectRaw('COALESCE(s.properties_value, s.color) AS color_spec');
$details = $this->boundedGet($products, 10)->keyBy(fn (object $row): string => $this->flowSkuKey((string) $row->sku_id));
$requiresProduct = $this->hasProductFilters($filters);
$result = [];
foreach ($rows as $row) {
$product = $details->get($this->flowSkuKey($row['sku_code']));
if ($requiresProduct && $product === null) {
continue;
}
$result[] = array_replace($row, array_intersect_key((array) $product, array_flip(self::OUTPUT_COLUMNS)));
}
return $result;
}
/** @return list<array<string,mixed>> */
private function calculateFlowQuantities(array $period, array $state, ?array $authority): array
{
$anchorAt = $authority['anchor_at'] ?? $this->stateAnchor($state);
$inventoryGeneration = $this->generation($state, 'active_inventory_generation');
$anchors = $authority !== null
? $this->connection->table(self::AUTHORITATIVE_ANCHOR_TABLE)
->where('generation', $authority['generation'])->select(['sku_code', 'qty', 'anchor_at as snapshot_at'])
: $this->connection->table(self::INVENTORY_TABLE)
->where('generation', $inventoryGeneration)->where('wms_co_id', self::WAREHOUSE_ID)
->select(['sku_code', 'qty', 'captured_at as snapshot_at']);
$anchors->whereNotNull('sku_code')->where('sku_code', '!=', '');
$snapshots = $this->boundedGet($anchors, 10)->keyBy(fn (object $row): string => $this->flowSkuKey((string) $row->sku_code));
$movements = $this->confirmedMovements(
$this->generation($state, 'active_header_generation'),
$this->generation($state, 'active_item_generation'),
)->where('h.occurred_at', '>=', min($period['start'], $anchorAt))
->where('h.occurred_at', '<', max($period['end'], $anchorAt));
if ($authority !== null) {
$movements->leftJoin(self::AUTHORITATIVE_ANCHOR_TABLE . ' as snapshot', function ($join) use ($authority): void {
$join->on('snapshot.sku_code', '=', 'i.sku_code')->where('snapshot.generation', $authority['generation']);
});
$snapshotTime = 'COALESCE(snapshot.anchor_at, ?)';
} else {
$movements->leftJoin(self::INVENTORY_TABLE . ' as snapshot', function ($join) use ($inventoryGeneration): void {
$join->on('snapshot.sku_code', '=', 'i.sku_code')->where('snapshot.generation', $inventoryGeneration)
->where('snapshot.wms_co_id', self::WAREHOUSE_ID);
});
$snapshotTime = 'COALESCE(snapshot.captured_at, ?)';
}
$signed = '(COALESCE(i.qty, 0) * COALESCE(i.qty_type, 0))';
$movements->groupBy('i.sku_code')->groupByRaw('DATE(h.occurred_at)')->select('i.sku_code')
->selectRaw('DATE(h.occurred_at) AS report_date')
->selectRaw('SUM(CASE WHEN h.occurred_at ' . ($authority === null ? '>' : '>=') . ' '
. $snapshotTime . ' THEN ' . $signed . ' ELSE 0 END) AS forward_qty', [$anchorAt])
->selectRaw('SUM(CASE WHEN h.occurred_at < ' . $snapshotTime . ' THEN '
. $signed . ' ELSE 0 END) AS backward_qty', [$anchorAt]);
foreach (self::MOVEMENT_COLUMNS as $field => $type) {
$movements->selectRaw('SUM(CASE WHEN h.io_type = ? THEN ' . $signed . ' ELSE 0 END) AS ' . $field, [$type]);
}
$daily = $this->boundedGet($movements, 15)->groupBy(fn (object $row): string => $this->flowSkuKey((string) $row->sku_code));
$skus = array_unique([...$snapshots->keys()->all(), ...$daily->keys()->all()]);
sort($skus, SORT_STRING);
$byDate = [];
foreach ($skus as $sku) {
$snapshot = $snapshots->get($sku);
$displaySku = (string) ($snapshot->sku_code ?? $daily->get($sku)?->first()?->sku_code ?? $sku);
$snapshotAt = (string) ($snapshot->snapshot_at ?? $anchorAt);
$flows = $daily->get($sku, collect())->sortBy('report_date')->keyBy('report_date');
$forward = 0.0;
$backward = (float) $flows->sum('backward_qty');
foreach ($flows as $date => $flow) {
if ($date < substr($period['start'], 0, 10)) {
$forward += (float) $flow->forward_qty;
$backward -= (float) $flow->backward_qty;
}
}
$cursor = Carbon::parse($period['start']);
$end = Carbon::parse($period['end']);
while ($cursor < $end) {
$date = $cursor->toDateString();
$flow = $flows->get($date);
$forward += (float) ($flow->forward_qty ?? 0);
$backward -= (float) ($flow->backward_qty ?? 0);
$dayEnd = $cursor->copy()->addDay()->toDateTimeString();
$closing = round((float) ($snapshot->qty ?? 0) + ($snapshotAt < $dayEnd ? $forward : -$backward), 4);
$row = ['report_date' => $date, 'sku_code' => $displaySku, 'closing_qty' => $closing, 'opening_aux' => 0];
$net = 0.0;
$nonZero = false;
foreach (self::MOVEMENT_COLUMNS as $field => $type) {
$row[$field] = round((float) ($flow->{$field} ?? 0), 4);
$net += $row[$field];
$nonZero = $nonZero || $row[$field] != 0;
}
$row['opening_qty'] = round($closing - $net, 4);
if ($nonZero || $closing != 0 || $row['opening_qty'] != 0) {
$byDate[$date][] = $this->mapRow((object) $row);
}
$cursor->addDay();
}
}
ksort($byDate);
return array_merge([], ...array_values($byDate));
}
private function flowSkuKey(string $sku): string
{
// MariaDB's shared mirror uses case-insensitive SKU joins. PHP keys
// must use the same identity or mixed-case movements duplicate SKUs.
return $this->connection->getDriverName() === 'mysql' ? mb_strtolower($sku) : $sku;
}
/** @return array{count:int,opening_qty:float,closing_qty:float} */
private function summarizeRows(array $rows): array
{
return [
'count' => count($rows),
'opening_qty' => round(array_sum(array_column($rows, 'opening_qty')), 4),
'closing_qty' => round(array_sum(array_column($rows, 'closing_qty')), 4),
];
}
/** @param array<string,mixed> $state */
private function filteredReportQuery(
array $filters,

View File

@ -57,34 +57,44 @@
frame.src = link.href;
frame.style.visibility = 'hidden';
status.textContent = '正在加载…';
const fail = () => {
const fail = (message = '页面暂时无法加载,请重试。') => {
frame.dataset.failed = '1';
frame.dataset.failureMessage = message;
if (selected === link) {
status.textContent = '页面加载失败或登录已过期,请重试。';
status.textContent = message;
retry.hidden = false;
}
};
const timeout = setTimeout(fail, 45000);
frame.addEventListener('error', fail);
const timeout = setTimeout(() => fail('页面响应超时,请稍后重试。'), 45000);
frame.addEventListener('error', () => fail());
frame.addEventListener('load', () => {
clearTimeout(timeout);
try {
const doc = frame.contentDocument;
const content = doc.querySelector('#app');
if (!content) { fail(); return; }
const content = doc?.querySelector('#app');
if (!content) {
const path = frame.contentWindow.location.pathname;
const text = doc?.body?.textContent || '';
if (/(?:^|\/)login(?:\/|$)/.test(path)) fail('登录已过期,请重新登录。');
else if (/504|Gateway Timeout/i.test((doc?.title || '') + text)) fail('页面响应超时,请稍后重试。');
else if (/403|Forbidden/i.test((doc?.title || '') + text)) fail('当前账号没有该页面权限。');
else fail();
return;
}
// Preserve each page's own scripts, forms and routes in its document.
const style = doc.createElement('style');
style.textContent = '.js-menu,body>.container,#header [data-page-title-first]{display:none!important} #panel{margin:0!important;padding:0!important} #main-body>.container{margin:0!important;padding:0!important;width:100%!important} #header{padding:0!important;margin:0!important}';
doc.head.appendChild(style);
frame.style.visibility = 'visible';
delete frame.dataset.failed;
delete frame.dataset.failureMessage;
if (selected === link) { status.textContent = ''; retry.hidden = true; }
} catch (_) { fail(); }
});
root.querySelector('[data-courier-frames]').appendChild(frame);
frames.set(link.dataset.courierTab, frame);
} else {
status.textContent = frame.dataset.failed ? '页面加载失败,请重试。' : '';
status.textContent = frame.dataset.failed ? frame.dataset.failureMessage : '';
retry.hidden = !frame.dataset.failed;
}
frame.hidden = false;

View File

@ -201,6 +201,23 @@ class ThirdPartyWarehouseStoragePageTest extends FeatureTestCase
->assertSee('不会显示空白数据冒充正式结果');
}
public function test_query_timeout_preserves_filters_and_explains_the_actual_failure(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
$repository->shouldReceive('available')->once()->andReturnTrue();
$repository->shouldReceive('ready')->once()->andReturnTrue();
$repository->shouldReceive('paginate')->once()->andThrow(new \RuntimeException('max_statement_time exceeded'));
$this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'courier-payables', 'page' => 'third-party-warehouse-storage',
'period' => '2026-09', 'sku_code' => 'SKU-TIMEOUT',
]))->assertOk()->assertSee('三方仓进销存查询超时')
->assertSee('value="2026-09"', false)->assertSee('value="SKU-TIMEOUT"', false)
->assertDontSee('登录已过期');
}
/** @return array<string,mixed> */
private static function row(): array
{

View File

@ -882,6 +882,76 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
$this->assertSame('SKU-B', $export->current()['sku_code']);
}
public function test_daily_aggregation_matches_the_original_balance_query_across_snapshot_boundaries(): void
{
$this->markReady();
$this->insertProduct('SKU-FRACTION');
$this->insertInventory('SKU-FRACTION', 1.2345, '2026-09-03 12:00:00');
foreach ([
['采购进仓', 0.1234, 1, '2026-09-01 00:00:00'],
['销售出仓', 0.2345, -1, '2026-09-02 23:59:59'],
['采购进仓', 0.3456, 1, '2026-09-03 11:59:59'],
['采购进仓', 0.4567, 1, '2026-09-03 12:00:00'],
['销售出仓', 0.5678, -1, '2026-09-03 12:00:01'],
['采购进仓', 0.6789, 1, '2026-09-04 00:00:00'],
] as [$type, $qty, $direction, $time]) {
$this->insertMovement('SKU-FRACTION', $type, $qty, $direction, $time);
}
$filters = $this->filters(['period' => '2026-09']);
$queryMethod = new \ReflectionMethod($this->repository, 'filteredReportQuery');
$mapMethod = new \ReflectionMethod($this->repository, 'mapRow');
$expected = $queryMethod->invoke($this->repository, $filters, $this->repository->state())
->orderBy('report_date')->orderBy('sku_code')->get()
->map(fn ($row) => $mapMethod->invoke($this->repository, $row))->all();
$actual = $this->repository->paginate($filters, 1, 100);
$this->assertSame($expected, $actual['data']);
$this->assertSame(count($expected), $actual['total']);
$this->assertSame($expected, iterator_to_array($this->repository->exportRows($filters, 1), false));
}
public function test_paging_reuses_quantities_and_refreshes_after_a_new_mirror_publication(): void
{
$this->markReady();
$this->insertProduct('SKU-CACHE');
$this->insertInventory('SKU-CACHE', 5, '2026-09-01 00:00:00');
$filters = $this->filters(['period' => '2026-09']);
$first = $this->repository->paginate($filters, 1, 1);
DB::connection('business_mirror')->enableQueryLog();
$second = $this->repository->paginate($filters, 2, 1);
$queries = DB::connection('business_mirror')->getQueryLog();
DB::connection('business_mirror')->disableQueryLog();
$this->assertSame('2026-09-02', $second['data'][0]['date']);
$this->assertSame($first['summary'], $second['summary']);
$this->assertFalse(collect($queries)->contains(fn ($q) => str_contains($q['query'], 'from "third_party_warehouse_inout_items"')
|| str_contains($q['query'], 'join "third_party_warehouse_inout_items"')));
$this->insertMovement('SKU-CACHE', '采购进仓', 2, 1, '2026-09-01 12:00:00');
DB::connection('business_mirror')->table('third_party_warehouse_sync_states')
->update(['last_succeeded_at' => '2026-09-12 01:00:00']);
$refreshed = $this->repository->paginate($filters, 1, 1);
$this->assertSame(7.0, $refreshed['data'][0]['closing_qty']);
DB::connection('business_mirror')->table('ops_jst_sku')->update(['supplier_name' => '新供应商']);
$filtered = $this->repository->paginate($filters + ['unused' => 'ignored'], 1, 1);
$this->assertSame('新供应商', $filtered['data'][0]['supplier']);
}
public function test_cancelled_fractional_movements_do_not_leave_phantom_zero_inventory_rows(): void
{
$this->markReady();
$this->insertProduct('SKU-ZERO');
$this->insertInventory('SKU-ZERO', 0.3, '2026-09-01 00:00:00');
$this->insertMovement('SKU-ZERO', '销售出仓', 0.1, -1, '2026-09-01 08:00:00');
$this->insertMovement('SKU-ZERO', '销售出仓', 0.2, -1, '2026-09-01 09:00:00');
$result = $this->repository->paginate($this->filters(['period' => '2026-09']), 1, 100);
$this->assertSame(1, $result['total']);
$this->assertSame(0.0, $result['data'][0]['closing_qty']);
$this->assertSame(0.3, $result['data'][0]['opening_qty']);
}
private function createMirrorTables(): void
{
$schema = Schema::connection('business_mirror');