diff --git a/furniture-3d-system/package.json b/furniture-3d-system/package.json index fa3fa7da..af8ab508 100644 --- a/furniture-3d-system/package.json +++ b/furniture-3d-system/package.json @@ -21,6 +21,8 @@ "sync:supply-chain-product-archive-enrichment": "tsx scripts/sync-supply-chain-product-archive-enrichment.ts", "backfill:product-archive-designer": "tsx scripts/backfill-product-archive-designer-from-jst-sku-info-manage.ts", "backfill:product-archive-designer:apply": "tsx scripts/backfill-product-archive-designer-from-jst-sku-info-manage.ts --apply", + "backfill:product-archive-production-type": "tsx scripts/backfill-product-archive-production-type.ts", + "backfill:product-archive-production-type:apply": "tsx scripts/backfill-product-archive-production-type.ts --apply", "sync:supply-chain-product-archive-sales-performance": "tsx scripts/sync-supply-chain-product-archive-sales-performance.ts", "sync:jst-sku-product-archive": "tsx scripts/sync-jst-sku-product-archive.ts", "sync:bom-cost-reference": "tsx scripts/sync-bom-cost-reference.ts", diff --git a/furniture-3d-system/prisma/migrations/20260928160000_add_supply_chain_product_archive_production_type/migration.sql b/furniture-3d-system/prisma/migrations/20260928160000_add_supply_chain_product_archive_production_type/migration.sql new file mode 100644 index 00000000..f682f1b1 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260928160000_add_supply_chain_product_archive_production_type/migration.sql @@ -0,0 +1,3 @@ +-- 产品全量档案新增「产品类型」:定厂「产品生产类型」优先,JST rpt.jst_sku.vc_name 兜底。 +ALTER TABLE "SupplyChainProductArchive" + ADD COLUMN "productionType" TEXT NOT NULL DEFAULT ''; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index e145fd43..dc5794a5 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -684,6 +684,7 @@ model SupplyChainProductArchive { productName String @default("") designer String @default("") productStatus String @default("") + productionType String @default("") material String @default("") color String @default("") weight Decimal? @db.Decimal(18, 3) diff --git a/furniture-3d-system/scripts/backfill-product-archive-production-type.ts b/furniture-3d-system/scripts/backfill-product-archive-production-type.ts new file mode 100644 index 00000000..f3480571 --- /dev/null +++ b/furniture-3d-system/scripts/backfill-product-archive-production-type.ts @@ -0,0 +1,87 @@ +import { loadEnvConfig } from '@next/env' + +loadEnvConfig(process.cwd()) + +function readArgument(name: string) { + const prefix = `--${name}=` + return process.argv.slice(2).find((argument) => argument.startsWith(prefix))?.slice(prefix.length) ?? '' +} + +function readRequired(name: string) { + const value = process.env[name]?.trim() + if (!value) throw new Error(`${name} is required.`) + return value +} + +function databaseIdentity(url: string) { + const parsed = new URL(url) + return [ + parsed.protocol.replace(/:$/, ''), + decodeURIComponent(parsed.username || ''), + parsed.hostname, + parsed.port || '', + parsed.pathname.replace(/^\//, ''), + parsed.searchParams.get('schema') ?? '', + ].join('|') +} + +function sourceDatabaseIdentity() { + return [ + 'sqlserver', + readRequired('JST_REPORT_DB_USER'), + readRequired('JST_REPORT_DB_HOST'), + process.env.JST_REPORT_DB_PORT?.trim() || '1433', + readRequired('JST_REPORT_DB_NAME'), + process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt', + ].join('|') +} + +/** + * 产品全量档案「产品类型」回填。 + * + * 口径(用户 2026-09-28 确认): + * - 定厂「产品生产类型」优先(payload.factoryRows[].productionType),JST jst_sku.vc_name 兜底; + * - 值原样写入,不做白名单过滤;两处都没有就留空; + * - 按 barcode 对齐档案条码。 + * + * 默认 dry-run,只统计会改多少行;必须显式带 --apply 才写库。 + */ +async function main() { + const apply = process.argv.includes('--apply') + const batchSizeValue = Number(readArgument('batch-size') || '200') + if (!Number.isSafeInteger(batchSizeValue) || batchSizeValue < 1 || batchSizeValue > 200) { + throw new Error('--batch-size must be an integer between 1 and 200.') + } + const expectedDatabaseIdentity = readArgument('expected-database-identity') + if (expectedDatabaseIdentity && expectedDatabaseIdentity !== databaseIdentity(readRequired('DATABASE_URL'))) { + throw new Error('expected database identity mismatch') + } + const expectedSourceIdentity = readArgument('expected-source-database-identity') + if (expectedSourceIdentity && expectedSourceIdentity !== sourceDatabaseIdentity()) { + throw new Error('expected source identity mismatch') + } + + const [ + { prisma }, + { supplyChainProductArchiveProductionTypeFillService }, + ] = await Promise.all([ + import('../src/lib/prisma'), + import('../src/server/services/supply-chain-product-archive-production-type-fill.service'), + ]) + + try { + console.log(apply ? 'mode=apply' : 'mode=dry-run') + const result = await supplyChainProductArchiveProductionTypeFillService.syncAll({ + apply, + batchSize: batchSizeValue, + }) + console.log(JSON.stringify(result, null, 2)) + } finally { + await prisma.$disconnect() + } +} + +void main().catch((error) => { + console.error(error instanceof Error ? (error.stack || error.message) : String(error)) + process.exitCode = 1 +}) diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/columns.ts b/furniture-3d-system/src/app/supply-chain/database/product-archive/columns.ts index 63250daf..84a8edbf 100644 --- a/furniture-3d-system/src/app/supply-chain/database/product-archive/columns.ts +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/columns.ts @@ -29,6 +29,7 @@ export const ARCHIVE_COLUMNS: ArchiveColumnGroup[] = [ { prop: 'productName', label: '商品名称', width: 180 }, { prop: 'designer', label: '设计师', width: 100 }, { prop: 'productStatus', label: '商品状态', width: 100 }, + { prop: 'productionType', label: '产品类型', width: 100 }, { prop: 'material', label: '材质', width: 100 }, { prop: 'color', label: '颜色', width: 90 }, { prop: 'spec', label: '产品尺寸', width: 100 }, diff --git a/furniture-3d-system/src/lib/supply-chain-product-archive-change-log.ts b/furniture-3d-system/src/lib/supply-chain-product-archive-change-log.ts index 2b9a2dcc..0a328e04 100644 --- a/furniture-3d-system/src/lib/supply-chain-product-archive-change-log.ts +++ b/furniture-3d-system/src/lib/supply-chain-product-archive-change-log.ts @@ -11,6 +11,7 @@ export const PRODUCT_ARCHIVE_CHANGE_FIELDS = [ { key: 'productName', label: '商品名称' }, { key: 'designer', label: '设计师' }, { key: 'productStatus', label: '商品状态' }, + { key: 'productionType', label: '产品类型' }, { key: 'material', label: '材质' }, { key: 'color', label: '颜色' }, { key: 'weight', label: '重量' }, diff --git a/furniture-3d-system/src/lib/supply-chain-product-archive.ts b/furniture-3d-system/src/lib/supply-chain-product-archive.ts index cddf177b..1f456b86 100644 --- a/furniture-3d-system/src/lib/supply-chain-product-archive.ts +++ b/furniture-3d-system/src/lib/supply-chain-product-archive.ts @@ -18,6 +18,7 @@ export type SupplyChainProductArchiveRow = { opsDeptHead: string opsHead: string productStatus: string + productionType: string material: string color: string weight: string @@ -148,6 +149,7 @@ export type ProductArchiveFilters = { productName?: string[] designer?: string[] productStatus?: ProductArchiveStatusFilter[] + productionType?: string[] material?: string[] color?: string[] brand?: string[] @@ -233,7 +235,8 @@ export type ProductArchiveFilters = { export const PRODUCT_ARCHIVE_FILTER_OPTION_KEYS = [ 'image', 'categoryL1', 'categoryL2', 'spu', 'sku', 'barcode', 'productName', 'designer', - 'productStatus', 'material', 'color', 'spec', 'weight', 'length', 'width', 'height', 'volume', + 'productStatus', 'productionType', 'material', 'color', 'spec', 'weight', 'length', 'width', + 'height', 'volume', 'packType', 'productTag', 'brand', 'orderType', 'storageType', 'isSeasonal', 'replaceCount', 'region', 'supplier', 'supplierType', 'supplierStatus', 'purchaser', 'settleType', 'boardProvider', 'penaltyStatus', 'moq', 'firstLeadTime', 'stableLeadTime', diff --git a/furniture-3d-system/src/server/repositories/jst-sku-production-type.repository.ts b/furniture-3d-system/src/server/repositories/jst-sku-production-type.repository.ts new file mode 100644 index 00000000..173121cb --- /dev/null +++ b/furniture-3d-system/src/server/repositories/jst-sku-production-type.repository.ts @@ -0,0 +1,80 @@ +import sql from 'mssql' + +export type JstSkuProductionTypeRow = { + barcode: string + productionType: string +} + +export interface JstSkuProductionTypeRepository { + listProductionTypes(): Promise + close(): Promise +} + +function requiredEnv(name: string) { + const value = process.env[name]?.trim() + if (!value) throw new Error(`Missing required environment variable: ${name}`) + return value +} + +function booleanEnv(name: string, fallback: boolean) { + const value = process.env[name]?.trim().toLowerCase() + if (!value) return fallback + return value === 'true' || value === '1' +} + +function safeIdentifier(value: string, label: string) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) { + throw new Error(`${label} is not a valid SQL Server identifier`) + } + return value +} + +class JstSkuProductionTypeRepositoryClass implements JstSkuProductionTypeRepository { + private pool: sql.ConnectionPool | null = null + + private async getPool() { + if (this.pool?.connected) return this.pool + this.pool = await new sql.ConnectionPool({ + server: requiredEnv('JST_REPORT_DB_HOST'), + port: Number(process.env.JST_REPORT_DB_PORT?.trim() || '1433'), + database: requiredEnv('JST_REPORT_DB_NAME'), + user: requiredEnv('JST_REPORT_DB_USER'), + password: requiredEnv('JST_REPORT_DB_PASSWORD'), + options: { + encrypt: booleanEnv('JST_REPORT_DB_ENCRYPT', true), + trustServerCertificate: booleanEnv('JST_REPORT_DB_TRUST_SERVER_CERTIFICATE', true), + enableArithAbort: true, + }, + pool: { min: 0, max: 4, idleTimeoutMillis: 30_000 }, + requestTimeout: 180_000, + connectionTimeout: 30_000, + }).connect() + return this.pool + } + + // 聚水潭商品资料的 vc_name 承载「产品生产类型」(OEM/ODM),按 sku_id 对齐档案条码。 + async listProductionTypes() { + const schema = safeIdentifier( + process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt', + 'JST_REPORT_DB_SCHEMA', + ) + const pool = await this.getPool() + const result = await pool.request().query(` + SELECT + UPPER(LTRIM(RTRIM(CAST(sku_id AS nvarchar(200))))) AS barcode, + LTRIM(RTRIM(CAST(vc_name AS nvarchar(200)))) AS productionType + FROM [${schema}].[jst_sku] + WHERE vc_name IS NOT NULL + AND LTRIM(RTRIM(CAST(vc_name AS nvarchar(200)))) <> '' + `) + return result.recordset + } + + async close() { + if (!this.pool) return + await this.pool.close() + this.pool = null + } +} + +export const jstSkuProductionTypeRepository = new JstSkuProductionTypeRepositoryClass() diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-enrichment-sync.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-enrichment-sync.repository.ts index 050c4720..4adf2b27 100644 --- a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-enrichment-sync.repository.ts +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-enrichment-sync.repository.ts @@ -14,6 +14,7 @@ import { export const PRODUCT_ARCHIVE_ENRICHMENT_FIELDS = [ 'image', 'designer', + 'productionType', 'opsDirector', 'opsDeptHead', 'opsHead', @@ -84,6 +85,7 @@ const currentArchiveSelect = { image: true, fieldSourceMap: true, designer: true, + productionType: true, opsDirector: true, opsDeptHead: true, opsHead: true, diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-filters.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-filters.ts index 8adb357a..2e2b2dbc 100644 --- a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-filters.ts +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-filters.ts @@ -161,6 +161,7 @@ export const FILTER_OPTION_DATABASE_FIELDS = { productName: 'productName', designer: 'designer', productStatus: 'productStatus', + productionType: 'productionType', material: 'material', color: 'color', brand: 'brand', diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-production-type.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-production-type.repository.ts new file mode 100644 index 00000000..a257f761 --- /dev/null +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-production-type.repository.ts @@ -0,0 +1,73 @@ +import { Prisma } from '@prisma/client' +import { prisma } from '@/lib/prisma' +import { + parseProductArchiveFieldSourceMap, +} from '@/lib/supply-chain-product-archive-field-source' + +export type FactoryProductionTypeRow = { + barcode: string + productionType: string +} + +export type ProductArchiveProductionTypeTarget = { + id: string + barcode: string + productionType: string + productionTypeSourceCode: string +} + +export interface SupplyChainProductArchiveProductionTypeRepository { + listFactoryProductionTypes(): Promise + listTargets(input: { + afterId?: string + limit: number + }): Promise +} + +const targetSelect = { + id: true, + barcode: true, + productionType: true, + fieldSourceMap: true, +} satisfies Prisma.SupplyChainProductArchiveSelect + +class SupplyChainProductArchiveProductionTypeRepositoryClass + implements SupplyChainProductArchiveProductionTypeRepository +{ + // 定厂「产品生产类型」按 SKU 行存在产品 payload 的 factoryRows 里,用条码对齐档案。 + async listFactoryProductionTypes() { + const rows = await prisma.$queryRaw` + SELECT + upper(btrim(row->>'barcode')) AS "barcode", + btrim(row->>'productionType') AS "productionType" + FROM "IntakeNonIntegratedProduct" AS product, + jsonb_array_elements( + COALESCE(product."payload"::jsonb->'factoryRows', '[]'::jsonb) + ) AS row + WHERE product."deletedAt" IS NULL + AND COALESCE(btrim(row->>'barcode'), '') <> '' + AND COALESCE(btrim(row->>'productionType'), '') <> '' + ` + return rows + } + + async listTargets(input: { afterId?: string; limit: number }) { + const records = await prisma.supplyChainProductArchive.findMany({ + where: input.afterId ? { id: { gt: input.afterId } } : undefined, + select: targetSelect, + orderBy: { id: 'asc' }, + take: Math.min(200, Math.max(1, Math.trunc(input.limit))), + }) + return records.map((record) => ({ + id: record.id, + barcode: record.barcode, + productionType: record.productionType, + productionTypeSourceCode: + parseProductArchiveFieldSourceMap(record.fieldSourceMap).productionType + ?.sourceCode ?? '', + })) + } +} + +export const supplyChainProductArchiveProductionTypeRepository = + new SupplyChainProductArchiveProductionTypeRepositoryClass() diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-select.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-select.ts index c20fd61d..e5383348 100644 --- a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-select.ts +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-select.ts @@ -24,6 +24,7 @@ export const productArchiveSelect = { opsDeptHead: true, opsHead: true, productStatus: true, + productionType: true, material: true, color: true, weight: true, diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-production-type-fill.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-production-type-fill.service.test.ts new file mode 100644 index 00000000..b02e6c13 --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-production-type-fill.service.test.ts @@ -0,0 +1,195 @@ +jest.mock('@/lib/prisma', () => ({ prisma: {} })) + +jest.mock('@/server/repositories/jst-sku-production-type.repository', () => ({ + jstSkuProductionTypeRepository: { listProductionTypes: jest.fn(), close: jest.fn() }, +})) + +jest.mock('@/server/repositories/supply-chain-product-archive-production-type.repository', () => ({ + supplyChainProductArchiveProductionTypeRepository: { + listFactoryProductionTypes: jest.fn(), + listTargets: jest.fn(), + }, +})) + +jest.mock('@/server/repositories/supply-chain-product-archive-enrichment-sync.repository', () => ({ + supplyChainProductArchiveEnrichmentSyncRepository: { applyPatch: jest.fn() }, +})) + +jest.mock('../supply-chain-product-archive-change-log.service', () => ({ + recordProductArchiveFieldChanges: jest.fn().mockResolvedValue(undefined), +})) + +import { recordProductArchiveFieldChanges } from '../supply-chain-product-archive-change-log.service' +import { + decideProductArchiveProductionType, + SupplyChainProductArchiveProductionTypeFillService, +} from '../supply-chain-product-archive-production-type-fill.service' + +const mockRecordChanges = recordProductArchiveFieldChanges as jest.Mock + +describe('decideProductArchiveProductionType', () => { + it('skips when neither source has a value', () => { + expect( + decideProductArchiveProductionType({ currentProductionType: '' }), + ).toEqual({ kind: 'skip' }) + }) + + it('fills an empty value from JST and marks the JST source', () => { + expect( + decideProductArchiveProductionType({ + currentProductionType: '', + jstProductionType: 'ODM-q', + }), + ).toEqual({ kind: 'write', productionType: 'ODM-q', sourceCode: 'JST_SKU' }) + }) + + it('prefers the factory value and marks the product development source', () => { + expect( + decideProductArchiveProductionType({ + currentProductionType: '', + factoryProductionType: 'OEM', + jstProductionType: 'ODM', + }), + ).toEqual({ kind: 'write', productionType: 'OEM', sourceCode: 'PRODUCT_DEVELOPMENT' }) + }) + + it('lets the factory value replace any existing source', () => { + expect( + decideProductArchiveProductionType({ + currentProductionType: 'ODM', + currentSourceCode: 'JST_SKU', + factoryProductionType: 'OEM', + }), + ).toEqual({ kind: 'write', productionType: 'OEM', sourceCode: 'PRODUCT_DEVELOPMENT' }) + }) + + it('lets JST update its own previous value', () => { + expect( + decideProductArchiveProductionType({ + currentProductionType: 'ODM', + currentSourceCode: 'JST_SKU', + jstProductionType: 'ODM-q', + }), + ).toEqual({ kind: 'write', productionType: 'ODM-q', sourceCode: 'JST_SKU' }) + }) + + it('never lets JST overwrite a non-JST source and treats equal values as unchanged', () => { + expect( + decideProductArchiveProductionType({ + currentProductionType: 'OEM', + currentSourceCode: 'PRODUCT_DEVELOPMENT', + jstProductionType: 'ODM', + }), + ).toEqual({ kind: 'skip' }) + expect( + decideProductArchiveProductionType({ + currentProductionType: 'ODM', + currentSourceCode: 'JST_SKU', + jstProductionType: 'ODM', + }), + ).toEqual({ kind: 'unchanged' }) + }) +}) + +function createDependencies(input: { + targets: Array<{ id: string; barcode: string; productionType: string; productionTypeSourceCode: string }> + factoryRows: Array<{ barcode: string; productionType: string }> + jstRows: Array<{ barcode: string; productionType: string }> +}) { + const listTargets = jest.fn().mockResolvedValueOnce(input.targets).mockResolvedValueOnce([]) + const listFactoryProductionTypes = jest.fn().mockResolvedValue(input.factoryRows) + const listProductionTypes = jest.fn().mockResolvedValue(input.jstRows) + const close = jest.fn().mockResolvedValue(undefined) + const applyPatch = jest.fn().mockResolvedValue({ + changes: [ + { field: 'productionType', fieldLabel: '产品类型', previousValue: '', nextValue: 'OEM' }, + ], + sourceInitialized: false, + }) + return { + archiveRepository: { listFactoryProductionTypes, listTargets }, + jstRepository: { listProductionTypes, close }, + writer: { applyPatch }, + listTargets, + applyPatch, + close, + } +} + +describe('SupplyChainProductArchiveProductionTypeFillService', () => { + beforeEach(() => mockRecordChanges.mockClear()) + + it('previews writes without touching the database in dry-run mode', async () => { + const dependencies = createDependencies({ + targets: [ + { id: 'a-1', barcode: 'B-1', productionType: '', productionTypeSourceCode: '' }, + { id: 'a-2', barcode: 'B-2', productionType: '', productionTypeSourceCode: '' }, + { id: 'a-3', barcode: 'B-3', productionType: 'OEM', productionTypeSourceCode: 'PRODUCT_DEVELOPMENT' }, + ], + factoryRows: [{ barcode: 'B-1', productionType: 'OEM' }], + jstRows: [ + { barcode: 'B-2', productionType: 'ODM' }, + { barcode: 'B-3', productionType: 'ODM' }, + ], + }) + const service = new SupplyChainProductArchiveProductionTypeFillService(dependencies) + + const result = await service.syncAll({ apply: false, batchSize: 200 }) + + expect(result).toMatchObject({ + apply: false, + factoryRows: 1, + jstRows: 2, + scanned: 3, + matchedFromFactory: 1, + matchedFromJst: 2, + written: 2, + unchanged: 0, + skipped: 1, + }) + expect(dependencies.applyPatch).not.toHaveBeenCalled() + expect(mockRecordChanges).not.toHaveBeenCalled() + expect(dependencies.close).toHaveBeenCalled() + }) + + it('writes the factory source for matches and the JST source for fallbacks', async () => { + const dependencies = createDependencies({ + targets: [ + { id: 'a-1', barcode: 'B-1', productionType: '', productionTypeSourceCode: '' }, + { id: 'a-2', barcode: 'B-2', productionType: '', productionTypeSourceCode: '' }, + ], + factoryRows: [{ barcode: 'B-1', productionType: 'OEM' }], + jstRows: [{ barcode: 'B-2', productionType: 'ODM' }], + }) + const service = new SupplyChainProductArchiveProductionTypeFillService(dependencies) + + const result = await service.syncAll({ apply: true, batchSize: 200 }) + + expect(result).toMatchObject({ written: 2, unchanged: 0 }) + expect(dependencies.applyPatch).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + archiveId: 'a-1', + patch: { productionType: 'OEM' }, + source: expect.objectContaining({ + sourceCode: 'PRODUCT_DEVELOPMENT', + sourceObject: 'IntakeNonIntegratedProduct.payload.factoryRows', + sourceColumns: { productionType: 'productionType' }, + }), + }), + ) + expect(dependencies.applyPatch).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + archiveId: 'a-2', + patch: { productionType: 'ODM' }, + source: expect.objectContaining({ + sourceCode: 'JST_SKU', + sourceObject: 'jst_sku', + sourceColumns: { productionType: 'vc_name' }, + }), + }), + ) + expect(mockRecordChanges).toHaveBeenCalledTimes(2) + }) +}) diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-production-type-fill.service.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-production-type-fill.service.ts new file mode 100644 index 00000000..e184bc0a --- /dev/null +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-production-type-fill.service.ts @@ -0,0 +1,240 @@ +import { + JST_SKU_ARCHIVE_SOURCE_CODE, + PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE, +} from '@/lib/supply-chain-product-archive-field-source' +import { + supplyChainProductArchiveEnrichmentSyncRepository, + type ProductArchiveEnrichmentPatch, + type ProductArchiveEnrichmentSource, + type SupplyChainProductArchiveEnrichmentSyncRepository, +} from '@/server/repositories/supply-chain-product-archive-enrichment-sync.repository' +import { + supplyChainProductArchiveProductionTypeRepository, + type SupplyChainProductArchiveProductionTypeRepository, +} from '@/server/repositories/supply-chain-product-archive-production-type.repository' +import { + jstSkuProductionTypeRepository, + type JstSkuProductionTypeRepository, +} from '@/server/repositories/jst-sku-production-type.repository' +import { recordProductArchiveFieldChanges } from './supply-chain-product-archive-change-log.service' + +const JST_SOURCE_SYSTEM = '聚水潭 JSReportReplica' +const JST_SOURCE_SCHEMA = 'rpt' +const JST_PRODUCTION_TYPE_OBJECT = 'jst_sku' +const JST_PRODUCTION_TYPE_COLUMN = 'vc_name' + +const FACTORY_SOURCE_SYSTEM = '家具产品开发系统' +const FACTORY_SOURCE_SCHEMA = 'public' +const FACTORY_SOURCE_OBJECT = 'IntakeNonIntegratedProduct.payload.factoryRows' +const FACTORY_PRODUCTION_TYPE_COLUMN = 'productionType' + +export type ProductArchiveProductionTypeDecision = + | { kind: 'skip' } + | { kind: 'unchanged' } + | { kind: 'write'; productionType: string; sourceCode: string } + +/** + * 产品类型取值口径(用户 2026-09-28 确认): + * - 定厂「产品生产类型」优先,JST jst_sku.vc_name 兜底; + * - 值原样保留,不做白名单过滤; + * - 定厂值可以覆盖任何已有来源;JST 只覆盖自己的历史值,不覆盖其他来源。 + */ +export function decideProductArchiveProductionType(input: { + currentProductionType: string | null | undefined + currentSourceCode?: string | null + factoryProductionType?: string | null + jstProductionType?: string | null +}): ProductArchiveProductionTypeDecision { + const factoryProductionType = input.factoryProductionType?.trim() ?? '' + const jstProductionType = input.jstProductionType?.trim() ?? '' + const currentProductionType = input.currentProductionType?.trim() ?? '' + const currentSourceCode = input.currentSourceCode?.trim() ?? '' + + const productionType = factoryProductionType || jstProductionType + if (!productionType) return { kind: 'skip' } + + const sourceCode = factoryProductionType + ? PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE + : JST_SKU_ARCHIVE_SOURCE_CODE + + if (currentProductionType === productionType) return { kind: 'unchanged' } + if (!currentProductionType) return { kind: 'write', productionType, sourceCode } + if (factoryProductionType) return { kind: 'write', productionType, sourceCode } + if (currentSourceCode === JST_SKU_ARCHIVE_SOURCE_CODE) { + return { kind: 'write', productionType, sourceCode } + } + return { kind: 'skip' } +} + +export type ProductArchiveProductionTypeFillResult = { + apply: boolean + factoryRows: number + jstRows: number + scanned: number + matchedFromFactory: number + matchedFromJst: number + written: number + unchanged: number + skipped: number + lastId: string +} + +type ProductionTypeFillDependencies = { + archiveRepository: Pick< + SupplyChainProductArchiveProductionTypeRepository, + 'listFactoryProductionTypes' | 'listTargets' + > + jstRepository: Pick + writer: Pick +} + +function buildSource(input: { sourceCode: string; barcode: string }): ProductArchiveEnrichmentSource { + if (input.sourceCode === PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE) { + return { + sourceCode: PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE, + sourceSystem: FACTORY_SOURCE_SYSTEM, + sourceSchema: FACTORY_SOURCE_SCHEMA, + sourceObject: FACTORY_SOURCE_OBJECT, + sourceRecordId: input.barcode, + sourceColumns: { productionType: FACTORY_PRODUCTION_TYPE_COLUMN }, + } + } + return { + sourceCode: JST_SKU_ARCHIVE_SOURCE_CODE, + sourceSystem: JST_SOURCE_SYSTEM, + sourceSchema: JST_SOURCE_SCHEMA, + sourceObject: JST_PRODUCTION_TYPE_OBJECT, + sourceRecordId: input.barcode, + sourceColumns: { productionType: JST_PRODUCTION_TYPE_COLUMN }, + } +} + +export class SupplyChainProductArchiveProductionTypeFillService { + constructor( + private readonly dependencies: ProductionTypeFillDependencies = { + archiveRepository: supplyChainProductArchiveProductionTypeRepository, + jstRepository: jstSkuProductionTypeRepository, + writer: supplyChainProductArchiveEnrichmentSyncRepository, + }, + ) {} + + async syncAll(input: { + apply: boolean + batchSize?: number + }): Promise { + const apply = input.apply === true + const batchSize = Math.min(200, Math.max(1, Math.trunc(input.batchSize ?? 200))) + const result: ProductArchiveProductionTypeFillResult = { + apply, + factoryRows: 0, + jstRows: 0, + scanned: 0, + matchedFromFactory: 0, + matchedFromJst: 0, + written: 0, + unchanged: 0, + skipped: 0, + lastId: '', + } + + try { + const factoryRows = await this.dependencies.archiveRepository.listFactoryProductionTypes() + result.factoryRows = factoryRows.length + const factoryByBarcode = new Map() + for (const row of factoryRows) { + const barcode = row.barcode?.trim().toUpperCase() ?? '' + const productionType = row.productionType?.trim() ?? '' + if (barcode && productionType && !factoryByBarcode.has(barcode)) { + factoryByBarcode.set(barcode, productionType) + } + } + + const jstRows = await this.dependencies.jstRepository.listProductionTypes() + result.jstRows = jstRows.length + const jstByBarcode = new Map() + for (const row of jstRows) { + const barcode = row.barcode?.trim().toUpperCase() ?? '' + const productionType = row.productionType?.trim() ?? '' + if (barcode && productionType && !jstByBarcode.has(barcode)) { + jstByBarcode.set(barcode, productionType) + } + } + + while (true) { + const targets = await this.dependencies.archiveRepository.listTargets({ + afterId: result.lastId || undefined, + limit: batchSize, + }) + if (targets.length === 0) break + + for (const target of targets) { + result.scanned += 1 + const barcode = target.barcode?.trim().toUpperCase() ?? '' + const factoryProductionType = barcode ? factoryByBarcode.get(barcode) ?? '' : '' + const jstProductionType = barcode ? jstByBarcode.get(barcode) ?? '' : '' + if (!factoryProductionType && !jstProductionType) { + result.skipped += 1 + continue + } + if (factoryProductionType) result.matchedFromFactory += 1 + else result.matchedFromJst += 1 + + const decision = decideProductArchiveProductionType({ + currentProductionType: target.productionType, + currentSourceCode: target.productionTypeSourceCode, + factoryProductionType, + jstProductionType, + }) + if (decision.kind === 'skip') { + result.skipped += 1 + continue + } + if (decision.kind === 'unchanged') { + result.unchanged += 1 + continue + } + if (!apply) { + result.written += 1 + continue + } + + const patch: ProductArchiveEnrichmentPatch = { + productionType: decision.productionType, + } + const applied = await this.dependencies.writer.applyPatch({ + archiveId: target.id, + patch, + source: buildSource({ sourceCode: decision.sourceCode, barcode: target.barcode }), + }) + if (applied.changes.length === 0) { + result.unchanged += 1 + continue + } + result.written += 1 + if (applied.sourceInitialized) continue + await recordProductArchiveFieldChanges({ + barcode: target.barcode, + actor: { + source: 'SYSTEM', + actorName: + decision.sourceCode === PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE + ? `${FACTORY_SOURCE_SYSTEM} / ${FACTORY_SOURCE_SCHEMA} / ${FACTORY_SOURCE_OBJECT}` + : `${JST_SOURCE_SYSTEM} / ${JST_SOURCE_SCHEMA} / ${JST_PRODUCTION_TYPE_OBJECT}`, + }, + changes: applied.changes, + }) + } + + result.lastId = targets[targets.length - 1]?.id ?? result.lastId + if (targets.length < batchSize) break + } + + return result + } finally { + await this.dependencies.jstRepository.close() + } + } +} + +export const supplyChainProductArchiveProductionTypeFillService = + new SupplyChainProductArchiveProductionTypeFillService() diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-query.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-query.ts index 53a6e2df..1ef983fd 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive-query.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-query.ts @@ -37,6 +37,7 @@ const productArchiveFiltersSchema = z.object({ z.enum(PRODUCT_ARCHIVE_SALES_STATUSES), z.literal(PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE), ])).max(3).optional(), + productionType: archiveValueArraySchema.optional(), material: archiveValueArraySchema.optional(), color: archiveValueArraySchema.optional(), brand: archiveValueArraySchema.optional(), diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts index 18b0db18..f1def4d5 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts @@ -69,6 +69,7 @@ export function mapProductArchiveRow({ opsDeptHead: record.opsDeptHead, opsHead: record.opsHead, productStatus: record.productStatus, + productionType: record.productionType, material: record.material, color: record.color, weight: decimalText(record.weight),