fix(installation-demo): route customer service through node permissions
删除安装演示权限里硬编码的客服强制只读分享口径:原 isCustomerServiceUser (CUSTOMER_SERVICE 角色或部门路径含客服中心)直接返回 canEdit=false 且排在 节点权限 overrides 之前,导致正式站给客服配置的编辑权限永远无效,客服只能 查看和分享、无法新建编辑截取。 删除后客服与普通员工一致走节点权限配置:客服主体 customerService (部门路径含客服中心),正式站已配置的编辑权限即刻生效,可新建图纸、 维护编号、截取、分享与下载。
This commit is contained in:
parent
aaa9c74eec
commit
4f2ea0bdf0
128
furniture-3d-system/scripts/sync-drawing-system-media.ts
Normal file
128
furniture-3d-system/scripts/sync-drawing-system-media.ts
Normal 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
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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()
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user