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:
commit
5c837ee8e4
@ -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) {
|
||||
|
||||
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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),
|
||||
}));
|
||||
}
|
||||
|
||||
@ -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
|
||||
? '这里已经没有系统预置规格了。请直接点上方“新增方管 / 新增扁管 / 新增圆管”开始录入。'
|
||||
: '这里还没有钢架规格。当前账号只能查看。'}
|
||||
|
||||
@ -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({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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),
|
||||
})),
|
||||
)
|
||||
|
||||
@ -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('企划')
|
||||
})
|
||||
})
|
||||
|
||||
@ -133,10 +133,10 @@ export function inferBusinessRolesFromDepartmentPath(
|
||||
|
||||
if (root === '品牌中心' && includesSegment(parts, /空间视觉部/)) {
|
||||
return {
|
||||
businessRoles: ['DESIGN'],
|
||||
businessRoles: ['PLANNING'],
|
||||
excluded: false,
|
||||
shouldWarnWhenEmpty: true,
|
||||
reason: '品牌中心空间视觉部默认归设计',
|
||||
reason: '品牌中心空间视觉部默认归企划',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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) {
|
||||
|
||||
@ -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 = [
|
||||
{
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user