feat(data-foundation): sync draft packaging and series cleanup

This commit is contained in:
WuYongJie 2026-06-03 15:52:05 +08:00
parent 67ff6fba96
commit 6a362bcdde
16 changed files with 1773 additions and 131 deletions

View File

@ -39,3 +39,26 @@ export async function PUT(
{ requireAuth: true, requireDesignWorkspaceWrite: true },
)
}
export async function DELETE(
request: NextRequest,
context: { params: Promise<{ id: string }> },
) {
return handleRequest(
request,
async (ctx) => {
const { id } = await context.params
try {
await assetCenterService.deleteSeries(id)
return jsonSuccess({ deleted: true }, 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true, allowedRoles: ['ADMIN', 'SUPER_ADMIN'] },
)
}

View File

@ -0,0 +1,113 @@
export {}
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
import { ErrorCode } from '@/server/core/errors'
const mockHandleRequest: any = jest.fn()
const mockJsonSuccess: any = jest.fn()
const mockJsonError: any = jest.fn()
const mockReplaceAll: any = jest.fn()
const mockList: any = jest.fn()
jest.mock('@/server/core/response', () => ({
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args),
jsonError: (...args: unknown[]) => mockJsonError(...args),
}))
jest.mock('@/server/services', () => ({
intakeReferenceLibraryService: {
list: (...args: unknown[]) => mockList(...args),
replaceAll: (...args: unknown[]) => mockReplaceAll(...args),
},
}))
const { PUT } = require('./route')
function createRequest(body: unknown) {
return {
json: jest.fn(async () => body),
nextUrl: {
searchParams: new URLSearchParams(),
},
} as any
}
function mockAuthenticatedUser(user: Record<string, unknown>) {
mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) =>
handler({
requestId: 'req-reference-library',
user,
}),
)
}
describe('PUT /api/intake/reference-library', () => {
beforeEach(() => {
jest.clearAllMocks()
mockJsonSuccess.mockImplementation((data, status, requestId) => ({ success: true, data, status, requestId }))
mockJsonError.mockImplementation((code, message, status, requestId) => ({ success: false, code, message, status, requestId }))
mockReplaceAll.mockResolvedValue([])
})
it('allows administrators to maintain SPU rules without internal business roles', async () => {
mockAuthenticatedUser({
id: 'admin-1',
role: 'ADMIN',
businessRoles: [],
status: 'ACTIVE',
})
const response = await PUT(createRequest({
type: 'SPU_RULE',
items: [
{
itemKey: 'rule-sz',
code: 'SZ',
name: '书桌',
payload: {},
},
],
}))
expect(mockHandleRequest.mock.calls[0][2]).toEqual({ requireAuth: true })
expect(mockReplaceAll).toHaveBeenCalledWith(
expect.objectContaining({
libraryType: 'SPU_RULE',
userId: 'admin-1',
}),
)
expect(response).toEqual(expect.objectContaining({ success: true }))
})
it('rejects non-admin users from maintaining SPU rules even when they have business roles', async () => {
mockAuthenticatedUser({
id: 'design-1',
role: 'USER',
businessRoles: ['DESIGN'],
status: 'ACTIVE',
})
const response = await PUT(createRequest({
type: 'SPU_RULE',
items: [
{
itemKey: 'rule-sz',
code: 'SZ',
name: '书桌',
payload: {},
},
],
}))
expect(mockReplaceAll).not.toHaveBeenCalled()
expect(response).toEqual(
expect.objectContaining({
success: false,
code: ErrorCode.AUTH_PERMISSION_DENIED,
status: 403,
message: expect.stringContaining('管理员'),
}),
)
})
})

View File

