'operations_replica.rpt.fys_companyInfo', 'store-information.read' => 'operations_replica.rpt.fys_shopInfo', 'trademark-information.read' => 'operations_replica.rpt.fys_TrademarkInfo', ]; private readonly ConnectionInterface $connection; public function __construct( private readonly BusinessMirrorDatasetRegistry $datasets, private readonly GenericBusinessMirrorRepository $mirror, ) { $this->connection = $mirror->connection(); } public function ready(string $scope): bool { return $this->mirror->ready($this->dataset($scope)); } /** @return array{data: array>, total: int} */ public function paginate(string $scope, int $page, int $perPage, array $filters = []): array { if (! $this->ready($scope)) { throw new RuntimeException('business_mirror_dataset_not_ready'); } $page = max($page, 1); $perPage = min(max($perPage, 1), 100); return match ($scope) { 'company-information.read' => $this->companies($page, $perPage, $filters), 'store-information.read' => $this->stores($page, $perPage, $filters), 'trademark-information.read' => $this->trademarks($page, $perPage, $filters), default => throw new InvalidArgumentException('未知的业务资料镜像范围:' . $scope), }; } /** @param array $filters @return array> */ public function allStores(array $filters = []): array { if (! $this->ready('store-information.read')) { throw new RuntimeException('business_mirror_dataset_not_ready'); } $query = $this->storeQuery($this->crawlerSubqueries())->orderByDesc('s.Id'); $this->applyStoreFilters($query, $filters); $rows = $query->get()->map(static fn (object $row): array => (array) $row)->all(); $trademarks = $this->crawlerTrademarkPairsForAll( array_map(static fn (array $row): string => (string) ($row['erp_store_name'] ?? ''), $rows), ); return array_map( fn (array $row): array => $this->applyCrawlerPriority( $row, $trademarks[(string) ($row['erp_store_name'] ?? '')] ?? [], ), $rows, ); } /** * Return the stable store-information rows used to seed the receiving * account pages. The query stays on the shared business mirror and only * reads the approved shop/company fields. * * @return list> */ public function receivingAccountStores(): array { if (! $this->ready('store-information.read')) { throw new RuntimeException('business_mirror_dataset_not_ready'); } $latestCompanies = $this->connection->table('ops_fys_company_info') ->selectRaw('unifiedSocialCreditCode, MAX(Id) AS latest_id') ->whereNotNull('unifiedSocialCreditCode') ->where('unifiedSocialCreditCode', '<>', '') ->groupBy('unifiedSocialCreditCode'); return $this->connection->table('ops_fys_shop_info AS s') ->leftJoinSub($latestCompanies, 'company_keys', function ($join): void { $join->on('company_keys.unifiedSocialCreditCode', '=', 's.unifiedSocialCreditCode'); }) ->leftJoin('ops_fys_company_info AS c', 'c.Id', '=', 'company_keys.latest_id') ->selectRaw(<<<'SQL' s.Id AS id, s.sotreId AS store_id, s.shopName AS shop_name, s.erpShopName AS erp_store_name, s.unifiedSocialCreditCode AS unified_social_credit_code, c.businessLicenseName AS company_name, s.platform AS source_platform, s.shopState AS store_status, s.shopType AS store_type SQL) ->orderBy('s.Id') ->get() ->map(static fn (object $row): array => (array) $row) ->all(); } /** 店铺维度报表的店铺字段查询条件;日期左闭右开 [from, to)。 */ private function applyStoreFilters($query, array $filters): void { foreach ([ 'erp_store_name' => 's.erpShopName', // 页面“店铺ID(唯一值)”列展示的就是 ERP 店铺名称,筛选按同一列匹配。 'store_id' => 's.erpShopName', 'profit_center' => 's.responsibleDepartment', 'store_manager' => 's.shopHead', 'department_manager' => 's.customerDeptManager', ] as $key => $column) { $value = trim((string) ($filters[$key] ?? '')); if ($value !== '') { $query->where($column, 'like', '%' . addcslashes($value, '%_\\') . '%'); } } if (trim((string) ($filters['company_type'] ?? '')) !== '') { $query->where('c.businessLicenseType', '=', trim((string) $filters['company_type'])); } if (trim((string) ($filters['status'] ?? '')) !== '') { $query->where('s.shopState', '=', (string) $filters['status']); } if (trim((string) ($filters['opened_at_from'] ?? '')) !== '') { $query->where('s.openTime', '>=', $filters['opened_at_from']); } if (trim((string) ($filters['opened_at_to'] ?? '')) !== '') { $query->where('s.openTime', '<', $filters['opened_at_to']); } } /** @return array>> */ public function companiesByNames(array $names): array { if (! $this->ready('company-information.read')) { throw new RuntimeException('business_mirror_dataset_not_ready'); } $names = array_values(array_unique(array_filter(array_map('trim', $names)))); if ($names === []) { return []; } return $this->connection->table('ops_fys_company_info') ->whereIn('businessLicenseName', $names) ->orderByDesc('Id') ->get() ->map(static fn (object $row): array => (array) $row) ->groupBy(static fn (array $row): string => (string) $row['businessLicenseName']) ->map(static fn ($rows): array => $rows->values()->all()) ->all(); } /** @return array{data: array>, total: int} */ private function companies(int $page, int $perPage, array $filters): array { $query = $this->connection->table('ops_fys_company_info') ->selectRaw(<<<'SQL' Id AS id, unifiedSocialCreditCode AS unified_social_credit_code, businessLicenseName AS company_name, registeredCapital AS subscribed_capital, businessLicenseType AS company_type, IncorporationDate AS establishment_date, legalRepresentative AS legal_representative, registeredAddress AS registered_address, businessScope AS business_scope, accountSerialNumber AS basic_deposit_account_number, registeredCapitalVaildTime AS subscribed_at, companyStatus AS company_status, financialManager AS financial_officer, supervisor AS supervisor, taxCategoryAndType AS taxpayer_category, NULL AS shareholders SQL); $this->applyFieldFilters($query, $filters, [ 'unified_social_credit_code' => 'unifiedSocialCreditCode', 'company_name' => 'businessLicenseName', 'establishment_date' => 'IncorporationDate', 'registered_at' => 'IncorporationDate', 'legal_representative' => 'legalRepresentative', 'registered_address' => 'registeredAddress', 'city' => 'registeredAddress', 'business_scope' => 'businessScope', 'subscribed_at' => 'registeredCapitalVaildTime', 'financial_officer' => 'financialManager', 'billing_officer' => 'financialManager', 'supervisor' => 'supervisor', ], ['subscribed_capital' => 'registeredCapital'], [ 'company_type' => 'businessLicenseType', 'taxpayer_type' => 'taxCategoryAndType', 'taxpayer_category' => 'taxCategoryAndType', ]); $companyStatus = trim((string) ($filters['company_status'] ?? '')); if ($companyStatus !== '') { $statusCode = ['正常' => '0', '已注销' => '1'][$companyStatus] ?? $companyStatus; $query->where('companyStatus', '=', $statusCode); } $relatedPeople = mb_substr(trim((string) ($filters['related_people'] ?? '')), 0, 191); if ($relatedPeople !== '') { $like = '%' . addcslashes($relatedPeople, '%_\\') . '%'; $query->where(function ($query) use ($like): void { $query->where('legalRepresentative', 'like', $like) ->orWhere('supervisor', 'like', $like) ->orWhere('financialManager', 'like', $like); }); } return $this->page($query, $page, $perPage, 'Id', $filters, [ 'unified_social_credit_code' => 'unifiedSocialCreditCode', 'company_name' => 'businessLicenseName', 'taxpayer_type' => 'taxCategoryAndType', 'company_status' => 'companyStatus', 'subscribed_capital' => 'registeredCapital', 'registered_at' => 'IncorporationDate', 'legal_representative' => 'legalRepresentative', 'supervisor' => 'supervisor', 'billing_officer' => 'financialManager', 'registered_address' => 'registeredAddress', 'city' => 'registeredAddress', 'business_scope' => 'businessScope', 'basic_deposit_account' => 'accountSerialNumber', 'subscribed_at' => 'registeredCapitalVaildTime', ]); } /** @return array{data: array>, total: int} */ private function stores(int $page, int $perPage, array $filters): array { $crawler = $this->crawlerSubqueries(); $query = $this->storeQuery($crawler); $this->applyFieldFilters($query, $filters, [ 'store_id' => 's.sotreId', 'store_name' => 's.erpShopName', 'business_department' => 's.responsibleDepartment', 'store_manager' => 's.shopHead', 'department_manager' => 's.customerDeptManager', 'opened_at' => 's.openTime', 'renewed_at' => 's.renewalTime', ], [], ['platform' => 's.platform']); $this->applyCrawlerPriorityFilters($query, $filters, $crawler); $storeStatus = trim((string) ($filters['store_status'] ?? '')); if ($storeStatus !== '') { $statusCode = ['正常' => '0', '关闭中' => '3', '已关闭' => '6'][$storeStatus] ?? $storeStatus; $query->where('s.shopState', '=', $statusCode); } $withdrawalAccount = trim((string) ($filters['withdrawal_account'] ?? '')); if ($withdrawalAccount === '已配置') { $query->whereNotNull('s.shopBankAccount')->where('s.shopBankAccount', '<>', ''); } elseif ($withdrawalAccount === '未配置') { $query->where(function ($query): void { $query->whereNull('s.shopBankAccount')->orWhere('s.shopBankAccount', '=', ''); }); } $result = $this->page($query, $page, $perPage, 's.Id', $filters, [ 'platform' => 's.platform', 'store_id' => 's.sotreId', 'store_name' => 's.erpShopName', 'store_status' => 's.shopState', 'business_department' => 's.responsibleDepartment', 'store_manager' => 's.shopHead', 'department_manager' => 's.customerDeptManager', 'opened_at' => 's.openTime', 'renewed_at' => 's.renewalTime', 'withdrawal_account' => 's.shopBankAccount', ]); $result['data'] = array_map( fn (array $row): array => $this->applyCrawlerPriority( $row, $this->crawlerTrademarkPairsFor((string) ($row['erp_store_name'] ?? '')), ), $result['data'], ); return $result; } /** * 店铺基础信息页的展示优先级:爬虫抓到的店铺主体、商标名称和商标号优先, * 爬虫没有覆盖的店铺保留财务系统原有链路的结果。 * * @param array $row * @return array */ /** * @param array $row * @param list $trademarkPairs * @return array */ private function applyCrawlerPriority(array $row, array $trademarkPairs = []): array { // 多商标拼接放在 PHP 层:SQLite 测试库不支持 group_concat 的 order by 语法。 if ($trademarkPairs !== []) { $row['crawler_trademark_name'] = implode('、', array_filter(array_column($trademarkPairs, 'name'))); $row['crawler_trademark_number'] = implode('、', array_filter(array_column($trademarkPairs, 'number'))); } // 保留财务系统登记主体,供店铺维度报表生成期初期间;展示仍按爬虫优先。 $row['system_company_name'] = $row['company_name'] ?? null; $company = trim((string) ($row['crawler_company_name'] ?? '')); if ($company !== '') { $row['company_name'] = $company; } $trademarkName = trim((string) ($row['crawler_trademark_name'] ?? '')); if ($trademarkName !== '') { $row['trademark_name'] = $trademarkName; } $trademarkNumber = trim((string) ($row['crawler_trademark_number'] ?? '')); if ($trademarkNumber !== '') { $row['trademark_number'] = $trademarkNumber; } unset( $row['crawler_company_name'], $row['crawler_trademark_name'], $row['crawler_trademark_number'], ); return $row; } /** * 某店铺爬虫抓到的全部商标,按商标号排序,保证名称与号码一一对应。 * * @return list */ /** @return list */ private function crawlerTrademarkPairsFor(string $storeName): array { $all = $this->crawlerTrademarkPairsForAll([$storeName]); return $all[trim($storeName)] ?? []; } /** * 批量加载多个店铺的爬虫商标,避免列表页逐行查询。 * * @param list $storeNames * @return array>|array{} */ private function crawlerTrademarkPairsForAll(array $storeNames): array { $names = array_values(array_unique(array_filter(array_map('trim', $storeNames)))); if ($names === [] || ! $this->connection->getSchemaBuilder()->hasTable('ops_crawl_shop_trademark')) { return []; } $result = []; foreach (array_chunk($names, 500) as $chunk) { $ranked = $this->connection->table('ops_crawl_shop_trademark') ->selectRaw('id, shop_name, trademark_no, trademark_name, crawl_date, row_number() over (partition by shop_name, trademark_no order by crawl_date desc, id desc) AS row_rank') ->whereIn('shop_name', $chunk); $rows = $this->connection->table($ranked, 'crawler_trademark') ->where('row_rank', 1) ->orderBy('shop_name') ->orderBy('trademark_no') ->get(); foreach ($rows as $row) { $store = trim((string) $row->shop_name); $number = trim((string) $row->trademark_no); if ($store === '' || $number === '') { continue; } $result[$store][] = [ 'number' => $number, 'name' => trim((string) $row->trademark_name), ]; } } return $result; } /** @param array $crawler @param array $filters */ private function applyCrawlerPriorityFilters($query, array $filters, array $crawler): void { $entity = mb_substr(trim((string) ($filters['store_entity'] ?? '')), 0, 191); if ($entity !== '') { $like = '%' . addcslashes($entity, '%_\\') . '%'; $query->where(function ($query) use ($crawler, $like): void { $query->where('c.businessLicenseName', 'like', $like); if ($crawler['store_entity'] !== null) { $query->orWhere($crawler['store_entity'], 'like', $like); } }); } foreach ([ 'trademark' => ['crawler' => 'trademark_name', 'fallback' => 't.trademarkName'], 'trademark_number' => ['crawler' => 'trademark_no', 'fallback' => 's.trademarkNumber'], ] as $key => $columns) { $value = mb_substr(trim((string) ($filters[$key] ?? '')), 0, 191); if ($value === '') { continue; } $like = '%' . addcslashes($value, '%_\\') . '%'; if ($crawler['trademark_table'] === null) { $query->where($columns['fallback'], 'like', $like); continue; } // 多商标店铺不能在联表结果里只匹配最新一条,这里按全量商标做 EXISTS 匹配。 $query->where(function ($query) use ($columns, $like, $crawler): void { $query->where($columns['fallback'], 'like', $like) ->orWhereExists(function ($sub) use ($columns, $like, $crawler): void { $sub->selectRaw('1') ->from($crawler['trademark_table'] . ' AS crawler_trademark_filter') ->whereColumn('crawler_trademark_filter.shop_name', 's.erpShopName') ->where('crawler_trademark_filter.' . $columns['crawler'], 'like', $like); }); }); } } /** * 爬虫表的“每店铺最新一版”子查询。镜像缺失时返回 null,页面回退原链路。 * * @return array{company:?object,store_entity:?string,trademark_table:?string} */ private function crawlerSubqueries(): array { $absence = ['company' => null, 'store_entity' => null, 'trademark_table' => null]; $schema = $this->connection->getSchemaBuilder(); if (! $schema->hasTable('ops_crawl_shop_company') && ! $schema->hasTable('ops_crawl_shop_trademark')) { return $absence; } $company = null; if ($schema->hasTable('ops_crawl_shop_company')) { $ranked = $this->connection->table('ops_crawl_shop_company') ->selectRaw('id, shop_name, company_name, tax_no, crawl_date, row_number() over (partition by shop_name order by crawl_date desc, id desc) AS row_rank'); $company = $this->connection->table($ranked, 'crawler_company') ->where('row_rank', 1) ->select(['shop_name', 'company_name', 'tax_no']); } return [ 'company' => $company, 'store_entity' => $company === null ? null : 'crawler_company.company_name', 'trademark_table' => $schema->hasTable('ops_crawl_shop_trademark') ? 'ops_crawl_shop_trademark' : null, ]; } private function storeQuery(array $crawler = ['company' => null]) { $latestCompanies = $this->connection->table('ops_fys_company_info') ->selectRaw('unifiedSocialCreditCode, MAX(Id) AS latest_id') ->whereNotNull('unifiedSocialCreditCode') ->where('unifiedSocialCreditCode', '<>', '') ->groupBy('unifiedSocialCreditCode'); $query = $this->connection->table('ops_fys_shop_info AS s') ->leftJoinSub($latestCompanies, 'company_keys', function ($join): void { $join->on('company_keys.unifiedSocialCreditCode', '=', 's.unifiedSocialCreditCode'); }) ->leftJoin('ops_fys_company_info AS c', 'c.Id', '=', 'company_keys.latest_id') ->leftJoinSub( $this->connection->table('ops_fys_trademark_info AS trademark_keys') ->selectRaw('trademark_keys.trademarkNumber, MAX(trademark_keys.Id) AS latest_id') ->where('trademark_keys.isDelete', 0) ->whereNotNull('trademark_keys.trademarkNumber') ->where('trademark_keys.trademarkNumber', '<>', '') ->groupBy('trademark_keys.trademarkNumber'), 'trademark_keys', function ($join): void { $join->on('trademark_keys.trademarkNumber', '=', 's.trademarkNumber'); }, ) ->leftJoin('ops_fys_trademark_info AS t', 't.Id', '=', 'trademark_keys.latest_id') ->when($crawler['company'] !== null, function ($query) use ($crawler): void { $query->leftJoinSub($crawler['company'], 'crawler_company', function ($join): void { $join->on('crawler_company.shop_name', '=', 's.erpShopName'); }); }) ->selectRaw(<<<'SQL' s.Id AS id, s.sotreId AS store_id, s.openTime AS opened_at, s.renewalTime AS renewed_at, s.erpShopName AS erp_store_name, s.unifiedSocialCreditCode AS unified_social_credit_code, c.businessLicenseName AS company_name, c.businessLicenseType AS company_type, s.trademarkNumber AS trademark_number, t.trademarkName AS trademark_name, s.platform AS platform, s.responsibleDepartment AS profit_center, s.shopHead AS store_manager, s.customerDeptManager AS department_manager, s.shopState AS store_status, s.shopBankAccount AS withdrawal_account, s.shopType AS store_type, s.channelType AS channel_mode, NULL AS crawler_company_name, NULL AS crawler_trademark_name, NULL AS crawler_trademark_number SQL); if ($crawler['company'] !== null) { $query->addSelect([ 'crawler_company.company_name AS crawler_company_name', 'crawler_company.tax_no AS crawler_tax_no', ]); } return $query; } /** @return array{data: array>, total: int} */ private function trademarks(int $page, int $perPage, array $filters): array { $query = $this->connection->table('ops_fys_trademark_info') ->where('isDelete', 0) ->selectRaw(<<<'SQL' Id AS id, trademarkNumber AS trademark_number, trademarkName AS trademark_name, internationalClassification AS trademark_type, trademarkStatus AS trademark_status, registrationDate AS registration_date, termOfValidity AS expiry_date, approvedCommodities AS approved_goods, transferenceUser AS final_holder, registrationAddress AS registered_address, custodyDepartment AS custody_department, remark AS notes SQL); $this->applyFieldFilters($query, $filters, [ 'trademark_number' => 'trademarkNumber', 'trademark_name' => 'trademarkName', 'registration_date' => 'registrationDate', 'expiry_date' => 'termOfValidity', 'international_classification' => 'internationalClassification', 'approved_goods' => 'approvedCommodities', 'final_holder' => 'transferenceUser', 'registered_address' => 'registrationAddress', 'custody_department' => 'custodyDepartment', ]); $status = trim((string) ($filters['trademark_status'] ?? '')); if ($status !== '') { $statusCode = ['未启用' => '0', '有效' => '1'][$status] ?? $status; $query->where('trademarkStatus', '=', $statusCode); } return $this->page($query, $page, $perPage, 'Id', $filters, [ 'trademark_name' => 'trademarkName', 'trademark_number' => 'trademarkNumber', 'trademark_status' => 'trademarkStatus', 'registration_date' => 'registrationDate', 'expiry_date' => 'termOfValidity', 'international_classification' => 'internationalClassification', 'approved_goods' => 'approvedCommodities', 'final_holder' => 'transferenceUser', 'registered_address' => 'registrationAddress', 'custody_department' => 'custodyDepartment', ]); } /** @param array $textColumns @param array $exactColumns @param array $enumColumns */ private function applyFieldFilters($query, array $filters, array $textColumns, array $exactColumns = [], array $enumColumns = []): void { foreach ($textColumns as $key => $column) { $value = mb_substr(trim((string) ($filters[$key] ?? '')), 0, 191); if ($value !== '') { $query->where($column, 'like', '%' . addcslashes($value, '%_\\') . '%'); } } foreach ($exactColumns as $key => $column) { $value = trim((string) ($filters[$key] ?? '')); if ($value !== '') { $query->where($column, '=', $value); } } foreach ($enumColumns as $key => $column) { $value = trim((string) ($filters[$key] ?? '')); if ($value !== '') { $query->where($column, '=', $value); } } foreach (['opened_at' => 's.openTime', 'renewed_at' => 's.renewalTime', 'registration_date' => 'registrationDate', 'expiry_date' => 'termOfValidity'] as $key => $column) { if (! array_key_exists($key, $textColumns)) continue; foreach (['_from' => '>=', '_to' => '<='] as $suffix => $operator) { $date = trim((string) ($filters[$key . $suffix] ?? '')); if (preg_match('/^\\d{4}-\\d{2}-\\d{2}$/D', $date)) $query->where($column, $operator, $date); } } } /** @return array{data: array>, total: int} */ private function page($query, int $page, int $perPage, string $orderColumn, array $filters = [], array $sortColumns = []): array { $total = (clone $query)->count(); $sortColumn = $sortColumns[$filters['_sort'] ?? ''] ?? null; $direction = ($filters['_direction'] ?? 'asc') === 'desc' ? 'desc' : 'asc'; if ($sortColumn !== null) { $query->orderBy($sortColumn, $direction)->orderByDesc($orderColumn); } else { $query->orderByDesc($orderColumn); } $rows = $query ->forPage($page, $perPage) ->get() ->map(static fn (object $row): array => (array) $row) ->all(); return ['data' => $rows, 'total' => $total]; } private function dataset(string $scope): BusinessMirrorDataset { $key = self::DATASETS[$scope] ?? null; if ($key === null) { throw new InvalidArgumentException('未知的业务资料镜像范围:' . $scope); } return $this->datasets->get($key); } }