furniture-3d-system-Wu/furniture-3d-system/scripts/verify-master-data-foundation.js

5350 lines
206 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const fs = require('node:fs')
const path = require('node:path')
const { Pool } = require('pg')
const {
REQUIRED_MIGRATIONS,
applyMissingMigrationFiles,
classifyAppliedMigrationPaths,
columnDefaultMatches,
columnExists,
dataCheckPasses,
guardExists,
getMigrationTargetRelations,
getPendingStructureMigrationPaths,
relationColumnExists,
relationExists,
validateRequiredMigrationCatalog,
} = require('./master-data-required-migrations')
const ROOT_DIR = path.resolve(__dirname, '..')
const PRODUCT_SKU_ASSET_PROFILE_LOCKABLE_FIELDS = [
'model',
'modelName',
'specCode',
'specName',
'barcode',
'size',
'color',
'material',
'imageUrl',
'images',
]
const PRODUCT_SKU_ASSET_PROFILE_LOCKABLE_FIELD_SQL_ARRAY = `ARRAY[${PRODUCT_SKU_ASSET_PROFILE_LOCKABLE_FIELDS
.map((field) => `'${field}'`)
.join(', ')}]::text[]`
function parseArgs(argv) {
const args = {
outputFile: '',
summaryOutputFile: '',
summaryStdout: false,
requireApplied: false,
readOnlyMigrationStatus: false,
readOnlyQuality: false,
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === '--require-applied') {
args.requireApplied = true
continue
}
if (arg === '--read-only-migration-status') {
args.readOnlyMigrationStatus = true
continue
}
if (arg === '--read-only-quality') {
args.readOnlyQuality = true
continue
}
if (arg === '--output') {
const outputFile = argv[index + 1]
if (!outputFile) throw new Error('--output 需要提供文件路径。')
args.outputFile = outputFile
index += 1
continue
}
if (arg.startsWith('--output=')) {
args.outputFile = arg.slice('--output='.length)
if (!args.outputFile) throw new Error('--output 需要提供文件路径。')
continue
}
if (arg === '--summary-output') {
const summaryOutputFile = argv[index + 1]
if (!summaryOutputFile) throw new Error('--summary-output 需要提供文件路径。')
args.summaryOutputFile = summaryOutputFile
index += 1
continue
}
if (arg.startsWith('--summary-output=')) {
args.summaryOutputFile = arg.slice('--summary-output='.length)
if (!args.summaryOutputFile) throw new Error('--summary-output 需要提供文件路径。')
continue
}
if (arg === '--summary-stdout') {
args.summaryStdout = true
continue
}
throw new Error(`未知参数:${arg}`)
}
if (args.requireApplied && (args.readOnlyMigrationStatus || args.readOnlyQuality)) {
throw new Error('--require-applied 不能和只读模式同时使用。')
}
if (args.readOnlyMigrationStatus && args.readOnlyQuality) {
throw new Error('--read-only-migration-status 和 --read-only-quality 不能同时使用。')
}
return args
}
function writeJsonReport(filePath, payload) {
if (!filePath) return ''
const absolutePath = path.resolve(ROOT_DIR, filePath)
fs.mkdirSync(path.dirname(absolutePath), { recursive: true })
fs.writeFileSync(absolutePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
return absolutePath
}
function summarizeBlockerValue(value) {
if (value === null || value === undefined) return true
if (typeof value === 'number' || typeof value === 'boolean') return value
if (Array.isArray(value)) return value.length
return true
}
function buildFoundationHardReadySummary(report) {
const gate = report.foundationHardReadyGate || buildFoundationHardReadyGate(report)
const blockers = Array.isArray(gate.blockers) ? gate.blockers : []
const migrationStatusSource = report.migrationApplicationStatus
? report.migrationApplicationStatus.source ||
(report.mode === 'current'
? 'current-database'
: report.mode === 'migration-dry-run' || report.mode === 'idempotent-migration-replay'
? 'transactional-dry-run-replay'
: 'reported')
: null
return {
mode: report.mode || 'unknown',
rolledBack: report.rolledBack === true,
hardReady: gate.hardReady === true,
blockerCount: gate.blockerCount ?? blockers.length,
blockerSourceCounts: gate.blockerSourceCounts || blockers.reduce((counts, blocker) => {
const source = typeof blocker?.source === 'string' && blocker.source ? blocker.source : 'UNKNOWN'
counts[source] = (counts[source] || 0) + 1
return counts
}, {}),
blockerKeys: blockers.map((blocker) => {
const source = typeof blocker?.source === 'string' && blocker.source ? blocker.source : 'UNKNOWN'
const key = typeof blocker?.key === 'string' && blocker.key ? blocker.key : 'UNKNOWN'
return `${source}:${key}`
}),
blockerValues: blockers.reduce((values, blocker) => {
const source = typeof blocker?.source === 'string' && blocker.source ? blocker.source : 'UNKNOWN'
const key = typeof blocker?.key === 'string' && blocker.key ? blocker.key : 'UNKNOWN'
values[`${source}:${key}`] = summarizeBlockerValue(blocker?.value)
return values
}, {}),
migrationApplicationStatus: report.migrationApplicationStatus
? {
source: migrationStatusSource,
appliedMigrationCount: report.migrationApplicationStatus.appliedMigrationCount ?? null,
pendingStructureMigrationCount:
report.migrationApplicationStatus.pendingStructureMigrationCount ?? null,
replayableIdempotentMigrationCount:
report.migrationApplicationStatus.replayableIdempotentMigrationCount ?? null,
strictReady: report.migrationApplicationStatus.strictReady === true,
fullyReady: report.migrationApplicationStatus.fullyReady === true,
}
: null,
}
}
function writeFoundationHardReadyOutputs(args, report) {
writeJsonReport(args.outputFile, report)
writeJsonReport(args.summaryOutputFile, buildFoundationHardReadySummary(report))
}
function printFoundationHardReadyReport(args, report) {
const payload = args.summaryStdout ? buildFoundationHardReadySummary(report) : report
console.log(JSON.stringify(payload, null, 2))
}
const REQUIRED_DATABASE_GUARDS = [
{
type: 'unique-index',
name: 'AssetSeries_code_key',
relationName: 'AssetSeries',
purpose: '资产系列编码是资产中心系列真相源,必须数据库级唯一。',
},
{
type: 'check-constraint',
name: 'AssetSeries_identity_valid',
relationName: 'AssetSeries',
expressionPattern: 'sortOrder',
purpose: '资产系列名称、编码和排序必须是稳定结构化字段,不能保存空名称或负排序。',
},
{
type: 'check-constraint',
name: 'AssetSeries_metadata_object',
relationName: 'AssetSeries',
expressionPattern: 'jsonb_typeof',
purpose: '资产系列 metadata 只能是对象,避免扩展字段形态漂移。',
},
{
type: 'unique-index',
name: 'AssetFamily_code_key',
relationName: 'AssetFamily',
purpose: '资产族编码是资产族真相源,必须数据库级唯一。',
},
{
type: 'check-constraint',
name: 'AssetFamily_identity_valid',
relationName: 'AssetFamily',
expressionPattern: 'connectionRoles',
purpose: '资产族名称、编码、连接角色和可选文本必须保持可用,源头资料不能出现空白身份。',
},
{
type: 'check-constraint',
name: 'AssetFamily_metadata_object',
relationName: 'AssetFamily',
expressionPattern: 'jsonb_typeof',
purpose: '资产族 metadata 只能是对象,避免结构化源数据扩展字段形态漂移。',
},
{
type: 'check-constraint',
name: 'AssetFamily_lifecycle_state_valid',
relationName: 'AssetFamily',
expressionPattern: 'PENDING_REVIEW',
purpose: '资产族提交、审核、驳回和软删除状态必须和对应时间/人员字段一致。',
},
{
type: 'unique-index',
name: 'AssetVariant_code_key',
relationName: 'AssetVariant',
purpose: '规格款编码是资产中心规格款真相源,必须数据库级唯一。',
},
{
type: 'unique-index',
name: 'AssetVariant_assetFamilyId_identityKey_key',
relationName: 'AssetVariant',
purpose: '同一资产族下规格款身份键必须唯一,防止同款规格被重复建档。',
},
{
type: 'partial-unique-index',
name: 'AssetVariant_barcode_active_key',
relationName: 'AssetVariant',
predicatePattern: '"deletedAt" IS NULL',
definitionPattern: 'upper(btrim(',
purpose: '未删除规格款的条码必须唯一,正式 SKU 同步副本不能从资产源头继承冲突。',
},
{
type: 'check-constraint',
name: 'AssetVariant_identity_valid',
relationName: 'AssetVariant',
expressionPattern: 'identityKey',
purpose: '资产规格款名称、编码、身份键、模型 URL 和可选文本必须保持可用。',
},
{
type: 'check-constraint',
name: 'AssetVariant_json_shape_valid',
relationName: 'AssetVariant',
expressionPattern: 'mountPoints',
purpose: '资产规格款尺寸、挂点、装配依据和业务扩展 JSON 形态必须稳定。',
},
{
type: 'check-constraint',
name: 'AssetVariant_cost_weight_valid',
relationName: 'AssetVariant',
expressionPattern: 'estimatedCostPrice',
purpose: '资产规格款重量、损耗率、成本和预估成本不能为负。',
},
{
type: 'check-constraint',
name: 'AssetVariant_lifecycle_state_valid',
relationName: 'AssetVariant',
expressionPattern: 'PENDING_REVIEW',
purpose: '资产规格款提交、审核、驳回和软删除状态必须和对应时间/人员字段一致。',
},
{
type: 'check-constraint',
name: 'AssetVariant_inheritance_not_self',
relationName: 'AssetVariant',
expressionPattern: 'inheritedFromVariantId',
purpose: '资产规格款继承关系不能指向自身,避免规格派生链出现自环。',
},
{
type: 'unique-index',
name: 'AssetFamilySeriesCompatibility_assetFamilyId_assetSeriesId_key',
relationName: 'AssetFamilySeriesCompatibility',
purpose: '资产族和系列兼容关系必须唯一,避免同一归属被重复表达。',
},
{
type: 'unique-index',
name: 'DraftGroup_code_key',
relationName: 'DraftGroup',
purpose: '草稿组编号必须唯一,辅助装配确认结果不能产生重复承接单。',
},
{
type: 'check-constraint',
name: 'DraftGroup_identity_valid',
relationName: 'DraftGroup',
expressionPattern: 'btrim',
purpose: '草稿组编号和名称必须保持非空且无首尾空格,不能让展示文本变成不稳定承接身份。',
},
{
type: 'check-constraint',
name: 'DraftGroup_summary_object',
relationName: 'DraftGroup',
expressionPattern: 'jsonb_typeof',
purpose: '草稿组 summary 只能是对象,过程摘要快照不能漂移成数组或自由值。',
},
{
type: 'check-constraint',
name: 'DraftGroup_optional_text_not_blank',
relationName: 'DraftGroup',
expressionPattern: 'description',
purpose: '草稿组可选长文本只能是 NULL 或有效文本,避免空字符串和 NULL 形成两套缺失状态。',
},
{
type: 'unique-index',
name: 'PlanningTemplate_code_key',
relationName: 'PlanningTemplate',
purpose: '规划模板编码必须唯一,避免同一模板身份被重复表达。',
},
{
type: 'unique-index',
name: 'Substrate_code_key',
relationName: 'Substrate',
purpose: '基材编码必须唯一,资产规格款不能引用语义重复的基材。',
},
{
type: 'unique-index',
name: 'WarehouseLocation_code_key',
relationName: 'WarehouseLocation',
purpose: '库位编码必须唯一,库存和资产位置绑定不能指向重复库位身份。',
},
{
type: 'unique-index',
name: 'User_email_normalized_key',
relationName: 'User',
definitionPattern: 'lower(btrim',
purpose: '用户邮箱必须按小写去空格后的语义唯一,登录身份不能被大小写绕开。',
},
{
type: 'unique-index',
name: 'User_name_normalized_key',
relationName: 'User',
definitionPattern: 'btrim',
purpose: '用户花名必须按去空格后的语义唯一,不能出现看似不同的同名账号。',
},
{
type: 'unique-index',
name: 'Department_name_normalized_key',
relationName: 'Department',
definitionPattern: 'btrim',
purpose: '部门名称必须按去空格后的语义唯一,组织树不能有空格差异重复节点。',
},
{
type: 'partial-unique-index',
name: 'Department_externalId_normalized_key',
relationName: 'Department',
predicatePattern: 'NULLIF(btrim',
definitionPattern: 'btrim',
purpose: '外部部门 ID 非空时必须语义唯一,花名册同步不能把同一钉钉部门写成两个节点。',
},
{
type: 'partial-unique-index',
name: 'EmployeeDirectory_dingtalkUserId_normalized_key',
relationName: 'EmployeeDirectory',
predicatePattern: 'NULLIF(btrim',
definitionPattern: 'btrim',
purpose: '员工钉钉 userId 非空时必须语义唯一,账号匹配不能出现多候选。',
},
{
type: 'partial-unique-index',
name: 'EmployeeDirectory_dingtalkUnionId_normalized_key',
relationName: 'EmployeeDirectory',
predicatePattern: 'NULLIF(btrim',
definitionPattern: 'btrim',
purpose: '员工钉钉 unionId 非空时必须语义唯一,跨应用匹配不能出现多候选。',
},
{
type: 'partial-unique-index',
name: 'EmployeeDirectory_jobNumber_normalized_key',
relationName: 'EmployeeDirectory',
predicatePattern: 'NULLIF(btrim',
definitionPattern: 'btrim',
purpose: '员工工号非空时必须语义唯一,后台创建账号和手动匹配不能命中不稳定记录。',
},
{
type: 'partial-unique-index',
name: 'EmployeeDirectory_mobile_normalized_key',
relationName: 'EmployeeDirectory',
predicatePattern: 'NULLIF(btrim',
definitionPattern: 'btrim',
purpose: '员工手机号非空时必须语义唯一,钉钉匹配和花名册匹配不能出现多候选。',
},
{
type: 'partial-unique-index',
name: 'WarehouseLocation_active_position_key',
relationName: 'WarehouseLocation',
predicatePattern: '"isActive" = true',
definitionPattern: 'lower(btrim',
purpose: '活动库位的区/巷道/货架/箱位组合必须唯一,避免同一物理位置被重复建档。',
},
{
type: 'unique-index',
name: 'PackagedItem_code_key',
relationName: 'PackagedItem',
purpose: '包装成品编号必须唯一,轻仓库成品不能产生重复身份。',
},
{
type: 'unique-index',
name: 'PreSku_draftGroupId_sequence_key',
relationName: 'PreSku',
purpose: '同一草稿组内准 SKU 序号必须唯一,避免入库前清单重复行。',
},
{
type: 'check-constraint',
name: 'PreSku_identity_valid',
relationName: 'PreSku',
expressionPattern: 'sourceCandidateKey',
purpose: '准 SKU 序号、默认名称和来源候选 key 是草稿承接身份,必须稳定非空。',
},
{
type: 'check-constraint',
name: 'PreSku_process_snapshots_shape_valid',
relationName: 'PreSku',
expressionPattern: 'familySelection',
purpose: '准 SKU 过程快照 JSON 形态必须稳定,避免展示/入库生成链路吞掉坏结构。',
},
{
type: 'check-constraint',
name: 'PreSku_weight_valid',
relationName: 'PreSku',
expressionPattern: 'weightPreview',
purpose: '准 SKU 预估重量不能为负,草稿承接层不能沉淀异常重量。',
},
{
type: 'check-constraint',
name: 'PreSku_component_codes_valid',
relationName: 'PreSku',
expressionPattern: 'componentCodes',
purpose: '准 SKU 组件编码数组不能包含空白元素,避免组件身份池出现隐形空值。',
},
{
type: 'check-constraint',
name: 'PreSku_optional_text_not_blank',
relationName: 'PreSku',
expressionPattern: 'customName',
purpose: '准 SKU 可选文本只能是 NULL 或有效文本,避免空字符串和 NULL 形成两套缺失状态。',
},
{
type: 'unique-index',
name: 'Product_modelNumber_key',
relationName: 'Product',
purpose: '正式产品型号必须唯一,是正式产品主数据身份。',
},
{
type: 'unique-index',
name: 'ProductSKU_skuCode_key',
relationName: 'ProductSKU',
purpose: '正式 SKU 编码必须唯一,是正式商品对内对外稳定身份。',
},
{
type: 'partial-unique-index',
name: 'ProductSKU_active_skuCode_normalized_key',
relationName: 'ProductSKU',
predicatePattern: '"deletedAt" IS NULL',
definitionPattern: 'upper(btrim(',
purpose: '活动正式 SKU 编码必须按大写去空格后的语义唯一,删除态墓碑不参与活动池。',
},
{
type: 'unique-index',
name: 'ProductSKU_sourcePreSkuId_key',
relationName: 'ProductSKU',
purpose: '一个准 SKU 只能正式入库一次,不能被重复转成多个正式 SKU。',
},
{
type: 'check-constraint',
name: 'ProductSKU_price_cost_stock_non_negative',
relationName: 'ProductSKU',
expressionPattern: '"costPrice"',
purpose: '正式 SKU 售价、成本价和库存不能为负,正式成本口径必须落在结构化 ProductSKU 字段而不是过程估算。',
},
{
type: 'unique-index',
name: 'IntakeNonIntegratedProduct_spu_brand_key',
relationName: 'IntakeNonIntegratedProduct',
purpose: '非集成开品 SPU 在同一品牌池内必须唯一,避免产品资料入口重复身份。',
},
{
type: 'unique-index',
name: 'IntakeNonIntegratedProduct_spu_brand_normalized_key',
relationName: 'IntakeNonIntegratedProduct',
definitionPattern: 'upper(btrim(',
purpose: '非集成开品 SPU 在同一品牌池内必须按大写去空格后的语义唯一。',
},
{
type: 'unique-index',
name: 'ProductSkuAssetProfile_skuId_key',
relationName: 'ProductSkuAssetProfile',
purpose: '正式 SKU 到资产资料同步副本必须一对一。',
},
{
type: 'partial-unique-index',
name: 'ProductSkuAssetProfile_barcode_active_non_empty_key',
relationName: 'ProductSkuAssetProfile',
predicatePattern: '"skuDeletedAt" IS NULL',
definitionPattern: 'upper(btrim(',
purpose: '正式 SKU 资产资料同步副本的非空活动条码必须唯一,且软删除 SKU 必须释放条码池。',
},
{
type: 'unique-index',
name: 'PreSkuBasicInfo_preSkuId_key',
relationName: 'PreSkuBasicInfo',
purpose: '准 SKU 基础资料必须一对一,不能产生第二份真相。',
},
{
type: 'partial-unique-index',
name: 'PreSkuBasicInfo_formalSkuCode_non_empty_key',
relationName: 'PreSkuBasicInfo',
predicatePattern: 'NULLIF(btrim(',
definitionPattern: 'upper(btrim(',
purpose: '准 SKU 非空正式规格码必须唯一,不能只靠页面冲突检测。',
},
{
type: 'partial-unique-index',
name: 'PreSkuBasicInfo_barcode_non_empty_key',
relationName: 'PreSkuBasicInfo',
predicatePattern: 'NULLIF(btrim(',
definitionPattern: 'upper(btrim(',
purpose: '准 SKU 非空条码必须唯一,进入正式产品前先锁住冲突池。',
},
{
type: 'unique-index',
name: 'IntakeNonIntegratedProductProfile_productId_key',
relationName: 'IntakeNonIntegratedProductProfile',
purpose: '非集成产品头结构化 profile 必须一对一。',
},
{
type: 'unique-index',
name: 'IntakeNonIntegratedSkuItem_productId_skuItemId_key',
relationName: 'IntakeNonIntegratedSkuItem',
purpose: '非集成 SKU 明细必须和 payload 的 skuItemId 稳定对账。',
},
{
type: 'partial-unique-index',
name: 'IntakeNonIntegratedSkuItem_skuCode_non_empty_key',
relationName: 'IntakeNonIntegratedSkuItem',
predicatePattern: '"dataQualityStatus" = \'READY\'',
definitionPattern: 'upper(btrim(',
purpose: 'READY 状态的非空 SKU 编码必须全局唯一;历史冲突行保留为 CONFLICT 等待治理。',
},
{
type: 'partial-unique-index',
name: 'IntakeNonIntegratedSkuItem_barcode_non_empty_key',
relationName: 'IntakeNonIntegratedSkuItem',
predicatePattern: '"dataQualityStatus" = \'READY\'',
definitionPattern: 'upper(btrim(',
purpose: 'READY 状态的非空条码必须全局唯一;历史冲突行保留为 CONFLICT 等待治理。',
},
{
type: 'unique-index',
name: 'ProductPatentRecord_patentGroupKey_key',
relationName: 'ProductPatentRecord',
purpose: '专利台账按产品分组一对一保存,不能让同一分组被多行重复表达。',
},
{
type: 'trigger',
name: 'IntakeNonIntegratedProduct_soft_delete_cleanup_sku_items',
relationName: 'IntakeNonIntegratedProduct',
purpose: '非集成产品软删除后释放结构化 SKU 编码和条码唯一池。',
},
{
type: 'unique-index',
name: 'IntakeNonIntegratedPricingRow_productId_skuItemId_key',
relationName: 'IntakeNonIntegratedPricingRow',
purpose: '非集成产品核价行必须按产品和 SKU 一对一,外部核价查询不能再从 payload 拼状态。',
},
{
type: 'check-constraint',
name: 'IntakeNonIntegratedPricingRow_identity_valid',
relationName: 'IntakeNonIntegratedPricingRow',
expressionPattern: 'skuItemId',
purpose: '核价行的 SKU 身份、SKU 编码和条码必须结构化归一,不能保存空白或未归一身份。',
},
{
type: 'check-constraint',
name: 'IntakeNonIntegratedPricingRow_cost_state_valid',
relationName: 'IntakeNonIntegratedPricingRow',
expressionPattern: 'jstVirtualInventoryStatus',
purpose: '核价成本不能为负,聚水潭虚拟库存状态只能使用受控枚举文本。',
},
{
type: 'check-constraint',
name: 'IntakeNonIntegratedPricingRow_optional_text_not_blank',
relationName: 'IntakeNonIntegratedPricingRow',
expressionPattern: 'productTag',
purpose: '核价行可选文本必须去除首尾空格,避免外部查询同一状态出现多个文本版本。',
},
{
type: 'foreign-key',
name: 'IntakeNonIntegratedPricingRow_product_sku_item_fkey',
relationName: 'IntakeNonIntegratedPricingRow',
targetRelationName: 'IntakeNonIntegratedSkuItem',
purpose: '核价行必须绑定到同一非集成产品下真实存在的结构化 SKU 明细,不能产生孤儿核价状态。',
},
{
type: 'trigger',
name: 'IntakeNonIntegratedProduct_soft_delete_cleanup_pricing_rows',
relationName: 'IntakeNonIntegratedProduct',
purpose: '非集成产品软删除后释放结构化核价行,避免已删除 SKU 继续被外部核价查询命中。',
},
{
type: 'unique-index',
name: 'IntakeReferenceLibraryItem_libraryType_itemKey_key',
relationName: 'IntakeReferenceLibraryItem',
purpose: '资料库型条目必须在服务端拥有稳定身份,不能继续只靠浏览器本地 ID。',
},
{
type: 'partial-unique-index',
name: 'IntakeReferenceLibraryItem_libraryType_code_non_empty_key',
relationName: 'IntakeReferenceLibraryItem',
predicatePattern: 'NULLIF(btrim(code),',
definitionPattern: 'upper(btrim(',
purpose: '同一资料库内非空编码必须唯一,为后续替换 localStorage 资料库提供硬约束。',
},
{
type: 'unique-index',
name: 'IntakeReferenceLibraryItem_libraryType_itemKey_normalized_key',
relationName: 'IntakeReferenceLibraryItem',
definitionPattern: 'lower(btrim(',
purpose: '同一资料库内 itemKey 必须按大小写和空格归一后唯一,避免本地缓存旧键制造第二份真相。',
},
{
type: 'partial-unique-index',
name: 'IntakeReferenceLibraryItem_spu_rule_name_non_empty_key',
relationName: 'IntakeReferenceLibraryItem',
predicatePattern: '"libraryType" = \'SPU_RULE\'',
purpose: 'SPU 规则按产品品类一对一,避免正式号段计数器初始化来源不确定。',
},
{
type: 'partial-unique-index',
name: 'IntakeReferenceLibraryItem_spu_rule_name_normalized_key',
relationName: 'IntakeReferenceLibraryItem',
predicatePattern: '"libraryType" = \'SPU_RULE\'',
definitionPattern: 'lower(btrim(',
purpose: 'SPU 规则产品品类必须按去空格和大小写归一后一对一,和正式 SPU 计数器查找语义保持一致。',
},
{
type: 'unique-index',
name: 'SpuNumberCounter_productCategory_key',
relationName: 'SpuNumberCounter',
purpose: 'SPU 正式号段计数器必须按产品品类一对一锁定,不能继续依赖前端本地缓存占号。',
},
{
type: 'unique-index',
name: 'SpuNumberCounter_productCategory_normalized_key',
relationName: 'SpuNumberCounter',
definitionPattern: 'lower(btrim(',
purpose: 'SPU 号段计数器产品品类必须按去空格和大小写归一后唯一,避免同品类双号池。',
},
{
type: 'unique-index',
name: 'SpuNumberCounter_prefix_key',
relationName: 'SpuNumberCounter',
purpose: 'SPU 正式号段前缀必须唯一,避免不同品类并发占用同一个前缀导致撞号。',
},
{
type: 'unique-index',
name: 'Material_code_key',
relationName: 'Material',
purpose: '材质花色编码必须唯一,资产和正式 SKU 不能引用重复花色身份。',
},
{
type: 'unique-index',
name: 'Hardware_code_key',
relationName: 'Hardware',
purpose: '标准五金编码必须唯一,孔位匹配和 BOM 不能引用重复五金身份。',
},
{
type: 'unique-index',
name: 'CommonComponent_code_key',
relationName: 'CommonComponent',
purpose: '通用组件编码必须唯一,辅助装配组件池不能产生重复组件身份。',
},
{
type: 'unique-index',
name: 'BrandMaterialBinding_brand_material_normalized_key',
relationName: 'BrandMaterialBinding',
definitionPattern: 'lower(btrim',
purpose: '品牌物料准备项必须按品牌和物料类型的规范化身份唯一,避免同一品牌同一资料重复绑定。',
},
{
type: 'unique-index',
name: 'Panel_product_sku_code_normalized_key',
relationName: 'Panel',
definitionPattern: 'upper(btrim',
purpose: '同一产品/SKU 范围内板件编号必须按大写去空格后唯一,旧 CAD 板件清单不能重复身份。',
},
{
type: 'check-constraint',
name: 'IntakeProductProfile_developmentKind_allowed',
relationName: 'IntakeNonIntegratedProductProfile',
expressionPattern: '"productDevelopmentKind" = \'\'',
purpose: '非集成产品开发类型只能为空草稿、product 或 series,避免旁路写入随手文本。',
},
{
type: 'check-constraint',
name: 'IntakeNonIntegratedSkuItem_missingRequiredFields_allowed',
relationName: 'IntakeNonIntegratedSkuItem',
expressionPattern: 'missingRequiredFields',
purpose: '非集成 SKU 缺失字段枚举只能使用统一质量规则定义的字段名。',
},
{
type: 'check-constraint',
name: 'IntakeNonIntegratedSkuItem_quality_state_required_fields',
relationName: 'IntakeNonIntegratedSkuItem',
expressionPattern: 'dataQualityStatus',
purpose: '非集成 SKU 的 READY/INCOMPLETE/CONFLICT 状态必须和必填字段、缺失字段数组、冲突身份一致。',
},
{
type: 'partial-unique-index',
name: 'MasterDataQualityDecision_open_target_key',
relationName: 'MasterDataQualityDecision',
predicatePattern: 'status = \'APPROVED_INCOMPLETE\'',
purpose: '同一主数据对象同一时间只能有一条未解决的不完整审批,避免分阶段清洗原因互相覆盖。',
},
{
type: 'check-constraint',
name: 'MasterDataQualityDecision_target_not_blank',
relationName: 'MasterDataQualityDecision',
expressionPattern: 'targetType',
purpose: '主数据质量决策必须绑定明确目标对象,不能留下游离审批记录。',
},
{
type: 'check-constraint',
name: 'MasterDataQualityDecision_status_allowed',
relationName: 'MasterDataQualityDecision',
expressionPattern: 'APPROVED_INCOMPLETE',
purpose: '主数据质量决策状态只能使用受控枚举文本,避免随手状态绕开 readiness 语义。',
},
{
type: 'check-constraint',
name: 'MasterDataQualityDecision_reason_not_blank',
relationName: 'MasterDataQualityDecision',
expressionPattern: 'reason',
purpose: '允许分阶段不完整时必须保留业务确认原因,不能只有一个布尔开关。',
},
{
type: 'check-constraint',
name: 'MasterDataQualityDecision_decidedBy_not_blank',
relationName: 'MasterDataQualityDecision',
expressionPattern: 'decidedBy',
purpose: '主数据质量决策必须记录确认人,后续清洗和问责可追溯。',
},
{
type: 'check-constraint',
name: 'MasterDataQualityDecision_reason_not_placeholder',
relationName: 'MasterDataQualityDecision',
expressionPattern: '待确认',
purpose: '主数据质量决策原因不能是待确认、UNKNOWN 或默认值这类占位文本。',
},
{
type: 'check-constraint',
name: 'MasterDataQualityDecision_decidedBy_not_placeholder',
relationName: 'MasterDataQualityDecision',
expressionPattern: '待确认',
purpose: '主数据质量决策确认人不能是待确认、UNKNOWN 或默认值这类占位文本。',
},
{
type: 'check-constraint',
name: 'MasterDataQualityDecision_resolvedBy_not_placeholder',
relationName: 'MasterDataQualityDecision',
expressionPattern: '待确认',
purpose: '主数据质量决策解决人不能是待确认、UNKNOWN 或默认值这类占位文本。',
},
{
type: 'check-constraint',
name: 'MasterDataQualityDecision_resolved_state_valid',
relationName: 'MasterDataQualityDecision',
expressionPattern: 'resolvedAt',
purpose: '已解决的质量决策必须记录解决人和时间,未解决记录不能伪装成已关闭。',
},
{
type: 'check-constraint',
name: 'SpuNumberCounter_productCategory_not_blank',
relationName: 'SpuNumberCounter',
expressionPattern: 'productCategory',
purpose: '正式 SPU 号段计数器必须绑定非空产品品类,不能产生游离号池。',
},
{
type: 'check-constraint',
name: 'SpuNumberCounter_productCategory_normalized',
relationName: 'SpuNumberCounter',
expressionPattern: 'btrim',
purpose: '正式 SPU 号段计数器产品品类必须无首尾空格,不能绕过服务层制造第二号池。',
},
{
type: 'check-constraint',
name: 'SpuNumberCounter_prefix_not_blank',
relationName: 'SpuNumberCounter',
expressionPattern: 'prefix',
purpose: '正式 SPU 号段计数器必须有非空前缀,不能生成无业务含义编号。',
},
{
type: 'check-constraint',
name: 'SpuNumberCounter_prefix_normalized',
relationName: 'SpuNumberCounter',
expressionPattern: 'upper(btrim',
purpose: 'SPU 正式号段前缀必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'SpuNumberCounter_lastUsedIndex_non_negative',
relationName: 'SpuNumberCounter',
expressionPattern: 'lastUsedIndex',
purpose: '正式 SPU 号段计数器不能回到负数,防止重新占用历史编号。',
},
{
type: 'check-constraint',
name: 'SpuNumberCounter_sourceLibraryItemId_normalized',
relationName: 'SpuNumberCounter',
expressionPattern: 'sourceLibraryItemId',
purpose: 'SPU 号段计数器关联的规则来源 id 非空时必须可追溯且无首尾空格。',
},
{
type: 'check-constraint',
name: 'ProductSKU_active_skuCode_not_deleted_marker',
relationName: 'ProductSKU',
expressionPattern: '__deleted__',
purpose: '活动正式 SKU 编码不能带软删除墓碑标记,防止旁路写入污染正式编码池。',
},
{
type: 'check-constraint',
name: 'ProductSKU_soft_delete_identity_valid',
relationName: 'ProductSKU',
expressionPattern: 'originalSkuCode',
purpose: '正式 SKU 软删除生命周期身份必须落结构化字段,活动 SKU 不得残留删除态身份。',
},
{
type: 'check-constraint',
name: 'ProductSKU_no_soft_delete_lifecycle_attributes',
relationName: 'ProductSKU',
expressionPattern: '_originalSkuCode',
purpose: '正式 SKU attributes 不能再承载软删除生命周期元数据,防止 JSON 兼容字段重新成为第二真相。',
},
{
type: 'check-constraint',
name: 'ProductSKU_soft_delete_tombstone_valid',
relationName: 'ProductSKU',
expressionPattern: 'deletedCodeAt',
purpose: '正式 SKU 删除态必须保留结构化删除编码时间,随产品删除的归属字段必须成对出现。',
},
{
type: 'check-constraint',
name: 'ProductSKU_no_formal_truth_attributes',
relationName: 'ProductSKU',
expressionPattern: 'skuName',
purpose: '正式 SKU attributes 不得保存名称、型号、规格码、条码、品类、颜色、材质和尺寸等正式资料字段。',
},
{
type: 'trigger',
name: 'ProductSKU_sync_asset_profile_deleted_at',
relationName: 'ProductSKU',
purpose: '正式 SKU 软删除生命周期必须自动同步到 ProductSkuAssetProfile.skuDeletedAt,避免条码活动池与 SKU 删除态漂移。',
},
{
type: 'check-constraint',
name: 'AssetSeries_code_normalized',
relationName: 'AssetSeries',
expressionPattern: 'upper(btrim',
purpose: '资产系列编码必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'AssetFamily_code_normalized',
relationName: 'AssetFamily',
expressionPattern: 'upper(btrim',
purpose: '资产族编码必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'DraftGroup_code_normalized',
relationName: 'DraftGroup',
expressionPattern: 'upper(btrim',
purpose: '草稿组编号必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'IntakeReferenceLibraryItem_code_normalized',
relationName: 'IntakeReferenceLibraryItem',
expressionPattern: 'upper(btrim',
purpose: '资料库条目编码非空时必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'IntakeReferenceLibraryItem_code_required',
relationName: 'IntakeReferenceLibraryItem',
expressionPattern: 'NULLIF(btrim',
purpose: '资料库条目编码必须非空且大写归一,payload 扩展不能替代结构化 code 身份。',
},
{
type: 'check-constraint',
name: 'IntakeReferenceLibraryItem_libraryType_allowed',
relationName: 'IntakeReferenceLibraryItem',
expressionPattern: 'PACKAGING_MATERIAL',
purpose: '资料库类型必须固定在后端支持的类型枚举内,不能由手工 SQL 或新入口写出游离类型。',
},
{
type: 'check-constraint',
name: 'IntakeReferenceLibraryItem_itemKey_required',
relationName: 'IntakeReferenceLibraryItem',
expressionPattern: 'btrim',
purpose: '资料库条目 itemKey 必须非空且无首尾空格,不能用空键或空格键作为主数据身份。',
},
{
type: 'check-constraint',
name: 'IntakeReferenceLibraryItem_letter_normalized',
relationName: 'IntakeReferenceLibraryItem',
expressionPattern: 'upper(btrim',
purpose: '资料库字母索引非空时必须大写且无首尾空格,避免筛选和排序出现语义重复。',
},
{
type: 'check-constraint',
name: 'IntakeReferenceLibraryItem_spu_rule_name_normalized',
relationName: 'IntakeReferenceLibraryItem',
expressionPattern: 'SPU_RULE',
purpose: 'SPU 规则产品品类不能为空且不能带首尾空格,避免号段规则和计数器查找漂移。',
},
{
type: 'check-constraint',
name: 'PlanningTemplate_code_normalized',
relationName: 'PlanningTemplate',
expressionPattern: 'upper(btrim',
purpose: '规划模板编码必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'PlanningTemplate_name_not_blank',
relationName: 'PlanningTemplate',
expressionPattern: 'name',
purpose: '规划模板名称必须非空且无首尾空格,模板编码不能指向无语义名称。',
},
{
type: 'check-constraint',
name: 'PlanningTemplate_slots_array',
relationName: 'PlanningTemplate',
expressionPattern: 'jsonb_array_length',
purpose: '规划模板槽位定义必须是非空数组,JSON 只承载受控模板结构。',
},
{
type: 'check-constraint',
name: 'PlanningTemplate_rules_object',
relationName: 'PlanningTemplate',
expressionPattern: 'rules',
purpose: '规划模板规则只能是对象或空值,不能写成第二套游离结构。',
},
{
type: 'check-constraint',
name: 'PlanningTemplate_optional_text_not_blank',
relationName: 'PlanningTemplate',
expressionPattern: 'namePattern',
purpose: '规划模板可缺文本必须使用 NULL,不能用空字符串制造第二套缺失状态。',
},
{
type: 'check-constraint',
name: 'PlanningWorkspace_name_not_blank',
relationName: 'PlanningWorkspace',
expressionPattern: 'name',
purpose: '规划工作台名称必须非空且无首尾空格。',
},
{
type: 'check-constraint',
name: 'PlanningWorkspace_visibility_allowed',
relationName: 'PlanningWorkspace',
expressionPattern: 'DEPARTMENT',
purpose: '规划工作台可见范围只能来自固定枚举,不能成为游离权限状态。',
},
{
type: 'check-constraint',
name: 'PlanningWorkspace_slotSelections_object',
relationName: 'PlanningWorkspace',
expressionPattern: 'slotSelections',
purpose: '规划工作台槽位选择只能是对象,明确作为过程筛选 JSON。',
},
{
type: 'check-constraint',
name: 'PlanningWorkspace_metadata_object',
relationName: 'PlanningWorkspace',
expressionPattern: 'metadata',
purpose: '规划工作台元数据只能是对象或空值,不能承载结构化主数据真相。',
},
{
type: 'check-constraint',
name: 'PlanningWorkspace_optional_text_not_blank',
relationName: 'PlanningWorkspace',
expressionPattern: 'description',
purpose: '规划工作台可缺文本必须使用 NULL,不能用空字符串制造第二套缺失状态。',
},
{
type: 'check-constraint',
name: 'PlanningDraft_identity_not_blank',
relationName: 'PlanningDraft',
expressionPattern: 'selectedCandidateKey',
purpose: '规划草稿名称和候选 key 必须非空且无首尾空格。',
},
{
type: 'check-constraint',
name: 'PlanningDraft_selectedAssets_object',
relationName: 'PlanningDraft',
expressionPattern: 'selectedAssets',
purpose: '规划草稿选中资产快照只能是对象,不能反向成为资产中心主数据。',
},
{
type: 'check-constraint',
name: 'PlanningDraft_previewNodes_array',
relationName: 'PlanningDraft',
expressionPattern: 'previewNodes',
purpose: '规划草稿 3D 预览节点快照只能是数组。',
},
{
type: 'check-constraint',
name: 'PlanningDraft_summary_array',
relationName: 'PlanningDraft',
expressionPattern: 'summary',
purpose: '规划草稿摘要快照只能是数组。',
},
{
type: 'check-constraint',
name: 'PlanningDraft_warnings_array',
relationName: 'PlanningDraft',
expressionPattern: 'warnings',
purpose: '规划草稿生成警告只能是数组或空值。',
},
{
type: 'check-constraint',
name: 'PlanningDraft_sourceSnapshot_object',
relationName: 'PlanningDraft',
expressionPattern: 'sourceSnapshot',
purpose: '规划草稿来源快照只能是对象或空值,用于追溯而不是主数据真相。',
},
{
type: 'check-constraint',
name: 'PlanningDraft_optional_text_not_blank',
relationName: 'PlanningDraft',
expressionPattern: 'notes',
purpose: '规划草稿可缺文本必须使用 NULL,不能用空字符串制造第二套缺失状态。',
},
{
type: 'check-constraint',
name: 'Substrate_code_normalized',
relationName: 'Substrate',
expressionPattern: 'upper(btrim',
purpose: '基材编码必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'WarehouseLocation_code_normalized',
relationName: 'WarehouseLocation',
expressionPattern: 'upper(btrim',
purpose: '库位编码必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'PackagedItem_code_normalized',
relationName: 'PackagedItem',
expressionPattern: 'upper(btrim',
purpose: '包装成品编号必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'PreSkuPackagingPlan_status_allowed',
relationName: 'PreSkuPackagingPlan',
expressionPattern: 'CONFIRMED',
purpose: '准 SKU 包装方案状态只能来自固定生命周期,不能用自由字符串变成第二套流程。',
},
{
type: 'check-constraint',
name: 'PreSkuPackagingPlan_boxes_array',
relationName: 'PreSkuPackagingPlan',
expressionPattern: 'jsonb_typeof',
purpose: '准 SKU 包装箱规必须保持数组结构,不能用对象或文本污染结构化 JSON。',
},
{
type: 'check-constraint',
name: 'PreSkuPackagingPlan_costSummary_object',
relationName: 'PreSkuPackagingPlan',
expressionPattern: 'costSummary',
purpose: '准 SKU 包装成本摘要只能是对象或空值,避免正式入库读取到非结构化成本。',
},
{
type: 'check-constraint',
name: 'PreSkuPackagingPlan_processDraft_object',
relationName: 'PreSkuPackagingPlan',
expressionPattern: 'processDraft',
purpose: '准 SKU 包装过程草稿只能是对象或空值,并明确不作为正式主数据。',
},
{
type: 'check-constraint',
name: 'PreSkuPackagingPlan_optional_text_not_blank',
relationName: 'PreSkuPackagingPlan',
expressionPattern: 'sourceRefId',
purpose: '准 SKU 包装方案可缺文本必须使用 NULL,不能用空字符串制造第二套缺失状态。',
},
{
type: 'check-constraint',
name: 'WarehouseLocation_name_not_blank',
relationName: 'WarehouseLocation',
expressionPattern: 'name',
purpose: '库位名称必须非空,库位编码不能指向无语义的空名称。',
},
{
type: 'check-constraint',
name: 'WarehouseLocation_optional_text_not_blank',
relationName: 'WarehouseLocation',
expressionPattern: 'zone',
purpose: '库位区/巷道/货架/箱位等可缺字段必须使用 NULL,不能用空字符串制造第二套缺失状态。',
},
{
type: 'check-constraint',
name: 'BrandMaterialBinding_materialType_allowed',
relationName: 'BrandMaterialBinding',
expressionPattern: 'CARTON_PRINT',
purpose: '品牌物料类型必须来自固定枚举,避免轻仓库资料准备项变成自由文本。',
},
{
type: 'check-constraint',
name: 'BrandMaterialBinding_identity_not_blank',
relationName: 'BrandMaterialBinding',
expressionPattern: 'materialType',
purpose: '品牌物料准备项必须有品牌和物料类型身份,不能保存空身份行。',
},
{
type: 'check-constraint',
name: 'BrandMaterialBinding_file_metadata_consistent',
relationName: 'BrandMaterialBinding',
expressionPattern: 'fileSize',
purpose: '品牌物料文件元数据必须成套出现,不能只保存孤立 URL、文件名或大小。',
},
{
type: 'check-constraint',
name: 'PackagedItem_status_allowed',
relationName: 'PackagedItem',
expressionPattern: 'ACTIVE',
purpose: '包装成品状态必须来自固定生命周期,轻仓库不能出现不可解释状态。',
},
{
type: 'check-constraint',
name: 'PackagedItem_name_not_blank',
relationName: 'PackagedItem',
expressionPattern: 'name',
purpose: '包装成品名称必须非空,库存项不能只有编号没有名称。',
},
{
type: 'check-constraint',
name: 'PackagedItem_quantities_valid',
relationName: 'PackagedItem',
expressionPattern: 'stockQuantity',
purpose: '包装成品装箱数量必须大于 0,库存数量不能为负数。',
},
{
type: 'check-constraint',
name: 'PackagedItem_dimensions_valid',
relationName: 'PackagedItem',
expressionPattern: 'weight',
purpose: '包装成品尺寸必须为正数,重量不能为负数。',
},
{
type: 'check-constraint',
name: 'PackagedItem_payload_object',
relationName: 'PackagedItem',
expressionPattern: 'jsonb_typeof',
purpose: '包装成品扩展载荷只能是对象或空值,避免过程快照变成任意 JSON 形态。',
},
{
type: 'check-constraint',
name: 'Material_code_normalized',
relationName: 'Material',
expressionPattern: 'upper(btrim',
purpose: '材质花色编码必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'Hardware_code_normalized',
relationName: 'Hardware',
expressionPattern: 'upper(btrim',
purpose: '标准五金编码必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'CommonComponent_code_normalized',
relationName: 'CommonComponent',
expressionPattern: 'upper(btrim',
purpose: '通用组件编码必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'IntakeNonIntegratedProduct_spu_brand_normalized',
relationName: 'IntakeNonIntegratedProduct',
expressionPattern: 'upper(btrim',
purpose: '非集成开品 SPU、SPU 基础号和品牌作用域必须由数据库兜底为规范化身份。',
},
{
type: 'check-constraint',
name: 'Product_modelNumber_normalized',
relationName: 'Product',
expressionPattern: 'upper(btrim',
purpose: '正式产品型号必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'Product_name_not_blank',
relationName: 'Product',
expressionPattern: 'name',
purpose: '正式产品名称必须非空且无首尾空格,上传链路不能写入无语义产品头。',
},
{
type: 'check-constraint',
name: 'Product_visibility_allowed',
relationName: 'Product',
expressionPattern: 'DEPARTMENT',
purpose: '正式产品可见范围只能来自固定枚举,不能成为游离权限状态。',
},
{
type: 'check-constraint',
name: 'Product_optional_text_not_blank',
relationName: 'Product',
expressionPattern: 'description',
purpose: '正式产品可缺文本必须使用 NULL,不能用空字符串制造第二套缺失状态。',
},
{
type: 'check-constraint',
name: 'ProductCollaborator_identity_not_blank',
relationName: 'ProductCollaborator',
expressionPattern: 'productId',
purpose: '产品协作者关系必须绑定有效产品和用户身份,不能保存空关系行。',
},
{
type: 'check-constraint',
name: 'ProductCollaborator_role_allowed',
relationName: 'ProductCollaborator',
expressionPattern: 'viewer',
purpose: '协作者角色只能是 editor/viewer,权限关系不能使用自由字符串。',
},
{
type: 'check-constraint',
name: 'ProductCollaborator_addedBy_not_blank',
relationName: 'ProductCollaborator',
expressionPattern: 'addedBy',
purpose: '协作者添加人可缺时必须使用 NULL,不能用空字符串表达缺失。',
},
{
type: 'check-constraint',
name: 'ShareLink_token_not_blank',
relationName: 'ShareLink',
expressionPattern: 'token',
purpose: '分享链接 token 必须非空且无首尾空格,公开访问入口不能出现空身份。',
},
{
type: 'check-constraint',
name: 'ShareLink_viewCount_non_negative',
relationName: 'ShareLink',
expressionPattern: 'viewCount',
purpose: '分享链接访问次数不能为负,访问统计只能单向累加。',
},
{
type: 'check-constraint',
name: 'ShareLink_expiresAt_after_createdAt',
relationName: 'ShareLink',
expressionPattern: 'expiresAt',
purpose: '分享链接过期时间必须晚于创建时间,避免产生创建即异常的公开入口。',
},
{
type: 'check-constraint',
name: 'Panel_identity_dimensions_valid',
relationName: 'Panel',
expressionPattern: 'gltfNodeName',
purpose: '旧 CAD 板件必须有规范编号、名称、节点名和正数尺寸,不能把无效几何作为板件真相。',
},
{
type: 'check-constraint',
name: 'Panel_explosionDirection_allowed',
relationName: 'Panel',
expressionPattern: 'explosionDirection',
purpose: '板件爆炸方向只能使用固定枚举,避免装配动画写入自由文本。',
},
{
type: 'check-constraint',
name: 'Panel_assemblySemantic_object',
relationName: 'Panel',
expressionPattern: 'assemblySemantic',
purpose: '板件装配语义 JSON 只允许对象快照,不能成为无形态约束的第二套结构。',
},
{
type: 'check-constraint',
name: 'Panel_optional_text_not_blank',
relationName: 'Panel',
expressionPattern: 'partType',
purpose: '板件可缺文本必须使用 NULL,不能用空字符串制造第二套缺失状态。',
},
{
type: 'check-constraint',
name: 'Hole_geometry_quality',
relationName: 'Hole',
expressionPattern: 'normalX',
purpose: '孔位坐标、直径、深度、法线和置信度必须有效,吸附和孔位高亮不能依赖脏几何。',
},
{
type: 'check-constraint',
name: 'Hole_type_allowed',
relationName: 'Hole',
expressionPattern: 'shelf_pin',
purpose: '孔位类型只能使用固定枚举,孔位匹配不能被自由字符串污染。',
},
{
type: 'check-constraint',
name: 'Hole_optional_text_not_blank',
relationName: 'Hole',
expressionPattern: 'groupId',
purpose: '孔组可缺时必须使用 NULL,不能用空字符串表达缺失。',
},
{
type: 'check-constraint',
name: 'ProductHardware_match_status_allowed',
relationName: 'ProductHardware',
expressionPattern: 'unmatched',
purpose: '产品五金匹配状态只能来自固定枚举,BOM 和装配匹配不能出现游离状态。',
},
{
type: 'check-constraint',
name: 'ProductHardware_match_confidence_valid',
relationName: 'ProductHardware',
expressionPattern: 'matchConfidence',
purpose: '产品五金匹配置信度必须在 0 到 1 之间。',
},
{
type: 'check-constraint',
name: 'ProductHardware_match_method_allowed',
relationName: 'ProductHardware',
expressionPattern: 'blockName',
purpose: '产品五金匹配方式只能来自固定枚举,不能把导入来源写成自由文本。',
},
{
type: 'check-constraint',
name: 'ProductHardware_quantities_geometry_valid',
relationName: 'ProductHardware',
expressionPattern: 'holeDistance',
purpose: '产品五金安装序号和孔距不能为负,装配排序和关联质量必须可解释。',
},
{
type: 'check-constraint',
name: 'ProductHardware_transform_object',
relationName: 'ProductHardware',
expressionPattern: 'transform',
purpose: '产品五金位姿 JSON 必须是对象,不能存入不可解释快照。',
},
{
type: 'check-constraint',
name: 'ProductHardware_sourceSignature_object',
relationName: 'ProductHardware',
expressionPattern: 'sourceSignature',
purpose: '产品五金 CAD 源签名只允许对象快照。',
},
{
type: 'check-constraint',
name: 'ProductHardware_optional_text_not_blank',
relationName: 'ProductHardware',
expressionPattern: 'sourceBlockName',
purpose: '产品五金可缺文本必须使用 NULL,不能用空字符串表达缺失。',
},
{
type: 'check-constraint',
name: 'AssemblyStep_identity_order_valid',
relationName: 'AssemblyStep',
expressionPattern: 'order',
purpose: '装配步骤必须有非负顺序和非空标题,安装流程不能出现无身份步骤。',
},
{
type: 'check-constraint',
name: 'AssemblyStep_type_allowed',
relationName: 'AssemblyStep',
expressionPattern: 'hardware',
purpose: '装配步骤类型只能是板件、五金或自定义,不能用自由文本驱动流程。',
},
{
type: 'check-constraint',
name: 'AssemblyStep_animation_valid',
relationName: 'AssemblyStep',
expressionPattern: 'cameraTransition',
purpose: '装配步骤动画时长、镜头切换和关联 ID 数组必须有效。',
},
{
type: 'check-constraint',
name: 'AssemblyStep_transform_object',
relationName: 'AssemblyStep',
expressionPattern: 'transform',
purpose: '装配步骤目标变换 JSON 必须是对象。',
},
{
type: 'check-constraint',
name: 'AssemblyStep_cameraPosition_object',
relationName: 'AssemblyStep',
expressionPattern: 'cameraPosition',
purpose: '装配步骤镜头位置 JSON 只允许对象快照。',
},
{
type: 'check-constraint',
name: 'AssemblyStep_infoCardData_object',
relationName: 'AssemblyStep',
expressionPattern: 'infoCardData',
purpose: '装配步骤信息卡 JSON 只允许对象快照。',
},
{
type: 'check-constraint',
name: 'AssemblyStep_optional_text_not_blank',
relationName: 'AssemblyStep',
expressionPattern: 'description',
purpose: '装配步骤可缺文本必须使用 NULL,不能用空字符串表达缺失。',
},
{
type: 'check-constraint',
name: 'AssemblyVersion_snapshot_array',
relationName: 'AssemblyVersion',
expressionPattern: 'snapshot',
purpose: '装配版本快照必须是步骤数组,版本历史不能保存任意 JSON 形态。',
},
{
type: 'check-constraint',
name: 'AssemblyVersion_version_positive',
relationName: 'AssemblyVersion',
expressionPattern: 'version',
purpose: '装配版本号必须为正数,版本历史不能倒退到无效编号。',
},
{
type: 'check-constraint',
name: 'AssemblyVersion_optional_text_not_blank',
relationName: 'AssemblyVersion',
expressionPattern: 'createdBy',
purpose: '装配版本可缺文本必须使用 NULL,不能用空字符串表达缺失。',
},
{
type: 'check-constraint',
name: 'ProductSKU_skuCode_normalized',
relationName: 'ProductSKU',
expressionPattern: 'upper(btrim',
purpose: '活动正式 SKU 编码必须由数据库兜底为大写且无首尾空格,删除态墓碑除外。',
},
{
type: 'check-constraint',
name: 'ProductSKU_active_skuCode_no_deleted_marker_ci',
relationName: 'ProductSKU',
expressionPattern: 'lower',
purpose: '活动正式 SKU 编码不能用大小写变体绕过软删除墓碑标记检查。',
},
{
type: 'check-constraint',
name: 'AssetVariant_code_normalized',
relationName: 'AssetVariant',
expressionPattern: 'upper(btrim',
purpose: '资产规格款编码必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'AssetVariant_barcode_normalized',
relationName: 'AssetVariant',
expressionPattern: 'upper(btrim',
purpose: '资产规格款条码非空时必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'PreSkuBasicInfo_formalSkuCode_normalized',
relationName: 'PreSkuBasicInfo',
expressionPattern: 'upper(btrim',
purpose: '准 SKU 正式规格码非空时必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'PreSkuBasicInfo_barcode_normalized',
relationName: 'PreSkuBasicInfo',
expressionPattern: 'upper(btrim',
purpose: '准 SKU 条码非空时必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'PreSkuBasicInfo_model_normalized',
relationName: 'PreSkuBasicInfo',
expressionPattern: 'upper(btrim',
purpose: '准 SKU 型号非空时必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'PreSkuBasicInfo_versionNo_normalized',
relationName: 'PreSkuBasicInfo',
expressionPattern: 'upper(btrim',
purpose: '准 SKU 版本号非空时必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'PreSkuBasicInfo_attributeCode_normalized',
relationName: 'PreSkuBasicInfo',
expressionPattern: 'upper(btrim',
purpose: '准 SKU 属性码非空时必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'PreSkuBasicInfo_drawingId_normalized',
relationName: 'PreSkuBasicInfo',
expressionPattern: 'upper(btrim',
purpose: '准 SKU 图纸号非空时必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'PreSkuBasicInfo_source_allowed',
relationName: 'PreSkuBasicInfo',
expressionPattern: 'DRAFT_GROUP',
purpose: '准 SKU 基础资料来源只能是系统承接或人工维护,避免随意字符串变成第二套状态。',
},
{
type: 'check-constraint',
name: 'PreSkuBasicInfo_optional_text_not_blank',
relationName: 'PreSkuBasicInfo',
expressionPattern: 'formalSkuCode',
purpose: '准 SKU 基础资料可缺失但不能用空字符串表示缺失,避免 NULL 和空值形成两套缺失状态。',
},
{
type: 'check-constraint',
name: 'AssetSkuSyncEvent_reason_allowed',
relationName: 'AssetSkuSyncEvent',
expressionPattern: 'VARIANT_UPDATED',
purpose: '资产 SKU 同步事件原因必须来自固定枚举,不能让自由字符串变成不可调度任务。',
},
{
type: 'check-constraint',
name: 'AssetSkuSyncEvent_counts_non_negative',
relationName: 'AssetSkuSyncEvent',
expressionPattern: 'retryCount',
purpose: '资产 SKU 同步事件计数器必须非负,数据库层阻断异常进度。',
},
{
type: 'check-constraint',
name: 'AssetSkuSyncEvent_synced_not_over_affected',
relationName: 'AssetSkuSyncEvent',
expressionPattern: 'syncedCount',
purpose: '资产 SKU 同步事件已同步数量不能超过受影响数量。',
},
{
type: 'check-constraint',
name: 'AssetSkuSyncEvent_completed_has_processed_at',
relationName: 'AssetSkuSyncEvent',
expressionPattern: 'processedAt',
purpose: '资产 SKU 同步事件完成后必须记录处理时间,便于追溯资料同步状态。',
},
{
type: 'check-constraint',
name: 'ProductSkuAssetProfile_barcode_normalized',
relationName: 'ProductSkuAssetProfile',
expressionPattern: 'upper(btrim',
purpose: '正式 SKU 资产资料副本条码非空时必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'ProductSkuAssetProfile_model_normalized',
relationName: 'ProductSkuAssetProfile',
expressionPattern: 'upper(btrim',
purpose: '正式 SKU 资产资料副本型号非空时必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'ProductSkuAssetProfile_specCode_normalized',
relationName: 'ProductSkuAssetProfile',
expressionPattern: 'upper(btrim',
purpose: '正式 SKU 资产资料副本规格码非空时必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'ProductSkuAssetProfile_primary_source_required',
relationName: 'ProductSkuAssetProfile',
expressionPattern: 'primarySourceAssetVariantId',
purpose: '正式 SKU 资产资料副本必须有主来源规格款,不能产生来源不明的对外资料。',
},
{
type: 'check-constraint',
name: 'ProductSkuAssetProfile_source_variant_ids_required',
relationName: 'ProductSkuAssetProfile',
expressionPattern: 'sourceAssetVariantIds',
purpose: '正式 SKU 资产资料副本必须保留来源规格款集合,不能用空数组表达来源。',
},
{
type: 'check-constraint',
name: 'ProductSkuAssetProfile_primary_source_in_source_ids',
relationName: 'ProductSkuAssetProfile',
expressionPattern: 'primarySourceAssetVariantId',
purpose: '正式 SKU 资产资料副本的主来源必须包含在来源规格款集合内。',
},
{
type: 'check-constraint',
name: 'ProductSkuAssetProfile_optional_text_not_blank',
relationName: 'ProductSkuAssetProfile',
expressionPattern: 'specCode',
purpose: '正式 SKU 资产资料副本可缺字段必须使用 NULL,不能用空字符串制造第二套缺失状态。',
},
{
type: 'check-constraint',
name: 'ProductPatentRecord_patentGroupKey_normalized',
relationName: 'ProductPatentRecord',
expressionPattern: 'upper(btrim',
purpose: '专利台账分组键必须非空、大写且无首尾空格,避免浏览器缓存旧值或手工 SQL 绕过。',
},
{
type: 'check-constraint',
name: 'ProductPatentRecord_productId_required',
relationName: 'ProductPatentRecord',
expressionPattern: 'btrim',
purpose: '专利台账必须保留来源产品 id,不能产生无法追溯的孤儿专利记录。',
},
{
type: 'check-constraint',
name: 'ProductPatentRecord_applicationNo_normalized',
relationName: 'ProductPatentRecord',
expressionPattern: 'upper(btrim',
purpose: '专利申请号非空时必须数据库级规范化,避免大小写和空格造成重复身份。',
},
{
type: 'check-constraint',
name: 'ProductPatentRecord_patentNo_normalized',
relationName: 'ProductPatentRecord',
expressionPattern: 'upper(btrim',
purpose: '专利号非空时必须数据库级规范化,避免大小写和空格造成重复身份。',
},
{
type: 'check-constraint',
name: 'ProductPatentRecord_spu_normalized',
relationName: 'ProductPatentRecord',
expressionPattern: 'upper(btrim',
purpose: '专利台账展示 SPU 非空时必须规范化,避免对账和外部只读消费出现同号不同写法。',
},
{
type: 'check-constraint',
name: 'ProductPatentRecord_patent_identity_required',
relationName: 'ProductPatentRecord',
expressionPattern: 'patentName',
purpose: '专利台账至少要有专利名称、申请号或专利号之一,不能保存空身份记录。',
},
{
type: 'check-constraint',
name: 'IntakeNonIntegratedSkuItem_skuCode_normalized',
relationName: 'IntakeNonIntegratedSkuItem',
expressionPattern: 'upper(btrim',
purpose: '非集成 SKU 编码非空时必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'IntakeNonIntegratedSkuItem_barcode_normalized',
relationName: 'IntakeNonIntegratedSkuItem',
expressionPattern: 'upper(btrim',
purpose: '非集成条码非空时必须由数据库兜底为大写且无首尾空格。',
},
{
type: 'check-constraint',
name: 'ApprovalWorkflow_approvalNumber_normalized',
relationName: 'ApprovalWorkflow',
expressionPattern: 'upper(btrim',
purpose: '审批编号必须由数据库兜底为大写且无首尾空格,避免同一流程出现多种写法。',
},
{
type: 'check-constraint',
name: 'ApprovalWorkflow_title_not_blank',
relationName: 'ApprovalWorkflow',
expressionPattern: 'title',
purpose: '审批流程标题必须非空,流程记录不能只有编号没有业务语义。',
},
{
type: 'check-constraint',
name: 'ApprovalWorkflow_productId_not_blank',
relationName: 'ApprovalWorkflow',
expressionPattern: 'productId',
purpose: '审批流程必须绑定产品资料来源,不能产生无来源的流程记录。',
},
{
type: 'check-constraint',
name: 'ApprovalWorkflow_accessKey_not_blank',
relationName: 'ApprovalWorkflow',
expressionPattern: 'accessKey',
purpose: '审批流程访问密钥必须非空,链接入口不能产生空身份。',
},
{
type: 'check-constraint',
name: 'ApprovalWorkflow_participant_user_ids_valid',
relationName: 'ApprovalWorkflow',
expressionPattern: 'text_array_distinct_non_blank',
purpose: '审批流程参与人列表不能包含空值或重复值,避免权限范围失真。',
},
{
type: 'check-constraint',
name: 'ApprovalWorkflow_business_roles_distinct',
relationName: 'ApprovalWorkflow',
expressionPattern: 'array_positions',
purpose: '审批流程业务角色列表不能重复,避免责任矩阵和权限判断出现重复语义。',
},
{
type: 'check-constraint',
name: 'ApprovalWorkflow_fieldMatrix_array',
relationName: 'ApprovalWorkflow',
expressionPattern: 'jsonb_typeof',
purpose: '审批字段矩阵只能是数组或空值,防止过程配置 JSON 形态漂移。',
},
{
type: 'check-constraint',
name: 'ApprovalWorkflow_summary_object',
relationName: 'ApprovalWorkflow',
expressionPattern: 'jsonb_typeof',
purpose: '审批摘要只能是对象或空值,正式产品资料承接时不能读到非对象快照。',
},
{
type: 'check-constraint',
name: 'ApprovalWorkflow_metadata_object',
relationName: 'ApprovalWorkflow',
expressionPattern: 'jsonb_typeof',
purpose: '审批流程扩展元数据只能是对象或空值,避免无边界 JSON 成为第二套真相。',
},
{
type: 'check-constraint',
name: 'ApprovalWorkflowFieldChange_fieldKey_not_blank',
relationName: 'ApprovalWorkflowFieldChange',
expressionPattern: 'fieldKey',
purpose: '审批字段变更必须记录非空字段 key,审计日志不能失去字段指向。',
},
{
type: 'check-constraint',
name: 'ApprovalWorkflowFieldChange_source_not_blank',
relationName: 'ApprovalWorkflowFieldChange',
expressionPattern: 'source',
purpose: '审批字段变更必须记录非空来源,后续追溯不能只看到值变化。',
},
{
type: 'check-constraint',
name: 'ApprovalWorkflowFieldChange_optional_text_not_blank',
relationName: 'ApprovalWorkflowFieldChange',
expressionPattern: 'sourcePage',
purpose: '审批字段变更可缺来源页面必须使用 NULL,不能用空字符串制造第二套缺失状态。',
},
{
type: 'check-constraint',
name: 'ApprovalWorkflowFieldChange_metadata_object',
relationName: 'ApprovalWorkflowFieldChange',
expressionPattern: 'jsonb_typeof',
purpose: '审批字段变更元数据只能是对象或空值,审计载荷保持可解释。',
},
{
type: 'check-constraint',
name: 'User_email_normalized',
relationName: 'User',
expressionPattern: 'lower(btrim',
purpose: '用户邮箱必须小写且无首尾空格,登录身份和唯一池保持同一语义。',
},
{
type: 'check-constraint',
name: 'User_name_not_blank',
relationName: 'User',
expressionPattern: 'btrim',
purpose: '用户花名必须非空且无首尾空格,权限与协作记录不能指向空身份。',
},
{
type: 'check-constraint',
name: 'User_loginSource_not_blank',
relationName: 'User',
expressionPattern: 'loginSource',
purpose: '用户登录来源必须非空,账号来源不能出现第二套缺失状态。',
},
{
type: 'check-constraint',
name: 'User_business_roles_distinct',
relationName: 'User',
expressionPattern: 'array_positions',
purpose: '用户业务类型不能重复,权限判断和业务归属不能出现重复语义。',
},
{
type: 'check-constraint',
name: 'User_optional_text_not_blank',
relationName: 'User',
expressionPattern: 'dingtalkUserId',
purpose: '用户可缺身份字段必须使用 NULL,不能用空字符串制造第二套缺失状态。',
},
{
type: 'check-constraint',
name: 'Department_identity_not_blank',
relationName: 'Department',
expressionPattern: 'fullPath',
purpose: '部门名称和完整路径必须非空且无首尾空格,组织树不能出现空身份节点。',
},
{
type: 'check-constraint',
name: 'Department_optional_text_not_blank',
relationName: 'Department',
expressionPattern: 'externalId',
purpose: '部门外部 ID 和描述可缺时必须使用 NULL,不能用空字符串污染同步身份。',
},
{
type: 'check-constraint',
name: 'Department_orderIndex_non_negative',
relationName: 'Department',
expressionPattern: 'orderIndex',
purpose: '部门排序值不能为负数,组织树排序保持可解释。',
},
{
type: 'check-constraint',
name: 'Team_identity_not_blank',
relationName: 'Team',
expressionPattern: 'orderIndex',
purpose: '小组名称必须非空且排序值非负,组织小组不能保存空身份。',
},
{
type: 'check-constraint',
name: 'EmployeeDirectory_name_not_blank',
relationName: 'EmployeeDirectory',
expressionPattern: 'btrim',
purpose: '员工目录姓名必须非空且无首尾空格,花名册不能出现空员工身份。',
},
{
type: 'check-constraint',
name: 'EmployeeDirectory_optional_text_not_blank',
relationName: 'EmployeeDirectory',
expressionPattern: 'primaryDepartmentExternalId',
purpose: '员工目录可缺身份字段必须使用 NULL,不能用空字符串影响匹配。',
},
{
type: 'check-constraint',
name: 'EmployeeDirectory_email_normalized',
relationName: 'EmployeeDirectory',
expressionPattern: 'lower(btrim',
purpose: '员工目录邮箱非空时必须小写且无首尾空格,外部资料匹配口径稳定。',
},
{
type: 'check-constraint',
name: 'EmployeeDirectory_departmentExternalIds_array',
relationName: 'EmployeeDirectory',
expressionPattern: 'jsonb_typeof',
purpose: '员工外部部门 ID 集合必须是数组或空值,不能让自由 JSON 影响主部门选择。',
},
{
type: 'check-constraint',
name: 'EmployeeDirectory_rawSnapshot_object',
relationName: 'EmployeeDirectory',
expressionPattern: 'jsonb_typeof',
purpose: '员工目录原始快照只能是对象或空值,集成缓存保持可解释。',
},
{
type: 'check-constraint',
name: 'EmployeeLookupCache_identity_not_blank',
relationName: 'EmployeeLookupCache',
expressionPattern: 'lookupValue',
purpose: '员工查找缓存键必须非空,缓存不能产生空身份项。',
},
{
type: 'check-constraint',
name: 'EmployeeLookupCache_result_object',
relationName: 'EmployeeLookupCache',
expressionPattern: 'jsonb_typeof',
purpose: '员工查找缓存结果只能是对象或空值,避免缓存载荷形态漂移。',
},
{
type: 'unique-index',
name: 'ProductCategory_name_normalized_key',
relationName: 'ProductCategory',
definitionPattern: 'lower(btrim',
purpose: '产品分类名称按去空格小写后的语义唯一,不能用大小写或空格制造重复分类。',
},
{
type: 'check-constraint',
name: 'ProductCategory_identity_valid',
relationName: 'ProductCategory',
expressionPattern: 'sortOrder',
purpose: '产品分类名称和排序是基础资料真相,必须非空、去空格且排序非负。',
},
{
type: 'check-constraint',
name: 'ProductCategory_optional_text_not_blank',
relationName: 'ProductCategory',
expressionPattern: 'description',
purpose: '产品分类可选说明和图标不能保存空字符串。',
},
{
type: 'unique-index',
name: 'MaterialCategory_name_normalized_key',
relationName: 'MaterialCategory',
definitionPattern: 'lower(btrim',
purpose: '材质分类名称按规范化语义唯一,花色资料不能分叉到重复分类。',
},
{
type: 'check-constraint',
name: 'MaterialCategory_identity_valid',
relationName: 'MaterialCategory',
expressionPattern: 'sortOrder',
purpose: '材质分类名称和排序必须由数据库兜底保证有效。',
},
{
type: 'check-constraint',
name: 'MaterialCategory_optional_text_not_blank',
relationName: 'MaterialCategory',
expressionPattern: 'icon',
purpose: '材质分类图标不能保存空字符串。',
},
{
type: 'unique-index',
name: 'HardwareCategory_name_normalized_key',
relationName: 'HardwareCategory',
definitionPattern: 'lower(btrim',
purpose: '五金分类名称按规范化语义唯一,自动匹配和五金库不能出现重复分类。',
},
{
type: 'check-constraint',
name: 'HardwareCategory_identity_valid',
relationName: 'HardwareCategory',
expressionPattern: 'sortOrder',
purpose: '五金分类名称和排序必须有效。',
},
{
type: 'check-constraint',
name: 'HardwareCategory_optional_text_not_blank',
relationName: 'HardwareCategory',
expressionPattern: 'description',
purpose: '五金分类说明和图标不能保存空字符串。',
},
{
type: 'unique-index',
name: 'CommonComponentCategory_name_normalized_key',
relationName: 'CommonComponentCategory',
definitionPattern: 'lower(btrim',
purpose: '通用组件分类名称按规范化语义唯一。',
},
{
type: 'check-constraint',
name: 'CommonComponentCategory_identity_valid',
relationName: 'CommonComponentCategory',
expressionPattern: 'sortOrder',
purpose: '通用组件分类名称和排序必须有效。',
},
{
type: 'check-constraint',
name: 'CommonComponentCategory_optional_text_not_blank',
relationName: 'CommonComponentCategory',
expressionPattern: 'description',
purpose: '通用组件分类说明和图标不能保存空字符串。',
},
{
type: 'unique-index',
name: 'SubstrateCategory_name_normalized_key',
relationName: 'SubstrateCategory',
definitionPattern: 'lower(btrim',
purpose: '基材主类名称按规范化语义唯一。',
},
{
type: 'check-constraint',
name: 'SubstrateCategory_identity_valid',
relationName: 'SubstrateCategory',
expressionPattern: 'sortOrder',
purpose: '基材主类名称和排序必须有效。',
},
{
type: 'unique-index',
name: 'SubstrateEnvironmentGrade_name_normalized_key',
relationName: 'SubstrateEnvironmentGrade',
definitionPattern: 'lower(btrim',
purpose: '环保等级名称按规范化语义唯一。',
},
{
type: 'check-constraint',
name: 'SubstrateEnvironmentGrade_identity_valid',
relationName: 'SubstrateEnvironmentGrade',
expressionPattern: 'sortOrder',
purpose: '环保等级名称和排序必须有效。',
},
{
type: 'unique-index',
name: 'SubstrateSurfaceOption_name_normalized_key',
relationName: 'SubstrateSurfaceOption',
definitionPattern: 'lower(btrim',
purpose: '贴面类型名称按规范化语义唯一。',
},
{
type: 'check-constraint',
name: 'SubstrateSurfaceOption_identity_valid',
relationName: 'SubstrateSurfaceOption',
expressionPattern: 'sortOrder',
purpose: '贴面类型名称和排序必须有效。',
},
{
type: 'unique-index',
name: 'SubstrateSystemNameOption_name_normalized_key',
relationName: 'SubstrateSystemNameOption',
definitionPattern: 'lower(btrim',
purpose: '系统名称选项按规范化语义唯一。',
},
{
type: 'check-constraint',
name: 'SubstrateSystemNameOption_identity_valid',
relationName: 'SubstrateSystemNameOption',
expressionPattern: 'sortOrder',
purpose: '系统名称选项名称和排序必须有效。',
},
{
type: 'unique-index',
name: 'SubstrateThicknessSpec_name_normalized_key',
relationName: 'SubstrateThicknessSpec',
definitionPattern: 'lower(btrim',
purpose: '厚度规格名称按规范化语义唯一。',
},
{
type: 'check-constraint',
name: 'SubstrateThicknessSpec_identity_dimensions_valid',
relationName: 'SubstrateThicknessSpec',
expressionPattern: 'thicknessMm',
purpose: '厚度规格名称、排序、厚度和板材尺寸必须有效。',
},
{
type: 'check-constraint',
name: 'HardwareMapping_match_key_exactly_one',
relationName: 'HardwareMapping',
expressionPattern: 'signatureHash',
purpose: '五金匹配记忆每行只能表达一个匹配键,避免图块名和尺寸哈希混成一条模糊真相。',
},
{
type: 'check-constraint',
name: 'HardwareMapping_usage_source_valid',
relationName: 'HardwareMapping',
expressionPattern: 'usageCount',
purpose: '五金匹配记忆使用次数和来源必须有效。',
},
{
type: 'check-constraint',
name: 'SubstrateFinish_relation_ids_not_blank',
relationName: 'SubstrateFinish',
expressionPattern: 'substrateId',
purpose: '基材和花色绑定关系不能保存空引用。',
},
{
type: 'check-constraint',
name: 'PanelMaterial_relation_ids_not_blank',
relationName: 'PanelMaterial',
expressionPattern: 'materialId',
purpose: '旧 CAD 板件材质绑定不能保存空引用。',
},
{
type: 'check-constraint',
name: 'AssetVariantLocationBinding_relation_ids_not_blank',
relationName: 'AssetVariantLocationBinding',
expressionPattern: 'warehouseLocationId',
purpose: '规格款库位绑定不能保存空引用。',
},
{
type: 'check-constraint',
name: 'PreSkuLocationBinding_relation_ids_not_blank',
relationName: 'PreSkuLocationBinding',
expressionPattern: 'warehouseLocationId',
purpose: '准 SKU 库位绑定不能保存空引用。',
},
{
type: 'check-constraint',
name: 'PackagingPlan_identity_counts_valid',
relationName: 'PackagingPlan',
expressionPattern: 'boxCount',
purpose: '旧正式产品包装方案名称和箱数必须有效。',
},
{
type: 'check-constraint',
name: 'PackagingPlan_boxes_array',
relationName: 'PackagingPlan',
expressionPattern: 'jsonb_typeof',
purpose: '旧正式产品包装方案 boxes 只能是数组快照。',
},
{
type: 'check-constraint',
name: 'PackagingPlan_totals_valid',
relationName: 'PackagingPlan',
expressionPattern: 'totalWeight',
purpose: '旧正式产品包装方案重量和体积不能为负数。',
},
{
type: 'check-constraint',
name: 'PackagingPlan_optional_text_not_blank',
relationName: 'PackagingPlan',
expressionPattern: 'description',
purpose: '旧正式产品包装方案可选文本不能保存空字符串。',
},
{
type: 'check-constraint',
name: 'SystemConfig_key_valid',
relationName: 'SystemConfig',
expressionPattern: 'btrim',
purpose: '系统配置 key 是配置真相身份,必须非空且无首尾空格。',
},
{
type: 'check-constraint',
name: 'SystemConfig_description_not_blank',
relationName: 'SystemConfig',
expressionPattern: 'description',
purpose: '系统配置说明不能保存空字符串。',
},
{
type: 'check-constraint',
name: 'ProviderConfig_identity_valid',
relationName: 'ProviderConfig',
expressionPattern: 'serviceType',
purpose: 'AI 服务商配置的服务类型、名称、地址、密钥和排序必须有结构化物理护栏。',
},
{
type: 'check-constraint',
name: 'ProviderConfig_optional_fields_valid',
relationName: 'ProviderConfig',
expressionPattern: 'jsonb_typeof',
purpose: 'AI 服务商配置扩展字段必须是对象,模型名不能保存空字符串。',
},
{
type: 'partial-unique-index',
name: 'ProviderConfig_active_service_key',
relationName: 'ProviderConfig',
predicatePattern: '"isActive" = true',
purpose: '同一个服务类型只能有一份激活配置,避免运行时读取到不确定配置。',
},
{
type: 'check-constraint',
name: 'ThemeConfig_identity_valid',
relationName: 'ThemeConfig',
expressionPattern: 'createdBy',
purpose: '主题配置名称和创建来源必须非空且无首尾空格。',
},
{
type: 'check-constraint',
name: 'ThemeConfig_optional_config_valid',
relationName: 'ThemeConfig',
expressionPattern: 'homeBackground',
purpose: '主题配置时间窗口和 JSON 配置形态必须可靠。',
},
{
type: 'unique-index',
name: 'ThemeConfig_name_normalized_key',
relationName: 'ThemeConfig',
definitionPattern: 'lower(btrim',
purpose: '主题配置名称按归一化语义唯一,避免同名主题重复。',
},
{
type: 'partial-unique-index',
name: 'ThemeConfig_default_key',
relationName: 'ThemeConfig',
predicatePattern: '"isDefault" = true',
purpose: '系统默认主题只能有一份,避免 fallback 不确定。',
},
{
type: 'check-constraint',
name: 'IntakeNodePermissionOverride_identity_valid',
relationName: 'IntakeNodePermissionOverride',
expressionPattern: 'targetKey',
purpose: '产品资料节点权限覆盖的目标、主体和动作必须是非空结构化身份。',
},
{
type: 'check-constraint',
name: 'IntakeNodePermissionOverride_audit_text_not_blank',
relationName: 'IntakeNodePermissionOverride',
expressionPattern: 'createdBy',
purpose: '产品资料节点权限覆盖的审计字段不能保存空字符串。',
},
{
type: 'check-constraint',
name: 'AssistedAssemblyAiRender_identity_valid',
relationName: 'AssistedAssemblyAiRender',
expressionPattern: 'prompt',
purpose: '辅助装配 AI 生图记录必须有用户和提示词身份。',
},
{
type: 'check-constraint',
name: 'AssistedAssemblyAiRender_json_shape_valid',
relationName: 'AssistedAssemblyAiRender',
expressionPattern: 'workspaceSnapshot',
purpose: '辅助装配 AI 生图参数、结果和快照 JSON 形态必须稳定。',
},
{
type: 'check-constraint',
name: 'AssistedAssemblyAiRender_status_duration_valid',
relationName: 'AssistedAssemblyAiRender',
expressionPattern: 'durationMs',
purpose: '辅助装配 AI 生图状态、结果和耗时不能互相矛盾。',
},
{
type: 'check-constraint',
name: 'AssistedAssemblyAiRender_optional_text_not_blank',
relationName: 'AssistedAssemblyAiRender',
expressionPattern: 'negativePrompt',
purpose: '辅助装配 AI 生图可选文本不能保存空字符串。',
},
{
type: 'check-constraint',
name: 'Feedback_identity_valid',
relationName: 'Feedback',
expressionPattern: 'description',
purpose: '反馈标题、描述和分类必须是非空结构化字段。',
},
{
type: 'check-constraint',
name: 'Feedback_images_valid',
relationName: 'Feedback',
expressionPattern: 'text_array_all_trimmed_non_blank',
purpose: '反馈图片数组不能保存空字符串。',
},
{
type: 'check-constraint',
name: 'Feedback_comments_array',
relationName: 'Feedback',
expressionPattern: 'jsonb_typeof',
purpose: '反馈评论 JSON 必须是数组过程记录。',
},
{
type: 'check-constraint',
name: 'Feedback_resolution_state_valid',
relationName: 'Feedback',
expressionPattern: 'resolvedAt',
purpose: '反馈解决状态必须和解决时间、解决说明保持一致。',
},
{
type: 'check-constraint',
name: 'ActivityLog_identity_valid',
relationName: 'ActivityLog',
expressionPattern: 'action',
purpose: '操作日志必须有操作者和动作说明。',
},
{
type: 'check-constraint',
name: 'ActivityLog_target_pair_valid',
relationName: 'ActivityLog',
expressionPattern: 'targetType',
purpose: '操作日志目标类型和目标 ID 必须同时存在或同时为空。',
},
{
type: 'check-constraint',
name: 'ActivityLog_json_shape_valid',
relationName: 'ActivityLog',
expressionPattern: 'metadata',
purpose: '操作日志变更和元数据 JSON 必须是对象。',
},
]
const REQUIRED_DATABASE_ENUMS = [
{
enumName: 'IntakeNonIntegratedSkuDataQualityStatus',
expectedLabels: ['READY', 'INCOMPLETE', 'CONFLICT'],
purpose: '非集成 SKU 质量状态必须支持 READY/INCOMPLETE/CONFLICT,冲突资料不能退回 JSON 或随手文本表达。',
},
]
function loadDatabaseUrl() {
if (process.env.DATABASE_URL) {
return process.env.DATABASE_URL
}
const envPath = path.join(ROOT_DIR, '.env')
if (!fs.existsSync(envPath)) {
return ''
}
const content = fs.readFileSync(envPath, 'utf8')
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const separatorIndex = trimmed.indexOf('=')
if (separatorIndex <= 0) continue
const key = trimmed.slice(0, separatorIndex).trim()
if (key !== 'DATABASE_URL') continue
let value = trimmed.slice(separatorIndex + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
return value
}
return ''
}
function normalizeSqlFragment(value) {
return String(value ?? '')
.replace(/\s+/g, ' ')
.replace(/""/g, '"')
.trim()
}
function loadExternalReadonlyContractExpectedColumns() {
const sqlPath = path.join(ROOT_DIR, 'scripts/sql/check-external-readonly-contract.sql')
const sql = fs.readFileSync(sqlPath, 'utf8')
const expectedColumns = new Map()
const tuplePattern = /\('([^']+_v1)',\s*'([^']+)'\)/g
for (const match of sql.matchAll(tuplePattern)) {
const viewName = match[1]
const columnName = match[2]
const columns = expectedColumns.get(viewName) ?? new Set()
columns.add(columnName)
expectedColumns.set(viewName, columns)
}
return expectedColumns
}
const EXTERNAL_STABLE_KEY_RULES = [
{
viewName: 'materials_v1',
requiredColumns: ['material_code', 'material_name'],
uniqueColumns: ['material_code'],
},
{
viewName: 'asset_variants_v1',
requiredColumns: ['spec_code', 'spec_name', 'asset_family_code'],
uniqueColumns: ['spec_code'],
},
{
viewName: 'product_skus_v1',
requiredColumns: ['product_business_key', 'sku_business_key', 'sku_code'],
uniqueColumns: ['sku_business_key', 'sku_code'],
},
{
viewName: 'product_assets_v1',
requiredColumns: ['product_business_key', 'sku_business_key', 'asset_url'],
uniqueColumns: [],
},
{
viewName: 'product_bom_v1',
requiredColumns: ['product_business_key', 'item_code', 'item_name'],
conditionalRequiredColumns: [
{
columnName: 'material_code',
conditionSql: "item_type = 'PANEL'",
},
],
uniqueColumns: [],
},
{
viewName: 'intake_products_ready_v1',
requiredColumns: ['product_business_key'],
uniqueColumns: ['product_business_key'],
},
{
viewName: 'intake_sku_items_ready_v1',
requiredColumns: ['product_business_key', 'sku_business_key', 'sku_code'],
uniqueColumns: ['sku_business_key'],
},
]
const EXTERNAL_ASSET_URL_RULES = [
['materials_v1', 'thumbnail_url'],
['materials_v1', 'texture_url'],
['materials_v1', 'normal_map_url'],
['asset_variants_v1', 'model_url'],
['asset_variants_v1', 'thumbnail_url'],
['product_skus_v1', 'product_thumbnail_url'],
['product_skus_v1', 'product_model_url'],
['product_skus_v1', 'model_url'],
['product_skus_v1', 'image_url'],
['product_skus_v1', 'image_urls', { isArray: true }],
['product_assets_v1', 'asset_url'],
['intake_products_v1', 'product_image_url'],
['intake_products_ready_v1', 'product_image_url'],
['intake_sku_items_v1', 'image_url'],
['intake_sku_items_v1', 'model_3d_url'],
['intake_sku_items_ready_v1', 'image_url'],
['intake_sku_items_ready_v1', 'model_3d_url'],
].map(([viewName, columnName, options]) => ({
viewName,
columnName,
isArray: Boolean(options?.isArray),
}))
const INTERNAL_ASSET_REFERENCE_RULES = [
['Product', 'modelUrl'],
['Product', 'thumbnailUrl'],
['ProductSKU', 'modelUrl'],
['ProductSKU', 'images', { isArray: true }],
['ProductSkuAssetProfile', 'imageUrl'],
['AssetVariant', 'modelUrl'],
['AssetVariant', 'thumbnailUrl'],
['Material', 'thumbnailUrl'],
['Material', 'textureUrl'],
['Material', 'normalMapUrl'],
['Hardware', 'modelUrl'],
['Hardware', 'thumbnailUrl'],
['CommonComponent', 'modelUrl'],
['CommonComponent', 'thumbnailUrl'],
['IntakeNonIntegratedSkuItem', 'image'],
['IntakeNonIntegratedSkuItem', 'model3dDataUrl'],
].map(([tableName, columnName, options]) => ({
tableName,
columnName,
isArray: Boolean(options?.isArray),
}))
function assertSafeExternalIdentifier(identifier) {
if (!/^[a-z][a-z0-9_]*$/.test(identifier)) {
throw new Error(`外部只读契约包含非法标识符:${identifier}`)
}
}
function quoteExternalIdentifier(identifier) {
assertSafeExternalIdentifier(identifier)
return `"${identifier}"`
}
async function getExternalReadonlyContractSummary(client) {
const expectedColumns = loadExternalReadonlyContractExpectedColumns()
const expectedViewNames = Array.from(expectedColumns.keys()).sort()
const existingViewsResult = await client.query(
`
SELECT table_name
FROM information_schema.views
WHERE table_schema = 'external'
AND table_name = ANY($1)
`,
[expectedViewNames],
)
const existingViews = new Set(existingViewsResult.rows.map((row) => row.table_name))
const existingColumnsResult = await client.query(
`
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'external'
AND table_name = ANY($1)
`,
[expectedViewNames],
)
const existingColumns = new Set(
existingColumnsResult.rows.map((row) => `${row.table_name}.${row.column_name}`),
)
const missingViews = expectedViewNames.filter((viewName) => !existingViews.has(viewName))
const missingColumns = []
const extraColumns = []
for (const [viewName, columns] of expectedColumns.entries()) {
for (const columnName of columns) {
if (!existingColumns.has(`${viewName}.${columnName}`)) {
missingColumns.push({ viewName, columnName })
}
}
}
existingColumnsResult.rows.forEach((row) => {
const expectedViewColumns = expectedColumns.get(row.table_name)
if (expectedViewColumns && !expectedViewColumns.has(row.column_name)) {
extraColumns.push({ viewName: row.table_name, columnName: row.column_name })
}
})
return {
expectedViews: expectedViewNames.length,
expectedColumns: Array.from(expectedColumns.values()).reduce(
(total, columns) => total + columns.size,
0,
),
missingViews,
missingColumns,
extraColumns,
}
}
async function getExternalStableKeyQualitySummary(client) {
const missingRequired = []
const duplicateGroups = []
const skippedViews = []
for (const rule of EXTERNAL_STABLE_KEY_RULES) {
const relationName = `external.${rule.viewName}`
if (!(await relationExists(client, relationName))) {
skippedViews.push({ viewName: rule.viewName, reason: 'missing_view' })
continue
}
for (const columnName of rule.requiredColumns) {
const column = quoteExternalIdentifier(columnName)
const result = await client.query(`
SELECT COUNT(*)::integer AS row_count
FROM external.${quoteExternalIdentifier(rule.viewName)}
WHERE NULLIF(btrim(COALESCE(${column}::text, '')), '') IS NULL
`)
const rowCount = result.rows[0]?.row_count ?? 0
if (rowCount > 0) {
missingRequired.push({ viewName: rule.viewName, columnName, rowCount })
}
}
for (const conditionalRule of rule.conditionalRequiredColumns ?? []) {
const column = quoteExternalIdentifier(conditionalRule.columnName)
const result = await client.query(`
SELECT COUNT(*)::integer AS row_count
FROM external.${quoteExternalIdentifier(rule.viewName)}
WHERE ${conditionalRule.conditionSql}
AND NULLIF(btrim(COALESCE(${column}::text, '')), '') IS NULL
`)
const rowCount = result.rows[0]?.row_count ?? 0
if (rowCount > 0) {
missingRequired.push({
viewName: rule.viewName,
columnName: conditionalRule.columnName,
condition: conditionalRule.conditionSql,
rowCount,
})
}
}
for (const columnName of rule.uniqueColumns) {
const column = quoteExternalIdentifier(columnName)
const result = await client.query(`
SELECT COUNT(*)::integer AS duplicate_groups
FROM (
SELECT upper(btrim(${column}::text)) AS normalized_value
FROM external.${quoteExternalIdentifier(rule.viewName)}
WHERE NULLIF(btrim(COALESCE(${column}::text, '')), '') IS NOT NULL
GROUP BY upper(btrim(${column}::text))
HAVING COUNT(*) > 1
) duplicate_groups
`)
const groupCount = result.rows[0]?.duplicate_groups ?? 0
if (groupCount > 0) {
duplicateGroups.push({ viewName: rule.viewName, columnName, groupCount })
}
}
}
return {
ruleCount: EXTERNAL_STABLE_KEY_RULES.length,
missingRequired,
duplicateGroups,
skippedViews,
}
}
function buildExternalAssetUrlRiskExpression(urlSql) {
const value = `btrim(${urlSql})`
return `
CASE
WHEN ${value} ~* '^data:' THEN 'data_url'
WHEN ${value} ~* '^file:' THEN 'file_url'
WHEN ${value} ~* '^https?://(localhost|127\\.0\\.0\\.1|\\[?::1\\]?)(:|/|$)' THEN 'localhost_url'
WHEN ${value} ~* '^https?://192\\.168\\.' THEN 'private_lan_url'
WHEN ${value} ~* '^https?://10\\.' THEN 'private_lan_url'
WHEN ${value} ~* '^https?://172\\.(1[6-9]|2[0-9]|3[0-1])\\.' THEN 'private_lan_url'
WHEN ${value} ~* '^/' THEN 'relative_or_internal_route'
WHEN ${value} ~* '(^|/)public/' THEN 'internal_public_path'
WHEN ${value} ~* '(^|/)uploads/' THEN 'legacy_uploads_path'
WHEN ${value} ~* 'hd-bkt\\.clouddn\\.com' THEN 'qiniu_test_domain'
ELSE NULL
END
`
}
async function getExternalAssetUrlRiskSummary(client) {
const risks = []
const skippedColumns = []
for (const rule of EXTERNAL_ASSET_URL_RULES) {
const relationName = `external.${rule.viewName}`
if (!(await relationExists(client, relationName))) {
skippedColumns.push({ viewName: rule.viewName, columnName: rule.columnName, reason: 'missing_view' })
continue
}
if (!(await columnExists(client, 'external', rule.viewName, rule.columnName))) {
skippedColumns.push({ viewName: rule.viewName, columnName: rule.columnName, reason: 'missing_column' })
continue
}
const view = `external.${quoteExternalIdentifier(rule.viewName)}`
const column = quoteExternalIdentifier(rule.columnName)
const valueSql = rule.isArray ? 'url_value' : `${column}::text`
const fromSql = rule.isArray
? `${view} CROSS JOIN LATERAL unnest(${column}) AS url_value`
: view
const result = await client.query(`
WITH classified AS (
SELECT
${buildExternalAssetUrlRiskExpression(valueSql)} AS risk_reason,
${valueSql} AS url_value
FROM ${fromSql}
WHERE NULLIF(btrim(COALESCE(${valueSql}, '')), '') IS NOT NULL
)
SELECT
risk_reason,
COUNT(*)::integer AS row_count,
MIN(url_value) AS sample_url
FROM classified
WHERE risk_reason IS NOT NULL
GROUP BY risk_reason
ORDER BY risk_reason
`)
for (const row of result.rows) {
risks.push({
viewName: rule.viewName,
columnName: rule.columnName,
riskReason: row.risk_reason,
rowCount: row.row_count,
sampleUrl: row.sample_url,
})
}
}
return {
ruleCount: EXTERNAL_ASSET_URL_RULES.length,
risks,
skippedColumns,
}
}
function assertSafePrismaIdentifier(identifier) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
throw new Error(`内部素材引用检查包含非法标识符:${identifier}`)
}
}
function quotePrismaIdentifier(identifier) {
assertSafePrismaIdentifier(identifier)
return `"${identifier}"`
}
function buildInternalAssetReferenceRiskExpression(urlSql) {
const value = `btrim(${urlSql})`
return `
CASE
WHEN ${value} ~* '^data:' THEN 'data_url'
WHEN ${value} ~* '^file:' THEN 'file_url'
WHEN ${value} ~* '^https?://(localhost|127\\.0\\.0\\.1|\\[?::1\\]?)(:|/|$)' THEN 'localhost_url'
WHEN ${value} ~* '^https?://192\\.168\\.' THEN 'private_lan_url'
WHEN ${value} ~* '^https?://10\\.' THEN 'private_lan_url'
WHEN ${value} ~* '^https?://172\\.(1[6-9]|2[0-9]|3[0-1])\\.' THEN 'private_lan_url'
WHEN ${value} ~* 'hd-bkt\\.clouddn\\.com' THEN 'qiniu_test_domain'
WHEN ${value} ~* '(^|[?&])(token|e|X-Amz-Signature|X-Amz-Credential)=' THEN 'signed_or_expiring_url'
ELSE NULL
END
`
}
async function getInternalAssetReferenceRiskSummary(client) {
const risks = []
const skippedColumns = []
for (const rule of INTERNAL_ASSET_REFERENCE_RULES) {
const relationName = quotePrismaIdentifier(rule.tableName)
if (!(await relationExists(client, relationName))) {
skippedColumns.push({ tableName: rule.tableName, columnName: rule.columnName, reason: 'missing_table' })
continue
}
if (!(await columnExists(client, rule.tableName, rule.columnName))) {
skippedColumns.push({ tableName: rule.tableName, columnName: rule.columnName, reason: 'missing_column' })
continue
}
const table = quotePrismaIdentifier(rule.tableName)
const column = quotePrismaIdentifier(rule.columnName)
const valueSql = rule.isArray ? 'url_value' : `${column}::text`
const fromSql = rule.isArray
? `${table} CROSS JOIN LATERAL unnest(${column}) AS url_value`
: table
const result = await client.query(`
WITH classified AS (
SELECT
${buildInternalAssetReferenceRiskExpression(valueSql)} AS risk_reason,
${valueSql} AS url_value
FROM ${fromSql}
WHERE NULLIF(btrim(COALESCE(${valueSql}, '')), '') IS NOT NULL
)
SELECT
risk_reason,
COUNT(*)::integer AS row_count,
MIN(url_value) AS sample_url
FROM classified
WHERE risk_reason IS NOT NULL
GROUP BY risk_reason
ORDER BY risk_reason
`)
for (const row of result.rows) {
risks.push({
tableName: rule.tableName,
columnName: rule.columnName,
riskReason: row.risk_reason,
rowCount: row.row_count,
sampleUrl: row.sample_url,
})
}
}
return {
ruleCount: INTERNAL_ASSET_REFERENCE_RULES.length,
risks,
skippedColumns,
}
}
async function getIndexGuard(client, guard) {
const result = await client.query(`
SELECT
cls.relname AS index_name,
table_cls.relname AS table_name,
idx.indisunique AS is_unique,
pg_get_expr(idx.indpred, idx.indrelid) AS predicate,
pg_get_indexdef(cls.oid) AS definition
FROM pg_index idx
JOIN pg_class cls ON cls.oid = idx.indexrelid
JOIN pg_class table_cls ON table_cls.oid = idx.indrelid
JOIN pg_namespace ns ON ns.oid = table_cls.relnamespace
WHERE ns.nspname = 'public'
AND cls.relname = $1
AND table_cls.relname = $2
LIMIT 1
`, [guard.name, guard.relationName])
const row = result.rows[0]
const predicate = normalizeSqlFragment(row?.predicate)
const definition = normalizeSqlFragment(row?.definition)
const hasExpectedPredicate =
guard.type !== 'partial-unique-index' ||
(predicate && predicate.includes(guard.predicatePattern))
const hasExpectedDefinition =
!guard.definitionPattern || definition.includes(guard.definitionPattern)
return {
...guard,
exists: Boolean(row),
isUnique: Boolean(row?.is_unique),
predicate,
definition,
ok:
Boolean(row) &&
(guard.type === 'index' || Boolean(row?.is_unique)) &&
hasExpectedPredicate &&
hasExpectedDefinition,
}
}
async function getTriggerGuard(client, guard) {
const result = await client.query(`
SELECT
trigger_name,
event_object_table,
action_timing,
event_manipulation
FROM information_schema.triggers
WHERE trigger_schema = 'public'
AND trigger_name = $1
AND event_object_table = $2
LIMIT 1
`, [guard.name, guard.relationName])
const row = result.rows[0]
return {
...guard,
exists: Boolean(row),
actionTiming: row?.action_timing ?? '',
eventManipulation: row?.event_manipulation ?? '',
ok: Boolean(row),
}
}
async function getCheckConstraintGuard(client, guard) {
const result = await client.query(`
SELECT
con.conname AS constraint_name,
table_cls.relname AS table_name,
pg_get_constraintdef(con.oid) AS definition
FROM pg_constraint con
JOIN pg_class table_cls ON table_cls.oid = con.conrelid
JOIN pg_namespace ns ON ns.oid = table_cls.relnamespace
WHERE ns.nspname = 'public'
AND con.contype = 'c'
AND con.conname = $1
AND table_cls.relname = $2
LIMIT 1
`, [guard.name, guard.relationName])
const row = result.rows[0]
const definition = normalizeSqlFragment(row?.definition)
const hasExpectedExpression =
!guard.expressionPattern || definition.includes(guard.expressionPattern)
return {
...guard,
exists: Boolean(row),
definition,
ok: Boolean(row) && hasExpectedExpression,
}
}
async function getForeignKeyGuard(client, guard) {
const result = await client.query(`
SELECT
con.conname AS constraint_name,
table_cls.relname AS table_name,
target_cls.relname AS target_table_name,
pg_get_constraintdef(con.oid) AS definition
FROM pg_constraint con
JOIN pg_class table_cls ON table_cls.oid = con.conrelid
JOIN pg_namespace ns ON ns.oid = table_cls.relnamespace
JOIN pg_class target_cls ON target_cls.oid = con.confrelid
WHERE ns.nspname = 'public'
AND con.contype = 'f'
AND con.conname = $1
AND table_cls.relname = $2
LIMIT 1
`, [guard.name, guard.relationName])
const row = result.rows[0]
const definition = normalizeSqlFragment(row?.definition)
const hasExpectedTarget =
!guard.targetRelationName || row?.target_table_name === guard.targetRelationName
return {
...guard,
exists: Boolean(row),
targetTableName: row?.target_table_name ?? '',
definition,
ok: Boolean(row) && hasExpectedTarget,
}
}
async function getDatabaseGuardSummary(client) {
const checks = []
for (const guard of REQUIRED_DATABASE_GUARDS) {
if (guard.type === 'trigger') {
checks.push(await getTriggerGuard(client, guard))
} else if (guard.type === 'check-constraint') {
checks.push(await getCheckConstraintGuard(client, guard))
} else if (guard.type === 'foreign-key') {
checks.push(await getForeignKeyGuard(client, guard))
} else {
checks.push(await getIndexGuard(client, guard))
}
}
return {
total: checks.length,
ok: checks.filter((guard) => guard.ok).length,
missingOrInvalid: checks.filter((guard) => !guard.ok),
checks,
}
}
async function getDatabaseEnumDriftSummary(client) {
const checks = []
for (const item of REQUIRED_DATABASE_ENUMS) {
const result = await client.query(`
SELECT enumlabel
FROM pg_enum enum_value
JOIN pg_type enum_type ON enum_type.oid = enum_value.enumtypid
JOIN pg_namespace namespace ON namespace.oid = enum_type.typnamespace
WHERE namespace.nspname = 'public'
AND enum_type.typname = $1
ORDER BY enum_value.enumsortorder
`, [item.enumName])
const actualLabels = result.rows.map((row) => row.enumlabel)
const missingLabels = item.expectedLabels.filter((label) => !actualLabels.includes(label))
const extraLabels = actualLabels.filter((label) => !item.expectedLabels.includes(label))
checks.push({
enumName: item.enumName,
expectedLabels: item.expectedLabels,
actualLabels,
missingLabels,
extraLabels,
purpose: item.purpose,
ok: missingLabels.length === 0 && extraLabels.length === 0,
})
}
return {
total: checks.length,
ok: checks.filter((check) => check.ok).length,
drifted: checks.filter((check) => !check.ok),
checks,
}
}
async function getAssetCenterSourceDataSummary(client) {
const result = await client.query(`
SELECT
(SELECT COUNT(*)::integer FROM "AssetSeries") AS asset_series_total,
(SELECT COUNT(*)::integer FROM "AssetSeries" WHERE "isActive" = true) AS active_asset_series,
(SELECT COUNT(*)::integer FROM "AssetFamily" WHERE "deletedAt" IS NULL) AS active_asset_families,
(
SELECT COUNT(*)::integer
FROM "AssetFamily"
WHERE "deletedAt" IS NULL
AND "assetSeriesId" IS NULL
) AS active_asset_families_without_series,
(SELECT COUNT(*)::integer FROM "AssetVariant" WHERE "deletedAt" IS NULL) AS active_asset_variants,
(
SELECT COUNT(*)::integer
FROM "AssetVariant"
WHERE "deletedAt" IS NULL
AND NULLIF(btrim("modelUrl"), '') IS NULL
) AS active_asset_variants_without_model_url,
(
SELECT COUNT(*)::integer
FROM "AssetVariant"
WHERE "deletedAt" IS NULL
AND NULLIF(btrim("thumbnailUrl"), '') IS NULL
) AS active_asset_variants_without_thumbnail_url
`)
return result.rows[0]
}
async function getAssetVariantDuplicateBarcodeGroupCount(client) {
const result = await client.query(`
SELECT COUNT(*)::integer AS duplicate_groups
FROM (
SELECT upper(btrim("barcode")) AS barcode
FROM "AssetVariant"
WHERE "deletedAt" IS NULL
AND NULLIF(btrim(COALESCE("barcode", '')), '') IS NOT NULL
GROUP BY upper(btrim("barcode"))
HAVING COUNT(*) > 1
) duplicate_groups
`)
return result.rows[0]?.duplicate_groups ?? 0
}
function assertNoPendingMigrationDryRun(appliedMigrations, options = {}) {
if (!options.requireApplied || appliedMigrations.length === 0) return
const pendingMigrations = getPendingStructureMigrationPaths(appliedMigrations)
if (pendingMigrations.length === 0) return
throw new Error(
`数据库治理迁移尚未真实落库,严格模式拒绝通过:${pendingMigrations.join(', ')}`,
)
}
function getMigrationApplicationStatus(
appliedMigrations,
migrationReplay = classifyAppliedMigrationPaths(appliedMigrations),
) {
return {
appliedMigrationCount: appliedMigrations.length,
pendingStructureMigrationCount: migrationReplay.structureMigrations.length,
replayableIdempotentMigrationCount: migrationReplay.idempotentMigrations.length,
pendingStructureMigrationPaths: migrationReplay.structureMigrations,
replayableIdempotentMigrationPaths: migrationReplay.idempotentMigrations,
strictReady: migrationReplay.structureMigrations.length === 0,
fullyReady: appliedMigrations.length === 0,
}
}
function shouldApplyMissingMigrationFiles(migrationReadiness) {
return Number(migrationReadiness?.pendingMigrationCount ?? 0) > 0
}
async function getMigrationHistoryStatus(client) {
const tableResult = await client.query(`
SELECT to_regclass('_prisma_migrations') AS relation_name
`)
const tableExists = tableResult.rows[0]?.relation_name !== null
if (!tableExists) {
return {
tableExists: false,
totalMigrationRows: 0,
appliedMigrationRows: 0,
unfinishedMigrationRows: 0,
rolledBackMigrationRows: 0,
resolvedRolledBackMigrationRows: 0,
unresolvedRolledBackMigrationRows: 0,
}
}
const historyResult = await client.query(`
WITH migration_state AS (
SELECT
migration_name,
COUNT(*) FILTER (
WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL
)::integer AS applied_rows,
COUNT(*) FILTER (
WHERE finished_at IS NULL AND rolled_back_at IS NULL
)::integer AS unfinished_rows,
COUNT(*) FILTER (
WHERE rolled_back_at IS NOT NULL
)::integer AS rolled_back_rows
FROM _prisma_migrations
GROUP BY migration_name
)
SELECT
COALESCE(SUM(applied_rows + unfinished_rows + rolled_back_rows), 0)::integer
AS total_migration_rows,
COALESCE(SUM(applied_rows), 0)::integer AS applied_migration_rows,
COALESCE(SUM(unfinished_rows), 0)::integer AS unfinished_migration_rows,
COALESCE(SUM(rolled_back_rows), 0)::integer AS rolled_back_migration_rows,
COALESCE(SUM(
CASE WHEN rolled_back_rows > 0 AND applied_rows > 0 THEN rolled_back_rows ELSE 0 END
), 0)::integer AS resolved_rolled_back_migration_rows,
COALESCE(SUM(
CASE WHEN rolled_back_rows > 0 AND applied_rows = 0 THEN rolled_back_rows ELSE 0 END
), 0)::integer AS unresolved_rolled_back_migration_rows
FROM migration_state
`)
const row = historyResult.rows[0] ?? {}
return {
tableExists: true,
totalMigrationRows: row.total_migration_rows ?? 0,
appliedMigrationRows: row.applied_migration_rows ?? 0,
unfinishedMigrationRows: row.unfinished_migration_rows ?? 0,
rolledBackMigrationRows: row.rolled_back_migration_rows ?? 0,
resolvedRolledBackMigrationRows: row.resolved_rolled_back_migration_rows ?? 0,
unresolvedRolledBackMigrationRows: row.unresolved_rolled_back_migration_rows ?? 0,
}
}
async function getRequiredMigrationReadiness(client, migrations = REQUIRED_MIGRATIONS) {
const checks = []
const migrationHistory = await getMigrationHistoryStatus(client)
for (const migration of migrations) {
const targetRelations = getMigrationTargetRelations(migration)
const relationChecks = await Promise.all(
targetRelations.map(async (relationName) => ({
relationName,
exists: await relationExists(client, relationName),
})),
)
const columnCheck = migration.columnCheck
? {
...migration.columnCheck,
exists: await columnExists(
client,
migration.columnCheck.tableName,
migration.columnCheck.columnName,
),
}
: null
const columnDefaultChecks = Array.isArray(migration.columnDefaultChecks)
? await Promise.all(
migration.columnDefaultChecks.map(async (check) => ({
...check,
matches: await columnDefaultMatches(
client,
check.tableName,
check.columnName,
check.expectedDefault,
),
})),
)
: []
const guardChecks = Array.isArray(migration.guardChecks)
? await Promise.all(
migration.guardChecks.map(async (check) => ({
...check,
exists: await guardExists(client, check),
})),
)
: []
const relationColumnChecks = Array.isArray(migration.relationColumnChecks)
? await Promise.all(
migration.relationColumnChecks.map(async (check) => ({
...check,
columns: await Promise.all(
check.columnNames.map(async (columnName) => ({
columnName,
exists: await relationColumnExists(client, check.relationName, columnName),
})),
),
})),
)
: []
const missingRelations = relationChecks
.filter((check) => !check.exists)
.map((check) => check.relationName)
const dataChecks = Array.isArray(migration.dataChecks)
? await Promise.all(
migration.dataChecks.map(async (check) => ({
name: check.name,
expectedValue: check.expectedValue,
matches: missingRelations.length === 0
? await dataCheckPasses(client, check)
: false,
})),
)
: []
const ready =
missingRelations.length === 0 &&
(!columnCheck || columnCheck.exists) &&
columnDefaultChecks.every((check) => check.matches) &&
guardChecks.every((check) => check.exists) &&
relationColumnChecks.every((check) => check.columns.every((column) => column.exists)) &&
dataChecks.every((check) => check.matches)
checks.push({
path: migration.path,
kind: migration.alwaysApply ? 'idempotent' : 'structure',
ready,
targetRelations: relationChecks,
...(columnCheck && { columnCheck }),
...(columnDefaultChecks.length > 0 && { columnDefaultChecks }),
...(guardChecks.length > 0 && { guardChecks }),
...(relationColumnChecks.length > 0 && { relationColumnChecks }),
...(dataChecks.length > 0 && { dataChecks }),
})
}
const pendingChecks = checks.filter((check) => !check.ready)
const pendingStructureMigrationPaths = pendingChecks
.filter((check) => check.kind === 'structure')
.map((check) => check.path)
const pendingIdempotentMigrationPaths = pendingChecks
.filter((check) => check.kind === 'idempotent')
.map((check) => check.path)
const migrationHistoryReady =
migrationHistory.tableExists &&
migrationHistory.unfinishedMigrationRows === 0 &&
migrationHistory.unresolvedRolledBackMigrationRows === 0
return {
mode: 'read-only-migration-status',
migrationHistory,
migrationCount: checks.length,
readyMigrationCount: checks.length - pendingChecks.length,
pendingMigrationCount: pendingChecks.length,
pendingStructureMigrationCount: pendingStructureMigrationPaths.length,
pendingIdempotentMigrationCount: pendingIdempotentMigrationPaths.length,
pendingStructureMigrationPaths,
pendingIdempotentMigrationPaths,
strictReady: pendingStructureMigrationPaths.length === 0 && migrationHistoryReady,
fullyReady: pendingChecks.length === 0 && migrationHistoryReady,
checks,
}
}
function assertNoMasterDataTruthDrift(report) {
const driftChecks = [
{
value: report.payloadSyncDiffCount,
message: '非集成 SKU 明细结构化表与 payload.skuItems 快照不一致,详见 payloadSyncDiffCount。',
},
{
value: report.pricingRowPayloadSyncDiffCount,
message: '非集成核价行结构化表与 payload.pricingRows / 核价确认快照不一致,详见 pricingRowPayloadSyncDiffCount。',
},
{
value: report.productSkuProfileSnapshotDiffCount,
message: '正式 SKU 资产 profile 与 attributes.assetSkuSnapshot 兼容快照不一致,详见 productSkuProfileSnapshotDiffCount。',
},
{
value: report.productSkuProfileDeletedAtMismatchCount,
message: '正式 SKU 资产 profile 的 skuDeletedAt 与 ProductSKU.deletedAt 不一致,详见 productSkuProfileDeletedAtMismatchCount。',
},
{
value: report.productSkuSoftDeleteTombstoneRiskCount,
message: '正式 SKU 软删除墓碑资料不完整,详见 productSkuSoftDeleteTombstoneRiskCount。',
},
{
value: report.preSkuBasicInfoSnapshotDiffCount,
message: '准 SKU 基础资料结构化表与 displaySummary/familySelection 旧快照不一致,详见 preSkuBasicInfoSnapshotDiffCount。',
},
{
value: report.nonIntegratedProductProfilePayloadDiffCount,
message: '非集成产品 profile 与 payload 产品头快照不一致,详见 nonIntegratedProductProfilePayloadDiffCount。',
},
{
value: report.nonIntegratedProductStockSourcePayloadDiffCount,
message: '非集成存量来源字段 profile 与 payload.metadata 快照不一致,详见 nonIntegratedProductStockSourcePayloadDiffCount。',
},
]
const drift = driftChecks.find((check) => Number(check.value ?? 0) > 0)
if (drift) {
throw new Error(drift.message)
}
if (Number(report.duplicateSkuCodeGroups ?? 0) > 0) {
throw new Error('非集成 SKU 明细存在重复 skuCode,详见 duplicateSkuCodeGroups。')
}
if (Number(report.duplicateBarcodeGroups ?? 0) > 0) {
throw new Error('非集成 SKU 明细存在重复 barcode,详见 duplicateBarcodeGroups。')
}
if (Number(report.productSkuPriceCostStockRiskCount ?? 0) > 0) {
throw new Error('正式 SKU 存在负售价、负成本或负库存,详见 productSkuPriceCostStockRiskCount。')
}
if (Number(report.productSkuFormalTruthAttributeRiskCount ?? 0) > 0) {
throw new Error('正式 SKU attributes 仍保存正式资料字段,详见 productSkuFormalTruthAttributeRiskCount。')
}
}
function asCount(value) {
const count = Number(value ?? 0)
return Number.isFinite(count) ? count : 0
}
function collectFoundationHardReadyBlockers(report) {
const blockers = []
const add = (source, key, value, message) => {
blockers.push({ source, key, value, message })
}
const addCount = (source, key, value, message) => {
const count = asCount(value)
if (count > 0) add(source, key, count, message)
}
const addBoolean = (source, key, ok, message) => {
if (!ok) add(source, key, ok, message)
}
addBoolean(
'migrationApplicationStatus',
'fullyReady',
report.migrationApplicationStatus?.fullyReady === true,
'治理迁移必须真实落库且没有 dry-run replay 缺口。',
)
addCount(
'databaseGuards',
'missingOrInvalid',
report.databaseGuards?.missingOrInvalid?.length,
'数据库物理护栏缺失或失效。',
)
addCount(
'databaseEnums',
'drifted',
report.databaseEnums?.drifted?.length,
'数据库 enum 类型必须和 Prisma schema / 业务状态机一致。',
)
addCount(
'assetCenterSourceData',
'active_asset_families_without_series',
report.assetCenterSourceData?.active_asset_families_without_series,
'活动资产族必须归属到结构化系列。',
)
addCount(
'assetCenterSourceData',
'active_asset_variants_without_model_url',
report.assetCenterSourceData?.active_asset_variants_without_model_url,
'活动规格款必须有模型 URL。',
)
addCount(
'assetCenterSourceData',
'active_asset_variants_without_thumbnail_url',
report.assetCenterSourceData?.active_asset_variants_without_thumbnail_url,
'活动规格款必须有预览图。',
)
addCount(
'assetVariantDuplicateBarcodeGroups',
'duplicateGroups',
report.assetVariantDuplicateBarcodeGroups,
'活动资产规格款条码不能重复。',
)
addCount(
'structuredSkuItems',
'incomplete',
report.structuredSkuItems?.incomplete,
'非集成 SKU 明细必须全部 READY。',
)
addCount(
'structuredSkuItems',
'duplicateSkuCodeGroups',
report.duplicateSkuCodeGroups,
'非集成 SKU 明细 skuCode 不能重复。',
)
addCount(
'structuredSkuItems',
'duplicateBarcodeGroups',
report.duplicateBarcodeGroups,
'非集成 SKU 明细 barcode 不能重复。',
)
addCount(
'structuredPricingRows',
'missingSkuRows',
report.structuredPricingRows?.missingSkuRows,
'每个活动非集成 SKU 必须有结构化核价状态行,外部核价查询不能回退到 payload。',
)
addCount(
'structuredPricingRows',
'payloadDiffCount',
report.pricingRowPayloadSyncDiffCount,
'结构化核价行和 payload.pricingRows / 核价确认快照不能漂移。',
)
addCount(
'productSkuAssetProfiles',
'productSkuWithoutAssetProfileCount',
report.productSkuWithoutAssetProfileCount,
'带资产快照的正式 SKU 必须有结构化资产 profile。',
)
addCount(
'productSkuAssetProfiles',
'productSkuProfileDuplicateBarcodeGroups',
report.productSkuProfileDuplicateBarcodeGroups,
'正式 SKU 资产 profile 活动条码不能重复。',
)
addCount(
'productSkuAssetProfiles',
'productSkuProfileDeletedAtMismatchCount',
report.productSkuProfileDeletedAtMismatchCount,
'正式 SKU 资产 profile 删除状态必须和 SKU 删除状态一致。',
)
addCount(
'productSkuAssetProfiles',
'productSkuSoftDeleteTombstoneRiskCount',
report.productSkuSoftDeleteTombstoneRiskCount,
'正式 SKU 软删除墓碑资料必须完整。',
)
addCount(
'productSkuAssetProfiles',
'invalid_locked_fields',
report.productSkuAssetProfiles?.invalid_locked_fields,
'正式 SKU 资产 profile 的人工锁定字段只能使用登记字段名,且不能保留空白或带空格字段。',
)
addCount(
'productSkuAssetProfiles',
'invalid_locked_reasons',
report.productSkuAssetProfiles?.invalid_locked_reasons,
'正式 SKU 资产 profile 的人工锁定原因必须是登记字段名对应的有效文本。',
)
addCount(
'productSkuTruth',
'productSkuMissingNameCount',
report.productSkuMissingNameCount,
'活动正式 SKU 必须有结构化名称。',
)
addCount(
'productSkuTruth',
'productSkuPriceCostStockRiskCount',
report.productSkuPriceCostStockRiskCount,
'正式 SKU 售价、成本和库存不能为负。',
)
addCount(
'productSkuTruth',
'productSkuFormalTruthAttributeRiskCount',
report.productSkuFormalTruthAttributeRiskCount,
'正式 SKU attributes 不能保存正式资料字段。',
)
addCount(
'assetSkuSyncEvents',
'failed',
report.assetSkuSyncEvents?.failed,
'资产中心到正式 SKU 的资料同步事件不能处于 FAILED 状态。',
)
addCount(
'assetSkuSyncEvents',
'stale_pending_or_processing',
report.assetSkuSyncEvents?.stale_pending_or_processing,
'资产中心到正式 SKU 的资料同步事件不能长期停留在 PENDING/PROCESSING。',
)
addCount(
'assetSkuSyncEvents',
'completed_with_failures',
report.assetSkuSyncEvents?.completed_with_failures,
'资产中心到正式 SKU 的资料同步事件完成态不能保留失败计数。',
)
addCount(
'preSkuBasicInfo',
'preSkuMissingBasicInfoCount',
report.preSkuMissingBasicInfoCount,
'准 SKU 必须有结构化基础资料。',
)
addCount(
'preSkuBasicInfo',
'preSkuDuplicateFormalSkuCodeGroups',
report.preSkuDuplicateFormalSkuCodeGroups,
'准 SKU 正式规格码不能重复。',
)
addCount(
'preSkuBasicInfo',
'preSkuDuplicateBarcodeGroups',
report.preSkuDuplicateBarcodeGroups,
'准 SKU 条码不能重复。',
)
addCount(
'preSkuLifecycle',
'preSkuFormalizationLifecycleMismatchCount',
report.preSkuFormalizationLifecycleMismatchCount,
'准 SKU 正式化状态必须和正式 SKU 来源关系一致。',
)
addCount(
'draftProcessSnapshots',
'draftGroupInvalidProcessShapeCount',
report.draftGroupInvalidProcessShapeCount,
'草稿组过程快照形态必须稳定。',
)
addCount(
'draftProcessSnapshots',
'preSkuInvalidProcessShapeCount',
report.preSkuInvalidProcessShapeCount,
'准 SKU 过程快照形态必须稳定。',
)
addCount(
'nonIntegratedProductProfiles',
'nonIntegratedProductMissingProfileCount',
report.nonIntegratedProductMissingProfileCount,
'非集成产品必须有结构化产品 profile。',
)
addCount(
'nonIntegratedProductProfiles',
'missing_product_name',
report.nonIntegratedProductProfiles?.missing_product_name,
'非集成产品 profile 必须有产品名称。',
)
addCount(
'nonIntegratedProductProfiles',
'missing_product_category',
report.nonIntegratedProductProfiles?.missing_product_category,
'非集成产品 profile 必须有品类。',
)
addCount(
'nonIntegratedProductProfiles',
'missing_development_kind',
report.nonIntegratedProductProfiles?.missing_development_kind,
'非集成产品 profile 必须有开发类型。',
)
addCount(
'productPatentRecords',
'temporary_id_count',
report.productPatentRecords?.temporary_id_count,
'专利台账不能保留草稿态临时随机 id。',
)
const qualityDecisionRisks = report.masterDataQualityDecisions || {}
for (const key of [
'openApprovedIncomplete',
'blankReasonRisk',
'placeholderReasonRisk',
'placeholderDecidedByRisk',
'placeholderResolvedByRisk',
'duplicateOpenTargetRisk',
]) {
addCount(
'masterDataQualityDecisions',
key,
qualityDecisionRisks[key],
'主数据质量决策只能作为分阶段审计线索,不能保留开放缺口或占位内容。',
)
}
const driftChecks = [
['snapshotDrift', 'payloadSyncDiffCount', report.payloadSyncDiffCount],
['snapshotDrift', 'pricingRowPayloadSyncDiffCount', report.pricingRowPayloadSyncDiffCount],
['snapshotDrift', 'productSkuProfileSnapshotDiffCount', report.productSkuProfileSnapshotDiffCount],
['snapshotDrift', 'preSkuBasicInfoSnapshotDiffCount', report.preSkuBasicInfoSnapshotDiffCount],
['snapshotDrift', 'nonIntegratedProductProfilePayloadDiffCount', report.nonIntegratedProductProfilePayloadDiffCount],
['snapshotDrift', 'nonIntegratedProductStockSourcePayloadDiffCount', report.nonIntegratedProductStockSourcePayloadDiffCount],
]
for (const [source, key, value] of driftChecks) {
addCount(source, key, value, '结构化主数据和兼容/过程快照不能漂移。')
}
addBoolean(
'externalReadonlyContract',
'externalIntakeSkuItemsView.exists',
report.externalIntakeSkuItemsView?.exists === true,
'外部只读 SKU 明细视图必须存在。',
)
addBoolean(
'externalReadonlyContract',
'externalIntakeReadyViews.exists',
report.externalIntakeReadyViews?.exists === true,
'外部只读 ready 视图必须存在。',
)
addBoolean(
'externalReadonlyContract',
'externalProductSkusView.exists',
report.externalProductSkusView?.exists === true,
'外部正式 SKU 视图必须存在。',
)
addBoolean(
'externalReadonlyContract',
'externalProductDetailViews.exists',
report.externalProductDetailViews?.exists === true,
'外部正式产品素材/BOM 明细视图必须存在。',
)
addCount(
'externalReadonlyContract',
'missingViews',
report.externalReadonlyContract?.missingViews?.length,
'外部只读契约视图不能缺失。',
)
addCount(
'externalReadonlyContract',
'missingColumns',
report.externalReadonlyContract?.missingColumns?.length,
'外部只读视图不能缺少字段字典承诺列。',
)
addCount(
'externalReadonlyContract',
'extraColumns',
report.externalReadonlyContract?.extraColumns?.length,
'外部只读视图不能暴露字段字典未登记列。',
)
addCount(
'externalStableKeyQuality',
'missingRequired',
report.externalStableKeyQuality?.missingRequired?.length,
'外部只读视图稳定业务键不能缺失。',
)
addCount(
'externalStableKeyQuality',
'duplicateGroups',
report.externalStableKeyQuality?.duplicateGroups?.length,
'外部只读视图稳定业务键不能重复。',
)
addCount(
'externalAssetUrlRisks',
'risks',
report.externalAssetUrlRisks?.risks?.length,
'外部只读素材 URL 不能暴露 data、本地地址、相对路径、内部路径或测试域名。',
)
addCount(
'internalAssetReferenceRisks',
'risks',
report.internalAssetReferenceRisks?.risks?.length,
'内部业务表素材引用不能保存 data、file、本地地址、内网地址、七牛测试域名或短期签名 URL。',
)
addCount(
'externalViewJsonSourceDependencyRisks',
'rowCount',
report.externalViewJsonSourceDependencyRisks?.length,
'外部只读视图不能依赖内部 JSON 源。',
)
addCount(
'externalViewIdFallbackRisks',
'rowCount',
report.externalViewIdFallbackRisks?.length,
'外部只读视图业务编码字段不能用内部 id 兜底。',
)
addCount(
'externalIntakeProductPeopleSourceRisks',
'rowCount',
report.externalIntakeProductPeopleSourceRisks?.length,
'外部非集成产品负责人字段必须优先读取结构化 profile。',
)
return blockers
}
function buildFoundationHardReadyGate(report) {
const blockers = collectFoundationHardReadyBlockers(report)
return {
hardReady: blockers.length === 0,
blockerCount: blockers.length,
blockerSourceCounts: blockers.reduce((counts, blocker) => {
counts[blocker.source] = (counts[blocker.source] || 0) + 1
return counts
}, {}),
blockers,
}
}
function assertFoundationHardReadyGate(report) {
const gate = report.foundationHardReadyGate || buildFoundationHardReadyGate(report)
if (gate.hardReady) return
const first = gate.blockers[0]
throw new Error(
`数据库主数据地基尚未 hard-ready:${first?.source || 'unknown'}.${first?.key || 'unknown'},详见 foundationHardReadyGate.blockers。`,
)
}
async function getStructuredSkuSummary(client) {
const result = await client.query(`
SELECT
COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE "dataQualityStatus"::text = 'READY')::integer AS ready,
COUNT(*) FILTER (WHERE "dataQualityStatus"::text = 'INCOMPLETE')::integer AS incomplete,
COUNT(*) FILTER (WHERE "dataQualityStatus"::text = 'CONFLICT')::integer AS conflict
FROM "IntakeNonIntegratedSkuItem" sku
JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId"
WHERE product."deletedAt" IS NULL
`)
return result.rows[0]
}
async function getIncompleteSkuItemSamples(client) {
const result = await client.query(`
SELECT
sku."id",
sku."productId",
product."spu",
sku."skuItemId",
sku."skuCode",
sku."specName",
sku."model",
sku."barcode",
sku."missingRequiredFields"
FROM "IntakeNonIntegratedSkuItem" sku
JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId"
WHERE product."deletedAt" IS NULL
AND sku."dataQualityStatus"::text = 'INCOMPLETE'
ORDER BY product."spu", sku."sortOrder", sku."id"
LIMIT 20
`)
return result.rows
}
async function getDuplicateGroupCount(client, columnName) {
const result = await client.query(`
SELECT COUNT(*)::integer AS duplicate_groups
FROM (
SELECT upper(btrim("${columnName}")) AS normalized_value
FROM "IntakeNonIntegratedSkuItem" sku
JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId"
WHERE product."deletedAt" IS NULL
AND sku."dataQualityStatus"::text = 'READY'
AND NULLIF(btrim("${columnName}"), '') IS NOT NULL
GROUP BY upper(btrim("${columnName}"))
HAVING COUNT(*) > 1
) duplicate_groups
`)
return result.rows[0]?.duplicate_groups ?? 0
}
async function getStructuredPricingRowSummary(client) {
const result = await client.query(`
WITH active_sku_items AS (
SELECT sku."productId", sku."skuItemId"
FROM "IntakeNonIntegratedSkuItem" sku
JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId"
WHERE product."deletedAt" IS NULL
)
SELECT
COUNT(pricing.*)::integer AS total,
COUNT(*) FILTER (WHERE pricing."pricingConfirmed" = true)::integer AS confirmed,
COUNT(*) FILTER (WHERE pricing."costPrice" IS NOT NULL)::integer AS priced,
(
SELECT COUNT(*)::integer
FROM active_sku_items sku
LEFT JOIN "IntakeNonIntegratedPricingRow" row
ON row."productId" = sku."productId"
AND row."skuItemId" = sku."skuItemId"
WHERE row."id" IS NULL
) AS missing_sku_rows
FROM "IntakeNonIntegratedPricingRow" pricing
JOIN "IntakeNonIntegratedProduct" product ON product."id" = pricing."productId"
WHERE product."deletedAt" IS NULL
`)
const row = result.rows[0] || {}
return {
total: row.total ?? 0,
confirmed: row.confirmed ?? 0,
priced: row.priced ?? 0,
missingSkuRows: row.missing_sku_rows ?? 0,
}
}
async function getPricingRowPayloadSyncDiffCount(client) {
const result = await client.query(`
WITH payload_sku_items AS (
SELECT
p."id" AS product_id,
item.ordinality::integer AS sort_order,
COALESCE(NULLIF(btrim(item.sku_item ->> 'id'), ''), 'sku-' || item.ordinality::text) AS raw_sku_item_id
FROM "IntakeNonIntegratedProduct" p
CROSS JOIN LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(p."payload"::jsonb -> 'skuItems') = 'array'
THEN p."payload"::jsonb -> 'skuItems'
ELSE '[]'::jsonb
END
) WITH ORDINALITY AS item(sku_item, ordinality)
WHERE p."deletedAt" IS NULL
),
deduped_payload_sku_items AS (
SELECT
payload_sku_items.*,
CASE
WHEN COUNT(*) OVER (PARTITION BY product_id, raw_sku_item_id) = 1
THEN raw_sku_item_id
ELSE raw_sku_item_id || '-' || ROW_NUMBER() OVER (
PARTITION BY product_id, raw_sku_item_id
ORDER BY sort_order
)::text
END AS sku_item_id
FROM payload_sku_items
),
payload_pricing_rows AS (
SELECT
p."id" AS product_id,
btrim(COALESCE(pricing_row.pricing_row ->> 'skuId', '')) AS raw_sku_item_id,
pricing_row.pricing_row
FROM "IntakeNonIntegratedProduct" p
CROSS JOIN LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(p."payload"::jsonb -> 'pricingRows') = 'array'
THEN p."payload"::jsonb -> 'pricingRows'
ELSE '[]'::jsonb
END
) AS pricing_row(pricing_row)
WHERE p."deletedAt" IS NULL
),
payload_snapshot AS (
SELECT
product."id" AS product_id,
sku.sku_item_id,
upper(btrim(COALESCE(structured_sku."skuCode", ''))) AS sku_code,
upper(btrim(COALESCE(structured_sku."barcode", ''))) AS barcode,
CASE
WHEN btrim(COALESCE(pricing.pricing_row ->> 'costPrice', '')) ~ '^[0-9]+(\\.[0-9]+)?$'
AND round((pricing.pricing_row ->> 'costPrice')::numeric, 2) <= 99999999.99
THEN round((pricing.pricing_row ->> 'costPrice')::numeric, 2)::text
ELSE ''
END AS cost_price,
COALESCE(product."payload"::jsonb #>> ARRAY['documentWorkspaceConfirmations', '产品核价'], 'false') = 'true'
AS pricing_confirmed
FROM deduped_payload_sku_items sku
JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku.product_id
JOIN "IntakeNonIntegratedSkuItem" structured_sku
ON structured_sku."productId" = sku.product_id
AND structured_sku."skuItemId" = sku.sku_item_id
LEFT JOIN LATERAL (
SELECT row.pricing_row
FROM payload_pricing_rows row
WHERE row.product_id = sku.product_id
AND (
row.raw_sku_item_id = sku.raw_sku_item_id
OR row.raw_sku_item_id = sku.sku_item_id
)
LIMIT 1
) pricing ON TRUE
),
structured_snapshot AS (
SELECT
pricing."productId" AS product_id,
pricing."skuItemId" AS sku_item_id,
upper(btrim(COALESCE(pricing."skuCode", ''))) AS sku_code,
upper(btrim(COALESCE(pricing."barcode", ''))) AS barcode,
COALESCE(pricing."costPrice"::text, '') AS cost_price,
pricing."pricingConfirmed" AS pricing_confirmed
FROM "IntakeNonIntegratedPricingRow" pricing
JOIN "IntakeNonIntegratedProduct" product ON product."id" = pricing."productId"
WHERE product."deletedAt" IS NULL
)
SELECT COUNT(*)::integer AS diff_count
FROM payload_snapshot payload
FULL JOIN structured_snapshot structured
ON structured.product_id = payload.product_id
AND structured.sku_item_id = payload.sku_item_id
WHERE payload.product_id IS NULL
OR structured.product_id IS NULL
OR payload.sku_code IS DISTINCT FROM structured.sku_code
OR payload.barcode IS DISTINCT FROM structured.barcode
OR payload.cost_price IS DISTINCT FROM structured.cost_price
OR payload.pricing_confirmed IS DISTINCT FROM structured.pricing_confirmed
`)
return result.rows[0]?.diff_count ?? 0
}
async function getPayloadSyncDiffCount(client) {
const result = await client.query(`
WITH payload_sku_items AS (
SELECT
p."id" AS product_id,
item.sku_item,
item.ordinality::integer AS sort_order,
COALESCE(NULLIF(btrim(item.sku_item ->> 'id'), ''), 'sku-' || item.ordinality::text) AS raw_sku_item_id
FROM "IntakeNonIntegratedProduct" p
CROSS JOIN LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(p."payload"::jsonb -> 'skuItems') = 'array'
THEN p."payload"::jsonb -> 'skuItems'
ELSE '[]'::jsonb
END
) WITH ORDINALITY AS item(sku_item, ordinality)
WHERE p."deletedAt" IS NULL
),
deduped_payload_sku_items AS (
SELECT
payload_sku_items.*,
CASE
WHEN COUNT(*) OVER (PARTITION BY product_id, raw_sku_item_id) = 1
THEN raw_sku_item_id
ELSE raw_sku_item_id || '-' || ROW_NUMBER() OVER (
PARTITION BY product_id, raw_sku_item_id
ORDER BY sort_order
)::text
END AS sku_item_id
FROM payload_sku_items
),
payload_snapshot AS (
SELECT
product_id,
sku_item_id,
sort_order,
COALESCE(sku_item ->> 'skuCode', '') AS sku_code,
COALESCE(sku_item ->> 'specName', '') AS spec_name,
COALESCE(sku_item ->> 'model', '') AS model,
COALESCE(sku_item ->> 'barcode', '') AS barcode
FROM deduped_payload_sku_items
),
structured_snapshot AS (
SELECT
sku."productId" AS product_id,
sku."skuItemId" AS sku_item_id,
sku."sortOrder" AS sort_order,
sku."skuCode" AS sku_code,
sku."specName" AS spec_name,
sku."model" AS model,
sku."barcode" AS barcode
FROM "IntakeNonIntegratedSkuItem" sku
JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId"
WHERE product."deletedAt" IS NULL
)
SELECT COUNT(*)::integer AS row_count
FROM payload_snapshot payload
FULL JOIN structured_snapshot structured
ON structured.product_id = payload.product_id
AND structured.sku_item_id = payload.sku_item_id
WHERE payload.product_id IS NULL
OR structured.product_id IS NULL
OR payload.sort_order IS DISTINCT FROM structured.sort_order
OR payload.sku_code IS DISTINCT FROM structured.sku_code
OR payload.spec_name IS DISTINCT FROM structured.spec_name
OR payload.model IS DISTINCT FROM structured.model
OR payload.barcode IS DISTINCT FROM structured.barcode
`)
return result.rows[0]?.row_count ?? 0
}
async function getExternalViewSummary(client) {
const exists = await relationExists(client, 'external.intake_sku_items_v1')
if (!exists) {
return { exists: false }
}
const result = await client.query(`
SELECT
COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE data_quality_status = 'READY')::integer AS ready,
COUNT(*) FILTER (WHERE data_quality_status = 'INCOMPLETE')::integer AS incomplete
FROM external.intake_sku_items_v1
`)
return {
exists: true,
...result.rows[0],
}
}
async function getExternalReadyViewSummary(client) {
const productsExists = await relationExists(client, 'external.intake_products_ready_v1')
const skuItemsExists = await relationExists(client, 'external.intake_sku_items_ready_v1')
if (!productsExists || !skuItemsExists) {
return { exists: false, productsExists, skuItemsExists }
}
const result = await client.query(`
SELECT
(SELECT COUNT(*)::integer FROM external.intake_products_ready_v1) AS ready_products,
(SELECT COUNT(*)::integer FROM external.intake_sku_items_ready_v1) AS ready_sku_items,
(
SELECT COUNT(*)::integer
FROM external.intake_sku_items_ready_v1
WHERE data_quality_status <> 'READY'
) AS ready_sku_quality_violations,
(
SELECT COUNT(*)::integer
FROM external.intake_products_ready_v1
WHERE NULLIF(btrim(COALESCE(product_business_key, '')), '') IS NULL
) AS ready_product_business_key_missing,
(
SELECT COUNT(*)::integer
FROM external.intake_sku_items_ready_v1
WHERE NULLIF(btrim(COALESCE(product_business_key, '')), '') IS NULL
OR NULLIF(btrim(COALESCE(sku_business_key, '')), '') IS NULL
) AS ready_sku_business_key_missing,
(
SELECT COUNT(*)::integer
FROM (
SELECT product_business_key
FROM external.intake_products_ready_v1
GROUP BY product_business_key
HAVING COUNT(*) > 1
) duplicate_groups
) AS ready_product_business_key_duplicate_groups,
(
SELECT COUNT(*)::integer
FROM (
SELECT sku_business_key
FROM external.intake_sku_items_ready_v1
GROUP BY sku_business_key
HAVING COUNT(*) > 1
) duplicate_groups
) AS ready_sku_business_key_duplicate_groups
`)
return {
exists: true,
...result.rows[0],
}
}
async function getProductSkuAssetProfileSummary(client) {
const result = await client.query(`
SELECT
COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE "sourceStatus" = 'ACTIVE')::integer AS active,
COUNT(*) FILTER (WHERE "sourceStatus" = 'DELETED')::integer AS deleted,
COUNT(*) FILTER (WHERE "sourceStatus" = 'MISSING')::integer AS missing,
COUNT(*) FILTER (
WHERE EXISTS (
SELECT 1
FROM unnest("lockedFields") AS locked_field(field_name)
WHERE NULLIF(btrim(locked_field.field_name), '') IS NULL
OR locked_field.field_name IS DISTINCT FROM btrim(locked_field.field_name)
OR btrim(locked_field.field_name) <> ALL (${PRODUCT_SKU_ASSET_PROFILE_LOCKABLE_FIELD_SQL_ARRAY})
)
)::integer AS invalid_locked_fields,
COUNT(*) FILTER (
WHERE jsonb_typeof("lockedReasons"::jsonb) <> 'object'
OR EXISTS (
SELECT 1
FROM jsonb_each("lockedReasons"::jsonb) AS locked_reason(field_name, reason_value)
WHERE NULLIF(btrim(locked_reason.field_name), '') IS NULL
OR locked_reason.field_name IS DISTINCT FROM btrim(locked_reason.field_name)
OR btrim(locked_reason.field_name) <> ALL (${PRODUCT_SKU_ASSET_PROFILE_LOCKABLE_FIELD_SQL_ARRAY})
OR jsonb_typeof(locked_reason.reason_value) <> 'string'
OR NULLIF(btrim(locked_reason.reason_value #>> '{}'), '') IS NULL
)
)::integer AS invalid_locked_reasons
FROM "ProductSkuAssetProfile"
`)
return result.rows[0]
}
async function getAssetSkuSyncEventSummary(client) {
const result = await client.query(`
SELECT
COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE "status" = 'PENDING')::integer AS pending,
COUNT(*) FILTER (WHERE "status" = 'PROCESSING')::integer AS processing,
COUNT(*) FILTER (WHERE "status" = 'COMPLETED')::integer AS completed,
COUNT(*) FILTER (WHERE "status" = 'FAILED')::integer AS failed,
COUNT(*) FILTER (
WHERE "status" IN ('PENDING', 'PROCESSING')
AND COALESCE("lockedAt", "createdAt") < NOW() - INTERVAL '15 minutes'
)::integer AS stale_pending_or_processing,
COUNT(*) FILTER (
WHERE "status" = 'COMPLETED'
AND "failedCount" > 0
)::integer AS completed_with_failures
FROM "AssetSkuSyncEvent"
`)
return result.rows[0]
}
async function getProductSkuWithoutAssetProfileCount(client) {
const result = await client.query(`
SELECT COUNT(*)::integer AS row_count
FROM "ProductSKU" sku
JOIN "Product" product ON product."id" = sku."productId"
LEFT JOIN "ProductSkuAssetProfile" profile ON profile."skuId" = sku."id"
WHERE sku."deletedAt" IS NULL
AND product."deletedAt" IS NULL
AND jsonb_typeof(sku."attributes"::jsonb -> 'assetSkuSnapshot') = 'object'
AND profile."id" IS NULL
`)
return result.rows[0]?.row_count ?? 0
}
async function getProductSkuMissingNameCount(client) {
const result = await client.query(`
SELECT COUNT(*)::integer AS row_count
FROM "ProductSKU" sku
JOIN "Product" product ON product."id" = sku."productId"
WHERE sku."deletedAt" IS NULL
AND product."deletedAt" IS NULL
AND NULLIF(btrim(COALESCE(sku."name", '')), '') IS NULL
`)
return result.rows[0]?.row_count ?? 0
}
async function getProductSkuPriceCostStockRiskCount(client) {
const result = await client.query(`
SELECT COUNT(*)::integer AS row_count
FROM "ProductSKU"
WHERE "price" < 0
OR ("costPrice" IS NOT NULL AND "costPrice" < 0)
OR "stock" < 0
`)
return result.rows[0]?.row_count ?? 0
}
async function getProductSkuFormalTruthAttributeRiskCount(client) {
const result = await client.query(`
SELECT COUNT(*)::integer AS row_count
FROM "ProductSKU"
WHERE attributes ?| ARRAY[
'skuName',
'model',
'specCode',
'barcode',
'category',
'color',
'material',
'size'
]
`)
return result.rows[0]?.row_count ?? 0
}
async function getProductSkuProfileSnapshotDiffCount(client) {
const result = await client.query(`
SELECT COUNT(*)::integer AS row_count
FROM "ProductSKU" sku
JOIN "Product" product ON product."id" = sku."productId"
JOIN "ProductSkuAssetProfile" profile ON profile."skuId" = sku."id"
WHERE sku."deletedAt" IS NULL
AND product."deletedAt" IS NULL
AND jsonb_typeof(sku."attributes"::jsonb -> 'assetSkuSnapshot') = 'object'
AND (
COALESCE(profile."model", '') IS DISTINCT FROM COALESCE(sku."attributes"::jsonb -> 'assetSkuSnapshot' ->> 'model', '')
OR COALESCE(profile."specCode", '') IS DISTINCT FROM COALESCE(sku."attributes"::jsonb -> 'assetSkuSnapshot' ->> 'specCode', '')
OR COALESCE(profile."barcode", '') IS DISTINCT FROM COALESCE(sku."attributes"::jsonb -> 'assetSkuSnapshot' ->> 'barcode', '')
OR COALESCE(profile."imageUrl", '') IS DISTINCT FROM COALESCE(sku."attributes"::jsonb -> 'assetSkuSnapshot' ->> 'imageUrl', '')
)
`)
return result.rows[0]?.row_count ?? 0
}
async function getProductSkuProfileDuplicateBarcodeGroupCount(client) {
const result = await client.query(`
SELECT COUNT(*)::integer AS duplicate_groups
FROM (
SELECT upper(btrim(profile."barcode")) AS barcode
FROM "ProductSkuAssetProfile" profile
JOIN "ProductSKU" sku ON sku."id" = profile."skuId"
JOIN "Product" product ON product."id" = sku."productId"
WHERE sku."deletedAt" IS NULL
AND product."deletedAt" IS NULL
AND profile."sourceStatus" = 'ACTIVE'
AND NULLIF(btrim(COALESCE(profile."barcode", '')), '') IS NOT NULL
GROUP BY upper(btrim(profile."barcode"))
HAVING COUNT(*) > 1
) duplicate_groups
`)
return result.rows[0]?.duplicate_groups ?? 0
}
async function getProductSkuProfileDeletedAtMismatchCount(client) {
const result = await client.query(`
SELECT COUNT(*)::integer AS row_count
FROM "ProductSkuAssetProfile" profile
JOIN "ProductSKU" sku ON sku."id" = profile."skuId"
WHERE profile."skuDeletedAt" IS DISTINCT FROM sku."deletedAt"
`)
return result.rows[0]?.row_count ?? 0
}
async function getProductSkuSoftDeleteTombstoneRiskCount(client) {
const result = await client.query(`
SELECT COUNT(*)::integer AS row_count
FROM "ProductSKU"
WHERE "deletedAt" IS NOT NULL
AND (
"deletedCodeAt" IS NULL
OR (
("deletedByProductId" IS NULL AND "productDeletedAt" IS NOT NULL)
OR ("deletedByProductId" IS NOT NULL AND "productDeletedAt" IS NULL)
OR ("deletedByProductId" IS NOT NULL AND NULLIF(btrim("deletedByProductId"), '') IS NULL)
)
)
`)
return result.rows[0]?.row_count ?? 0
}
async function getPreSkuMissingBasicInfoCount(client) {
const result = await client.query(`
SELECT COUNT(*)::integer AS row_count
FROM "PreSku" pre_sku
LEFT JOIN "PreSkuBasicInfo" basic_info ON basic_info."preSkuId" = pre_sku."id"
WHERE basic_info."id" IS NULL
AND (
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'model', pre_sku."familySelection"::jsonb ->> 'model', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'formalSkuCode', pre_sku."familySelection"::jsonb ->> 'formalSkuCode', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'barcode', pre_sku."familySelection"::jsonb ->> 'barcode', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'specName', pre_sku."familySelection"::jsonb ->> 'specName', '')), '') IS NOT NULL
)
`)
return result.rows[0]?.row_count ?? 0
}
async function getPreSkuBasicInfoSnapshotDiffCount(client) {
const result = await client.query(`
SELECT COUNT(*)::integer AS row_count
FROM "PreSku" pre_sku
JOIN "PreSkuBasicInfo" basic_info ON basic_info."preSkuId" = pre_sku."id"
WHERE COALESCE(basic_info."model", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'model', pre_sku."familySelection"::jsonb ->> 'model', '')), ''), '')
OR COALESCE(basic_info."formalSkuCode", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'formalSkuCode', pre_sku."familySelection"::jsonb ->> 'formalSkuCode', '')), ''), '')
OR COALESCE(basic_info."barcode", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'barcode', pre_sku."familySelection"::jsonb ->> 'barcode', '')), ''), '')
OR COALESCE(basic_info."specName", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'specName', pre_sku."familySelection"::jsonb ->> 'specName', '')), ''), '')
`)
return result.rows[0]?.row_count ?? 0
}
async function getPreSkuBasicInfoDuplicateGroupCount(client, columnName) {
const result = await client.query(`
SELECT COUNT(*)::integer AS duplicate_groups
FROM (
SELECT upper(btrim(basic_info."${columnName}")) AS normalized_value
FROM "PreSkuBasicInfo" basic_info
JOIN "PreSku" pre_sku ON pre_sku."id" = basic_info."preSkuId"
WHERE pre_sku."status" <> 'ARCHIVED'
AND NULLIF(btrim(basic_info."${columnName}"), '') IS NOT NULL
GROUP BY upper(btrim(basic_info."${columnName}"))
HAVING COUNT(*) > 1
) duplicate_groups
`)
return result.rows[0]?.duplicate_groups ?? 0
}
async function getDraftGroupInvalidProcessShapeCount(client) {
const result = await client.query(`
SELECT COUNT(*)::integer AS row_count
FROM "DraftGroup"
WHERE NULLIF(btrim(code), '') IS NULL
OR code <> btrim(code)
OR NULLIF(btrim(name), '') IS NULL
OR name <> btrim(name)
OR jsonb_typeof(summary::jsonb) <> 'object'
OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL)
OR (notes IS NOT NULL AND NULLIF(btrim(notes), '') IS NULL)
`)
return result.rows[0]?.row_count ?? 0
}
async function getPreSkuInvalidProcessShapeCount(client) {
const result = await client.query(`
SELECT COUNT(*)::integer AS row_count
FROM "PreSku"
WHERE sequence <= 0
OR NULLIF(btrim("defaultName"), '') IS NULL
OR "defaultName" <> btrim("defaultName")
OR NULLIF(btrim("sourceCandidateKey"), '') IS NULL
OR "sourceCandidateKey" <> btrim("sourceCandidateKey")
OR jsonb_typeof("familySelection"::jsonb) <> 'object'
OR jsonb_typeof("variantSelection"::jsonb) <> 'object'
OR jsonb_typeof("previewNodes"::jsonb) <> 'array'
OR jsonb_typeof("displaySummary"::jsonb) <> 'object'
OR jsonb_typeof("bomPreview"::jsonb) <> 'array'
OR (
"recommendedHardware" IS NOT NULL
AND jsonb_typeof("recommendedHardware"::jsonb) NOT IN ('array', 'null')
)
OR (
"explosionPreview" IS NOT NULL
AND jsonb_typeof("explosionPreview"::jsonb) NOT IN ('array', 'null')
)
OR jsonb_typeof("installStepPreview"::jsonb) <> 'array'
OR (
"packagingSnapshot" IS NOT NULL
AND jsonb_typeof("packagingSnapshot"::jsonb) NOT IN ('object', 'null')
)
OR ("weightPreview" IS NOT NULL AND "weightPreview" < 0)
OR EXISTS (
SELECT 1
FROM unnest("componentCodes") AS item
WHERE NULLIF(btrim(item), '') IS NULL
)
OR ("customName" IS NOT NULL AND NULLIF(btrim("customName"), '') IS NULL)
OR (notes IS NOT NULL AND NULLIF(btrim(notes), '') IS NULL)
`)
return result.rows[0]?.row_count ?? 0
}
async function getPreSkuFormalizationLifecycleMismatchCount(client) {
const result = await client.query(`
SELECT COUNT(*)::integer AS row_count
FROM (
SELECT pre_sku."id"
FROM "PreSku" pre_sku
LEFT JOIN "ProductSKU" sku ON sku."sourcePreSkuId" = pre_sku."id"
WHERE pre_sku.status = 'FORMALIZED'::"PreSkuStatus"
AND sku."id" IS NULL
UNION ALL
SELECT pre_sku."id"
FROM "ProductSKU" sku
JOIN "PreSku" pre_sku ON pre_sku."id" = sku."sourcePreSkuId"
WHERE sku."deletedAt" IS NULL
AND pre_sku.status <> 'FORMALIZED'::"PreSkuStatus"
) mismatches
`)
return result.rows[0]?.row_count ?? 0
}
async function getPreSkuFormalizationLifecycleMismatchSamples(client) {
const result = await client.query(`
SELECT
mismatches."mismatchReason",
mismatches."preSkuId",
draft_group."id" AS "draftGroupId",
draft_group."code" AS "draftGroupCode",
pre_sku."sequence",
pre_sku."status"::text AS "status",
mismatches."linkedFormalSkuId",
mismatches."linkedSkuCode",
mismatches."linkedSkuDeletedAt",
mismatches."linkedProductId",
mismatches."linkedProductDeletedAt"
FROM (
SELECT
'formalized_without_product_sku' AS "mismatchReason",
pre_sku."id" AS "preSkuId",
NULL::text AS "linkedFormalSkuId",
NULL::text AS "linkedSkuCode",
NULL::timestamp AS "linkedSkuDeletedAt",
NULL::text AS "linkedProductId",
NULL::timestamp AS "linkedProductDeletedAt"
FROM "PreSku" pre_sku
LEFT JOIN "ProductSKU" sku ON sku."sourcePreSkuId" = pre_sku."id"
WHERE pre_sku.status = 'FORMALIZED'::"PreSkuStatus"
AND sku."id" IS NULL
UNION ALL
SELECT
'active_product_sku_points_to_unformalized_pre_sku' AS "mismatchReason",
pre_sku."id" AS "preSkuId",
sku."id" AS "linkedFormalSkuId",
sku."skuCode" AS "linkedSkuCode",
sku."deletedAt" AS "linkedSkuDeletedAt",
product."id" AS "linkedProductId",
product."deletedAt" AS "linkedProductDeletedAt"
FROM "ProductSKU" sku
JOIN "PreSku" pre_sku ON pre_sku."id" = sku."sourcePreSkuId"
JOIN "Product" product ON product."id" = sku."productId"
WHERE sku."deletedAt" IS NULL
AND pre_sku.status <> 'FORMALIZED'::"PreSkuStatus"
) mismatches
JOIN "PreSku" pre_sku ON pre_sku."id" = mismatches."preSkuId"
JOIN "DraftGroup" draft_group ON draft_group."id" = pre_sku."draftGroupId"
ORDER BY draft_group."code", pre_sku."sequence", mismatches."mismatchReason"
LIMIT 20
`)
return result.rows
}
async function getNonIntegratedProductProfileSummary(client) {
const result = await client.query(`
SELECT
COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE NULLIF(btrim(profile."productName"), '') IS NULL)::integer AS missing_product_name,
COUNT(*) FILTER (WHERE NULLIF(btrim(profile."productCategory"), '') IS NULL)::integer AS missing_product_category,
COUNT(*) FILTER (WHERE NULLIF(btrim(profile."productDevelopmentKind"), '') IS NULL)::integer AS missing_development_kind
FROM "IntakeNonIntegratedProductProfile" profile
JOIN "IntakeNonIntegratedProduct" product ON product."id" = profile."productId"
WHERE product."deletedAt" IS NULL
`)
return result.rows[0]
}
async function getNonIntegratedProductProfileQualitySamples(client) {
const result = await client.query(`
SELECT
product."id",
product."spu",
profile."productName",
profile."productCategory",
profile."productDevelopmentKind",
ARRAY_REMOVE(ARRAY[
CASE WHEN NULLIF(btrim(profile."productName"), '') IS NULL THEN 'productName' END,
CASE WHEN NULLIF(btrim(profile."productCategory"), '') IS NULL THEN 'productCategory' END,
CASE WHEN NULLIF(btrim(profile."productDevelopmentKind"), '') IS NULL THEN 'productDevelopmentKind' END
], NULL) AS "missingFields"
FROM "IntakeNonIntegratedProduct" product
JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id"
WHERE product."deletedAt" IS NULL
AND (
NULLIF(btrim(profile."productName"), '') IS NULL
OR NULLIF(btrim(profile."productCategory"), '') IS NULL
OR NULLIF(btrim(profile."productDevelopmentKind"), '') IS NULL
)
ORDER BY product."spu", product."id"
LIMIT 20
`)
return result.rows
}
async function getNonIntegratedProductMissingProfileCount(client) {
const result = await client.query(`
SELECT COUNT(*)::integer AS row_count
FROM "IntakeNonIntegratedProduct" product
LEFT JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id"
WHERE product."deletedAt" IS NULL
AND profile."id" IS NULL
`)
return result.rows[0]?.row_count ?? 0
}
async function getNonIntegratedProductProfilePayloadDiffCount(client) {
const result = await client.query(`
WITH payload_profile AS (
SELECT
product."id" AS product_id,
COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'productName'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'previewImageName'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'spu'), ''), '') AS product_name,
COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'productImage'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'previewImage'), ''), '') AS product_image_url,
COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'productCategory'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'productCategory'), ''), '') AS product_category,
COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'salesCategory'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'salesCategory'), ''), '') AS sales_category,
COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'year'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'year'), ''), '') AS product_year,
COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'currentStage'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'projectStatus'), ''), '') AS product_status,
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'productDevelopmentKind'), ''), '') AS product_development_kind,
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'goodsType'), ''), '') AS goods_type,
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'materialType'), ''), '') AS material_type,
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'productionType'), ''), '') AS production_type,
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'size'), ''), NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'size'), ''), '') AS size,
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'color'), ''), '') AS color,
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'tagMaterial'), ''), '') AS tag_material,
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'priceBand'), ''), NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'targetPriceBand'), ''), '') AS price_band,
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'demandFlowNo'), ''), '') AS demand_flow_no,
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'processTemplate'), ''), '') AS process_template,
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'productPlanning'), ''), NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'planningOwner'), ''), '') AS product_planning,
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'brandPlanning'), ''), '') AS brand_planning,
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'demandDepartment'), ''), '') AS demand_department,
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'spuOwner'), ''), '') AS spu_owner,
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'operationsOwner'), ''), '') AS operations_owner,
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'purchaseOwner'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'purchaser'), ''), '') AS purchase_owner,
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'visualDirector'), ''), '') AS visual_director,
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'visualPlanning'), ''), '') AS visual_planning,
COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'structureDesigner'), ''), '') AS structure_designer
FROM "IntakeNonIntegratedProduct" product
WHERE product."deletedAt" IS NULL
)
SELECT COUNT(*)::integer AS row_count
FROM payload_profile payload
JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = payload.product_id
WHERE COALESCE(profile."productName", '') IS DISTINCT FROM payload.product_name
OR COALESCE(profile."productImageUrl", '') IS DISTINCT FROM payload.product_image_url
OR COALESCE(profile."productCategory", '') IS DISTINCT FROM payload.product_category
OR COALESCE(profile."salesCategory", '') IS DISTINCT FROM payload.sales_category
OR COALESCE(profile."productYear", '') IS DISTINCT FROM payload.product_year
OR COALESCE(profile."productStatus", '') IS DISTINCT FROM payload.product_status
OR COALESCE(profile."productDevelopmentKind", '') IS DISTINCT FROM payload.product_development_kind
OR COALESCE(profile."goodsType", '') IS DISTINCT FROM payload.goods_type
OR COALESCE(profile."materialType", '') IS DISTINCT FROM payload.material_type
OR COALESCE(profile."productionType", '') IS DISTINCT FROM payload.production_type
OR COALESCE(profile."size", '') IS DISTINCT FROM payload.size
OR COALESCE(profile."color", '') IS DISTINCT FROM payload.color
OR COALESCE(profile."tagMaterial", '') IS DISTINCT FROM payload.tag_material
OR COALESCE(profile."priceBand", '') IS DISTINCT FROM payload.price_band
OR COALESCE(profile."demandFlowNo", '') IS DISTINCT FROM payload.demand_flow_no
OR COALESCE(profile."processTemplate", '') IS DISTINCT FROM payload.process_template
OR COALESCE(profile."productPlanning", '') IS DISTINCT FROM payload.product_planning
OR COALESCE(profile."brandPlanning", '') IS DISTINCT FROM payload.brand_planning
OR COALESCE(profile."demandDepartment", '') IS DISTINCT FROM payload.demand_department
OR COALESCE(profile."spuOwner", '') IS DISTINCT FROM payload.spu_owner
OR COALESCE(profile."operationsOwner", '') IS DISTINCT FROM payload.operations_owner
OR COALESCE(profile."purchaseOwner", '') IS DISTINCT FROM payload.purchase_owner
OR COALESCE(profile."visualDirector", '') IS DISTINCT FROM payload.visual_director
OR COALESCE(profile."visualPlanning", '') IS DISTINCT FROM payload.visual_planning
OR COALESCE(profile."structureDesigner", '') IS DISTINCT FROM payload.structure_designer
`)
return result.rows[0]?.row_count ?? 0
}
async function getNonIntegratedProductStockSourcePayloadDiffCount(client) {
const result = await client.query(`
WITH payload_stock_source AS (
SELECT
product."id" AS product_id,
COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSpu'), ''), '') AS source_spu,
COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSkuId'), ''), '') AS source_sku_id,
COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceCategory'), ''), '') AS source_category,
COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceItemType'), ''), '') AS source_item_type,
COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSalesStatus'), ''), '') AS source_sales_status,
COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceEnabled'), ''), '') AS source_enabled,
COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceStockDisabled'), ''), '') AS source_stock_disabled,
CASE
WHEN jsonb_typeof(product."payload"::jsonb -> 'skuItems') = 'array'
THEN jsonb_array_length(product."payload"::jsonb -> 'skuItems')
ELSE 0
END AS stock_sku_count,
COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSalesStatus'), ''), '') = '销售' AS stock_has_on_sale_sku
FROM "IntakeNonIntegratedProduct" product
WHERE product."deletedAt" IS NULL
)
SELECT COUNT(*)::integer AS row_count
FROM payload_stock_source payload
JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = payload.product_id
WHERE COALESCE(profile."sourceSpu", '') IS DISTINCT FROM payload.source_spu
OR COALESCE(profile."sourceSkuId", '') IS DISTINCT FROM payload.source_sku_id
OR COALESCE(profile."sourceCategory", '') IS DISTINCT FROM payload.source_category
OR COALESCE(profile."sourceItemType", '') IS DISTINCT FROM payload.source_item_type
OR COALESCE(profile."sourceSalesStatus", '') IS DISTINCT FROM payload.source_sales_status
OR COALESCE(profile."sourceEnabled", '') IS DISTINCT FROM payload.source_enabled
OR COALESCE(profile."sourceStockDisabled", '') IS DISTINCT FROM payload.source_stock_disabled
OR COALESCE(profile."stockSkuCount", 0) IS DISTINCT FROM payload.stock_sku_count
OR COALESCE(profile."stockHasOnSaleSku", false) IS DISTINCT FROM payload.stock_has_on_sale_sku
`)
return result.rows[0]?.row_count ?? 0
}
async function getExternalProductSkusSummary(client) {
const exists = await relationExists(client, 'external.product_skus_v1')
if (!exists) {
return { exists: false }
}
const result = await client.query(`
SELECT
COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE NULLIF(btrim(COALESCE(sku_code, '')), '') IS NULL)::integer AS missing_sku_code,
COUNT(*) FILTER (WHERE NULLIF(btrim(COALESCE(sku_name, '')), '') IS NULL)::integer AS missing_sku_name,
COUNT(*) FILTER (WHERE NULLIF(btrim(COALESCE(spec_code, '')), '') IS NULL)::integer AS missing_spec_code,
COUNT(*) FILTER (WHERE NULLIF(btrim(COALESCE(barcode, '')), '') IS NULL)::integer AS missing_barcode,
COUNT(*) FILTER (WHERE NULLIF(btrim(COALESCE(product_business_key, '')), '') IS NULL)::integer AS missing_product_business_key,
COUNT(*) FILTER (WHERE NULLIF(btrim(COALESCE(sku_business_key, '')), '') IS NULL)::integer AS missing_sku_business_key,
(
SELECT COUNT(*)::integer
FROM (
SELECT sku_business_key
FROM external.product_skus_v1
GROUP BY sku_business_key
HAVING COUNT(*) > 1
) duplicate_groups
) AS duplicate_sku_business_key_groups
FROM external.product_skus_v1
`)
return {
exists: true,
...result.rows[0],
}
}
async function getExternalProductDetailViewsSummary(client) {
const productAssetsExists = await relationExists(client, 'external.product_assets_v1')
const productBomExists = await relationExists(client, 'external.product_bom_v1')
if (!productAssetsExists || !productBomExists) {
return { exists: false, productAssetsExists, productBomExists }
}
const result = await client.query(`
SELECT
(SELECT COUNT(*)::integer FROM external.product_assets_v1) AS product_assets_total,
(
SELECT COUNT(*)::integer
FROM external.product_assets_v1
WHERE NULLIF(btrim(COALESCE(product_business_key, '')), '') IS NULL
) AS product_assets_missing_product_business_key,
(
SELECT COUNT(*)::integer
FROM external.product_assets_v1
WHERE NULLIF(btrim(COALESCE(sku_business_key, '')), '') IS NULL
) AS product_assets_missing_sku_business_key,
(SELECT COUNT(*)::integer FROM external.product_bom_v1) AS product_bom_total,
(
SELECT COUNT(*)::integer
FROM external.product_bom_v1
WHERE NULLIF(btrim(COALESCE(product_business_key, '')), '') IS NULL
) AS product_bom_missing_product_business_key,
(
SELECT COUNT(*)::integer
FROM external.product_bom_v1
WHERE sku_id IS NOT NULL
AND NULLIF(btrim(COALESCE(sku_business_key, '')), '') IS NULL
) AS product_bom_missing_sku_business_key_with_sku_id
`)
return {
exists: true,
...result.rows[0],
}
}
async function getProductPatentRecordSummary(client) {
const result = await client.query(`
SELECT
COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE NULLIF(btrim("productId"), '') IS NULL)::integer AS missing_product_id,
COUNT(*) FILTER (WHERE NULLIF(btrim("patentGroupKey"), '') IS NULL)::integer AS missing_group_key,
COUNT(*) FILTER (WHERE NULLIF(btrim("patentName"), '') IS NULL AND NULLIF(btrim("patentNo"), '') IS NULL AND NULLIF(btrim("applicationNo"), '') IS NULL)::integer AS missing_patent_identity,
COUNT(*) FILTER (WHERE btrim(id) ~ '^patent-[0-9]+-[A-Za-z0-9]+$')::integer AS temporary_id_count
FROM "ProductPatentRecord"
`)
return result.rows[0]
}
async function getIntakeReferenceLibrarySummary(client) {
const result = await client.query(`
SELECT
COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE "isActive" = true)::integer AS active,
COUNT(*) FILTER (WHERE NULLIF(btrim("itemKey"), '') IS NULL)::integer AS missing_item_key,
COUNT(*) FILTER (WHERE NULLIF(btrim("code"), '') IS NULL)::integer AS missing_code
FROM "IntakeReferenceLibraryItem"
`)
return result.rows[0]
}
async function getSpuNumberCounterSummary(client) {
const exists = await relationExists(client, '"SpuNumberCounter"')
if (!exists) {
return { exists: false }
}
const result = await client.query(`
SELECT
COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE NULLIF(btrim("productCategory"), '') IS NULL)::integer AS missing_product_category,
COUNT(*) FILTER (WHERE NULLIF(btrim("prefix"), '') IS NULL)::integer AS missing_prefix,
(
SELECT COUNT(*)::integer
FROM (
SELECT upper(btrim("prefix")) AS normalized_prefix
FROM "SpuNumberCounter"
WHERE NULLIF(btrim("prefix"), '') IS NOT NULL
GROUP BY upper(btrim("prefix"))
HAVING COUNT(*) > 1
) duplicate_prefixes
) AS duplicate_prefix_groups,
COUNT(*) FILTER (WHERE "lastUsedIndex" < 0)::integer AS invalid_index
FROM "SpuNumberCounter"
`)
return {
exists: true,
...result.rows[0],
}
}
async function getExternalViewJsonSourceDependencyRisks(client) {
const result = await client.query(`
SELECT
viewname AS view_name,
CASE
WHEN pg_get_viewdef(format('%I.%I', schemaname, viewname)::regclass) ~ '"payload"' THEN 'payload'
WHEN pg_get_viewdef(format('%I.%I', schemaname, viewname)::regclass) ~ '"attributes"' THEN 'attributes'
WHEN pg_get_viewdef(format('%I.%I', schemaname, viewname)::regclass) ~ '"businessData"' THEN 'businessData'
WHEN pg_get_viewdef(format('%I.%I', schemaname, viewname)::regclass) ~ '"displaySummary"' THEN 'displaySummary'
WHEN pg_get_viewdef(format('%I.%I', schemaname, viewname)::regclass) ~ '"familySelection"' THEN 'familySelection'
ELSE 'json_operator'
END AS dependency_hint
FROM pg_views
WHERE schemaname = 'external'
AND (
pg_get_viewdef(format('%I.%I', schemaname, viewname)::regclass) ~ '"(payload|attributes|businessData|displaySummary|familySelection)"'
OR pg_get_viewdef(format('%I.%I', schemaname, viewname)::regclass) ~ '(->>|->|jsonb_array_elements)'
)
ORDER BY viewname, dependency_hint
`)
return result.rows
}
async function getExternalViewIdFallbackRisks(client) {
const result = await client.query(`
SELECT
viewname AS view_name,
pg_get_viewdef(format('%I.%I', schemaname, viewname)::regclass) AS view_definition
FROM pg_views
WHERE schemaname = 'external'
ORDER BY viewname
`)
const risks = []
for (const row of result.rows) {
const viewDefinition = normalizeSqlFragment(row.view_definition)
const aliasPattern = /\bAS\s+([a-z_]+(?:_code|_business_key))\b/gi
for (const match of viewDefinition.matchAll(aliasPattern)) {
const aliasStart = match.index ?? 0
const expressionStart = Math.max(
viewDefinition.lastIndexOf(',', aliasStart),
viewDefinition.lastIndexOf(' SELECT ', aliasStart),
viewDefinition.lastIndexOf(' UNION ALL SELECT ', aliasStart),
)
const expression = viewDefinition.slice(expressionStart + 1, aliasStart).trim()
if (!/(?:^|[^\w])(?:[a-z_]+\.)?"?id"?(?:[^\w]|$)/i.test(expression)) {
continue
}
risks.push({
view_name: row.view_name,
column_name: match[1],
risk: 'id_fallback_in_business_column',
expression,
})
}
}
return risks
}
async function getExternalIntakeProductPeopleSourceRisks(client) {
const exists = await relationExists(client, 'external.intake_products_v1')
if (!exists) {
return [{ view_name: 'intake_products_v1', column_name: '*', risk: 'missing_view' }]
}
const result = await client.query(`
SELECT pg_get_viewdef('external.intake_products_v1'::regclass) AS view_definition
`)
const viewDefinition = normalizeSqlFragment(result.rows[0]?.view_definition)
const requiredSources = [
{
columnName: 'designer',
sourcePattern: /profile\."structureDesigner"/,
risk: 'designer_must_prefer_profile_structure_designer',
},
{
columnName: 'operator',
sourcePattern: /profile\."productPlanning"/,
risk: 'operator_must_prefer_profile_product_planning',
},
{
columnName: 'purchaser',
sourcePattern: /profile\."purchaseOwner"/,
risk: 'purchaser_must_prefer_profile_purchase_owner',
},
]
return requiredSources
.filter((source) => !source.sourcePattern.test(viewDefinition))
.map((source) => ({
view_name: 'intake_products_v1',
column_name: source.columnName,
risk: source.risk,
}))
}
async function getLegacyCadStructureSummary(client) {
const result = await client.query(`
WITH panel_duplicate_code_scope_risk AS (
SELECT COUNT(*)::integer AS row_count
FROM (
SELECT "productId", COALESCE("skuId", '') AS sku_scope, upper(btrim(code)) AS normalized_code
FROM "Panel"
GROUP BY "productId", COALESCE("skuId", ''), upper(btrim(code))
HAVING COUNT(*) > 1
) duplicate_panel_codes
)
SELECT
(
SELECT COUNT(*)::integer
FROM "Panel"
WHERE NULLIF(btrim(code), '') IS NULL
OR code <> upper(btrim(code))
OR NULLIF(btrim(name), '') IS NULL
OR name <> btrim(name)
OR NULLIF(btrim("gltfNodeName"), '') IS NULL
OR "gltfNodeName" <> btrim("gltfNodeName")
OR length <= 0
OR width <= 0
OR thickness <= 0
OR COALESCE("installOrder", 0) < 0
OR ("partType" IS NOT NULL AND NULLIF(btrim("partType"), '') IS NULL)
OR ("explosionDirection" IS NOT NULL AND "explosionDirection" NOT IN ('auto', 'up', 'down', 'left', 'right', 'front', 'back'))
OR ("assemblySemantic" IS NOT NULL AND jsonb_typeof("assemblySemantic") NOT IN ('object', 'null'))
) AS panel_identity_dimension_risk,
(SELECT row_count FROM panel_duplicate_code_scope_risk) AS panel_duplicate_code_scope_risk,
(
SELECT COUNT(*)::integer
FROM "Hole"
WHERE "positionX" IN ('Infinity'::float8, '-Infinity'::float8, 'NaN'::float8)
OR "positionY" IN ('Infinity'::float8, '-Infinity'::float8, 'NaN'::float8)
OR "positionZ" IN ('Infinity'::float8, '-Infinity'::float8, 'NaN'::float8)
OR diameter <= 0
OR depth < 0
OR "normalX" IN ('Infinity'::float8, '-Infinity'::float8, 'NaN'::float8)
OR "normalY" IN ('Infinity'::float8, '-Infinity'::float8, 'NaN'::float8)
OR "normalZ" IN ('Infinity'::float8, '-Infinity'::float8, 'NaN'::float8)
OR ("normalX" = 0 AND "normalY" = 0 AND "normalZ" = 0)
OR confidence < 0
OR confidence > 1
OR ("holeType" IS NOT NULL AND "holeType" NOT IN ('hinge', 'connector', 'shelf_pin', 'screw', 'dowel', 'cable', 'unknown'))
OR ("groupId" IS NOT NULL AND NULLIF(btrim("groupId"), '') IS NULL)
) AS hole_geometry_quality_risk,
(
SELECT COUNT(*)::integer
FROM "ProductHardware"
WHERE jsonb_typeof(transform) <> 'object'
OR "matchStatus" NOT IN ('matched', 'pending', 'unmatched')
OR ("matchConfidence" IS NOT NULL AND ("matchConfidence" < 0 OR "matchConfidence" > 1))
OR ("matchMethod" IS NOT NULL AND "matchMethod" NOT IN ('blockName', 'mapping', 'size', 'manual'))
OR ("installOrder" IS NOT NULL AND "installOrder" < 0)
OR ("holeDistance" IS NOT NULL AND "holeDistance" < 0)
OR ("sourceSignature" IS NOT NULL AND jsonb_typeof("sourceSignature") NOT IN ('object', 'null'))
OR ("sourceBlockName" IS NOT NULL AND NULLIF(btrim("sourceBlockName"), '') IS NULL)
OR (notes IS NOT NULL AND NULLIF(btrim(notes), '') IS NULL)
OR ("panelId" IS NOT NULL AND NULLIF(btrim("panelId"), '') IS NULL)
OR ("connectedPanelId" IS NOT NULL AND NULLIF(btrim("connectedPanelId"), '') IS NULL)
OR ("associatedHoleId" IS NOT NULL AND NULLIF(btrim("associatedHoleId"), '') IS NULL)
OR ("parentHardwareId" IS NOT NULL AND NULLIF(btrim("parentHardwareId"), '') IS NULL)
) AS product_hardware_quality_risk,
(
SELECT COUNT(*)::integer
FROM "AssemblyStep"
WHERE "order" < 0
OR NULLIF(btrim(title), '') IS NULL
OR title <> btrim(title)
OR "stepType" NOT IN ('panel', 'hardware', 'custom')
OR duration <= 0
OR NULLIF(btrim(easing), '') IS NULL
OR "cameraTransition" NOT IN ('smooth', 'cut', 'orbit')
OR jsonb_typeof(transform) <> 'object'
OR ("cameraPosition" IS NOT NULL AND jsonb_typeof("cameraPosition") NOT IN ('object', 'null'))
OR ("infoCardData" IS NOT NULL AND jsonb_typeof("infoCardData") NOT IN ('object', 'null'))
OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL)
OR ("panelId" IS NOT NULL AND NULLIF(btrim("panelId"), '') IS NULL)
OR EXISTS (SELECT 1 FROM unnest("hardwareIds") AS item WHERE NULLIF(btrim(item), '') IS NULL)
OR cardinality("hardwareIds") <> (SELECT COUNT(DISTINCT btrim(item))::integer FROM unnest("hardwareIds") AS item)
OR EXISTS (SELECT 1 FROM unnest("partIds") AS item WHERE NULLIF(btrim(item), '') IS NULL)
OR cardinality("partIds") <> (SELECT COUNT(DISTINCT btrim(item))::integer FROM unnest("partIds") AS item)
) AS assembly_step_quality_risk,
(
SELECT COUNT(*)::integer
FROM "AssemblyVersion"
WHERE version <= 0
OR jsonb_typeof(snapshot) <> 'array'
OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL)
OR ("createdBy" IS NOT NULL AND NULLIF(btrim("createdBy"), '') IS NULL)
) AS assembly_version_snapshot_risk
`)
return result.rows[0]
}
async function getReferenceOptionAndMappingSummary(client) {
const result = await client.query(`
WITH duplicate_reference_option_names AS (
SELECT COUNT(*)::integer AS row_count
FROM (
SELECT 'ProductCategory' AS table_name, lower(btrim(name)) AS normalized_name FROM "ProductCategory" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1
UNION ALL SELECT 'MaterialCategory', lower(btrim(name)) FROM "MaterialCategory" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1
UNION ALL SELECT 'HardwareCategory', lower(btrim(name)) FROM "HardwareCategory" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1
UNION ALL SELECT 'CommonComponentCategory', lower(btrim(name)) FROM "CommonComponentCategory" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1
UNION ALL SELECT 'SubstrateCategory', lower(btrim(name)) FROM "SubstrateCategory" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1
UNION ALL SELECT 'SubstrateEnvironmentGrade', lower(btrim(name)) FROM "SubstrateEnvironmentGrade" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1
UNION ALL SELECT 'SubstrateSurfaceOption', lower(btrim(name)) FROM "SubstrateSurfaceOption" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1
UNION ALL SELECT 'SubstrateSystemNameOption', lower(btrim(name)) FROM "SubstrateSystemNameOption" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1
UNION ALL SELECT 'SubstrateThicknessSpec', lower(btrim(name)) FROM "SubstrateThicknessSpec" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1
) duplicated
)
SELECT
(
SELECT COUNT(*)::integer
FROM (
SELECT name, "sortOrder", description, icon FROM "ProductCategory"
UNION ALL SELECT name, "sortOrder", NULL::text AS description, icon FROM "MaterialCategory"
UNION ALL SELECT name, "sortOrder", description, icon FROM "HardwareCategory"
UNION ALL SELECT name, "sortOrder", description, icon FROM "CommonComponentCategory"
UNION ALL SELECT name, "sortOrder", NULL::text AS description, NULL::text AS icon FROM "SubstrateCategory"
UNION ALL SELECT name, "sortOrder", NULL::text AS description, NULL::text AS icon FROM "SubstrateEnvironmentGrade"
UNION ALL SELECT name, "sortOrder", NULL::text AS description, NULL::text AS icon FROM "SubstrateSurfaceOption"
UNION ALL SELECT name, "sortOrder", NULL::text AS description, NULL::text AS icon FROM "SubstrateSystemNameOption"
) options
WHERE NULLIF(btrim(name), '') IS NULL
OR name <> btrim(name)
OR "sortOrder" < 0
OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL)
OR (icon IS NOT NULL AND NULLIF(btrim(icon), '') IS NULL)
) AS reference_option_identity_risk,
(SELECT row_count FROM duplicate_reference_option_names) AS duplicate_reference_option_name_risk,
(
SELECT COUNT(*)::integer
FROM "SubstrateThicknessSpec"
WHERE NULLIF(btrim(name), '') IS NULL
OR name <> btrim(name)
OR "sortOrder" < 0
OR "thicknessMm" <= 0
OR "sheetLengthMm" <= 0
OR "sheetWidthMm" <= 0
) AS substrate_thickness_spec_risk,
(
SELECT COUNT(*)::integer
FROM "HardwareMapping"
WHERE (
CASE WHEN NULLIF(btrim(COALESCE("blockName", '')), '') IS NOT NULL THEN 1 ELSE 0 END
+ CASE WHEN NULLIF(btrim(COALESCE("signatureHash", '')), '') IS NOT NULL THEN 1 ELSE 0 END
) <> 1
OR "usageCount" < 0
OR NULLIF(btrim("createdBy"), '') IS NULL
OR "createdBy" <> btrim("createdBy")
) AS hardware_mapping_identity_risk,
(
SELECT COUNT(*)::integer
FROM "SubstrateFinish"
WHERE NULLIF(btrim("substrateId"), '') IS NULL
OR NULLIF(btrim("finishId"), '') IS NULL
) AS substrate_finish_relation_risk,
(
SELECT COUNT(*)::integer
FROM "PanelMaterial"
WHERE NULLIF(btrim("panelId"), '') IS NULL
OR NULLIF(btrim("materialId"), '') IS NULL
) AS panel_material_relation_risk,
(
SELECT COUNT(*)::integer
FROM "AssetVariantLocationBinding"
WHERE NULLIF(btrim("assetVariantId"), '') IS NULL
OR NULLIF(btrim("warehouseLocationId"), '') IS NULL
) AS asset_variant_location_binding_risk,
(
SELECT COUNT(*)::integer
FROM "PreSkuLocationBinding"
WHERE NULLIF(btrim("preSkuId"), '') IS NULL
OR NULLIF(btrim("warehouseLocationId"), '') IS NULL
) AS pre_sku_location_binding_risk,
(
SELECT COUNT(*)::integer
FROM "PackagingPlan"
WHERE NULLIF(btrim(name), '') IS NULL
OR name <> btrim(name)
OR "boxCount" <= 0
OR jsonb_typeof(boxes) <> 'array'
OR ("totalWeight" IS NOT NULL AND "totalWeight" < 0)
OR ("totalVolume" IS NOT NULL AND "totalVolume" < 0)
OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL)
OR (notes IS NOT NULL AND NULLIF(btrim(notes), '') IS NULL)
) AS packaging_plan_quality_risk
`)
return result.rows[0]
}
async function getConfigAuditPermissionSummary(client) {
const result = await client.query(`
SELECT
(
SELECT COUNT(*)::integer
FROM "SystemConfig"
WHERE NULLIF(btrim(key), '') IS NULL
OR key <> btrim(key)
OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL)
) AS system_config_identity_risk,
(
SELECT COUNT(*)::integer
FROM "ProviderConfig"
WHERE NULLIF(btrim("serviceType"), '') IS NULL
OR "serviceType" <> btrim("serviceType")
OR NULLIF(btrim(name), '') IS NULL
OR name <> btrim(name)
OR NULLIF(btrim("baseUrl"), '') IS NULL
OR "baseUrl" <> regexp_replace(btrim("baseUrl"), '/+$', '')
OR NULLIF(btrim("apiKey"), '') IS NULL
OR "apiKey" <> btrim("apiKey")
OR "sortOrder" < 0
OR (model IS NOT NULL AND NULLIF(btrim(model), '') IS NULL)
OR (extra IS NOT NULL AND jsonb_typeof(extra) <> 'object')
) AS provider_config_identity_risk,
(
SELECT COUNT(*)::integer
FROM (
SELECT "serviceType"
FROM "ProviderConfig"
WHERE "isActive" = true
GROUP BY "serviceType"
HAVING COUNT(*) > 1
) duplicated_active
) AS provider_config_active_conflict_risk,
(
SELECT COUNT(*)::integer
FROM "ThemeConfig"
WHERE NULLIF(btrim(name), '') IS NULL
OR name <> btrim(name)
OR NULLIF(btrim("createdBy"), '') IS NULL
OR "createdBy" <> btrim("createdBy")
OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL)
OR ("startDate" IS NOT NULL AND "endDate" IS NOT NULL AND "startDate" >= "endDate")
OR ("homeBackground" IS NOT NULL AND jsonb_typeof("homeBackground") <> 'object')
OR ("loginPanel" IS NOT NULL AND jsonb_typeof("loginPanel") <> 'object')
) AS theme_config_identity_risk,
(
SELECT COUNT(*)::integer
FROM (
SELECT lower(btrim(name))
FROM "ThemeConfig"
GROUP BY lower(btrim(name))
HAVING COUNT(*) > 1
) duplicated_names
) AS theme_config_duplicate_name_risk,
(
SELECT GREATEST(COUNT(*)::integer - 1, 0)
FROM "ThemeConfig"
WHERE "isDefault" = true
) AS theme_config_default_conflict_risk,
(
SELECT COUNT(*)::integer
FROM "IntakeNodePermissionOverride"
WHERE NULLIF(btrim("targetKey"), '') IS NULL
OR "targetKey" <> btrim("targetKey")
OR NULLIF(btrim("subjectKey"), '') IS NULL
OR "subjectKey" <> btrim("subjectKey")
OR NULLIF(btrim(action), '') IS NULL
OR action <> btrim(action)
OR ("createdBy" IS NOT NULL AND NULLIF(btrim("createdBy"), '') IS NULL)
OR ("updatedBy" IS NOT NULL AND NULLIF(btrim("updatedBy"), '') IS NULL)
) AS intake_node_permission_override_identity_risk,
(
SELECT COUNT(*)::integer
FROM "AssistedAssemblyAiRender"
WHERE NULLIF(btrim("createdBy"), '') IS NULL
OR "createdBy" <> btrim("createdBy")
OR NULLIF(btrim(prompt), '') IS NULL
OR prompt <> btrim(prompt)
OR jsonb_typeof(params) <> 'object'
OR jsonb_typeof("resultImages") <> 'array'
OR jsonb_typeof("workspaceSnapshot") <> 'object'
OR jsonb_typeof("assetSnapshot") <> 'object'
OR ("attempts" IS NOT NULL AND jsonb_typeof("attempts") <> 'array')
OR ("durationMs" IS NOT NULL AND "durationMs" < 0)
OR ("negativePrompt" IS NOT NULL AND NULLIF(btrim("negativePrompt"), '') IS NULL)
OR ("referenceImageUrl" IS NOT NULL AND NULLIF(btrim("referenceImageUrl"), '') IS NULL)
OR ("externalRequestId" IS NOT NULL AND NULLIF(btrim("externalRequestId"), '') IS NULL)
OR ("errorMessage" IS NOT NULL AND NULLIF(btrim("errorMessage"), '') IS NULL)
OR (status = 'SUCCEEDED' AND jsonb_array_length("resultImages") = 0)
OR (status = 'FAILED' AND NULLIF(btrim(COALESCE("errorMessage", '')), '') IS NULL)
) AS assisted_assembly_ai_render_quality_risk,
(
SELECT COUNT(*)::integer
FROM "Feedback"
WHERE NULLIF(btrim(title), '') IS NULL
OR title <> btrim(title)
OR NULLIF(btrim(description), '') IS NULL
OR description <> btrim(description)
OR NULLIF(btrim(category), '') IS NULL
OR category <> btrim(category)
OR array_position(images, '') IS NOT NULL
OR EXISTS (SELECT 1 FROM unnest(images) AS image WHERE NULLIF(btrim(image), '') IS NULL OR image <> btrim(image))
OR ("comments" IS NOT NULL AND jsonb_typeof("comments") <> 'array')
OR (resolution IS NOT NULL AND NULLIF(btrim(resolution), '') IS NULL)
OR (status = 'RESOLVED' AND ("resolvedAt" IS NULL OR NULLIF(btrim(COALESCE(resolution, '')), '') IS NULL))
) AS feedback_quality_risk,
(
SELECT COUNT(*)::integer
FROM "ActivityLog"
WHERE NULLIF(btrim("userId"), '') IS NULL
OR "userId" <> btrim("userId")
OR NULLIF(btrim(action), '') IS NULL
OR action <> btrim(action)
OR (("targetType" IS NULL) <> ("targetId" IS NULL))
OR ("targetType" IS NOT NULL AND NULLIF(btrim("targetType"), '') IS NULL)
OR ("targetId" IS NOT NULL AND NULLIF(btrim("targetId"), '') IS NULL)
OR ("changes" IS NOT NULL AND jsonb_typeof("changes") <> 'object')
OR ("metadata" IS NOT NULL AND jsonb_typeof("metadata") <> 'object')
) AS activity_log_quality_risk
`)
return result.rows[0]
}
async function getMasterDataQualityDecisionSummary(client) {
if (!await relationExists(client, '"MasterDataQualityDecision"')) {
return {
tableExists: false,
openApprovedIncomplete: 0,
resolved: 0,
blankReasonRisk: 0,
placeholderReasonRisk: 0,
placeholderDecidedByRisk: 0,
placeholderResolvedByRisk: 0,
duplicateOpenTargetRisk: 0,
}
}
const result = await client.query(`
WITH placeholder_tokens AS (
SELECT unnest(ARRAY[
'unknown', 'unk', 'n/a', 'na', 'null', 'none', 'todo', 'tbd',
'default', 'test', '测试', '待补', '待补齐', '待确认', '未知',
'默认', '默认值', '暂无'
]) AS token
)
SELECT
COUNT(*) FILTER (WHERE "status" = 'APPROVED_INCOMPLETE')::integer AS open_approved_incomplete,
COUNT(*) FILTER (WHERE "status" = 'RESOLVED')::integer AS resolved,
COUNT(*) FILTER (
WHERE NULLIF(btrim(COALESCE("reason", '')), '') IS NULL
OR NULLIF(btrim(COALESCE("decidedBy", '')), '') IS NULL
)::integer AS blank_reason_risk,
COUNT(*) FILTER (
WHERE NULLIF(btrim(COALESCE("reason", '')), '') IS NOT NULL
AND (
lower(regexp_replace(btrim("reason"), '\\s+', '', 'g')) IN (
SELECT token FROM placeholder_tokens
)
OR regexp_replace(btrim("reason"), '\\s+', '', 'g') ~ '(待补|待确认|默认值)'
)
)::integer AS placeholder_reason_risk,
COUNT(*) FILTER (
WHERE NULLIF(btrim(COALESCE("decidedBy", '')), '') IS NOT NULL
AND (
lower(regexp_replace(btrim("decidedBy"), '\\s+', '', 'g')) IN (
SELECT token FROM placeholder_tokens
)
OR regexp_replace(btrim("decidedBy"), '\\s+', '', 'g') ~ '(待补|待确认|默认值)'
)
)::integer AS placeholder_decided_by_risk,
COUNT(*) FILTER (
WHERE NULLIF(btrim(COALESCE("resolvedBy", '')), '') IS NOT NULL
AND (
lower(regexp_replace(btrim("resolvedBy"), '\\s+', '', 'g')) IN (
SELECT token FROM placeholder_tokens
)
OR regexp_replace(btrim("resolvedBy"), '\\s+', '', 'g') ~ '(待补|待确认|默认值)'
)
)::integer AS placeholder_resolved_by_risk,
(
SELECT COUNT(*)::integer
FROM (
SELECT "targetType", "targetId"
FROM "MasterDataQualityDecision"
WHERE "status" = 'APPROVED_INCOMPLETE'
GROUP BY "targetType", "targetId"
HAVING COUNT(*) > 1
) duplicate_open_targets
) AS duplicate_open_target_risk
FROM "MasterDataQualityDecision"
`)
const row = result.rows[0]
return {
tableExists: true,
openApprovedIncomplete: row.open_approved_incomplete,
resolved: row.resolved,
blankReasonRisk: row.blank_reason_risk,
placeholderReasonRisk: row.placeholder_reason_risk,
placeholderDecidedByRisk: row.placeholder_decided_by_risk,
placeholderResolvedByRisk: row.placeholder_resolved_by_risk,
duplicateOpenTargetRisk: row.duplicate_open_target_risk,
}
}
async function buildReport(client, mode, appliedMigrations, migrationReplay = classifyAppliedMigrationPaths(appliedMigrations)) {
const report = {
mode,
appliedMigrations,
migrationReplay,
migrationApplicationStatus: getMigrationApplicationStatus(appliedMigrations, migrationReplay),
databaseGuards: await getDatabaseGuardSummary(client),
databaseEnums: await getDatabaseEnumDriftSummary(client),
assetCenterSourceData: await getAssetCenterSourceDataSummary(client),
assetVariantDuplicateBarcodeGroups: await getAssetVariantDuplicateBarcodeGroupCount(client),
structuredSkuItems: await getStructuredSkuSummary(client),
structuredPricingRows: await getStructuredPricingRowSummary(client),
incompleteSkuItemSamples: await getIncompleteSkuItemSamples(client),
duplicateSkuCodeGroups: await getDuplicateGroupCount(client, 'skuCode'),
duplicateBarcodeGroups: await getDuplicateGroupCount(client, 'barcode'),
payloadSyncDiffCount: await getPayloadSyncDiffCount(client),
pricingRowPayloadSyncDiffCount: await getPricingRowPayloadSyncDiffCount(client),
productSkuAssetProfiles: await getProductSkuAssetProfileSummary(client),
productSkuMissingNameCount: await getProductSkuMissingNameCount(client),
productSkuPriceCostStockRiskCount: await getProductSkuPriceCostStockRiskCount(client),
productSkuFormalTruthAttributeRiskCount: await getProductSkuFormalTruthAttributeRiskCount(client),
productSkuWithoutAssetProfileCount: await getProductSkuWithoutAssetProfileCount(client),
productSkuProfileSnapshotDiffCount: await getProductSkuProfileSnapshotDiffCount(client),
productSkuProfileDuplicateBarcodeGroups: await getProductSkuProfileDuplicateBarcodeGroupCount(client),
productSkuProfileDeletedAtMismatchCount: await getProductSkuProfileDeletedAtMismatchCount(client),
productSkuSoftDeleteTombstoneRiskCount: await getProductSkuSoftDeleteTombstoneRiskCount(client),
assetSkuSyncEvents: await getAssetSkuSyncEventSummary(client),
preSkuMissingBasicInfoCount: await getPreSkuMissingBasicInfoCount(client),
preSkuBasicInfoSnapshotDiffCount: await getPreSkuBasicInfoSnapshotDiffCount(client),
preSkuDuplicateFormalSkuCodeGroups: await getPreSkuBasicInfoDuplicateGroupCount(client, 'formalSkuCode'),
preSkuDuplicateBarcodeGroups: await getPreSkuBasicInfoDuplicateGroupCount(client, 'barcode'),
draftGroupInvalidProcessShapeCount: await getDraftGroupInvalidProcessShapeCount(client),
preSkuInvalidProcessShapeCount: await getPreSkuInvalidProcessShapeCount(client),
preSkuFormalizationLifecycleMismatchCount: await getPreSkuFormalizationLifecycleMismatchCount(client),
preSkuFormalizationLifecycleMismatchSamples: await getPreSkuFormalizationLifecycleMismatchSamples(client),
nonIntegratedProductProfiles: await getNonIntegratedProductProfileSummary(client),
nonIntegratedProductProfileQualitySamples: await getNonIntegratedProductProfileQualitySamples(client),
nonIntegratedProductMissingProfileCount: await getNonIntegratedProductMissingProfileCount(client),
nonIntegratedProductProfilePayloadDiffCount: await getNonIntegratedProductProfilePayloadDiffCount(client),
nonIntegratedProductStockSourcePayloadDiffCount: await getNonIntegratedProductStockSourcePayloadDiffCount(client),
productPatentRecords: await getProductPatentRecordSummary(client),
intakeReferenceLibraryItems: await getIntakeReferenceLibrarySummary(client),
spuNumberCounters: await getSpuNumberCounterSummary(client),
externalIntakeSkuItemsView: await getExternalViewSummary(client),
externalIntakeReadyViews: await getExternalReadyViewSummary(client),
externalReadonlyContract: await getExternalReadonlyContractSummary(client),
externalStableKeyQuality: await getExternalStableKeyQualitySummary(client),
externalAssetUrlRisks: await getExternalAssetUrlRiskSummary(client),
internalAssetReferenceRisks: await getInternalAssetReferenceRiskSummary(client),
externalProductSkusView: await getExternalProductSkusSummary(client),
externalProductDetailViews: await getExternalProductDetailViewsSummary(client),
externalViewJsonSourceDependencyRisks: await getExternalViewJsonSourceDependencyRisks(client),
externalViewIdFallbackRisks: await getExternalViewIdFallbackRisks(client),
externalIntakeProductPeopleSourceRisks: await getExternalIntakeProductPeopleSourceRisks(client),
legacyCadStructure: await getLegacyCadStructureSummary(client),
referenceOptionAndMapping: await getReferenceOptionAndMappingSummary(client),
configAuditPermission: await getConfigAuditPermissionSummary(client),
masterDataQualityDecisions: await getMasterDataQualityDecisionSummary(client),
}
return {
...report,
foundationHardReadyGate: buildFoundationHardReadyGate(report),
}
}
function buildMigrationApplicationStatusFromReadiness(migrationReadiness = {}) {
return {
source: 'read-only-migration-status',
appliedMigrationCount: migrationReadiness.readyMigrationCount ?? null,
pendingStructureMigrationCount: migrationReadiness.pendingStructureMigrationCount ?? null,
replayableIdempotentMigrationCount: migrationReadiness.pendingIdempotentMigrationCount ?? null,
pendingStructureMigrationPaths: Array.isArray(migrationReadiness.pendingStructureMigrationPaths)
? migrationReadiness.pendingStructureMigrationPaths
: [],
replayableIdempotentMigrationPaths: Array.isArray(migrationReadiness.pendingIdempotentMigrationPaths)
? migrationReadiness.pendingIdempotentMigrationPaths
: [],
strictReady: migrationReadiness.strictReady === true,
fullyReady: migrationReadiness.fullyReady === true,
}
}
async function buildReadOnlyQualityReport(client, migrationReadiness) {
const migrationApplicationStatus =
buildMigrationApplicationStatusFromReadiness(migrationReadiness)
const emptyMigrationReplay = {
structureMigrations: [],
idempotentMigrations: [],
}
if (migrationReadiness?.fullyReady !== true) {
const report = {
mode: 'read-only-quality',
readOnly: true,
qualityQuerySkipped: true,
qualityQuerySkippedReason:
'治理迁移只读状态尚未 fullyReady,严格只读模式不会事务回放迁移 SQL。',
appliedMigrations: [],
migrationReplay: emptyMigrationReplay,
migrationReadiness,
migrationApplicationStatus,
}
return {
...report,
foundationHardReadyGate: buildFoundationHardReadyGate(report),
}
}
const report = await buildReport(client, 'read-only-quality', [], emptyMigrationReplay)
const reportWithReadOnlyMigrationStatus = {
...report,
readOnly: true,
migrationReadiness,
migrationApplicationStatus,
}
return {
...reportWithReadOnlyMigrationStatus,
foundationHardReadyGate: buildFoundationHardReadyGate(reportWithReadOnlyMigrationStatus),
}
}
async function main() {
const args = parseArgs(process.argv.slice(2))
validateRequiredMigrationCatalog()
const databaseUrl = loadDatabaseUrl()
if (!databaseUrl) {
throw new Error('DATABASE_URL 未配置,无法验证数据库主数据地基。')
}
const pool = new Pool({ connectionString: databaseUrl, connectionTimeoutMillis: 5000 })
const client = await pool.connect()
let transactionStarted = false
try {
if (args.readOnlyMigrationStatus) {
const migrationReadiness = await getRequiredMigrationReadiness(client)
writeJsonReport(args.outputFile, migrationReadiness)
console.log(JSON.stringify(migrationReadiness, null, 2))
if (!migrationReadiness.fullyReady) {
throw new Error(
`数据库治理迁移尚未全部真实落库:${[
...migrationReadiness.pendingStructureMigrationPaths,
...migrationReadiness.pendingIdempotentMigrationPaths,
].join(', ')}`,
)
}
return
}
if (args.readOnlyQuality) {
const migrationReadiness = await getRequiredMigrationReadiness(client)
const report = await buildReadOnlyQualityReport(client, migrationReadiness)
writeFoundationHardReadyOutputs(args, report)
printFoundationHardReadyReport(args, report)
assertFoundationHardReadyGate(report)
return
}
if (args.requireApplied) {
const migrationReadiness = await getRequiredMigrationReadiness(client)
if (!migrationReadiness.fullyReady) {
console.log(JSON.stringify(migrationReadiness, null, 2))
throw new Error(
`数据库治理迁移尚未全部真实落库,严格模式拒绝通过:${[
...migrationReadiness.pendingStructureMigrationPaths,
...migrationReadiness.pendingIdempotentMigrationPaths,
].join(', ')}`,
)
}
}
await client.query('BEGIN')
transactionStarted = true
const migrationReadinessBeforeReplay = await getRequiredMigrationReadiness(client)
const appliedMigrations = shouldApplyMissingMigrationFiles(migrationReadinessBeforeReplay)
? await applyMissingMigrationFiles(client)
: []
const migrationReplay = classifyAppliedMigrationPaths(appliedMigrations)
const mode = migrationReplay.structureMigrations.length > 0
? 'migration-dry-run'
: migrationReplay.idempotentMigrations.length > 0
? 'idempotent-migration-replay'
: 'current'
const migrationApplicationStatus = getMigrationApplicationStatus(
appliedMigrations,
migrationReplay,
)
if (args.requireApplied && !migrationApplicationStatus.strictReady) {
const blockedReport = {
mode,
appliedMigrations,
migrationReplay,
migrationApplicationStatus,
rolledBack: true,
}
await client.query('ROLLBACK')
writeFoundationHardReadyOutputs(args, blockedReport)
printFoundationHardReadyReport(args, blockedReport)
assertNoPendingMigrationDryRun(appliedMigrations, args)
}
assertNoPendingMigrationDryRun(appliedMigrations, args)
const report = await buildReport(client, mode, appliedMigrations, migrationReplay)
await client.query('ROLLBACK')
const reportWithRollback = { ...report, rolledBack: true }
writeFoundationHardReadyOutputs(args, reportWithRollback)
printFoundationHardReadyReport(args, reportWithRollback)
if (report.databaseGuards.missingOrInvalid.length > 0) {
throw new Error('数据库主数据物理护栏缺失或失效,详见 databaseGuards.missingOrInvalid。')
}
if (report.databaseEnums.drifted.length > 0) {
throw new Error('数据库 enum 类型和 Prisma schema 不一致,详见 databaseEnums.drifted。')
}
if (
!report.externalIntakeSkuItemsView.exists ||
!report.externalIntakeReadyViews.exists ||
report.externalReadonlyContract.missingViews.length > 0 ||
!report.externalProductSkusView.exists ||
!report.externalProductDetailViews.exists
) {
throw new Error('外部只读核心视图缺失,详见 externalIntakeSkuItemsView / externalIntakeReadyViews / externalReadonlyContract / externalProductSkusView / externalProductDetailViews。')
}
if (report.externalReadonlyContract.missingColumns.length > 0) {
throw new Error('外部只读视图缺少字段字典承诺列,详见 externalReadonlyContract.missingColumns。')
}
if (report.externalReadonlyContract.extraColumns.length > 0) {
throw new Error('外部只读视图暴露了字段字典未登记列,详见 externalReadonlyContract.extraColumns。')
}
if (
report.externalStableKeyQuality.missingRequired.length > 0 ||
report.externalStableKeyQuality.duplicateGroups.length > 0
) {
throw new Error('外部只读视图稳定业务键缺失或重复,详见 externalStableKeyQuality。')
}
if (report.externalAssetUrlRisks.risks.length > 0) {
throw new Error('外部只读素材 URL 存在不可长期外部读取风险,详见 externalAssetUrlRisks。')
}
if (report.internalAssetReferenceRisks.risks.length > 0) {
throw new Error('内部素材引用存在不可长期保存风险,详见 internalAssetReferenceRisks。')
}
if (report.externalViewJsonSourceDependencyRisks.length > 0) {
throw new Error('外部只读视图仍依赖内部 JSON 源,详见 externalViewJsonSourceDependencyRisks。')
}
if (report.externalViewIdFallbackRisks.length > 0) {
throw new Error('外部只读视图业务编码字段仍使用内部 id 兜底,详见 externalViewIdFallbackRisks。')
}
if (
report.externalProductSkusView.missing_product_business_key > 0 ||
report.externalProductSkusView.missing_sku_business_key > 0
) {
throw new Error('外部正式 SKU 视图缺少稳定业务键,详见 externalProductSkusView。')
}
if (report.externalProductSkusView.duplicate_sku_business_key_groups > 0) {
throw new Error('外部正式 SKU 视图存在重复稳定业务键,详见 externalProductSkusView。')
}
if (
report.externalProductDetailViews.product_assets_missing_product_business_key > 0 ||
report.externalProductDetailViews.product_assets_missing_sku_business_key > 0 ||
report.externalProductDetailViews.product_bom_missing_product_business_key > 0 ||
report.externalProductDetailViews.product_bom_missing_sku_business_key_with_sku_id > 0
) {
throw new Error('外部正式产品素材/BOM 明细视图缺少稳定业务键,详见 externalProductDetailViews。')
}
if (report.externalIntakeProductPeopleSourceRisks.length > 0) {
throw new Error(
'外部非集成产品负责人字段没有优先读取结构化 profile,详见 externalIntakeProductPeopleSourceRisks。',
)
}
if (report.externalIntakeReadyViews.ready_sku_quality_violations > 0) {
throw new Error('外部 ready SKU 视图混入非 READY 明细,详见 externalIntakeReadyViews.ready_sku_quality_violations。')
}
if (
report.externalIntakeReadyViews.ready_product_business_key_missing > 0 ||
report.externalIntakeReadyViews.ready_sku_business_key_missing > 0
) {
throw new Error('外部 ready 视图缺少稳定业务键,详见 externalIntakeReadyViews。')
}
if (
report.externalIntakeReadyViews.ready_product_business_key_duplicate_groups > 0 ||
report.externalIntakeReadyViews.ready_sku_business_key_duplicate_groups > 0
) {
throw new Error('外部 ready 视图存在重复稳定业务键,详见 externalIntakeReadyViews。')
}
if (report.productPatentRecords.temporary_id_count > 0) {
throw new Error('专利台账仍存在草稿态临时随机 id,详见 productPatentRecords.temporary_id_count。')
}
assertNoMasterDataTruthDrift(report)
assertFoundationHardReadyGate(report)
} catch (error) {
if (transactionStarted) {
await client.query('ROLLBACK').catch(() => undefined)
}
throw error
} finally {
client.release()
await pool.end()
}
}
function formatError(error) {
if (!(error instanceof Error)) {
return String(error)
}
const details = [
error.message,
error.name ? `name=${error.name}` : '',
error.code ? `code=${error.code}` : '',
error.cause ? `cause=${error.cause}` : '',
].filter(Boolean)
return details.length > 0 ? details.join('\n') : error.stack || String(error)
}
if (require.main === module) {
main().catch((error) => {
console.error(formatError(error))
process.exit(1)
})
}
module.exports = {
REQUIRED_MIGRATIONS,
REQUIRED_DATABASE_GUARDS,
parseArgs,
assertNoPendingMigrationDryRun,
getMigrationApplicationStatus,
shouldApplyMissingMigrationFiles,
getRequiredMigrationReadiness,
getExternalReadonlyContractSummary,
getExternalStableKeyQualitySummary,
getExternalAssetUrlRiskSummary,
getInternalAssetReferenceRiskSummary,
getDatabaseEnumDriftSummary,
getExternalViewIdFallbackRisks,
getPreSkuFormalizationLifecycleMismatchSamples,
getAssetSkuSyncEventSummary,
getProductSkuAssetProfileSummary,
assertNoMasterDataTruthDrift,
buildFoundationHardReadyGate,
buildFoundationHardReadySummary,
buildMigrationApplicationStatusFromReadiness,
buildReadOnlyQualityReport,
assertFoundationHardReadyGate,
printFoundationHardReadyReport,
getStructuredSkuSummary,
getStructuredPricingRowSummary,
getPricingRowPayloadSyncDiffCount,
getNonIntegratedProductProfileSummary,
getDuplicateGroupCount,
buildReport,
getMigrationHistoryStatus,
getPendingStructureMigrationPaths,
validateRequiredMigrationCatalog,
}