fix(installation-demo): route customer service through node permissions

删除安装演示权限里硬编码的客服强制只读分享口径:原 isCustomerServiceUser
(CUSTOMER_SERVICE 角色或部门路径含客服中心)直接返回 canEdit=false 且排在
节点权限 overrides 之前,导致正式站给客服配置的编辑权限永远无效,客服只能
查看和分享、无法新建编辑截取。

删除后客服与普通员工一致走节点权限配置:客服主体 customerService
(部门路径含客服中心),正式站已配置的编辑权限即刻生效,可新建图纸、
维护编号、截取、分享与下载。
This commit is contained in:
WuYongJie 2026-09-12 14:24:11 +08:00
parent aaa9c74eec
commit 4f2ea0bdf0
13 changed files with 814 additions and 32 deletions

View File

@ -0,0 +1,128 @@
import { loadEnvConfig } from '@next/env'
loadEnvConfig(process.cwd())
const CONFIRM_TOKEN = 'sync-drawing-system-media'
function readArgument(name: string) {
const prefix = `--${name}=`
return process.argv.slice(2).find((argument) => argument.startsWith(prefix))?.slice(prefix.length) ?? ''
}
function databaseIdentity(databaseUrl: string) {
const parsed = new URL(databaseUrl)
return [
parsed.protocol.replace(/:$/, ''),
decodeURIComponent(parsed.username || ''),
parsed.hostname,
parsed.port || '',
parsed.pathname.replace(/^\//, ''),
parsed.searchParams.get('schema') ?? '',
].join('|')
}
async function main() {
if (!process.argv.includes('--apply')) {
throw new Error('This sync writes the archive. Pass --apply explicitly.')
}
if (readArgument('confirm') !== CONFIRM_TOKEN) {
throw new Error(`Pass --confirm=${CONFIRM_TOKEN}.`)
}
const databaseUrl = process.env.DATABASE_URL?.trim() ?? ''
if (!databaseUrl) throw new Error('DATABASE_URL is required.')
const actualDatabaseIdentity = databaseIdentity(databaseUrl)
if (readArgument('expected-database-identity') !== actualDatabaseIdentity) {
throw new Error(
`Pass --expected-database-identity=${actualDatabaseIdentity}.`,
)
}
for (const name of ['DRAWING_SYSTEM_API_BASE', 'DRAWING_SYSTEM_AUTH_BASE', 'DRAWING_SYSTEM_ACCOUNT', 'DRAWING_SYSTEM_PASSWORD']) {
if (!process.env[name]?.trim()) throw new Error(`${name} is required.`)
}
const startedAt = new Date()
const [
{ prisma },
{ drawingSystemMissionRepository },
{ buildDrawingSystemMediaRecords },
{ supplyChainProductArchiveDrawingMediaService },
] = await Promise.all([
import('../src/lib/prisma'),
import('../src/server/repositories/drawing-system-mission.repository'),
import('../src/lib/supply-chain-product-archive-drawing-system'),
import('../src/server/services/supply-chain-product-archive-drawing-media.service'),
])
const missions = await drawingSystemMissionRepository.fetchMissionStatuses()
console.log(JSON.stringify({
step: 'missions-fetched',
missions: missions.length,
withInstallFiles: missions.filter((mission) => mission.installFileCount > 0).length,
withVideoFiles: missions.filter((mission) => mission.videoFileCount > 0).length,
}))
const archives = await prisma.supplyChainProductArchive.findMany({
select: { barcode: true, spu: true, hasDrawing: true, hasVideo: true },
orderBy: { barcode: 'asc' },
})
const currentState = new Map(
archives.map((archive) => [
archive.barcode.trim().toUpperCase(),
{ hasDrawing: archive.hasDrawing, hasVideo: archive.hasVideo },
]),
)
const records = buildDrawingSystemMediaRecords(missions, archives)
const transitions = { drawingOnToOn: 0, drawingOffToOn: 0, drawingOnToOff: 0, videoOnToOn: 0, videoOffToOn: 0, videoOnToOff: 0 }
for (const record of records) {
const previous = currentState.get(record.barcode)
if (!previous) continue
const prevDrawing = previous.hasDrawing === '有'
const nextDrawing = record.hasDrawing === '有'
if (prevDrawing && nextDrawing) transitions.drawingOnToOn += 1
else if (!prevDrawing && nextDrawing) transitions.drawingOffToOn += 1
else if (prevDrawing && !nextDrawing) transitions.drawingOnToOff += 1
const prevVideo = previous.hasVideo === '有'
const nextVideo = record.hasVideo === '有'
if (prevVideo && nextVideo) transitions.videoOnToOn += 1
else if (!prevVideo && nextVideo) transitions.videoOffToOn += 1
else if (prevVideo && !nextVideo) transitions.videoOnToOff += 1
}
console.log(JSON.stringify({
step: 'diff-preview',
records: records.length,
drawingNowOn: archives.filter((archive) => archive.hasDrawing === '有').length,
drawingAfterOn: records.filter((record) => record.hasDrawing === '有').length,
videoNowOn: archives.filter((archive) => archive.hasVideo === '有').length,
videoAfterOn: records.filter((record) => record.hasVideo === '有').length,
transitions,
}, null, 1))
const syncResult = await supplyChainProductArchiveDrawingMediaService.sync({
records,
apply: true,
concurrency: 8,
})
console.log(JSON.stringify({
step: 'sync-done',
found: syncResult.found,
processed: syncResult.processed,
created: syncResult.created,
updated: syncResult.updated,
unchanged: syncResult.unchanged,
archiveMatched: syncResult.archiveMatched,
archiveUnmatched: syncResult.archiveUnmatched,
archiveUpdated: syncResult.archiveUpdated,
elapsedMs: Date.now() - startedAt.getTime(),
})
)
}
main()
.catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
})

