diff --git a/furniture-3d-system/docs/架构设计/内部主数据地基治理方案.md b/furniture-3d-system/docs/架构设计/内部主数据地基治理方案.md index 0f20d71c..a540f2d7 100644 --- a/furniture-3d-system/docs/架构设计/内部主数据地基治理方案.md +++ b/furniture-3d-system/docs/架构设计/内部主数据地基治理方案.md @@ -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 号段身份,不只要求非空和大写,还必须数据库级唯一;产品品类还要按去空格和大小写归一后唯一。不同产品品类不能共用同一个前缀,同一产品品类也不能因为空格差异形成两个号池,否则并发占号会变成不可追溯的双计数器。 diff --git a/furniture-3d-system/scripts/sql/check-internal-master-data-foundation.sql b/furniture-3d-system/scripts/sql/check-internal-master-data-foundation.sql index d405b9f7..9b57d7f2 100644 --- a/furniture-3d-system/scripts/sql/check-internal-master-data-foundation.sql +++ b/furniture-3d-system/scripts/sql/check-internal-master-data-foundation.sql @@ -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 diff --git a/furniture-3d-system/scripts/verify-master-data-foundation.js b/furniture-3d-system/scripts/verify-master-data-foundation.js index e804985e..6dfb27df 100644 --- a/furniture-3d-system/scripts/verify-master-data-foundation.js +++ b/furniture-3d-system/scripts/verify-master-data-foundation.js @@ -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, diff --git a/furniture-3d-system/src/server/services/__tests__/asset-sku-snapshot.test.ts b/furniture-3d-system/src/server/services/__tests__/asset-sku-snapshot.test.ts index 2d521d6d..b6704378 100644 --- a/furniture-3d-system/src/server/services/__tests__/asset-sku-snapshot.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/asset-sku-snapshot.test.ts @@ -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: '保留人工预览图', + }, + }), + ) + }) }) diff --git a/furniture-3d-system/src/server/services/asset-sku-snapshot.ts b/furniture-3d-system/src/server/services/asset-sku-snapshot.ts index ab26ad86..3a363945 100644 --- a/furniture-3d-system/src/server/services/asset-sku-snapshot.ts +++ b/furniture-3d-system/src/server/services/asset-sku-snapshot.ts @@ -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(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): Record { + 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 { - 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 { diff --git a/furniture-3d-system/tests/scripts/verify-master-data-foundation.test.js b/furniture-3d-system/tests/scripts/verify-master-data-foundation.test.js index 0bc5276a..65a1b404 100644 --- a/furniture-3d-system/tests/scripts/verify-master-data-foundation.test.js +++ b/furniture-3d-system/tests/scripts/verify-master-data-foundation.test.js @@ -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', ]),