@ -2,11 +2,23 @@ import { NextRequest } from 'next/server'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { intakeReferenceLibraryService } from '@/server/services'
import { canCreateContent, isAdmin } from '@/lib/role-scope'
function readLibraryTypeFromRequest(request: NextRequest) {
return request.nextUrl.searchParams.get('type') ?? ''
}
function canWriteReferenceLibrary(
user: Parameters<typeof canCreateContent>[0],
libraryType: string,
) {
if (libraryType.trim().toUpperCase() === 'SPU_RULE') {
return isAdmin(user)
}
return canCreateContent(user)
}
export async function GET(request: NextRequest) {
return handleRequest(
request,
@ -47,6 +59,12 @@ export async function PUT(request: NextRequest) {
if (!items) {
return jsonError(ErrorCode.VALIDATION_FAILED, '资料库 items 必须是数组', 400, ctx.requestId)
}
if (!canWriteReferenceLibrary(ctx.user, libraryType)) {
const message = libraryType.trim().toUpperCase() === 'SPU_RULE'
? '只有管理员或超级管理员可以维护 SPU 编码规则库'
: '当前账号没有内部业务写入权限'
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, message, 403, ctx.requestId)
}
try {
const savedItems = await intakeReferenceLibraryService.replaceAll({
@ -64,6 +82,6 @@ export async function PUT(request: NextRequest) {
)
}
},
{ requireAuth: true, requireInternalContentWrite: true },
{ requireAuth: true },
)
}

View File

@ -17,7 +17,7 @@ import Button from '@/components/ui/Button'
import { DeleteConfirmDialog } from '@/components/ui/DeleteConfirmDialog'
import { Select } from '@/components/ui/Select'
import { useToast, ToastContainer } from '@/components/ui/Toast'
import { canOperateDesignWorkspace as canOperateDesignWorkspaceForUser } from '@/lib/role-scope'
import { canOperateDesignWorkspace as canOperateDesignWorkspaceForUser, isAdmin } from '@/lib/role-scope'
import { cn } from '@/lib/utils'
import {
@ -47,6 +47,7 @@ import {
uploadMaterialAsset,
createAssetSeries,
updateAssetSeries,
deleteAssetSeries,
} from '@/lib/open-product/platform-api'
interface FinishForm {
@ -458,6 +459,7 @@ export default function MaterialLibraryClient({
const toast = useToast()
const canOperateDesignWorkspace =
canEditOverride ?? (user ? canOperateDesignWorkspaceForUser(user) : false)
const canDeleteSeries = user ? isAdmin(user) : false
const showReadOnlyWarning = useCallback(() => {
toast.warning('当前账号只能查看基础资料库,不能新增、编辑或保存')
}, [toast])
@ -532,7 +534,7 @@ export default function MaterialLibraryClient({
const [dictionaryForm, setDictionaryForm] = useState({ name: '', sortOrder: '', thicknessMm: '' })
const [deleteTarget, setDeleteTarget] = useState<{
type: 'finish' | 'substrate' | 'material-category' | 'substrate-dictionary'
type: 'finish' | 'substrate' | 'material-category' | 'substrate-dictionary' | 'series'
id: string
name: string
dictionaryType?: SubstrateDictionaryType
@ -1108,8 +1110,31 @@ export default function MaterialLibraryClient({
}
}
async function handleDeleteSeries(seriesId: string) {
if (!canDeleteSeries) {
toast.warning('只有管理员或超级管理员可以删除系列')
return
}
setSubmitting(true)
try {
await deleteAssetSeries(seriesId)
setSeriesList((current) => current.filter((item) => item.id !== seriesId))
setPanel({ mode: 'empty' })
setDeleteTarget(null)
toast.success('系列已删除')
} catch (error) {
toast.error(error instanceof Error ? error.message : '删除失败')
} finally {
setSubmitting(false)
}
}
async function handleDeleteConfirm() {
if (!deleteTarget) return
if (deleteTarget.type === 'series') {
await handleDeleteSeries(deleteTarget.id)
return
}
if (!canOperateDesignWorkspace) {
showReadOnlyWarning()
setDeleteTarget(null)
@ -1336,6 +1361,23 @@ export default function MaterialLibraryClient({
>
{activeSeries.isActive ? '停用' : '启用'}
</Button>
{canDeleteSeries ? (
<Button
variant="plain"
size="small"
className="text-apple-red"
loading={submitting}
onClick={() =>
setDeleteTarget({
type: 'series',
id: activeSeries.id,
name: activeSeries.displayName,
})
}
>
删除
</Button>
) : null}
</>
) : null}
{isCreate || isEdit ? (
@ -2899,9 +2941,15 @@ export default function MaterialLibraryClient({
? '删除基材'
: deleteTarget?.type === 'material-category'
? '删除花色分类'
: '删除字典选项'
: deleteTarget?.type === 'series'
? '删除系列'
: '删除字典选项'
}
description={
deleteTarget?.type === 'series'
? `确认删除「${deleteTarget?.name ?? ''}」吗?只有未被资产族、草稿组、产品资料引用的系列可以删除。`
: `确认删除「${deleteTarget?.name ?? ''}」吗?这个操作不能撤回。`
}
description={`确认删除「${deleteTarget?.name ?? ''}」吗?这个操作不能撤回。`}
confirmText="确认删除"
/>
<ToastContainer toasts={toast.toasts} onClose={toast.removeToast} position="top" />

View File

@ -17,6 +17,7 @@ import {
} from 'lucide-react'
import {
readPreSkuComponentDetails,
readPreSkuPackageComponentDetails,
readPreSkuSkuFields,
type SeriesDraftSkuFields,
} from '@/app/intake/seriesComponentDetails'
@ -210,6 +211,7 @@ const FORMAL_LEFT_PANE_WIDTH_STORAGE_KEY = 'draft-groups-formal-left-pane-width'
const FORMAL_RIGHT_PANE_WIDTH_STORAGE_KEY = 'draft-groups-formal-right-pane-width'
const FORMAL_PACKAGE_COMPONENT_ASSIGNMENTS_STORAGE_KEY = 'draft-groups-formal-package-component-assignments'
const FORMAL_PACKAGE_MATERIAL_ASSIGNMENTS_STORAGE_KEY = 'draft-groups-formal-package-material-assignments'
const PRIMARY_CARTON_ASSIGNMENT_NOTE = 'PRIMARY_CARTON'
const DEFAULT_LEFT_PANE_WIDTH = 372
const DEFAULT_RIGHT_PANE_WIDTH = 486
const MIN_LEFT_PANE_WIDTH = 300
@ -681,7 +683,9 @@ function getPackageSuffixByIndex(index: number) {
}
function getPackageKey(packageSuffix: string) {
return packageSuffix.trim().toUpperCase() || 'single'
const normalized = packageSuffix.trim().toUpperCase()
if (!normalized || normalized === 'SINGLE' || normalized === '单包') return 'single'
return normalized
}
function getPackageLabel(packageKey: string) {
@ -855,6 +859,89 @@ function buildPackageMaterialAssignmentsFromPackagingPlan(
}, {} as PackageMaterialAssignments)
}
function isCartonText(value: string | null | undefined) {
const normalized = value?.trim().toLowerCase() ?? ''
return Boolean(normalized) && (
normalized.includes('纸箱') ||
normalized.includes('carton') ||
normalized.includes('ctn')
)
}
function isCartonMaterialOption(option: PackagingMaterialOption) {
return (
isCartonText(option.assetFamilyName) ||
isCartonText(option.name) ||
isCartonText(option.code)
)
}
function getCartonMaterialOptions(options: PackagingMaterialOption[]) {
return options.filter(isCartonMaterialOption)
}
function getSupplementalPackagingMaterialOptions(options: PackagingMaterialOption[]) {
return options.filter((option) => !isCartonMaterialOption(option))
}
function isPrimaryCartonAssignment(
material: PackageMaterialAssignment,
materialOptionById: Map<string, PackagingMaterialOption>,
) {
if (material.notes === PRIMARY_CARTON_ASSIGNMENT_NOTE) return true
const option = materialOptionById.get(material.assetVariantId)
return (
isCartonText(material.name) ||
isCartonText(material.code) ||
Boolean(option && isCartonMaterialOption(option))
)
}
function getPrimaryCartonAssignment(
materials: PackageMaterialAssignment[],
materialOptions: PackagingMaterialOption[],
) {
const materialOptionById = new Map(materialOptions.map((option) => [option.assetVariantId, option]))
return normalizePackageMaterialAssignments(materials).find((material) =>
isPrimaryCartonAssignment(material, materialOptionById),
) ?? null
}
function getSupplementalPackageMaterialAssignments(
materials: PackageMaterialAssignment[],
materialOptions: PackagingMaterialOption[],
) {
const materialOptionById = new Map(materialOptions.map((option) => [option.assetVariantId, option]))
return normalizePackageMaterialAssignments(materials).filter(
(material) => !isPrimaryCartonAssignment(material, materialOptionById),
)
}
function replacePrimaryCartonAssignment(
materials: PackageMaterialAssignment[],
carton: PackagingMaterialOption | null,
materialOptions: PackagingMaterialOption[],
) {
const materialOptionById = new Map(materialOptions.map((option) => [option.assetVariantId, option]))
const otherMaterials = normalizePackageMaterialAssignments(materials).filter(
(material) => !isPrimaryCartonAssignment(material, materialOptionById),
)
if (!carton) return otherMaterials
return normalizePackageMaterialAssignments([
{
assetVariantId: carton.assetVariantId,
assetFamilyId: carton.assetFamilyId,
code: carton.code,
name: carton.name,
quantity: 1,
notes: PRIMARY_CARTON_ASSIGNMENT_NOTE,
},
...otherMaterials,
])
}
function getComponentDetailCode(component: NonIntegratedSkuComponentDetail) {
return normalizeComponentCode(component.code || component.name || component.role)
}
@ -1167,18 +1254,40 @@ function MaterialPreview({
packageRows,
packageAssignments,
packageMaterialAssignments,
packagingMaterialOptions,
packagingMaterialOptionsLoading,
packagingMaterialOptionsError,
saving,
packagingPlanSaving,
onComponentPackageChange,
onPrimaryCartonChange,
onPackageMaterialAdd,
onPackageMaterialRemove,
}: {
preSku: DraftGroupPreSkuDTO
packageRows: SkuContentPreviewRow[]
packageAssignments: PackageComponentAssignments
packageMaterialAssignments: PackageMaterialAssignments
packagingMaterialOptions: PackagingMaterialOption[]
packagingMaterialOptionsLoading: boolean
packagingMaterialOptionsError: string | null
saving: boolean
packagingPlanSaving: boolean
onComponentPackageChange: (componentCode: string, packageKey: string | null) => void
onPrimaryCartonChange: (packageKey: string, assetVariantId: string) => void
onPackageMaterialAdd: (packageKey: string, material: PackagingMaterialOption) => void
onPackageMaterialRemove: (packageKey: string, assetVariantId: string) => void
}) {
const [selectedSupplementalMaterialByPackageKey, setSelectedSupplementalMaterialByPackageKey] =
useState<Record<string, string>>({})
const sections = deriveDraftMaterialSections(preSku)
const allComponentDetails = readPreSkuComponentDetails(preSku)
const cartonOptions = getCartonMaterialOptions(packagingMaterialOptions)
const supplementalMaterialOptions =
getSupplementalPackagingMaterialOptions(packagingMaterialOptions)
const materialOptionById = new Map(
packagingMaterialOptions.map((option) => [option.assetVariantId, option]),
)
const packageOptions = packageRows.map((row) => {
const key = getPackageKey(row.packageSuffix)
return {
@ -1299,6 +1408,27 @@ function MaterialPreview({
const materials = normalizePackageMaterialAssignments(
packageMaterialAssignments[packageKey] ?? [],
)
const primaryCarton = getPrimaryCartonAssignment(materials, packagingMaterialOptions)
const availableCartonOptions = cartonOptions.filter(
(option) => option.assetVariantId !== primaryCarton?.assetVariantId,
)
const supplementalMaterials = getSupplementalPackageMaterialAssignments(
materials,
packagingMaterialOptions,
)
const selectedSupplementalMaterialIds = new Set(
supplementalMaterials.map((material) => material.assetVariantId),
)
const availableSupplementalMaterialOptions = supplementalMaterialOptions.filter(
(option) => !selectedSupplementalMaterialIds.has(option.assetVariantId),
)
const selectedSupplementalMaterialId =
selectedSupplementalMaterialByPackageKey[packageKey] ?? ''
const selectedSupplementalMaterialOption =
availableSupplementalMaterialOptions.find(
(option) => option.assetVariantId === selectedSupplementalMaterialId,
) ?? null
const packageSaving = saving || packagingPlanSaving
return (
<div key={packageKey} className="px-3 py-2.5">
<div className="flex items-start justify-between gap-3">
@ -1311,30 +1441,162 @@ function MaterialPreview({
</p>
</div>
<span className="rounded-[4px] bg-[#edf4f3] px-1.5 py-0.5 text-[10px] font-black text-[#47646b]">
{materials.length}
{Number(Boolean(primaryCarton)) + supplementalMaterials.length}
</span>
</div>
{materials.length > 0 ? (
<div className="mt-2 space-y-1.5">
{materials.map((material) => (
<div
key={material.assetVariantId}
className="flex items-center justify-between gap-2 rounded-[5px] bg-[#f7f9f9] px-2 py-1.5"
>
<span className="min-w-0 truncate text-[11px] font-black text-[#2d3335]">
{[material.code, material.name].filter(Boolean).join(' · ') ||
material.assetVariantId}
</span>
<span className="shrink-0 text-[10px] font-black text-[#47646b]">
x{material.quantity}
</span>
</div>
))}
<div className="mt-2 rounded-[6px] border border-[#edf0f1] bg-[#fbfcfc] p-2">
<div className="flex items-center justify-between gap-2">
<span className="shrink-0 text-[11px] font-black text-[#2d3335]">
适配纸箱条码
</span>
{packagingPlanSaving ? (
<span className="text-[10px] font-black text-[#47646b]/62">保存中</span>
) : null}
</div>
) : (
<p className="mt-2 rounded-[6px] border border-dashed border-[#cfd8da] bg-[#fbfcfc] p-2 text-[11px] font-semibold text-[#5a6062]/62">
未配置纸箱、泡沫、护角等包装 SKU
<select
aria-label={`${getPackageLabel(packageKey)}适配纸箱条码`}
value=""
disabled={packageSaving || packagingMaterialOptionsLoading || availableCartonOptions.length === 0}
onChange={(event) => onPrimaryCartonChange(packageKey, event.target.value)}
className="mt-1.5 h-8 w-full rounded-[5px] border border-[#dbe3e5] bg-white px-2 text-[11px] font-black text-[#2d3335] outline-none disabled:bg-[#f7f9f9] disabled:text-[#5a6062]/55"
>
<option value="">
{packagingMaterialOptionsLoading
? '读取纸箱条码'
: primaryCarton
? availableCartonOptions.length > 0
? '更换适配纸箱条码'
: '已选择纸箱条码'
: availableCartonOptions.length > 0
? '选择适配纸箱条码'
: '暂无可选纸箱条码'}
</option>
{availableCartonOptions.map((option) => (
<option key={option.assetVariantId} value={option.assetVariantId}>
{[option.code, option.name].filter(Boolean).join(' · ')}
</option>
))}
</select>
{primaryCarton ? (
<div className="mt-2 rounded-[5px] bg-white px-2 py-1.5">
<p className="truncate text-[11px] font-black text-[#2d3335]">
{primaryCarton.name ||
materialOptionById.get(primaryCarton.assetVariantId)?.name ||
'纸箱'}
</p>
<p className="mt-0.5 truncate text-[10px] font-black text-[#47646b]/75">
{primaryCarton.code ||
materialOptionById.get(primaryCarton.assetVariantId)?.code ||
primaryCarton.assetVariantId}
</p>
</div>
) : (
<p className="mt-2 rounded-[5px] border border-dashed border-[#cfd8da] bg-white px-2 py-1.5 text-[11px] font-semibold text-[#5a6062]/62">
未选择纸箱条码
</p>
)}
</div>
<div className="mt-2 rounded-[6px] border border-[#edf0f1] bg-[#fbfcfc] p-2">
<div className="flex items-center justify-between gap-2">
<span className="shrink-0 text-[11px] font-black text-[#2d3335]">
适配包材条码
</span>
{packagingPlanSaving ? (
<span className="text-[10px] font-black text-[#47646b]/62">保存中</span>
) : null}
</div>
<select
aria-label={`${getPackageLabel(packageKey)}适配包材条码`}
value={selectedSupplementalMaterialOption ? selectedSupplementalMaterialId : ''}
disabled={
packageSaving ||
packagingMaterialOptionsLoading ||
availableSupplementalMaterialOptions.length === 0
}
onChange={(event) => {
const nextMaterialId = event.target.value
const nextMaterial =
availableSupplementalMaterialOptions.find(
(option) => option.assetVariantId === nextMaterialId,
) ?? null
setSelectedSupplementalMaterialByPackageKey((current) => ({
...current,
[packageKey]: nextMaterial ? nextMaterialId : '',
}))
if (!nextMaterial) return
onPackageMaterialAdd(packageKey, nextMaterial)
setSelectedSupplementalMaterialByPackageKey((current) => ({
...current,
[packageKey]: '',
}))
}}
className="mt-1.5 h-8 w-full rounded-[5px] border border-[#dbe3e5] bg-white px-2 text-[11px] font-black text-[#2d3335] outline-none disabled:bg-[#f7f9f9] disabled:text-[#5a6062]/55"
>
<option value="">
{packagingMaterialOptionsLoading
? '读取包材条码'
: availableSupplementalMaterialOptions.length > 0
? '选择适配包材条码'
: supplementalMaterials.length > 0
? '已选择全部包材条码'
: '暂无可选包材条码'}
</option>
{availableSupplementalMaterialOptions.map((option) => (
<option key={option.assetVariantId} value={option.assetVariantId}>
{[option.code, option.name].filter(Boolean).join(' · ')}
</option>
))}
</select>
{supplementalMaterials.length > 0 ? (
<div className="mt-2 space-y-1.5">
{supplementalMaterials.map((material) => {
const option = materialOptionById.get(material.assetVariantId)
const displayName = material.name || option?.name || '包材'
const displayCode = material.code || option?.code || material.assetVariantId
return (
<div
key={material.assetVariantId}
className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2 rounded-[5px] bg-white px-2 py-1.5"
>
<div className="min-w-0">
<p className="truncate text-[11px] font-black text-[#2d3335]">
{displayName}
</p>
<p className="mt-0.5 truncate text-[10px] font-black text-[#47646b]/75">
{displayCode}
</p>
</div>
<span className="shrink-0 text-[10px] font-black text-[#47646b]">
x{material.quantity}
</span>
<button
type="button"
aria-label={`移除${displayCode}`}
disabled={packageSaving}
onClick={() => onPackageMaterialRemove(packageKey, material.assetVariantId)}
className="inline-flex h-7 w-7 items-center justify-center rounded-[5px] border border-rose-200 bg-white text-rose-700 transition hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)
})}
</div>
) : (
<p className="mt-2 rounded-[5px] border border-dashed border-[#cfd8da] bg-white px-2 py-1.5 text-[11px] font-semibold text-[#5a6062]/62">
未选择泡沫、护角、丝绵纸等包材条码
</p>
)}
</div>
{packagingMaterialOptionsError ? (
<p className="mt-1.5 text-[10px] font-semibold text-rose-700">
{packagingMaterialOptionsError}
</p>
) : (
null
)}
</div>
)
@ -2194,12 +2456,17 @@ function buildPushedSkuItems({
: `series-${preSku.id}`
const packageKey = getPackageKey(row.packageSuffix)
const assignedComponentCodes = packageAssignments[packageKey] ?? []
const componentDetails =
const selectedComponentDetails =
setCount > 1
? getComponentDetailsByCodes(allComponentDetails, assignedComponentCodes)
: assignedComponentCodes.length > 0
? getComponentDetailsByCodes(allComponentDetails, assignedComponentCodes)
: allComponentDetails
const packageComponentDetails = readPreSkuPackageComponentDetails(preSku, packageKey)
const componentDetails =
packageComponentDetails.length > 0
? packageComponentDetails
: selectedComponentDetails
return buildPushedSkuItem({
preSku,
@ -3214,6 +3481,31 @@ export default function DraftGroupsFormalClient({
)
}
function handlePrimaryCartonChange(packageKey: string, assetVariantId: string) {
if (!selectedPreSku) return
const normalizedPackageKey = getPackageKey(packageKey)
const carton =
selectedPackagingMaterialOptionsState.options.find(
(option) => option.assetVariantId === assetVariantId,
) ?? null
const nextMaterials = replacePrimaryCartonAssignment(
selectedPackageMaterialAssignments[normalizedPackageKey] ?? [],
carton,
selectedPackagingMaterialOptionsState.options,
)
const nextAssignments = {
...selectedPackageMaterialAssignments,
[normalizedPackageKey]: nextMaterials,
}
savePackageMaterialAssignmentsForPreSku(selectedPreSku.id, nextAssignments)
void syncPreSkuPackagingPlan(
selectedPackageComponentAssignments,
nextAssignments,
normalizedPackageKey,
)
}
function handlePackageMaterialRemove(packageKey: string, assetVariantId: string) {
if (!selectedPreSku) return
const normalizedPackageKey = getPackageKey(packageKey)
@ -3381,11 +3673,13 @@ export default function DraftGroupsFormalClient({
}
}
const activePackagingPreSkuId = selectedPreSku?.id ?? ''
useEffect(() => {
if (!selectedPreSku) return
const preSkuId = selectedPreSku.id
if (!activePackagingPreSkuId) return
const preSkuId = activePackagingPreSkuId
const existingState = packagingMaterialOptionsByPreSkuId[preSkuId]
if (existingState) return
if (existingState?.options.length > 0) return
let cancelled = false
setPackagingMaterialOptionsByPreSkuId((current) => ({
@ -3425,7 +3719,7 @@ export default function DraftGroupsFormalClient({
return () => {
cancelled = true
}
}, [packagingMaterialOptionsByPreSkuId, selectedPreSku])
}, [activePackagingPreSkuId])
useEffect(() => {
return () => {
@ -3921,11 +4215,21 @@ export default function DraftGroupsFormalClient({
packageRows={selectedPackageRows}
packageAssignments={selectedPackageComponentAssignments}
packageMaterialAssignments={selectedPackageMaterialAssignments}
packagingMaterialOptions={selectedPackagingMaterialOptionsState.options}
packagingMaterialOptionsLoading={selectedPackagingMaterialOptionsState.loading}
packagingMaterialOptionsError={selectedPackagingMaterialOptionsState.error}
saving={Boolean(
selectedPreSku &&
savingPackageComponentKey?.startsWith(`${selectedPreSku.id}:`),
)}
packagingPlanSaving={Boolean(
selectedPreSku &&
savingPackagingPlanKey?.startsWith(`${selectedPreSku.id}:`),
)}
onComponentPackageChange={handleMaterialComponentPackageChange}
onPrimaryCartonChange={handlePrimaryCartonChange}
onPackageMaterialAdd={handlePackageMaterialAdd}
onPackageMaterialRemove={handlePackageMaterialRemove}
/>
</section>
</div>

View File

@ -982,6 +982,7 @@ type WorkbenchSku = {
status: string;
componentCodes: string[];
componentDetails?: NonIntegratedSkuComponentDetail[];
packagingPlan?: { boxes?: unknown[] | null } | null;
holePoints?: NonIntegratedSkuHolePoint[];
skuFields?: SeriesDraftSkuFields;
model3dPreviewNodes?: unknown[];
@ -2571,6 +2572,7 @@ function getDraftGroupSeriesSkuOptions(props: IntakeClientProps): WorkbenchSku[]
status: mapStatus(preSku.status),
componentCodes: preSku.componentCodes ?? [],
componentDetails: readPreSkuComponentDetails(preSku),
packagingPlan: preSku.packagingPlan,
holePoints: buildSkuHolePointsFromVariantSelection(preSku),
skuFields,
model3dPreviewNodes: preSku.previewNodes.length

View File

@ -0,0 +1,105 @@
import {
createRuleFromImportedRow,
dedupeRules,
type SpuRule,
} from './SpuRuleLibraryWorkspace'
describe('SpuRuleLibraryWorkspace import helpers', () => {
it('parses PLM productCategory type=0 rows as SPU rules', () => {
const rule = createRuleFromImportedRow(
{
Id: 259,
type: 0,
topCategory: '住宅家具',
topCategoryCode: 'ZZJJ',
secondCategory: '桌类',
secondCategoryCode: 'ZL',
thirdCategory: '书桌',
thirdCategoryCode: 'SZ',
SPUIndex: 241,
enable: '1',
operationsManager: '江城',
visualSupervisor: '伯言',
updateTime: '2026-06-03 09:25:55',
},
0,
)
expect(rule).toMatchObject({
sourceId: '259',
topCategory: '住宅家具',
topCategoryCode: 'ZZJJ',
secondCategory: '桌类',
secondCategoryCode: 'ZL',
productCategory: '书桌',
categoryCode: 'SZ',
spuIndex: 241,
enabled: true,
operationsManager: '江城',
visualSupervisor: '伯言',
updatedAt: '2026-06-03 09:25:55',
})
})
it('ignores PLM type=1 category reference rows', () => {
const rule = createRuleFromImportedRow(
{
Id: 91,
type: 1,
topCategory: '商业/办公家具',
topCategoryCode: '26',
secondCategory: '办公家具',
secondCategoryCode: '2601',
thirdCategory: '办公屏风/隔断',
thirdCategoryCode: '260106',
enable: '1',
},
0,
)
expect(rule).toBeNull()
})
it('keeps the stronger duplicate category row after PLM import', () => {
const rows = [
createRuleFromImportedRow(
{
Id: 1,
type: 0,
topCategory: '住宅家具',
secondCategory: '架类',
thirdCategory: '鞋架',
thirdCategoryCode: 'XJ',
SPUIndex: 1,
enable: '0',
updateTime: '2024-10-30 13:32:50',
},
0,
),
createRuleFromImportedRow(
{
Id: 78,
type: 0,
topCategory: '住宅家具',
secondCategory: '架类',
thirdCategory: '鞋架',
thirdCategoryCode: 'XJ',
SPUIndex: 95,
enable: '1',
updateTime: '2025-04-30 15:08:16',
},
1,
),
].filter((rule): rule is SpuRule => Boolean(rule))
expect(dedupeRules(rows)).toEqual([
expect.objectContaining({
productCategory: '鞋架',
categoryCode: 'XJ',
spuIndex: 95,
enabled: true,
sourceCount: 2,
}),
])
})
})

View File

@ -17,7 +17,7 @@ import {
} from "./spuRuleLibraryLogic";
import { SPU_RULE_LIBRARY_SEED_RULES } from "./spuRuleLibrarySeed";
type SpuRule = {
export type SpuRule = {
id: string;
sourceId: string;
topCategory: string;
@ -52,6 +52,7 @@ type EditableSpuRule = Pick<
type ImportSummary = {
fileName: string;
rawCount: number;
ignoredRows: number;
uniqueCount: number;
duplicateRows: number;
conflictCount: number;
@ -80,6 +81,10 @@ function normalizeText(value: unknown) {
return String(value).trim();
}
function normalizeColumnKey(value: string) {
return value.trim().toLowerCase().replace(/\s+/g, "");
}
function normalizeNumber(value: unknown) {
const text = normalizeText(value);
if (!text) return 0;
@ -91,9 +96,31 @@ function readColumn(row: Record<string, unknown>, keys: string[]) {
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(row, key)) return row[key];
}
const normalizedKeys = new Set(keys.map(normalizeColumnKey));
for (const [key, value] of Object.entries(row)) {
if (normalizedKeys.has(normalizeColumnKey(key))) return value;
}
return "";
}
function hasColumn(row: Record<string, unknown>, keys: string[]) {
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(row, key)) return true;
}
const normalizedKeys = new Set(keys.map(normalizeColumnKey));
return Object.keys(row).some((key) => normalizedKeys.has(normalizeColumnKey(key)));
}
function shouldImportSpuRuleRow(row: Record<string, unknown>) {
if (!hasColumn(row, ["type", "类型"])) return true;
const type = normalizeText(readColumn(row, ["type", "类型"])).toLowerCase();
return type === "0" || type === "spu" || type === "spu规则";
}
function isEnabled(value: unknown) {
const text = normalizeText(value).toLowerCase();
return !["0", "false", "否", "停用", "disabled"].includes(text);
@ -168,11 +195,15 @@ function normalizeWorkspaceRules(rules: unknown[]) {
return normalizedRules.length > 0 ? normalizedRules : getSeedRules();
}
function createRuleFromImportedRow(
export function createRuleFromImportedRow(
row: Record<string, unknown>,
index: number,
): SpuRule | null {
const productCategory = normalizeText(readColumn(row, ["类目", "商品类目", "productCategory"]));
if (!shouldImportSpuRuleRow(row)) return null;
const productCategory = normalizeText(
readColumn(row, ["thirdCategory", "三级类目", "类目", "商品类目", "productCategory"]),
);
if (!productCategory) return null;
return {
@ -183,8 +214,10 @@ function createRuleFromImportedRow(
secondCategory: normalizeText(readColumn(row, ["品类", "二级类目", "secondCategory"])),
secondCategoryCode: normalizeText(readColumn(row, ["secondCategoryCode", "品类代码", "二级类目代码"])),
productCategory,
categoryCode: normalizeText(readColumn(row, ["类目代码", "categoryCode"])).toUpperCase(),
spuIndex: normalizeNumber(readColumn(row, ["SPU索引", "spuIndex", "当前已用号码"])),
categoryCode: normalizeText(
readColumn(row, ["thirdCategoryCode", "SPU前缀", "类目代码", "categoryCode"]),
).toUpperCase(),
spuIndex: normalizeNumber(readColumn(row, ["SPUIndex", "SPU索引", "spuIndex", "当前已用号码"])),
enabled: isEnabled(readColumn(row, ["enable", "启用", "状态"])),
operationsManager: normalizeText(readColumn(row, ["operationsManager", "运营负责人"])),
visualSupervisor: normalizeText(readColumn(row, ["visualSupervisor", "视觉负责人"])),
@ -194,7 +227,7 @@ function createRuleFromImportedRow(
} satisfies SpuRule;
}
function dedupeRules(rawRules: SpuRule[]) {
export function dedupeRules(rawRules: SpuRule[]) {
const grouped = new Map<string, SpuRule[]>();
rawRules.forEach((rule) => {
const key = rule.productCategory.trim();
@ -468,7 +501,8 @@ export function SpuRuleLibraryWorkspace() {
setSummary({
fileName: file.name,
rawCount: rawRules.length,
rawCount: rows.length,
ignoredRows: rows.length - rawRules.length,
uniqueCount: savedRules.length,
duplicateRows: rawRules.length - dedupedRules.length,
conflictCount: savedRules.filter((rule) => rule.conflict).length,
@ -634,6 +668,7 @@ export function SpuRuleLibraryWorkspace() {
<FileSpreadsheet className="h-3.5 w-3.5" aria-hidden="true" />
<span>{summary.fileName}</span>
<span>原始 {summary.rawCount} 行</span>
{summary.ignoredRows > 0 ? <span>忽略非SPU {summary.ignoredRows} 行</span> : null}
<span>保留 {summary.uniqueCount} 个类目</span>
<span>合并重复 {summary.duplicateRows} 行</span>
<span>前缀冲突 {summary.conflictCount} 条</span>

View File

@ -25,6 +25,7 @@ import {
import { createPortal } from "react-dom";
import { ArrowLeft, Ban, ChevronDown, Copy, Plus, Search, X } from "lucide-react";
import CameraScannerModal from "@/components/mobile/CameraScannerModal";
import { ModelViewer } from "@/components/3d/ModelViewer";
import { ModelPreviewPanel } from "@/components/platform/ModelPreviewPanel";
import Button from "@/components/ui/Button";
import {
@ -182,7 +183,10 @@ import {
getEstimatedSpuAssignment,
syncSpuRuleLibraryRulesFromServer,
} from "../spuRuleLibraryLogic";
import type { SeriesDraftSkuFields } from "../seriesComponentDetails";
import {
readPreSkuPackageComponentDetails,
type SeriesDraftSkuFields,
} from "../seriesComponentDetails";
import ApprovalWorkflowWorkspace from "./ApprovalWorkflowWorkspace";
import { createApprovalWorkflow } from "@/app/approval-workflows/approvalWorkflowApi";
import { extractPreviewModelFromZip } from "../../../lib/model-preview-zip";
@ -745,6 +749,7 @@ type SeriesDraftSkuOption = {
status: string;
componentCodes: string[];
componentDetails?: NonIntegratedSkuComponentDetail[];
packagingPlan?: { boxes?: unknown[] | null } | null;
holePoints?: NonIntegratedSkuHolePoint[];
skuFields?: SeriesDraftSkuFields;
model3dPreviewNodes?: unknown[];
@ -3277,6 +3282,100 @@ function resolveSeriesDraftSkuComponentDetails(
return buildSeriesComponentDetailsFromCodes(option.componentCodes);
}
function normalizeSeriesComponentDetailQuantity(value: string) {
const normalizedValue = value.trim();
return normalizedValue || "1";
}
function mergeSeriesSkuComponentDetails(
details: NonIntegratedSkuComponentDetail[],
) {
const detailByKey = new Map<string, NonIntegratedSkuComponentDetail>();
details.forEach((detail) => {
const code = detail.code.trim();
const name = detail.name.trim();
if (!code && !name) return;
const role = detail.role.trim();
const assetType = detail.assetType.trim();
const key = [
code.toUpperCase(),
name,
role.toUpperCase(),
assetType.toUpperCase(),
].join("|");
const existing = detailByKey.get(key);
if (!existing) {
detailByKey.set(key, {
code,
name,
quantity: normalizeSeriesComponentDetailQuantity(detail.quantity),
role,
assetType,
});
return;
}
const existingQuantity = Number(existing.quantity);
const nextQuantity = Number(detail.quantity);
if (Number.isFinite(existingQuantity) && Number.isFinite(nextQuantity)) {
existing.quantity = String(existingQuantity + nextQuantity);
}
if (!existing.name && name) existing.name = name;
if (!existing.role && role) existing.role = role;
if (!existing.assetType && assetType) existing.assetType = assetType;
});
return Array.from(detailByKey.values());
}
function inferSeriesPreviewNodeAssetType(node: Record<string, unknown>) {
const modelUrl = readRecordString(node, "modelUrl").toLowerCase();
const role = readRecordString(node, "role").toLowerCase();
if (modelUrl.includes("/hardware/")) return "HARDWARE";
if (modelUrl.includes("/component/")) return "COMPONENT";
if (modelUrl.includes("/board/")) return "BOARD";
if (/hardware|connector|screw|bolt|hinge/.test(role)) return "HARDWARE";
if (/leg|frame|support|component/.test(role)) return "COMPONENT";
if (/desktop|tabletop|panel|board|surface/.test(role)) return "BOARD";
return "";
}
function buildSeriesComponentDetailsFromPreviewNodes(
previewNodes: unknown[] | undefined,
): NonIntegratedSkuComponentDetail[] {
if (!Array.isArray(previewNodes)) return [];
return mergeSeriesSkuComponentDetails(
previewNodes.flatMap((node) => {
if (!isRecordLike(node)) return [];
const code = readRecordString(node, "code");
const name = readRecordString(node, "name") || readRecordString(node, "label");
if (!code && !name) return [];
return [
{
code,
name,
quantity: "1",
role: readRecordString(node, "role"),
assetType: inferSeriesPreviewNodeAssetType(node),
},
];
}),
);
}
function resolveSeriesDraftSkuPackageComponentDetails(
option: SeriesDraftSkuOption | null,
packageSuffix: string,
) {
return option
? readPreSkuPackageComponentDetails(option, packageSuffix || "single")
: [];
}
function inferComponentSkuCategory(
component: Pick<
NonIntegratedSkuComponentDetail,
@ -3565,6 +3664,17 @@ type SkuPreviewImageCandidate = {
imageName: string;
};
type SkuAssemblyThumbnailMaterial = {
key: string;
role: string;
name: string;
categoryName: string;
color: string;
textureUrl: string;
thumbnailUrl: string;
metalness: number | null;
};
function isRecordLike(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
@ -3574,6 +3684,18 @@ function readRecordString(value: Record<string, unknown>, key: string) {
return typeof rawValue === "string" ? rawValue.trim() : "";
}
function readRecordNumber(value: Record<string, unknown>, key: string) {
const rawValue = value[key];
if (typeof rawValue === "number" && Number.isFinite(rawValue)) {
return rawValue;
}
if (typeof rawValue === "string" && rawValue.trim()) {
const parsedValue = Number(rawValue.trim());
return Number.isFinite(parsedValue) ? parsedValue : null;
}
return null;
}
function readRecordStringIfPresent(value: Record<string, unknown>, key: string) {
if (!Object.prototype.hasOwnProperty.call(value, key)) return undefined;
const rawValue = value[key];
@ -3623,21 +3745,11 @@ function readAssetVariantSkuContentFields(
}, {});
}
function normalizeAssetVariantCodeForPreviewLookup(code: string) {
return code
.trim()
.toUpperCase()
.replace(/-SPJ0+(\d+)$/, (_match, sequence: string) => {
const numericSequence = Number.parseInt(sequence, 10);
return `-SPJ${String(numericSequence).padStart(2, "0")}`;
});
}
function resolveSeriesDraftSkuPreviewImage(
option: SeriesDraftSkuOption,
): SkuPreviewImageCandidate | null {
const skuFieldImage = option.skuFields?.image?.trim() ?? "";
if (skuFieldImage) {
if (skuFieldImage && !isAssetCenterThumbnailImageSource(skuFieldImage)) {
return {
image: skuFieldImage,
imageName:
@ -3655,6 +3767,8 @@ function resolveSeriesDraftSkuPreviewImage(
if (!nodeWithThumbnail) return null;
const image = readRecordString(nodeWithThumbnail, "thumbnailUrl");
if (isAssetCenterThumbnailImageSource(image)) return null;
const imageName =
readRecordString(nodeWithThumbnail, "code") ||
readRecordString(nodeWithThumbnail, "name") ||
@ -3669,18 +3783,268 @@ function resolveSeriesDraftSkuPreviewImage(
};
}
function createAssetVariantPreviewImageCandidate(
variant: AssetVariantDTO,
function isAssetCenterThumbnailImageSource(value: string) {
const normalizedValue = value.trim().replace(/\\/g, "/").toLowerCase();
return normalizedValue.includes("asset-center/thumbnails/");
}
function resolveSkuContentDisplayImage(
item: NonIntegratedSkuItem,
options: {
isSeriesDevelopment: boolean;
isSeriesComponentDetailProduct: boolean;
sharedProductImage: string;
sharedProductImageName: string;
},
): SkuPreviewImageCandidate | null {
const image = variant.thumbnailUrl?.trim() ?? "";
if (!image) return null;
const itemImage = item.image.trim();
const itemImageName = item.imageName.trim();
if (
options.isSeriesDevelopment &&
!options.isSeriesComponentDetailProduct &&
(!itemImage || isAssetCenterThumbnailImageSource(itemImage))
) {
return null;
}
if (!itemImage) return null;
return {
image,
imageName: `${variant.code.trim() || variant.id}-thumb`,
image: itemImage,
imageName: itemImageName,
};
}
function normalizeSkuThumbnailColor(value: string) {
const normalizedValue = value.trim();
if (/^#[0-9a-f]{3}(?:[0-9a-f]{3})?$/i.test(normalizedValue)) {
return normalizedValue;
}
return "";
}
function resolveSkuAssemblyThumbnailMaterials(
item: NonIntegratedSkuItem,
): SkuAssemblyThumbnailMaterial[] {
const nodes = Array.isArray(item.model3dPreviewNodes)
? item.model3dPreviewNodes
: [];
const materialByKey = new Map<string, SkuAssemblyThumbnailMaterial>();
nodes.forEach((node, index) => {
if (!isRecordLike(node)) return;
const materialConfig = isRecordLike(node.materialConfig)
? node.materialConfig
: null;
if (!materialConfig) return;
const color = normalizeSkuThumbnailColor(
readRecordString(materialConfig, "color"),
);
const textureUrl = readRecordString(materialConfig, "textureUrl");
const thumbnailUrl = readRecordString(materialConfig, "thumbnailUrl");
if (!color && !textureUrl && !thumbnailUrl) return;
const role = readRecordString(node, "role");
const materialName = readRecordString(materialConfig, "name");
const nodeName = readRecordString(node, "name");
const categoryName = readRecordString(materialConfig, "categoryName");
const key = [
role,
materialName || nodeName || `material-${index}`,
categoryName,
color,
textureUrl,
thumbnailUrl,
].join("|");
if (materialByKey.has(key)) return;
materialByKey.set(key, {
key,
role,
name: materialName || nodeName,
categoryName,
color: color || "#d1d5db",
textureUrl,
thumbnailUrl,
metalness: readRecordNumber(materialConfig, "metalness"),
});
});
return Array.from(materialByKey.values());
}
function scoreSkuAssemblySurfaceMaterial(
material: SkuAssemblyThumbnailMaterial,
) {
const text = `${material.role} ${material.name} ${material.categoryName}`.toLowerCase();
let score = 0;
if (/desktop|tabletop|panel|board|top|surface/.test(text)) score += 80;
if (text.includes("桌面") || text.includes("板") || text.includes("木")) {
score += 50;
}
if (material.metalness !== null && material.metalness <= 0.2) score += 20;
if (text.includes("金属") || text.includes("钢") || text.includes("五金")) {
score -= 60;
}
return score;
}
function scoreSkuAssemblyFrameMaterial(
material: SkuAssemblyThumbnailMaterial,
) {
const text = `${material.role} ${material.name} ${material.categoryName}`.toLowerCase();
let score = 0;
if (/leg|frame|support|component|hardware/.test(text)) score += 60;
if (text.includes("金属") || text.includes("钢") || text.includes("五金")) {
score += 70;
}
if (material.metalness !== null && material.metalness >= 0.25) score += 30;
return score;
}
function selectSkuAssemblyThumbnailMaterial(
materials: SkuAssemblyThumbnailMaterial[],
scoreMaterial: (material: SkuAssemblyThumbnailMaterial) => number,
excludedKey?: string,
) {
return materials
.filter((material) => material.key !== excludedKey)
.map((material) => ({ material, score: scoreMaterial(material) }))
.sort((left, right) => right.score - left.score)[0]?.material;
}
function buildSkuAssemblyThumbnailSurfaceStyle(
material: SkuAssemblyThumbnailMaterial | undefined,
) {
if (!material) return undefined;
const imageUrl = material.textureUrl || material.thumbnailUrl;
return {
backgroundColor: material.color,
backgroundImage: imageUrl ? `url("${imageUrl.replace(/"/g, "%22")}")` : undefined,
backgroundSize: "cover",
backgroundPosition: "center",
};
}
function SkuAssemblyColorThumbnail({
materials,
alt,
}: {
materials: SkuAssemblyThumbnailMaterial[];
alt: string;
}) {
const surfaceMaterial =
selectSkuAssemblyThumbnailMaterial(
materials,
scoreSkuAssemblySurfaceMaterial,
) ?? materials[0];
const frameMaterial =
selectSkuAssemblyThumbnailMaterial(
materials,
scoreSkuAssemblyFrameMaterial,
surfaceMaterial?.key,
) ?? materials.find((material) => material.key !== surfaceMaterial?.key);
const fallbackMaterials = materials.slice(0, 4);
if (!surfaceMaterial && fallbackMaterials.length === 0) return null;
return (
<div
aria-label={alt}
className="relative h-full w-full overflow-hidden rounded-[4px] bg-[#f7f8f8] shadow-inner ring-1 ring-black/5"
title={materials.map((material) => material.name).filter(Boolean).join(" / ")}
>
{surfaceMaterial ? (
<div
className="absolute left-[18%] right-[12%] top-[18%] h-[28%] rounded-[3px] border border-black/10 shadow-[0_5px_10px_rgba(0,0,0,0.14)]"
style={buildSkuAssemblyThumbnailSurfaceStyle(surfaceMaterial)}
/>
) : null}
{frameMaterial ? (
<>
<div
className="absolute bottom-[17%] left-[25%] h-[42%] w-[8%] rounded-full border border-black/10"
style={buildSkuAssemblyThumbnailSurfaceStyle(frameMaterial)}
/>
<div
className="absolute bottom-[17%] right-[21%] h-[42%] w-[8%] rounded-full border border-black/10"
style={buildSkuAssemblyThumbnailSurfaceStyle(frameMaterial)}
/>
<div
className="absolute bottom-[30%] left-[24%] right-[20%] h-[8%] rounded-full border border-black/10"
style={buildSkuAssemblyThumbnailSurfaceStyle(frameMaterial)}
/>
</>
) : null}
<div className="absolute bottom-1 left-1 flex gap-0.5">
{fallbackMaterials.map((material) => (
<span
key={material.key}
className="block h-1.5 w-1.5 rounded-full border border-black/15"
style={buildSkuAssemblyThumbnailSurfaceStyle(material)}
/>
))}
</div>
</div>
);
}
function getSkuAssemblyPreviewNodes(item: NonIntegratedSkuItem) {
return Array.isArray(item.model3dPreviewNodes)
? item.model3dPreviewNodes
: [];
}
function hasSkuAssemblyPreviewNodes(item: NonIntegratedSkuItem) {
return readPreviewWorldNodes(getSkuAssemblyPreviewNodes(item)).length > 0;
}
function SkuAssemblyModelThumbnail({
item,
alt,
}: {
item: NonIntegratedSkuItem;
alt: string;
}) {
const previewNodes = getSkuAssemblyPreviewNodes(item);
const worldNodes = useMemo(
() => readPreviewWorldNodes(previewNodes),
[previewNodes],
);
const materialAssignments = useMemo(
() => readPreviewMaterialAssignments(previewNodes),
[previewNodes],
);
if (worldNodes.length === 0) return null;
return (
<div
aria-label={alt}
title={alt}
className="pointer-events-none relative h-full w-full overflow-hidden rounded-[4px] bg-[#f7f8f8]"
>
<div className="absolute inset-[-40%]">
<ModelViewer
worldNodes={worldNodes}
worldFrameMode="initial"
materialAssignments={materialAssignments}
showSceneGrid={false}
showFoldLines={false}
showPanelLeaders={false}
showHoleHighlights={false}
clearColor={0xf7f8f8}
clearAlpha={1}
className="bg-[#f7f8f8]"
/>
</div>
</div>
);
}
function createSkuItemFromSeriesDraftSku(
option: SeriesDraftSkuOption,
index: number,
@ -8534,6 +8898,70 @@ function ImagePreviewDialog({
);
}
function SkuModelPreviewDialog({
item,
alt,
onClose,
}: {
item: NonIntegratedSkuItem;
alt: string;
onClose: () => void;
}) {
const previewNodes = getSkuAssemblyPreviewNodes(item);
const worldNodes = useMemo(
() => readPreviewWorldNodes(previewNodes),
[previewNodes],
);
const materialAssignments = useMemo(
() => readPreviewMaterialAssignments(previewNodes),
[previewNodes],
);
return (
<div
data-testid="sku-model-preview-dialog"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4"
role="dialog"
aria-label={alt}
onClick={onClose}
>
<div
className="relative flex h-[78vh] w-full max-w-5xl flex-col rounded-apple-xl bg-apple-bg-primary p-4 shadow-apple-lg"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-3 flex items-center justify-between gap-3">
<p className="min-w-0 truncate text-sm font-apple-medium text-apple-label-primary">
{alt}
</p>
<Button type="button" variant="plain" size="small" onClick={onClose}>
关闭
</Button>
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-apple-xl border border-apple-separator bg-[#f7f8f8]">
{worldNodes.length > 0 ? (
<ModelViewer
worldNodes={worldNodes}
worldFrameMode="initial"
materialAssignments={materialAssignments}
showSceneGrid={false}
showFoldLines={false}
showPanelLeaders={false}
showHoleHighlights={false}
clearColor={0xf7f8f8}
clearAlpha={1}
className="bg-[#f7f8f8]"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-apple-label-secondary">
当前 SKU 暂无可预览的 3D 模型
</div>
)}
</div>
</div>
</div>
);
}
function SeriesComponentDetailsDialog({
skuLabel,
componentDetails,
@ -8569,14 +8997,13 @@ function SeriesComponentDetailsDialog({
</div>
{componentDetails.length > 0 ? (
<div className="max-h-[60vh] overflow-auto rounded-apple-lg border border-apple-separator">
<table className="w-full border-collapse text-left text-xs">
<table className="w-full min-w-[520px] border-collapse text-left text-xs">
<thead className="sticky top-0 bg-apple-bg-secondary text-apple-label-secondary">
<tr>
<th className="w-14 px-3 py-2 font-apple-medium">序号</th>
<th className="px-3 py-2 font-apple-medium">部件编码</th>
<th className="w-28 px-3 py-2 font-apple-medium">部件编码</th>
<th className="px-3 py-2 font-apple-medium">部件名称</th>
<th className="w-20 px-3 py-2 font-apple-medium">数量</th>
<th className="px-3 py-2 font-apple-medium">类型/角色</th>
</tr>
</thead>
<tbody>
@ -8588,7 +9015,7 @@ function SeriesComponentDetailsDialog({
<td className="px-3 py-2 text-apple-label-secondary">
{index + 1}
</td>
<td className="px-3 py-2 font-apple-medium text-apple-label-primary">
<td className="whitespace-nowrap px-3 py-2 font-apple-medium text-apple-label-primary">
{component.code || "-"}
</td>
<td className="px-3 py-2 text-apple-label-primary">
@ -8597,11 +9024,6 @@ function SeriesComponentDetailsDialog({
<td className="px-3 py-2 text-apple-label-primary">
{component.quantity || "1"}
</td>
<td className="px-3 py-2 text-apple-label-secondary">
{[component.assetType, component.role]
.filter(Boolean)
.join(" / ") || "-"}
</td>
</tr>
))}
</tbody>
@ -11751,10 +12173,19 @@ export default function NonIntegratedDetailClient({
const [autoSaveNoticeMessage, setAutoSaveNoticeMessage] =
useState("已自动保存");
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [skuImagePreview, setSkuImagePreview] = useState<{
src: string;
alt: string;
} | null>(null);
const [skuImagePreview, setSkuImagePreview] = useState<
| {
kind: "image";
src: string;
alt: string;
}
| {
kind: "model";
item: NonIntegratedSkuItem;
alt: string;
}
| null
>(null);
const [bomImagePreview, setBomImagePreview] = useState<{
src: string;
alt: string;
@ -12756,22 +13187,6 @@ export default function NonIntegratedDetailClient({
),
[seriesComponentAssetFamilies, seriesComponentAssetVariants],
);
const seriesAssetVariantPreviewImageByCode = useMemo(() => {
const lookup = new Map<string, SkuPreviewImageCandidate>();
seriesComponentAssetVariants.forEach((variant) => {
const previewImage = createAssetVariantPreviewImageCandidate(variant);
if (!previewImage) return;
const code = variant.code.trim().toUpperCase();
const normalizedCode = normalizeAssetVariantCodeForPreviewLookup(code);
[code, normalizedCode]
.filter(Boolean)
.forEach((key) => lookup.set(key, previewImage));
});
return lookup;
}, [seriesComponentAssetVariants]);
const isStockSeriesProductCreate =
isStockProductCreate && isSeriesDevelopment;
const developmentCreateTitle = isSeriesComponentCreate
@ -12878,6 +13293,46 @@ export default function NonIntegratedDetailClient({
return lookup;
}, [selectedSeriesDraftSkuOptions]);
const selectedSeriesDraftSkuOptionLookup = useMemo(() => {
const lookup = new Map<string, SeriesDraftSkuOption>();
selectedSeriesDraftSkuOptions.forEach((option, index) => {
const sequence = normalizeSeriesDraftSkuSequence(option, index);
const keys = [
option.id,
`series-${option.id}`,
option.code.trim(),
String(sequence).padStart(2, "0"),
String(sequence),
]
.map((key) => key.trim())
.filter(Boolean);
keys.forEach((key) => lookup.set(key, option));
});
return lookup;
}, [selectedSeriesDraftSkuOptions]);
const seriesDraftSkuOptionLookup = useMemo(() => {
const lookup = new Map<string, SeriesDraftSkuOption>();
seriesDraftSkuOptions.forEach((option, index) => {
const sequence = normalizeSeriesDraftSkuSequence(option, index);
const keys = [
option.id,
`series-${option.id}`,
option.code.trim(),
String(sequence).padStart(2, "0"),
String(sequence),
]
.map((key) => key.trim())
.filter(Boolean);
keys.forEach((key) => lookup.set(key, option));
});
return lookup;
}, [seriesDraftSkuOptions]);
const selectedSeriesDraftSkuCount = selectedSeriesDraftSkuOptions.length;
const selectedSeriesDraftSkuPreviewImageByKey = useMemo(() => {
const lookup = new Map<string, SkuPreviewImageCandidate>();
@ -12968,34 +13423,17 @@ export default function NonIntegratedDetailClient({
if (
mode !== "edit" ||
!isSeriesDevelopment ||
isSeriesComponentDetailProduct ||
(selectedSeriesDraftSkuPreviewImageByKey.size === 0 &&
seriesAssetVariantPreviewImageByCode.size === 0)
isSeriesComponentDetailProduct
) {
return;
}
let hasChanged = false;
const nextSkuItems = skuItems.map((item) => {
if (item.image.trim()) return item;
const componentPreviewImage = normalizeNonIntegratedSkuComponentDetails(
item.componentDetails,
)
.map((detail) =>
seriesAssetVariantPreviewImageByCode.get(
normalizeAssetVariantCodeForPreviewLookup(detail.code),
),
)
.find(Boolean);
if (componentPreviewImage) {
hasChanged = true;
return {
...item,
image: componentPreviewImage.image,
imageName: componentPreviewImage.imageName,
};
}
const hasImage = item.image.trim().length > 0;
const hasAssetThumbnailImage =
hasImage && isAssetCenterThumbnailImageSource(item.image);
if (hasImage && !hasAssetThumbnailImage) return item;
const lookupKeys = [
item.sourceSkuId?.trim() ?? "",
@ -13006,8 +13444,20 @@ export default function NonIntegratedDetailClient({
].filter(Boolean);
const previewImage = lookupKeys
.map((key) => selectedSeriesDraftSkuPreviewImageByKey.get(key))
.find(Boolean);
if (!previewImage) return item;
.find(
(candidate) =>
candidate && !isAssetCenterThumbnailImageSource(candidate.image),
);
if (!previewImage) {
if (!hasAssetThumbnailImage) return item;
hasChanged = true;
return {
...item,
image: "",
imageName: "",
};
}
hasChanged = true;
return {
@ -13025,7 +13475,6 @@ export default function NonIntegratedDetailClient({
isSeriesDevelopment,
mode,
selectedSeriesDraftSkuPreviewImageByKey,
seriesAssetVariantPreviewImageByCode,
skuItems,
]);
const canUseSelectedSeriesName =
@ -13297,30 +13746,57 @@ export default function NonIntegratedDetailClient({
const storedDetails = normalizeNonIntegratedSkuComponentDetails(
item.componentDetails,
);
if (storedDetails.length > 0) return storedDetails;
const lookupKeys = [
item.sourceSkuId?.trim() ?? "",
item.id,
item.id.startsWith("series-") ? item.id.slice("series-".length) : "",
item.model.trim(),
String(item.skuSequence).padStart(2, "0"),
String(item.skuSequence),
].filter(Boolean);
for (const key of lookupKeys) {
const matchedDetails = selectedSeriesDraftSkuComponentLookup.get(key);
if (matchedDetails?.length) return matchedDetails;
}
const legacyCodes = [
...extractLegacySeriesComponentCodes(item.attributeCode),
...extractLegacySeriesComponentCodes(item.drawingId),
];
return buildSeriesComponentDetailsFromCodes(
const legacyDetails = buildSeriesComponentDetailsFromCodes(
Array.from(new Set(legacyCodes)),
);
const matchedOption =
lookupKeys
.map(
(key) =>
selectedSeriesDraftSkuOptionLookup.get(key) ??
seriesDraftSkuOptionLookup.get(key),
)
.find(Boolean) ?? null;
const packageDetails = resolveSeriesDraftSkuPackageComponentDetails(
matchedOption,
item.packageSuffix,
);
if (packageDetails.length > 0) return packageDetails;
const matchedDraftDetails =
lookupKeys
.map((key) => selectedSeriesDraftSkuComponentLookup.get(key))
.find((details) => details?.length) ?? [];
const previewNodeDetails = buildSeriesComponentDetailsFromPreviewNodes(
item.model3dPreviewNodes,
);
const mergedDetails = mergeSeriesSkuComponentDetails([
...storedDetails,
...(storedDetails.length > 0
? []
: [...matchedDraftDetails, ...previewNodeDetails]),
...legacyDetails,
]);
return mergedDetails.length > 0 ? mergedDetails : matchedDraftDetails;
},
[selectedSeriesDraftSkuComponentLookup],
[
selectedSeriesDraftSkuComponentLookup,
selectedSeriesDraftSkuOptionLookup,
seriesDraftSkuOptionLookup,
],
);
const selectedSeriesComponentSku = useMemo(
() =>
@ -20161,23 +20637,56 @@ export default function NonIntegratedDetailClient({
item: NonIntegratedSkuItem,
index: number,
) {
if (!item.image) {
const displayImage = resolveSkuContentDisplayImage(item, {
isSeriesDevelopment,
isSeriesComponentDetailProduct,
sharedProductImage,
sharedProductImageName,
});
if (!displayImage) {
return;
}
const derivedItem = deriveSkuItem(item, index, skuCodePreviewSpu, formValues);
setSkuImagePreview({
src: item.image,
kind: "image",
src: displayImage.image,
alt: derivedItem.skuCode || item.model || "SKU图片",
});
}
function handleSkuImageCellClick(item: NonIntegratedSkuItem, index: number) {
if (item.image) {
if (
isSeriesDevelopment &&
!isSeriesComponentDetailProduct &&
hasSkuAssemblyPreviewNodes(item)
) {
const derivedItem = deriveSkuItem(item, index, skuCodePreviewSpu, formValues);
setSkuImagePreview({
kind: "model",
item,
alt: derivedItem.skuCode || item.model || "SKU 3D",
});
return;
}
const displayImage = resolveSkuContentDisplayImage(item, {
isSeriesDevelopment,
isSeriesComponentDetailProduct,
sharedProductImage,
sharedProductImageName,
});
if (displayImage) {
handleOpenSkuImagePreview(item, index);
return;
}
if (isSeriesDevelopment && !isSeriesComponentDetailProduct) {
return;
}
if (
isReadOnly ||
isSkuItemFactoryConfirmed(item) ||
@ -24801,6 +25310,7 @@ export default function NonIntegratedDetailClient({
className="block h-12 w-12 overflow-hidden rounded border border-apple-separator bg-apple-bg-secondary"
onClick={() =>
setSkuImagePreview({
kind: "image",
src: sku.image,
alt: row.model || `产品定厂图片${index + 1}`,
})
@ -25392,6 +25902,7 @@ export default function NonIntegratedDetailClient({
className="block h-12 w-12 overflow-hidden rounded border border-apple-separator bg-apple-bg-secondary"
onClick={() =>
setSkuImagePreview({
kind: "image",
src: row.image,
alt: row.model || `成品寄样图片${index + 1}`,
})
@ -25615,6 +26126,7 @@ export default function NonIntegratedDetailClient({
className="block h-12 w-12 overflow-hidden rounded border border-apple-separator bg-apple-bg-secondary"
onClick={() =>
setSkuImagePreview({
kind: "image",
src: row.image,
alt: row.model || `空间布局图片${index + 1}`,
})
@ -26228,6 +26740,7 @@ export default function NonIntegratedDetailClient({
className="block h-12 w-12 overflow-hidden rounded border border-apple-separator bg-apple-bg-secondary"
onClick={() =>
setSkuImagePreview({
kind: "image",
src: row.image,
alt: row.spu || `产品限价图片${index + 1}`,
})
@ -26776,6 +27289,7 @@ function renderFactoryQualityWorkspace() {
className="mx-auto block h-8 w-12 overflow-hidden rounded border border-apple-separator bg-apple-bg-secondary"
onClick={() =>
setSkuImagePreview({
kind: "image",
src: row.image,
alt: row.model || row.skuCode || "首批质检产品图",
})
@ -30995,6 +31509,23 @@ function renderFactoryQualityWorkspace() {
isReadOnly ||
isSkuItemFactoryConfirmed(item) ||
isImportedSkuContentLocked(item);
const skuDisplayImage =
resolveSkuContentDisplayImage(item, {
isSeriesDevelopment,
isSeriesComponentDetailProduct,
sharedProductImage,
sharedProductImageName,
});
const shouldShowAssemblyModelThumbnail =
isSeriesDevelopment &&
!isSeriesComponentDetailProduct &&
hasSkuAssemblyPreviewNodes(item);
const isSeriesAssemblySkuRow =
isSeriesDevelopment &&
!isSeriesComponentDetailProduct;
const skuImageUploadDisabled =
skuImageReadOnly ||
isSeriesAssemblySkuRow;
return (
<tr
@ -31029,23 +31560,31 @@ function renderFactoryQualityWorkspace() {
}
}}
className={`flex h-10 w-16 cursor-pointer items-center justify-center overflow-hidden rounded border border-apple-separator text-[11px] ${
item.image
shouldShowAssemblyModelThumbnail ||
skuDisplayImage
? "bg-apple-bg-primary"
: "bg-apple-bg-secondary text-apple-blue"
}`}
>
{item.image ? (
{shouldShowAssemblyModelThumbnail ? (
<SkuAssemblyModelThumbnail
item={item}
alt={`${item.model || "SKU"} 配色`}
/>
) : skuDisplayImage ? (
<img
src={item.image}
src={skuDisplayImage.image}
alt={`${item.model || "SKU"} 图片`}
className="h-full w-full object-cover"
/>
) : isSeriesAssemblySkuRow ? (
"暂无模型"
) : skuImageReadOnly ? (
"暂无图片"
) : (
"点击上传"
)}
{skuImageReadOnly ? null : (
{skuImageUploadDisabled ? null : (
<input
id={`sku-image-input-${item.id}`}
type="file"
@ -34056,13 +34595,20 @@ function renderFactoryQualityWorkspace() {
onClose={() => setIsPreviewOpen(false)}
/>
) : null}
{skuImagePreview ? (
{skuImagePreview?.kind === "image" ? (
<ImagePreviewDialog
src={skuImagePreview.src}
alt={skuImagePreview.alt}
onClose={() => setSkuImagePreview(null)}
/>
) : null}
{skuImagePreview?.kind === "model" ? (
<SkuModelPreviewDialog
item={skuImagePreview.item}
alt={skuImagePreview.alt}
onClose={() => setSkuImagePreview(null)}
/>
) : null}
{bomImagePreview ? (
<ImagePreviewDialog
src={bomImagePreview.src}

View File

@ -297,6 +297,7 @@ export default async function IntakePage({
status: mapWorkbenchStatus(preSku.status),
componentCodes: preSku.componentCodes ?? [],
componentDetails: readPreSkuComponentDetails(preSku),
packagingPlan: preSku.packagingPlan,
holePoints: buildSkuHolePointsFromVariantSelection(preSku),
skuFields,
model3dPreviewNodes: preSku.previewNodes.length

View File

@ -34,8 +34,16 @@ type PreSkuComponentDetailSource = {
basicInfo?: Partial<Record<keyof SeriesDraftSkuFields | "formalSkuCode", string | null>> | null;
displaySummary?: Record<string, unknown> | null;
familySelection?: Record<string, unknown> | null;
variantSelection?: {
slots?: unknown[] | null;
hardwareSelections?: unknown[] | null;
} | null;
bomPreview?: unknown[] | null;
componentCodes?: string[] | null;
componentDetails?: unknown[] | null;
packagingPlan?: {
boxes?: unknown[] | null;
} | null;
};
function buildComponentDetailsFromCodes(
@ -53,15 +61,327 @@ function buildComponentDetailsFromCodes(
}));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function normalizePreSkuNumberText(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return String(value);
if (typeof value === "string" && value.trim()) return value.trim();
return "1";
}
function readPreSkuRecordString(record: Record<string, unknown>, key: string) {
const value = record[key];
if (typeof value === "string") return value.trim();
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return "";
}
function toComponentDetailFromRecord(
record: Record<string, unknown>,
options: {
codeKeys: string[];
nameKeys: string[];
quantityKeys?: string[];
roleKeys?: string[];
assetType?: string;
assetTypeKeys?: string[];
},
): NonIntegratedSkuComponentDetail | null {
const code = readFirstRecordString(record, options.codeKeys);
const name = readFirstRecordString(record, options.nameKeys);
if (!code && !name) return null;
return {
code,
name,
quantity: normalizePreSkuNumberText(
readFirstRecordValue(record, options.quantityKeys ?? ["quantity"]),
),
role: readFirstRecordString(record, options.roleKeys ?? ["role"]),
assetType:
options.assetType ||
readFirstRecordString(record, options.assetTypeKeys ?? ["assetType"]),
};
}
function readFirstRecordValue(
record: Record<string, unknown>,
keys: string[],
) {
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(record, key)) {
return record[key];
}
}
return undefined;
}
function readFirstRecordString(
record: Record<string, unknown>,
keys: string[],
) {
for (const key of keys) {
const value = readPreSkuRecordString(record, key);
if (value) return value;
}
return "";
}
function buildComponentDetailsFromVariantSelection(
preSku: PreSkuComponentDetailSource,
): NonIntegratedSkuComponentDetail[] {
const slots = Array.isArray(preSku.variantSelection?.slots)
? preSku.variantSelection?.slots ?? []
: [];
const hardwareSelections = Array.isArray(
preSku.variantSelection?.hardwareSelections,
)
? preSku.variantSelection?.hardwareSelections ?? []
: [];
return [
...slots.flatMap((item) => {
if (!isRecord(item)) return [];
const detail = toComponentDetailFromRecord(item, {
codeKeys: ["code", "refCode"],
nameKeys: ["name", "label"],
quantityKeys: ["quantity"],
roleKeys: ["role", "slotKey"],
assetTypeKeys: ["assetType"],
});
return detail ? [detail] : [];
}),
...hardwareSelections.flatMap((item) => {
if (!isRecord(item)) return [];
const detail = toComponentDetailFromRecord(item, {
codeKeys: ["code", "refCode"],
nameKeys: ["name", "label"],
quantityKeys: ["quantity"],
roleKeys: ["selectionKey", "role"],
assetType: "HARDWARE",
});
return detail ? [detail] : [];
}),
];
}
function mergeComponentDetails(
details: NonIntegratedSkuComponentDetail[],
): NonIntegratedSkuComponentDetail[] {
const detailByKey = new Map<string, NonIntegratedSkuComponentDetail>();
details.forEach((detail) => {
const code = detail.code.trim();
const name = detail.name.trim();
if (!code && !name) return;
const key = [
code.toUpperCase(),
detail.assetType.trim().toUpperCase(),
detail.role.trim().toUpperCase(),
name,
].join("|");
const existing = detailByKey.get(key);
if (!existing) {
detailByKey.set(key, {
code,
name,
quantity: detail.quantity.trim() || "1",
role: detail.role.trim(),
assetType: detail.assetType.trim(),
});
return;
}
const existingQuantity = Number(existing.quantity);
const nextQuantity = Number(detail.quantity);
if (Number.isFinite(existingQuantity) && Number.isFinite(nextQuantity)) {
existing.quantity = String(existingQuantity + nextQuantity);
}
if (!existing.name && name) existing.name = name;
if (!existing.role && detail.role.trim()) existing.role = detail.role.trim();
if (!existing.assetType && detail.assetType.trim()) {
existing.assetType = detail.assetType.trim();
}
});
return Array.from(detailByKey.values());
}
export function readPreSkuComponentDetails(
preSku: PreSkuComponentDetailSource,
): NonIntegratedSkuComponentDetail[] {
const fromComponentDetails = normalizeNonIntegratedSkuComponentDetails(
preSku.componentDetails,
);
if (fromComponentDetails.length > 0) {
return mergeComponentDetails(fromComponentDetails);
}
const fromBomPreview = normalizeNonIntegratedSkuComponentDetails(
preSku.bomPreview,
);
if (fromBomPreview.length > 0) return fromBomPreview;
const fromVariantSelection = buildComponentDetailsFromVariantSelection(preSku);
const bomCodes = new Set(
fromBomPreview
.map((detail) => detail.code.trim().toUpperCase())
.filter(Boolean),
);
const missingVariantSelection =
bomCodes.size === 0
? fromVariantSelection
: fromVariantSelection.filter((detail) => {
const code = detail.code.trim().toUpperCase();
return !code || !bomCodes.has(code);
});
const fromComponentCodes =
fromBomPreview.length > 0 || missingVariantSelection.length > 0
? []
: buildComponentDetailsFromCodes(preSku.componentCodes);
return buildComponentDetailsFromCodes(preSku.componentCodes);
return mergeComponentDetails([
...fromBomPreview,
...missingVariantSelection,
...fromComponentCodes,
]);
}
function normalizePreSkuPackageKey(value: unknown) {
const rawValue =
typeof value === "string" || typeof value === "number" ? String(value) : "";
const normalizedValue = rawValue.trim();
if (!normalizedValue) return "single";
const upperValue = normalizedValue.toUpperCase();
if (upperValue === "SINGLE" || normalizedValue.includes("单")) return "single";
const letterMatch = upperValue.match(/[A-Z]/);
if (letterMatch?.[0]) return letterMatch[0];
const numericValue = Number.parseInt(normalizedValue, 10);
if (Number.isFinite(numericValue) && numericValue > 0) {
return String.fromCharCode(64 + numericValue);
}
return upperValue;
}
function readPackageKeyFromBox(box: Record<string, unknown>, index: number) {
const explicitKey = readFirstRecordString(box, ["packageKey", "key", "packageSuffix"]);
if (explicitKey) return normalizePreSkuPackageKey(explicitKey);
const name = readPreSkuRecordString(box, "name");
if (name) return normalizePreSkuPackageKey(name);
const boxNumber = readFirstRecordValue(box, ["boxNumber", "sequence"]);
if (boxNumber !== undefined) return normalizePreSkuPackageKey(boxNumber);
return normalizePreSkuPackageKey(index + 1);
}
function buildPackageItemDetails(
box: Record<string, unknown>,
assemblyQuantityByCode: Map<string, string>,
): NonIntegratedSkuComponentDetail[] {
const items = Array.isArray(box.items) ? box.items : [];
return items.flatMap((item) => {
if (!isRecord(item)) return [];
const detail = toComponentDetailFromRecord(item, {
codeKeys: ["refCode", "code"],
nameKeys: ["name", "label"],
quantityKeys: ["quantity"],
roleKeys: ["role", "slotKey", "selectionKey"],
assetTypeKeys: ["itemType", "assetType"],
});
if (!detail) return [];
const assemblyQuantity = assemblyQuantityByCode.get(
detail.code.trim().toUpperCase(),
);
if (!assemblyQuantity) return [detail];
const detailQuantity = Number(detail.quantity);
const assemblyQuantityNumber = Number(assemblyQuantity);
if (
Number.isFinite(detailQuantity) &&
Number.isFinite(assemblyQuantityNumber)
) {
return [
{
...detail,
quantity: String(Math.max(detailQuantity, assemblyQuantityNumber)),
},
];
}
return [
{
...detail,
quantity: assemblyQuantity,
},
];
});
}
function buildPackageMaterialDetails(
box: Record<string, unknown>,
): NonIntegratedSkuComponentDetail[] {
const materials = Array.isArray(box.materials) ? box.materials : [];
return materials.flatMap((material) => {
if (!isRecord(material)) return [];
const role = readPreSkuRecordString(material, "notes");
const detail = toComponentDetailFromRecord(material, {
codeKeys: ["code", "refCode"],
nameKeys: ["name", "label"],
quantityKeys: ["quantity"],
assetType: "PACKAGING",
});
return detail
? [
{
...detail,
role: role || "PACKAGING_MATERIAL",
},
]
: [];
});
}
export function readPreSkuPackageComponentDetails(
preSku: PreSkuComponentDetailSource,
packageKey: string,
): NonIntegratedSkuComponentDetail[] {
const boxes = Array.isArray(preSku.packagingPlan?.boxes)
? preSku.packagingPlan?.boxes ?? []
: [];
if (boxes.length === 0) return [];
const targetPackageKey = normalizePreSkuPackageKey(packageKey);
const assemblyQuantityByCode = new Map<string, string>();
readPreSkuComponentDetails(preSku).forEach((detail) => {
const code = detail.code.trim().toUpperCase();
if (!code) return;
assemblyQuantityByCode.set(code, detail.quantity.trim() || "1");
});
const matchedBoxes = boxes.filter((box, index) => {
if (!isRecord(box)) return false;
const currentPackageKey = readPackageKeyFromBox(box, index);
if (targetPackageKey === "single" && boxes.length === 1) return true;
return currentPackageKey === targetPackageKey;
});
return mergeComponentDetails(
matchedBoxes.flatMap((box) =>
isRecord(box)
? [
...buildPackageItemDetails(box, assemblyQuantityByCode),
...buildPackageMaterialDetails(box),
]
: [],
),
);
}
function normalizePreSkuTextValue(value: unknown) {

View File

@ -105,6 +105,12 @@ export async function updateAssetSeries(
return data.series
}
export async function deleteAssetSeries(id: string): Promise<void> {
await requestPlatform<{ deleted: boolean }>(`/api/asset-center/series/${id}`, {
method: 'DELETE',
})
}
export async function ensureAssetSeriesComponentDetail(
assetSeriesId: string,
): Promise<AssetSeriesComponentDetailSummaryDTO> {

View File

@ -388,6 +388,10 @@ class AssetSeriesRepositoryClass extends BaseRepository<
async updateSeries(id: string, data: Prisma.AssetSeriesUpdateInput): Promise<AssetSeries> {
return this.prisma.assetSeries.update({ where: { id }, data })
}
async deleteSeries(id: string): Promise<AssetSeries> {
return this.prisma.assetSeries.delete({ where: { id } })
}
}
class AssetVariantRepositoryClass extends BaseRepository<

View File

@ -2797,7 +2797,6 @@ describe('draftGroupService', () => {
expect(mockedAssetFamilyRepository.findManyWithRelations).toHaveBeenCalledWith({
filter: {
type: 'PACKAGING',
status: 'AVAILABLE',
},
})
expect(mockedAssetVariantRepository.findManyByFamilyIds).toHaveBeenCalledWith(

View File

@ -2216,6 +2216,125 @@ class AssetCenterService {
return toAssetSeriesDTO(updated)
}
async deleteSeries(id: string): Promise<void> {
const existing = await assetSeriesRepository.findById(id)
if (!existing) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '系列不存在')
}
const [
assetFamilyCount,
draftGroupCount,
productSkuProfileCount,
] = await Promise.all([
prisma.assetFamily.count({
where: {
assetSeriesId: id,
deletedAt: null,
},
}),
prisma.draftGroup.count({
where: { assetSeriesId: id },
}),
prisma.productSkuAssetProfile.count({
where: { sourceAssetSeriesId: id },
}),
])
const normalizedSeriesCode = existing.code.trim().toUpperCase()
const componentDetailRecords = await intakeNonIntegratedProductRepository.listActive({
payload: {
path: ['metadata', 'componentDetailType'],
equals: SERIES_COMPONENT_DETAIL_METADATA_TYPE,
},
})
const componentDetailCount = componentDetailRecords.filter((record) => {
const payload = isRecord(record.payload) ? record.payload : null
const metadata = isRecord(payload?.metadata) ? payload.metadata : null
const sourceAssetSeriesId =
typeof metadata?.sourceAssetSeriesId === 'string' ? metadata.sourceAssetSeriesId.trim() : ''
return (
sourceAssetSeriesId === id ||
isSeriesComponentDevelopmentPayloadForSeries(record.payload, normalizedSeriesCode, record.spu)
)
}).length
const referenceCount =
assetFamilyCount +
draftGroupCount +
productSkuProfileCount +
componentDetailCount
if (referenceCount > 0) {
throw new AppError(
ErrorCode.VALIDATION_FAILED,
'该系列已有资产族、草稿组或产品资料引用,请先清理关联数据后再删除',
)
}
const deletedFamilies = await prisma.assetFamily.findMany({
where: {
assetSeriesId: id,
deletedAt: { not: null },
},
select: {
id: true,
variants: {
select: {
id: true,
},
},
},
})
const deletedFamilyIds = deletedFamilies.map((family) => family.id)
const deletedVariantIds = deletedFamilies.flatMap((family) =>
family.variants.map((variant) => variant.id),
)
await prisma.$transaction(async (tx) => {
const profileCleanupWhere: Prisma.ProductSkuAssetProfileWhereInput[] = [
{ sourceAssetSeriesId: id },
]
if (deletedFamilyIds.length > 0) {
profileCleanupWhere.push({ sourceAssetFamilyId: { in: deletedFamilyIds } })
}
if (deletedVariantIds.length > 0) {
profileCleanupWhere.push(
{ primarySourceAssetVariantId: { in: deletedVariantIds } },
{ sourceAssetVariantIds: { hasSome: deletedVariantIds } },
)
await tx.productSKU.updateMany({
where: {
OR: [
{ sourceAssetVariantId: { in: deletedVariantIds } },
{ sourceAssetVariantIds: { hasSome: deletedVariantIds } },
],
},
data: {
sourceAssetVariantId: null,
sourceAssetVariantIds: [],
},
})
}
await tx.productSkuAssetProfile.deleteMany({
where: {
OR: profileCleanupWhere,
},
})
if (deletedFamilyIds.length > 0) {
await tx.assetFamily.deleteMany({
where: {
id: { in: deletedFamilyIds },
},
})
}
await tx.assetFamilySeriesCompatibility.deleteMany({
where: { assetSeriesId: id },
})
await tx.assetSeries.delete({ where: { id } })
})
}
async listFamilies(filter?: Pick<AssetFamilyFilter, 'type' | 'status' | 'assetSeriesId'>) {
const result = await assetFamilyRepository.findManyWithRelations(filter ? { filter } : undefined)
return result.items.map((item) => toAssetFamilyDTO(item))

View File

@ -86,7 +86,6 @@ class PreSkuPackagingService {
const families = await assetCenterService.listFamilies({
type: 'PACKAGING',
status: 'AVAILABLE',
})
const familyIds = families.map((family) => family.id)
const variants =