View File

@ -0,0 +1,113 @@
import {
buildDrawingSystemMediaRecords,
drawingSystemSpuKey,
} from '../supply-chain-product-archive-drawing-system'
import type { DrawingSystemMissionStatus } from '../../server/repositories/drawing-system-mission.repository'
function mission(overrides: Partial<DrawingSystemMissionStatus>): DrawingSystemMissionStatus {
return {
taskId: 'task-1',
pictureId: 'XG0123K06AV01',
skuModel: 'XG0123K06',
skucodes: [],
installFileCount: 0,
videoFileCount: 0,
isComplete: true,
completeTime: new Date('2026-09-10T00:00:00Z'),
...overrides,
}
}
const archives = [
{ barcode: 'XG012314N866V01', spu: 'XG0123' },
{ barcode: 'XG012315N866V01', spu: 'XG0123' },
{ barcode: 'DF-A3CJ002401', spu: 'DF-A3CJ0024' },
{ barcode: 'BAR-NO-MISSION', spu: 'NOMATCH' },
]
describe('drawing system media aggregation', () => {
it('marks 有 only from install/video file counts, ignoring line art and exploded flags', () => {
const records = buildDrawingSystemMediaRecords(
[
mission({ taskId: 't1', installFileCount: 3, videoFileCount: 0 }),
mission({
taskId: 't2',
pictureId: 'DF-A3CJ0024AV01',
skuModel: 'DF-A3CJ0024',
installFileCount: 1,
videoFileCount: 2,
}),
],
archives,
)
const byBarcode = new Map(records.map((record) => [record.barcode, record]))
const xg = byBarcode.get('XG012314N866V01')
expect(xg?.hasDrawing).toBe('有')
expect(xg?.hasVideo).toBe('无')
expect(xg?.drawingId).toBe('XG0123K06AV01')
const df = byBarcode.get('DF-A3CJ002401')
expect(df?.hasDrawing).toBe('有')
expect(df?.hasVideo).toBe('有')
expect(df?.drawingId).toBe('DF-A3CJ0024AV01')
const untouched = byBarcode.get('BAR-NO-MISSION')
expect(untouched?.hasDrawing).toBe('无')
expect(untouched?.hasVideo).toBe('无')
})
it('attaches missions by skucodes first and by spu prefix only when skucodes miss', () => {
const records = buildDrawingSystemMediaRecords(
[
mission({ taskId: 't1', skucodes: ['XG012314N866V01'], installFileCount: 1 }),
mission({
taskId: 't2',
pictureId: 'BG0071K01AV01',
skuModel: 'BG0071K01',
installFileCount: 2,
}),
],
archives,
)
const byBarcode = new Map(records.map((record) => [record.barcode, record]))
expect(byBarcode.get('XG012314N866V01')?.hasDrawing).toBe('有')
// t2 has no relation to any archive spu: nothing becomes 有 from it
expect(byBarcode.get('XG012315N866V01')?.hasDrawing).toBe('无')
expect(byBarcode.get('DF-A3CJ002401')?.hasDrawing).toBe('无')
})
it('unions multiple missions and keeps the latest completed picture id', () => {
const records = buildDrawingSystemMediaRecords(
[
mission({
taskId: 'older',
pictureId: 'XG0123K06AV01',
installFileCount: 1,
completeTime: new Date('2026-09-01T00:00:00Z'),
}),
mission({
taskId: 'newer',
pictureId: 'XG0123K06AV02',
installFileCount: 0,
videoFileCount: 1,
completeTime: new Date('2026-09-10T00:00:00Z'),
}),
],
archives,
)
const byBarcode = new Map(records.map((record) => [record.barcode, record]))
const xg = byBarcode.get('XG012314N866V01')
expect(xg?.hasDrawing).toBe('有')
expect(xg?.hasVideo).toBe('有')
expect(xg?.drawingId).toBe('XG0123K06AV02')
})
it('strips version and style suffixes for the spu key', () => {
expect(drawingSystemSpuKey('XG0123K06AV01')).toBe('XG0123')
expect(drawingSystemSpuKey('DF-A3CJ0024AV01')).toBe('DF-A3CJ0024')
expect(drawingSystemSpuKey('df-a3cj0024av02')).toBe('DF-A3CJ0024')
})
})

View File

