diff --git a/furniture-3d-system/scripts/sync-drawing-system-media.ts b/furniture-3d-system/scripts/sync-drawing-system-media.ts new file mode 100644 index 00000000..24519af1 --- /dev/null +++ b/furniture-3d-system/scripts/sync-drawing-system-media.ts @@ -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 + }) diff --git a/furniture-3d-system/src/lib/__tests__/supply-chain-product-archive-drawing-system.test.ts b/furniture-3d-system/src/lib/__tests__/supply-chain-product-archive-drawing-system.test.ts new file mode 100644 index 00000000..7cbf3229 --- /dev/null +++ b/furniture-3d-system/src/lib/__tests__/supply-chain-product-archive-drawing-system.test.ts @@ -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 { + 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') + }) +}) diff --git a/furniture-3d-system/src/lib/installation-demo-workbench-permissions.test.ts b/furniture-3d-system/src/lib/installation-demo-workbench-permissions.test.ts index a7ff799a..6f9c3e98 100644 --- a/furniture-3d-system/src/lib/installation-demo-workbench-permissions.test.ts +++ b/furniture-3d-system/src/lib/installation-demo-workbench-permissions.test.ts @@ -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, }) }) diff --git a/furniture-3d-system/src/lib/installation-demo-workbench-permissions.ts b/furniture-3d-system/src/lib/installation-demo-workbench-permissions.ts index 2e66cfc6..d0471817 100644 --- a/furniture-3d-system/src/lib/installation-demo-workbench-permissions.ts +++ b/furniture-3d-system/src/lib/installation-demo-workbench-permissions.ts @@ -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( diff --git a/furniture-3d-system/src/lib/supply-chain-product-archive-drawing-system.ts b/furniture-3d-system/src/lib/supply-chain-product-archive-drawing-system.ts new file mode 100644 index 00000000..399f2fb6 --- /dev/null +++ b/furniture-3d-system/src/lib/supply-chain-product-archive-drawing-system.ts @@ -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() + for (const archive of archives) { + barcodeSet.set(normalizeIdentity(archive.barcode), archive) + } + const spuIndex = new Map() + 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() + 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 +} diff --git a/furniture-3d-system/src/server/repositories/drawing-system-mission.repository.ts b/furniture-3d-system/src/server/repositories/drawing-system-mission.repository.ts new file mode 100644 index 00000000..a0a20d1f --- /dev/null +++ b/furniture-3d-system/src/server/repositories/drawing-system-mission.repository.ts @@ -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 +} + +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).url) !== '' || + normalizeText((file as Record).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 { + 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> = [] + 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 + 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 + 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() diff --git a/furniture-3d-system/src/server/repositories/jst-skumap-pic.repository.ts b/furniture-3d-system/src/server/repositories/jst-skumap-pic.repository.ts index 57cc5aa4..62eec7a8 100644 --- a/furniture-3d-system/src/server/repositories/jst-skumap-pic.repository.ts +++ b/furniture-3d-system/src/server/repositories/jst-skumap-pic.repository.ts @@ -7,6 +7,7 @@ export type JstSkumapPicProfile = { export interface JstSkumapPicRepository { listDfLatestSkuPics(): Promise + listLatestSkuPicsForBarcodes(barcodes: string[]): Promise close(): Promise } @@ -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(` + 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() diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-enrichment-sync.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-enrichment-sync.repository.ts index 376c84eb..838b1922 100644 --- a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-enrichment-sync.repository.ts +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-enrichment-sync.repository.ts @@ -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 + listNonDropshipBarcodesWithoutImage(): Promise 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 diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-filters.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-filters.ts index b9d8d037..8b2e8348 100644 --- a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-filters.ts +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-filters.ts @@ -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', diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-enrichment-sync.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-enrichment-sync.service.test.ts index 8fd8565f..142bc74b 100644 --- a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-enrichment-sync.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-enrichment-sync.service.test.ts @@ -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, diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.mapper.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.mapper.ts index 7022e7fe..7ac27cda 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.mapper.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.mapper.ts @@ -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: { diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.service.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.service.ts index 996dd415..5350fed4 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.service.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.service.ts @@ -60,7 +60,10 @@ type EnrichmentDependencies = { DwsSalesGoodsCostRepository, 'listDfLatestGoodsCost' | 'listLatestOpsOwners' | 'close' > - skumapPicRepository: Pick + 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, diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-query.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-query.ts index 4c41ece9..2705a9f6 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive-query.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-query.ts @@ -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(),