initialize(); $admin = Db::name('admin')->where('root', 1)->whereNull('delete_time')->find(); if (!$admin) { echo "FAN_DETAIL_DB_SMOKE_SKIP no_root\n"; exit(0); } $params = [ 'time_type' => 'custom', 'start_date' => '2026-08-01', 'end_date' => '2026-08-25', ]; $overviewStartedAt = microtime(true); $overview = FirstVisitConversionLogic::overview($params, (int) $admin['id'], $admin); $overviewElapsedMs = (microtime(true) - $overviewStartedAt) * 1000; $candidates = []; $collect = static function (array $rows, array $path = []) use (&$collect, &$candidates): void { foreach ($rows as $row) { if (!is_array($row)) { continue; } $rowPath = array_merge($path, [(string) ($row['name'] ?? $row['id'] ?? '')]); if ((int) ($row['add_fans_count'] ?? 0) > 0) { $row['_smoke_path'] = implode('/', $rowPath); $candidates[] = $row; } $collect(is_array($row['children'] ?? null) ? $row['children'] : [], $rowPath); } }; $collect(is_array($overview['rows'] ?? null) ? $overview['rows'] : []); if ($candidates === []) { echo "FAN_DETAIL_DB_SMOKE_SKIP no_fans\n"; exit(0); } usort($candidates, static function (array $left, array $right): int { $leftCenter = str_contains((string) ($left['_smoke_path'] ?? ''), '郑州二中心') ? 0 : 1; $rightCenter = str_contains((string) ($right['_smoke_path'] ?? ''), '郑州二中心') ? 0 : 1; $leftMember = in_array((string) ($left['type'] ?? ''), ['member', 'unbound'], true) ? 0 : 1; $rightMember = in_array((string) ($right['type'] ?? ''), ['member', 'unbound'], true) ? 0 : 1; return ($leftCenter <=> $rightCenter) ?: ($leftMember <=> $rightMember) ?: ((int) ($left['add_fans_count'] ?? 0) <=> (int) ($right['add_fans_count'] ?? 0)); }); $row = $candidates[0]; $entityType = in_array((string) ($row['type'] ?? ''), ['member', 'unbound'], true) ? 'member' : 'dept'; $detailStartedAt = microtime(true); $detail = FirstVisitConversionLogic::fansDetail($params + [ 'entity_type' => $entityType, 'entity_id' => (string) ($row['id'] ?? ''), 'admin_id' => (int) ($row['admin_id'] ?? 0), 'page_no' => 1, 'page_size' => 100, ], (int) $admin['id'], $admin); $detailElapsedMs = (microtime(true) - $detailStartedAt) * 1000; if ((int) ($detail['count'] ?? -1) !== (int) ($row['add_fans_count'] ?? 0)) { throw new RuntimeException('Fan detail count does not match the clicked add_fans_count row'); } if ((int) ($detail['entity']['deleted_fans_count'] ?? -1) !== (int) ($row['deleted_fans_count'] ?? 0)) { throw new RuntimeException('Fan detail entity metadata lost deleted_fans_count'); } foreach ($detail['lists'] ?? [] as $fan) { foreach (['external_userid', 'customer_name', 'wecom_userid', 'wecom_staff_name', 'add_time', 'is_deleted', 'delete_time'] as $field) { if (!array_key_exists($field, $fan)) { throw new RuntimeException("Fan detail row is missing {$field}"); } } } $forgedDetail = FirstVisitConversionLogic::fansDetail($params + [ 'entity_type' => 'member', 'entity_id' => 'M999999_-2', 'admin_id' => 999999, 'page_no' => 1, 'page_size' => 1, ], (int) $admin['id'], $admin); if ((int) ($forgedDetail['count'] ?? -1) !== 0 || ($forgedDetail['entity'] ?? null) !== null) { throw new RuntimeException('Forged fan-detail entity did not fail closed'); } $centerDetailElapsedMs = 0.0; $centerDetailCount = -1; $centerRows = array_values(array_filter($candidates, static fn (array $candidate): bool => !in_array((string) ($candidate['type'] ?? ''), ['member', 'unbound'], true) && str_contains((string) ($candidate['name'] ?? ''), '郑州二中心') )); if ($centerRows !== []) { $centerRow = $centerRows[0]; $centerStartedAt = microtime(true); $centerDetail = FirstVisitConversionLogic::fansDetail($params + [ 'entity_type' => 'dept', 'entity_id' => (string) ($centerRow['id'] ?? ''), 'page_no' => 1, 'page_size' => 100, ], (int) $admin['id'], $admin); $centerDetailElapsedMs = (microtime(true) - $centerStartedAt) * 1000; $centerDetailCount = (int) ($centerDetail['count'] ?? -1); if ($centerDetailCount !== (int) ($centerRow['add_fans_count'] ?? 0) || (int) ($centerDetail['entity']['deleted_fans_count'] ?? -1) !== (int) ($centerRow['deleted_fans_count'] ?? 0) ) { throw new RuntimeException('郑州二中心 detail count does not match overview'); } } $parentCandidates = array_values(array_filter($candidates, static function (array $candidate): bool { foreach (is_array($candidate['children'] ?? null) ? $candidate['children'] : [] as $child) { if (is_array($child) && !in_array((string) ($child['type'] ?? ''), ['member', 'unbound'], true)) { return true; } } return false; })); $parentChecked = false; if ($parentCandidates !== []) { usort( $parentCandidates, static fn (array $left, array $right): int => ((int) ($left['add_fans_count'] ?? 0)) <=> ((int) ($right['add_fans_count'] ?? 0)) ); $parentRow = $parentCandidates[0]; $parentDeptIds = []; $collectDeptIds = static function (array $node) use (&$collectDeptIds, &$parentDeptIds): void { if (in_array((string) ($node['type'] ?? ''), ['member', 'unbound'], true)) { return; } $parentDeptIds[(int) ($node['id'] ?? 0)] = true; foreach (is_array($node['children'] ?? null) ? $node['children'] : [] as $child) { if (is_array($child)) { $collectDeptIds($child); } } }; $collectDeptIds($parentRow); $parentDetail = ConversionLogic::fanDetails([ 'dimension' => 'dept', 'time_type' => 'custom', 'start_date' => (string) ($overview['meta']['start_date'] ?? ''), 'end_date' => (string) ($overview['meta']['end_date'] ?? ''), 'page_no' => 1, 'page_size' => 1, ], [ 'type' => 'dept', 'dept_ids' => array_keys($parentDeptIds), ], (int) $admin['id'], $admin); if ((int) ($parentDetail['count'] ?? -1) !== (int) ($parentRow['add_fans_count'] ?? 0)) { throw new RuntimeException('Parent department fan detail does not include the same descendant total as overview'); } $parentChecked = true; } echo sprintf( "FAN_DETAIL_DB_SMOKE_OK range=2026-08-01..2026-08-25 type=%s entity=%s path=%s count=%d deleted=%d parent=%d overview_ms=%.1f detail_ms=%.1f center_count=%d center_detail_ms=%.1f forged=0\n", $entityType, (string) ($row['name'] ?? $row['id'] ?? ''), (string) ($row['_smoke_path'] ?? ''), (int) $detail['count'], (int) ($detail['entity']['deleted_fans_count'] ?? 0), $parentChecked ? 1 : 0, $overviewElapsedMs, $detailElapsedMs, $centerDetailCount, $centerDetailElapsedMs );