$this->companyPage($currentPage, $perPage), 'store-management/basic-information' => $this->storePage($currentPage, $perPage), 'store-management/store-accounts' => $this->storeAccountPage($currentPage, $perPage), 'store-management/store-report' => $this->storeReportPage($currentPage, $perPage), 'company-management/subject-operating-report' => $this->subjectOperatingReportPage($currentPage, $perPage), 'trademark-management/information' => $this->trademarkPage($currentPage, $perPage), 'financial-operations/installation-details' => $this->installationPage($currentPage, $perPage), default => ['data' => [], 'total' => 0], }; return new LengthAwarePaginator( array_map(static fn (array $row) => (object) $row, $payload['data']), (int) $payload['total'], $perPage, $currentPage, [ 'path' => request()->url(), 'query' => request()->query(), ], ); } private function installationPage(int $page, int $perPage): array { $payload = $this->installationMirror->paginate($page, $perPage, InstallationDetailMirrorRepository::requestFilters()); if (! $payload['ready']) { throw new ServiceUnavailableHttpException(null, '安装明细镜像尚未完成首次同步'); } return ['data' => $payload['data'], 'total' => $payload['total']]; } /** * 将已登记的经营分析副本库查询还原成不经过页面缓存的固定只读查询。 * * 生产后台刷新器使用这个入口生成共享快照;页面仍然只能通过 * UpstreamQueryCache 读取快照,不能绕过缓存直接访问上游。 * * @return array{data: array>, total: int} */ public function snapshot(string $scope, array $parameters): array { $page = max((int) ($parameters['page'] ?? 1), 1); $perPage = min(max((int) ($parameters['perPage'] ?? 50), 1), 100); $query = new UpstreamReadOnlyQuery(self::SOURCE); return match ($scope) { 'company-information.read' => $this->companySnapshot($query, $page, $perPage), 'store-information.read' => $this->storeSnapshot($query, $page, $perPage), 'trademark-information.read' => $this->trademarkSnapshot($query, $page, $perPage), default => throw new InvalidArgumentException('未知的经营分析副本库快照类型:' . $scope), }; } private function companyPage(int $page, int $perPage): array { $filters = $this->pageFilters('company-management', 'basic-information'); return $this->page( 'company-information.read', $page, $perPage, $filters, fn () => $this->snapshot('company-information.read', [ 'page' => $page, 'perPage' => $perPage, ]), ); } private function companySnapshot(UpstreamReadOnlyQuery $query, int $page, int $perPage): array { $offset = ($page - 1) * $perPage; $rows = $query->select(<< array_map(fn (array $row) => $this->mapCompany($row), $rows), 'total' => (int) $query->scalar('SELECT COUNT(*) FROM rpt.fys_companyInfo'), ]; } private function storePage(int $page, int $perPage): array { $filters = $this->pageFilters('store-management', 'basic-information'); return $this->page( 'store-information.read', $page, $perPage, $filters, fn () => $this->snapshot('store-information.read', [ 'page' => $page, 'perPage' => $perPage, ]), ); } private function storeReportPage(int $page, int $perPage): array { $filters = StoreReportRepository::requestFilters(); $rows = $this->storeReportRows($filters); $offset = ($page - 1) * $perPage; return ['data' => array_slice($rows, $offset, $perPage), 'total' => count($rows)]; } /** @return array{data:array>,total:int} */ private function storeAccountPage(int $page, int $perPage): array { try { return ($this->storeAccounts ?? app(StoreAccountMirrorRepository::class))->paginate( $page, $perPage, $this->pageFilters('store-management', 'store-accounts'), ); } catch (\RuntimeException $exception) { report($exception); throw new ServiceUnavailableHttpException(null, '店铺账号共享镜像尚未完成首次同步。'); } } /** @param array $filters @return array{data:array>,total:int} */ private function allStores(array $filters = []): array { if ($this->mirror->ready('store-information.read')) { $rows = $this->mirror->allStores($filters); return ['data' => array_map(fn (array $row): array => $this->mapStore($row), $rows), 'total' => count($rows)]; } $cached = $this->caches->forSource(self::SOURCE)->remember('store-information.read', ['all' => true], fn () => $this->storeSnapshotAll(new UpstreamReadOnlyQuery(self::SOURCE))); $rows = array_values(array_filter( $cached['data'], fn (array $row): bool => $this->storeMatchesFilters($row, $filters), )); return ['data' => $rows, 'total' => count($rows)]; } /** * 镜像不可用时的兜底路径:在已映射的快照行上应用同样的店铺字段条件。 * * @param array $row * @param array $filters */ private function storeMatchesFilters(array $row, array $filters): bool { foreach (['erp_store_name', 'store_name', 'store_id', 'profit_center', 'store_manager', 'department_manager'] as $key) { $expected = trim((string) ($filters[$key] ?? '')); if ($expected === '') { continue; } $actual = trim((string) ($row[$key] ?? '')); if (! str_contains(mb_strtolower($actual), mb_strtolower($expected))) { return false; } } $companyType = trim((string) ($filters['company_type'] ?? '')); if ($companyType !== '' && trim((string) ($row['company_type'] ?? '')) !== $companyType) { return false; } $statusMap = ['0' => '正常', '3' => '关闭中', '6' => '已关闭']; $status = trim((string) ($filters['status'] ?? '')); if ($status !== '' && (string) ($row['store_status'] ?? '') !== ($statusMap[$status] ?? $status)) { return false; } $from = trim((string) ($filters['opened_at_from'] ?? '')); $to = trim((string) ($filters['opened_at_to'] ?? '')); if ($from !== '' || $to !== '') { $value = trim((string) ($row['opened_at'] ?? '')); if ($value === '') { return false; } if ($from !== '' && strcmp($value, $from) < 0) { return false; } if ($to !== '' && strcmp($value, $to) >= 0) { return false; } } return true; } private function storeSnapshotAll(UpstreamReadOnlyQuery $query): array { $rows = $query->select(<< $this->mapStore($row), $rows); return ['data' => $rows, 'total' => count($rows)]; } private function subjectOperatingReportPage(int $page, int $perPage): array { $filters = $this->pageFilters('company-management', 'subject-operating-report'); $rows = $this->storeReportRows(array_intersect_key($filters, array_flip(['company_type']))); $entities = []; foreach ($rows as $row) { $entity = trim((string) ($row['entity'] ?? '')); if ($entity === '') { continue; } $entities[$entity] ??= [ 'city' => null, 'entity' => $entity, 'company_type' => $row['company_type'] ?? null, 'rolling_12m_income' => null, 'can_change_entity' => null, 'plan' => null, 'new_entity' => null, ]; if (empty($entities[$entity]['company_type']) && ! empty($row['company_type'])) { $entities[$entity]['company_type'] = $row['company_type']; } $this->sumNullable($entities[$entity], 'rolling_12m_income', $row['rolling_12m_income'] ?? null); for ($month = 1; $month <= 12; $month++) { $key = sprintf('income_%02d', $month); $entities[$entity][$key] ??= null; $this->sumNullable($entities[$entity], $key, $row[$key] ?? null); } } $rows = array_values($entities); $details = $this->companyDetails(array_column($rows, 'entity')); foreach ($rows as &$row) { $row['company_details'] = $details[$row['entity']] ?? []; $row['city'] = $this->cityFromDetails($row['company_details']); } unset($row); $rows = array_values(array_filter($rows, fn (array $row): bool => $this->subjectMatchesFilters($row, $filters))); $this->sortSubjectRows($rows, $filters); $offset = ($page - 1) * $perPage; $pageRows = array_slice($rows, $offset, $perPage); return ['data' => $pageRows, 'total' => count($rows)]; } /** @param array $row @param array $filters */ private function subjectMatchesFilters(array $row, array $filters): bool { if (($filters['company_type'] ?? '') !== '' && (string) ($row['company_type'] ?? '') !== (string) $filters['company_type']) { return false; } foreach (['city', 'entity', 'plan', 'new_entity'] as $key) { $needle = trim((string) ($filters[$key] ?? '')); if ($needle !== '' && mb_stripos((string) ($row[$key] ?? ''), $needle) === false) { return false; } } foreach (['rolling_12m_income', ...array_map(static fn (int $month): string => sprintf('income_%02d', $month), range(1, 12))] as $key) { $needle = str_replace(',', '', trim((string) ($filters[$key] ?? ''))); if ($needle !== '' && (! is_numeric($needle) || ! is_numeric($row[$key] ?? null) || abs((float) $row[$key] - (float) $needle) > 0.000001)) { return false; } } $canChange = trim((string) ($filters['can_change_entity'] ?? '')); return $canChange === '' || (string) ($row['can_change_entity'] ?? '') === $canChange; } /** @param array> $rows @param array $filters */ private function sortSubjectRows(array &$rows, array $filters): void { $key = (string) ($filters['_sort'] ?? 'entity'); $allowed = [ 'city', 'entity', 'company_type', 'rolling_12m_income', ...array_map(static fn (int $month): string => sprintf('income_%02d', $month), range(1, 12)), 'can_change_entity', 'plan', 'new_entity', ]; if (! in_array($key, $allowed, true)) { return; } $direction = ($filters['_direction'] ?? 'asc') === 'desc' ? -1 : 1; usort($rows, static function (array $left, array $right) use ($key, $direction): int { $leftValue = $left[$key] ?? null; $rightValue = $right[$key] ?? null; if (is_numeric($leftValue) && is_numeric($rightValue)) { $comparison = ((float) $leftValue <=> (float) $rightValue) * $direction; } else { $comparison = strcmp((string) ($leftValue ?? ''), (string) ($rightValue ?? '')) * $direction; } return $comparison !== 0 ? $comparison : strcmp((string) ($left['entity'] ?? ''), (string) ($right['entity'] ?? '')); }); } private function sumNullable(array &$row, string $key, mixed $value): void { if ($value === null || $value === '') { return; } $row[$key] = (float) ($row[$key] ?? 0) + (float) $value; } /** * 「市」来自主体基础资料的注册地址:取第一条能解析出地级市的记录 * (详情按 Id 倒序,即优先最新资料);资料缺失或地址无法识别时返回 null, * 页面按统一缺失值展示。 * * @param array> $details */ private function cityFromDetails(array $details): ?string { foreach ($details as $detail) { $city = RegisteredAddressCity::parse($detail['registered_address'] ?? null); if ($city !== null) { return $city; } } return null; } /** @return array>> */ private function companyDetails(array $names): array { $names = array_values(array_unique(array_filter(array_map('trim', $names)))); if ($names === []) { return []; } try { if ($this->mirror->ready('company-information.read')) { $rows = $this->mirror->companiesByNames($names); } else { $placeholders = implode(',', array_fill(0, count($names), '?')); $rawRows = (new UpstreamReadOnlyQuery(self::SOURCE))->select( "SELECT * FROM rpt.fys_companyInfo WHERE businessLicenseName IN ($placeholders) ORDER BY Id DESC", $names, ); $rows = []; foreach ($rawRows as $rawRow) { $rows[(string) ($rawRow['businessLicenseName'] ?? '')][] = $rawRow; } } } catch (Throwable) { return []; } $result = []; foreach ($rows as $name => $companyRows) { $result[$name] = array_map(fn (array $row): array => $this->mapCompanyDetail($row), $companyRows); } return $result; } private function mapCompanyDetail(array $row): array { return $this->mapCompany([ 'id' => $row['Id'] ?? $row['id'] ?? null, 'unified_social_credit_code' => $row['unifiedSocialCreditCode'] ?? $row['unified_social_credit_code'] ?? null, 'company_name' => $row['businessLicenseName'] ?? $row['company_name'] ?? null, 'subscribed_capital' => $row['registeredCapital'] ?? $row['subscribed_capital'] ?? null, 'company_type' => $row['businessLicenseType'] ?? $row['company_type'] ?? null, 'establishment_date' => $row['IncorporationDate'] ?? $row['establishment_date'] ?? null, 'legal_representative' => $row['legalRepresentative'] ?? $row['legal_representative'] ?? null, 'registered_address' => $row['registeredAddress'] ?? $row['registered_address'] ?? null, 'business_scope' => $row['businessScope'] ?? $row['business_scope'] ?? null, 'basic_deposit_account_number' => $row['accountSerialNumber'] ?? $row['basic_deposit_account_number'] ?? null, 'subscribed_at' => $row['registeredCapitalVaildTime'] ?? $row['subscribed_at'] ?? null, 'company_status' => $row['companyStatus'] ?? $row['company_status'] ?? null, 'financial_officer' => $row['financialManager'] ?? $row['financial_officer'] ?? null, 'supervisor' => $row['supervisor'] ?? null, 'taxpayer_category' => $row['taxCategoryAndType'] ?? $row['taxpayer_category'] ?? null, 'shareholders' => $row['shareholders'] ?? null, ]); } /** @param array $filters @return array> */ private function storeReportRows(array $filters = []): array { // 店铺负责人、部门负责人、利润中心展示店铺账号镜像(PostgreSQL)中的值; // 这三个字段的筛选条件随行级过滤按覆盖后的值匹配,不再走镜像 SQL。 $ownership = $this->storeOwnership(); $storeFilters = array_diff_key($filters, array_flip(['store_manager', 'department_manager', 'profit_center'])); $stores = $this->allStores($storeFilters); foreach ($stores['data'] as &$store) { $match = $ownership[trim((string) ($store['erp_store_name'] ?? ''))] ?? null; if ($match === null) { continue; } // 未匹配到账号或字段为空时保留经营分析副本库原值。 foreach ([ 'store_manager' => $match['operation_owner'], 'department_manager' => $match['operation_director'], 'profit_center' => $match['profit_center'], ] as $field => $value) { if ($value !== '') { $store[$field] = $value; } } } unset($store); try { return ($this->storeReports ?? app(StoreReportRepository::class))->rows($stores['data'], null, $filters); } catch (\RuntimeException $exception) { report($exception); throw new ServiceUnavailableHttpException(null, '店铺报表数据暂时无法读取,请稍后重试。'); } } /** @return array */ private function storeOwnership(): array { try { return ($this->storeAccounts ?? app(StoreAccountMirrorRepository::class))->ownershipByStoreName(); } catch (\RuntimeException | \InvalidArgumentException $exception) { // 账号镜像未登记或未就绪时保留经营分析副本库原值,不阻断店铺报表。 return []; } } private function storeSnapshot(UpstreamReadOnlyQuery $query, int $page, int $perPage): array { $offset = ($page - 1) * $perPage; $rows = $query->select(<< array_map(fn (array $row) => $this->mapStore($row), $rows), 'total' => (int) $query->scalar('SELECT COUNT(*) FROM rpt.fys_shopInfo'), ]; } private function trademarkPage(int $page, int $perPage): array { $filters = $this->pageFilters('trademark-management', 'information'); return $this->page( 'trademark-information.read', $page, $perPage, $filters, fn () => $this->snapshot('trademark-information.read', [ 'page' => $page, 'perPage' => $perPage, ]), ); } private function trademarkSnapshot(UpstreamReadOnlyQuery $query, int $page, int $perPage): array { $offset = ($page - 1) * $perPage; $rows = $query->select(<< array_map(fn (array $row) => $this->mapTrademark($row), $rows), 'total' => (int) $query->scalar('SELECT COUNT(*) FROM rpt.fys_TrademarkInfo WHERE isDelete = 0'), ]; } private function page(string $scope, int $page, int $perPage, array $filters, callable $resolver): array { if ($this->mirror->ready($scope)) { return $this->normalize($scope, $this->mirror->paginate($scope, $page, $perPage, $filters)); } if ($filters !== []) { throw new ServiceUnavailableHttpException(null, '当前数据源暂不支持所选条件查询,请检查共享业务镜像状态。'); } if ((app(\App\Utilities\SystemDatabaseConfiguration::class)->current()['mode'] ?? '') === 'bootstrap' && ! config('upstreams.local_configuration.enabled', false)) { throw new ServiceUnavailableHttpException(null, '当前仍使用本机临时引导库。请在“系统设置 → 数据库管理 → 系统数据库”连接团队共享数据库,然后重新登录;无需另配上游 SQL Server。'); } $cache = $this->caches->forSource(self::SOURCE); return $cache->remember($scope, compact('page', 'perPage'), function () use ($resolver) { return $resolver(new UpstreamReadOnlyQuery(self::SOURCE)); }); } /** @return array */ private function pageFilters(string $section, string $page): array { $fields = collect(config('business_navigation')) ->firstWhere('slug', $section)['pages'] ?? []; $pageConfig = collect($fields)->flatMap(fn (array $item) => [$item, ...($item['children'] ?? [])])->firstWhere('slug', $page); $allowed = collect($pageConfig['directory']['fields'] ?? []) ->reject(fn (array $field) => ($field['kind'] ?? '') === 'secret' && ($field['name'] ?? '') !== 'withdrawal_account') ->pluck('name')->all(); if ($section === 'company-management' && $page === 'basic-information') { $allowed = array_values(array_unique([...$allowed, 'company_type', 'related_people'])); } $filters = []; foreach ($allowed as $name) { foreach ([$name, $name . '_from', $name . '_to'] as $key) { $value = trim((string) request($key, '')); if ($value !== '') $filters[$key] = mb_substr($value, 0, 191); } } $sort = trim((string) request('sort', '')); if (in_array($sort, $allowed, true)) { $filters['_sort'] = $sort; $filters['_direction'] = request('direction') === 'desc' ? 'desc' : 'asc'; } return $filters; } /** @param array{data: array>, total: int} $payload */ private function normalize(string $scope, array $payload): array { $mapper = match ($scope) { 'company-information.read' => fn (array $row): array => $this->mapCompany($row), 'store-information.read' => fn (array $row): array => $this->mapStore($row), 'trademark-information.read' => fn (array $row): array => $this->mapTrademark($row), default => throw new InvalidArgumentException('未知的经营分析副本库资料类型:' . $scope), }; return [ 'data' => array_map($mapper, $payload['data']), 'total' => (int) $payload['total'], ]; } private function mapCompany(array $row): array { $row = $this->toUtf8($row); $row['establishment_date'] = $this->date($row['establishment_date'] ?? null); $row['subscribed_at'] = $this->date($row['subscribed_at'] ?? null); $row['city'] = RegisteredAddressCity::parse($row['registered_address'] ?? null); $row['company_status'] = match ((string) ($row['company_status'] ?? '')) { '0' => '正常', '1' => '已注销', default => $row['company_status'] ?? null, }; return $row; } private function mapStore(array $row): array { $row = $this->toUtf8($row); $row['opened_at'] = $this->date($row['opened_at'] ?? null); $row['renewed_at'] = $this->date($row['renewed_at'] ?? null); $row['store_status'] = match ((string) ($row['store_status'] ?? '')) { '0' => '正常', '3' => '关闭中', '6' => '已关闭', default => $row['store_status'] ?? null, }; return $row; } private function mapTrademark(array $row): array { $row = $this->toUtf8($row); $row['registration_date'] = $this->date($row['registration_date'] ?? null); $row['expiry_date'] = $this->date($row['expiry_date'] ?? null); $row['trademark_status'] = match ((string) ($row['trademark_status'] ?? '')) { '0' => '未启用', '1' => '有效', default => $row['trademark_status'] ?? null, }; return $row; } private function toUtf8(array $row): array { foreach ($row as &$value) { if (is_string($value) && ! mb_check_encoding($value, 'UTF-8')) { $value = mb_convert_encoding($value, 'UTF-8', 'GB18030'); } } unset($value); return $row; } private function date($value): ?string { if ($value === null || $value === '') { return null; } return substr((string) $value, 0, 10); } }