5350 lines
206 KiB
JavaScript
5350 lines
206 KiB
JavaScript
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,
|
||
}
|