Compare commits

...

1 Commits

Author SHA1 Message Date
WuYongJie
633ed6f598 fix(data-foundation): 兼容非集成 SKU 历史编码冲突 2026-06-02 21:43:48 +08:00
10 changed files with 200 additions and 80 deletions

View File

@ -1,69 +1,8 @@
-- Structure non-integrated intake SKU rows so SKU master data is not only stored in payload JSON.
-- Legacy stock payloads can contain duplicated skuCode values. Preserve those values,
-- mark the structured rows as CONFLICT, and let the governance flow clean them later.
DO $$
DECLARE
duplicate_sku_code_groups INTEGER;
duplicate_barcode_groups INTEGER;
BEGIN
WITH payload_sku_items AS (
SELECT
NULLIF(btrim(item.sku_item ->> 'skuCode'), '') AS sku_code,
NULLIF(btrim(item.sku_item ->> 'barcode'), '') AS barcode
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
) AS item(sku_item)
WHERE p."deletedAt" IS NULL
),
duplicate_sku_codes AS (
SELECT sku_code
FROM payload_sku_items
WHERE sku_code IS NOT NULL
GROUP BY sku_code
HAVING COUNT(*) > 1
)
SELECT COUNT(*)::integer
INTO duplicate_sku_code_groups
FROM duplicate_sku_codes;
WITH payload_sku_items AS (
SELECT
NULLIF(btrim(item.sku_item ->> 'skuCode'), '') AS sku_code,
NULLIF(btrim(item.sku_item ->> 'barcode'), '') AS barcode
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
) AS item(sku_item)
WHERE p."deletedAt" IS NULL
),
duplicate_barcodes AS (
SELECT barcode
FROM payload_sku_items
WHERE barcode IS NOT NULL
GROUP BY barcode
HAVING COUNT(*) > 1
)
SELECT COUNT(*)::integer
INTO duplicate_barcode_groups
FROM duplicate_barcodes;
IF duplicate_sku_code_groups > 0 OR duplicate_barcode_groups > 0 THEN
RAISE EXCEPTION
'Cannot structure non-integrated SKU items: active payload contains % duplicate skuCode groups and % duplicate barcode groups. Run scripts/sql/preflight-intake-non-integrated-sku-items.sql before this migration.',
duplicate_sku_code_groups,
duplicate_barcode_groups;
END IF;
END $$;
CREATE TYPE "IntakeNonIntegratedSkuDataQualityStatus" AS ENUM ('READY', 'INCOMPLETE');
CREATE TYPE "IntakeNonIntegratedSkuDataQualityStatus" AS ENUM ('READY', 'INCOMPLETE', 'CONFLICT');
CREATE TABLE "IntakeNonIntegratedSkuItem" (
"id" TEXT NOT NULL,
@ -113,10 +52,12 @@ CREATE UNIQUE INDEX "IntakeNonIntegratedSkuItem_productId_skuItemId_key"
ON "IntakeNonIntegratedSkuItem"("productId", "skuItemId");
CREATE UNIQUE INDEX "IntakeNonIntegratedSkuItem_skuCode_non_empty_key"
ON "IntakeNonIntegratedSkuItem"("skuCode")
WHERE NULLIF(btrim("skuCode"), '') IS NOT NULL;
WHERE "dataQualityStatus" = 'READY'
AND NULLIF(btrim("skuCode"), '') IS NOT NULL;
CREATE UNIQUE INDEX "IntakeNonIntegratedSkuItem_barcode_non_empty_key"
ON "IntakeNonIntegratedSkuItem"("barcode")
WHERE NULLIF(btrim("barcode"), '') IS NOT NULL;
WHERE "dataQualityStatus" = 'READY'
AND NULLIF(btrim("barcode"), '') IS NOT NULL;
CREATE INDEX "IntakeNonIntegratedSkuItem_productId_idx" ON "IntakeNonIntegratedSkuItem"("productId");
CREATE INDEX "IntakeNonIntegratedSkuItem_skuCode_idx" ON "IntakeNonIntegratedSkuItem"("skuCode");
CREATE INDEX "IntakeNonIntegratedSkuItem_barcode_idx" ON "IntakeNonIntegratedSkuItem"("barcode");
@ -175,6 +116,8 @@ normalized_sku_items AS (
sku_item,
sort_order,
sku_item_id,
NULLIF(upper(btrim(COALESCE(sku_item ->> 'skuCode', ''))), '') AS normalized_sku_code,
NULLIF(upper(btrim(COALESCE(sku_item ->> 'barcode', ''))), '') AS normalized_barcode,
array_remove(
ARRAY[
CASE
@ -193,6 +136,17 @@ normalized_sku_items AS (
NULL
) AS missing_required_fields
FROM deduped_sku_items
),
quality_sku_items AS (
SELECT
normalized_sku_items.*,
COUNT(*) FILTER (WHERE normalized_sku_code IS NOT NULL) OVER (
PARTITION BY normalized_sku_code
) AS sku_code_occurrences,
COUNT(*) FILTER (WHERE normalized_barcode IS NOT NULL) OVER (
PARTITION BY normalized_barcode
) AS barcode_occurrences
FROM normalized_sku_items
)
INSERT INTO "IntakeNonIntegratedSkuItem" (
"id",
@ -272,12 +226,14 @@ SELECT
COALESCE(sku_item ->> 'sourceSkuId', ''),
LOWER(COALESCE(sku_item ->> 'contentLocked', '')) IN ('true', '1', 'yes', 'on'),
CASE
WHEN sku_code_occurrences > 1 OR barcode_occurrences > 1
THEN 'CONFLICT'::"IntakeNonIntegratedSkuDataQualityStatus"
WHEN cardinality(missing_required_fields) = 0
THEN 'READY'::"IntakeNonIntegratedSkuDataQualityStatus"
ELSE 'INCOMPLETE'::"IntakeNonIntegratedSkuDataQualityStatus"
END,
missing_required_fields
FROM normalized_sku_items;
FROM quality_sku_items;
CREATE OR REPLACE VIEW external.intake_sku_items_v1 AS
SELECT

View File

@ -414,12 +414,14 @@ CREATE UNIQUE INDEX IF NOT EXISTS "ProductSkuAssetProfile_barcode_active_non_emp
DROP INDEX IF EXISTS "IntakeNonIntegratedSkuItem_skuCode_non_empty_key";
CREATE UNIQUE INDEX IF NOT EXISTS "IntakeNonIntegratedSkuItem_skuCode_non_empty_key"
ON "IntakeNonIntegratedSkuItem"(upper(btrim("skuCode")))
WHERE NULLIF(btrim("skuCode"), '') IS NOT NULL;
WHERE "dataQualityStatus" = 'READY'
AND NULLIF(btrim("skuCode"), '') IS NOT NULL;
DROP INDEX IF EXISTS "IntakeNonIntegratedSkuItem_barcode_non_empty_key";
CREATE UNIQUE INDEX IF NOT EXISTS "IntakeNonIntegratedSkuItem_barcode_non_empty_key"
ON "IntakeNonIntegratedSkuItem"(upper(btrim("barcode")))
WHERE NULLIF(btrim("barcode"), '') IS NOT NULL;
WHERE "dataQualityStatus" = 'READY'
AND NULLIF(btrim("barcode"), '') IS NOT NULL;
DROP INDEX IF EXISTS "IntakeReferenceLibraryItem_libraryType_code_non_empty_key";
CREATE UNIQUE INDEX IF NOT EXISTS "IntakeReferenceLibraryItem_libraryType_code_non_empty_key"

View File

@ -1433,6 +1433,7 @@ enum ProductSkuAssetSourceStatus {
enum IntakeNonIntegratedSkuDataQualityStatus {
READY
INCOMPLETE
CONFLICT
}
enum PackagingSourceType {

View File

@ -123,6 +123,8 @@ normalized_sku_items AS (
sku_item,
sort_order,
sku_item_id,
NULLIF(upper(btrim(COALESCE(sku_item ->> 'skuCode', ''))), '') AS normalized_sku_code,
NULLIF(upper(btrim(COALESCE(sku_item ->> 'barcode', ''))), '') AS normalized_barcode,
array_remove(
ARRAY[
CASE
@ -141,6 +143,17 @@ normalized_sku_items AS (
NULL
) AS missing_required_fields
FROM deduped_sku_items
),
quality_sku_items AS (
SELECT
normalized_sku_items.*,
COUNT(*) FILTER (WHERE normalized_sku_code IS NOT NULL) OVER (
PARTITION BY normalized_sku_code
) AS sku_code_occurrences,
COUNT(*) FILTER (WHERE normalized_barcode IS NOT NULL) OVER (
PARTITION BY normalized_barcode
) AS barcode_occurrences
FROM normalized_sku_items
)
INSERT INTO "IntakeNonIntegratedSkuItem" (
"id",
@ -222,6 +235,8 @@ SELECT
COALESCE(sku_item ->> 'sourceSkuId', ''),
LOWER(COALESCE(sku_item ->> 'contentLocked', '')) IN ('true', '1', 'yes', 'on'),
CASE
WHEN sku_code_occurrences > 1 OR barcode_occurrences > 1
THEN 'CONFLICT'::"IntakeNonIntegratedSkuDataQualityStatus"
WHEN cardinality(missing_required_fields) = 0
THEN 'READY'::"IntakeNonIntegratedSkuDataQualityStatus"
ELSE 'INCOMPLETE'::"IntakeNonIntegratedSkuDataQualityStatus"
@ -229,7 +244,7 @@ SELECT
missing_required_fields,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM normalized_sku_items
FROM quality_sku_items
ON CONFLICT ("productId", "skuItemId") DO NOTHING;
CREATE OR REPLACE FUNCTION "cleanup_intake_non_integrated_sku_items_on_soft_delete"()

View File

@ -37,8 +37,8 @@ WITH required_index_guards(index_name, table_name, predicate_hint, definition_hi
('PreSkuBasicInfo_barcode_non_empty_key', 'PreSkuBasicInfo', 'NULLIF(btrim(', 'upper(btrim('),
('IntakeNonIntegratedProductProfile_productId_key', 'IntakeNonIntegratedProductProfile', NULL, NULL),
('IntakeNonIntegratedSkuItem_productId_skuItemId_key', 'IntakeNonIntegratedSkuItem', NULL, NULL),
('IntakeNonIntegratedSkuItem_skuCode_non_empty_key', 'IntakeNonIntegratedSkuItem', 'NULLIF(btrim(', 'upper(btrim('),
('IntakeNonIntegratedSkuItem_barcode_non_empty_key', 'IntakeNonIntegratedSkuItem', 'NULLIF(btrim(', 'upper(btrim('),
('IntakeNonIntegratedSkuItem_skuCode_non_empty_key', 'IntakeNonIntegratedSkuItem', '"dataQualityStatus" = ''READY''', 'upper(btrim('),
('IntakeNonIntegratedSkuItem_barcode_non_empty_key', 'IntakeNonIntegratedSkuItem', '"dataQualityStatus" = ''READY''', 'upper(btrim('),
('MasterDataQualityDecision_open_target_key', 'MasterDataQualityDecision', 'status = ''APPROVED_INCOMPLETE''', NULL),
('ProductPatentRecord_patentGroupKey_key', 'ProductPatentRecord', NULL, NULL),
('IntakeReferenceLibraryItem_libraryType_itemKey_key', 'IntakeReferenceLibraryItem', NULL, NULL),

View File

@ -514,17 +514,17 @@ const REQUIRED_DATABASE_GUARDS = [
type: 'partial-unique-index',
name: 'IntakeNonIntegratedSkuItem_skuCode_non_empty_key',
relationName: 'IntakeNonIntegratedSkuItem',
predicatePattern: 'NULLIF(btrim(',
predicatePattern: '"dataQualityStatus" = \'READY\'',
definitionPattern: 'upper(btrim(',
purpose: '非空 SKU 编码必须全局唯一,空值只允许作为待补齐状态。',
purpose: 'READY 状态的非空 SKU 编码必须全局唯一;历史冲突行保留为 CONFLICT 等待治理。',
},
{
type: 'partial-unique-index',
name: 'IntakeNonIntegratedSkuItem_barcode_non_empty_key',
relationName: 'IntakeNonIntegratedSkuItem',
predicatePattern: 'NULLIF(btrim(',
predicatePattern: '"dataQualityStatus" = \'READY\'',
definitionPattern: 'upper(btrim(',
purpose: '非空条码必须全局唯一,空值只允许作为待补齐状态。',
purpose: 'READY 状态的非空条码必须全局唯一;历史冲突行保留为 CONFLICT 等待治理。',
},
{
type: 'unique-index',

View File

@ -370,6 +370,8 @@ export type NonIntegratedSkuItem = {
sourceTable?: string;
sourceSkuId?: string;
contentLocked?: boolean;
dataQualityStatus?: "READY" | "INCOMPLETE" | "CONFLICT";
missingRequiredFields?: string[];
};
export type NonIntegratedSkuComponentDetail = {

View File

@ -1070,7 +1070,7 @@ describe('intakeNonIntegratedProductService', () => {
const product = createPayload({
skuItems: [
createSkuItem({ id: 'sku-ready', skuCode: 'SKU-READY', specName: 'Ready', model: 'M-READY' }),
createSkuItem({ id: 'sku-incomplete', skuCode: '', specName: '', model: '' }),
createSkuItem({ id: 'sku-incomplete', skuCode: '', specName: '', model: '', barcode: '' }),
],
})
@ -1089,6 +1089,47 @@ describe('intakeNonIntegratedProductService', () => {
).toThrow('产品定厂合同存在未补齐的 SKU 主数据')
})
it('marks duplicated non-integrated SKU identities as conflict', () => {
const product = createPayload({
skuItems: [
createSkuItem({
id: 'sku-conflict-a',
skuCode: 'dup-sku-001',
barcode: 'BAR-CONFLICT-A',
specName: 'Conflict A',
model: 'MODEL-A',
}),
createSkuItem({
id: 'sku-conflict-b',
skuCode: ' DUP-SKU-001 ',
barcode: 'BAR-CONFLICT-B',
specName: 'Conflict B',
model: 'MODEL-B',
}),
],
})
const structuredInputs = buildStructuredSkuItemInputs(product)
expect(structuredInputs).toEqual([
expect.objectContaining({
skuCode: 'DUP-SKU-001',
dataQualityStatus: 'CONFLICT',
missingRequiredFields: [],
}),
expect.objectContaining({
skuCode: 'DUP-SKU-001',
dataQualityStatus: 'CONFLICT',
missingRequiredFields: [],
}),
])
expect(() =>
assertNonIntegratedSkuItemsReadyForFormalUse(product, {
usageLabel: '产品定厂合同',
}),
).toThrow('产品定厂合同存在冲突的 SKU 主数据')
})
it('blocks formal downstream use when non-integrated product profile master data is incomplete', () => {
const product = createPayload({
productDevelopmentKind: '',

View File

@ -1937,6 +1937,15 @@ function buildStructuredSkuItemOverlay(
sourceTable: readStructuredText(row.sourceTable),
sourceSkuId: readStructuredText(row.sourceSkuId),
contentLocked: row.contentLocked === true,
dataQualityStatus:
row.dataQualityStatus === 'CONFLICT' ||
row.dataQualityStatus === 'READY' ||
row.dataQualityStatus === 'INCOMPLETE'
? row.dataQualityStatus
: undefined,
missingRequiredFields: Array.isArray(row.missingRequiredFields)
? row.missingRequiredFields.filter((item): item is string => typeof item === 'string')
: undefined,
}
})
}
@ -3116,13 +3125,35 @@ export function buildStructuredSkuItemInputs(
product: NonIntegratedProductRecord & Record<string, unknown>,
): StructuredNonIntegratedSkuItemCreateInput[] {
const seenSkuItemIds = new Map<string, number>()
const skuCodeCounts = new Map<string, number>()
const barcodeCounts = new Map<string, number>()
product.skuItems.forEach((item) => {
const skuCode = normalizeSkuIdentityText(item.skuCode)
const barcode = normalizeSkuIdentityText(item.barcode)
if (skuCode) {
skuCodeCounts.set(skuCode, (skuCodeCounts.get(skuCode) ?? 0) + 1)
}
if (barcode) {
barcodeCounts.set(barcode, (barcodeCounts.get(barcode) ?? 0) + 1)
}
})
return product.skuItems.map((item, index) => {
const rawSkuItemId = trimSkuText(item.id) || `sku-${index + 1}`
const seenCount = seenSkuItemIds.get(rawSkuItemId) ?? 0
seenSkuItemIds.set(rawSkuItemId, seenCount + 1)
const skuItemId = seenCount === 0 ? rawSkuItemId : `${rawSkuItemId}-${seenCount + 1}`
const quality = resolveNonIntegratedSkuQuality(item)
const normalizedSkuCode = normalizeSkuIdentityText(item.skuCode)
const normalizedBarcode = normalizeSkuIdentityText(item.barcode)
const quality = resolveNonIntegratedSkuQuality(item, {
hasDuplicateSkuCode: normalizedSkuCode
? (skuCodeCounts.get(normalizedSkuCode) ?? 0) > 1
: false,
hasDuplicateBarcode: normalizedBarcode
? (barcodeCounts.get(normalizedBarcode) ?? 0) > 1
: false,
})
return {
id: `${product.id}:${skuItemId}`,

View File

@ -14,6 +14,10 @@ export const NON_INTEGRATED_PRODUCT_REQUIRED_FIELDS = [
] as const
type NonIntegratedSkuItem = NonIntegratedProductRecord['skuItems'][number]
type NonIntegratedSkuQualityOptions = {
hasDuplicateSkuCode?: boolean
hasDuplicateBarcode?: boolean
}
function trimSkuText(value: unknown) {
return typeof value === 'string' ? value.trim() : ''
@ -47,15 +51,42 @@ export function resolveNonIntegratedSkuMissingRequiredFields(
.map((field) => field.missingField)
}
export function resolveNonIntegratedSkuQuality(item: NonIntegratedSkuItem) {
export function resolveNonIntegratedSkuQuality(
item: NonIntegratedSkuItem,
options: NonIntegratedSkuQualityOptions = {},
) {
const missingRequiredFields = resolveNonIntegratedSkuMissingRequiredFields(item)
const hasIdentityConflict = options.hasDuplicateSkuCode || options.hasDuplicateBarcode
return {
dataQualityStatus: missingRequiredFields.length === 0 ? 'READY' : 'INCOMPLETE',
dataQualityStatus: hasIdentityConflict
? 'CONFLICT'
: missingRequiredFields.length === 0
? 'READY'
: 'INCOMPLETE',
missingRequiredFields,
} as const
}
function normalizeSkuIdentity(value: unknown) {
return trimSkuText(value).toUpperCase()
}
function countNonEmptyIdentities(
items: NonIntegratedSkuItem[],
key: 'skuCode' | 'barcode',
) {
const counts = new Map<string, number>()
items.forEach((item) => {
const identity = normalizeSkuIdentity(item[key])
if (!identity) return
counts.set(identity, (counts.get(identity) ?? 0) + 1)
})
return counts
}
export function resolveNonIntegratedProductMissingRequiredFields(
product: Partial<NonIntegratedProductRecord>,
) {
@ -109,6 +140,21 @@ export function assertNonIntegratedSkuItemsReadyForFormalUse(
const targetSkuItems = product.skuItems.filter((item) =>
batch === 'all' ? true : (item.batchNo || 1) === batch,
)
const skuCodeCounts = countNonEmptyIdentities(targetSkuItems, 'skuCode')
const barcodeCounts = countNonEmptyIdentities(targetSkuItems, 'barcode')
const conflictItems = targetSkuItems
.map((item, index) => ({
index,
item,
hasDuplicateSkuCode: (skuCodeCounts.get(normalizeSkuIdentity(item.skuCode)) ?? 0) > 1,
hasDuplicateBarcode: (barcodeCounts.get(normalizeSkuIdentity(item.barcode)) ?? 0) > 1,
}))
.filter(
(entry) =>
entry.item.dataQualityStatus === 'CONFLICT' ||
entry.hasDuplicateSkuCode ||
entry.hasDuplicateBarcode,
)
const incompleteItems = targetSkuItems
.map((item, index) => ({
index,
@ -117,6 +163,32 @@ export function assertNonIntegratedSkuItemsReadyForFormalUse(
}))
.filter((entry) => entry.missingFields.length > 0)
if (conflictItems.length > 0) {
const sample = conflictItems
.slice(0, 3)
.map((entry) => {
const rowLabel =
trimSkuText(entry.item.skuCode) ||
trimSkuText(entry.item.barcode) ||
trimSkuText(entry.item.model) ||
trimSkuText(entry.item.specName) ||
`第 ${entry.index + 1} 行`
const conflictLabels = [
entry.hasDuplicateSkuCode ? 'SKU编码重复' : null,
entry.hasDuplicateBarcode ? '条码重复' : null,
entry.item.dataQualityStatus === 'CONFLICT' ? '结构化资料冲突' : null,
].filter((item): item is string => Boolean(item))
return `${rowLabel} ${conflictLabels.join('、')}`
})
.join(';')
throw new AppError(
ErrorCode.VALIDATION_REQUIRED_FIELD,
`${options.usageLabel ?? '正式流转'}存在冲突的 SKU 主数据,不能继续:${sample}`,
)
}
if (incompleteItems.length === 0) {
return
}