feat(data-foundation): sync draft packaging and series cleanup
This commit is contained in:
parent
67ff6fba96
commit
6a362bcdde
@ -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'] },
|
||||
)
|
||||
}
|
||||
|
||||
@ -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('管理员'),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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<
|
||||
|
||||
@ -2797,7 +2797,6 @@ describe('draftGroupService', () => {
|
||||
expect(mockedAssetFamilyRepository.findManyWithRelations).toHaveBeenCalledWith({
|
||||
filter: {
|
||||
type: 'PACKAGING',
|
||||
status: 'AVAILABLE',
|
||||
},
|
||||
})
|
||||
expect(mockedAssetVariantRepository.findManyByFamilyIds).toHaveBeenCalledWith(
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -86,7 +86,6 @@ class PreSkuPackagingService {
|
||||
|
||||
const families = await assetCenterService.listFamilies({
|
||||
type: 'PACKAGING',
|
||||
status: 'AVAILABLE',
|
||||
})
|
||||
const familyIds = families.map((family) => family.id)
|
||||
const variants =
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user