fix(data-foundation): 收紧资产同步锁定字段门禁

This commit is contained in:
WuYongJie 2026-06-04 18:11:34 +08:00
parent 7459f2aef1
commit d6ac21db0e
6 changed files with 264 additions and 8 deletions

View File

@ -302,6 +302,7 @@ baseline resolve 完成后,治理迁移、外部只读视图、约束、触发
- 后端写入入口也必须和数据库护栏同口径,不能把数据库约束当成第一道业务校验。当前已收紧 `PlanningTemplate.code`、`WarehouseLocation.code`、`Substrate.code`、`Material.code`、`Hardware.code`、`CommonComponent.code`、正式 `Product.modelNumber`、正式 `ProductSKU.skuCode`、`IntakeNonIntegratedSkuItem.skuCode / barcode`、SKU CAD 导入、传统产品上传、准 SKU 包装方案保存、品牌物料准备项、正式入库承接链路、协作者角色和产品上传可见范围:进入仓储层前先规范化身份字段,空编码/空身份、游离状态、负数数量、重复物理库位、非法权限角色和可提前发现的冲突由服务层直接阻断。
- 仓储查询入口不能再把“调用方已经规范化”当隐含前提。资产中心、正式产品、正式 SKU、基础物料、库位和规划模板等核心 `findByCode / findBySkuCode / findByModelNumber / codeExists` 查询会先做 `trim + uppercase`;资产 CAD 导入也会在查找已有资产族、创建规格款和生成模型存储路径前归一手填编码,避免同一编码因大小写或首尾空格在服务层表现不一致。
- 正式 SKU 资产同步副本的身份字段必须在快照构建层二次规范化:`ProductSkuAssetProfile.model / specCode / barcode` 以及 `sourceVariants` 中对应字段进入结构化副本前统一 `trim + uppercase`,人工锁定字段也不能绕过数据库 `CHECK` 护栏。
- `ProductSkuAssetProfile.lockedFields / lockedReasons` 只能保存登记过的锁定字段名,快照构建会过滤空白、拼错和重复字段;readiness 会把历史 profile 中无效锁定字段或无效锁定原因列为 hard blocker,避免人工锁定优先级变成不可审计的自由 JSON。
- `AssetSkuSyncEvent` 不只检查字段形态,还要作为资产中心到正式 SKU 同步是否真实闭环的运行证据。readiness 会把 `FAILED`、超过 15 分钟仍停留在 `PENDING / PROCESSING`、以及完成态仍保留失败计数的事件列为 hard blocker,避免源资产变更、缩略图重生成或删除事件没有真正刷新正式 SKU 资产资料副本。
- `PackagedItem.code` 由正式 `ProductSKU.skuCode` 和包装箱编号生成;正式入库前必须校验包装箱编号是唯一正整数,且包装箱尺寸必须大于 0、预估重量不能为负数,避免用重复箱号、小数箱号或脏箱规生成冲突、不稳定的包装成品和轻仓库数据。
- `SpuNumberCounter.productCategory / prefix` 是正式 SPU 号段身份,不只要求非空和大写,还必须数据库级唯一;产品品类还要按去空格和大小写归一后唯一。不同产品品类不能共用同一个前缀,同一产品品类也不能因为空格差异形成两个号池,否则并发占号会变成不可追溯的双计数器。

View File

