refactor(supply-chain): retire sku operator source

This commit is contained in:
WuYongJie 2026-08-14 09:39:20 +08:00
parent 032b609f1b
commit 0d6d080687
8 changed files with 74 additions and 407 deletions

View File

@ -0,0 +1,52 @@
BEGIN;
CREATE TEMP TABLE "_retired_js_sku_info_manage_archives" ON COMMIT DROP AS
SELECT
"id",
"fieldSourceMap"->'opsHead'->>'sourceCode' = 'JST_SKU_INFO_MANAGE' AS "clearOpsHead",
"fieldSourceMap"->'opsDeptHead'->>'sourceCode' = 'JST_SKU_INFO_MANAGE' AS "clearOpsDeptHead",
"fieldSourceMap"->'designer'->>'sourceCode' = 'JST_SKU_INFO_MANAGE' AS "clearDesigner",
(
"fieldSourceMap"->'opsHead'->>'sourceCode' = 'JST_SKU_INFO_MANAGE'
AND "fieldSourceMap"->'opsDirector'->>'sourceCode' = 'SYSTEM_USER_DEPARTMENT'
) AS "clearOpsDirector",
ARRAY_REMOVE(ARRAY[
CASE WHEN "fieldSourceMap"->'opsHead'->>'sourceCode' = 'JST_SKU_INFO_MANAGE' THEN 'opsHead' END,
CASE WHEN "fieldSourceMap"->'opsDeptHead'->>'sourceCode' = 'JST_SKU_INFO_MANAGE' THEN 'opsDeptHead' END,
CASE WHEN "fieldSourceMap"->'designer'->>'sourceCode' = 'JST_SKU_INFO_MANAGE' THEN 'designer' END,
CASE WHEN
"fieldSourceMap"->'opsHead'->>'sourceCode' = 'JST_SKU_INFO_MANAGE'
AND "fieldSourceMap"->'opsDirector'->>'sourceCode' = 'SYSTEM_USER_DEPARTMENT'
THEN 'opsDirector'
END
], NULL)::text[] AS "sourceKeys"
FROM "SupplyChainProductArchive"
WHERE
"fieldSourceMap"->'opsHead'->>'sourceCode' = 'JST_SKU_INFO_MANAGE'
OR "fieldSourceMap"->'opsDeptHead'->>'sourceCode' = 'JST_SKU_INFO_MANAGE'
OR "fieldSourceMap"->'designer'->>'sourceCode' = 'JST_SKU_INFO_MANAGE';
CREATE INDEX ON "_retired_js_sku_info_manage_archives" ("id");
UPDATE "SupplyChainProductArchive" AS archive
SET
"opsHead" = CASE WHEN retired."clearOpsHead" THEN '' ELSE archive."opsHead" END,
"opsDeptHead" = CASE WHEN retired."clearOpsDeptHead" THEN '' ELSE archive."opsDeptHead" END,
"designer" = CASE WHEN retired."clearDesigner" THEN '' ELSE archive."designer" END,
"opsDirector" = CASE WHEN retired."clearOpsDirector" THEN '' ELSE archive."opsDirector" END,
"fieldSourceMap" = archive."fieldSourceMap" - retired."sourceKeys",
"updatedAt" = CURRENT_TIMESTAMP
FROM "_retired_js_sku_info_manage_archives" AS retired
WHERE archive."id" = retired."id";
DELETE FROM "SupplyChainProductArchiveSource"
WHERE "sourceCode" = 'JST_SKU_INFO_MANAGE';
DELETE FROM "SupplyChainProductArchiveSource" AS source
USING "_retired_js_sku_info_manage_archives" AS retired
WHERE
source."archiveId" = retired."id"
AND retired."clearOpsDirector"
AND source."sourceCode" = 'SYSTEM_USER_DEPARTMENT';
COMMIT;

View File

