镜像列名。 */ private const FIELD_COLUMNS = [ 'store_name' => 'storeName', 'operation_owner' => 'operationOwner', 'operation_department_head' => 'operationDepartmentHead', 'operation_director' => 'operationDirector', 'profit_center' => 'profitCenter', 'account_type' => 'accountType', 'account' => 'account', 'password_configured' => 'passwordConfigured', 'bound_phone' => 'boundPhone', 'verification_contact' => 'verificationContact', 'account_group_name' => 'accountGroupName', 'created_at' => 'createdAt', 'updated_at' => 'updatedAt', ]; /** 店铺档案(fys_shopInfo.shopState)到页面文案的映射。 */ private const STORE_STATUS_LABELS = [0 => '正常', 3 => '关闭中', 6 => '已关闭']; /** 授权状态文案;顺序同时用于下拉和排序。 */ private const AUTHORIZATION_LABELS = ['已授权', '已过期', '未授权']; /** 店铺申请流程(开店/变更/关店/主体注销自动)所在的数据集。 */ private const FLOW_FORM_DATASET = 'jiansun.dbo.fys_shopInfo_flow'; private const FLOW_OA_DATASET = 'jiansun.dbo.flow_flow'; /** 流程类型 / 审批状态文案;顺序同时用于下拉和排序。 */ private const FLOW_TYPE_LABELS = ['店铺开店', '店铺变更', '店铺关店', '主体注销自动流程']; private const FLOW_STATUS_LABELS = ['流程中', '待审批', '完成', '拒绝', '撤回', '暂存', '待签署', '已签署']; /** 账号店铺名 -> 状态 的缓存键(两张档案表要全表读,缓存 10 分钟)。 */ private const STATUS_CACHE_KEY = 'store-accounts.statuses:v4'; public function __construct( private readonly BusinessMirrorDatasetRegistry $datasets, private readonly GenericBusinessMirrorRepository $mirror, ) {} /** @return array{data:array>,total:int,summary:array} */ public function paginate(int $page, int $perPage, array $filters = []): array { $dataset = $this->datasets->get(self::DATASET); if (! $this->mirror->ready($dataset)) { throw new RuntimeException('store_account_mirror_not_ready'); } $page = max($page, 1); $perPage = min(max($perPage, 1), 100); $query = $this->mirror->connection()->table($dataset->targetTable()); $columns = [ 'store_name' => 'storeName', 'operation_owner' => 'operationOwner', 'operation_department_head' => 'operationDepartmentHead', 'operation_director' => 'operationDirector', 'profit_center' => 'profitCenter', 'account' => 'account', 'bound_phone' => 'boundPhone', 'verification_contact' => 'verificationContact', 'account_group_name' => 'accountGroupName', ]; foreach ($columns as $key => $column) { $value = mb_substr(trim((string) ($filters[$key] ?? '')), 0, 191); if ($value !== '') $query->where($column, 'like', '%' . addcslashes($value, '%_\\') . '%'); } $accountType = trim((string) ($filters['account_type'] ?? '')); if ($accountType !== '') $query->where('accountType', '=', $accountType === '主账号' ? 'MAIN' : ($accountType === '子账号' ? 'SUB' : $accountType)); $passwordState = trim((string) ($filters['password_configured'] ?? '')); if (in_array($passwordState, ['已设置', '未设置', '状态未同步'], true)) { if ($passwordState === '状态未同步') $query->whereNull('passwordConfigured'); else $query->where('passwordConfigured', $passwordState === '已设置'); } foreach (['created_at' => 'createdAt', 'updated_at' => 'updatedAt'] as $key => $column) { foreach (['_from' => '>=', '_to' => '<='] as $suffix => $operator) { $date = trim((string) ($filters[$key . $suffix] ?? '')); if (preg_match('/^\d{4}-\d{2}-\d{2}$/D', $date)) $query->whereDate($column, $operator, $date); } } // 授权状态 / 店铺状态来自另两张镜像表(按店铺名关联),这里先在 PHP 里 // 把"账号店铺名 -> 状态"算出来,再用 whereIn 下推到 SQL,分页和总数不受影响。 $statusFilters = [ 'store_status' => $this->filterValues($filters['store_status'] ?? ''), 'authorization_status' => $this->filterValues($filters['authorization_status'] ?? ''), 'flow_type' => $this->filterValues($filters['flow_type'] ?? ''), 'approval_status' => $this->filterValues($filters['approval_status'] ?? ''), ]; $statuses = ($statusFilters['store_status'] !== [] || $statusFilters['authorization_status'] !== [] || $statusFilters['flow_type'] !== [] || $statusFilters['approval_status'] !== []) ? $this->statusesByStoreName() : null; if ($statuses !== null) { foreach ([ 'store_status' => 'store', 'authorization_status' => 'authorization', 'flow_type' => 'flow_type', 'approval_status' => 'flow', ] as $key => $bucket) { $expected = $statusFilters[$key]; if ($expected === []) { continue; } $query->whereIn('storeName', $this->storeNamesWithStatuses($statuses[$bucket], $expected)); } } $summaryRow = (clone $query) ->select([]) ->selectRaw(<<<'SQL' COUNT(*) AS total, COALESCE(SUM(CASE WHEN accountType = 'MAIN' THEN 1 ELSE 0 END), 0) AS main_accounts, COALESCE(SUM(CASE WHEN accountType = 'SUB' THEN 1 ELSE 0 END), 0) AS sub_accounts, COALESCE(SUM(CASE WHEN passwordConfigured = 1 THEN 1 ELSE 0 END), 0) AS password_set, COALESCE(SUM(CASE WHEN passwordConfigured = 0 THEN 1 ELSE 0 END), 0) AS password_missing SQL) ->first(); $summary = array_map(static fn ($value): int => (int) $value, (array) $summaryRow); $total = (clone $query)->count(); $sortColumns = [ 'store_name' => 'storeName', 'operation_owner' => 'operationOwner', 'operation_department_head' => 'operationDepartmentHead', 'operation_director' => 'operationDirector', 'profit_center' => 'profitCenter', 'account_type' => 'accountType', 'account' => 'account', 'password_configured' => 'passwordConfigured', 'bound_phone' => 'boundPhone', 'verification_contact' => 'verificationContact', 'account_group_name' => 'accountGroupName', 'created_at' => 'createdAt', 'updated_at' => 'updatedAt', ]; $sortColumn = $sortColumns[$filters['_sort'] ?? ''] ?? null; $direction = ($filters['_direction'] ?? 'asc') === 'desc' ? 'desc' : 'asc'; if ($sortColumn !== null) { $query->orderBy($sortColumn, $direction)->orderByDesc('id'); } elseif (in_array($filters['_sort'] ?? '', ['store_status', 'authorization_status'], true)) { // 关联出来的状态列按业务顺序排(正常/关闭中/已关闭、已授权/已过期/未授权)。 $sortKey = $filters['_sort'] === 'store_status' ? 'store' : 'authorization'; $labels = $filters['_sort'] === 'store_status' ? array_values(self::STORE_STATUS_LABELS) : self::AUTHORIZATION_LABELS; $statuses ??= $this->statusesByStoreName(); [$sql, $bindings] = $this->statusOrderBy($statuses[$sortKey], $labels, $direction); $query->orderByRaw($sql, $bindings)->orderByDesc('id'); } elseif (in_array($filters['_sort'] ?? '', ['flow_type', 'approval_status'], true)) { $statuses ??= $this->statusesByStoreName(); $sortedKey = $filters['_sort'] === 'flow_type' ? 'flow_type' : 'flow'; $labels = $filters['_sort'] === 'flow_type' ? self::FLOW_TYPE_LABELS : self::FLOW_STATUS_LABELS; [$sql, $bindings] = $this->statusOrderBy($statuses[$sortedKey], $labels, $direction); $query->orderByRaw($sql, $bindings)->orderByDesc('id'); } else { $query->orderByDesc('id'); } $statuses ??= $this->statusesByStoreName(); $followedColumns = $this->followedColumns(); $rows = $query ->forPage($page, $perPage) ->get() ->map(function (object $row) use ($statuses, $followedColumns): array { $mapped = [ 'id' => $row->id, 'store_name' => $row->storeName, 'operation_owner' => $row->operationOwner, 'operation_department_head' => $row->operationDepartmentHead, 'operation_director' => $row->operationDirector, 'profit_center' => $row->profitCenter, 'account_type' => $this->accountType($row->accountType), 'account' => $row->account, // Only the safe boolean may enter the mirror, never a password. 'password_configured' => isset($row->passwordConfigured) ? (bool) $row->passwordConfigured : null, 'bound_phone' => $row->boundPhone, 'verification_contact' => $row->verificationContact, 'account_group_name' => $row->accountGroupName, 'authorization_status' => $statuses['authorization'][$row->storeName] ?? '', 'store_status' => $statuses['store'][$row->storeName] ?? '', 'flow_type' => $statuses['flow_type'][$row->storeName] ?? '', 'approval_status' => $statuses['flow'][$row->storeName] ?? '', 'created_at' => $row->createdAt, 'updated_at' => $row->updatedAt, ]; // 自动跟随上游补进来的字段:镜像里有什么就带什么,页面据此多出对应列。 foreach ($followedColumns as $column) { $mapped[$column] = $row->{$column} ?? null; } return $mapped; }) ->all(); return ['data' => $rows, 'total' => $total, 'summary' => $summary]; } /** * 账号店铺名 -> 授权状态 / 店铺状态。 * * - 授权状态:聚水潭店铺档案(jst_shopInfo)的 session_expired —— 无授权时间为未授权, * 已过期;否则为已授权。 * - 店铺状态:店铺档案(fys_shopInfo)的 shopState —— 0 正常 / 3 关闭中 / 6 已关闭。 * * 两张表按店铺名(把下划线、空格统一成短横线后)关联,实测账号表 966 个店铺里 * 授权命中 963、店铺状态命中 948。 * * @return array{store:array,authorization:array} */ private function statusesByStoreName(): array { if (is_array($cached = Cache::get(self::STATUS_CACHE_KEY))) { return $cached; } $storeStates = []; foreach ($this->mirror->connection()->table('ops_fys_shop_info') ->select(['Id', 'erpShopName', 'shopState'])->orderBy('Id')->get() as $row) { $key = $this->shopKey($row->erpShopName); if ($key !== '') { $storeStates[$key] = self::STORE_STATUS_LABELS[(int) $row->shopState] ?? ''; } } $authorizations = []; $now = now()->getTimestamp(); foreach ($this->mirror->connection()->table('ops_jst_shop_info') ->select(['Id', 'shop_name', 'session_expired'])->orderBy('Id')->get() as $row) { $key = $this->shopKey($row->shop_name); if ($key === '') { continue; } $expired = trim((string) $row->session_expired); $timestamp = $expired === '' ? false : strtotime($expired); $authorizations[$key] = $timestamp === false ? '未授权' : ($timestamp >= $now ? '已授权' : '已过期'); } $flows = $this->latestFlowByStoreName(); $result = ['store' => [], 'authorization' => [], 'flow' => [], 'flow_type' => []]; foreach ($this->mirror->connection()->table($this->datasets->get(self::DATASET)->targetTable()) ->select('storeName')->whereNotNull('storeName')->distinct()->pluck('storeName') as $name) { $name = (string) $name; $key = $this->shopKey($name); $result['store'][$name] = $storeStates[$key] ?? ''; $result['authorization'][$name] = $authorizations[$key] ?? ''; $result['flow_type'][$name] = $flows[$key]['flow_type'] ?? ''; $result['flow'][$name] = $flows[$key]['approval_status'] ?? ''; } // 状态取自店铺档案与流程镜像,变化很慢;全量算一次要几秒,缓存久一点。 Cache::put(self::STATUS_CACHE_KEY, $result, now()->addMinutes(30)); return $result; } /** * 账号店铺 -> 最近一单店铺申请流程(开店/变更/关店/主体注销自动)的类型与审批状态。 * * 按 ERP 店铺名关联 jiansun.dbo.fys_shopInfo_flow,同一店铺取申请时间最近的一单; * 审批状态用中台库 OA 流程表(dbo.flow_flow)的字典文案。 * * @return array 归一键 => 流程信息 */ private function latestFlowByStoreName(): array { try { $form = $this->datasets->get(self::FLOW_FORM_DATASET)->targetTable(); $oa = $this->datasets->get(self::FLOW_OA_DATASET)->targetTable(); } catch (\Throwable) { // 店铺申请流程数据集尚未接入时,这两列留空即可,不影响其它列。 return []; } try { $rows = $this->mirror->connection()->table($form . ' as f') ->leftJoin($oa . ' as o', 'o.flowId', '=', 'f.flowId') ->select(['f.erpShopName as erp_shop_name', 'f.flowType as flow_type', 'o.flowStatus as flow_status']) ->orderByDesc('f.applyTime')->orderByDesc('f.Id') ->get(); } catch (\Throwable $exception) { report($exception); return []; } $flows = []; foreach ($rows as $row) { $key = $this->shopKey($row->erp_shop_name); if ($key === '' || array_key_exists($key, $flows)) { continue; // 已按申请时间倒序,首次出现的就是最近一单 } $type = (int) $row->flow_type; $flows[$key] = [ 'flow_type' => ShopInfoFlowMirrorRepository::FLOW_TYPES[$type] ?? ('其它(' . $type . ')'), 'approval_status' => $row->flow_status === null ? '' : (ShopInfoFlowMirrorRepository::APPROVAL_STATUSES[(int) $row->flow_status] ?? (string) $row->flow_status), ]; } return $flows; } /** * 筛选值统一成列表:单选("正常")和多选(["正常","已关闭"])都支持。 * * @return list */ private function filterValues(mixed $value): array { $values = []; foreach ((array) $value as $item) { if (! is_scalar($item)) { continue; } $item = trim((string) $item); if ($item !== '') { $values[$item] = true; } } return array_keys($values); } /** @return list 命中任一指定状态的账号店铺名 */ private function storeNamesWithStatuses(array $byStore, array $expected): array { $names = []; foreach ($byStore as $name => $status) { if (in_array($status, $expected, true)) { $names[] = $name; } } return $names; } /** * 把状态列翻译成 SQL 排序:按给定文案顺序生成 CASE WHEN。 * * @param array $byStore * @param list $labels * @return array{0:string,1:list} */ private function statusOrderBy(array $byStore, array $labels, string $direction): array { $cases = []; $bindings = []; foreach ($labels as $index => $label) { $names = $this->storeNamesWithStatuses($byStore, [$label]); if ($names === []) { continue; } $cases[] = 'WHEN storeName IN (' . implode(', ', array_fill(0, count($names), '?')) . ') THEN ' . $index; array_push($bindings, ...$names); } if ($cases === []) { return ['1', []]; } return ['CASE ' . implode(' ', $cases) . ' ELSE ' . count($labels) . ' END ' . $direction, $bindings]; } /** 店铺名归一键:下划线、半/全角空格统一成短横线,便于跨表关联。 */ public static function storeNameKey(mixed $name): string { return str_replace(['_', ' ', ' '], ['-', '', ''], trim((string) $name)); } private function shopKey(mixed $name): string { return self::storeNameKey($name); } /** @return list 自动跟随上游补进来的字段(镜像列名) */ public function followedColumns(): array { return app(FollowedUpstreamColumns::class)->columns(self::DATASET); } /** * 给页面字段列表补上自动跟随进来的列(字段名与标签都用上游列名)。 * * @param list> $fields * @return list> */ public function withFollowedFields(array $fields): array { return app(FollowedUpstreamColumns::class)->appendFields(self::DATASET, $fields); } public function containsAccount(string $id): bool { $dataset = $this->datasets->get(self::DATASET); if (! $this->mirror->ready($dataset)) { throw new RuntimeException('store_account_mirror_not_ready'); } return $this->mirror->connection()->table($dataset->targetTable())->where('id', $id)->exists(); } public function containsAccounts(array $ids): bool { $ids = array_values(array_unique($ids)); if ($ids === []) { return false; } $dataset = $this->datasets->get(self::DATASET); if (! $this->mirror->ready($dataset)) { throw new RuntimeException('store_account_mirror_not_ready'); } return $this->mirror->connection()->table($dataset->targetTable()) ->whereIn('id', $ids)->distinct()->count('id') === count($ids); } /** * 店铺维度归属字段,以归一后的店铺名称(storeName)为键。 * * 店铺负责人取 operationOwner,部门负责人取 operationDepartmentHead; * operationDirector 是运营总监,不能替代部门负责人。 * * @return array */ public function ownershipByStoreName(): array { $dataset = $this->datasets->get(self::DATASET); if (! $this->mirror->ready($dataset)) { throw new RuntimeException('store_account_mirror_not_ready'); } $map = []; foreach ($this->mirror->connection()->table($dataset->targetTable()) ->select('storeName', 'operationOwner', 'operationDepartmentHead', 'operationDirector', 'profitCenter') ->orderBy('id') ->get() as $row) { $name = trim((string) $row->storeName); if ($name === '') { continue; } $map[self::storeNameKey($name)] = [ 'operation_owner' => trim((string) $row->operationOwner), 'operation_department_head' => trim((string) $row->operationDepartmentHead), 'operation_director' => trim((string) $row->operationDirector), 'profit_center' => trim((string) $row->profitCenter), ]; } return $map; } private function accountType(mixed $value): ?string { $type = trim((string) $value); return match (strtoupper($type)) { 'MAIN' => '主账号', 'SUB' => '子账号', '', '/' => null, default => $type, }; } }