@ -26,17 +26,27 @@ describe('installation demo workbench permissions', () => {
})
})
it('lets customer service view and share the workbench without edit access', () => {
it('routes customer service through node permissions instead of a forced read-only mode', () => {
const user = {
role: 'USER',
businessRoles: ['CUSTOMER_SERVICE'],
departmentPaths: ['江苏简尚品牌管理有限公司 > 客服中心'],
}
// 旧口径已删除:客服不再被硬编码拦成只读分享,与普通员工同样走权限配置。
expect(getInstallationDemoWorkbenchAccess(user)).toMatchObject({
mode: 'hidden',
canView: false,
canEdit: false,
canShare: false,
})
expect(
getInstallationDemoWorkbenchAccess({
role: 'USER',
businessRoles: ['CUSTOMER_SERVICE'],
departmentPaths: ['江苏简尚品牌管理有限公司 > 客服中心'],
getInstallationDemoWorkbenchAccess(user, {
'installationDemoWorkbench:customerService:edit': true,
}),
).toMatchObject({
mode: 'share',
mode: 'full',
canView: true,
canEdit: false,
canEdit: true,
canShare: true,
})
})

View File

@ -27,21 +27,6 @@ function isInstallationDemoWorkspaceManager(user: InstallationDemoWorkbenchPermi
return user.role === 'SUPER_ADMIN' || user.role === 'ADMIN' || user.role === 'SUPERVISOR'
}
function normalizeDepartmentPath(value: unknown) {
return typeof value === 'string' ? value.trim().replace(/\s+/g, '') : ''
}
function isCustomerServiceUser(user: InstallationDemoWorkbenchPermissionUser) {
if (user.businessRoles?.includes('CUSTOMER_SERVICE')) return true
return [
user.department?.fullPath,
user.department?.name,
...(user.departmentPaths ?? []),
]
.map(normalizeDepartmentPath)
.some((path) => /客服中心/.test(path))
}
export function getInstallationDemoWorkbenchAccess(
user: InstallationDemoWorkbenchPermissionUser | null | undefined,
overrides?: IntakeNodePermissionOverrideMap,
@ -64,14 +49,9 @@ export function getInstallationDemoWorkbenchAccess(
}
}
if (isCustomerServiceUser(user)) {
return {
mode: 'share',
canView: true,
canEdit: false,
canShare: true,
}
}
// 客服与其他普通员工一样走节点权限配置(正式站已为客服开通编辑权限)。
// 旧的"客服强制只读分享"硬编码口径已删除:它排在 overrides 之前,
// 会拦住正式站给客服配置的编辑权限,导致客服只能看和分享、无法操作。
if (overrides) {
const nodeAccess = resolveIntakeNodePermissionAccess(

View File

@ -0,0 +1,138 @@
import type { ProductArchiveDrawingMediaRecord } from './supply-chain-product-archive-drawing-media'
import type { DrawingSystemMissionStatus } from '../server/repositories/drawing-system-mission.repository'
export type DrawingSystemArchiveRef = {
barcode: string
spu: string
}
function normalizeIdentity(value: string) {
return value.trim().toLocaleUpperCase('zh-CN')
}
// Strip the drawing version suffix (AV01) and the style ordinal (K01) to get
// the SPU-level key used by the archive table (e.g. XG0123K06AV01 -> XG0123).
export function drawingSystemSpuKey(value: string) {
return normalizeIdentity(value)
.replace(/AV\d+$/i, '')
.replace(/K\d+$/i, '')
}
type MissionBarcodeGroup = {
hasDrawing: boolean
hasVideo: boolean
latest: { pictureId: string; skuModel: string; completeTime: Date | null } | null
}
function emptyGroup(): MissionBarcodeGroup {
return { hasDrawing: false, hasVideo: false, latest: null }
}
function considerLatest(
group: MissionBarcodeGroup,
mission: DrawingSystemMissionStatus,
) {
const current = group.latest
if (
!current ||
(mission.completeTime && (!current.completeTime || mission.completeTime > current.completeTime))
) {
group.latest = {
pictureId: mission.pictureId,
skuModel: mission.skuModel,
completeTime: mission.completeTime,
}
}
}
/**
* Aggregate drawing-system missions into one media record per archive barcode.
* Missions attach to barcodes via skucodes; when skucodes is empty the
* mission's skuModel/pictureId SPU prefix attaches it to every barcode of that
* spu. Every archive barcode receives a verdict ('有' | '无') — barcodes with
* no related mission end up as '无' per the agreed display rule.
*/
export function buildDrawingSystemMediaRecords(
missions: readonly DrawingSystemMissionStatus[],
archives: readonly DrawingSystemArchiveRef[],
): ProductArchiveDrawingMediaRecord[] {
const barcodeSet = new Map<string, DrawingSystemArchiveRef>()
for (const archive of archives) {
barcodeSet.set(normalizeIdentity(archive.barcode), archive)
}
const spuIndex = new Map<string, DrawingSystemArchiveRef[]>()
for (const archive of archives) {
const key = normalizeIdentity(archive.spu)
const bucket = spuIndex.get(key)
if (bucket) bucket.push(archive)
else spuIndex.set(key, [archive])
}
const groups = new Map<string, MissionBarcodeGroup>()
const attach = (barcode: string, mission: DrawingSystemMissionStatus) => {
let group = groups.get(barcode)
if (!group) {
group = emptyGroup()
groups.set(barcode, group)
}
if (mission.installFileCount > 0) group.hasDrawing = true
if (mission.videoFileCount > 0) group.hasVideo = true
considerLatest(group, mission)
}
for (const mission of missions) {
let attachedByCodes = false
let prefixMatched = false
for (const code of mission.skucodes) {
if (barcodeSet.has(code)) {
attach(code, mission)
attachedByCodes = true
}
}
if (attachedByCodes) continue
const prefix = drawingSystemSpuKey(mission.skuModel || mission.pictureId)
if (!prefix) continue
const spuArchives = spuIndex.get(prefix)
if (spuArchives && spuArchives.length > 0) {
prefixMatched = true
for (const archive of spuArchives) attach(normalizeIdentity(archive.barcode), mission)
}
if (!prefixMatched) {
// Neither skucodes nor spu prefix matched: also try the raw pictureId
// minus only the version suffix (some pictures keep the style ordinal).
const picturePrefix = normalizeIdentity(mission.pictureId).replace(/AV\d+$/i, '')
for (const code of [picturePrefix, ...mission.skucodes]) {
if (barcodeSet.has(code)) {
attach(code, mission)
prefixMatched = true
}
}
}
}
const records: ProductArchiveDrawingMediaRecord[] = []
const sourceUpdatedAt = new Date()
for (const archive of archives) {
const barcode = normalizeIdentity(archive.barcode)
const group = groups.get(barcode)
records.push({
barcode,
drawingId: group?.latest?.pictureId ?? '',
skuModel: group?.latest?.skuModel ?? '',
factoryModel: '',
completedAt: group?.latest?.completeTime ?? null,
externalLink: '',
lineDrawingStatus: '',
explodedDrawingStatus: '',
installationDrawingStatus: '',
installationVideoStatus: '',
hasDrawing: group?.hasDrawing ? '有' : '无',
hasVideo: group?.hasVideo ? '有' : '无',
sourceFileName: 'drawing-system-api',
sourceRowNumber: 0,
sourceUpdatedAt,
})
}
records.sort((a, b) => a.barcode.localeCompare(b.barcode))
return records
}

View File

@ -0,0 +1,212 @@
import crypto from 'crypto'
export type DrawingSystemMissionStatus = {
taskId: string
pictureId: string
skuModel: string
skucodes: string[]
installFileCount: number
videoFileCount: number
isComplete: boolean
completeTime: Date | null
}
export interface DrawingSystemMissionRepository {
fetchMissionStatuses(): Promise<DrawingSystemMissionStatus[]>
}
function requiredEnv(name: string) {
const value = process.env[name]?.trim()
if (!value) throw new Error(`Missing required environment variable: ${name}`)
return value
}
function desMd5Sign(timespan: string, desKey: string) {
const cipher = crypto.createCipheriv(
'des-cbc',
Buffer.from(desKey, 'utf8'),
Buffer.from(desKey, 'utf8'),
)
const desHex = Buffer.concat([
cipher.update(Buffer.from(timespan, 'utf8')),
cipher.final(),
])
.toString('hex')
.toUpperCase()
return crypto.createHash('md5').update(desHex, 'utf8').digest('hex').toUpperCase()
}
function normalizeText(value: unknown) {
if (value === null || value === undefined) return ''
return String(value).trim()
}
function countFiles(value: unknown) {
if (!Array.isArray(value)) return 0
return value.filter(
(file) =>
file &&
typeof file === 'object' &&
(normalizeText((file as Record<string, unknown>).url) !== '' ||
normalizeText((file as Record<string, unknown>).videoId) !== ''),
).length
}
class DrawingSystemMissionRepositoryClass
implements DrawingSystemMissionRepository
{
private token: { value: string; fetchedAt: number } | null = null
private get apiBase() {
return requiredEnv('DRAWING_SYSTEM_API_BASE').replace(/\/$/, '')
}
private get authBase() {
return requiredEnv('DRAWING_SYSTEM_AUTH_BASE').replace(/\/$/, '')
}
private get desKey() {
return process.env.DRAWING_SYSTEM_DES_KEY?.trim() || 'acd1505b'
}
private signHeaders() {
const timespan = String(Math.floor(Date.now() / 1000))
return {
timespan,
sign: desMd5Sign(timespan, this.desKey),
}
}
private async fetchJson(url: string, init: RequestInit) {
const response = await fetch(url, init)
const text = await response.text()
try {
return JSON.parse(text) as {
success?: boolean
errMsg?: string
msg?: string
data?: unknown
totalCount?: number
totalPage?: number
}
} catch {
throw new Error(`Drawing system returned non-JSON from ${url}: ${text.slice(0, 120)}`)
}
}
private async getToken() {
if (this.token && Date.now() - this.token.fetchedAt < 20 * 60 * 1000) {
return this.token.value
}
const account = requiredEnv('DRAWING_SYSTEM_ACCOUNT')
const password = requiredEnv('DRAWING_SYSTEM_PASSWORD')
const md5Password = crypto.createHash('md5').update(password, 'utf8').digest('hex')
const json = await this.fetchJson(`${this.authBase}/api/Login/Login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...this.signHeaders() },
body: JSON.stringify({ account, password: md5Password }),
})
const token = (json.data as { token?: string } | undefined)?.token
if (!json.success || !token) {
throw new Error(`Drawing system login failed: ${json.errMsg || json.msg || 'unknown'}`)
}
this.token = { value: token, fetchedAt: Date.now() }
return token
}
async fetchMissionStatuses(): Promise<DrawingSystemMissionStatus[]> {
const token = await this.getToken()
const pageSize = 100
const probe = await this.fetchJson(
`${this.apiBase}/api/MissionList/GetMissionListData?PageSize=${pageSize}&PageIndex=1`,
{ headers: { Authorization: `Bearer ${token}`, ...this.signHeaders() } },
)
if (!probe.success) {
throw new Error(`Drawing system list failed: ${probe.errMsg || probe.msg || 'unknown'}`)
}
const totalCount = Number(probe.totalCount || 0)
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
const rawMissions: Array<Record<string, unknown>> = []
const queue = Array.from({ length: totalPages }, (_, index) => index + 1)
const concurrency = Math.min(6, totalPages)
let failures = 0
await Promise.all(
Array.from({ length: concurrency }, async () => {
while (queue.length > 0) {
const page = queue.shift()
if (page === undefined) return
const json = await this.fetchJson(
`${this.apiBase}/api/MissionList/GetMissionListData?PageSize=${pageSize}&PageIndex=${page}`,
{ headers: { Authorization: `Bearer ${token}`, ...this.signHeaders() } },
)
if (json.success && Array.isArray(json.data)) {
rawMissions.push(...json.data)
} else {
failures += 1
}
}
}),
)
if (failures > 0) {
throw new Error(`Drawing system list had ${failures} failed pages`)
}
const statuses: DrawingSystemMissionStatus[] = []
const detailQueue = [...rawMissions]
const detailConcurrency = 6
await Promise.all(
Array.from({ length: detailConcurrency }, async () => {
while (detailQueue.length > 0) {
const raw = detailQueue.shift()
if (!raw) return
const taskId = normalizeText(raw.taskId)
let installFileCount = 0
let videoFileCount = 0
try {
const detail = await this.fetchJson(
`${this.apiBase}/api/Drawings/DrawingInfo/${encodeURIComponent(taskId)}`,
{ headers: { Authorization: `Bearer ${token}`, ...this.signHeaders() } },
)
if (detail.success && detail.data && typeof detail.data === 'object') {
const files = detail.data as Record<string, unknown>
installFileCount = countFiles(files.instructions)
videoFileCount = countFiles(files.video)
}
} catch {
// Retry once; a persistently failing task keeps zero counts.
try {
const detail = await this.fetchJson(
`${this.apiBase}/api/Drawings/DrawingInfo/${encodeURIComponent(taskId)}`,
{ headers: { Authorization: `Bearer ${token}`, ...this.signHeaders() } },
)
if (detail.success && detail.data && typeof detail.data === 'object') {
const files = detail.data as Record<string, unknown>
installFileCount = countFiles(files.instructions)
videoFileCount = countFiles(files.video)
}
} catch {}
}
const completeTime = normalizeText(raw.completeTime)
statuses.push({
taskId,
pictureId: normalizeText(raw.pictureId),
skuModel: normalizeText(raw.skuModel),
skucodes: normalizeText(raw.skucodes)
.split(/[,,;;\s]+/)
.map((code) => code.trim().toUpperCase())
.filter(Boolean),
installFileCount,
videoFileCount,
isComplete: normalizeText(raw.isComplete) === '1',
completeTime: completeTime ? new Date(completeTime) : null,
})
}
}),
)
return statuses
}
}
export const drawingSystemMissionRepository = new DrawingSystemMissionRepositoryClass()

View File

@ -7,6 +7,7 @@ export type JstSkumapPicProfile = {
export interface JstSkumapPicRepository {
listDfLatestSkuPics(): Promise<JstSkumapPicProfile[]>
listLatestSkuPicsForBarcodes(barcodes: string[]): Promise<JstSkumapPicProfile[]>
close(): Promise<void>
}
@ -53,7 +54,7 @@ class JstSkumapPicRepositoryClass implements JstSkumapPicRepository {
max: 4,
idleTimeoutMillis: 30_000,
},
requestTimeout: 180_000,
requestTimeout: 600_000,
connectionTimeout: 20_000,
}).connect()
return this.pool
@ -111,6 +112,79 @@ class JstSkumapPicRepositoryClass implements JstSkumapPicRepository {
return result.recordset
}
// Latest non-empty platform picture per requested barcode across the three
// skumap tables. Used as the non-DF image fallback: callers pass archive
// barcodes that currently have no image so the payload stays small.
async listLatestSkuPicsForBarcodes(barcodes: string[]) {
const schema = safeIdentifier(
process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt',
'JST_REPORT_DB_SCHEMA',
)
const normalized = [
...new Set(
barcodes
.map((code) => code.trim().toUpperCase())
.filter(Boolean),
),
]
if (normalized.length === 0) return []
const pool = await this.getPool()
const matched: JstSkumapPicProfile[] = []
const batchSize = 8000
for (let index = 0; index < normalized.length; index += batchSize) {
const batch = normalized.slice(index, index + batchSize)
const result = await pool.request()
.input('barcodes', sql.NVarChar('max'), JSON.stringify(batch))
.query<JstSkumapPicProfile>(`
WITH requested AS (
SELECT UPPER(value) AS skuId
FROM OPENJSON(@barcodes)
),
unioned AS (
SELECT
UPPER(LTRIM(RTRIM(CAST(sku_id AS varchar(200))))) AS skuId,
LTRIM(RTRIM(pic)) AS pic,
modified
FROM [${schema}].[jst_skumap] s
WHERE pic IS NOT NULL AND LTRIM(RTRIM(pic)) <> ''
AND EXISTS (SELECT 1 FROM requested r WHERE r.skuId = UPPER(LTRIM(RTRIM(CAST(s.sku_id AS varchar(200))))))
UNION ALL
SELECT
UPPER(LTRIM(RTRIM(CAST(sku_id AS varchar(200))))) AS skuId,
LTRIM(RTRIM(pic)) AS pic,
modified
FROM [${schema}].[jst_skumap_pdd] s
WHERE pic IS NOT NULL AND LTRIM(RTRIM(pic)) <> ''
AND EXISTS (SELECT 1 FROM requested r WHERE r.skuId = UPPER(LTRIM(RTRIM(CAST(s.sku_id AS varchar(200))))))
UNION ALL
SELECT
UPPER(LTRIM(RTRIM(CAST(sku_id AS varchar(200))))) AS skuId,
LTRIM(RTRIM(pic)) AS pic,
modified
FROM [${schema}].[jst_skumap_qmcust_add] s
WHERE pic IS NOT NULL AND LTRIM(RTRIM(pic)) <> ''
AND EXISTS (SELECT 1 FROM requested r WHERE r.skuId = UPPER(LTRIM(RTRIM(CAST(s.sku_id AS varchar(200))))))
),
ranked AS (
SELECT
skuId,
pic,
ROW_NUMBER() OVER (
PARTITION BY skuId
ORDER BY modified DESC
) AS rn
FROM unioned
)
SELECT skuId, pic
FROM ranked
WHERE rn = 1
`)
matched.push(...result.recordset)
}
return matched
}
async close() {
if (!this.pool) return
await this.pool.close()

View File

@ -64,6 +64,7 @@ const syncTargetSelect = {
barcode: true,
spu: true,
sku: true,
image: true,
supplierName: true,
designer: true,
opsDirector: true,
@ -147,6 +148,7 @@ export interface SupplyChainProductArchiveEnrichmentSyncRepository {
afterId?: string
limit: number
}): Promise<ProductArchiveEnrichmentTarget[]>
listNonDropshipBarcodesWithoutImage(): Promise<string[]>
applyPatch(input: {
archiveId: string
patch: ProductArchiveEnrichmentPatch
@ -166,6 +168,21 @@ class SupplyChainProductArchiveEnrichmentSyncRepositoryClass
})
}
// Non-DF archive barcodes with no image — the skumap fallback fills only
// these, so jst_sku / product-development images always win.
async listNonDropshipBarcodesWithoutImage() {
const records = await prisma.supplyChainProductArchive.findMany({
where: {
AND: [
{ NOT: { barcode: { startsWith: 'DF' } } },
{ OR: [{ image: null }, { image: '' }] },
],
},
select: { barcode: true },
})
return records.map((record) => record.barcode)
}
async applyPatch(input: {
archiveId: string
patch: ProductArchiveEnrichmentPatch

View File

@ -153,6 +153,8 @@ export const FILTER_OPTION_DATABASE_FIELDS = {
opsDirector: 'opsDirector',
opsDeptHead: 'opsDeptHead',
opsHead: 'opsHead',
hasDrawing: 'hasDrawing',
hasVideo: 'hasVideo',
warehouseHead: 'warehouseHead',
warehouseType: 'warehouseType',
purchasePrice: 'purchasePrice',

View File

@ -47,6 +47,7 @@ describe('supply-chain product archive enrichment sync', () => {
sku: 'SKU-001',
supplierName: '',
inOutWarehouse: 'WH-1',
image: '',
},
{
clearanceStocks: [],
@ -57,6 +58,7 @@ describe('supply-chain product archive enrichment sync', () => {
prices: [],
dwsGoodsCosts: [],
skumapPics: [],
skumapFillPics: [],
dwsOpsOwners: [],
catalogCosts: [],
warehouseConfigs: [],
@ -79,6 +81,7 @@ describe('supply-chain product archive enrichment sync', () => {
],
dwsGoodsCosts: [{ skuoid: 'DF-A3BJ006601', goodsCost: '70.18' }],
skumapPics: [{ skuId: 'DF-A3BJ006601', pic: 'https://img.pddpic.com/mms-material-img/df.jpg' }],
skumapFillPics: [],
dwsOpsOwners: [],
catalogCosts: [],
warehouseConfigs: [],
@ -92,6 +95,7 @@ describe('supply-chain product archive enrichment sync', () => {
sku: 'DF-A3BJ0066',
supplierName: '',
inOutWarehouse: '',
image: '',
},
lookups,
)
@ -116,6 +120,7 @@ describe('supply-chain product archive enrichment sync', () => {
sku: 'DF-A3BJ0066',
supplierName: '',
inOutWarehouse: '',
image: '',
},
{ ...lookups, skumapPics: [{ skuId: 'DF-A3BJ006601', pic: 'https://x/df.jpg' }] },
)
@ -131,12 +136,68 @@ describe('supply-chain product archive enrichment sync', () => {
sku: 'SKU-001',
supplierName: '',
inOutWarehouse: '',
image: '',
},
lookups,
)
expect(barUpdates.some((update) => 'image' in update.patch)).toBe(false)
})
it('fills empty non-DF images from skumap but never overwrites existing images', () => {
const lookups = {
clearanceStocks: [],
publicAvailableStocks: [],
suppliers: [],
stocks: [],
warehouses: [],
prices: [],
dwsGoodsCosts: [],
skumapPics: [],
skumapFillPics: [{ skuId: 'BAR-001', pic: 'https://img.example.com/bar-001.jpg' }],
dwsOpsOwners: [],
catalogCosts: [],
warehouseConfigs: [],
}
// Empty archive image: filled from skumap
const emptyUpdates = buildProductArchiveEnrichmentUpdates(
{
id: 'archive-1',
barcode: 'BAR-001',
spu: 'SPU-001',
sku: 'SKU-001',
supplierName: '',
inOutWarehouse: '',
image: '',
},
lookups,
)
const fillUpdate = emptyUpdates.find((update) => 'image' in update.patch)
expect(fillUpdate?.patch).toEqual({ image: 'https://img.example.com/bar-001.jpg' })
expect(fillUpdate?.source).toEqual(
expect.objectContaining({
sourceCode: 'JST_SKUMAP_PIC',
sourceObject: 'jst_skumap|jst_skumap_pdd|jst_skumap_qmcust_add',
sourceColumns: { image: 'pic' },
}),
)
// Existing archive image: untouched (jst_sku / product-development wins)
const filledUpdates = buildProductArchiveEnrichmentUpdates(
{
id: 'archive-2',
barcode: 'BAR-001',
spu: 'SPU-001',
sku: 'SKU-001',
supplierName: '',
inOutWarehouse: '',
image: 'https://img.example.com/existing.jpg',
},
lookups,
)
expect(filledUpdates.some((update) => 'image' in update.patch)).toBe(false)
})
it('takes ops owners from the dws sales table only when dws has a value', () => {
const lookups = {
clearanceStocks: [],
@ -147,6 +208,7 @@ describe('supply-chain product archive enrichment sync', () => {
prices: [],
dwsGoodsCosts: [],
skumapPics: [],
skumapFillPics: [],
dwsOpsOwners: [
{ skuoid: 'BAR-001', cater: '比干', ider: '精卫' },
{ skuoid: 'BAR-002', cater: '香茗', ider: '' },
@ -163,6 +225,7 @@ describe('supply-chain product archive enrichment sync', () => {
sku: 'SKU-001',
supplierName: '',
inOutWarehouse: '',
image: '',
},
lookups,
)
@ -187,6 +250,7 @@ describe('supply-chain product archive enrichment sync', () => {
sku: 'SKU-002',
supplierName: '',
inOutWarehouse: '',
image: '',
},
lookups,
)
@ -203,6 +267,7 @@ describe('supply-chain product archive enrichment sync', () => {
sku: 'SKU-009',
supplierName: '',
inOutWarehouse: '',
image: '',
},
lookups,
)
@ -223,6 +288,7 @@ describe('supply-chain product archive enrichment sync', () => {
],
dwsGoodsCosts: [{ skuoid: 'DF-A3BJ006601', goodsCost: '70.18' }],
skumapPics: [],
skumapFillPics: [],
dwsOpsOwners: [],
catalogCosts: [],
warehouseConfigs: [],
@ -236,6 +302,7 @@ describe('supply-chain product archive enrichment sync', () => {
sku: 'DF-A3BJ0066',
supplierName: '',
inOutWarehouse: '',
image: '',
},
lookups,
)
@ -261,6 +328,7 @@ describe('supply-chain product archive enrichment sync', () => {
sku: 'DF-A3BJ0066',
supplierName: '',
inOutWarehouse: '',
image: '',
},
{ ...lookups, dwsGoodsCosts: [] },
)
@ -277,6 +345,7 @@ describe('supply-chain product archive enrichment sync', () => {
sku: 'SKU-001',
supplierName: '',
inOutWarehouse: '',
image: '',
},
lookups,
)
@ -294,12 +363,14 @@ describe('supply-chain product archive enrichment sync', () => {
sku: 'SKU-001',
supplierName: '源沃家具',
inOutWarehouse: 'WH-1',
image: '',
}
const repository = {
listTargets: jest
.fn()
.mockResolvedValueOnce([target])
.mockResolvedValueOnce([]),
listNonDropshipBarcodesWithoutImage: jest.fn().mockResolvedValue([]),
applyPatch: jest.fn().mockImplementation(async (input) => {
if ('purchasePrice' in input.patch) {
return {
@ -385,6 +456,7 @@ describe('supply-chain product archive enrichment sync', () => {
},
skumapPicRepository: {
listDfLatestSkuPics: jest.fn().mockResolvedValue([]),
listLatestSkuPicsForBarcodes: jest.fn().mockResolvedValue([]),
close: jest.fn().mockResolvedValue(undefined),
},
publicAvailableStockRepository,

View File

@ -52,6 +52,7 @@ export type ProductArchiveEnrichmentLookups = {
prices: readonly JstSkuPriceProfile[]
dwsGoodsCosts: readonly DwsSalesGoodsCostProfile[]
skumapPics: readonly JstSkumapPicProfile[]
skumapFillPics: readonly JstSkumapPicProfile[]
dwsOpsOwners: readonly DwsSalesOpsOwnerProfile[]
catalogCosts: readonly ProductCatalogCostPriceProfile[]
warehouseConfigs: readonly ProductArchiveWarehouseConfigRecord[]
@ -147,6 +148,29 @@ export function buildProductArchiveEnrichmentUpdates(
(profile) => normalizeIdentity(profile.skuId) === normalizeIdentity(target.barcode),
)
: undefined
// Non-DF image fallback: only barcodes whose archive image is empty receive
// the skumap platform picture, so jst_sku / product-development images win.
const skumapFillPic = !isDropshipSku && !target.image
? lookups.skumapFillPics.find(
(profile) => normalizeIdentity(profile.skuId) === normalizeIdentity(target.barcode),
)
: undefined
const skumapFillUpdates: ProductArchiveEnrichmentUpdate[] =
!isDropshipSku && skumapFillPic?.pic
? [
{
patch: { image: skumapFillPic.pic },
source: source(
JST_SKUMAP_PIC_ARCHIVE_SOURCE_CODE,
JST_SOURCE_SYSTEM,
JST_SOURCE_SCHEMA,
'jst_skumap|jst_skumap_pdd|jst_skumap_qmcust_add',
skumapFillPic.skuId,
{ image: 'pic' },
),
},
]
: []
// Ops owners come from the sales detail table for every barcode with sales
// history; per agreed contract, empty dws values keep the current archive
// value untouched (field-level overwrite only when dws has a name).
@ -191,6 +215,7 @@ export function buildProductArchiveEnrichmentUpdates(
},
]
: []),
...skumapFillUpdates,
...opsOwnerUpdates,
{
patch: {

View File

@ -60,7 +60,10 @@ type EnrichmentDependencies = {
DwsSalesGoodsCostRepository,
'listDfLatestGoodsCost' | 'listLatestOpsOwners' | 'close'
>
skumapPicRepository: Pick<JstSkumapPicRepository, 'listDfLatestSkuPics' | 'close'>
skumapPicRepository: Pick<
JstSkumapPicRepository,
'listDfLatestSkuPics' | 'listLatestSkuPicsForBarcodes' | 'close'
>
publicAvailableStockRepository: Pick<
JstInventoryPublicAvailableRepository,
'listByBarcodes' | 'close'
@ -115,6 +118,11 @@ export class SupplyChainProductArchiveEnrichmentSyncService {
const skumapPics = await this.dependencies.skumapPicRepository.listDfLatestSkuPics()
// One shot: latest ops owners (cater/ider) for every barcode with sales.
const dwsOpsOwners = await this.dependencies.dwsGoodsCostRepository.listLatestOpsOwners()
// One shot: skumap platform pictures for non-DF barcodes that currently
// have no image — the fallback layer behind jst_sku/product-development.
const skumapFillPics = await this.dependencies.skumapPicRepository.listLatestSkuPicsForBarcodes(
await this.dependencies.repository.listNonDropshipBarcodesWithoutImage(),
)
try {
while (true) {
const targets = await this.dependencies.repository.listTargets({
@ -164,6 +172,7 @@ export class SupplyChainProductArchiveEnrichmentSyncService {
prices,
dwsGoodsCosts,
skumapPics,
skumapFillPics,
dwsOpsOwners,
catalogCosts,
warehouseConfigs,

View File

@ -62,6 +62,8 @@ const productArchiveFiltersSchema = z.object({
opsDirector: archiveValueArraySchema.optional(),
opsDeptHead: archiveValueArraySchema.optional(),
opsHead: archiveValueArraySchema.optional(),
hasDrawing: archiveValueArraySchema.optional(),
hasVideo: archiveValueArraySchema.optional(),
warehouseHead: archiveValueArraySchema.optional(),
warehouseOwnership: archiveValueArraySchema.optional(),
warehouseType: archiveValueArraySchema.optional(),