@ -67,11 +67,10 @@ async function main() {
}
const syncJstBase = process.argv.includes('--sync-jst-base')
const operatorOnly = process.argv.includes('--operator-only')
const opsDirectorOnly = process.argv.includes('--ops-director-only')
if (operatorOnly && opsDirectorOnly) {
throw new Error('--operator-only and --ops-director-only cannot be used together')
if (process.argv.includes('--operator-only')) {
throw new Error('--operator-only has been retired; js_sku_info_manage is no longer a product archive source.')
}
const opsDirectorOnly = process.argv.includes('--ops-director-only')
const jstBatchSize = Number(readArgument('jst-batch-size') || '500')
if (
!Number.isSafeInteger(jstBatchSize) ||
@ -92,13 +91,11 @@ async function main() {
const [
{ prisma },
{ supplyChainProductArchiveEnrichmentSyncService },
{ supplyChainProductArchiveOperatorInitialSyncService },
{ supplyChainProductArchiveOpsDirectorSyncService },
{ jstSkuProductArchiveFullSyncService },
] = await Promise.all([
import('../src/lib/prisma'),
import('../src/server/services/supply-chain-product-archive-enrichment-sync.service'),
import('../src/server/services/supply-chain-product-archive-operator-initial-sync.service'),
import('../src/server/services/supply-chain-product-archive-ops-director-sync.service'),
syncJstBase
? import('../src/server/services/jst-sku-product-archive-full-sync.service')
@ -112,24 +109,18 @@ async function main() {
concurrency: jstConcurrency,
})
: null
const operatorInitialResult = operatorOnly
? await supplyChainProductArchiveOperatorInitialSyncService.syncAll({
batchSize: batchSizeValue,
})
: null
const opsDirectorResult = opsDirectorOnly
? await supplyChainProductArchiveOpsDirectorSyncService.syncAll({
batchSize: batchSizeValue,
})
: null
const enrichmentResult = operatorOnly || opsDirectorOnly
const enrichmentResult = opsDirectorOnly
? null
: await supplyChainProductArchiveEnrichmentSyncService.syncAll({
batchSize: batchSizeValue,
})
console.log(JSON.stringify({
baseResult,
operatorInitialResult,
opsDirectorResult,
enrichmentResult,
}, null, 2))

View File

@ -11,7 +11,6 @@ export const JST_WAREHOUSE_ARCHIVE_SOURCE_CODE = 'JST_WAREHOUSE_INFO'
export const JST_SKU_PRICE_ARCHIVE_SOURCE_CODE = 'JST_SKU_PRICE'
export const PRODUCT_CATALOG_COST_ARCHIVE_SOURCE_CODE = 'PRODUCT_CATALOG_COST_PRICE'
export const WAREHOUSE_CONFIG_ARCHIVE_SOURCE_CODE = 'WAREHOUSE_CONFIG'
export const JST_SKU_OPERATOR_ARCHIVE_SOURCE_CODE = 'JST_SKU_INFO_MANAGE'
export const SYSTEM_USER_DEPARTMENT_ARCHIVE_SOURCE_CODE = 'SYSTEM_USER_DEPARTMENT'
export type ProductArchiveFieldSource = {
@ -80,12 +79,6 @@ export const PRODUCT_DEVELOPMENT_ARCHIVE_FIELD_COLUMNS = {
minimumOrderQuantity: 'archive_minimum_order_quantity',
} as const
export const JST_SKU_OPERATOR_ARCHIVE_FIELD_COLUMNS = {
opsDeptHead: 'operation_dept_manager',
opsHead: 'sku_manager',
designer: 'designer',
} as const
export const DRAWING_MEDIA_ARCHIVE_FIELD_COLUMNS = {
installDocId: '图纸ID',
hasDrawing: '线稿图 | 爆炸图 | 安装图',

View File

@ -36,36 +36,36 @@ function createTransaction(input: {
}
}
const initialSource = {
sourceCode: 'JST_SKU_INFO_MANAGE',
sourceSystem: '聚水潭 JSReportReplica',
sourceSchema: 'rpt',
sourceObject: 'js_sku_info_manage',
const initialImportSource = {
sourceCode: 'INITIAL_OWNER_IMPORT',
sourceSystem: 'Local seed import',
sourceSchema: 'public',
sourceObject: 'ProductArchiveOwnerSeed',
sourceRecordId: 'BAR-001',
sourceColumns: {
designer: 'designer',
opsDeptHead: 'operation_dept_manager',
opsHead: 'sku_manager',
opsDeptHead: 'opsDeptHead',
opsHead: 'opsHead',
},
fillEmptyOnly: true,
} as const
describe('product archive initial operator source', () => {
describe('product archive initial import source', () => {
beforeEach(() => jest.clearAllMocks())
it('fills only empty owner fields and preserves an existing designer', async () => {
const tx = createTransaction({ designer: '现有设计师' })
const tx = createTransaction({ designer: 'Existing designer' })
mockPrisma.$transaction.mockImplementation(async (callback) => callback(tx))
const repository = new SupplyChainProductArchiveEnrichmentSyncRepositoryClass()
const result = await repository.applyPatch({
archiveId: 'archive-1',
patch: {
designer: '来源设计师',
opsDeptHead: '运营部门负责人',
opsHead: '运营负责人',
designer: 'Imported designer',
opsDeptHead: 'Imported department owner',
opsHead: 'Imported operator',
},
source: initialSource,
source: initialImportSource,
})
expect(result.changes.map((change) => change.field)).toEqual([
@ -76,23 +76,23 @@ describe('product archive initial operator source', () => {
expect.objectContaining({
where: { id: 'archive-1' },
data: expect.objectContaining({
opsDeptHead: '运营部门负责人',
opsHead: '运营负责人',
opsDeptHead: 'Imported department owner',
opsHead: 'Imported operator',
}),
}),
)
expect(tx.supplyChainProductArchive.update.mock.calls[0][0].data).not.toHaveProperty('designer')
})
it('never reapplies the initial source after that barcode was initialized', async () => {
it('never reapplies an initial source after that barcode was initialized', async () => {
const tx = createTransaction({ existingSource: { id: 'source-1' } })
mockPrisma.$transaction.mockImplementation(async (callback) => callback(tx))
const repository = new SupplyChainProductArchiveEnrichmentSyncRepositoryClass()
await expect(repository.applyPatch({
archiveId: 'archive-1',
patch: { opsHead: '后续变化的负责人' },
source: initialSource,
patch: { opsHead: 'Changed operator' },
source: initialImportSource,
})).resolves.toEqual({ changes: [], sourceInitialized: false })
expect(tx.supplyChainProductArchive.update).not.toHaveBeenCalled()

View File

@ -1,116 +0,0 @@
import sql from 'mssql'
export type JstSkuOperatorProfile = {
barcode: string
sku: string
skuManager: string
operationDeptManager: string
designer: string
updatedAt: Date | null
}
export interface JstSkuOperatorRepository {
listByKeys(keys: readonly { barcode: string; sku: string }[]): Promise<JstSkuOperatorProfile[]>
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
}
function normalizedKeys(keys: readonly { barcode: string; sku: string }[]) {
const barcodes = Array.from(
new Set(keys.map((key) => key.barcode.trim().toUpperCase()).filter(Boolean)),
).slice(0, 200)
const skus = Array.from(
new Set(keys.map((key) => key.sku.trim().toUpperCase()).filter(Boolean)),
).slice(0, 200)
return { barcodes, skus }
}
class JstSkuOperatorRepositoryClass implements JstSkuOperatorRepository {
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),
},
pool: { min: 0, max: 4, idleTimeoutMillis: 30_000 },
requestTimeout: 30_000,
connectionTimeout: 15_000,
}).connect()
return this.pool
}
async listByKeys(keys: readonly { barcode: string; sku: string }[]) {
const normalized = normalizedKeys(keys)
if (normalized.barcodes.length === 0 && normalized.skus.length === 0) return []
const schema = safeIdentifier(
process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt',
'JST_REPORT_DB_SCHEMA',
)
const pool = await this.getPool()
const request = pool.request()
const barcodeParameters = normalized.barcodes.map((barcode, index) => {
const parameter = `barcode${index}`
request.input(parameter, sql.NVarChar(120), barcode)
return `@${parameter}`
})
const skuParameters = normalized.skus.map((sku, index) => {
const parameter = `sku${index}`
request.input(parameter, sql.NVarChar(120), sku)
return `@${parameter}`
})
const conditions = [
barcodeParameters.length
? `UPPER(LTRIM(RTRIM(CAST(sku_id AS nvarchar(120))))) IN (${barcodeParameters.join(', ')})`
: '',
skuParameters.length
? `UPPER(LTRIM(RTRIM(CAST(sku AS nvarchar(120))))) IN (${skuParameters.join(', ')})`
: '',
].filter(Boolean)
const result = await request.query<JstSkuOperatorProfile>(`
SELECT
UPPER(LTRIM(RTRIM(CAST(sku_id AS nvarchar(120))))) AS barcode,
UPPER(LTRIM(RTRIM(CAST(sku AS nvarchar(120))))) AS sku,
LTRIM(RTRIM(COALESCE(CAST(sku_manager AS nvarchar(120)), N''))) AS skuManager,
LTRIM(RTRIM(COALESCE(CAST(operation_dept_manager AS nvarchar(120)), N''))) AS operationDeptManager,
LTRIM(RTRIM(COALESCE(CAST(designer AS nvarchar(120)), N''))) AS designer,
update_time AS updatedAt
FROM [${schema}].[js_sku_info_manage]
WHERE ${conditions.join(' OR ')}
`)
return result.recordset
}
async close() {
if (!this.pool) return
await this.pool.close()
this.pool = null
}
}
export const jstSkuOperatorRepository = new JstSkuOperatorRepositoryClass()

View File

@ -1,102 +0,0 @@
import {
buildProductArchiveEnrichmentUpdates,
buildProductArchiveOperatorInitialUpdate,
} from '../supply-chain-product-archive-enrichment-sync.mapper'
const emptyLookups = {
suppliers: [],
stocks: [],
warehouses: [],
prices: [],
catalogCosts: [],
warehouseConfigs: [],
}
describe('product archive operator source mapping', () => {
it('matches the source by exact barcode before SKU and marks it as initial-only', () => {
const update = buildProductArchiveOperatorInitialUpdate(
{
id: 'archive-1',
barcode: 'BAR-001',
spu: 'SPU-001',
sku: 'SKU-001',
supplierName: '',
inOutWarehouse: '',
},
[
{
barcode: 'BAR-OTHER',
sku: 'SKU-001',
skuManager: '错误负责人',
operationDeptManager: '错误部门负责人',
designer: '',
updatedAt: null,
},
{
barcode: 'BAR-001',
sku: 'SKU-001',
skuManager: '王也',
operationDeptManager: '子龙',
designer: '财神',
updatedAt: new Date('2026-07-30T08:01:00.000Z'),
},
],
)
expect(update).toEqual(expect.objectContaining({
patch: {
opsDeptHead: '子龙',
opsHead: '王也',
designer: '财神',
},
source: expect.objectContaining({
sourceObject: 'js_sku_info_manage',
fillEmptyOnly: true,
}),
}))
})
it('uses SKU only when that SKU has one unambiguous source row', () => {
const target = {
id: 'archive-2',
barcode: 'BAR-NOT-MATCHED',
spu: 'SPU-002',
sku: 'SKU-002',
supplierName: '',
inOutWarehouse: '',
}
const unique = buildProductArchiveOperatorInitialUpdate(target, [{
barcode: 'SOURCE-BAR',
sku: 'SKU-002',
skuManager: '唯一负责人',
operationDeptManager: '唯一部门负责人',
designer: '唯一设计师',
updatedAt: null,
}])
const ambiguous = buildProductArchiveOperatorInitialUpdate(target, [
{
barcode: 'SOURCE-BAR-1',
sku: 'SKU-002',
skuManager: '负责人一',
operationDeptManager: '',
designer: '',
updatedAt: null,
},
{
barcode: 'SOURCE-BAR-2',
sku: 'SKU-002',
skuManager: '负责人二',
operationDeptManager: '',
designer: '',
updatedAt: null,
},
])
expect(unique?.patch).toEqual({
opsDeptHead: '唯一部门负责人',
opsHead: '唯一负责人',
designer: '唯一设计师',
})
expect(ambiguous).toBeNull()
})
})

View File

@ -1,13 +1,11 @@
import {
JST_SKU_PRICE_ARCHIVE_SOURCE_CODE,
JST_SKU_OPERATOR_ARCHIVE_SOURCE_CODE,
JST_STOCK_MONITOR_ARCHIVE_SOURCE_CODE,
JST_SUPPLIER_ARCHIVE_SOURCE_CODE,
JST_WAREHOUSE_ARCHIVE_SOURCE_CODE,
PRODUCT_CATALOG_COST_ARCHIVE_SOURCE_CODE,
WAREHOUSE_CONFIG_ARCHIVE_SOURCE_CODE,
} from '@/lib/supply-chain-product-archive-field-source'
import type { JstSkuOperatorProfile } from '@/server/repositories/jst-sku-operator.repository'
import type { JstSkuPriceProfile } from '@/server/repositories/jst-sku-price.repository'
import type { JstStockMonitorProfile } from '@/server/repositories/jst-stock-monitor.repository'
import type { JstSupplierProfile } from '@/server/repositories/jst-supplier-profile.repository'
@ -93,53 +91,6 @@ function source(
}
}
function findOperatorProfile(
target: ProductArchiveEnrichmentTarget,
profiles: readonly JstSkuOperatorProfile[],
) {
const barcode = normalizeIdentity(target.barcode)
const exact = barcode
? profiles.find((profile) => normalizeIdentity(profile.barcode) === barcode)
: undefined
if (exact) return exact
const sku = normalizeIdentity(target.sku)
if (!sku) return undefined
const candidates = profiles.filter(
(profile) => normalizeIdentity(profile.sku) === sku,
)
return candidates.length === 1 ? candidates[0] : undefined
}
export function buildProductArchiveOperatorInitialUpdate(
target: ProductArchiveEnrichmentTarget,
operators: readonly JstSkuOperatorProfile[],
): ProductArchiveEnrichmentUpdate | null {
const operator = findOperatorProfile(target, operators)
return operator
? {
patch: {
opsDeptHead: operator.operationDeptManager,
opsHead: operator.skuManager,
designer: operator.designer,
},
source: source(
JST_SKU_OPERATOR_ARCHIVE_SOURCE_CODE,
JST_SOURCE_SYSTEM,
JST_SOURCE_SCHEMA,
'js_sku_info_manage',
operator.barcode || operator.sku,
{
opsDeptHead: 'operation_dept_manager',
opsHead: 'sku_manager',
designer: 'designer',
},
{ sourceRecordedAt: operator.updatedAt, fillEmptyOnly: true },
),
}
: null
}
export function buildProductArchiveEnrichmentUpdates(
target: ProductArchiveEnrichmentTarget,
lookups: ProductArchiveEnrichmentLookups,

View File

@ -1,102 +0,0 @@
import {
jstSkuOperatorRepository,
type JstSkuOperatorRepository,
} from '@/server/repositories/jst-sku-operator.repository'
import {
supplyChainProductArchiveEnrichmentSyncRepository,
type SupplyChainProductArchiveEnrichmentSyncRepository,
} from '@/server/repositories/supply-chain-product-archive-enrichment-sync.repository'
import { recordProductArchiveFieldChanges } from './supply-chain-product-archive-change-log.service'
import { buildProductArchiveOperatorInitialUpdate } from './supply-chain-product-archive-enrichment-sync.mapper'
export type ProductArchiveOperatorInitialSyncResult = {
batches: number
scanned: number
matched: number
changed: number
unchanged: number
lastId: string
}
type OperatorInitialSyncDependencies = {
repository: SupplyChainProductArchiveEnrichmentSyncRepository
operatorRepository: Pick<JstSkuOperatorRepository, 'listByKeys' | 'close'>
}
export class SupplyChainProductArchiveOperatorInitialSyncService {
constructor(
private readonly dependencies: OperatorInitialSyncDependencies = {
repository: supplyChainProductArchiveEnrichmentSyncRepository,
operatorRepository: jstSkuOperatorRepository,
},
) {}
async syncAll(input: { batchSize?: number } = {}) {
const batchSize = Math.min(200, Math.max(1, Math.trunc(input.batchSize ?? 200)))
const result: ProductArchiveOperatorInitialSyncResult = {
batches: 0,
scanned: 0,
matched: 0,
changed: 0,
unchanged: 0,
lastId: '',
}
try {
while (true) {
const targets = await this.dependencies.repository.listTargets({
afterId: result.lastId || undefined,
limit: batchSize,
})
if (targets.length === 0) break
const operators = await this.dependencies.operatorRepository.listByKeys(
targets.map((target) => ({ barcode: target.barcode, sku: target.sku })),
)
for (const target of targets) {
const update = buildProductArchiveOperatorInitialUpdate(target, operators)
if (!update) {
result.unchanged += 1
continue
}
result.matched += 1
const applied = await this.dependencies.repository.applyPatch({
archiveId: target.id,
patch: update.patch,
source: update.source,
})
if (applied.changes.length === 0) {
result.unchanged += 1
continue
}
result.changed += applied.changes.length
if (applied.sourceInitialized) continue
await recordProductArchiveFieldChanges({
barcode: target.barcode,
actor: {
source: 'SYSTEM',
actorName: `${update.source.sourceSystem} / ${update.source.sourceSchema} / ${update.source.sourceObject}`,
},
source: {
sourceSystem: update.source.sourceSystem,
sourceSchema: update.source.sourceSchema,
sourceObject: update.source.sourceObject,
},
changes: applied.changes,
})
}
result.batches += 1
result.scanned += targets.length
result.lastId = targets[targets.length - 1]?.id ?? result.lastId
if (targets.length < batchSize) break
}
return result
} finally {
await this.dependencies.operatorRepository.close()
}
}
}
export const supplyChainProductArchiveOperatorInitialSyncService =
new SupplyChainProductArchiveOperatorInitialSyncService()