Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/xuxiake-assisted-assembly-ai-render

# Conflicts:
#	furniture-3d-system/src/lib/business-role-inference.test.ts
This commit is contained in:
WuYongJie 2026-05-28 12:03:58 +08:00
commit 5c837ee8e4
25 changed files with 5128 additions and 582 deletions

View File

@ -43,6 +43,12 @@ export async function PUT(request: NextRequest, context: RouteContext) {
const rawBody = await request.json().catch(() => null)
const product =
rawBody && typeof rawBody === 'object' ? (rawBody as { product?: unknown }).product : null
const workspaceCategory =
rawBody &&
typeof rawBody === 'object' &&
typeof (rawBody as { workspaceCategory?: unknown }).workspaceCategory === 'string'
? ((rawBody as { workspaceCategory: string }).workspaceCategory.trim() || null)
: null
if (!product || typeof product !== 'object' || Array.isArray(product)) {
return jsonError(
@ -60,6 +66,7 @@ export async function PUT(request: NextRequest, context: RouteContext) {
id: decodedId,
},
ctx.user,
{ workspaceCategory },
)
return jsonSuccess({ product: savedProduct }, 200, ctx.requestId)
} catch (error) {

View File

@ -76,6 +76,7 @@ const LETTER_CATEGORY_LABELS: Partial<Record<AccessoryLibraryLetter, string>> =
A: '自攻螺丝',
B: '平头螺丝',
C: '连接螺杆',
D: '偏心连接件',
}
const A_HEAD_TYPES: HeadType[] = [
@ -137,6 +138,20 @@ const HEAD_TYPES_BY_LETTER: Partial<Record<AccessoryLibraryLetter, HeadType[]>>
const A_SCREW_CATEGORIES = ['自攻螺丝', '过山丝']
const B_SCREW_CATEGORIES = ['平头螺丝', '内六角螺丝', '拉手螺丝', '杯头螺丝']
const C_SCREW_CATEGORIES = ['全牙螺杆', '双头螺杆', '双头牙']
const D_SCREW_CATEGORIES = [
'三合一连接件',
'二合一连接件',
'四合一连接件',
'隐形连接件',
'双头偏心连接杆',
]
const CATEGORY_SHORTCUTS_BY_LETTER: Partial<
Record<AccessoryLibraryLetter, string[]>
> = {
C: C_SCREW_CATEGORIES,
D: D_SCREW_CATEGORIES,
}
const SELECT_FIELDS = [
{
@ -385,10 +400,66 @@ const C_SELECT_FIELD_OVERRIDES: Partial<
},
}
const D_SELECT_FIELD_OVERRIDES: Partial<
Record<ScrewFilterKey, Partial<Pick<SelectFieldDefinition, 'label' | 'options'>>>
> = {
screwCategory: {
options: D_SCREW_CATEGORIES,
},
material: {
label: '材质/表面处理',
options: [
'锌合金',
'铁',
'碳钢',
'碳钢+锌合金',
'锌合金+碳钢',
'铁+尼龙',
'锌合金+塑料',
'塑料',
'尼龙',
'镀锌',
'镀铬',
'白锌',
'黑色',
'枪黑',
'本色',
],
},
diameter: {
label: '规格',
options: [
'M6',
'M8',
'φ12 M6',
'φ15 M6',
'φ15 M8',
'φ18 M8',
],
},
length: {
label: '长度',
options: [
'25mm',
'30mm',
'34mm',
'35mm',
'40mm',
'42mm',
'44mm',
'45mm',
'50mm',
'60mm',
'80mm',
],
},
}
const HIDDEN_SELECT_FIELD_KEYS_BY_LETTER: Partial<
Record<AccessoryLibraryLetter, ScrewFilterKey[]>
> = {
C: ['drive'],
D: ['drive', 'tail', 'finish', 'tooth', 'color', 'preDrill'],
}
const EMPTY_FILTERS: ScrewFilterState = {
@ -458,12 +529,46 @@ const TABLE_COLUMNS = [
'操作',
]
const TABLE_COLUMN_FIELD_KEYS: Partial<
Record<(typeof TABLE_COLUMNS)[number], ScrewFilterKey | 'headType'>
> = {
分类: 'screwCategory',
头型: 'headType',
槽型: 'drive',
尾型: 'tail',
材质: 'material',
表面处理: 'finish',
牙型: 'tooth',
规格: 'diameter',
长度: 'length',
颜色: 'color',
是否预开孔: 'preDrill',
}
function getHiddenFieldKeys(
letter: AccessoryLibraryLetter,
shouldShowHeadType: boolean,
) {
const hiddenKeys = new Set<ScrewFilterKey | 'headType'>(
HIDDEN_SELECT_FIELD_KEYS_BY_LETTER[letter] ?? [],
)
if (!shouldShowHeadType) {
hiddenKeys.add('headType')
}
return hiddenKeys
}
function getTableColumns(letter: AccessoryLibraryLetter, shouldShowHeadType: boolean) {
const hiddenFieldKeys = getHiddenFieldKeys(letter, shouldShowHeadType)
return TABLE_COLUMNS.filter((column) => {
if (!shouldShowHeadType && column === '头型') return false
if (letter === 'C' && column === '槽型') return false
const fieldKey = TABLE_COLUMN_FIELD_KEYS[column]
if (fieldKey && hiddenFieldKeys.has(fieldKey)) return false
return true
}).map((column) => (letter === 'C' && column === '长度' ? '尺寸' : column))
}).map((column) => {
if (letter === 'C' && column === '长度') return '尺寸'
if (letter === 'D' && column === '材质') return '材质/表面处理'
return column
})
}
const SCREW_DUPLICATE_KEYS = [
@ -544,6 +649,21 @@ const DEFAULT_SCREW_FIELDS_BY_LETTER: Partial<
preDrill: '需预开孔',
environments: ['室内', '防锈'],
},
D: {
screwCategory: '三合一连接件',
headType: '',
drive: '',
tail: '',
material: '锌合金',
finish: '',
tooth: '',
diameter: 'φ15 M6',
length: '34mm',
usages: ['板式家具', '五金件', '柜体'],
color: '',
preDrill: '按配套孔位安装',
environments: ['室内'],
},
}
const PRIMARY_BUTTON_CLASS =
@ -587,12 +707,18 @@ function getHeadTypes(letter: AccessoryLibraryLetter) {
return HEAD_TYPES_BY_LETTER[letter] ?? []
}
function getCategoryShortcuts(letter: AccessoryLibraryLetter) {
return CATEGORY_SHORTCUTS_BY_LETTER[letter] ?? []
}
function getSelectFields(letter: AccessoryLibraryLetter) {
const overrides =
letter === 'B'
? B_SELECT_FIELD_OVERRIDES
: letter === 'C'
? C_SELECT_FIELD_OVERRIDES
: letter === 'D'
? D_SELECT_FIELD_OVERRIDES
: {}
const hiddenKeys = HIDDEN_SELECT_FIELD_KEYS_BY_LETTER[letter] ?? []
@ -654,6 +780,137 @@ function formatScrewLength(length: string) {
return length.trim().replace(/mm$/i, '')
}
function escapeSvgText(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
function getTemporaryReferencePalette(category: string) {
if (category.includes('全牙')) {
return { accent: '#2563eb', background: '#eff6ff' }
}
if (category.includes('双头螺杆')) {
return { accent: '#059669', background: '#ecfdf5' }
}
if (category.includes('双头牙')) {
return { accent: '#ca8a04', background: '#fefce8' }
}
if (category.includes('三合一')) {
return { accent: '#7c3aed', background: '#f5f3ff' }
}
if (category.includes('二合一')) {
return { accent: '#0891b2', background: '#ecfeff' }
}
if (category.includes('四合一')) {
return { accent: '#db2777', background: '#fdf2f8' }
}
if (category.includes('隐形')) {
return { accent: '#475569', background: '#f8fafc' }
}
if (category.includes('双头偏心')) {
return { accent: '#ea580c', background: '#fff7ed' }
}
return { accent: '#52525b', background: '#f4f4f5' }
}
function renderThreadedRodArtwork(accent: string, category: string) {
const threads = Array.from({ length: 16 }, (_, index) => {
const x = 44 + index * 14
return `<line x1="${x}" y1="132" x2="${x + 10}" y2="104" stroke="${accent}" stroke-width="2" stroke-linecap="round" opacity="0.7" />`
}).join('')
const doubleHeadThreads = [46, 60, 74, 210, 224, 238]
.map(
(x) =>
`<line x1="${x}" y1="132" x2="${x + 10}" y2="104" stroke="${accent}" stroke-width="2" stroke-linecap="round" opacity="0.75" />`,
)
.join('')
const pointedEnds = category.includes('双头牙')
? `<polygon points="38,118 58,104 58,132" fill="${accent}" opacity="0.85" />
<polygon points="262,118 242,104 242,132" fill="${accent}" opacity="0.85" />`
: ''
if (category.includes('全牙')) {
return `
<rect x="42" y="106" width="216" height="24" rx="12" fill="#ffffff" stroke="${accent}" stroke-width="4" />
${threads}
`
}
return `
${pointedEnds}
<rect x="54" y="106" width="192" height="24" rx="12" fill="#ffffff" stroke="${accent}" stroke-width="4" />
<rect x="92" y="108" width="112" height="20" rx="10" fill="${accent}" opacity="0.14" />
${doubleHeadThreads}
`
}
function renderEccentricConnectorArtwork(accent: string, category: string) {
const extraPart = category.includes('隐形')
? `<rect x="214" y="88" width="38" height="68" rx="12" fill="#ffffff" stroke="${accent}" stroke-width="4" />
<circle cx="233" cy="122" r="8" fill="${accent}" opacity="0.55" />`
: `<rect x="214" y="96" width="38" height="52" rx="10" fill="#ffffff" stroke="${accent}" stroke-width="4" />
<line x1="222" y1="106" x2="244" y2="138" stroke="${accent}" stroke-width="3" opacity="0.5" />`
return `
<circle cx="78" cy="122" r="36" fill="#ffffff" stroke="${accent}" stroke-width="5" />
<circle cx="78" cy="122" r="12" fill="${accent}" opacity="0.24" />
<path d="M78 92 A30 30 0 0 1 106 114" fill="none" stroke="${accent}" stroke-width="5" stroke-linecap="round" />
<rect x="118" y="110" width="88" height="22" rx="11" fill="#ffffff" stroke="${accent}" stroke-width="4" />
<line x1="128" y1="132" x2="140" y2="110" stroke="${accent}" stroke-width="2" opacity="0.65" />
<line x1="148" y1="132" x2="160" y2="110" stroke="${accent}" stroke-width="2" opacity="0.65" />
<line x1="168" y1="132" x2="180" y2="110" stroke="${accent}" stroke-width="2" opacity="0.65" />
${category.includes('双头偏心') ? '<circle cx="206" cy="121" r="11" fill="#ffffff" stroke="' + accent + '" stroke-width="4" />' : extraPart}
`
}
function getTemporaryReferenceImage(item: ScrewItem) {
if (item.imageUrl.trim()) return ''
if (item.letter !== 'C' && item.letter !== 'D') return ''
const category = item.screwCategory.trim() || '配件'
const palette = getTemporaryReferencePalette(category)
const length = formatScrewLength(item.length)
const spec =
item.diameter.trim() && length
? `${item.diameter.trim()}*${length}`
: item.diameter.trim() || length
const artwork =
item.letter === 'C'
? renderThreadedRodArtwork(palette.accent, category)
: renderEccentricConnectorArtwork(palette.accent, category)
const title = escapeSvgText(category)
const subtitle = escapeSvgText(spec || item.name || '临时参考图')
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240" viewBox="0 0 320 240">
<rect width="320" height="240" rx="24" fill="${palette.background}" />
<rect x="18" y="18" width="284" height="204" rx="18" fill="#ffffff" opacity="0.92" />
<text x="32" y="48" font-family="Arial, 'Microsoft YaHei', sans-serif" font-size="20" font-weight="700" fill="#111827">${title}</text>
<text x="32" y="73" font-family="Arial, 'Microsoft YaHei', sans-serif" font-size="13" fill="#64748b">${subtitle}</text>
${artwork}
<rect x="32" y="184" width="92" height="24" rx="12" fill="${palette.accent}" opacity="0.12" />
<text x="47" y="201" font-family="Arial, 'Microsoft YaHei', sans-serif" font-size="12" font-weight="700" fill="${palette.accent}">临时参考图</text>
</svg>
`
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`
}
function getDisplayImageUrl(item: ScrewItem) {
return item.imageUrl.trim() || getTemporaryReferenceImage(item)
}
function getDisplayImageName(item: ScrewItem) {
if (item.imageName.trim()) return item.imageName
if (getTemporaryReferenceImage(item)) {
return `${item.screwCategory || item.code} 临时参考图`
}
return `${item.code} 图片`
}
function buildScrewName(
item: Pick<
ScrewItem,
@ -664,6 +921,8 @@ function buildScrewName(
| 'headType'
| 'tail'
| 'screwCategory'
| 'material'
| 'finish'
>,
) {
const diameter = item.diameter.trim()
@ -678,6 +937,16 @@ function buildScrewName(
.join(' ')
}
if (item.letter === 'D') {
const spec =
diameter && size ? `${diameter}*${size}` : diameter || size
const materialOrFinish = item.material.trim() || item.finish.trim()
return [spec, materialOrFinish, item.screwCategory]
.map((value) => value.trim())
.filter(Boolean)
.join(' ')
}
return [specSize, item.drive, item.headType, item.tail, item.screwCategory]
.map((value) => value.trim())
.filter(Boolean)
@ -847,6 +1116,7 @@ export default function AccessoryLibraryPanel({
const [selectedLetter, setSelectedLetter] =
useState<AccessoryLibraryLetter>('A')
const [activeHeadType, setActiveHeadType] = useState('全部')
const [activeCategory, setActiveCategory] = useState('全部')
const [draft, setDraft] = useState<ScrewItem | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [copySource, setCopySource] = useState<ScrewItem | null>(null)
@ -885,10 +1155,19 @@ export default function AccessoryLibraryPanel({
[selectedLetter],
)
const shouldShowHeadType = selectedHeadTypes.length > 0
const selectedCategoryShortcuts = useMemo(
() => getCategoryShortcuts(selectedLetter),
[selectedLetter],
)
const shouldShowCategoryShortcuts = selectedCategoryShortcuts.length > 0
const selectedSelectFields = useMemo(
() => getSelectFields(selectedLetter),
[selectedLetter],
)
const selectedHiddenFieldKeys = useMemo(
() => getHiddenFieldKeys(selectedLetter, shouldShowHeadType),
[selectedLetter, shouldShowHeadType],
)
const selectedTableColumns = useMemo(
() => {
const columns = getTableColumns(selectedLetter, shouldShowHeadType)
@ -919,6 +1198,11 @@ export default function AccessoryLibraryPanel({
shouldShowHeadType
? appliedFilters.headType || (activeHeadType === '全部' ? '' : activeHeadType)
: ''
const categoryFilter =
shouldShowCategoryShortcuts
? appliedFilters.screwCategory ||
(activeCategory === '全部' ? '' : activeCategory)
: appliedFilters.screwCategory
if (codeKeyword && !normalizeText(item.code).includes(codeKeyword)) {
return false
@ -932,17 +1216,24 @@ export default function AccessoryLibraryPanel({
return false
}
if (categoryFilter && item.screwCategory !== categoryFilter) {
return false
}
return selectedSelectFields.every((field) => {
const filterValue = appliedFilters[field.key]
if (field.key === 'screwCategory') return true
return !filterValue || item[field.key] === filterValue
})
})
}, [
activeCategory,
activeHeadType,
appliedFilters,
isSelectedLetterConfigured,
selectedLetterItems,
selectedSelectFields,
shouldShowCategoryShortcuts,
shouldShowHeadType,
])
@ -956,12 +1247,14 @@ export default function AccessoryLibraryPanel({
function applyFilters() {
setAppliedFilters(filterDraft)
setActiveHeadType(filterDraft.headType || '全部')
setActiveCategory(filterDraft.screwCategory || '全部')
}
function resetFilters() {
setFilterDraft(EMPTY_FILTERS)
setAppliedFilters(EMPTY_FILTERS)
setActiveHeadType('全部')
setActiveCategory('全部')
}
function selectLetter(letter: AccessoryLibraryLetter) {
@ -969,6 +1262,7 @@ export default function AccessoryLibraryPanel({
setFilterDraft(EMPTY_FILTERS)
setAppliedFilters(EMPTY_FILTERS)
setActiveHeadType('全部')
setActiveCategory('全部')
}
function selectHeadType(headType: string) {
@ -984,6 +1278,19 @@ export default function AccessoryLibraryPanel({
}))
}
function selectCategory(category: string) {
const nextCategory = category === '全部' ? '' : category
setActiveCategory(category)
setFilterDraft((current) => ({
...current,
screwCategory: nextCategory,
}))
setAppliedFilters((current) => ({
...current,
screwCategory: nextCategory,
}))
}
function openCreateModal(source?: ScrewItem) {
if (!canEditItems) return
@ -1030,6 +1337,8 @@ export default function AccessoryLibraryPanel({
'headType',
'tail',
'screwCategory',
'material',
'finish',
].includes(key)
) {
return {
@ -1344,6 +1653,39 @@ export default function AccessoryLibraryPanel({
</div>
) : null}
{isSelectedLetterConfigured && shouldShowCategoryShortcuts ? (
<div className="mb-4 flex flex-wrap items-center gap-1.5 rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/30 px-2 py-1.5">
<button
type="button"
onClick={() => selectCategory('全部')}
className={`inline-flex h-7 items-center whitespace-nowrap rounded-full border px-3 text-[11px] font-apple-semibold transition ${
activeCategory === '全部'
? 'border-zinc-950 bg-zinc-950 text-white'
: 'border-apple-separator bg-apple-bg-primary text-apple-label-secondary hover:bg-apple-bg-tertiary'
}`}
>
全部分类
</button>
{selectedCategoryShortcuts.map((category) => {
const isActive = activeCategory === category
return (
<button
key={category}
type="button"
onClick={() => selectCategory(category)}
className={`inline-flex h-7 items-center whitespace-nowrap rounded-full border px-3 text-[11px] font-apple-semibold transition ${
isActive
? 'border-zinc-950 bg-zinc-950 text-white'
: 'border-apple-separator bg-apple-bg-primary text-apple-label-secondary hover:bg-apple-bg-tertiary'
}`}
>
{category}
</button>
)
})}
</div>
) : null}
{isSelectedLetterConfigured ? (
<div className="min-h-0 overflow-x-auto pb-1">
<table className="w-full min-w-[1980px] border-collapse text-xs">
@ -1369,7 +1711,10 @@ export default function AccessoryLibraryPanel({
</thead>
<tbody>
{visibleItems.length ? (
visibleItems.map((item, index) => (
visibleItems.map((item, index) => {
const displayImageUrl = getDisplayImageUrl(item)
return (
<tr
key={item.id}
className={`border-b border-apple-separator ${
@ -1380,14 +1725,14 @@ export default function AccessoryLibraryPanel({
{item.code}
</td>
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2">
{item.imageUrl ? (
{displayImageUrl ? (
<button
type="button"
onClick={() => setPreviewItem(item)}
className="block"
>
<img
src={item.imageUrl}
src={displayImageUrl}
alt={item.name}
className="h-12 w-12 rounded-apple-md border border-apple-separator object-cover"
/>
@ -1409,38 +1754,50 @@ export default function AccessoryLibraryPanel({
{item.headType}
</td>
) : null}
{selectedLetter !== 'C' ? (
{!selectedHiddenFieldKeys.has('drive') ? (
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2 text-apple-label-secondary">
{item.drive}
</td>
) : null}
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2 text-apple-label-secondary">
{item.tail}
</td>
{!selectedHiddenFieldKeys.has('tail') ? (
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2 text-apple-label-secondary">
{item.tail}
</td>
) : null}
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2 text-apple-label-secondary">
{item.material}
</td>
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2 text-apple-label-secondary">
{item.finish}
</td>
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2 text-apple-label-secondary">
{item.tooth}
</td>
{!selectedHiddenFieldKeys.has('finish') ? (
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2 text-apple-label-secondary">
{item.finish}
</td>
) : null}
{!selectedHiddenFieldKeys.has('tooth') ? (
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2 text-apple-label-secondary">
{item.tooth}
</td>
) : null}
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2 text-apple-label-secondary">
{item.diameter}
</td>
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2 text-apple-label-secondary">
{item.length}
</td>
{!selectedHiddenFieldKeys.has('length') ? (
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2 text-apple-label-secondary">
{item.length}
</td>
) : null}
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2">
{renderTagList(item.usages)}
</td>
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2 text-apple-label-secondary">
{item.color}
</td>
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2 text-apple-label-secondary">
{item.preDrill}
</td>
{!selectedHiddenFieldKeys.has('color') ? (
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2 text-apple-label-secondary">
{item.color}
</td>
) : null}
{!selectedHiddenFieldKeys.has('preDrill') ? (
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2 text-apple-label-secondary">
{item.preDrill}
</td>
) : null}
<td className="whitespace-nowrap border-r border-apple-separator px-3 py-2">
{renderTagList(item.environments, 2)}
</td>
@ -1487,7 +1844,8 @@ export default function AccessoryLibraryPanel({
</td>
) : null}
</tr>
))
)
})
) : (
<tr>
<td
@ -1698,18 +2056,18 @@ export default function AccessoryLibraryPanel({
title={previewItem ? `${previewItem.code} · ${previewItem.name}` : '图片预览'}
size="large"
>
{previewItem?.imageUrl ? (
{previewItem && getDisplayImageUrl(previewItem) ? (
<div className="rounded-apple-lg bg-apple-bg-secondary/30 p-4">
<div className="flex justify-center rounded-apple-lg bg-apple-bg-primary p-4">
<img
src={previewItem.imageUrl}
src={getDisplayImageUrl(previewItem)}
alt={previewItem.name}
className="max-h-[65vh] rounded-apple-lg object-contain"
/>
</div>
<p className="mt-3 flex items-center gap-1 text-xs text-apple-label-secondary">
<Eye className="h-3.5 w-3.5" />
{previewItem.imageName || `${previewItem.code} 图片`}
{getDisplayImageName(previewItem)}
</p>
</div>
) : null}

View File

@ -66,10 +66,10 @@ describe('alphabet library panels', () => {
render(<AccessoryLibraryPanel />)
await actor.click(screen.getByRole('button', { name: 'D 0 条' }))
await actor.click(screen.getByRole('button', { name: 'E 0 条' }))
expect(screen.getByText('D 组配件')).toBeInTheDocument()
expect(screen.getByText('D 组暂未配置配件分类')).toBeInTheDocument()
expect(screen.getByText('E 组配件')).toBeInTheDocument()
expect(screen.getByText('E 组暂未配置配件分类')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '新增 A 组' })).not.toBeInTheDocument()
await actor.click(screen.getByRole('button', { name: 'A 自攻螺丝 0 条' }))
@ -87,6 +87,10 @@ describe('alphabet library panels', () => {
expect(screen.getByText('C 组配件 · 连接螺杆')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '全部分类' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '全牙螺杆' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '双头螺杆' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '双头牙' })).toBeInTheDocument()
await actor.click(screen.getByRole('button', { name: '新增 C 组' }))
@ -135,6 +139,119 @@ describe('alphabet library panels', () => {
})
})
it('filters C and D accessory groups by category shortcuts', async () => {
const actor = userEvent.setup()
render(<AccessoryLibraryPanel />)
await actor.click(screen.getByRole('button', { name: 'C 连接螺杆 0 条' }))
await actor.click(screen.getByRole('button', { name: '新增 C 组' }))
let dialog = await screen.findByRole('dialog', { name: '新增C组配件' })
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
await waitFor(() => {
expect(screen.getByText('M8*100 双平尾 全牙螺杆')).toBeInTheDocument()
})
await actor.click(screen.getByRole('button', { name: '新增 C 组' }))
dialog = await screen.findByRole('dialog', { name: '新增C组配件' })
fireEvent.change(within(dialog).getByLabelText('分类'), {
target: { value: '双头牙' },
})
fireEvent.change(within(dialog).getByLabelText('尺寸'), {
target: { value: '70mm' },
})
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
await waitFor(() => {
expect(screen.getByText('M8*70 双平尾 双头牙')).toBeInTheDocument()
})
await actor.click(screen.getByRole('button', { name: '双头牙' }))
expect(screen.queryByText('M8*100 双平尾 全牙螺杆')).not.toBeInTheDocument()
expect(screen.getByText('M8*70 双平尾 双头牙')).toBeInTheDocument()
await actor.click(screen.getByRole('button', { name: '全部分类' }))
expect(screen.getByText('M8*100 双平尾 全牙螺杆')).toBeInTheDocument()
expect(screen.getByText('M8*70 双平尾 双头牙')).toBeInTheDocument()
await actor.click(screen.getByRole('button', { name: 'D 偏心连接件 0 条' }))
expect(screen.getByRole('button', { name: '全部分类' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '三合一连接件' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '二合一连接件' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '四合一连接件' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '隐形连接件' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '双头偏心连接杆' })).toBeInTheDocument()
})
it('uses whole-set eccentric connector rules for D accessories', async () => {
const actor = userEvent.setup()
render(<AccessoryLibraryPanel />)
await actor.click(screen.getByRole('button', { name: 'D 偏心连接件 0 条' }))
expect(screen.getByText('D 组配件 · 偏心连接件')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument()
await actor.click(screen.getByRole('button', { name: '新增 D 组' }))
const dialog = await screen.findByRole('dialog', { name: '新增D组配件' })
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
'φ15 M6*34 锌合金 三合一连接件',
)
expect(within(dialog).queryByLabelText('头型')).not.toBeInTheDocument()
expect(within(dialog).queryByLabelText('槽型')).not.toBeInTheDocument()
expect(within(dialog).queryByLabelText('尾型')).not.toBeInTheDocument()
expect(within(dialog).queryByLabelText('牙型')).not.toBeInTheDocument()
expect(within(dialog).getByLabelText('规格')).toBeInTheDocument()
expect(within(dialog).getByLabelText('长度')).toBeInTheDocument()
expect(within(dialog).getByLabelText('材质/表面处理')).toBeInTheDocument()
fireEvent.change(within(dialog).getByLabelText('分类'), {
target: { value: '双头偏心连接杆' },
})
fireEvent.change(within(dialog).getByLabelText('规格'), {
target: { value: 'M8' },
})
fireEvent.change(within(dialog).getByLabelText('长度'), {
target: { value: '42mm' },
})
fireEvent.change(within(dialog).getByLabelText('材质/表面处理'), {
target: { value: '镀锌' },
})
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
'M8*42 镀锌 双头偏心连接杆',
)
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
await waitFor(() => {
expect(screen.getByText('M8*42 镀锌 双头偏心连接杆')).toBeInTheDocument()
})
expect(screen.getAllByText('双头偏心连接杆').length).toBeGreaterThan(0)
expect(screen.getAllByText('42mm').length).toBeGreaterThan(0)
const savedItems = JSON.parse(
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
)
expect(savedItems[0]).toMatchObject({
letter: 'D',
code: 'D0',
name: 'M8*42 镀锌 双头偏心连接杆',
screwCategory: '双头偏心连接杆',
headType: '',
drive: '',
tail: '',
material: '镀锌',
finish: '',
tooth: '',
diameter: 'M8',
length: '42mm',
})
})
it('opens copy-new mode and blocks exact duplicate screw configs', async () => {
const actor = userEvent.setup()

View File

@ -1,6 +1,6 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState, type InputHTMLAttributes } from 'react'
import { Copy, Layers3, Plus, Ruler, Save, Search, Trash2 } from 'lucide-react'
import Button from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
@ -32,6 +32,26 @@ const EDGE_BANDING_CATEGORY_STORAGE_KEY =
'formal-intake-edge-banding-library-categories'
const LIBRARY_PRIMARY_BUTTON_CLASS =
'!border-zinc-950 !bg-zinc-950 !bg-none !text-white !shadow-none hover:!border-zinc-800 hover:!bg-zinc-800 focus:!border-zinc-600 disabled:!border-zinc-200 disabled:!bg-zinc-200 disabled:!text-zinc-400'
const EDGE_BANDING_TOP_FIELD_CLASS =
'mt-1 h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-primary px-3 text-xs text-apple-label-primary outline-none placeholder:text-apple-label-tertiary focus:border-apple-blue'
function EdgeBandingTopField({
label,
className = '',
...inputProps
}: InputHTMLAttributes<HTMLInputElement> & {
label: string
}) {
return (
<label className="block text-xs text-apple-label-secondary">
<span>{label}</span>
<input
className={`${EDGE_BANDING_TOP_FIELD_CLASS} ${className}`}
{...inputProps}
/>
</label>
)
}
function normalizeText(value: string) {
return value.trim().toLowerCase()
@ -54,10 +74,42 @@ function parseEdgeBandingThicknessFromSpec(value: string) {
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
}
function parsePositiveNumberFromText(value: string) {
const matchedText = value.trim().replace(/,/g, '').match(/(\d+(?:\.\d+)?)/)?.[1]
if (!matchedText) return null
const parsed = Number(matchedText)
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
}
function parseEdgeBandingWidthFromSpec(value: string) {
const matchedText = value.trim().match(/[*×xX]\s*(\d+(?:\.\d+)?)/)?.[1]
if (!matchedText) return null
const parsed = Number(matchedText)
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
}
function getEdgeBandingThicknessTextFromSpec(value: string) {
return value.trim().match(/^(\d+(?:\.\d+)?)/)?.[1] ?? ''
}
function matchesNumberOrTextQuery(
query: string,
numericValue: number | null,
textValues: string[],
) {
const keyword = normalizeText(query)
if (!keyword) return true
const queryNumber = parsePositiveNumberFromText(keyword)
if (queryNumber !== null && numericValue !== null) {
return Math.abs(numericValue - queryNumber) < 0.0001
}
return textValues.map(normalizeText).some((value) => value.includes(keyword))
}
function getEdgeBandingBaseName(item: Pick<EdgeBandingLibraryItem, 'name' | 'spec'>) {
const spec = normalizeDisplayText(item.spec)
const edgeThickness = getEdgeBandingThicknessTextFromSpec(spec)
@ -174,9 +226,13 @@ export default function EdgeBandingLibraryPanel({
const [items, setItems] = useState(initialItems)
const [categories, setCategories] = useState(initialCategories)
const [queryName, setQueryName] = useState('')
const [queryEdgeThickness, setQueryEdgeThickness] = useState('')
const [queryThickness, setQueryThickness] = useState('')
const [querySpecWidth, setQuerySpecWidth] = useState('')
const [appliedQueryName, setAppliedQueryName] = useState('')
const [appliedQueryEdgeThickness, setAppliedQueryEdgeThickness] = useState('')
const [appliedQueryThickness, setAppliedQueryThickness] = useState('')
const [appliedQuerySpecWidth, setAppliedQuerySpecWidth] = useState('')
const [categoryDraftName, setCategoryDraftName] = useState('')
const [selectedItemId, setSelectedItemId] = useState<string | null>(
initialItems[0]?.id ?? null,
@ -208,21 +264,40 @@ export default function EdgeBandingLibraryPanel({
const filteredItems = useMemo(() => {
const nameKeyword = normalizeText(appliedQueryName)
const queryThicknessValue = parseBoardThicknessMmFromText(appliedQueryThickness)
const queryBoardThicknessValue = parseBoardThicknessMmFromText(appliedQueryThickness)
return normalizedItems.filter((item) => {
const displayName = buildEdgeBandingDisplayName(item)
const baseName = getEdgeBandingBaseName(item)
const matchesName =
!nameKeyword ||
[item.name, item.spec, buildEdgeBandingDisplayName(item)]
[item.name, baseName, displayName]
.map(normalizeText)
.some((value) => value.includes(nameKeyword))
const edgeThickness = parseEdgeBandingThicknessFromSpec(item.spec)
const matchesEdgeThickness = matchesNumberOrTextQuery(
appliedQueryEdgeThickness,
edgeThickness,
[item.spec, displayName],
)
const itemThickness = parseBoardThicknessMmFromText(item.compatibleThicknessMm)
const matchesThickness =
queryThicknessValue === null || itemThickness === queryThicknessValue
queryBoardThicknessValue === null || itemThickness === queryBoardThicknessValue
const specWidth = parseEdgeBandingWidthFromSpec(item.spec)
const matchesSpecWidth = matchesNumberOrTextQuery(appliedQuerySpecWidth, specWidth, [
item.spec,
displayName,
])
return matchesName && matchesThickness
return matchesName && matchesEdgeThickness && matchesThickness && matchesSpecWidth
})
}, [appliedQueryName, appliedQueryThickness, normalizedItems])
}, [
appliedQueryEdgeThickness,
appliedQueryName,
appliedQuerySpecWidth,
appliedQueryThickness,
normalizedItems,
])
const sortedFilteredItems = useMemo(
() =>
@ -255,7 +330,7 @@ export default function EdgeBandingLibraryPanel({
}
>()
const nameKeyword = normalizeText(appliedQueryName)
const queryThicknessValue = parseBoardThicknessMmFromText(appliedQueryThickness)
const queryBoardThicknessValue = parseBoardThicknessMmFromText(appliedQueryThickness)
categories.forEach((category) => {
if (
@ -266,7 +341,15 @@ export default function EdgeBandingLibraryPanel({
) {
return
}
if (queryThicknessValue !== null) return
if (
!matchesNumberOrTextQuery(appliedQueryEdgeThickness, category.sortThickness, [
category.edgeThicknessText,
category.label,
])
) {
return
}
if (queryBoardThicknessValue !== null || normalizeText(appliedQuerySpecWidth)) return
groupMap.set(category.key, {
key: category.key,
@ -308,7 +391,14 @@ export default function EdgeBandingLibraryPanel({
return left.label.localeCompare(right.label, 'zh-CN', { numeric: true })
})
}, [appliedQueryName, appliedQueryThickness, categories, sortedFilteredItems])
}, [
appliedQueryEdgeThickness,
appliedQueryName,
appliedQuerySpecWidth,
appliedQueryThickness,
categories,
sortedFilteredItems,
])
const [selectedGroupKey, setSelectedGroupKey] = useState<string | null>(null)
const activeGroup =
@ -378,7 +468,9 @@ export default function EdgeBandingLibraryPanel({
function applySearch() {
setAppliedQueryName(queryName)
setAppliedQueryEdgeThickness(queryEdgeThickness)
setAppliedQueryThickness(queryThickness)
setAppliedQuerySpecWidth(querySpecWidth)
}
function startCreate() {
@ -583,49 +675,55 @@ export default function EdgeBandingLibraryPanel({
return (
<div className="flex h-full min-h-0 flex-col gap-4">
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-lg font-apple-semibold text-apple-label-primary">
封边资料库
</h3>
<div className="grid items-end gap-3 xl:grid-cols-[minmax(0,1fr)_auto]">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<EdgeBandingTopField
label="名称 / 材质"
value={queryName}
onChange={(event) => setQueryName(event.target.value)}
placeholder="例如:PVC / ABS / 同色"
/>
<EdgeBandingTopField
label="封边厚度"
value={queryEdgeThickness}
onChange={(event) => setQueryEdgeThickness(event.target.value)}
placeholder="例如:0.5"
/>
<EdgeBandingTopField
label="适配板厚"
value={queryThickness}
onChange={(event) => setQueryThickness(event.target.value)}
placeholder="例如:12MM"
/>
<EdgeBandingTopField
label="规格宽度"
value={querySpecWidth}
onChange={(event) => setQuerySpecWidth(event.target.value)}
placeholder="例如:18"
/>
</div>
</div>
<div className="mt-4 grid gap-3 xl:grid-cols-[minmax(0,1fr)_240px_auto]">
<Input
label="规格名称 / 规格"
value={queryName}
onChange={(event) => setQueryName(event.target.value)}
placeholder="例如:PVC封边条 / 22×1"
/>
<Input
label="适配厚度"
value={queryThickness}
onChange={(event) => setQueryThickness(event.target.value)}
placeholder="例如:12MM"
/>
<div className="flex flex-wrap items-end justify-end gap-2">
<Button
type="button"
size="small"
className={LIBRARY_PRIMARY_BUTTON_CLASS}
onClick={applySearch}
>
<Search className="h-4 w-4" />
查询
</Button>
<div className="flex min-h-9 flex-wrap items-center justify-end gap-2">
{canEditItems ? (
<Button
type="button"
variant="tinted"
size="small"
className={LIBRARY_PRIMARY_BUTTON_CLASS}
className={`${LIBRARY_PRIMARY_BUTTON_CLASS} h-9 whitespace-nowrap text-xs`}
onClick={startCreate}
>
<Plus className="h-4 w-4" />
新建封边
</Button>
) : null}
<Button
type="button"
size="small"
className={`${LIBRARY_PRIMARY_BUTTON_CLASS} h-9 whitespace-nowrap text-xs`}
onClick={applySearch}
>
<Search className="h-4 w-4" />
查询
</Button>
</div>
</div>
</div>

View File

@ -73,6 +73,7 @@ import {
type NonIntegratedDocumentWorkspaceCategory,
type NonIntegratedProductRecord,
type NonIntegratedSkuComponentDetail,
type NonIntegratedSkuHolePoint,
type NonIntegratedWorkflowNodeCategory,
} from "./non-integrated/nonIntegratedStorage";
import NonIntegratedDetailClient from "./non-integrated/NonIntegratedDetailClient";
@ -900,6 +901,7 @@ type WorkbenchSku = {
status: string;
componentCodes: string[];
componentDetails?: NonIntegratedSkuComponentDetail[];
holePoints?: NonIntegratedSkuHolePoint[];
skuFields?: SeriesDraftSkuFields;
};
@ -2075,6 +2077,117 @@ function readPreSkuTextField(
return "";
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function readFiniteNumber(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function readVector3Tuple(value: unknown): [number, number, number] | null {
if (!Array.isArray(value) || value.length < 3) return null;
const x = readFiniteNumber(value[0]);
const y = readFiniteNumber(value[1]);
const z = readFiniteNumber(value[2]);
if (x === null || y === null || z === null) return null;
return [x, y, z];
}
function readRecordArray(value: unknown) {
return Array.isArray(value)
? value.filter((item): item is Record<string, unknown> =>
isPlainRecord(item),
)
: [];
}
function readTrimmedString(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function buildSkuHolePointFromRecord(params: {
record: Record<string, unknown>;
fallbackId: string;
panelId: string;
panelName: string;
sourceNodeName: string;
}): NonIntegratedSkuHolePoint | null {
const position = readVector3Tuple(params.record.position);
if (!position) return null;
return {
id: readTrimmedString(params.record.id) || params.fallbackId,
panelId: readTrimmedString(params.record.panelId) || params.panelId,
panelName: readTrimmedString(params.record.panelName) || params.panelName,
position,
normal: readVector3Tuple(params.record.normal) ?? [0, 1, 0],
diameter:
readFiniteNumber(params.record.diameter) ??
readFiniteNumber(params.record.width) ??
5,
depth: readFiniteNumber(params.record.depth) ?? 10,
holeType:
readTrimmedString(params.record.holeType) ||
readTrimmedString(params.record.type) ||
"mount-point",
sourceNodeName: params.sourceNodeName,
};
}
function buildSkuHolePointsFromVariantSelection(
preSku: DraftGroupDetailDTO["preSkus"][number],
): NonIntegratedSkuHolePoint[] {
const selection = preSku.variantSelection;
const entries = [
...(selection.slots ?? []),
...(selection.hardwareSelections ?? []),
];
return entries.flatMap((entry, entryIndex) => {
const sourceNodeName = entry.code || entry.name || `node-${entryIndex + 1}`;
const panelId = entry.variantId || sourceNodeName;
const panelName = entry.name || sourceNodeName;
const assemblyBasis = isPlainRecord(entry.assemblyBasis)
? entry.assemblyBasis
: null;
const holes = readRecordArray(assemblyBasis?.holes)
.map((record, holeIndex) =>
buildSkuHolePointFromRecord({
record,
fallbackId: `${panelId}:hole-${holeIndex + 1}`,
panelId,
panelName,
sourceNodeName,
}),
)
.filter((item): item is NonIntegratedSkuHolePoint => item !== null);
if (holes.length > 0) return holes;
return readRecordArray(entry.mountPoints)
.map((record, mountIndex) =>
buildSkuHolePointFromRecord({
record: {
...record,
id:
readTrimmedString(record.id) ||
readTrimmedString(record.name) ||
`mount-${mountIndex + 1}`,
holeType: readTrimmedString(record.type) || "mount-point",
},
fallbackId: `${panelId}:mount-${mountIndex + 1}`,
panelId,
panelName,
sourceNodeName,
}),
)
.filter((item): item is NonIntegratedSkuHolePoint => item !== null);
});
}
function mapStatus(status: string) {
if (status === "FORMALIZED") return "已完成";
if (status === "READY_FOR_INTAKE") return "待执行";
@ -2209,6 +2322,7 @@ function getDraftGroupSeriesSkuOptions(props: IntakeClientProps): WorkbenchSku[]
status: mapStatus(preSku.status),
componentCodes: preSku.componentCodes ?? [],
componentDetails: readPreSkuComponentDetails(preSku),
holePoints: buildSkuHolePointsFromVariantSelection(preSku),
skuFields: readPreSkuSkuFields(preSku),
}));
}

View File

@ -1,11 +1,9 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { Calculator, Plus, Save, Search, SquareStack, Trash2 } from 'lucide-react'
import { useEffect, useMemo, useState, type InputHTMLAttributes } from 'react'
import { ChevronDown, Copy, Plus, Save, Search, Trash2 } from 'lucide-react'
import Button from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import {
STEEL_FRAME_TEMPLATE_FIELDS,
buildSteelFrameLibraryItems,
buildSteelFrameMatchLabel,
buildSteelFrameSpecTitle,
@ -24,6 +22,31 @@ function isRoundName(value: string) {
return normalizeText(value).includes('圆')
}
function formatSteelFrameMetric(value: string, maximumFractionDigits = 2) {
const normalizedValue = value.trim()
const numericValue = Number(normalizedValue.replace(/,/g, ''))
if (!Number.isFinite(numericValue)) {
return normalizedValue || '-'
}
return numericValue.toLocaleString('zh-CN', {
maximumFractionDigits,
})
}
function formatSteelFrameMetricWithUnit(
value: string,
maximumFractionDigits: number,
unit: string,
prefix = '',
) {
const formattedValue = formatSteelFrameMetric(value, maximumFractionDigits)
if (formattedValue === '-') return formattedValue
return `${prefix}${formattedValue}${unit}`
}
type SteelFramePanelItem = ReturnType<typeof readSteelFrameLibraryState>['items'][number]
type EditorMode = 'view' | 'create' | 'edit'
@ -32,9 +55,38 @@ const STEEL_FRAME_CREATE_PRESETS = [
{ key: 'flat', label: '新增扁管', name: '扁管' },
{ key: 'round', label: '新增圆管', name: '圆管' },
] as const
const STEEL_FRAME_TABLE_GRID =
'minmax(170px,1.4fr) minmax(56px,0.4fr) minmax(56px,0.4fr) minmax(64px,0.4fr) minmax(96px,0.7fr) minmax(96px,0.7fr) minmax(96px,0.65fr)'
const STEEL_FRAME_FIELD_CLASS =
'mt-1 h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-primary px-3 text-xs text-apple-label-primary outline-none placeholder:text-apple-label-tertiary focus:border-apple-blue disabled:cursor-not-allowed disabled:opacity-50'
const LIBRARY_PRIMARY_BUTTON_CLASS =
'!border-zinc-950 !bg-zinc-950 !bg-none !text-white !shadow-none hover:!border-zinc-800 hover:!bg-zinc-800 focus:!border-zinc-600 disabled:!border-zinc-200 disabled:!bg-zinc-200 disabled:!text-zinc-400'
function SteelFrameField({
label,
helperText,
containerClassName = '',
className = '',
...inputProps
}: InputHTMLAttributes<HTMLInputElement> & {
label: string
helperText?: string
containerClassName?: string
}) {
return (
<label className={`block text-xs text-apple-label-secondary ${containerClassName}`}>
<span>{label}</span>
<input
className={`${STEEL_FRAME_FIELD_CLASS} ${className}`}
{...inputProps}
/>
{helperText ? (
<span className="mt-1 block text-[11px] leading-4 text-apple-label-secondary">{helperText}</span>
) : null}
</label>
)
}
export default function SteelFrameLibraryPanel({
canEditItems = true,
}: {
@ -54,6 +106,7 @@ export default function SteelFrameLibraryPanel({
const [editingItemId, setEditingItemId] = useState<string | null>(null)
const [editorDraft, setEditorDraft] = useState(createEmptySteelFrameDraft())
const [panelMessage, setPanelMessage] = useState('')
const [rulesExpanded, setRulesExpanded] = useState(false)
const items = useMemo(
() => buildSteelFrameLibraryItems(referenceSteelPrice, savedItems),
@ -159,6 +212,7 @@ export default function SteelFrameLibraryPanel({
),
)
setPanelMessage('')
setRulesExpanded(false)
}
function handleSelectItem(item: SteelFramePanelItem) {
@ -168,6 +222,7 @@ export default function SteelFrameLibraryPanel({
setEditingItemId(null)
setEditorDraft(createEmptySteelFrameDraft())
setPanelMessage('')
setRulesExpanded(false)
return
}
@ -175,6 +230,7 @@ export default function SteelFrameLibraryPanel({
setEditingItemId(item.id)
setEditorDraft(item)
setPanelMessage('')
setRulesExpanded(false)
}
function handleDraftFieldChange(
@ -242,53 +298,68 @@ export default function SteelFrameLibraryPanel({
setPanelMessage('钢架规格已删除。')
}
function handleCopyDraft() {
if (!canEditItems) return
if (!editingItemId) return
setEditorMode('create')
setEditingItemId(null)
setSelectedItemId(null)
setEditorDraft(
hydrateSteelFrameLibraryDraft(
{
name: editorDraft.name,
length: editorDraft.length,
width: editorDraft.width,
wallThickness: editorDraft.wallThickness,
remark: editorDraft.remark,
},
referenceSteelPrice,
),
)
setPanelMessage('已复制为新规格,请修改长、宽或壁厚后保存。')
setRulesExpanded(false)
}
const shouldShowEditor =
canEditItems &&
(editorMode === 'create' || (editorMode === 'edit' && Boolean(editingItemId)))
return (
<div className="space-y-4">
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-lg font-apple-semibold text-apple-label-primary">钢架资料库</h3>
<p className="mt-1 text-sm leading-6 text-apple-label-secondary">
这里不再预置系统钢架规格。你只需要维护每吨钢价,并按需新建方管、扁管、圆管规格;保存后的规格会自动带出重量、支数和每米单价。
</p>
</div>
<div className="flex flex-wrap gap-2 text-xs">
<span className="rounded-full bg-emerald-100 px-3 py-1.5 text-emerald-700">
已录规格 {items.length} 条
</span>
<span className="rounded-full bg-apple-blue/10 px-3 py-1.5 text-apple-blue">
当前钢价联动重算
</span>
</div>
</div>
<div className="mt-4 grid gap-3 xl:grid-cols-[minmax(0,1fr)_auto]">
<div className="grid gap-3 md:grid-cols-3">
<Input
<div className="flex h-[calc(100vh-180px)] min-h-[560px] flex-col gap-4 overflow-hidden">
<div className="shrink-0 rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-4">
<div className="grid items-end gap-3 xl:grid-cols-[minmax(0,1fr)_auto]">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<SteelFrameField
label="名称 / 管型"
value={queryName}
onChange={(event) => setQueryName(event.target.value)}
placeholder="例如:方管 / 扁管 / 圆管"
/>
<Input
<SteelFrameField
label="规格关键词"
value={querySize}
onChange={(event) => setQuerySize(event.target.value)}
placeholder="例如:20x20 / 30x15 / 25x0.7"
/>
<Input
<SteelFrameField
label="壁厚"
value={queryWallThickness}
onChange={(event) => setQueryWallThickness(event.target.value)}
placeholder="例如:0.5"
/>
<SteelFrameField
label="每吨钢管参考价"
value={referenceSteelPrice}
onChange={(event) => {
if (canEditItems) setReferenceSteelPrice(event.target.value)
}}
placeholder="例如:4200"
readOnly={!canEditItems}
/>
</div>
<div className="flex flex-wrap items-end justify-end gap-2">
<div className="flex min-h-9 flex-wrap items-center justify-end gap-2">
{canEditItems
? STEEL_FRAME_CREATE_PRESETS.map((preset) => (
<Button
@ -296,7 +367,7 @@ export default function SteelFrameLibraryPanel({
type="button"
size="small"
variant="gray"
className={LIBRARY_PRIMARY_BUTTON_CLASS}
className={`${LIBRARY_PRIMARY_BUTTON_CLASS} h-9 whitespace-nowrap text-xs`}
onClick={() => handleStartCreate(preset.name)}
>
<Plus className="h-4 w-4" />
@ -307,7 +378,7 @@ export default function SteelFrameLibraryPanel({
<Button
type="button"
size="small"
className={LIBRARY_PRIMARY_BUTTON_CLASS}
className={`${LIBRARY_PRIMARY_BUTTON_CLASS} h-9 whitespace-nowrap text-xs`}
onClick={applySearch}
>
<Search className="h-4 w-4" />
@ -317,61 +388,37 @@ export default function SteelFrameLibraryPanel({
</div>
</div>
<div className="grid gap-4 xl:grid-cols-[320px_minmax(0,1fr)]">
<div className="space-y-4">
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-4">
<div className="flex items-center gap-2">
<Calculator className="h-4 w-4 text-apple-blue" />
<p className="text-sm font-apple-semibold text-apple-label-primary">钢管参考价</p>
</div>
<p className="mt-2 text-xs leading-5 text-apple-label-secondary">
这里只填“每吨钢管参考价”。你一改这里,下面所有已保存钢架规格的每米单价都会跟着自动刷新。
</p>
<div className="mt-3">
<Input
label="每吨钢管参考价"
value={referenceSteelPrice}
onChange={(event) => {
if (canEditItems) setReferenceSteelPrice(event.target.value)
}}
placeholder="例如:4200"
readOnly={!canEditItems}
helperText="每米单价 = 单支重量 × 每吨钢管参考价 ÷ 6000"
/>
<div className="grid min-h-0 flex-1 gap-4 xl:grid-cols-[340px_minmax(0,1fr)]">
<div className="order-2 flex min-h-0 flex-col rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-4 xl:order-2">
<div className="flex shrink-0 flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm font-apple-semibold text-apple-label-primary">钢架规格列表</p>
<p className="mt-1 text-xs leading-5 text-apple-label-secondary">
点一行即可查看或编辑;单价会按当前每吨钢价自动重算。
</p>
</div>
<span className="rounded-full bg-apple-bg-secondary px-3 py-1 text-xs text-apple-label-secondary">
{sortedFilteredItems.length} 条
</span>
</div>
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-4">
<div className="flex items-center gap-2">
<SquareStack className="h-4 w-4 text-apple-blue" />
<p className="text-sm font-apple-semibold text-apple-label-primary">自动字段</p>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{STEEL_FRAME_TEMPLATE_FIELDS.map((field) => (
<span
key={field}
className="rounded-full border border-apple-separator bg-apple-bg-secondary px-3 py-1 text-xs text-apple-label-primary"
>
{field}
</span>
))}
</div>
</div>
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-4">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-sm font-apple-semibold text-apple-label-primary">钢架规格列表</p>
<p className="mt-1 text-xs leading-5 text-apple-label-secondary">
左侧只显示你已经保存的钢架规格。后续谁新建,都会按当前每吨钢价自动重算单价。
</p>
</div>
<span className="text-xs text-apple-label-secondary">{sortedFilteredItems.length} 条</span>
<div className="mt-4 flex min-h-0 flex-1 flex-col overflow-x-auto pb-1">
<div
className="grid gap-3 rounded-apple-lg bg-apple-bg-secondary/70 px-3 py-2 text-xs font-apple-semibold text-apple-label-secondary"
style={{ gridTemplateColumns: STEEL_FRAME_TABLE_GRID, minWidth: 760 }}
>
<span>规格</span>
<span>长</span>
<span>宽</span>
<span>壁厚</span>
<span>单支重量</span>
<span>每吨支数</span>
<span>每米单价</span>
</div>
<div className="mt-3 max-h-[620px] space-y-2 overflow-y-auto pr-1">
<div className="mt-2 min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
{sortedFilteredItems.length === 0 ? (
<div className="rounded-apple-lg border border-dashed border-apple-separator bg-apple-bg-secondary/50 px-4 py-8 text-center text-xs leading-6 text-apple-label-secondary">
<div className="rounded-apple-lg border border-dashed border-apple-separator bg-apple-bg-secondary/50 px-4 py-10 text-center text-sm leading-6 text-apple-label-secondary">
{canEditItems
? '当前查询条件下还没有钢架规格,直接点上面的新增按钮即可开始录入。'
: '当前查询条件下还没有钢架规格。'}
@ -385,28 +432,28 @@ export default function SteelFrameLibraryPanel({
key={item.id}
type="button"
onClick={() => handleSelectItem(item)}
className={`w-full rounded-apple-lg border px-3 py-3 text-left transition ${
className={`grid w-full gap-3 rounded-apple-lg border px-3 py-3 text-left text-sm transition ${
isActive
? 'border-apple-blue bg-apple-blue/10'
: 'border-apple-separator bg-apple-bg-secondary/40 hover:bg-apple-bg-secondary'
: 'border-apple-separator bg-apple-bg-secondary/30 hover:bg-apple-bg-secondary'
}`}
style={{ gridTemplateColumns: STEEL_FRAME_TABLE_GRID, minWidth: 760 }}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-apple-medium text-apple-label-primary">
{buildSteelFrameSpecTitle(item)}
</p>
<p className="mt-1 text-xs text-apple-label-secondary">
重量 {item.weight.trim() || '-'} / 支数 {item.pieceCount.trim() || '-'}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<span className="rounded-full bg-emerald-100 px-2.5 py-1 text-[11px] text-emerald-700">
已保存
</span>
<span className="text-xs text-apple-label-secondary">¥ {item.unitPrice || '-'}</span>
</div>
</div>
<span className="min-w-0 truncate font-apple-medium text-apple-label-primary">
{buildSteelFrameSpecTitle(item)}
</span>
<span className="text-apple-label-secondary">{item.length || '-'}</span>
<span className="text-apple-label-secondary">{item.width || '-'}</span>
<span className="text-apple-label-secondary">{item.wallThickness || '-'}</span>
<span className="text-apple-label-secondary">
{formatSteelFrameMetricWithUnit(item.weight, 3, ' kg')}
</span>
<span className="text-apple-label-secondary">
{formatSteelFrameMetricWithUnit(item.pieceCount, 1, ' 支')}
</span>
<span className="font-apple-medium text-apple-label-primary">
{formatSteelFrameMetricWithUnit(item.unitPrice, 2, ' /米', '¥ ')}
</span>
</button>
)
})
@ -415,60 +462,74 @@ export default function SteelFrameLibraryPanel({
</div>
</div>
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-4">
<div className="order-1 flex min-h-0 flex-col overflow-hidden rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-3 xl:order-1">
{shouldShowEditor ? (
<div className="flex h-full flex-col">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h4 className="text-lg font-apple-semibold text-apple-label-primary">
<div className="min-h-0 overflow-y-auto pr-1">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<h4 className="text-base font-apple-semibold text-apple-label-primary">
{editorMode === 'create' ? '新建钢架规格' : '编辑钢架规格'}
</h4>
<p className="mt-1 text-sm text-apple-label-secondary">
这里只需要填名称、长、宽、壁厚,下面会自动带出重量、支数和每米单价。
</p>
</div>
<div className="flex flex-wrap gap-2">
<div className="flex shrink-0 gap-1.5">
{editorMode === 'edit' ? (
<Button type="button" size="small" variant="plain" onClick={handleDeleteDraft}>
<Trash2 className="h-4 w-4" />
删除
</Button>
<>
<Button
type="button"
size="small"
variant="plain"
className="!h-7 !min-h-0 !px-2 !py-0 whitespace-nowrap text-[11px] [&>span]:gap-1"
onClick={handleDeleteDraft}
>
<Trash2 className="h-3.5 w-3.5" />
删除
</Button>
<Button
type="button"
size="small"
variant="plain"
className="!h-7 !min-h-0 !px-2 !py-0 whitespace-nowrap text-[11px] [&>span]:gap-1"
onClick={handleCopyDraft}
>
<Copy className="h-3.5 w-3.5" />
复制
</Button>
</>
) : null}
<Button
type="button"
size="small"
className={LIBRARY_PRIMARY_BUTTON_CLASS}
className={`${LIBRARY_PRIMARY_BUTTON_CLASS} !h-7 !min-h-0 !px-2 !py-0 whitespace-nowrap text-[11px] [&>span]:gap-1`}
onClick={handleSaveDraft}
disabled={!draftCanSave}
>
<Save className="h-4 w-4" />
<Save className="h-3.5 w-3.5" />
保存
</Button>
</div>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<Input
<div className="mt-3 grid grid-cols-2 gap-2">
<SteelFrameField
label="名称 / 管型"
value={editorDraft.name}
onChange={(event) => handleDraftFieldChange('name', event.target.value)}
placeholder="例如:方管 / 扁管 / 圆管"
/>
<Input
<SteelFrameField
label={draftIsRound ? '长 / 直径' : '长'}
value={editorDraft.length}
onChange={(event) => handleDraftFieldChange('length', event.target.value)}
placeholder={draftIsRound ? '例如:25' : '例如:20'}
/>
<Input
<SteelFrameField
label="宽"
value={editorDraft.width}
onChange={(event) => handleDraftFieldChange('width', event.target.value)}
placeholder={draftIsRound ? '圆管可留空' : '例如:15'}
disabled={draftIsRound}
helperText={draftIsRound ? '圆管按直径 + 壁厚计算,宽度可以不填。' : undefined}
/>
<Input
<SteelFrameField
label="壁厚"
value={editorDraft.wallThickness}
onChange={(event) => handleDraftFieldChange('wallThickness', event.target.value)}
@ -476,25 +537,34 @@ export default function SteelFrameLibraryPanel({
/>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="mt-3 grid grid-cols-2 gap-2">
{[
{ label: '系统匹配名', value: buildSteelFrameMatchLabel(resolvedEditorDraft) || '-' },
{ label: '单支重量', value: resolvedEditorDraft.weight || '-' },
{ label: '每吨支数', value: resolvedEditorDraft.pieceCount || '-' },
{ label: '当前每米单价', value: resolvedEditorDraft.unitPrice || '-' },
{
label: '单支重量',
value: formatSteelFrameMetricWithUnit(resolvedEditorDraft.weight, 3, ' kg'),
},
{
label: '每吨支数',
value: formatSteelFrameMetricWithUnit(resolvedEditorDraft.pieceCount, 1, ' 支'),
},
{
label: '当前每米单价',
value: formatSteelFrameMetricWithUnit(resolvedEditorDraft.unitPrice, 2, ' /米', '¥ '),
},
].map((field) => (
<div
key={field.label}
className="rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/60 p-3"
className="rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/60 px-3 py-2.5"
>
<p className="text-xs text-apple-label-secondary">{field.label}</p>
<p className="mt-2 text-sm font-apple-medium text-apple-label-primary">{field.value}</p>
<p className="mt-1 text-sm font-apple-medium text-apple-label-primary">{field.value}</p>
</div>
))}
</div>
<div className="mt-4">
<Input
<div className="mt-3">
<SteelFrameField
label="备注"
value={editorDraft.remark}
onChange={(event) => handleDraftFieldChange('remark', event.target.value)}
@ -503,68 +573,107 @@ export default function SteelFrameLibraryPanel({
</div>
{panelMessage ? (
<p className="mt-4 text-sm text-apple-blue">{panelMessage}</p>
<p className="mt-3 text-xs text-apple-blue">{panelMessage}</p>
) : duplicateItem ? (
<p className="mt-4 text-sm text-[#d94b45]">
<p className="mt-3 text-xs text-[#d94b45]">
当前规格和左侧“{buildSteelFrameSpecTitle(duplicateItem)}”重复了。
</p>
) : null}
<div className="mt-4 rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/50 p-4">
<p className="text-sm font-apple-semibold text-apple-label-primary">计算规则说明</p>
<div className="mt-2 space-y-2 text-sm leading-6 text-apple-label-secondary">
<p>1. 方管、扁管按矩形管公式自动算重量;圆管按圆管公式自动算重量。</p>
<p>2. 每吨支数 = 1000 ÷ 单支重量。</p>
<p>3. 每米单价 = 单支重量 × 每吨钢管参考价 ÷ 6000。</p>
<p>4. 保存后,这条规格会直接参与钢架 BOM 的自动匹配和自动带价。</p>
</div>
<div className="mt-3 rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/50">
<button
type="button"
className="flex w-full items-center justify-between gap-2 px-3 py-2.5 text-left text-xs font-apple-semibold text-apple-label-primary"
onClick={() => setRulesExpanded((current) => !current)}
>
<span>计算规则说明</span>
<ChevronDown
className={`h-4 w-4 text-apple-label-secondary transition ${
rulesExpanded ? 'rotate-180' : ''
}`}
/>
</button>
{rulesExpanded ? (
<div className="border-t border-apple-separator px-3 py-2.5">
<div className="space-y-1.5 text-xs leading-5 text-apple-label-secondary">
<p>1. 方管、扁管按矩形管公式自动算重量;圆管按圆管公式自动算重量。</p>
<p>2. 每吨支数 = 1000 ÷ 单支重量。</p>
<p>3. 每米单价 = 单支重量 × 每吨钢管参考价 ÷ 6000。</p>
<p>4. 保存后,这条规格会直接参与钢架 BOM 的自动匹配和自动带价。</p>
</div>
</div>
) : null}
</div>
</div>
) : selectedItem ? (
<div className="flex h-full flex-col">
<div className="min-h-0 overflow-y-auto pr-1">
<div>
<h4 className="text-lg font-apple-semibold text-apple-label-primary">
<h4 className="text-base font-apple-semibold text-apple-label-primary">
{buildSteelFrameSpecTitle(selectedItem)}
</h4>
<p className="mt-1 text-sm text-apple-label-secondary">
<p className="mt-1 text-xs leading-5 text-apple-label-secondary">
{canEditItems
? '这是当前已保存的钢架规格。需要新尺寸时,直接点上方新增按钮即可。'
? '当前已保存规格。需要新尺寸时,点上方新增按钮。'
: '这是当前已保存的钢架规格。当前账号只能查看。'}
</p>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<div className="mt-3 grid grid-cols-2 gap-2">
{[
{ label: '名称 / 管型', value: selectedItem.name || '-' },
{ label: '系统匹配名', value: buildSteelFrameMatchLabel(selectedItem) || '-' },
{ label: '长', value: selectedItem.length || '-' },
{ label: '宽', value: selectedItem.width || '-' },
{ label: '壁厚', value: selectedItem.wallThickness || '-' },
{ label: '单支重量', value: selectedItem.weight || '-' },
{ label: '每吨支数', value: selectedItem.pieceCount || '-' },
{ label: '当前每米单价', value: selectedItem.unitPrice || '-' },
{
label: '单支重量',
value: formatSteelFrameMetricWithUnit(selectedItem.weight, 3, ' kg'),
},
{
label: '每吨支数',
value: formatSteelFrameMetricWithUnit(selectedItem.pieceCount, 1, ' 支'),
},
{
label: '当前每米单价',
value: formatSteelFrameMetricWithUnit(selectedItem.unitPrice, 2, ' /米', '¥ '),
},
].map((field) => (
<div
key={field.label}
className="rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/60 p-3"
className="rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/60 px-3 py-2.5"
>
<p className="text-xs text-apple-label-secondary">{field.label}</p>
<p className="mt-2 text-sm font-apple-medium text-apple-label-primary">{field.value}</p>
<p className="mt-1 text-sm font-apple-medium text-apple-label-primary">{field.value}</p>
</div>
))}
</div>
<div className="mt-4 rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/50 p-4">
<p className="text-sm font-apple-semibold text-apple-label-primary">当前规则说明</p>
<div className="mt-2 space-y-2 text-sm leading-6 text-apple-label-secondary">
<p>1. 你可以继续用右上角按钮补录新的钢架规格。</p>
<p>2. 右侧看到的单价会随每吨钢管参考价变化而自动刷新。</p>
<p>3. 钢架 BOM 录入时,只要材质名称写成标准匹配名,系统就会自动带出单价和重量。</p>
</div>
<div className="mt-3 rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/50">
<button
type="button"
className="flex w-full items-center justify-between gap-2 px-3 py-2.5 text-left text-xs font-apple-semibold text-apple-label-primary"
onClick={() => setRulesExpanded((current) => !current)}
>
<span>当前规则说明</span>
<ChevronDown
className={`h-4 w-4 text-apple-label-secondary transition ${
rulesExpanded ? 'rotate-180' : ''
}`}
/>
</button>
{rulesExpanded ? (
<div className="border-t border-apple-separator px-3 py-2.5">
<div className="space-y-1.5 text-xs leading-5 text-apple-label-secondary">
<p>1. 你可以继续用右上角按钮补录新的钢架规格。</p>
<p>2. 右侧看到的单价会随每吨钢管参考价变化而自动刷新。</p>
<p>3. 钢架 BOM 录入时,只要材质名称写成标准匹配名,系统就会自动带出单价和重量。</p>
</div>
</div>
) : null}
</div>
</div>
) : (
<div className="flex h-full min-h-[520px] items-center justify-center rounded-apple-lg border border-dashed border-apple-separator bg-apple-bg-secondary/40 p-6 text-center text-sm leading-7 text-apple-label-secondary">
<div className="flex h-full min-h-[420px] items-center justify-center rounded-apple-lg border border-dashed border-apple-separator bg-apple-bg-secondary/40 p-5 text-center text-xs leading-6 text-apple-label-secondary">
{canEditItems
? '这里已经没有系统预置规格了。请直接点上方“新增方管 / 新增扁管 / 新增圆管”开始录入。'
: '这里还没有钢架规格。当前账号只能查看。'}

View File

@ -96,6 +96,11 @@ jest.mock("@/components/platform/ModelPreviewPanel", () => ({
nodeName: string;
material: { id?: string; textureRepeatU?: number };
}>;
holes?: Array<unknown>;
activeHolePanelIds?: string[];
panelNodeNameByPanelId?: Record<string, string>;
panelLabelByPanelId?: Record<string, string>;
showHoleHighlights?: boolean;
selectedNodeName?: string | null;
selectedNodeNames?: string[];
enableTranslateGizmo?: boolean;
@ -2034,6 +2039,66 @@ describe("NonIntegratedDetailClient", () => {
expect(screen.getByLabelText("商品企划")).toHaveValue("企划B");
});
it("saves current detail immediately with Ctrl+S and shows the autosave toast", async () => {
const actor = userEvent.setup();
saveNonIntegratedProduct(
createProductRecord({
id: "SZ0230-ctrl-s-save",
spu: "KP-SZ0230",
}),
);
render(
<NonIntegratedDetailClient mode="edit" productId="SZ0230-ctrl-s-save" />,
);
await actor.click(screen.getByTestId("document-workspace-trigger-开品参考"));
await actor.clear(screen.getByLabelText("开品理由"));
await actor.type(screen.getByLabelText("开品理由"), "Ctrl+S保存内容");
fireEvent.keyDown(window, { key: "s", code: "KeyS", ctrlKey: true });
expect(await screen.findByText("正在保存当前内容...")).toBeInTheDocument();
expect(await screen.findByText("已保存当前内容")).toBeInTheDocument();
await waitFor(() => {
const savedProduct = getNonIntegratedProductById("SZ0230-ctrl-s-save");
expect(savedProduct?.openingReference.reason).toBe("Ctrl+S保存内容");
});
});
it("shows a clear Ctrl+S failure toast when current detail save fails", async () => {
const actor = userEvent.setup();
const product = createProductRecord({
id: "SZ0230-ctrl-s-save-failure",
spu: "KP-SZ0230",
previewImage: "",
previewImageName: "",
});
(globalThis as NonIntegratedRemoteAssetStorageTestGlobal)
.__NON_INTEGRATED_REMOTE_ASSET_STORAGE_TEST_OVERRIDE__ = true;
mockedSaveSharedNonIntegratedProduct.mockRejectedValue(
new Error("服务暂时不可用"),
);
render(
<NonIntegratedDetailClient
mode="edit"
productId="SZ0230-ctrl-s-save-failure"
initialProduct={product}
/>,
);
await actor.click(screen.getByTestId("document-workspace-trigger-开品参考"));
await actor.type(screen.getByLabelText("开品理由"), "失败时不应保存");
fireEvent.keyDown(window, { key: "s", code: "KeyS", ctrlKey: true });
expect(await screen.findByText("正在保存当前内容...")).toBeInTheDocument();
expect(
await screen.findByText("保存失败,当前内容未保存:服务暂时不可用"),
).toBeInTheDocument();
});
it("shows progress while batch downloading selected files", async () => {
const actor = userEvent.setup();
const productPhotoCategory = NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES[7];
@ -3934,6 +3999,62 @@ describe("NonIntegratedDetailClient", () => {
).not.toBeInTheDocument();
});
it("flushes pending planning layout autosave when space visual users leave before debounce", async () => {
const actor = userEvent.setup();
const planningLayoutCategory = NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES[5];
saveNonIntegratedProduct(
createProductRecord({
id: "STOCK001-space-layout-autosave-flush",
spu: "STOCK001",
processTemplate: "存量商品",
visualPlanning: "希文",
skuItems: [
createSkuItem({
id: "stock-space-layout-autosave-flush-sku-1",
model: "STOCKK01",
skuCode: "STOCK00101",
barcode: "STOCK00101V01",
specName: "存量SKU",
salesStatus: "销售",
}),
],
}),
);
const { unmount } = render(
<NonIntegratedDetailClient
mode="edit"
productId="STOCK001-space-layout-autosave-flush"
currentUserRole="USER"
currentUserName="希文"
currentUserBusinessRoles={["PLANNING"]}
currentUserDepartmentPaths={[
"江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部",
]}
/>,
);
await actor.click(
screen.getByTestId(`document-workspace-trigger-${planningLayoutCategory}`),
);
fireEvent.change(
screen.getByTestId(
"planning-layout-priceBand-stock-space-layout-autosave-flush-sku-1",
),
{ target: { value: "700-800" } },
);
unmount();
await waitFor(() => {
const savedProduct = getNonIntegratedProductById(
"STOCK001-space-layout-autosave-flush",
);
expect(savedProduct?.planningLayoutRows?.[0]?.priceBand).toBe("700-800");
});
});
it("hides redundant fields from the product detail summary", async () => {
const actor = userEvent.setup();
@ -4654,7 +4775,7 @@ describe("NonIntegratedDetailClient", () => {
const workspace = screen.getByTestId("sample-measurement-workspace");
expect(workspace).toBeInTheDocument();
expect(panel).toHaveTextContent("型号");
expect(panel).toHaveTextContent("规格码");
expect(panel).toHaveTextContent("货号");
expect(panel).toHaveTextContent("套件量");
expect(panel).toHaveTextContent("供应商名称");
expect(panel).toHaveTextContent("条码规格名称");
@ -5434,7 +5555,7 @@ describe("NonIntegratedDetailClient", () => {
const pricingWorkspace = screen.getByTestId("product-pricing-workspace");
expect(pricingWorkspace).toBeInTheDocument();
expect(pricingWorkspace).toHaveTextContent("规格码");
expect(pricingWorkspace).toHaveTextContent("货号");
expect(pricingWorkspace).toHaveTextContent("条码");
expect(pricingWorkspace).toHaveTextContent("成本价");
expect(pricingWorkspace).toHaveTextContent("是否录入系统");
@ -7208,6 +7329,111 @@ describe("NonIntegratedDetailClient", () => {
);
});
it("passes SKU hole points into 3D preview and snaps moved nodes to nearby holes", async () => {
window.localStorage.setItem(
NON_INTEGRATED_STORAGE_KEY,
JSON.stringify([
createProductRecord({
id: "DNZ0001-3d-hole-snap",
skuItems: [
createSkuItem({
id: "sku-3d-hole-snap",
model: "SZ0230K01",
barcode: "SZ023001N1280V01",
model3dFileName: "model.glb",
model3dDataUrl: "data:model/gltf-binary;base64,bW9kZWw=",
holePoints: [
{
id: "leg-origin",
panelId: "leg",
panelName: "桌腿",
position: [0, 0, 0],
normal: [0, 1, 0],
diameter: 6,
depth: 10,
holeType: "mount-point",
sourceNodeName: "leg-node",
},
{
id: "desktop-hole",
panelId: "desktop",
panelName: "桌面",
position: [100, 0, 0],
normal: [0, 1, 0],
diameter: 6,
depth: 10,
holeType: "mount-point",
sourceNodeName: "desktop-node",
},
],
}),
],
}),
]),
);
render(
<NonIntegratedDetailClient
mode="edit"
productId="DNZ0001-3d-hole-snap"
/>,
);
const getProductPreviewProps = () => {
const props = [...modelPreviewPanelMock.mock.calls]
.reverse()
.map(([callProps]) => callProps as Record<string, unknown>)
.find((callProps) => callProps.title === "3D预览");
expect(props).toBeDefined();
return props!;
};
await waitFor(() =>
expect(getProductPreviewProps().modelUrl).toBeTruthy(),
);
expect(getProductPreviewProps().holes).toHaveLength(2);
act(() => {
(
getProductPreviewProps().onGizmoInteractionStart as (
nodeName: string,
) => void
)("leg-node");
(
getProductPreviewProps().onNodePositionOffsetChange as (payload: {
nodeName: string;
offset: [number, number, number];
}) => void
)({ nodeName: "leg-node", offset: [96, 0, 0] });
});
await waitFor(() =>
expect(getProductPreviewProps().nodePositionOffsets).toEqual([
{ nodeName: "leg-node", offset: [100, 0, 0] },
]),
);
expect(
(getProductPreviewProps().holes as Array<{ id: string; isHighlighted?: boolean }>)
.find((hole) => hole.id === "desktop-hole")?.isHighlighted,
).toBe(true);
act(() => {
(
getProductPreviewProps().onGizmoInteractionEnd as (
nodeName: string,
) => void
)("leg-node");
});
await waitFor(() =>
expect(getProductPreviewProps().nodePositionOffsets).toEqual([
{ nodeName: "leg-node", offset: [100, 0, 0] },
]),
);
});
it("applies SKU material to the whole model and supports node material overrides", async () => {
const actor = userEvent.setup();
const walnutFinish = createMaterialFinish({

View File

@ -256,13 +256,21 @@ export async function getSharedNonIntegratedProductById(id: string) {
return data.product
}
export async function saveSharedNonIntegratedProduct(product: NonIntegratedProductRecord) {
export async function saveSharedNonIntegratedProduct(
product: NonIntegratedProductRecord,
options: { workspaceCategory?: string | null } = {},
) {
const response = await fetchWithTimeout(`/api/intake/non-integrated/${encodeURIComponent(product.id)}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ product }),
body: JSON.stringify({
product,
...(options.workspaceCategory
? { workspaceCategory: options.workspaceCategory }
: {}),
}),
})
const data = await readApiData<{ product: NonIntegratedProductRecord }>(response)

View File

@ -367,6 +367,7 @@ export type NonIntegratedSkuItem = {
model3dNodeMaterialIds?: Record<string, string>;
model3dUvConfig?: AssetVariantUvConfig | null;
componentDetails?: NonIntegratedSkuComponentDetail[];
holePoints?: NonIntegratedSkuHolePoint[];
source?: string;
sourceTable?: string;
sourceSkuId?: string;
@ -381,6 +382,18 @@ export type NonIntegratedSkuComponentDetail = {
assetType: string;
};
export type NonIntegratedSkuHolePoint = {
id: string;
panelId: string;
panelName: string;
position: [number, number, number];
normal: [number, number, number];
diameter: number;
depth: number;
holeType: string;
sourceNodeName?: string;
};
export type NonIntegratedFactoryRow = {
skuId: string;
confirmed: boolean;
@ -1210,6 +1223,67 @@ export function normalizeNonIntegratedSkuComponentDetails(
.filter((item): item is NonIntegratedSkuComponentDetail => item !== null);
}
function normalizeNumberTuple3(value: unknown): [number, number, number] | null {
if (!Array.isArray(value) || value.length < 3) return null;
const tuple = value.slice(0, 3);
if (
!tuple.every((item) => typeof item === "number" && Number.isFinite(item))
) {
return null;
}
return tuple as [number, number, number];
}
function normalizeSkuHolePoint(value: unknown): NonIntegratedSkuHolePoint | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const item = value as Partial<NonIntegratedSkuHolePoint>;
const position = normalizeNumberTuple3(item.position);
if (!position) return null;
const id = normalizeString(item.id).trim();
const panelId = normalizeString(item.panelId).trim();
const sourceNodeName = normalizeString(item.sourceNodeName).trim();
const stableId =
id ||
[
panelId || sourceNodeName || "model",
position.map((entry) => entry.toFixed(3)).join("_"),
].join(":");
return {
id: stableId,
panelId: panelId || sourceNodeName || "model-root",
panelName: normalizeString(item.panelName).trim(),
position,
normal: normalizeNumberTuple3(item.normal) ?? [0, 1, 0],
diameter:
typeof item.diameter === "number" && Number.isFinite(item.diameter)
? item.diameter
: 5,
depth:
typeof item.depth === "number" && Number.isFinite(item.depth)
? item.depth
: 10,
holeType: normalizeString(item.holeType).trim() || "mount-point",
...(sourceNodeName ? { sourceNodeName } : {}),
};
}
export function normalizeNonIntegratedSkuHolePoints(
value: unknown,
): NonIntegratedSkuHolePoint[] {
if (!Array.isArray(value)) return [];
return value
.map((item) => normalizeSkuHolePoint(item))
.filter((item): item is NonIntegratedSkuHolePoint => item !== null);
}
function normalizeSkuItem(
item: Partial<NonIntegratedSkuItem>,
): NonIntegratedSkuItem | null {
@ -1267,6 +1341,7 @@ function normalizeSkuItem(
componentDetails: normalizeNonIntegratedSkuComponentDetails(
item.componentDetails,
),
holePoints: normalizeNonIntegratedSkuHolePoints(item.holePoints),
source: normalizeString(item.source),
sourceTable: normalizeString(item.sourceTable),
sourceSkuId: normalizeString(item.sourceSkuId),

View File

@ -8,6 +8,7 @@ import { materialService } from '@/server/services/material.service'
import { warehouseService } from '@/server/services/warehouse.service'
import IntakeClient from './IntakeClient'
import { readPreSkuComponentDetails, readPreSkuSkuFields } from './seriesComponentDetails'
import type { NonIntegratedSkuHolePoint } from './non-integrated/nonIntegratedStorage'
function readPreSkuTextField(
preSku: {
@ -36,6 +37,119 @@ function mapWorkbenchStatus(status: string) {
return '待处理'
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function readFiniteNumber(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function readVector3Tuple(value: unknown): [number, number, number] | null {
if (!Array.isArray(value) || value.length < 3) return null
const x = readFiniteNumber(value[0])
const y = readFiniteNumber(value[1])
const z = readFiniteNumber(value[2])
if (x === null || y === null || z === null) return null
return [x, y, z]
}
function readRecordArray(value: unknown) {
return Array.isArray(value)
? value.filter((item): item is Record<string, unknown> => isPlainRecord(item))
: []
}
function readTrimmedString(value: unknown) {
return typeof value === 'string' ? value.trim() : ''
}
function buildSkuHolePointFromRecord(params: {
record: Record<string, unknown>
fallbackId: string
panelId: string
panelName: string
sourceNodeName: string
}): NonIntegratedSkuHolePoint | null {
const position = readVector3Tuple(params.record.position)
if (!position) return null
return {
id: readTrimmedString(params.record.id) || params.fallbackId,
panelId: readTrimmedString(params.record.panelId) || params.panelId,
panelName: readTrimmedString(params.record.panelName) || params.panelName,
position,
normal: readVector3Tuple(params.record.normal) ?? [0, 1, 0],
diameter: readFiniteNumber(params.record.diameter) ?? readFiniteNumber(params.record.width) ?? 5,
depth: readFiniteNumber(params.record.depth) ?? 10,
holeType:
readTrimmedString(params.record.holeType) ||
readTrimmedString(params.record.type) ||
'mount-point',
sourceNodeName: params.sourceNodeName,
}
}
function buildSkuHolePointsFromVariantSelection(preSku: {
variantSelection: {
slots?: Array<{
variantId: string
code: string
name: string
mountPoints: unknown[]
assemblyBasis: Record<string, unknown> | null
}>
hardwareSelections?: Array<{
variantId: string
code: string
name: string
mountPoints: unknown[]
assemblyBasis: Record<string, unknown> | null
}>
}
}): NonIntegratedSkuHolePoint[] {
const entries = [
...(preSku.variantSelection.slots ?? []),
...(preSku.variantSelection.hardwareSelections ?? []),
]
return entries.flatMap((entry, entryIndex) => {
const sourceNodeName = entry.code || entry.name || `node-${entryIndex + 1}`
const panelId = entry.variantId || sourceNodeName
const panelName = entry.name || sourceNodeName
const assemblyBasis = isPlainRecord(entry.assemblyBasis) ? entry.assemblyBasis : null
const holes = readRecordArray(assemblyBasis?.holes)
.map((record, holeIndex) =>
buildSkuHolePointFromRecord({
record,
fallbackId: `${panelId}:hole-${holeIndex + 1}`,
panelId,
panelName,
sourceNodeName,
}),
)
.filter((item): item is NonIntegratedSkuHolePoint => item !== null)
if (holes.length > 0) return holes
return readRecordArray(entry.mountPoints)
.map((record, mountIndex) =>
buildSkuHolePointFromRecord({
record: {
...record,
id: readTrimmedString(record.id) || readTrimmedString(record.name) || `mount-${mountIndex + 1}`,
holeType: readTrimmedString(record.type) || 'mount-point',
},
fallbackId: `${panelId}:mount-${mountIndex + 1}`,
panelId,
panelName,
sourceNodeName,
}),
)
.filter((item): item is NonIntegratedSkuHolePoint => item !== null)
})
}
function isDraftGroupEligibleForIntake(status: string) {
return status === 'PACKAGING' || status === 'READY_FOR_INTAKE'
}
@ -127,6 +241,7 @@ export default async function IntakePage({
status: mapWorkbenchStatus(preSku.status),
componentCodes: preSku.componentCodes ?? [],
componentDetails: readPreSkuComponentDetails(preSku),
holePoints: buildSkuHolePointsFromVariantSelection(preSku),
skuFields: readPreSkuSkuFields(preSku),
})),
)

View File

@ -38,7 +38,7 @@ describe('business-role-inference', () => {
inferBusinessRolesFromDepartmentPath(
'江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部',
).businessRoles,
).toEqual(['DESIGN'])
).toEqual(['PLANNING'])
expect(
inferBusinessRolesFromDepartmentPath(
@ -46,4 +46,12 @@ describe('business-role-inference', () => {
).businessRoles,
).toEqual(['PLANNING'])
})
it('品牌中心空间视觉部按主线口径归企划', () => {
const result = inferBusinessRolesFromDepartmentPath(
'江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部',
)
expect(result.businessRoles).toEqual(['PLANNING'])
expect(result.reason).toContain('企划')
})
})

View File

@ -133,10 +133,10 @@ export function inferBusinessRolesFromDepartmentPath(
if (root === '品牌中心' && includesSegment(parts, /空间视觉部/)) {
return {
businessRoles: ['DESIGN'],
businessRoles: ['PLANNING'],
excluded: false,
shouldWarnWhenEmpty: true,
reason: '品牌中心空间视觉部默认归设计',
reason: '品牌中心空间视觉部默认归企划',
}
}

View File

@ -16,6 +16,18 @@ describe('intake node permission settings', () => {
}),
).toEqual(['brandCenter', 'space'])
expect(
resolveIntakeNodePermissionSubjectKeys({
role: 'USER',
businessRoles: ['PLANNING'],
department: {
name: '空间视觉部',
fullPath: null,
},
departmentPaths: [],
}),
).toEqual(['space'])
expect(
resolveIntakeNodePermissionSubjectKeys({
role: 'USER',

View File

@ -615,7 +615,7 @@ function isBrandPlanningDepartmentPath(path: string) {
}
function isSpaceVisualDepartmentPath(path: string) {
return /品牌中心/.test(path) && /空间视觉部/.test(path);
return /空间视觉部/.test(path) && (!/>/.test(path) || /品牌中心/.test(path));
}
function isBrandCenterDepartmentPath(path: string) {

View File

@ -1,7 +1,9 @@
import type { NonIntegratedBomRow } from '@/app/intake/non-integrated/nonIntegratedStorage'
import {
buildNonIntegratedBomFileName,
createNonIntegratedCuttingBomWorkbook,
createNonIntegratedBomWorkbook,
createNonIntegratedPanelBomWorkbook,
} from '@/lib/non-integrated-bom-export'
import { NON_INTEGRATED_BOM_PRICING_CONFIG } from '@/lib/non-integrated-bom-schema'
@ -66,6 +68,12 @@ function findRowNumber(worksheet: import('exceljs').Worksheet, matcher: string)
return matchedRowNumber
}
function getFormulaText(value: unknown) {
return value && typeof value === 'object' && 'formula' in value
? String((value as { formula?: unknown }).formula ?? '')
: ''
}
describe('non-integrated-bom-export', () => {
it('exports grouped bom sections with subtotals, edge rows, and total price', async () => {
const blob = await createNonIntegratedBomWorkbook({
@ -118,33 +126,34 @@ describe('non-integrated-bom-export', () => {
const worksheet = workbook.getWorksheet('细节资料')
expect(worksheet).toBeDefined()
expect(worksheet?.getCell('A1').value).toBe('细节资料清单')
expect(worksheet?.getCell('B2').value).toBe('SZ0230')
expect(worksheet?.getCell('F2').value).toBe('K01')
expect(worksheet?.getCell('J2').value).toBe('SZ023001')
expect(worksheet?.getCell('Q4').value).toBe('¥ 36.04')
expect(worksheet?.getCell('A1').value).toBe('分类')
expect(worksheet?.getCell('B1').value).toBe('编号')
expect(worksheet?.getCell('A2').value).toBe('板材')
expect(worksheet?.getCell('B2').value).toBe('PJ001')
expect(worksheet?.getCell('K2').value).toBe('0.84')
expect(worksheet?.getCell('O2').value).toBe('25.2')
const boardTitleRow = findRowNumber(worksheet!, '板材(2 行)')
expect(boardTitleRow).toBeGreaterThan(0)
expect(worksheet?.getCell(boardTitleRow + 1, 1).value).toBe('编号')
expect(worksheet?.getCell(boardTitleRow + 2, 1).value).toBe('PJ001')
expect(worksheet?.getCell(boardTitleRow + 2, 13).value).toBe('0.84')
expect(worksheet?.getCell(boardTitleRow + 2, 17).value).toBe('25.2')
const edgeRow = findRowNumber(worksheet!, '12E1级刨花板的封边+胶水')
expect(edgeRow).toBeGreaterThan(0)
expect(worksheet?.getCell(edgeRow, 1).value).toBe('封边')
expect(worksheet?.getCell(edgeRow, 10).value).toBe('1.05')
expect(worksheet?.getCell(edgeRow, 12).value).toBe('4')
expect(worksheet?.getCell(edgeRow, 13).value).toBe('0.2')
expect(worksheet?.getCell(edgeRow, 15).value).toBe('0.84')
expect(worksheet?.getCell(boardTitleRow + 3, 2).value).toBe('封边+胶水')
expect(worksheet?.getCell(boardTitleRow + 3, 12).value).toBe('1.05')
expect(worksheet?.getCell(boardTitleRow + 3, 14).value).toBe('4')
expect(worksheet?.getCell(boardTitleRow + 3, 15).value).toBe('0.2')
expect(worksheet?.getCell(boardTitleRow + 3, 17).value).toBe('0.84')
expect(worksheet?.getCell(boardTitleRow + 4, 1).value).toBe('板材小计')
expect(worksheet?.getCell(boardTitleRow + 4, 17).value).toBe('¥ 26.04')
const accessoryRow = findRowNumber(worksheet!, '螺丝包')
expect(accessoryRow).toBeGreaterThan(edgeRow)
expect(worksheet?.getCell(accessoryRow, 1).value).toBe('配件')
expect(worksheet?.getCell(accessoryRow, 2).value).toBe('PJ009')
expect(worksheet?.getCell(accessoryRow, 9).value).toBe('2')
expect(worksheet?.getCell(accessoryRow, 13).value).toBe('5')
expect(worksheet?.getCell(accessoryRow, 14).value).toBe('0.2')
expect(worksheet?.getCell(accessoryRow, 15).value).toBe('10')
expect(worksheet?.getCell(accessoryRow, 16).value).toBe('配件备注')
const accessoryTitleRow = findRowNumber(worksheet!, '配件(1 行)')
expect(accessoryTitleRow).toBeGreaterThan(boardTitleRow)
expect(worksheet?.getCell(accessoryTitleRow + 2, 1).value).toBe('PJ009')
expect(worksheet?.getCell(accessoryTitleRow + 2, 13).value).toBe('2')
expect(worksheet?.getCell(accessoryTitleRow + 2, 17).value).toBe('10')
expect(worksheet?.getCell(accessoryTitleRow + 2, 18).value).toBe('配件备注')
const totalRow = findRowNumber(worksheet!, '总材料费')
expect(totalRow).toBeGreaterThan(accessoryRow)
expect(worksheet?.getCell(totalRow, 15).value).toBe('36.04')
})
it('exports manual synthetic unit-price overrides', async () => {
@ -285,13 +294,13 @@ describe('non-integrated-bom-export', () => {
const workbook = await loadWorkbookFromBlob(blob)
const worksheet = workbook.worksheets[0]
const accessoryTitleRow = findRowNumber(worksheet!, '配件(1 行)')
const accessoryRow = findRowNumber(worksheet!, '测试配件')
expect(accessoryTitleRow).toBeGreaterThan(0)
expect(worksheet?.getCell(accessoryTitleRow + 2, 10).value).toBe('')
expect(worksheet?.getCell(accessoryTitleRow + 2, 15).value).toBe('8')
expect(worksheet?.getCell(accessoryTitleRow + 2, 16).value).toBe('')
expect(worksheet?.getCell(accessoryTitleRow + 2, 17).value).toBe('24')
expect(accessoryRow).toBeGreaterThan(0)
expect(worksheet?.getCell(accessoryRow, 8).value ?? '').toBe('')
expect(worksheet?.getCell(accessoryRow, 13).value).toBe('8')
expect(worksheet?.getCell(accessoryRow, 14).value ?? '').toBe('')
expect(worksheet?.getCell(accessoryRow, 15).value).toBe('24')
} finally {
const configIndex = NON_INTEGRATED_BOM_PRICING_CONFIG.findIndex(
(item) => item.id === 'accessory-test-config',
@ -302,6 +311,85 @@ describe('non-integrated-bom-export', () => {
}
})
it('exports panel bom with steel length in column E', async () => {
const blob = await createNonIntegratedPanelBomWorkbook({
spu: 'CJ0034',
model: 'K01',
skuCode: 'CJ003401',
sections: [
{
category: '钢架',
rows: [
createBomRow({
category: '钢架',
partCode: 'G1',
partName: '15*15*0.7方管',
material: '15*15*0.7方管',
length: '1440',
width: '999',
unit: '根',
quantity: '2',
factor: '1.01',
unitPrice: '2.3',
}),
],
},
],
})
const workbook = await loadWorkbookFromBlob(blob)
const worksheet = workbook.worksheets[0]
const steelRow = findRowNumber(worksheet!, '15*15*0.7方管')
expect(steelRow).toBeGreaterThan(0)
expect(worksheet?.getCell(steelRow, 1).value).toBe('钢材')
expect(worksheet?.getCell(steelRow, 5).value).toBe(1440)
expect(worksheet?.getCell(steelRow, 6).value).toBeNull()
expect(getFormulaText(worksheet?.getCell(steelRow, 12).value)).toBe(
`IF(OR(E${steelRow}="",J${steelRow}=""),"",E${steelRow}*J${steelRow}/1000)`,
)
expect(getFormulaText(worksheet?.getCell(steelRow, 15).value)).toBe(
`IF(OR(K${steelRow}="",L${steelRow}="",N${steelRow}=""),"",L${steelRow}*N${steelRow}*K${steelRow})`,
)
})
it('exports cutting bom workbook using the cutting template sections', async () => {
const blob = await createNonIntegratedCuttingBomWorkbook({
spu: 'SM001',
model: 'K01',
sections: [
{
category: '板材',
rows: [
createBomRow({
category: '板材',
partCode: 'B1',
partName: '桌面',
material: '橡胶木',
length: '1200',
width: '600',
quantity: '1',
specSize: '18',
}),
],
},
],
})
const workbook = await loadWorkbookFromBlob(blob)
const worksheet = workbook.worksheets[0]
const boardRow = findRowNumber(worksheet!, '桌面')
expect(worksheet?.getCell('A1').value).toBe('产品材料明细表')
expect(boardRow).toBeGreaterThan(0)
expect(worksheet?.getCell(boardRow, 5).value).toBe(1200)
expect(worksheet?.getCell(boardRow, 6).value).toBe(600)
expect(worksheet?.getCell(boardRow, 7).value).toBe(18)
expect(getFormulaText(worksheet?.getCell(boardRow, 10).value)).toBe(
`IF(OR(E${boardRow}="",F${boardRow}="",G${boardRow}="",H${boardRow}=""),"",E${boardRow}*F${boardRow}*G${boardRow}*H${boardRow}/1000000000)`,
)
})
it('builds a readable file name from current spu and model', () => {
expect(
buildNonIntegratedBomFileName({
@ -310,5 +398,15 @@ describe('non-integrated-bom-export', () => {
skuCode: 'SZ023001',
}),
).toBe('SZ0230-K01-细节资料.xlsx')
expect(
buildNonIntegratedBomFileName(
{
spu: 'SZ0230',
model: 'K01',
skuCode: 'SZ023001',
},
'panel',
),
).toBe('SZ0230-K01-板式BOM.xlsx')
})
})

View File

@ -49,6 +49,8 @@ type NonIntegratedBomExportBlock = {
treatAsLabor?: boolean
}
type NonIntegratedBomWorkbookKind = 'detail' | 'panel' | 'cutting'
type NonIntegratedBomWorkbookInput = {
sections: NonIntegratedBomExportSection[]
spu?: string
@ -173,6 +175,11 @@ function normalizeFileNamePart(value = '') {
return value.trim().replace(/[\\/:*?"<>|]/g, '-')
}
function normalizeSheetName(value = '') {
const normalized = normalizeFileNamePart(value).replace(/[\[\]]/g, '-').trim()
return (normalized || 'BOM').slice(0, 31)
}
function getBomExportBaseName({
spu = '',
model = '',
@ -263,6 +270,446 @@ function getBomRowsSubtotal(rows: NonIntegratedBomDisplayRow[]) {
return rows.reduce((sum, row) => sum + (getNonIntegratedBomDisplayRowTotalPrice(row) ?? 0), 0)
}
function parseBomExportNumber(value?: string | number | null) {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value !== 'string') return null
const normalized = value.trim().replace(/,/g, '')
if (!normalized) return null
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : null
}
function getBomText(value?: string | number | null) {
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : ''
return value?.trim() ?? ''
}
function getBomNumberOrText(value?: string | number | null) {
const parsed = parseBomExportNumber(value)
if (parsed !== null) return parsed
return getBomText(value)
}
function getBomDisplayName(row: NonIntegratedBomDisplayRow) {
if (
isNonIntegratedBomSyntheticRow(row) &&
(row.syntheticKind === 'edge-banding' ||
row.syntheticKind === 'wood-paint' ||
row.syntheticKind === 'spray')
) {
return row.material || row.partName
}
return row.partName || row.material || row.structure
}
function getBomRemark(row: NonIntegratedBomDisplayRow) {
return [row.remark?.trim(), row.process?.trim()].filter(Boolean).join(';')
}
function setCellValue(
worksheet: import('exceljs').Worksheet,
rowNumber: number,
columnNumber: number,
value: string | number | null | { formula: string },
) {
const cell = worksheet.getCell(rowNumber, columnNumber)
cell.value = value === '' ? null : value
return cell
}
function styleBomTemplateRange(
worksheet: import('exceljs').Worksheet,
rowNumber: number,
fromColumn: number,
toColumn: number,
options: {
bold?: boolean
align?: 'left' | 'center' | 'right'
fillColor?: string
} = {},
) {
for (let column = fromColumn; column <= toColumn; column += 1) {
const cell = worksheet.getCell(rowNumber, column)
applyCellStyle(cell, {
bold: options.bold,
align: options.align ?? (column >= 10 && column <= 15 ? 'right' : 'center'),
fillColor: options.fillColor,
})
cell.alignment = {
...cell.alignment,
wrapText: false,
}
}
}
function setBomTemplateNumberFormat(
worksheet: import('exceljs').Worksheet,
rowNumber: number,
columns: number[],
numFmt = '0.###',
) {
columns.forEach((column) => {
worksheet.getCell(rowNumber, column).numFmt = numFmt
})
}
function setPanelBomBaseRowValues(
worksheet: import('exceljs').Worksheet,
rowNumber: number,
values: Partial<Record<number, string | number | null | { formula: string }>>,
) {
for (let column = 1; column <= 16; column += 1) {
setCellValue(worksheet, rowNumber, column, values[column] ?? '')
}
styleBomTemplateRange(worksheet, rowNumber, 1, 16)
setBomTemplateNumberFormat(worksheet, rowNumber, [5, 6, 7, 8, 10, 11, 12, 13, 14, 15])
}
function appendPanelBomSubtotalRow(
worksheet: import('exceljs').Worksheet,
rowNumber: number,
subtotalStartRow: number,
subtotalEndRow: number,
) {
setPanelBomBaseRowValues(worksheet, rowNumber, {
1: '小计',
15:
subtotalEndRow >= subtotalStartRow
? { formula: `SUM(O${subtotalStartRow}:O${subtotalEndRow})` }
: 0,
})
styleBomTemplateRange(worksheet, rowNumber, 1, 16, {
bold: true,
fillColor: 'FFF8FAFC',
})
}
function appendPanelBomDisplayRow(
worksheet: import('exceljs').Worksheet,
rowNumber: number,
row: NonIntegratedBomDisplayRow,
mode:
| 'board'
| 'edge'
| 'paint'
| 'steel'
| 'spray'
| 'accessory'
| 'foam'
| 'foam-corner'
| 'carton'
| 'package'
| 'labor',
categoryLabel: string,
options: { steelStartRow?: number; steelEndRow?: number } = {},
) {
const factor = getBomNumberOrText(row.factor || '1')
const unitPrice = getBomNumberOrText(row.unitPrice)
const quantity = getBomNumberOrText(row.quantity)
const pricingQuantity = getNonIntegratedBomDisplayRowPricingQuantity(row)
const edgeMeters = getNonIntegratedBomDisplayRowEdgeMeters(row)
if (mode === 'board') {
setPanelBomBaseRowValues(worksheet, rowNumber, {
1: categoryLabel,
2: getBomText(row.partCode),
3: getBomDisplayName(row),
4: getBomText(row.structure),
5: getBomNumberOrText(row.length),
6: getBomNumberOrText(row.width),
7: getBomNumberOrText(row.longEdgeCount),
8: getBomNumberOrText(row.shortEdgeCount),
9: getBomText(row.unit),
10: quantity,
11: factor,
12: { formula: `IF(OR(E${rowNumber}="",F${rowNumber}="",J${rowNumber}=""),"",E${rowNumber}*F${rowNumber}*J${rowNumber}/1000000)` },
13: { formula: `IF(OR(E${rowNumber}="",F${rowNumber}="",J${rowNumber}=""),"",(E${rowNumber}*G${rowNumber}+F${rowNumber}*H${rowNumber})*J${rowNumber}/1000)` },
14: unitPrice,
15: { formula: `IF(OR(K${rowNumber}="",L${rowNumber}="",N${rowNumber}=""),"",K${rowNumber}*L${rowNumber}*N${rowNumber})` },
16: getBomRemark(row),
})
return
}
if (mode === 'edge') {
setPanelBomBaseRowValues(worksheet, rowNumber, {
1: categoryLabel,
2: getBomText(row.partCode),
3: getBomDisplayName(row),
9: getBomText(row.unit),
11: factor,
13: edgeMeters ?? '',
14: unitPrice,
15: { formula: `IF(OR(K${rowNumber}="",M${rowNumber}="",N${rowNumber}=""),"",K${rowNumber}*M${rowNumber}*N${rowNumber})` },
16: getBomRemark(row),
})
return
}
if (mode === 'paint') {
setPanelBomBaseRowValues(worksheet, rowNumber, {
1: categoryLabel,
2: getBomText(row.partCode),
3: getBomDisplayName(row),
9: getBomText(row.unit),
11: factor,
12: pricingQuantity ?? '',
14: unitPrice,
15: { formula: `IF(OR(K${rowNumber}="",L${rowNumber}="",N${rowNumber}=""),"",K${rowNumber}*L${rowNumber}*N${rowNumber})` },
16: getBomRemark(row),
})
return
}
if (mode === 'steel') {
setPanelBomBaseRowValues(worksheet, rowNumber, {
1: categoryLabel,
2: getBomText(row.partCode),
3: getBomDisplayName(row),
4: getBomText(row.structure),
5: getBomNumberOrText(row.length),
9: getBomText(row.unit),
10: quantity,
11: factor,
12: { formula: `IF(OR(E${rowNumber}="",J${rowNumber}=""),"",E${rowNumber}*J${rowNumber}/1000)` },
14: unitPrice,
15: { formula: `IF(OR(K${rowNumber}="",L${rowNumber}="",N${rowNumber}=""),"",L${rowNumber}*N${rowNumber}*K${rowNumber})` },
16: getBomRemark(row),
})
return
}
if (mode === 'spray') {
const steelRange =
options.steelStartRow && options.steelEndRow && options.steelEndRow >= options.steelStartRow
? `SUM(L${options.steelStartRow}:L${options.steelEndRow})`
: String(pricingQuantity ?? 0)
setPanelBomBaseRowValues(worksheet, rowNumber, {
1: categoryLabel,
2: getBomText(row.partCode),
3: getBomDisplayName(row),
12: { formula: steelRange },
13: { formula: `L${rowNumber}*0.06` },
14: unitPrice,
15: { formula: `IF(OR(M${rowNumber}="",N${rowNumber}=""),"",M${rowNumber}*N${rowNumber})` },
16: getBomRemark(row),
})
return
}
if (mode === 'accessory' || mode === 'labor') {
setPanelBomBaseRowValues(worksheet, rowNumber, {
1: categoryLabel,
2: getBomText(row.partCode),
3: getBomDisplayName(row),
4: getBomText(row.structure),
9: getBomText(row.unit),
10: quantity,
14: unitPrice,
15: { formula: `IF(N${rowNumber}="","",IF(J${rowNumber}="",N${rowNumber},J${rowNumber}*N${rowNumber}))` },
16: getBomRemark(row),
})
return
}
if (mode === 'foam') {
setPanelBomBaseRowValues(worksheet, rowNumber, {
1: categoryLabel,
2: getBomText(row.partCode),
3: getBomDisplayName(row),
5: getBomNumberOrText(row.length),
6: getBomNumberOrText(row.width),
7: getBomNumberOrText(row.longEdgeCount),
8: quantity,
9: getBomText(row.unit),
12: { formula: `IF(OR(E${rowNumber}="",F${rowNumber}="",G${rowNumber}="",H${rowNumber}=""),"",E${rowNumber}*F${rowNumber}*G${rowNumber}*H${rowNumber}/1000000000)` },
14: unitPrice,
15: { formula: `IF(OR(L${rowNumber}="",N${rowNumber}=""),"",L${rowNumber}*N${rowNumber})` },
16: getBomRemark(row),
})
return
}
if (mode === 'carton') {
setPanelBomBaseRowValues(worksheet, rowNumber, {
1: categoryLabel,
2: getBomText(row.partCode),
3: getBomDisplayName(row),
5: getBomNumberOrText(row.length),
6: getBomNumberOrText(row.width),
9: getBomText(row.unit),
10: quantity,
12: { formula: `IF(OR(E${rowNumber}="",F${rowNumber}=""),"",E${rowNumber}*F${rowNumber}/1000000)` },
14: unitPrice,
15: { formula: `IF(OR(L${rowNumber}="",N${rowNumber}=""),"",L${rowNumber}*N${rowNumber})` },
16: getBomRemark(row),
})
return
}
setPanelBomBaseRowValues(worksheet, rowNumber, {
1: categoryLabel,
2: getBomText(row.partCode),
3: getBomDisplayName(row),
5: getBomNumberOrText(row.length),
9: getBomText(row.unit),
10: quantity,
12: pricingQuantity ?? '',
14: unitPrice,
15: { formula: `IF(OR(L${rowNumber}="",N${rowNumber}=""),"",L${rowNumber}*N${rowNumber})` },
16: getBomRemark(row),
})
}
function appendPanelBomBlock(
worksheet: import('exceljs').Worksheet,
startRow: number,
rows: NonIntegratedBomDisplayRow[],
label: string,
mode: Parameters<typeof appendPanelBomDisplayRow>[3],
options: { treatAsLabor?: boolean; steelStartRow?: number; steelEndRow?: number } = {},
) {
if (rows.length === 0) {
return {
nextRow: startRow,
subtotalRow: 0,
dataStartRow: 0,
dataEndRow: 0,
treatAsLabor: Boolean(options.treatAsLabor),
}
}
let rowCursor = startRow
const dataStartRow = rowCursor
rows.forEach((row) => {
appendPanelBomDisplayRow(worksheet, rowCursor, row, mode, label, options)
rowCursor += 1
})
const dataEndRow = rowCursor - 1
appendPanelBomSubtotalRow(worksheet, rowCursor, dataStartRow, dataEndRow)
return {
nextRow: rowCursor + 1,
subtotalRow: rowCursor,
dataStartRow,
dataEndRow,
treatAsLabor: Boolean(options.treatAsLabor),
}
}
function applyPanelBomWorksheetLayout(worksheet: import('exceljs').Worksheet) {
const widths = [10, 12, 28, 8, 11, 11, 9, 9, 10, 10, 10, 12, 12, 10, 12, 22]
worksheet.columns = widths.map((width) => ({ width }))
worksheet.properties.defaultRowHeight = 22
worksheet.views = [{ state: 'frozen', ySplit: 2 }]
}
function getPackageExportMode(subcategory: NonIntegratedBomPackageSubcategory) {
if (subcategory === '泡沫') return 'foam'
if (subcategory === '泡沫护角') return 'foam-corner'
if (subcategory === '纸箱') return 'carton'
if (subcategory === '人工杂费') return 'labor'
return 'package'
}
function setCuttingCell(
worksheet: import('exceljs').Worksheet,
rowNumber: number,
columnNumber: number,
value: string | number | null | { formula: string },
) {
const cell = setCellValue(worksheet, rowNumber, columnNumber, value)
applyCellStyle(cell, {
align: columnNumber >= 5 && columnNumber <= 10 ? 'right' : 'center',
})
cell.alignment = {
...cell.alignment,
wrapText: false,
}
return cell
}
function styleCuttingSectionTitle(
worksheet: import('exceljs').Worksheet,
rowNumber: number,
title: string,
) {
worksheet.mergeCells(rowNumber, 1, rowNumber, 11)
const cell = worksheet.getCell(rowNumber, 1)
cell.value = title
applyCellStyle(cell, {
bold: true,
align: 'center',
fillColor: 'FFF3F5F7',
})
}
function inferBoardThickness(row: NonIntegratedBomDisplayRow) {
const fromSpec = parseBomExportNumber(row.specSize)
if (fromSpec !== null) return fromSpec
const sourceText = [row.material, row.partName, row.remark].filter(Boolean).join(' ')
const matched = sourceText.match(/(\d+(?:\.\d+)?)\s*(?:mm|毫米|厘|厚|密度板|刨花板|生态板|板)/i)
if (!matched) return ''
const parsed = Number(matched[1])
return Number.isFinite(parsed) ? parsed : ''
}
function appendCuttingBoardHeader(worksheet: import('exceljs').Worksheet, rowNumber: number) {
worksheet.mergeCells(rowNumber, 1, rowNumber + 1, 1)
worksheet.mergeCells(rowNumber, 2, rowNumber + 1, 2)
worksheet.mergeCells(rowNumber, 3, rowNumber + 1, 3)
worksheet.mergeCells(rowNumber, 4, rowNumber + 1, 4)
worksheet.mergeCells(rowNumber, 5, rowNumber, 7)
worksheet.mergeCells(rowNumber, 8, rowNumber + 1, 8)
worksheet.mergeCells(rowNumber, 9, rowNumber + 1, 9)
worksheet.mergeCells(rowNumber, 10, rowNumber + 1, 10)
worksheet.mergeCells(rowNumber, 11, rowNumber + 1, 11)
const labels = [
[1, '序号'],
[2, '零部件序号'],
[3, '零部件名称'],
[4, '材质'],
[5, '成品尺寸'],
[8, '成品数量'],
[9, '板厚等级'],
[10, '材积m³'],
[11, '备注'],
] as const
labels.forEach(([column, label]) => {
const cell = worksheet.getCell(rowNumber, column)
cell.value = label
})
worksheet.getCell(rowNumber + 1, 5).value = '长'
worksheet.getCell(rowNumber + 1, 6).value = '宽'
worksheet.getCell(rowNumber + 1, 7).value = '厚'
for (let row = rowNumber; row <= rowNumber + 1; row += 1) {
styleBomTemplateRange(worksheet, row, 1, 11, {
bold: true,
fillColor: 'FFF8FAFC',
})
}
}
function appendCuttingAccessoryHeader(worksheet: import('exceljs').Worksheet, rowNumber: number) {
const headers = ['序号', '五金名称', '规格', '材质', '颜色', '数量', '单位', '供应商', '单价', '金额', '备注']
headers.forEach((header, index) => {
const cell = worksheet.getCell(rowNumber, index + 1)
cell.value = header
})
styleBomTemplateRange(worksheet, rowNumber, 1, 11, {
bold: true,
fillColor: 'FFF8FAFC',
})
}
function createExportBlock(
label: string,
rows: NonIntegratedBomDisplayRow[],
@ -547,10 +994,332 @@ export async function createNonIntegratedBomWorkbook({
return new Blob([buffer], { type: EXCEL_MIME_TYPE })
}
export async function createNonIntegratedPanelBomWorkbook({
sections,
spu = '',
model = '',
skuCode = '',
edgeBandingOptions = [],
}: NonIntegratedBomWorkbookInput) {
const ExcelJSImport = await import('exceljs')
const ExcelJS = ExcelJSImport as typeof import('exceljs')
const workbook = new ExcelJS.Workbook()
workbook.creator = 'Codex'
workbook.lastModifiedBy = 'Codex'
workbook.created = new Date()
workbook.modified = new Date()
const worksheet = workbook.addWorksheet(
normalizeSheetName(getBomExportBaseName({ spu, model, skuCode })),
)
applyPanelBomWorksheetLayout(worksheet)
worksheet.mergeCells('A1:P1')
const titleCell = worksheet.getCell('A1')
titleCell.value = `型号:${[spu, model || skuCode].filter(Boolean).join(' ')}`
applyCellStyle(titleCell, {
bold: true,
align: 'center',
fillColor: 'FFEAF2FF',
})
titleCell.font = { name: 'Microsoft YaHei', size: 12, bold: true }
const headers = [
'分类',
'编号',
'名称',
'1',
'长',
'宽',
'长边',
'短边',
'单位',
'数量',
'系数',
'面积',
'封边米数',
'单价',
'金额',
'备注',
]
headers.forEach((header, index) => {
const cell = worksheet.getCell(2, index + 1)
cell.value = header
})
styleBomTemplateRange(worksheet, 2, 1, 16, {
bold: true,
fillColor: 'FFF3F5F7',
})
const resolvedSections = getResolvedSections(sections, edgeBandingOptions)
const subtotalRows: { subtotalRow: number; treatAsLabor: boolean }[] = []
let rowCursor = 3
let steelDataStartRow = 0
let steelDataEndRow = 0
resolvedSections.forEach((section) => {
if (section.category === '板材') {
const regularRows = section.displayRows.filter((row) => !isNonIntegratedBomSyntheticRow(row))
const edgeRows = section.displayRows.filter(
(row) => isNonIntegratedBomSyntheticRow(row) && row.syntheticKind === 'edge-banding',
)
const paintRows = section.displayRows.filter(
(row) => isNonIntegratedBomSyntheticRow(row) && row.syntheticKind === 'wood-paint',
)
const boardBlock = appendPanelBomBlock(worksheet, rowCursor, regularRows, '板材', 'board')
rowCursor = boardBlock.nextRow
if (boardBlock.subtotalRow) subtotalRows.push(boardBlock)
const edgeBlock = appendPanelBomBlock(worksheet, rowCursor, edgeRows, '封边', 'edge')
rowCursor = edgeBlock.nextRow
if (edgeBlock.subtotalRow) subtotalRows.push(edgeBlock)
const paintBlock = appendPanelBomBlock(worksheet, rowCursor, paintRows, '油漆', 'paint')
rowCursor = paintBlock.nextRow
if (paintBlock.subtotalRow) subtotalRows.push(paintBlock)
return
}
if (section.category === '钢架') {
const regularRows = section.displayRows.filter((row) => !isNonIntegratedBomSyntheticRow(row))
const sprayRows = section.displayRows.filter(
(row) => isNonIntegratedBomSyntheticRow(row) && row.syntheticKind === 'spray',
)
const steelBlock = appendPanelBomBlock(worksheet, rowCursor, regularRows, '钢材', 'steel')
rowCursor = steelBlock.nextRow
steelDataStartRow = steelBlock.dataStartRow
steelDataEndRow = steelBlock.dataEndRow
if (steelBlock.subtotalRow) subtotalRows.push(steelBlock)
const sprayBlock = appendPanelBomBlock(worksheet, rowCursor, sprayRows, '其他', 'spray', {
steelStartRow: steelDataStartRow,
steelEndRow: steelDataEndRow,
})
rowCursor = sprayBlock.nextRow
if (sprayBlock.subtotalRow) subtotalRows.push(sprayBlock)
return
}
if (section.category === '配件') {
const accessoryBlock = appendPanelBomBlock(
worksheet,
rowCursor,
section.displayRows,
'配件',
'accessory',
)
rowCursor = accessoryBlock.nextRow
if (accessoryBlock.subtotalRow) subtotalRows.push(accessoryBlock)
return
}
if (section.category === '包材') {
section.packageGroups.forEach((group) => {
const packageBlock = appendPanelBomBlock(
worksheet,
rowCursor,
group.displayRows,
group.subcategory,
getPackageExportMode(group.subcategory),
{
treatAsLabor: group.subcategory === '人工杂费',
},
)
rowCursor = packageBlock.nextRow
if (packageBlock.subtotalRow) subtotalRows.push(packageBlock)
})
}
})
const materialSubtotalRows = subtotalRows
.filter((item) => !item.treatAsLabor)
.map((item) => `O${item.subtotalRow}`)
const laborSubtotalRows = subtotalRows
.filter((item) => item.treatAsLabor)
.map((item) => `O${item.subtotalRow}`)
const materialFormula = materialSubtotalRows.length ? materialSubtotalRows.join('+') : '0'
const laborFormula = laborSubtotalRows.length ? laborSubtotalRows.join('+') : '0'
setPanelBomBaseRowValues(worksheet, rowCursor, {
1: '总材料费',
15: { formula: materialFormula },
})
styleBomTemplateRange(worksheet, rowCursor, 1, 16, {
bold: true,
fillColor: 'FFFFF8EC',
})
const materialTotalRow = rowCursor
rowCursor += 1
setPanelBomBaseRowValues(worksheet, rowCursor, {
1: '人工+杂费',
15: { formula: laborFormula },
})
styleBomTemplateRange(worksheet, rowCursor, 1, 16, {
bold: true,
fillColor: 'FFFFF8EC',
})
const laborTotalRow = rowCursor
rowCursor += 1
setPanelBomBaseRowValues(worksheet, rowCursor, {
1: '总成本',
15: { formula: `SUM(O${materialTotalRow}:O${laborTotalRow})` },
})
styleBomTemplateRange(worksheet, rowCursor, 1, 16, {
bold: true,
fillColor: 'FFFFF3D9',
})
const buffer = await workbook.xlsx.writeBuffer()
return new Blob([buffer], { type: EXCEL_MIME_TYPE })
}
export async function createNonIntegratedCuttingBomWorkbook({
sections,
spu = '',
model = '',
skuCode = '',
edgeBandingOptions = [],
}: NonIntegratedBomWorkbookInput) {
const ExcelJSImport = await import('exceljs')
const ExcelJS = ExcelJSImport as typeof import('exceljs')
const workbook = new ExcelJS.Workbook()
workbook.creator = 'Codex'
workbook.lastModifiedBy = 'Codex'
workbook.created = new Date()
workbook.modified = new Date()
const worksheet = workbook.addWorksheet('Sheet1')
worksheet.columns = [8, 14, 20, 20, 10, 10, 10, 10, 12, 12, 22].map((width) => ({
width,
}))
worksheet.properties.defaultRowHeight = 22
worksheet.views = [{ state: 'frozen', ySplit: 5 }]
worksheet.mergeCells('A1:K1')
const titleCell = worksheet.getCell('A1')
titleCell.value = '产品材料明细表'
applyCellStyle(titleCell, {
bold: true,
align: 'center',
fillColor: 'FFEAF2FF',
})
titleCell.font = { name: 'Microsoft YaHei', size: 14, bold: true }
const infoLabels = ['产品编号', spu || skuCode, '产品名称', model, '产品规格', '', '表面涂装', '', '制表日期', new Date()]
infoLabels.forEach((value, index) => {
const cell = worksheet.getCell(2, index + 1)
cell.value = value
applyCellStyle(cell, {
bold: index % 2 === 0,
align: index % 2 === 0 ? 'center' : 'left',
fillColor: index % 2 === 0 ? 'FFF8FAFC' : undefined,
})
})
worksheet.getCell(2, 10).numFmt = 'yyyy-mm-dd'
const resolvedSections = getResolvedSections(sections, edgeBandingOptions)
const boardRows =
resolvedSections
.find((section) => section.category === '板材')
?.displayRows.filter(
(row) =>
!isNonIntegratedBomSyntheticRow(row) ||
row.syntheticKind === 'wood-paint',
) ?? []
const accessoryRows =
resolvedSections.find((section) => section.category === '配件')?.displayRows ?? []
const steelRows =
resolvedSections.find((section) => section.category === '钢架')?.displayRows ?? []
const packageRows =
resolvedSections.find((section) => section.category === '包材')?.displayRows ?? []
let rowCursor = 3
styleCuttingSectionTitle(worksheet, rowCursor, '实木明细表')
rowCursor += 1
appendCuttingBoardHeader(worksheet, rowCursor)
rowCursor += 2
const boardStartRow = rowCursor
boardRows.forEach((row, index) => {
setCuttingCell(worksheet, rowCursor, 1, index + 1)
setCuttingCell(worksheet, rowCursor, 2, getBomText(row.partCode))
setCuttingCell(worksheet, rowCursor, 3, getBomDisplayName(row))
setCuttingCell(worksheet, rowCursor, 4, row.material || row.structure)
setCuttingCell(worksheet, rowCursor, 5, getBomNumberOrText(row.length))
setCuttingCell(worksheet, rowCursor, 6, getBomNumberOrText(row.width))
setCuttingCell(worksheet, rowCursor, 7, inferBoardThickness(row))
setCuttingCell(worksheet, rowCursor, 8, getBomNumberOrText(row.quantity))
setCuttingCell(worksheet, rowCursor, 9, getBomText(row.specSize))
setCuttingCell(worksheet, rowCursor, 10, {
formula: `IF(OR(E${rowCursor}="",F${rowCursor}="",G${rowCursor}="",H${rowCursor}=""),"",E${rowCursor}*F${rowCursor}*G${rowCursor}*H${rowCursor}/1000000000)`,
})
setCuttingCell(worksheet, rowCursor, 11, getBomRemark(row))
rowCursor += 1
})
const boardEndRow = rowCursor - 1
worksheet.mergeCells(rowCursor, 1, rowCursor, 9)
setCuttingCell(worksheet, rowCursor, 1, '材积合计')
setCuttingCell(worksheet, rowCursor, 10, {
formula: boardEndRow >= boardStartRow ? `SUM(J${boardStartRow}:J${boardEndRow})` : '0',
})
setCuttingCell(worksheet, rowCursor, 11, '')
styleBomTemplateRange(worksheet, rowCursor, 1, 11, {
bold: true,
fillColor: 'FFFFF8EC',
})
rowCursor += 1
const appendCuttingSimpleSection = (title: string, rows: NonIntegratedBomDisplayRow[]) => {
rowCursor += 1
styleCuttingSectionTitle(worksheet, rowCursor, title)
rowCursor += 1
appendCuttingAccessoryHeader(worksheet, rowCursor)
rowCursor += 1
rows.forEach((row, index) => {
setCuttingCell(worksheet, rowCursor, 1, index + 1)
setCuttingCell(worksheet, rowCursor, 2, getBomDisplayName(row))
setCuttingCell(worksheet, rowCursor, 3, row.specSize || row.length || row.width || '')
setCuttingCell(worksheet, rowCursor, 4, row.material || row.structure || '')
setCuttingCell(worksheet, rowCursor, 5, '')
setCuttingCell(worksheet, rowCursor, 6, getBomNumberOrText(row.quantity))
setCuttingCell(worksheet, rowCursor, 7, getBomText(row.unit))
setCuttingCell(worksheet, rowCursor, 8, '')
setCuttingCell(worksheet, rowCursor, 9, getBomNumberOrText(row.unitPrice))
setCuttingCell(worksheet, rowCursor, 10, {
formula: `IF(OR(F${rowCursor}="",I${rowCursor}=""),"",F${rowCursor}*I${rowCursor})`,
})
setCuttingCell(worksheet, rowCursor, 11, getBomRemark(row))
rowCursor += 1
})
}
appendCuttingSimpleSection('组装五金明细表', [...accessoryRows, ...steelRows])
appendCuttingSimpleSection('组包五金明细表', packageRows)
rowCursor += 1
worksheet.mergeCells(rowCursor, 1, rowCursor, 11)
const makerCell = worksheet.getCell(rowCursor, 1)
makerCell.value = '制表人:'
applyCellStyle(makerCell, {
align: 'left',
})
const buffer = await workbook.xlsx.writeBuffer()
return new Blob([buffer], { type: EXCEL_MIME_TYPE })
}
export function buildNonIntegratedBomFileName(
options: Pick<NonIntegratedBomWorkbookInput, 'spu' | 'model' | 'skuCode'>,
kind: NonIntegratedBomWorkbookKind = 'detail',
) {
return `${getBomExportBaseName(options)}-细节资料.xlsx`
const suffixByKind: Record<NonIntegratedBomWorkbookKind, string> = {
detail: '细节资料',
panel: '板式BOM',
cutting: '开料单BOM',
}
return `${getBomExportBaseName(options)}-${suffixByKind[kind]}.xlsx`
}
export function downloadNonIntegratedBomWorkbook(blob: Blob, fileName: string) {

View File

@ -165,6 +165,38 @@ describe('non-integrated intake permissions', () => {
expect(canEditNonIntegratedWorkspace(baseProduct, user, '设计卖点')).toBe(false)
})
it('allows space visual users when the auth context only carries department name', () => {
const user = {
id: 'space-name-only-id',
name: '希文',
role: 'USER',
businessRoles: ['PLANNING'],
department: {
name: '空间视觉部',
fullPath: null,
},
departmentPaths: [],
}
expect(canEditNonIntegratedWorkspace(baseProduct, user, '空间布局')).toBe(true)
expect(canEditNonIntegratedWorkspace(baseProduct, user, '产品定厂')).toBe(false)
})
it('keeps planning business role from opening planning layout without space visual department', () => {
const user = {
id: 'planning-role-only-id',
name: '企划B',
role: 'USER',
businessRoles: ['PLANNING'],
departmentPaths: [
'江苏简尚品牌管理有限公司 > 品牌中心 > 商品企划部 > 一线品牌',
],
}
expect(canEditNonIntegratedWorkspace(baseProduct, user, '空间布局')).toBe(false)
expect(canEditNonIntegratedWorkspace(baseProduct, user, '企划布局')).toBe(false)
})
it('separates purchaser owner, pricing group, and quality group access', () => {
const purchaser = {
id: 'purchase-id',

View File

@ -181,7 +181,7 @@ function isBrandCenterDepartmentPath(path: string) {
}
function isSpaceVisualDepartmentPath(path: string) {
return /空间视觉部/.test(path)
return /空间视觉部/.test(path) && (!/>/.test(path) || /品牌中心/.test(path))
}
function isSupplyChainPurchaseDepartmentPath(path: string) {
@ -267,10 +267,7 @@ export function isIntakeNonIntegratedBrandCenterPlanningUser(
export function isIntakeNonIntegratedSpaceDepartmentUser(
user: IntakeNonIntegratedPermissionUser,
) {
return (
hasDepartmentPath(user, isSpaceVisualDepartmentPath) ||
hasBusinessRoleFallback(user, 'DESIGN')
)
return hasDepartmentPath(user, isSpaceVisualDepartmentPath)
}
export function isIntakeNonIntegratedSupplyChainUser(

View File

@ -0,0 +1,83 @@
jest.mock('@/lib/prisma', () => ({
prisma: {
intakeNonIntegratedProduct: {
findMany: jest.fn(),
},
},
}))
import { prisma } from '@/lib/prisma'
import { IntakeNonIntegratedProductRepositoryClass } from '../intake-non-integrated-product.repository'
const mockPrisma = prisma as unknown as {
intakeNonIntegratedProduct: {
findMany: jest.Mock
}
}
describe('IntakeNonIntegratedProductRepositoryClass.findDeletedBySpu', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('reuses soft-deleted records when old brand names match normalized brands', async () => {
const repository = new IntakeNonIntegratedProductRepositoryClass()
const deletedRecord = {
id: 'deleted-gujia',
spu: 'GBGJ-SZ0230',
brand: '顾家',
deletedAt: new Date('2026-05-19T01:52:56.892Z'),
}
mockPrisma.intakeNonIntegratedProduct.findMany.mockResolvedValue([
deletedRecord,
])
const result = await repository.findDeletedBySpu(
'GBGJ-SZ0230',
'GBGJ-SZ0230-1779442274541',
'代理品牌-顾家',
)
expect(mockPrisma.intakeNonIntegratedProduct.findMany).toHaveBeenCalledWith({
where: {
spu: 'GBGJ-SZ0230',
deletedAt: {
not: null,
},
id: {
not: 'GBGJ-SZ0230-1779442274541',
},
},
orderBy: {
updatedAt: 'desc',
},
})
expect(result).toBe(deletedRecord)
})
it('does not reuse an unrelated soft-deleted brand when several deleted records exist', async () => {
const repository = new IntakeNonIntegratedProductRepositoryClass()
mockPrisma.intakeNonIntegratedProduct.findMany.mockResolvedValue([
{
id: 'deleted-sofiya',
spu: 'GBGJ-SZ0230',
brand: '索菲亚',
deletedAt: new Date('2026-05-19T01:52:56.892Z'),
},
{
id: 'deleted-pumei',
spu: 'GBGJ-SZ0230',
brand: '朴美',
deletedAt: new Date('2026-05-19T01:52:56.892Z'),
},
])
const result = await repository.findDeletedBySpu(
'GBGJ-SZ0230',
undefined,
'代理品牌-顾家',
)
expect(result).toBeNull()
})
})

View File

@ -1,4 +1,5 @@
import { IntakeNonIntegratedProduct, Prisma } from '@prisma/client'
import { normalizeNonIntegratedBrand } from '@/app/intake/non-integrated/nonIntegratedStorage'
import { BaseRepository } from './base.repository'
export type IntakeNonIntegratedProductCreateInput = Prisma.IntakeNonIntegratedProductCreateInput
@ -88,10 +89,9 @@ class IntakeNonIntegratedProductRepositoryClass extends BaseRepository<
}
async findDeletedBySpu(spu: string, excludeId?: string, brand?: string) {
return this.prisma.intakeNonIntegratedProduct.findFirst({
const deletedRecords = await this.prisma.intakeNonIntegratedProduct.findMany({
where: {
spu,
...(brand ? { brand } : null),
deletedAt: {
not: null,
},
@ -107,6 +107,17 @@ class IntakeNonIntegratedProductRepositoryClass extends BaseRepository<
updatedAt: 'desc',
},
})
if (!brand) {
return deletedRecords[0] ?? null
}
const normalizedBrand = normalizeNonIntegratedBrand(brand)
return (
deletedRecords.find(
(record) => normalizeNonIntegratedBrand(record.brand) === normalizedBrand,
) ?? null
)
}
async softDeleteByUser(id: string, updatedBy: string) {

View File

@ -14,6 +14,12 @@ jest.mock('../../repositories/intake-non-integrated-product.repository', () => (
},
}))
jest.mock('../intake-node-permission-settings.service', () => ({
intakeNodePermissionSettingsService: {
getSettings: jest.fn().mockResolvedValue({ overrides: {} }),
},
}))
import { intakeNonIntegratedProductRepository } from '../../repositories/intake-non-integrated-product.repository'
import { intakeNonIntegratedProductService } from '../intake-non-integrated-product.service'
@ -493,6 +499,634 @@ describe('intakeNonIntegratedProductService', () => {
expect(mockedRepository.update).toHaveBeenCalled()
})
it('allows space visual department users to save planning layout through the shared save API', async () => {
const skuItems = [
{
id: 'sku-1',
skuSequence: 1,
packageGroupId: '',
packageSuffix: '',
image: '',
imageName: '',
model: 'K01',
skuCode: 'DNZ000101',
setCount: '1',
specName: '款式一',
barcode: 'DNZ000101N1280V01',
size: '',
color: '',
material: '',
attributeCode: 'A',
drawingId: 'DNZ0001K01AV01',
installMethod: '',
executeStandard: '',
manufacturer: '',
manufacturerCode: '',
tagMaterial: '',
model3dFileName: '',
model3dDataUrl: '',
model3dNodeOffsets: {},
model3dNodeRotations: {},
},
]
const productLimitRows = [
{
skuId: 'sku-1',
expectedDailyLimitPrice: '599',
expectedLimitGrossMarginRate: '',
nonSpuOwnerLimitPrice: '',
},
]
const existingPayload = createPayload({
skuItems,
productLimitRows,
planningLayoutRows: [],
})
const incomingPayload = createPayload({
...existingPayload,
skuItems,
productLimitRows,
planningLayoutRows: [
{
skuId: 'sku-1',
style: '',
styles: ['现代'],
styleTone: '',
styleTones: ['简约'],
audience: '',
audiences: ['年轻家庭'],
spaces: ['客厅'],
categories: [],
model: '',
priceBand: '500-600',
brands: [],
shop: '',
},
],
})
mockedRepository.findBySpu.mockResolvedValue(null as never)
mockedRepository.findById.mockResolvedValue(
createRecord({
payload: existingPayload,
}) as never,
)
mockedRepository.update.mockResolvedValue(
createRecord({
payload: incomingPayload,
}) as never,
)
mockedRepository.findActiveById.mockResolvedValue(
createRecord({
payload: incomingPayload,
}) as never,
)
await expect(
intakeNonIntegratedProductService.save(incomingPayload, {
...baseUser,
id: 'space-visual-1',
email: 'space@example.com',
name: '希文',
businessRoles: ['PLANNING'] as const,
department: {
id: 'space-dept',
name: '空间视觉部',
fullPath: '',
},
departmentPaths: [],
}),
).resolves.toMatchObject({
planningLayoutRows: [
expect.objectContaining({
skuId: 'sku-1',
priceBand: '500-600',
}),
],
})
expect(mockedRepository.update).toHaveBeenCalled()
})
it('treats normal planning layout manual saves with only readonly differences as successful no-ops', async () => {
const existingPayload = createPayload({
id: 'normal-planning-layout-manual-save',
spu: 'GBGJ-BJ0191',
processTemplate: '普通产品',
brand: '代理品牌-顾家',
productLimitRows: [
{
skuId: 'sku-1',
expectedDailyLimitPrice: '599',
expectedLimitGrossMarginRate: '',
nonSpuOwnerLimitPrice: '',
},
],
planningLayoutRows: [
{
skuId: 'sku-1',
styles: ['中古风'],
styleTones: ['浅色系'],
audiences: ['年轻家庭'],
spaces: ['餐厅'],
categories: [],
brands: ['代理品牌-顾家'],
model: '',
priceBand: '',
shop: '',
},
],
})
const incomingPayload = createPayload({
...existingPayload,
productLimitRows: [
{
skuId: 'sku-1',
expectedDailyLimitPrice: '699',
expectedLimitGrossMarginRate: '',
nonSpuOwnerLimitPrice: '',
},
],
planningLayoutRows: existingPayload.planningLayoutRows,
})
mockedRepository.findBySpu.mockResolvedValue(null as never)
mockedRepository.findById.mockResolvedValue(
createRecord({
id: 'normal-planning-layout-manual-save',
spu: 'GBGJ-BJ0191',
payload: existingPayload,
}) as never,
)
mockedRepository.findActiveById.mockResolvedValue(
createRecord({
id: 'normal-planning-layout-manual-save',
spu: 'GBGJ-BJ0191',
payload: existingPayload,
}) as never,
)
await expect(
intakeNonIntegratedProductService.save(
incomingPayload,
{
...baseUser,
id: 'space-visual-1',
email: 'space@example.com',
name: '希文',
businessRoles: ['PLANNING'] as const,
department: {
id: 'space-dept',
name: '空间视觉部',
fullPath: '江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部',
},
departmentPaths: [
'江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部',
],
},
{ workspaceCategory: '企划布局' },
),
).resolves.toEqual(
expect.objectContaining({ id: 'normal-planning-layout-manual-save' }),
)
expect(mockedRepository.update).not.toHaveBeenCalled()
})
it('allows space visual users to save stock planning layout without rewriting readonly legacy fields', async () => {
const stockSku = {
id: 'jst-stock-sku-4fe2f2b80cfa6ad08100',
skuSequence: 1,
batchNo: 1,
packageGroupId: '',
packageSuffix: '',
image: 'https://static.jiansun.vip:3333/Resource/SkuImg/GBPM-AD030203N1280V01.jpg',
imageName: 'GBPM-AD030203N1280V01.jpg',
model: '',
skuCode: 'GBPM-AD030203',
setCount: '1',
specName: 'GBPM-款式一 32/32/45 朴橡胡桃木色',
barcode: 'GBPM-AD030203N1280V01',
size: '32/32/45',
color: '朴橡胡桃木色',
material: '橡胶木',
attributeCode: '',
drawingId: 'AD0302K01AV01',
installMethod: '',
executeStandard: '',
manufacturer: '',
manufacturerCode: '',
salesStatus: '销售',
tagMaterial: '',
versionNo: 'V01',
model3dFileName: '',
model3dDataUrl: '',
model3dNodeOffsets: {},
model3dNodeRotations: {},
componentDetails: [],
source: 'jst_sku',
sourceTable: 'jst_sku',
sourceSkuId: 'GBPM-AD030203N1280V01',
contentLocked: false,
}
const existingPayload = createPayload({
id: 'jst-stock-edd94032a9d1022f',
spu: 'GBPM-AD0302',
productCode: 'GBPM-AD0302',
productDevelopmentKind: 'product',
processTemplate: '存量商品',
brand: '基本盘',
adaptableBrands: ['基本盘'],
designer: '',
previewImage:
'https://static.jiansun.vip:3333/Resource/SkuImg/GBPM-AD030203N1280V01.jpg',
previewImageName: 'GBPM-AD030203N1280V01.jpg',
productCategory: '住宅家具 - 矮凳',
salesCategory: '住宅家具 - 矮凳',
size: '32/32/45',
materialType: '橡胶木',
projectStatus: 'approved',
openingReference: {
productImage:
'https://static.jiansun.vip:3333/Resource/SkuImg/GBPM-AD030203N1280V01.jpg',
productImageName: 'GBPM-AD030203N1280V01.jpg',
productName: 'GBPM-款式一 32/32/45 朴橡胡桃木色',
temporaryCode: 'GBPM-AD0302',
brand: '基本盘',
adaptableBrands: ['基本盘'],
productCategory: '住宅家具 - 矮凳',
salesCategory: '住宅家具 - 矮凳',
designer: '',
planningOwner: '',
structureDesigner: '',
currentStage: '存量商品',
year: '2026',
competitorLink: '',
targetPriceBand: '',
size: '32/32/45',
material: '橡胶木',
urgent: '',
reason: '',
},
patentApplication: {
shouldApply: '待判断',
patentName: '',
patentStatus: '待确认',
saRequired: '否',
salesStatus: '在售',
designer: '',
applicationNo: '',
applicationDate: '',
applicationResult: '',
resultDate: '',
patentNo: '',
firstFeeDate: '',
nextFeeDueDate: '',
feeAmount: '',
owner: '',
note: '',
},
skuItems: [stockSku],
planningLayoutRows: [],
formalSpuAssigned: true,
})
const incomingPayload = createPayload({
...existingPayload,
brand: '其他品牌-普通大货',
adaptableBrands: ['其他品牌-普通大货'],
openingReference: {
...(existingPayload.openingReference as Record<string, unknown>),
brand: '其他品牌-普通大货',
adaptableBrands: ['其他品牌-普通大货'],
},
patentApplication: {
...(existingPayload.patentApplication as Record<string, unknown>),
patentName: 'GBPM-款式一 32/32/45 朴橡胡桃木色',
},
planningLayoutRows: [
{
skuId: stockSku.id,
style: '',
styles: ['中古风'],
styleTone: '',
styleTones: ['深木色系'],
audience: '',
audiences: ['租房人群'],
spaces: ['餐厅'],
categories: [],
model: '',
priceBand: '',
brands: ['自有品牌-朴美'],
shop: '',
},
],
})
mockedRepository.findBySpu.mockResolvedValue(null as never)
mockedRepository.findById.mockResolvedValue(
createRecord({
id: 'jst-stock-edd94032a9d1022f',
spu: 'GBPM-AD0302',
spuBase: 'AD0302',
brand: '其他品牌-普通大货',
designer: '',
purchaser: '',
payload: existingPayload,
}) as never,
)
mockedRepository.update.mockResolvedValue(
createRecord({
id: 'jst-stock-edd94032a9d1022f',
payload: incomingPayload,
}) as never,
)
mockedRepository.findActiveById.mockResolvedValue(
createRecord({
id: 'jst-stock-edd94032a9d1022f',
payload: incomingPayload,
}) as never,
)
await expect(
intakeNonIntegratedProductService.save(incomingPayload, {
...baseUser,
id: 'space-visual-1',
email: 'space@example.com',
name: '希文',
businessRoles: ['PLANNING'] as const,
department: {
id: 'space-dept',
name: '空间视觉部',
fullPath: '江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部',
},
departmentPaths: [
'江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部',
],
}),
).resolves.toMatchObject({
planningLayoutRows: [
expect.objectContaining({
skuId: stockSku.id,
styles: ['中古风'],
}),
],
})
const updateData = mockedRepository.update.mock.calls[0]?.[1] as
| Record<string, unknown>
| undefined
const updatePayload = updateData?.payload as Record<string, unknown>
expect(updateData).toEqual(
expect.objectContaining({
brand: '其他品牌-普通大货',
designer: '',
purchaser: '',
}),
)
expect(updatePayload).toEqual(
expect.objectContaining({
brand: '基本盘',
adaptableBrands: ['基本盘'],
patentApplication: expect.objectContaining({
patentName: '',
}),
planningLayoutRows: [
expect.objectContaining({
skuId: stockSku.id,
styles: ['中古风'],
}),
],
}),
)
expect(updatePayload.openingReference).toEqual(
expect.objectContaining({
brand: '基本盘',
adaptableBrands: ['基本盘'],
}),
)
})
it('rejects stock product factory edits even from purchase owners', async () => {
const existingFactoryRows = [
{
skuId: 'stock-sku-1',
confirmed: false,
model: 'AD0302',
skuCode: 'GBPM-AD030201',
setCount: '1',
barcode: 'GBPM-AD030201N1280V01',
specName: '存量款式',
size: '',
color: '',
material: '',
installMethod: '',
supplierName: '供应商A',
supplierCode: 'N1280',
warehouse: '',
minimumOrderQuantity: '',
leadTime: '',
assemblyMode: '',
productionType: '',
installationService: '',
supplierCategory: '',
purchaseOrderMethod: '',
executeStandard: '',
},
]
const existingPayload = createPayload({
id: 'stock-product-factory-readonly',
processTemplate: '存量商品',
purchaser: '采购A',
purchaseOwner: '采购A',
openingReference: {
currentStage: '存量商品',
},
factoryRows: existingFactoryRows,
})
const incomingPayload = createPayload({
...existingPayload,
factoryRows: existingFactoryRows.map((row) => ({
...row,
confirmed: true,
supplierName: '供应商B',
})),
})
mockedRepository.findBySpu.mockResolvedValue(null as never)
mockedRepository.findById.mockResolvedValue(
createRecord({
id: 'stock-product-factory-readonly',
payload: existingPayload,
}) as never,
)
await expect(
intakeNonIntegratedProductService.save(incomingPayload, {
...baseUser,
id: 'purchase-1',
email: 'purchase@example.com',
name: '采购A',
businessRoles: [] as const,
departmentPaths: [
'江苏简尚品牌管理有限公司 > 供应链中心 > 基本盘 采购部 > 采购一部',
],
}),
).rejects.toThrow('没有权限编辑这条非集成产品记录')
expect(mockedRepository.update).not.toHaveBeenCalled()
})
it('does not rewrite stock product legacy values when there is no permitted node change', async () => {
const existingPayload = createPayload({
id: 'stock-readonly-noop-save',
spu: 'GBPM-AD0302',
productCode: 'GBPM-AD0302',
processTemplate: '存量商品',
brand: '基本盘',
adaptableBrands: ['基本盘'],
openingReference: {
brand: '基本盘',
adaptableBrands: ['基本盘'],
currentStage: '存量商品',
},
planningLayoutRows: [],
})
const incomingPayload = createPayload({
...existingPayload,
brand: '其他品牌-普通大货',
adaptableBrands: ['其他品牌-普通大货'],
openingReference: {
...(existingPayload.openingReference as Record<string, unknown>),
brand: '其他品牌-普通大货',
adaptableBrands: ['其他品牌-普通大货'],
},
planningLayoutRows: [],
})
mockedRepository.findBySpu.mockResolvedValue(null as never)
mockedRepository.findById.mockResolvedValue(
createRecord({
id: 'stock-readonly-noop-save',
spu: 'GBPM-AD0302',
payload: existingPayload,
}) as never,
)
mockedRepository.findActiveById.mockResolvedValue(
createRecord({
id: 'stock-readonly-noop-save',
spu: 'GBPM-AD0302',
payload: existingPayload,
}) as never,
)
await expect(
intakeNonIntegratedProductService.save(incomingPayload, {
...baseUser,
id: 'space-visual-1',
email: 'space@example.com',
name: '希文',
businessRoles: ['PLANNING'] as const,
department: {
id: 'space-dept',
name: '空间视觉部',
fullPath: '江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部',
},
departmentPaths: [
'江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部',
],
}),
).resolves.toEqual(expect.objectContaining({ id: 'stock-readonly-noop-save' }))
expect(mockedRepository.update).not.toHaveBeenCalled()
})
it('treats stock planning layout manual saves with only readonly legacy differences as successful no-ops', async () => {
const existingPayload = createPayload({
id: 'stock-planning-layout-manual-save',
spu: 'GBPM-AD0302',
productCode: 'GBPM-AD0302',
processTemplate: '存量商品',
brand: '基本盘',
adaptableBrands: ['基本盘'],
openingReference: {
brand: '基本盘',
adaptableBrands: ['基本盘'],
currentStage: '存量商品',
},
patentApplication: {
patentName: '',
},
planningLayoutRows: [
{
skuId: 'stock-sku-1',
styles: ['中古风'],
styleTones: [],
audiences: [],
spaces: ['餐厅'],
categories: [],
brands: ['自有品牌-朴美'],
model: '',
priceBand: '',
shop: '',
},
],
})
const incomingPayload = createPayload({
...existingPayload,
brand: '其他品牌-普通大货',
adaptableBrands: ['其他品牌-普通大货'],
openingReference: {
...(existingPayload.openingReference as Record<string, unknown>),
brand: '其他品牌-普通大货',
adaptableBrands: ['其他品牌-普通大货'],
},
patentApplication: {
...(existingPayload.patentApplication as Record<string, unknown>),
patentName: '不应写入的只读差异',
},
})
mockedRepository.findBySpu.mockResolvedValue(null as never)
mockedRepository.findById.mockResolvedValue(
createRecord({
id: 'stock-planning-layout-manual-save',
spu: 'GBPM-AD0302',
payload: existingPayload,
}) as never,
)
mockedRepository.findActiveById.mockResolvedValue(
createRecord({
id: 'stock-planning-layout-manual-save',
spu: 'GBPM-AD0302',
payload: existingPayload,
}) as never,
)
await expect(
intakeNonIntegratedProductService.save(
incomingPayload,
{
...baseUser,
id: 'space-visual-1',
email: 'space@example.com',
name: '希文',
businessRoles: ['PLANNING'] as const,
department: {
id: 'space-dept',
name: '空间视觉部',
fullPath: '江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部',
},
departmentPaths: [
'江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部',
],
},
{ workspaceCategory: '企划布局' },
),
).resolves.toEqual(expect.objectContaining({ id: 'stock-planning-layout-manual-save' }))
expect(mockedRepository.update).not.toHaveBeenCalled()
})
it('keeps existing BOM details when an autosave submits an empty BOM payload', async () => {
const existingBomDetails = [
{

View File

@ -56,4 +56,42 @@ describe('IntakeNonIntegratedScopeService node permissions', () => {
service.canWriteWorkspaceRecordWithSettings(record, spaceUser, '产品定厂'),
).resolves.toBe(false)
})
it('allows space layout writes when only the space visual department name is available', async () => {
const record = {
id: 'stock-space-record',
createdBy: 'designer-id',
designer: '设计同事',
purchaser: '采购同事',
visibility: 'DEPARTMENT',
departmentId: null,
teamId: null,
payload: {
productPlanning: '商品企划同事',
visualPlanning: '其他空间同事',
visualDirector: '其他空间同事',
},
}
const spaceUser: AuthUser = {
id: 'xiweng-user-id',
email: 'xiweng@example.local',
name: '希文',
role: 'USER',
status: 'ACTIVE',
businessRoles: ['PLANNING'],
department: {
id: 'space-dept',
name: '空间视觉部',
fullPath: '',
},
departmentPaths: [],
}
await expect(
service.canWriteWorkspaceRecordWithSettings(record, spaceUser, '空间布局'),
).resolves.toBe(true)
await expect(
service.canWriteWorkspaceRecordWithSettings(record, spaceUser, '产品定厂'),
).resolves.toBe(false)
})
})

View File

@ -196,11 +196,11 @@ const DEFAULT_FIELD_MATRIX: FieldMatrixItem[] = [
key: 'spuSkuIdentity',
label: 'SPU / SKU / 货号信息',
section: '基础资料',
helperText: '对应正式产品和仓库里都会用到的 SPU、SKU、型号、规格码、货号。',
helperText: '对应正式产品和仓库里都会用到的 SPU、SKU、型号、货号。',
businessRole: 'WAREHOUSE',
required: true,
allowSecondEdit: true,
placeholder: '填写 SPU、SKU、型号、规格码、条码规格名或货号承接信息。',
placeholder: '填写 SPU、SKU、型号、条码规格名或货号承接信息。',
},
{
key: 'baseAttributeSummary',