diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index a6978880..20b6c426 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -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 [ diff --git a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php index f75e3500..3a81afa6 100644 --- a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php @@ -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>|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> */ + 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 $state */ private function filteredReportQuery( array $filters, diff --git a/resources/views/business/pages/courier-payables/final-workspace.blade.php b/resources/views/business/pages/courier-payables/final-workspace.blade.php index 4597d31a..f9e9a551 100644 --- a/resources/views/business/pages/courier-payables/final-workspace.blade.php +++ b/resources/views/business/pages/courier-payables/final-workspace.blade.php @@ -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; diff --git a/tests/Feature/Business/ThirdPartyWarehouseStoragePageTest.php b/tests/Feature/Business/ThirdPartyWarehouseStoragePageTest.php index 01bc82ff..1dd2e5cb 100644 --- a/tests/Feature/Business/ThirdPartyWarehouseStoragePageTest.php +++ b/tests/Feature/Business/ThirdPartyWarehouseStoragePageTest.php @@ -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 */ private static function row(): array { diff --git a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php index 5b038e55..f3531d71 100644 --- a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php @@ -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');