feat: normalize legacy migration learning data

This commit is contained in:
Codex
2026-06-30 10:04:48 +08:00
parent 2510d736a4
commit 3303426806
7 changed files with 584 additions and 22 deletions

View File

@@ -96,6 +96,7 @@ const supportedCollections = new Set([
'categories',
'code_batches',
'codes',
'commission_settings',
'coupons',
'coupon_redemptions',
'crm_config',
@@ -109,6 +110,7 @@ const supportedCollections = new Set([
'handbook_subjects',
'images',
'majors',
'mock_exam_configs',
'module_nodes',
'orders',
'products',
@@ -117,6 +119,7 @@ const supportedCollections = new Set([
'questions',
'recent_practices',
'referral_tracks',
'referral_qrcodes',
'region_modules',
'regions',
'reports',
@@ -132,6 +135,7 @@ const supportedCollections = new Set([
'svip_plans',
'timelines',
'user_badges',
'user_answer_records',
'user_word_favorites',
'user_word_progress',
'users',

View File

@@ -39,6 +39,7 @@ const legacyLookupTables = new Set([
'majors',
'module_nodes',
'orders',
'practice_blueprints',
'products',
'questions',
'region_modules',
@@ -54,6 +55,12 @@ const legacyLookupTables = new Set([
'vocabulary_words',
]);
const nonCollectionJsonFiles = new Set([
'pb_schema.sqlite.json',
'sqlite-export-manifest.json',
'storage-manifest.json',
]);
function asArray(input: unknown): JsonRecord[] {
if (Array.isArray(input)) return input as JsonRecord[];
if (input && typeof input === 'object' && Array.isArray((input as { items?: unknown[] }).items)) {
@@ -179,6 +186,12 @@ function arrayValue(value: unknown): unknown[] {
return [];
}
function objectValue(value: unknown): JsonRecord {
const parsed = parseJsonish(value, {});
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed as JsonRecord;
return {};
}
function entriesValue(value: unknown): Array<[string, unknown]> {
const parsed = parseJsonish(value, {});
if (Array.isArray(parsed)) return parsed.map((item, index) => [String(index), item]);
@@ -190,6 +203,20 @@ function dateText(value: unknown): string {
return text(value) || '';
}
function nullableDateText(...values: unknown[]): string {
for (const value of values) {
const result = dateText(value);
if (result) return result;
}
return '';
}
function clampRate(value: unknown, fallback = 0.2): number {
const parsed = numberValue(value, fallback);
if (parsed === null || !Number.isFinite(parsed)) return fallback;
return Math.min(1, Math.max(0, parsed));
}
function normalizeTenantRole(roleValue: unknown): string {
const role = text(roleValue)?.toLowerCase();
if (role === 'superadmin') return 'platform_admin';
@@ -395,22 +422,45 @@ async function markNormalized(runId: string, collection: string) {
);
}
const legacyLookupCache = new Map<string, string | null>();
const userLookupCache = new Map<string, string | null>();
const questionVersionLookupCache = new Map<string, string | null>();
async function legacyId(tableName: string, legacyValue: unknown): Promise<string | null> {
const value = text(legacyValue);
if (!value) return null;
if (!legacyLookupTables.has(tableName)) throw new Error(`Unsafe legacy lookup table: ${tableName}`);
const cacheKey = `${tableName}:${value}`;
if (legacyLookupCache.has(cacheKey)) return legacyLookupCache.get(cacheKey) || null;
const row = await queryOne<{ id: string }>(
`select id from public.${tableName} where tenant_id = $1 and legacy_id = $2 limit 1`,
[tenantId, value],
);
return row?.id || null;
const result = row?.id || null;
legacyLookupCache.set(cacheKey, result);
return result;
}
async function userIdByLegacy(value: unknown): Promise<string | null> {
const legacyValue = text(value);
if (!legacyValue) return null;
if (userLookupCache.has(legacyValue)) return userLookupCache.get(legacyValue) || null;
const row = await queryOne<{ id: string }>('select id from public.platform_users where legacy_id = $1 limit 1', [legacyValue]);
return row?.id || null;
const result = row?.id || null;
userLookupCache.set(legacyValue, result);
return result;
}
async function currentQuestionVersionId(questionId: string | null): Promise<string | null> {
if (!questionId) return null;
if (questionVersionLookupCache.has(questionId)) return questionVersionLookupCache.get(questionId) || null;
const row = await queryOne<{ current_version_id: string | null }>(
`select current_version_id from public.questions where tenant_id = $1 and id = $2 limit 1`,
[tenantId, questionId],
);
const result = row?.current_version_id || null;
questionVersionLookupCache.set(questionId, result);
return result;
}
async function upsertSecret(secretScope: string, secretKey: string, secretValue: unknown, provider: string | null = null) {
@@ -825,6 +875,109 @@ async function normalizeQuestions(records: JsonRecord[]) {
}
}
async function normalizeUserAnswerRecords(runId: string, records: JsonRecord[]) {
const affectedWrongPairs = new Set<string>();
for (const r of records) {
const userId = await userIdByLegacy(r.userId);
if (!userId) {
await issue(
runId,
'user_answer_records',
r.id,
'answer_user_not_found',
`Answer record cannot be imported because userId was not resolved: ${text(r.userId) || '(empty)'}`,
'critical',
);
continue;
}
const questionId = await legacyId('questions', r.questionId);
if (!questionId) {
await issue(
runId,
'user_answer_records',
r.id,
'answer_question_not_found',
`Answer record kept with legacy_question_id only because questionId was not resolved: ${text(r.questionId) || '(empty)'}`,
'warning',
);
}
const isCorrect = r.isCorrect === null || r.isCorrect === undefined || r.isCorrect === ''
? null
: boolValue(r.isCorrect);
const answeredAt = nullableDateText(r.answeredAt, r.updated, r.created);
const answerPayload = {
source: 'pocketbase.user_answer_records',
selectedOptions: arrayValue(r.selectedOptions),
legacyCategoryId: text(r.categoryId),
legacyQuestionId: text(r.questionId),
importedAt: new Date().toISOString(),
};
await pool.query(
`
insert into public.answer_records (
tenant_id, user_id, question_id, question_version_id, legacy_id,
legacy_question_id, legacy_category_id, selected_options,
answer_payload, is_correct, answered_at, created_at
)
values ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9::jsonb,$10,
coalesce(nullif($11::text,'')::timestamptz, now()),
coalesce(nullif($12::text,'')::timestamptz, now())
)
on conflict (tenant_id, legacy_id) do update set
user_id = excluded.user_id,
question_id = excluded.question_id,
question_version_id = excluded.question_version_id,
legacy_question_id = excluded.legacy_question_id,
legacy_category_id = excluded.legacy_category_id,
selected_options = excluded.selected_options,
answer_payload = excluded.answer_payload,
is_correct = excluded.is_correct,
answered_at = excluded.answered_at
`,
[
tenantId,
userId,
questionId,
await currentQuestionVersionId(questionId),
r.id,
text(r.questionId),
text(r.categoryId),
JSON.stringify(arrayValue(r.selectedOptions)),
JSON.stringify(answerPayload),
isCorrect,
answeredAt,
nullableDateText(r.created, answeredAt),
],
);
if (isCorrect === false && questionId) affectedWrongPairs.add(`${userId}:${questionId}`);
}
for (const pair of affectedWrongPairs) {
const [userId, questionId] = pair.split(':');
await pool.query(
`
insert into public.wrong_questions (tenant_id, user_id, question_id, wrong_count, last_wrong_at, resolved_at)
select $1, $2, $3, count(*)::integer, max(answered_at), null
from public.answer_records
where tenant_id = $1
and user_id = $2
and question_id = $3
and is_correct = false
on conflict (tenant_id, user_id, question_id)
do update set wrong_count = excluded.wrong_count,
last_wrong_at = excluded.last_wrong_at,
resolved_at = null
`,
[tenantId, userId, questionId],
);
}
}
async function normalizeUsers(records: JsonRecord[]) {
for (const r of records) {
const safeProfile = publicProfile(r);
@@ -1166,6 +1319,84 @@ async function normalizeSvipPlans(records: JsonRecord[]) {
}
}
function normalizeMockExamSections(questionTypes: unknown) {
return arrayValue(questionTypes)
.map((item, index) => {
const object = objectValue(item);
const questionType = text(object.type) || text(object.questionType) || `section_${index + 1}`;
const count = intValue(object.count, 0);
return {
key: text(object.key) || questionType,
title: text(object.title) || text(object.name) || questionType,
questionType,
count,
sortOrder: intValue(object.order, index + 1) - 1,
scoreEach: numberValue(object.scoreEach ?? object.score, null),
};
})
.filter(section => section.count > 0)
.sort((a, b) => a.sortOrder - b.sortOrder);
}
async function normalizeMockExamConfigs(runId: string, records: JsonRecord[]) {
for (const r of records) {
const subjectId = await legacyId('subjects', r.subjectId);
const sections = normalizeMockExamSections(r.questionTypes);
const questionLimit = intValue(r.totalQuestions, sections.reduce((sum, section) => sum + section.count, 0));
if (!subjectId) {
await issue(
runId,
'mock_exam_configs',
r.id,
'mock_exam_subject_not_found',
`Mock exam blueprint imported without resolved subject because subjectId was not found: ${text(r.subjectId) || '(empty)'}`,
'warning',
);
}
await pool.query(
`
insert into public.practice_blueprints (
tenant_id, legacy_id, name, mode, assembly_type, question_limit,
duration_minutes, sections, rules, status, sort_order, created_at, updated_at
)
values ($1,$2,$3,'mock_exam','filters',$4,$5,$6::jsonb,$7::jsonb,$8,$9,
coalesce(nullif($10::text,'')::timestamptz, now()),
coalesce(nullif($11::text,'')::timestamptz, now())
)
on conflict (tenant_id, legacy_id) do update set
name = excluded.name,
question_limit = excluded.question_limit,
duration_minutes = excluded.duration_minutes,
sections = excluded.sections,
rules = excluded.rules,
status = excluded.status,
sort_order = excluded.sort_order,
updated_at = excluded.updated_at
`,
[
tenantId,
r.id,
text(r.name) || `全真模拟-${text(r.subjectId) || text(r.id) || '未命名'}`,
questionLimit > 0 ? questionLimit : null,
intValue(r.duration, 45),
JSON.stringify(sections),
JSON.stringify({
source: 'pocketbase.mock_exam_configs',
subjectId,
legacySubjectId: text(r.subjectId),
legacyQuestionTypes: arrayValue(r.questionTypes),
randomize: true,
}),
boolValue(r.isActive, true) ? 'active' : 'archived',
intValue(r.order),
dateText(r.created),
dateText(r.updated),
],
);
}
}
async function normalizeCodeBatches(records: JsonRecord[]) {
for (const r of records) {
await pool.query(
@@ -1373,8 +1604,19 @@ async function normalizeCouponRedemptions(records: JsonRecord[]) {
}
}
async function normalizeOrders(records: JsonRecord[]) {
async function normalizeOrders(runId: string, records: JsonRecord[]) {
for (const r of records) {
const resolvedUserId = await userIdByLegacy(r.userId);
const sanitized = sanitizeRecord(r);
const rawPayload = {
...(sanitized as Record<string, unknown>),
migration: {
source: 'pocketbase.orders',
reviewRequired: !resolvedUserId,
reviewReason: !resolvedUserId ? 'legacy_user_not_resolved' : null,
entitlementBlocked: !resolvedUserId,
},
};
const order = await queryOne<{ id: string }>(
`
insert into public.orders (
@@ -1410,7 +1652,7 @@ async function normalizeOrders(records: JsonRecord[]) {
`,
[
tenantId,
await userIdByLegacy(r.userId),
resolvedUserId,
r.id,
text(r.userId),
text(r.orderNo) || `legacy-${text(r.id)}`,
@@ -1426,13 +1668,23 @@ async function normalizeOrders(records: JsonRecord[]) {
await legacyId('regions', r.regionId),
text(r.regionId),
dateText(r.paidAt),
JSON.stringify(sanitizeRecord(r)),
JSON.stringify(rawPayload),
dateText(r.created),
dateText(r.updated),
],
);
if (!order) continue;
if (!resolvedUserId) {
await issue(
runId,
'orders',
r.id,
'order_user_not_resolved',
`Order imported for finance review only because userId was not resolved: ${text(r.userId) || '(empty)'}`,
normalizeOrderStatus(r.status) === 'paid' ? 'critical' : 'warning',
);
}
await pool.query(
`
@@ -1625,17 +1877,60 @@ async function normalizeHandbookSubjects(records: JsonRecord[]) {
}
}
async function normalizeHandbookChapters(records: JsonRecord[]) {
async function migrationOrphanHandbookSubjectId(): Promise<string> {
const legacyIdValue = '__migration_orphan_handbook_subject__';
const row = await queryOne<{ id: string }>(
`
insert into public.handbook_subjects (
tenant_id, legacy_id, name, type, description, sort_order, is_active, metadata
)
values ($1,$2,'迁移待复核手册','migration_review','旧 PocketBase 章节缺少 subjectId 时自动挂载到这里,需人工归并。',999999,true,$3::jsonb)
on conflict (tenant_id, legacy_id) do update set
name = excluded.name,
description = excluded.description,
metadata = public.handbook_subjects.metadata || excluded.metadata,
updated_at = now()
returning id
`,
[
tenantId,
legacyIdValue,
JSON.stringify({
source: 'pocketbase.handbook_chapters',
reviewRequired: true,
reviewReason: 'legacy_chapter_subject_missing',
}),
],
);
if (!row) throw new Error('Failed to create migration orphan handbook subject');
return row.id;
}
async function normalizeHandbookChapters(runId: string, records: JsonRecord[]) {
for (const r of records) {
let subjectId = await legacyId('handbook_subjects', r.subjectId);
const missingSubject = !subjectId;
if (missingSubject) {
subjectId = await migrationOrphanHandbookSubjectId();
await issue(
runId,
'handbook_chapters',
r.id,
'handbook_chapter_subject_missing',
`Handbook chapter was attached to migration review subject because subjectId was missing or unresolved: ${text(r.subjectId) || '(empty)'}`,
'critical',
);
}
await pool.query(
`
insert into public.handbook_chapters (
tenant_id, subject_id, legacy_id, name, description, sort_order,
is_active, created_at, updated_at
is_active, metadata, created_at, updated_at
)
values ($1,$2,$3,$4,$5,$6,$7,
coalesce(nullif($8::text,'')::timestamptz, now()),
coalesce(nullif($9::text,'')::timestamptz, now())
values ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,
coalesce(nullif($9::text,'')::timestamptz, now()),
coalesce(nullif($10::text,'')::timestamptz, now())
)
on conflict (tenant_id, legacy_id) do update set
subject_id = excluded.subject_id,
@@ -1643,16 +1938,23 @@ async function normalizeHandbookChapters(records: JsonRecord[]) {
description = excluded.description,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
metadata = excluded.metadata,
updated_at = excluded.updated_at
`,
[
tenantId,
await legacyId('handbook_subjects', r.subjectId),
subjectId,
r.id,
text(r.name) || '未命名手册章节',
text(r.description),
intValue(r.order),
boolValue(r.isActive, true),
JSON.stringify({
source: 'pocketbase.handbook_chapters',
legacySubjectId: text(r.subjectId),
reviewRequired: missingSubject,
reviewReason: missingSubject ? 'legacy_subject_missing_or_unresolved' : null,
}),
dateText(r.created),
dateText(r.updated),
],
@@ -2273,6 +2575,130 @@ async function normalizeReferralTracks(records: JsonRecord[]) {
}
}
function refCodeFromScene(sceneValue: unknown, fallback: unknown): string {
const scene = text(sceneValue);
if (scene) {
const match = /^ref[_=-]?(.+)$/i.exec(scene);
const code = (match?.[1] || scene).replace(/[^a-z0-9]/gi, '').toUpperCase();
if (code) return code;
}
const fallbackText = text(fallback)?.replace(/[^a-z0-9]/gi, '').toUpperCase();
return fallbackText || 'LEGACY';
}
async function normalizeReferralQrcodes(runId: string, records: JsonRecord[]) {
for (const r of records) {
const userId = await userIdByLegacy(r.userId);
const scene = text(r.scene) || `legacy_${text(r.id) || refCodeFromScene(null, r.userId)}`;
const page = text(r.page) || 'pages/index/index';
const refCode = refCodeFromScene(scene, r.id);
const qrcodeUrl = text(r.qrcodeUrl) || text(r.image);
const metadata = {
source: 'pocketbase.referral_qrcodes',
legacyId: text(r.id),
legacyUserId: text(r.userId),
image: text(r.image),
originalQrcodeUrl: text(r.qrcodeUrl),
};
if (!userId) {
await issue(
runId,
'referral_qrcodes',
r.id,
'referral_qrcode_user_not_found',
`Referral qrcode cannot be attached to a user because userId was not resolved: ${text(r.userId) || '(empty)'}`,
'critical',
);
continue;
}
await pool.query(
`
insert into public.referral_codes (tenant_id, user_id, code, status, channel, landing_path, metadata, created_at, updated_at)
values ($1,$2,$3::citext,'active','wechat-miniapp',$4,$5::jsonb,
coalesce(nullif($6::text,'')::timestamptz, now()),
coalesce(nullif($7::text,'')::timestamptz, now())
)
on conflict (tenant_id, user_id) do update set
code = excluded.code,
channel = excluded.channel,
landing_path = excluded.landing_path,
metadata = public.referral_codes.metadata || excluded.metadata,
updated_at = excluded.updated_at
`,
[tenantId, userId, refCode, page, JSON.stringify(metadata), dateText(r.created), dateText(r.updated)],
);
await pool.query(
`
insert into public.referral_qrcodes (
tenant_id, user_id, ref_code, scene, page, provider, qrcode_url, status,
metadata, created_at, updated_at
)
values ($1,$2,$3::citext,$4,$5,'wechat-miniapp',$6,$7,$8::jsonb,
coalesce(nullif($9::text,'')::timestamptz, now()),
coalesce(nullif($10::text,'')::timestamptz, now())
)
on conflict (tenant_id, provider, scene, page) do update set
user_id = excluded.user_id,
ref_code = excluded.ref_code,
qrcode_url = excluded.qrcode_url,
status = excluded.status,
metadata = public.referral_qrcodes.metadata || excluded.metadata,
updated_at = excluded.updated_at
`,
[
tenantId,
userId,
refCode,
scene,
page,
qrcodeUrl,
qrcodeUrl ? 'ready' : 'pending',
JSON.stringify(metadata),
dateText(r.created),
dateText(r.updated),
],
);
}
}
async function normalizeCommissionSettings(records: JsonRecord[]) {
for (const r of records) {
const config = {
source: 'pocketbase.commission_settings',
legacyId: text(r.id),
remark: text(r.remark),
raw: sanitizeRecord(r),
};
await pool.query(
`
insert into public.tenant_commission_settings (
tenant_id, default_rate, min_settlement_cents, settlement_cycle, config,
created_at, updated_at
)
values ($1,$2,0,'monthly',$3::jsonb,
coalesce(nullif($4::text,'')::timestamptz, now()),
coalesce(nullif($5::text,'')::timestamptz, now())
)
on conflict (tenant_id) do update set
default_rate = excluded.default_rate,
config = public.tenant_commission_settings.config || excluded.config,
updated_at = excluded.updated_at
`,
[
tenantId,
clampRate(r.defaultRate, 0.2),
JSON.stringify(config),
dateText(r.created),
dateText(r.updated),
],
);
}
}
async function normalizeBadges(records: JsonRecord[]) {
for (const r of records) {
await pool.query(
@@ -2820,6 +3246,7 @@ async function loadCollections(): Promise<CollectionMap> {
const files = fs.readdirSync(exportDir).filter(file => file.toLowerCase().endsWith('.json'));
const collections: CollectionMap = {};
for (const file of files) {
if (nonCollectionJsonFiles.has(file)) continue;
const collection = collectionNameFromFile(file);
collections[collection] = asArray(JSON.parse(fs.readFileSync(path.join(exportDir, file), 'utf8')));
}
@@ -2841,14 +3268,16 @@ async function normalizeAll(runId: string, collections: CollectionMap) {
await runNormalizer(runId, collections, 'users', normalizeUsers);
await normalizeUserEntitlementsAndStats(runId, collections.users || []);
if ((collections.users || []).length > 0) await markNormalized(runId, 'users');
await runNormalizer(runId, collections, 'user_answer_records', records => normalizeUserAnswerRecords(runId, records));
await runNormalizer(runId, collections, 'settings', normalizeSettings);
await runNormalizer(runId, collections, 'svip_plans', normalizeSvipPlans);
await runNormalizer(runId, collections, 'mock_exam_configs', records => normalizeMockExamConfigs(runId, records));
await runNormalizer(runId, collections, 'code_batches', normalizeCodeBatches);
await runNormalizer(runId, collections, 'coupons', normalizeCoupons);
await runNormalizer(runId, collections, 'coupon_redemptions', normalizeCouponRedemptions);
await runNormalizer(runId, collections, 'codes', normalizeActivationCodes);
await runNormalizer(runId, collections, 'orders', normalizeOrders);
await runNormalizer(runId, collections, 'orders', records => normalizeOrders(runId, records));
await runNormalizer(runId, collections, 'vocabulary_units', normalizeVocabularyUnits);
await runNormalizer(runId, collections, 'vocabulary', normalizeVocabularyWords);
@@ -2863,7 +3292,7 @@ async function normalizeAll(runId: string, collections: CollectionMap) {
}
await runNormalizer(runId, collections, 'handbook_subjects', normalizeHandbookSubjects);
await runNormalizer(runId, collections, 'handbook_chapters', normalizeHandbookChapters);
await runNormalizer(runId, collections, 'handbook_chapters', records => normalizeHandbookChapters(runId, records));
await runNormalizer(runId, collections, 'handbook_entries', normalizeHandbookEntries);
await runNormalizer(runId, collections, 'banners', records => normalizeSimpleContent(records, 'banners'));
await runNormalizer(runId, collections, 'faqs', records => normalizeSimpleContent(records, 'faqs'));
@@ -2880,6 +3309,8 @@ async function normalizeAll(runId: string, collections: CollectionMap) {
await runNormalizer(runId, collections, 'scoreline_fields', normalizeScorelineFields);
await runNormalizer(runId, collections, 'scoreline_records', normalizeScorelineRecords);
await runNormalizer(runId, collections, 'referral_tracks', normalizeReferralTracks);
await runNormalizer(runId, collections, 'referral_qrcodes', records => normalizeReferralQrcodes(runId, records));
await runNormalizer(runId, collections, 'commission_settings', normalizeCommissionSettings);
await runNormalizer(runId, collections, 'badges', normalizeBadges);
await runNormalizer(runId, collections, 'user_badges', normalizeUserBadges);
await runNormalizer(runId, collections, 'recent_practices', normalizeRecentPractices);

View File

@@ -217,6 +217,120 @@ async function validationChecks(): Promise<CheckResult[]> {
),
);
checks.push(
result(
'legacy_answers_missing_user',
await scalar(
`
select count(*)
from public.answer_records ar
left join public.platform_users u on u.id = ar.user_id
where ar.tenant_id = $1
and ar.legacy_id is not null
and u.id is null
`,
[tenantId],
),
'Legacy answer_records exist without a valid user.',
'Legacy answer_records reference valid users.',
),
);
checks.push(
result(
'legacy_answers_unresolved_questions',
await scalar(
`
select count(*)
from public.answer_records
where tenant_id = $1
and legacy_question_id is not null
and question_id is null
`,
[tenantId],
),
'Some legacy answer_records kept only legacy_question_id because questions were not resolved.',
'Legacy answer_records question references are resolved.',
true,
),
);
checks.push(
result(
'wrong_questions_invalid_references',
await scalar(
`
select count(*)
from public.wrong_questions wq
left join public.platform_users u on u.id = wq.user_id
left join public.questions q on q.id = wq.question_id and q.tenant_id = wq.tenant_id
where wq.tenant_id = $1
and (u.id is null or q.id is null)
`,
[tenantId],
),
'Wrong-question rows reference missing users or questions.',
'Wrong-question rows reference valid users and questions.',
),
);
checks.push(
result(
'legacy_mock_blueprints_incomplete',
await scalar(
`
select count(*)
from public.practice_blueprints
where tenant_id = $1
and mode = 'mock_exam'
and legacy_id is not null
and (
question_limit is null
or jsonb_array_length(sections) = 0
or rules->>'legacySubjectId' is null
)
`,
[tenantId],
),
'Legacy mock exam blueprints are missing limits, sections, or legacy subject traceability.',
'Legacy mock exam blueprints have limits, sections, and legacy subject traceability.',
),
);
checks.push(
result(
'referral_qrcodes_incomplete',
await scalar(
`
select count(*)
from public.referral_qrcodes
where tenant_id = $1
and (ref_code is null or scene = '' or page = '')
`,
[tenantId],
),
'Referral qrcodes are missing ref_code, scene, or page.',
'Referral qrcodes have ref_code, scene, and page.',
),
);
checks.push(
result(
'commission_settings_invalid_rate',
await scalar(
`
select count(*)
from public.tenant_commission_settings
where tenant_id = $1
and (default_rate < 0 or default_rate > 1)
`,
[tenantId],
),
'Tenant commission settings contain invalid default_rate values.',
'Tenant commission settings default_rate values are valid.',
),
);
checks.push(
result(
'entitlements_unresolved_user',