feat(supply-chain): add product type column to product archive

产品全量档案在「商品状态」后新增「产品类型」:定厂「产品生产类型」(IntakeNonIntegratedProduct.payload.factoryRows[].productionType) 优先,rpt.jst_sku.vc_name 兜底,按条码对应,都没有留空;值原样写入。附迁移、筛选与回填脚本。
This commit is contained in:
WuYongJie 2026-09-28 16:30:14 +08:00
parent 3f36d2b51e
commit 199be5d7cb
16 changed files with 693 additions and 1 deletions

View File

@ -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",

View File

@ -0,0 +1,3 @@
-- 产品全量档案新增「产品类型」:定厂「产品生产类型」优先,JST rpt.jst_sku.vc_name 兜底。
ALTER TABLE "SupplyChainProductArchive"
ADD COLUMN "productionType" TEXT NOT NULL DEFAULT '';

View File

@ -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)

View File

@ -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
})

View File

@ -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 },

View File

@ -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: '重量' },

View File

@ -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',

View File

@ -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()

View File

@ -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,

View File

@ -161,6 +161,7 @@ export const FILTER_OPTION_DATABASE_FIELDS = {
productName: 'productName',
designer: 'designer',
productStatus: 'productStatus',
productionType: 'productionType',
material: 'material',
color: 'color',
brand: 'brand',

View File

@ -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()

View File

@ -24,6 +24,7 @@ export const productArchiveSelect = {
opsDeptHead: true,
opsHead: true,
productStatus: true,
productionType: true,
material: true,
color: true,
weight: true,

View File

@ -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)
})
})

View File

@ -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()

View File

@ -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(),

View File

@ -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),