@ -727,6 +727,53 @@ WHERE sku."deletedAt" IS NULL
AND NULLIF(btrim(COALESCE(profile."primarySourceAssetVariantId", '')), '') IS NOT NULL
AND sku."sourceAssetVariantId" IS DISTINCT FROM profile."primarySourceAssetVariantId";
SELECT
'product_sku_asset_profile_locked_field_risk' AS check_name,
COUNT(*) FILTER (
WHERE EXISTS (
SELECT 1
FROM unnest(profile."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 (ARRAY[
'model',
'modelName',
'specCode',
'specName',
'barcode',
'size',
'color',
'material',
'imageUrl',
'images'
]::text[])
)
)::integer AS invalid_locked_fields,
COUNT(*) FILTER (
WHERE jsonb_typeof(profile."lockedReasons"::jsonb) <> 'object'
OR EXISTS (
SELECT 1
FROM jsonb_each(profile."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 (ARRAY[
'model',
'modelName',
'specCode',
'specName',
'barcode',
'size',
'color',
'material',
'imageUrl',
'images'
]::text[])
OR jsonb_typeof(locked_reason.reason_value) <> 'string'
OR NULLIF(btrim(locked_reason.reason_value #>> '{}'), '') IS NULL
)
)::integer AS invalid_locked_reasons
FROM "ProductSkuAssetProfile" profile;
SELECT
'pre_sku_missing_truth_snapshot_risk' AS check_name,
COUNT(*)::integer AS row_count

View File

@ -17,6 +17,21 @@ const {
} = 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 = {
@ -3271,6 +3286,18 @@ function collectFoundationHardReadyBlockers(report) {
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',
@ -3849,7 +3876,28 @@ async function getProductSkuAssetProfileSummary(client) {
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 "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]
@ -5281,6 +5329,7 @@ module.exports = {
getExternalViewIdFallbackRisks,
getPreSkuFormalizationLifecycleMismatchSamples,
getAssetSkuSyncEventSummary,
getProductSkuAssetProfileSummary,
assertNoMasterDataTruthDrift,
buildFoundationHardReadyGate,
buildFoundationHardReadySummary,

View File

@ -54,6 +54,44 @@ describe('asset-sku-snapshot', () => {
)
})
it('只保留登记过的人工锁定字段和有效锁定原因', () => {
const snapshot: AssetSkuSnapshot = {
sourceAssetVariantId: 'variant-primary',
sourceAssetFamilyId: 'family-1',
sourceAssetSeriesId: 'series-1',
model: 'DBZ-SPJK001',
modelName: '桌面板',
specCode: 'DBZ-SPJ00001',
specName: '1200 柚木色',
barcode: 'DBZ-SPJ00001N05V01',
size: { length: 1200 },
color: '柚木色',
material: '颗粒板',
imageUrl: '/thumb.webp',
sourceStatus: 'ACTIVE',
sourceAssetVariantIds: ['variant-primary'],
sourceVariants: [],
syncedAt: '2026-05-31T10:00:00.000Z',
lockedFields: [' barcode ', 'images', 'unknown', '', 'barcode'],
lockedReasons: {
' barcode ': ' 人工条码 ',
images: '保留主图顺序',
unknown: '无效字段',
color: '',
},
}
expect(buildProductSkuAssetProfileData(snapshot)).toEqual(
expect.objectContaining({
lockedFields: ['barcode', 'images'],
lockedReasons: {
barcode: '人工条码',
images: '保留主图顺序',
},
}),
)
})
it('从旧 profile 读取快照时也会补齐主来源到来源数组', () => {
const profile = {
primarySourceAssetVariantId: 'variant-primary',
@ -81,4 +119,39 @@ describe('asset-sku-snapshot', () => {
'variant-secondary',
])
})
it('从旧 profile 读取快照时会丢弃无效人工锁定字段', () => {
const profile = {
primarySourceAssetVariantId: 'variant-primary',
sourceAssetVariantIds: ['variant-primary'],
sourceAssetFamilyId: 'family-1',
sourceAssetSeriesId: 'series-1',
model: 'dbz-spjk001',
modelName: '桌面板',
specCode: 'dbz-spj00001',
specName: '1200 柚木色',
barcode: null,
size: {},
color: null,
material: null,
imageUrl: null,
sourceStatus: 'ACTIVE',
syncedAt: new Date('2026-05-31T10:00:00.000Z'),
lockedFields: [' imageUrl ', 'bad-field'],
lockedReasons: {
' imageUrl ': '保留人工预览图',
'bad-field': '不应保留',
},
sourceVariants: [],
}
expect(buildAssetSkuSnapshotFromProfile(profile as never)).toEqual(
expect.objectContaining({
lockedFields: ['imageUrl'],
lockedReasons: {
imageUrl: '保留人工预览图',
},
}),
)
})
})

View File

@ -3,6 +3,21 @@ import type { ProductSkuAssetProfile } from '@prisma/client'
import type { AssetVariantWithRelations } from '../repositories'
export type AssetSkuSnapshotSourceStatus = 'ACTIVE' | 'DELETED' | 'MISSING'
export const ASSET_SKU_SNAPSHOT_LOCKABLE_FIELDS = [
'model',
'modelName',
'specCode',
'specName',
'barcode',
'size',
'color',
'material',
'imageUrl',
'images',
] as const
export type AssetSkuSnapshotLockableField = typeof ASSET_SKU_SNAPSHOT_LOCKABLE_FIELDS[number]
const ASSET_SKU_SNAPSHOT_LOCKABLE_FIELD_SET = new Set<string>(ASSET_SKU_SNAPSHOT_LOCKABLE_FIELDS)
export interface AssetSkuSnapshotVariant {
sourceAssetVariantId: string
@ -41,6 +56,35 @@ function readIdentityString(value: unknown): string | null {
return text ? text.toUpperCase() : null
}
function normalizeLockedField(value: string): AssetSkuSnapshotLockableField | null {
const field = value.trim()
return ASSET_SKU_SNAPSHOT_LOCKABLE_FIELD_SET.has(field)
? field as AssetSkuSnapshotLockableField
: null
}
function normalizeLockedFields(values: readonly string[]): AssetSkuSnapshotLockableField[] {
return Array.from(
new Set(
values
.map((value) => normalizeLockedField(value))
.filter((value): value is AssetSkuSnapshotLockableField => value !== null),
),
)
}
function normalizeLockedReasons(value: Record<string, unknown>): Record<string, string> {
return Object.fromEntries(
Object.entries(value)
.map(([key, reason]) => {
const field = normalizeLockedField(key)
const normalizedReason = readString(reason)
return field && normalizedReason ? [field, normalizedReason] : null
})
.filter((entry): entry is [AssetSkuSnapshotLockableField, string] => entry !== null),
)
}
function normalizeSnapshotVariant(variant: AssetSkuSnapshotVariant): AssetSkuSnapshotVariant {
return {
...variant,
@ -87,6 +131,8 @@ function normalizeSnapshot(snapshot: AssetSkuSnapshot): AssetSkuSnapshot {
color: readString(snapshot.color),
material: readString(snapshot.material),
imageUrl: readString(snapshot.imageUrl),
lockedFields: normalizeLockedFields(snapshot.lockedFields),
lockedReasons: normalizeLockedReasons(snapshot.lockedReasons),
}
}
@ -94,18 +140,14 @@ function readLockedFields(value: unknown): string[] {
if (!isRecord(value) || !Array.isArray(value.lockedFields)) {
return []
}
return value.lockedFields.filter((item): item is string => typeof item === 'string')
return normalizeLockedFields(value.lockedFields.filter((item): item is string => typeof item === 'string'))
}
function readLockedReasons(value: unknown): Record<string, string> {
if (!isRecord(value) || !isRecord(value.lockedReasons)) {
if (!isRecord(value)) {
return {}
}
return Object.fromEntries(
Object.entries(value.lockedReasons).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string',
),
)
return normalizeLockedReasons(isRecord(value.lockedReasons) ? value.lockedReasons : value)
}
function readSourceStatus(value: unknown): AssetSkuSnapshotSourceStatus {

View File

@ -23,6 +23,7 @@ const {
printFoundationHardReadyReport,
getStructuredSkuSummary,
getNonIntegratedProductProfileSummary,
getProductSkuAssetProfileSummary,
getDuplicateGroupCount,
getMigrationHistoryStatus,
validateRequiredMigrationCatalog,
@ -1693,6 +1694,38 @@ describe('verify-master-data-foundation', () => {
})
})
it('summarizes invalid asset profile locked-field metadata for hard-ready gating', async () => {
const client = {
query: jest.fn((sql) => {
const query = String(sql)
expect(query).toContain('FROM "ProductSkuAssetProfile"')
expect(query).toContain('jsonb_each("lockedReasons"::jsonb)')
expect(query).toContain("'images'")
return Promise.resolve({
rows: [
{
total: 4,
active: 2,
deleted: 1,
missing: 1,
invalid_locked_fields: 2,
invalid_locked_reasons: 1,
},
],
})
}),
}
await expect(getProductSkuAssetProfileSummary(client)).resolves.toEqual({
total: 4,
active: 2,
deleted: 1,
missing: 1,
invalid_locked_fields: 2,
invalid_locked_reasons: 1,
})
})
it('fails when structured master data drifts from compatibility snapshots', () => {
expect(() =>
assertNoMasterDataTruthDrift({
@ -1769,6 +1802,10 @@ describe('verify-master-data-foundation', () => {
productSkuMissingNameCount: 0,
productSkuPriceCostStockRiskCount: 0,
productSkuFormalTruthAttributeRiskCount: 0,
productSkuAssetProfiles: {
invalid_locked_fields: 0,
invalid_locked_reasons: 0,
},
assetSkuSyncEvents: {
failed: 0,
stale_pending_or_processing: 0,
@ -1859,6 +1896,10 @@ describe('verify-master-data-foundation', () => {
stale_pending_or_processing: 2,
completed_with_failures: 1,
},
productSkuAssetProfiles: {
invalid_locked_fields: 2,
invalid_locked_reasons: 1,
},
})
expect(blockedGate.hardReady).toBe(false)
@ -1870,6 +1911,7 @@ describe('verify-master-data-foundation', () => {
databaseEnums: 1,
migrationApplicationStatus: 1,
nonIntegratedProductProfiles: 2,
productSkuAssetProfiles: 2,
preSkuLifecycle: 1,
structuredSkuItems: 1,
})
@ -1883,6 +1925,8 @@ describe('verify-master-data-foundation', () => {
'failed',
'stale_pending_or_processing',
'completed_with_failures',
'invalid_locked_fields',
'invalid_locked_reasons',
'risks',
'rowCount',
]),