fix(data-foundation): 收紧资产同步锁定字段门禁
This commit is contained in:
parent
7459f2aef1
commit
d6ac21db0e
@ -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 号段身份,不只要求非空和大写,还必须数据库级唯一;产品品类还要按去空格和大小写归一后唯一。不同产品品类不能共用同一个前缀,同一产品品类也不能因为空格差异形成两个号池,否则并发占号会变成不可追溯的双计数器。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: '保留人工预览图',
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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',
|
||||
]),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user