feat(supply-chain): add product type column to product archive
产品全量档案在「商品状态」后新增「产品类型」:定厂「产品生产类型」(IntakeNonIntegratedProduct.payload.factoryRows[].productionType) 优先,rpt.jst_sku.vc_name 兜底,按条码对应,都没有留空;值原样写入。附迁移、筛选与回填脚本。
This commit is contained in:
parent
3f36d2b51e
commit
199be5d7cb
@ -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",
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
-- 产品全量档案新增「产品类型」:定厂「产品生产类型」优先,JST rpt.jst_sku.vc_name 兜底。
|
||||
ALTER TABLE "SupplyChainProductArchive"
|
||||
ADD COLUMN "productionType" TEXT NOT NULL DEFAULT '';
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
})
|
||||
@ -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 },
|
||||
|
||||
@ -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: '重量' },
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -0,0 +1,80 @@
|
||||
import sql from 'mssql'
|
||||
|
||||
export type JstSkuProductionTypeRow = {
|
||||
barcode: string
|
||||
productionType: string
|
||||
}
|
||||
|
||||
export interface JstSkuProductionTypeRepository {
|
||||
listProductionTypes(): Promise<JstSkuProductionTypeRow[]>
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
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<JstSkuProductionTypeRow>(`
|
||||
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()
|
||||
@ -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,
|
||||
|
||||
@ -161,6 +161,7 @@ export const FILTER_OPTION_DATABASE_FIELDS = {
|
||||
productName: 'productName',
|
||||
designer: 'designer',
|
||||
productStatus: 'productStatus',
|
||||
productionType: 'productionType',
|
||||
material: 'material',
|
||||
color: 'color',
|
||||
brand: 'brand',
|
||||
|
||||
@ -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<FactoryProductionTypeRow[]>
|
||||
listTargets(input: {
|
||||
afterId?: string
|
||||
limit: number
|
||||
}): Promise<ProductArchiveProductionTypeTarget[]>
|
||||
}
|
||||
|
||||
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<FactoryProductionTypeRow[]>`
|
||||
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()
|
||||
@ -24,6 +24,7 @@ export const productArchiveSelect = {
|
||||
opsDeptHead: true,
|
||||
opsHead: true,
|
||||
productStatus: true,
|
||||
productionType: true,
|
||||
material: true,
|
||||
color: true,
|
||||
weight: true,
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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<JstSkuProductionTypeRepository, 'listProductionTypes' | 'close'>
|
||||
writer: Pick<SupplyChainProductArchiveEnrichmentSyncRepository, 'applyPatch'>
|
||||
}
|
||||
|
||||
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<ProductArchiveProductionTypeFillResult> {
|
||||
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<string, string>()
|
||||
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<string, string>()
|
||||
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()
|
||||
@ -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(),
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user