fix(bom-costing): align material and packaging cost truth

This commit is contained in:
WuYongJie 2026-07-29 19:56:27 +08:00
parent c1151b6af5
commit 91a5ce30ce
15 changed files with 1504 additions and 133 deletions

View File

@ -8,6 +8,34 @@ type RouteContext = {
params: Promise<{ id: string; lineId: string }>
}
export async function GET(request: NextRequest, context: RouteContext) {
return handleRequest(
request,
async (ctx) => {
const { id, lineId } = await context.params
try {
return jsonSuccess(
{
candidates: await bomCostingService.listLinePriceCandidates(
decodeURIComponent(id),
decodeURIComponent(lineId),
ctx.user,
),
},
200,
ctx.requestId,
)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}
export async function PATCH(request: NextRequest, context: RouteContext) {
return handleRequest(
request,

View File

@ -659,4 +659,214 @@ describe('BomCostingWorkbenchClient', () => {
global.fetch = originalFetch
}
})
it('未匹配材料不显示零元,并可在 BOM 行确认资料库价格', async () => {
const originalFetch = global.fetch
const now = new Date().toISOString()
const unresolvedLine = {
id: 'line-rubberwood',
externalLineId: 'top-board',
category: 'BOARD',
categoryLabel: '板材',
partName: '橡胶木桌面板',
materialCode: '',
materialName: 'FAS级橡胶木实木板',
materialSpec: '',
processName: '',
packagingKind: '',
lengthMm: 740,
widthMm: 400,
heightMm: null,
quantity: 2,
unit: '块',
weightKg: null,
lossFactor: 1.08,
sourceConfidence: 0.82,
evidence: [],
matchStatus: 'NEEDS_CONFIRMATION',
matchedLibraryType: '',
matchedLibraryRecordId: '',
matchedLibraryName: '',
matchedLibrarySpec: '',
resolvedUnitPrice: null,
calculatedQuantity: 0.592,
subtotal: null,
updatedAt: now,
}
const detail = {
id: 'case-price',
sourceUrl: 'https://example.com/product/price',
sourceHost: 'example.com',
title: '竞品橡胶木茶几',
providerCode: 'market-detail-multimodal',
providerTaskId: 'bom-price-task',
sourceCacheHit: true,
status: 'READY',
progress: 100,
statusMessage: '已完成',
targetErrorPercent: 10,
currency: 'CNY',
createdById: 'user-1',
processingStartedAt: now,
processingFinishedAt: now,
createdAt: now,
updatedAt: now,
sourceSnapshot: null,
skus: [{
id: 'sku-price',
externalSkuId: 'sku-price',
name: '原木色',
structureGroupKey: 'table',
imageUrl: null,
attributes: {},
lengthMm: 740,
widthMm: 400,
heightMm: 620,
weightKg: null,
packageLengthMm: null,
packageWidthMm: null,
packageHeightMm: null,
packageWeightKg: null,
estimatedCost: null,
costCalculatedAt: null,
costSummary: {
material: 0,
processing: 83,
labor: 35,
packaging: 0,
lossIncludedTotal: 118,
unresolvedLineCount: 1,
missingCostGroups: ['PACKAGING'],
},
bomLines: [unresolvedLine],
}],
taskEvents: [],
}
const resolvedDetail = {
...detail,
skus: [{
...detail.skus[0],
estimatedCost: null,
costCalculatedAt: null,
costSummary: {
material: 51.57,
processing: 83,
labor: 35,
packaging: 0,
lossIncludedTotal: 169.57,
unresolvedLineCount: 0,
missingCostGroups: ['PACKAGING'],
},
bomLines: [{
...unresolvedLine,
heightMm: 18,
matchStatus: 'MATCHED',
matchedLibraryType: 'SUBSTRATE',
matchedLibraryRecordId: 'rubberwood-18',
matchedLibraryName: '18ENF级橡胶木',
matchedLibrarySpec: '18mm · ENF',
resolvedUnitPrice: 80.63,
subtotal: 51.57,
}],
}],
}
global.fetch = jest.fn(async (input, init) => {
const url = String(input)
if (url.endsWith('/lines/line-rubberwood') && init?.method === 'PATCH') {
return {
ok: true,
json: async () => ({
success: true,
data: { case: resolvedDetail },
}),
} as Response
}
if (url.endsWith('/lines/line-rubberwood')) {
return {
ok: true,
json: async () => ({
success: true,
data: {
candidates: [{
recordId: 'rubberwood-18',
libraryType: 'SUBSTRATE',
code: 'RW-18',
name: '18ENF级橡胶木',
spec: '18mm · ENF',
unit: '㎡',
unitPrice: 80.63,
thicknessMm: 18,
recommended: true,
}],
},
}),
} as Response
}
return {
ok: true,
json: async () => ({
success: true,
data: { case: detail },
}),
} as Response
}) as jest.Mock
try {
render(
<BomCostingWorkbenchClient
initialCapabilities={{
enabled: true,
analysisConfigured: true,
modelingConfigured: false,
analysisProviderCode: 'market-detail-multimodal',
targetErrorPercent: 10,
fixtureAllowed: false,
}}
initialCases={[{
id: 'case-price',
title: '竞品橡胶木茶几',
sourceUrl: 'https://example.com/product/price',
sourceHost: 'example.com',
status: 'READY',
progress: 100,
statusMessage: '已完成',
sourceCacheHit: true,
storedImageCount: 0,
failedImageCount: 0,
createdById: 'user-1',
createdAt: now,
skuCount: 1,
estimatedCostRange: null,
}]}
/>,
)
expect(await screen.findByText('橡胶木桌面板')).toBeInTheDocument()
expect(screen.getByText('缺板厚')).toBeInTheDocument()
expect(screen.getByText('待匹配')).toBeInTheDocument()
expect(screen.queryByText('¥0.00')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /确认价格/ }))
fireEvent.click(
await screen.findByRole('button', { name: /18ENF级橡胶木/ }),
)
await waitFor(() => {
const patchCall = (global.fetch as jest.Mock).mock.calls.find(
([url, init]) =>
String(url).endsWith('/lines/line-rubberwood') &&
init?.method === 'PATCH',
)
expect(JSON.parse(patchCall?.[1]?.body)).toEqual({
matchedLibraryRecordId: 'rubberwood-18',
heightMm: 18,
})
})
expect((await screen.findAllByText('¥51.57')).length).toBeGreaterThan(0)
expect(screen.getByText('18ENF级橡胶木')).toBeInTheDocument()
} finally {
global.fetch = originalFetch
}
})
})

View File

@ -2,6 +2,7 @@
import Image from 'next/image'
import {
Fragment,
useCallback,
useEffect,
useMemo,
@ -35,6 +36,8 @@ import type {
BomCostingCapabilities,
BomCostingCaseDetail,
BomCostingCaseSummary,
BomCostingLineDetail,
BomCostingPriceCandidateDetail,
BomCostingSkuDetail,
} from '@/lib/bom-costing/client-types'
import {
@ -510,10 +513,12 @@ function ResultCanvas({
detail,
selectedSku,
onSelectSku,
onCaseUpdated,
}: {
detail: BomCostingCaseDetail
selectedSku: BomCostingSkuDetail
onSelectSku: (skuId: string) => void
onCaseUpdated: (detail: BomCostingCaseDetail) => void
}) {
const sourceAssets = useMemo(
() =>
@ -547,6 +552,20 @@ function ResultCanvas({
const [selectedAssetIdBySku, setSelectedAssetIdBySku] = useState<
Record<string, string>
>({})
const [editingPriceLineId, setEditingPriceLineId] = useState<string | null>(
null,
)
const [priceCandidatesByLine, setPriceCandidatesByLine] = useState<
Record<string, BomCostingPriceCandidateDetail[]>
>({})
const [priceCandidateQuery, setPriceCandidateQuery] = useState('')
const [loadingPriceLineId, setLoadingPriceLineId] = useState<string | null>(
null,
)
const [savingPriceLineId, setSavingPriceLineId] = useState<string | null>(
null,
)
const [priceEditorError, setPriceEditorError] = useState('')
const assetIdByStoredUrl = new Map<string, string>()
for (const asset of sourceAssets) {
if (asset.storedUrl && !assetIdByStoredUrl.has(asset.storedUrl)) {
@ -591,6 +610,130 @@ function ResultCanvas({
}
}
const openPriceEditor = async (line: BomCostingLineDetail) => {
if (editingPriceLineId === line.id) {
setEditingPriceLineId(null)
setPriceEditorError('')
return
}
setEditingPriceLineId(line.id)
setPriceCandidateQuery('')
setPriceEditorError('')
if (priceCandidatesByLine[line.id]) return
setLoadingPriceLineId(line.id)
try {
const response = await fetch(
`/api/bom-costing/cases/${encodeURIComponent(detail.id)}/lines/${encodeURIComponent(line.id)}`,
{ cache: 'no-store' },
)
const data = await readApiData<{
candidates: BomCostingPriceCandidateDetail[]
}>(response)
setPriceCandidatesByLine((current) => ({
...current,
[line.id]: data.candidates,
}))
} catch (error) {
setPriceEditorError(
error instanceof Error ? error.message : '资料库价格读取失败',
)
} finally {
setLoadingPriceLineId(null)
}
}
const confirmPriceCandidate = async (
line: BomCostingLineDetail,
candidate: BomCostingPriceCandidateDetail,
) => {
if (savingPriceLineId) return
setSavingPriceLineId(line.id)
setPriceEditorError('')
try {
const response = await fetch(
`/api/bom-costing/cases/${encodeURIComponent(detail.id)}/lines/${encodeURIComponent(line.id)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
matchedLibraryRecordId: candidate.recordId,
...(line.category === 'BOARD' &&
line.heightMm === null &&
candidate.thicknessMm !== null
? { heightMm: candidate.thicknessMm }
: {}),
}),
},
)
const data = await readApiData<{ case: BomCostingCaseDetail }>(response)
onCaseUpdated(data.case)
setEditingPriceLineId(null)
} catch (error) {
setPriceEditorError(
error instanceof Error ? error.message : '价格确认失败,请重试',
)
} finally {
setSavingPriceLineId(null)
}
}
const missingCostGroups = new Set(
selectedSku.costSummary.missingCostGroups ?? [],
)
const materialHasUnresolved =
missingCostGroups.has('MATERIAL') ||
selectedSku.bomLines.some(
(line) =>
['BOARD', 'STEEL', 'ACCESSORY'].includes(line.category) &&
line.subtotal === null,
)
const packagingHasUnresolved =
missingCostGroups.has('PACKAGING') ||
selectedSku.bomLines.some(
(line) => line.category === 'PACKAGING' && line.subtotal === null,
)
const missingCostGroupLabels = [
missingCostGroups.has('MATERIAL') ? '材料' : '',
missingCostGroups.has('PROCESSING') ? '加工' : '',
missingCostGroups.has('LABOR') ? '人工' : '',
missingCostGroups.has('PACKAGING') ? '包材' : '',
].filter(Boolean)
const incompleteCostMessage = [
selectedSku.costSummary.unresolvedLineCount > 0
? `${selectedSku.costSummary.unresolvedLineCount} 项价格待确认`
: '',
missingCostGroupLabels.length > 0
? `${missingCostGroupLabels.join('、')}尚未识别`
: '',
].filter(Boolean).join(',')
const costSummaryRows = [
{
label: '材料',
value: selectedSku.costSummary.material,
unresolved: materialHasUnresolved,
missing: missingCostGroups.has('MATERIAL'),
},
{
label: '加工(AI 估算)',
value: selectedSku.costSummary.processing,
unresolved: missingCostGroups.has('PROCESSING'),
missing: missingCostGroups.has('PROCESSING'),
},
{
label: '人工(AI 估算)',
value: selectedSku.costSummary.labor,
unresolved: missingCostGroups.has('LABOR'),
missing: missingCostGroups.has('LABOR'),
},
{
label: '包材',
value: selectedSku.costSummary.packaging,
unresolved: packagingHasUnresolved,
missing: missingCostGroups.has('PACKAGING'),
},
]
return (
<div className="h-full min-h-[580px] overflow-y-auto bg-white">
<div className="mx-auto grid w-full max-w-[1500px] gap-5 px-4 py-4 xl:grid-cols-[minmax(0,1fr)_360px] xl:px-5">
@ -730,63 +873,229 @@ function ResultCanvas({
const evidenceAssetId = evidence?.imageUrl
? assetIdByStoredUrl.get(evidence.imageUrl) ?? null
: null
const canConfirmFromLibrary = [
'BOARD',
'STEEL',
'ACCESSORY',
'PACKAGING',
].includes(line.category)
const editing = editingPriceLineId === line.id
const query = priceCandidateQuery.trim().toLowerCase()
const candidates = priceCandidatesByLine[line.id] ?? []
const visibleCandidates = query
? candidates.filter((candidate) =>
[
candidate.code,
candidate.name,
candidate.spec,
].some((value) =>
value.toLowerCase().includes(query),
),
)
: candidates
return (
<tr key={line.id} className="text-[11px] hover:bg-[#fafbfb]">
<td className="px-4 py-3">
<span className="rounded-[5px] bg-[#edf1f1] px-2 py-1 font-bold text-[#566466]">
{line.categoryLabel}
</span>
</td>
<td className="px-3 py-3">
<div className="font-semibold text-[#353b3d]">
{line.partName}
</div>
<div className="mt-0.5 text-[10px] text-[#878d8f]">
{[line.materialName, line.materialSpec, line.processName]
.filter(Boolean)
.join(' · ') || '--'}
</div>
{evidence?.note ? (
<div className="mt-1 flex max-w-[560px] items-start gap-1.5 text-[9px] leading-4 text-[#758082]">
<ScanSearch size={11} className="mt-0.5 shrink-0" />
<span className="line-clamp-2">{evidence.note}</span>
{evidenceAssetId ? (
<Fragment key={line.id}>
<tr className="text-[11px] hover:bg-[#fafbfb]">
<td className="px-4 py-3">
<span className="rounded-[5px] bg-[#edf1f1] px-2 py-1 font-bold text-[#566466]">
{line.categoryLabel}
</span>
</td>
<td className="px-3 py-3">
<div className="font-semibold text-[#353b3d]">
{line.partName}
</div>
<div className="mt-0.5 text-[10px] text-[#878d8f]">
{[line.materialName, line.materialSpec, line.processName]
.filter(Boolean)
.join(' · ') || '--'}
</div>
{evidence?.note ? (
<div className="mt-1 flex max-w-[560px] items-start gap-1.5 text-[9px] leading-4 text-[#758082]">
<ScanSearch size={11} className="mt-0.5 shrink-0" />
<span className="line-clamp-2">{evidence.note}</span>
{evidenceAssetId ? (
<button
type="button"
onClick={() => selectEvidenceAsset(evidenceAssetId)}
className="shrink-0 rounded-[4px] px-1.5 py-0.5 font-bold text-[#4d6267] hover:bg-[#edf1f1]"
>
查看图像
</button>
) : null}
</div>
) : null}
</td>
<td className="px-3 py-3 text-[#687174]">
{[line.lengthMm, line.widthMm, line.heightMm]
.filter((value) => value !== null)
.join(' × ') || '--'}
{line.category === 'BOARD' && line.heightMm === null ? (
<div className="mt-1 text-[9px] font-semibold text-[#9a6941]">
缺板厚
</div>
) : null}
</td>
<td className="px-3 py-3">
{line.quantity} {line.unit}
</td>
<td className="px-3 py-3">
{line.category === 'PROCESSING' ||
line.category === 'LABOR' ? (
<span className="font-semibold text-[#667274]">
{line.resolvedUnitPrice === null
? 'AI 估算待补'
: 'AI 估算'}
</span>
) : canConfirmFromLibrary ? (
<button
type="button"
onClick={() => void openPriceEditor(line)}
aria-expanded={editing}
className={cn(
'flex max-w-full items-center gap-1 rounded-[4px] px-1.5 py-1 text-left font-semibold outline-none transition focus-visible:ring-2 focus-visible:ring-[#40585d]',
line.matchStatus === 'MATCHED'
? 'text-[#4c625f] hover:bg-[#edf1f1]'
: 'text-[#8a5f3f] hover:bg-[#f5eee8]',
)}
>
<span className="truncate">
{line.matchStatus === 'MATCHED'
? line.matchedLibraryName
: line.matchStatus === 'NEEDS_CONFIRMATION'
? '确认价格'
: '匹配价格'}
</span>
<ChevronDown
size={12}
className={cn(
'shrink-0 transition-transform',
editing && 'rotate-180',
)}
/>
</button>
) : null}
{line.resolvedUnitPrice !== null ? (
<div className="mt-0.5 text-[9px] text-[#8a9092]">
{formatMoney(line.resolvedUnitPrice)}/{line.resolvedPriceUnit || line.unit}
</div>
) : null}
</td>
<td className="px-3 py-3 text-right font-bold">
{formatMoney(line.subtotal)}
</td>
</tr>
{editing ? (
<tr className="bg-[#f7f9f8]">
<td colSpan={6} className="px-4 py-3">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<div className="text-[11px] font-bold">
确认资料库价格
</div>
<div className="mt-0.5 text-[9px] text-[#727b7d]">
{line.category === 'PACKAGING'
? '价格只来自资产中心的可用包材规格款'
: line.category === 'BOARD' && line.heightMm === null
? '材种已识别,请确认板厚与对应基材'
: '选择与当前部件规格一致的资料库记录'}
</div>
</div>
{candidates.length > 6 ? (
<Input
value={priceCandidateQuery}
onChange={(event) =>
setPriceCandidateQuery(event.target.value)
}
placeholder="搜索名称、编码或规格"
aria-label="搜索价格候选"
leftIcon={<Search size={13} />}
containerClassName="w-[240px] !space-y-0"
className="!min-h-9 !rounded-[5px] !bg-white !py-1.5 text-[10px]"
/>
) : null}
</div>
{loadingPriceLineId === line.id ? (
<div className="mt-3 flex h-16 items-center gap-2 text-[10px] text-[#687375]">
<span className="h-2 w-2 animate-pulse rounded-full bg-[#536b70] motion-reduce:animate-none" />
正在读取资料库价格
</div>
) : priceEditorError ? (
<div className="mt-3 text-[10px] font-semibold text-[#a14f49]">
{priceEditorError}
</div>
) : candidates.length === 0 ? (
<div className="mt-3 flex h-16 items-center text-[10px] font-semibold text-[#8a5f3f]">
{line.category === 'PACKAGING'
? '资产中心暂无已启用且有价格的包材规格款'
: '基础资料库暂无可用于当前分类的价格'}
</div>
) : visibleCandidates.length === 0 ? (
<div className="mt-3 flex h-16 items-center text-[10px] text-[#747d7f]">
没有匹配的资料库记录
</div>
) : (
<div className="mt-3 grid max-h-[220px] grid-cols-2 gap-1.5 overflow-y-auto pr-1">
{visibleCandidates.map((candidate) => (
<button
key={candidate.recordId}
type="button"
disabled={savingPriceLineId === line.id}
onClick={() =>
void confirmPriceCandidate(line, candidate)
}
className={cn(
'flex min-w-0 items-center gap-3 rounded-[5px] border bg-white px-3 py-2 text-left outline-none transition hover:border-[#60777c]/50 focus-visible:ring-2 focus-visible:ring-[#40585d] disabled:cursor-wait disabled:opacity-60',
candidate.recommended
? 'border-[#60777c]/35'
: 'border-black/[0.07]',
)}
>
<div className="min-w-0 flex-1">
<div className="truncate text-[10px] font-bold">
{candidate.name}
</div>
<div className="mt-0.5 truncate text-[9px] text-[#7a8385]">
{[candidate.code, candidate.spec]
.filter(Boolean)
.join(' · ')}
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-[10px] font-bold tabular-nums">
{formatMoney(candidate.unitPrice)}
</div>
<div className="text-[8px] text-[#899092]">
/{candidate.unit}
</div>
</div>
{candidate.recommended ? (
<span className="shrink-0 rounded-[3px] bg-[#e5eeeb] px-1.5 py-0.5 text-[8px] font-bold text-[#4e6662]">
建议
</span>
) : null}
</button>
))}
</div>
)}
</div>
<button
type="button"
onClick={() => selectEvidenceAsset(evidenceAssetId)}
className="shrink-0 rounded-[4px] px-1.5 py-0.5 font-bold text-[#4d6267] hover:bg-[#edf1f1]"
onClick={() => setEditingPriceLineId(null)}
aria-label="关闭价格确认"
title="关闭"
className="shrink-0 rounded-[5px] p-1.5 text-[#737d7f] hover:bg-black/[0.05] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#40585d]"
>
查看图像
<X size={14} />
</button>
) : null}
</div>
) : null}
</td>
<td className="px-3 py-3 text-[#687174]">
{[line.lengthMm, line.widthMm, line.heightMm]
.filter((value) => value !== null)
.join(' × ') || '--'}
</td>
<td className="px-3 py-3">
{line.quantity} {line.unit}
</td>
<td className="px-3 py-3">
{line.matchStatus === 'MATCHED' ? (
<span className="text-[#4c625f]">
{line.matchedLibraryName || '分析估算'}
</span>
) : (
<span className="text-[#9a6941]">
{line.matchStatus === 'NEEDS_CONFIRMATION'
? '存在歧义'
: '待确认'}
</span>
)}
</td>
<td className="px-3 py-3 text-right font-bold">
{formatMoney(line.subtotal)}
</td>
</tr>
</div>
</td>
</tr>
) : null}
</Fragment>
)
})}
</tbody>
@ -818,32 +1127,46 @@ function ResultCanvas({
<section className="mt-5 bg-[#2d3335] px-4 py-4 text-white">
<p className="text-[10px] font-semibold text-white/70">预计出厂成本</p>
<div className="mt-1 text-[30px] font-black leading-none tabular-nums">
{formatMoney(selectedSku.estimatedCost)}
{selectedSku.estimatedCost === null
? '待确认'
: formatMoney(selectedSku.estimatedCost)}
</div>
<p className="mt-2 text-[10px] leading-4 text-white/68">
含材料、加工、人工、包材和损耗;不含税费、物流、平台佣金及营销费用
{selectedSku.estimatedCost === null
? `${incompleteCostMessage || '成本资料尚未完整'},完整后计算总价`
: '含材料、加工、人工、包材和损耗;不含税费、物流、平台佣金及营销费用'}
</p>
</section>
<section className="border-b border-black/[0.07] py-4">
<div className="grid grid-cols-2 gap-x-5 gap-y-2 text-[10px]">
{[
['材料', selectedSku.costSummary.material],
['加工', selectedSku.costSummary.processing],
['人工', selectedSku.costSummary.labor],
['包材', selectedSku.costSummary.packaging],
].map(([label, value]) => (
<div key={String(label)} className="flex items-center justify-between gap-2">
<span className="text-[#747c7e]">{label}</span>
<span className="font-bold">{formatMoney(value as number | null)}</span>
{costSummaryRows.map((row) => (
<div key={row.label} className="flex items-center justify-between gap-2">
<span className="text-[#747c7e]">{row.label}</span>
<span
className={cn(
'font-bold',
row.unresolved && 'text-[#8a5f3f]',
)}
>
{row.missing
? '未识别'
: row.unresolved
? '待匹配'
: formatMoney(row.value)}
</span>
</div>
))}
</div>
{selectedSku.costSummary.unresolvedLineCount > 0 ? (
{incompleteCostMessage ? (
<p className="mt-3 text-[10px] font-semibold text-[#9a6941]">
{selectedSku.costSummary.unresolvedLineCount} 项价格待确认
{incompleteCostMessage}
</p>
) : null}
) : (
<p className="mt-3 text-[9px] leading-4 text-[#7a8385]">
误差 ≤{detail.targetErrorPercent}% 是核算目标,需经真实采购成本回测验证
</p>
)}
</section>
<section className="border-b border-black/[0.07] py-4">
@ -1211,7 +1534,7 @@ export default function BomCostingWorkbenchClient({
</span>
</div>
<p className="mt-0.5 truncate text-[10px] text-[#747c7e]">
目标误差 ≤{initialCapabilities.targetErrorPercent}%
目标误差 ≤{initialCapabilities.targetErrorPercent}% · 待回测
</p>
</div>
</div>
@ -1430,6 +1753,7 @@ export default function BomCostingWorkbenchClient({
detail={selectedCase}
selectedSku={selectedSku}
onSelectSku={setSelectedSkuId}
onCaseUpdated={applyCaseDetail}
/>
) : hasCases ? (
<div className="flex min-h-[320px] items-center justify-center px-6 py-12 xl:h-full xl:min-h-0">

View File

@ -91,11 +91,24 @@ export interface BomCostingLineDetail {
matchedLibraryName: string
matchedLibrarySpec: string
resolvedUnitPrice: number | null
resolvedPriceUnit?: string
calculatedQuantity: number | null
subtotal: number | null
updatedAt: string
}
export interface BomCostingPriceCandidateDetail {
recordId: string
libraryType: string
code: string
name: string
spec: string
unit: string
unitPrice: number
thicknessMm: number | null
recommended: boolean
}
export interface BomCostingSkuDetail {
id: string
externalSkuId: string
@ -121,6 +134,7 @@ export interface BomCostingSkuDetail {
packaging: number
lossIncludedTotal: number
unresolvedLineCount: number
missingCostGroups?: Array<'MATERIAL' | 'PROCESSING' | 'LABOR' | 'PACKAGING'>
}
}

View File

@ -62,6 +62,23 @@ describe('BOM 核算计算内核', () => {
).toBeNull()
})
it('资产中心包材使用规格款单件成本,不再把单件价重复乘面积', () => {
expect(
calculateBomCostingLine({
category: 'PACKAGING',
packagingKind: 'CARTON',
pricingMode: 'UNIT',
lengthMm: 900,
widthMm: 800,
quantity: 2,
unitPrice: 12.5,
}),
).toMatchObject({
pricingQuantity: 2,
subtotal: 25,
})
})
it('汇总出厂成本并单独标记待确认行', () => {
expect(
summarizeBomCosting([
@ -79,6 +96,21 @@ describe('BOM 核算计算内核', () => {
packaging: 4,
lossIncludedTotal: 47,
unresolvedLineCount: 1,
missingCostGroups: [],
})
})
it('缺少成本组时不能把零元当作完整结果', () => {
expect(
summarizeBomCosting([
{ category: 'BOARD', subtotal: 30 },
{ category: 'PROCESSING', subtotal: 8 },
{ category: 'LABOR', subtotal: 5 },
]),
).toMatchObject({
packaging: 0,
unresolvedLineCount: 0,
missingCostGroups: ['PACKAGING'],
})
})
})

View File

@ -3,14 +3,19 @@ import type { BomCostingLineCategoryValue } from './contracts'
export type BomCostingPackagingKind =
| 'FOAM'
| 'FOAM_CORNER'
| 'EPE'
| 'PAPER_CORNER'
| 'PACKING_STRAP'
| 'CARTON'
| 'PE_BAG'
| 'STRETCH_FILM'
| 'ADHESIVE_TAPE'
| 'OTHER'
export interface BomCostingQuantityInput {
category: BomCostingLineCategoryValue
packagingKind?: BomCostingPackagingKind | null
pricingMode?: 'AREA_SQM' | 'LENGTH_M' | 'VOLUME_CBM' | 'UNIT' | null
lengthMm?: number | null
widthMm?: number | null
heightMm?: number | null
@ -34,8 +39,15 @@ export interface BomCostingCostSummary {
packaging: number
lossIncludedTotal: number
unresolvedLineCount: number
missingCostGroups: BomCostingCostGroup[]
}
export type BomCostingCostGroup =
| 'MATERIAL'
| 'PROCESSING'
| 'LABOR'
| 'PACKAGING'
function finitePositive(value: number | null | undefined) {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
@ -52,6 +64,35 @@ export function resolveBomCostingQuantity(input: BomCostingQuantityInput) {
const widthMm = finitePositive(input.widthMm)
const heightMm = finitePositive(input.heightMm)
if (input.pricingMode === 'UNIT') {
return quantity
}
if (
input.pricingMode === 'AREA_SQM' &&
lengthMm !== null &&
widthMm !== null &&
quantity !== null
) {
return (lengthMm * widthMm * quantity) / 1_000_000
}
if (
input.pricingMode === 'LENGTH_M' &&
lengthMm !== null &&
quantity !== null
) {
const normalizedLengthMeters = lengthMm > 10 ? lengthMm / 1_000 : lengthMm
return normalizedLengthMeters * quantity
}
if (
input.pricingMode === 'VOLUME_CBM' &&
lengthMm !== null &&
widthMm !== null &&
heightMm !== null &&
quantity !== null
) {
return (lengthMm * widthMm * heightMm * quantity) / 1_000_000_000
}
if (input.category === 'BOARD') {
if (lengthMm !== null && widthMm !== null && quantity !== null) {
return (lengthMm * widthMm * quantity) / 1_000_000
@ -129,9 +170,20 @@ export function summarizeBomCosting(lines: BomCostingSummaryLine[]): BomCostingC
packaging: 0,
lossIncludedTotal: 0,
unresolvedLineCount: 0,
missingCostGroups: [],
}
const presentGroups = new Set<BomCostingCostGroup>()
for (const line of lines) {
if (
line.category === 'BOARD' ||
line.category === 'STEEL' ||
line.category === 'ACCESSORY'
) {
presentGroups.add('MATERIAL')
} else {
presentGroups.add(line.category)
}
if (line.subtotal === null || !Number.isFinite(line.subtotal)) {
result.unresolvedLineCount += 1
continue
@ -149,6 +201,10 @@ export function summarizeBomCosting(lines: BomCostingSummaryLine[]): BomCostingC
}
}
result.missingCostGroups = (
['MATERIAL', 'PROCESSING', 'LABOR', 'PACKAGING'] as const
).filter((group) => !presentGroups.has(group))
result.material = roundBomCostMoney(result.material)
result.processing = roundBomCostMoney(result.processing)
result.labor = roundBomCostMoney(result.labor)

View File

@ -0,0 +1,80 @@
import { buildBomCostingPackagingCandidate } from './packaging-candidate'
describe('BOM 资产中心包材候选', () => {
it('使用规格款成本价并保留结构化包材类型', () => {
expect(
buildBomCostingPackagingCandidate({
id: 'carton-1',
code: 'PKG-CARTON-1',
name: '五层瓦楞纸箱',
sizeLabel: '900×800×200',
businessData: {
templateType: 'carton',
materialType: '五层瓦楞纸',
templateFields: {
pricingLengthMm: '1800',
pricingWidthMm: '1200',
squareMeterPrice: '4',
},
},
dimensions: {},
costPrice: 12.5,
estimatedCostPrice: 10,
familyName: '外箱',
familyMetadata: null,
}),
).toMatchObject({
libraryType: 'ASSET_PACKAGING_VARIANT',
recordId: 'carton-1',
packagingKind: 'CARTON',
pricingMode: 'UNIT',
unitPrice: 12.5,
})
})
it('没有直接成本价时使用资产中心模板公式,缺价格则不进入候选', () => {
expect(
buildBomCostingPackagingCandidate({
id: 'estimate-1',
code: 'PKG-ESTIMATE-1',
name: '估算价纸箱',
businessData: { templateType: 'carton', templateFields: {} },
dimensions: {},
costPrice: 0,
estimatedCostPrice: 9.8,
familyName: '外箱',
}),
).toMatchObject({ unitPrice: 9.8 })
expect(
buildBomCostingPackagingCandidate({
id: 'epe-1',
code: 'PKG-EPE-1',
name: 'EPE 片材',
businessData: {
templateType: 'epeSheet',
templateFields: {
lengthMm: '1000',
widthMm: '500',
thicknessMm: '20',
pricePerCubicMeter: '600',
},
},
dimensions: {},
familyName: '缓冲防护',
}),
).toMatchObject({
packagingKind: 'EPE',
unitPrice: 6,
})
expect(
buildBomCostingPackagingCandidate({
id: 'empty-1',
code: 'PKG-EMPTY-1',
name: '未定价包材',
businessData: { templateType: 'peBag', templateFields: {} },
dimensions: {},
familyName: '外包装',
}),
).toBeNull()
})
})

View File

@ -0,0 +1,154 @@
import {
PACKAGING_TEMPLATE_MAP,
type PackagingTemplateKey,
} from '@/lib/packaging-materials/catalog'
import { computePackagingMaterialCost } from '@/lib/packaging-materials/pricing'
import type { BomCostingLibraryCandidate } from './price-matcher'
interface AssetPackagingCandidateSource {
id: string
code: string
name: string
sizeLabel?: string | null
businessData?: unknown
dimensions?: unknown
costPrice?: number | null
estimatedCostPrice?: number | null
familyName: string
familyMetadata?: unknown
}
function readRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
return value as Record<string, unknown>
}
function readTemplateType(
businessDataValue: unknown,
familyMetadataValue: unknown,
): PackagingTemplateKey | null {
const businessData = readRecord(businessDataValue)
const familyMetadata = readRecord(familyMetadataValue)
for (const candidate of [
businessData.templateType,
familyMetadata.categoryLevel2,
]) {
if (
typeof candidate === 'string' &&
PACKAGING_TEMPLATE_MAP.has(candidate as PackagingTemplateKey)
) {
return candidate as PackagingTemplateKey
}
}
return null
}
function readTemplateFields(value: unknown): Record<string, string> {
const rawFields = readRecord(value).templateFields
if (!rawFields || typeof rawFields !== 'object' || Array.isArray(rawFields)) {
return {}
}
return Object.fromEntries(
Object.entries(rawFields).flatMap(([key, item]) =>
typeof item === 'string' || typeof item === 'number'
? [[key, String(item)]]
: [],
),
)
}
function readDimensions(
value: unknown,
): Record<string, string | number | null | undefined> {
return Object.fromEntries(
Object.entries(readRecord(value)).flatMap(([key, item]) =>
typeof item === 'string' || typeof item === 'number'
? [[key, item]]
: [],
),
)
}
function readPositivePrice(value: number | null | undefined) {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
: null
}
function mapPackagingKind(
templateType: PackagingTemplateKey | null,
): string {
switch (templateType) {
case 'carton':
return 'CARTON'
case 'foamVolume':
return 'FOAM'
case 'epeProfile':
case 'epeSheet':
return 'EPE'
case 'paperCorner':
return 'PAPER_CORNER'
case 'strappingBand':
return 'PACKING_STRAP'
case 'peBag':
return 'PE_BAG'
case 'stretchFilm':
return 'STRETCH_FILM'
case 'adhesiveTape':
return 'ADHESIVE_TAPE'
default:
return 'OTHER'
}
}
export function buildBomCostingPackagingCandidate(
source: AssetPackagingCandidateSource,
): BomCostingLibraryCandidate | null {
const templateType = readTemplateType(
source.businessData,
source.familyMetadata,
)
const templateFields = readTemplateFields(source.businessData)
const calculatedCost = templateType
? computePackagingMaterialCost(
templateType,
templateFields,
readDimensions(source.dimensions),
)
: null
const unitPrice =
readPositivePrice(source.costPrice) ??
readPositivePrice(source.estimatedCostPrice) ??
readPositivePrice(calculatedCost)
if (unitPrice === null) return null
const businessData = readRecord(source.businessData)
const materialType =
typeof businessData.materialType === 'string'
? businessData.materialType.trim()
: ''
const template = templateType
? PACKAGING_TEMPLATE_MAP.get(templateType)
: null
return {
libraryType: 'ASSET_PACKAGING_VARIANT',
recordId: source.id,
code: source.code,
name: source.name,
aliases: [
source.familyName,
template?.label ?? '',
materialType,
].filter(Boolean),
spec: [
template?.label,
materialType,
source.sizeLabel,
].filter(Boolean).join(' · '),
unit: '件',
unitPrice,
packagingKind: mapPackagingKind(templateType),
pricingMode: 'UNIT',
}
}

View File

@ -12,6 +12,8 @@ const candidates: BomCostingLibraryCandidate[] = [
spec: '18mm',
unit: '㎡',
unitPrice: 21,
materialFamily: '刨花板',
thicknessMm: 18,
},
{
libraryType: 'SUBSTRATE',
@ -21,6 +23,8 @@ const candidates: BomCostingLibraryCandidate[] = [
spec: '25mm',
unit: '㎡',
unitPrice: 29,
materialFamily: '刨花板',
thicknessMm: 25,
},
]
@ -45,4 +49,77 @@ describe('BOM 资料库价格匹配', () => {
}),
).toMatchObject({ status: 'MATCHED', candidate: { recordId: 'board-1' } })
})
it('按材质族识别外部描述,但缺少厚度时保持待确认', () => {
const rubberwoodCandidates: BomCostingLibraryCandidate[] = [
{
...candidates[0],
recordId: 'rubberwood-18',
code: 'RW-18',
name: '18ENF级橡胶木',
materialFamily: '橡胶木',
aliases: ['ENF级橡胶木'],
},
{
...candidates[1],
recordId: 'rubberwood-25',
code: 'RW-25',
name: '25ENF级橡胶木',
materialFamily: '橡胶木',
aliases: ['ENF级橡胶木'],
},
]
expect(
matchBomCostingLibraryCandidate(rubberwoodCandidates, {
name: 'FAS级橡胶木实木板',
}),
).toMatchObject({ status: 'NEEDS_CONFIRMATION' })
expect(
matchBomCostingLibraryCandidate(rubberwoodCandidates, {
name: 'FAS级橡胶木实木板',
thicknessMm: 18,
}),
).toMatchObject({
status: 'MATCHED',
candidate: { recordId: 'rubberwood-18' },
})
})
it('包材按结构化类型收窄候选,不用模糊名称猜价格', () => {
const packagingCandidates: BomCostingLibraryCandidate[] = [
{
libraryType: 'ASSET_PACKAGING_VARIANT',
recordId: 'carton-1',
code: 'PKG-CARTON-1',
name: '五层瓦楞纸箱',
spec: '纸箱',
unit: '件',
unitPrice: 12,
packagingKind: 'CARTON',
pricingMode: 'UNIT',
},
{
libraryType: 'ASSET_PACKAGING_VARIANT',
recordId: 'foam-1',
code: 'PKG-FOAM-1',
name: 'EPS 泡沫',
spec: '泡沫',
unit: '件',
unitPrice: 8,
packagingKind: 'FOAM',
pricingMode: 'UNIT',
},
]
expect(
matchBomCostingLibraryCandidate(packagingCandidates, {
name: '外箱',
packagingKind: 'CARTON',
}),
).toMatchObject({
status: 'MATCHED',
candidate: { recordId: 'carton-1' },
})
})
})

View File

@ -6,6 +6,11 @@ export interface BomCostingLibraryCandidate {
spec: string
unit: string
unitPrice: number
aliases?: string[]
materialFamily?: string
thicknessMm?: number | null
packagingKind?: string | null
pricingMode?: BomCostingPricingMode
}
export interface BomCostingLibraryLookup {
@ -13,8 +18,16 @@ export interface BomCostingLibraryLookup {
code?: string | null
name?: string | null
spec?: string | null
thicknessMm?: number | null
packagingKind?: string | null
}
export type BomCostingPricingMode =
| 'AREA_SQM'
| 'LENGTH_M'
| 'VOLUME_CBM'
| 'UNIT'
export type BomCostingLibraryMatchResult =
| { status: 'MATCHED'; candidate: BomCostingLibraryCandidate }
| { status: 'NEEDS_CONFIRMATION'; candidates: BomCostingLibraryCandidate[] }
@ -28,6 +41,24 @@ export function normalizeBomCostingIdentity(value?: string | null) {
.replace(/\s+/g, '')
}
export function normalizeBomCostingMaterialFamily(value?: string | null) {
return normalizeBomCostingIdentity(value)
.replace(/\d+(?:\.\d+)?(?:mm|毫米)/g, '')
.replace(/(?:enf|e0|e1|fas|fsc|carbp2|carb)/g, '')
.replace(/(?:环保)?等级?|级/g, '')
.replace(/(?:实木台面板?|实木板材?|实木板|基材|板材|木板)$/g, '')
.replace(/^厚度\d+(?:\.\d+)?/g, '')
}
export function readBomCostingThicknessMm(
value?: string | null,
): number | null {
const match = value?.match(/(\d+(?:\.\d+)?)\s*(?:mm|毫米)/i)
if (!match) return null
const parsed = Number(match[1])
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
}
function resolveCandidates(candidates: BomCostingLibraryCandidate[]) {
if (candidates.length === 1) {
return { status: 'MATCHED' as const, candidate: candidates[0] }
@ -61,17 +92,63 @@ export function matchBomCostingLibraryCandidate(
}
const name = normalizeBomCostingIdentity(lookup.name)
if (!name) return { status: 'UNMATCHED', candidates: [] }
const spec = normalizeBomCostingIdentity(lookup.spec)
const namedCandidates = candidates.filter(
(candidate) => normalizeBomCostingIdentity(candidate.name) === name,
)
const candidatesWithSpec = spec
? namedCandidates.filter(
(candidate) => normalizeBomCostingIdentity(candidate.spec) === spec,
)
: namedCandidates
if (name) {
const namedCandidates = candidates.filter(
(candidate) =>
normalizeBomCostingIdentity(candidate.name) === name ||
candidate.aliases?.some(
(alias) => normalizeBomCostingIdentity(alias) === name,
),
)
const candidatesWithSpec = spec
? namedCandidates.filter(
(candidate) => normalizeBomCostingIdentity(candidate.spec) === spec,
)
: namedCandidates
const exactResult = resolveCandidates(candidatesWithSpec)
if (exactResult.status !== 'UNMATCHED') return exactResult
}
return resolveCandidates(candidatesWithSpec)
const packagingKind = normalizeBomCostingIdentity(lookup.packagingKind)
if (packagingKind) {
const packagingCandidates = candidates.filter(
(candidate) =>
normalizeBomCostingIdentity(candidate.packagingKind) === packagingKind,
)
const packagingResult = resolveCandidates(packagingCandidates)
if (packagingResult.status !== 'UNMATCHED') return packagingResult
}
const materialFamily = normalizeBomCostingMaterialFamily(lookup.name)
if (!materialFamily) return { status: 'UNMATCHED', candidates: [] }
const semanticCandidates = candidates.filter((candidate) => {
const identities = [
candidate.materialFamily,
candidate.name,
...(candidate.aliases ?? []),
]
.map(normalizeBomCostingMaterialFamily)
.filter(Boolean)
return identities.some(
(identity) =>
identity === materialFamily ||
(identity.length >= 2 &&
materialFamily.length >= 2 &&
(identity.includes(materialFamily) ||
materialFamily.includes(identity))),
)
})
const thicknessMm =
lookup.thicknessMm ?? readBomCostingThicknessMm(lookup.spec)
const candidatesWithThickness = thicknessMm
? semanticCandidates.filter(
(candidate) =>
candidate.thicknessMm != null &&
Math.abs(candidate.thicknessMm - thicknessMm) < 0.01,
)
: semanticCandidates
return resolveCandidates(candidatesWithThickness)
}

View File

@ -12,10 +12,6 @@ import {
buildNonIntegratedBomAccessoryOptions,
decodeAccessoryReferenceLibraryRows,
} from '@/lib/accessory-library'
import {
buildNonIntegratedBomPackagingMaterialOptions,
decodePackagingMaterialReferenceLibraryRows,
} from '@/lib/packaging-material-library'
import {
buildNonIntegratedBomSteelFrameOptions,
} from '@/lib/non-integrated-bom-steel-materials'
@ -29,6 +25,7 @@ import {
import type {
BomCostingLibraryCandidate,
} from '@/lib/bom-costing/price-matcher'
import { buildBomCostingPackagingCandidate } from '@/lib/bom-costing/packaging-candidate'
const caseDetailInclude = Prisma.validator<Prisma.BomCostingCaseInclude>()({
sourceSnapshot: {
@ -252,7 +249,14 @@ export class BomCostingRepository {
id: true,
externalSkuId: true,
name: true,
estimatedCost: true,
bomLines: {
orderBy: { sortOrder: 'asc' },
select: {
category: true,
matchedLibraryType: true,
subtotal: true,
},
},
},
},
},
@ -737,7 +741,11 @@ export class BomCostingRepository {
const lines = await tx.bomCostingLine.findMany({
where: { skuId: input.skuId },
select: { subtotal: true },
select: {
category: true,
matchedLibraryType: true,
subtotal: true,
},
})
return lines
})
@ -756,20 +764,41 @@ export class BomCostingRepository {
async listPriceCandidates(): Promise<
Record<BomCostingLineCategory, BomCostingLibraryCandidate[]>
> {
const [substrates, referenceRows] = await Promise.all([
const [substrates, referenceRows, packagingVariants] = await Promise.all([
prisma.substrate.findMany({
where: { isActive: true },
include: { thicknessSpec: true },
include: {
category: true,
environmentGrade: true,
systemNameOption: true,
thicknessSpec: true,
},
}),
prisma.intakeReferenceLibraryItem.findMany({
where: {
isActive: true,
libraryType: {
in: ['STEEL_FRAME', 'ACCESSORY', 'PACKAGING_MATERIAL', 'EDGE_BANDING'],
in: ['STEEL_FRAME', 'ACCESSORY', 'EDGE_BANDING'],
},
},
orderBy: [{ sortOrder: 'asc' }, { updatedAt: 'desc' }],
}),
prisma.assetVariant.findMany({
where: {
assetType: 'PACKAGING',
status: 'AVAILABLE',
deletedAt: null,
assetFamily: {
type: 'PACKAGING',
status: 'AVAILABLE',
deletedAt: null,
},
},
include: {
assetFamily: true,
},
orderBy: [{ updatedAt: 'desc' }, { code: 'asc' }],
}),
])
const boardCandidates = substrates.flatMap<BomCostingLibraryCandidate>((substrate) => {
@ -778,14 +807,28 @@ export class BomCostingRepository {
1_000_000
const fullSheetPrice = substrate.suggestedPrice?.toNumber() ?? null
if (!fullSheetPrice || sheetArea <= 0) return []
const systemName =
readSystemName(substrate.metadata) ||
substrate.systemNameOption?.name ||
substrate.category.name ||
substrate.name
return [{
libraryType: 'SUBSTRATE',
recordId: substrate.id,
code: substrate.code,
name: readSystemName(substrate.metadata) || substrate.name,
spec: `${substrate.thicknessSpec.thicknessMm}mm`,
name: substrate.name,
aliases: [
systemName,
substrate.category.name,
substrate.systemNameOption?.name ?? '',
substrate.environmentGrade.name,
].filter(Boolean),
materialFamily: systemName,
thicknessMm: substrate.thicknessSpec.thicknessMm,
spec: `${substrate.thicknessSpec.thicknessMm}mm · ${substrate.environmentGrade.name}`,
unit: '㎡',
unitPrice: fullSheetPrice / sheetArea,
pricingMode: 'AREA_SQM',
}]
})
@ -845,21 +888,22 @@ export class BomCostingRepository {
}]
})
const packagingCandidates = buildNonIntegratedBomPackagingMaterialOptions(
decodePackagingMaterialReferenceLibraryRows(rowsByType('PACKAGING_MATERIAL')),
).flatMap<BomCostingLibraryCandidate>((option) => {
const unitPrice = parsePositivePrice(option.unitPrice)
if (unitPrice === null) return []
return [{
libraryType: 'PACKAGING_MATERIAL',
recordId: option.id,
code: option.code,
name: option.name,
spec: '',
unit: '件',
unitPrice,
}]
})
const packagingCandidates =
packagingVariants.flatMap<BomCostingLibraryCandidate>((variant) => {
const candidate = buildBomCostingPackagingCandidate({
id: variant.id,
code: variant.code,
name: variant.name,
sizeLabel: variant.sizeLabel,
businessData: variant.businessData,
dimensions: variant.dimensions,
costPrice: variant.costPrice?.toNumber() ?? null,
estimatedCostPrice: variant.estimatedCostPrice?.toNumber() ?? null,
familyName: variant.assetFamily.name,
familyMetadata: variant.assetFamily.metadata,
})
return candidate ? [candidate] : []
})
return {
BOARD: boardCandidates,

View File

@ -27,7 +27,10 @@ const SYSTEM_PROMPT = `你是家具产品结构与成本工程师。你的任务
7. 同结构 SKU 使用相同 structureGroupKey;每个结构组只在 structureGroups 中返回一次 bomLines,SKU 记录只绑定结构组,不得重复输出 BOM。
8. BOM 分类只能是 BOARD、STEEL、ACCESSORY、PACKAGING、PROCESSING、LABOR。
9. evidence 必须说明依据;引用图片时使用输入中真实的图片 URL。
10. confidence 必须在 0 到 1 之间。`
10. confidence 必须在 0 到 1 之间。
11. BOARD 的 lengthMm/widthMm 表示单块板件长宽,heightMm 只能表示板厚;没有板厚证据时必须省略 heightMm,并在 evidence.note 写明“板厚待确认”,不能把整机高度或部件外形高度填成板厚。
12. PACKAGING 必须按纸箱、泡沫、珍珠棉、护角、打包带等实际包材逐行拆分;packagingKind 必须与部件一致,尺寸表示单件包材规格,quantity 表示单套产品实际用量。看不出包材时不要编造。
13. materialName 使用可用于资料库匹配的材种或材料名称,例如“橡胶木”“刨花板”“碳钢”“瓦楞纸箱”,不要把颜色、营销等级或整段商品描述当作材种。`
const aiSkuAnalysisSchema = competitorSkuSchema
.omit({
@ -232,7 +235,7 @@ function buildUserText(
dimensions: {
lengthMm: 'number optional',
widthMm: 'number optional',
heightMm: 'number optional',
heightMm: 'BOARD 时仅表示板厚;其他分类表示单件高度,number optional',
},
},
structureGroups: [{
@ -244,7 +247,7 @@ function buildUserText(
materialName: 'string optional',
materialSpec: 'string optional',
processName: 'string optional',
packagingKind: 'FOAM | FOAM_CORNER | PAPER_CORNER | PACKING_STRAP | CARTON | OTHER optional',
packagingKind: 'FOAM | FOAM_CORNER | EPE | PAPER_CORNER | PACKING_STRAP | CARTON | PE_BAG | STRETCH_FILM | ADHESIVE_TAPE | OTHER optional',
lengthMm: 'number optional',
widthMm: 'number optional',
heightMm: 'number optional',

View File

@ -28,9 +28,13 @@ function normalizePackagingKind(value?: string) {
const supported = new Set<BomCostingPackagingKind>([
'FOAM',
'FOAM_CORNER',
'EPE',
'PAPER_CORNER',
'PACKING_STRAP',
'CARTON',
'PE_BAG',
'STRETCH_FILM',
'ADHESIVE_TAPE',
'OTHER',
])
return supported.has(normalized as BomCostingPackagingKind)
@ -70,6 +74,7 @@ function createExternalEstimateCandidate(
spec: line.processName ?? '',
unit: line.unit,
unitPrice: line.estimatedUnitPrice,
pricingMode: 'UNIT',
}
}
@ -87,11 +92,17 @@ function prepareLine(
code: line.materialCode,
name: line.materialName || line.partName,
spec: line.materialSpec,
thicknessMm: line.category === 'BOARD' ? line.heightMm : null,
packagingKind:
line.category === 'PACKAGING'
? normalizePackagingKind(line.packagingKind)
: null,
})
const matchedCandidate = match.status === 'MATCHED' ? match.candidate : null
const calculation = calculateBomCostingLine({
category: line.category,
packagingKind: normalizePackagingKind(line.packagingKind),
pricingMode: matchedCandidate?.pricingMode,
lengthMm: line.lengthMm,
widthMm: line.widthMm,
heightMm: line.heightMm,
@ -155,7 +166,9 @@ export function prepareBomCostingAnalysis(
subtotal: line.subtotal,
})),
)
const allLinesPriced = summary.unresolvedLineCount === 0
const allLinesPriced =
summary.unresolvedLineCount === 0 &&
summary.missingCostGroups.length === 0
return {
externalSkuId: sku.externalSkuId,

View File

@ -1,5 +1,6 @@
/** @jest-environment node */
import { Prisma } from '@prisma/client'
import { ErrorCode } from '@/server/core/errors'
import { bomCostingRepository } from '@/server/repositories/bom-costing.repository'
import { bomCostingService } from './bom-costing.service'
@ -104,4 +105,135 @@ describe('BomCostingService', () => {
resumed: false,
})
})
it('板材缺厚度时返回同材种候选并要求用户确认', async () => {
jest.spyOn(bomCostingRepository, 'findLineForUpdate').mockResolvedValue({
id: 'line-board',
category: 'BOARD',
materialCode: '',
materialName: 'FAS级橡胶木实木板',
materialSpec: '',
partName: '桌面板',
packagingKind: '',
heightMm: null,
sku: {
case: {
id: 'case-1',
createdById: operationsUser.id,
deletedAt: null,
},
},
} as never)
jest.spyOn(bomCostingRepository, 'listPriceCandidates').mockResolvedValue({
BOARD: [
{
libraryType: 'SUBSTRATE',
recordId: 'rubberwood-18',
code: 'RW-18',
name: '18ENF级橡胶木',
aliases: ['橡胶木'],
materialFamily: '橡胶木',
thicknessMm: 18,
spec: '18mm · ENF',
unit: '㎡',
unitPrice: 80.63,
pricingMode: 'AREA_SQM',
},
{
libraryType: 'SUBSTRATE',
recordId: 'rubberwood-30',
code: 'RW-30',
name: '30ENF级橡胶木',
aliases: ['橡胶木'],
materialFamily: '橡胶木',
thicknessMm: 30,
spec: '30mm · ENF',
unit: '㎡',
unitPrice: 117.58,
pricingMode: 'AREA_SQM',
},
],
STEEL: [],
ACCESSORY: [],
PACKAGING: [],
PROCESSING: [],
LABOR: [],
})
const candidates = await bomCostingService.listLinePriceCandidates(
'case-1',
'line-board',
operationsUser,
)
expect(candidates).toEqual([
expect.objectContaining({
recordId: 'rubberwood-18',
thicknessMm: 18,
recommended: true,
}),
expect.objectContaining({
recordId: 'rubberwood-30',
thicknessMm: 30,
recommended: true,
}),
])
})
it('历史包材价格不是资产中心规格款时不再展示完整成本', async () => {
jest.spyOn(bomCostingRepository, 'listCases').mockResolvedValue([
{
id: 'case-legacy-package',
title: '历史案例',
sourceUrl: 'https://example.com/item/1',
sourceHost: 'example.com',
status: 'READY',
progress: 100,
statusMessage: '已完成',
sourceCacheHit: false,
sourceSnapshot: {
status: 'READY',
imageCount: 1,
storedImageCount: 1,
failedImageCount: 0,
},
createdById: operationsUser.id,
createdAt: new Date('2026-07-29T00:00:00.000Z'),
skus: [
{
id: 'sku-legacy-package',
externalSkuId: 'sku-1',
name: '测试规格',
estimatedCost: new Prisma.Decimal(168.4),
bomLines: [
{
category: 'BOARD',
matchedLibraryType: 'SUBSTRATE',
subtotal: new Prisma.Decimal(96.56),
},
{
category: 'PROCESSING',
matchedLibraryType: 'EXTERNAL_ANALYSIS',
subtotal: new Prisma.Decimal(28),
},
{
category: 'LABOR',
matchedLibraryType: 'EXTERNAL_ANALYSIS',
subtotal: new Prisma.Decimal(18),
},
{
category: 'PACKAGING',
matchedLibraryType: 'PACKAGING',
subtotal: new Prisma.Decimal(25.84),
},
],
},
],
},
] as never)
const [summary] = await bomCostingService.listCases(operationsUser)
expect(summary.estimatedCostRange).toBeNull()
})
})

View File

@ -19,6 +19,7 @@ import {
type BomCostingPackagingKind,
} from '@/lib/bom-costing/cost-calculator'
import {
matchBomCostingLibraryCandidate,
type BomCostingLibraryCandidate,
} from '@/lib/bom-costing/price-matcher'
import { parseSafeCompetitorUrl } from '@/lib/bom-costing/url-safety'
@ -46,18 +47,34 @@ const CATEGORY_LABELS: Record<BomCostingLineCategory, string> = {
LABOR: '人工',
}
const CURRENT_PACKAGING_LIBRARY_TYPE = 'ASSET_PACKAGING_VARIANT'
function decimalToNumber(value: Prisma.Decimal | null | undefined) {
return value === null || value === undefined ? null : value.toNumber()
}
function isLegacyPackagingPriceSource(input: {
category: BomCostingLineCategory
matchedLibraryType: string
}) {
return (
input.category === BomCostingLineCategory.PACKAGING &&
input.matchedLibraryType !== CURRENT_PACKAGING_LIBRARY_TYPE
)
}
function normalizePackagingKind(value?: string) {
const normalized = value?.trim().toUpperCase() ?? ''
const supported = new Set<BomCostingPackagingKind>([
'FOAM',
'FOAM_CORNER',
'EPE',
'PAPER_CORNER',
'PACKING_STRAP',
'CARTON',
'PE_BAG',
'STRETCH_FILM',
'ADHESIVE_TAPE',
'OTHER',
])
return supported.has(normalized as BomCostingPackagingKind)
@ -81,6 +98,7 @@ function createSnapshot(
}
function serializeLine(line: BomCostingCaseDetailRecord['skus'][number]['bomLines'][number]) {
const needsPackagingReconfirmation = isLegacyPackagingPriceSource(line)
return {
id: line.id,
externalLineId: line.externalLineId,
@ -101,14 +119,33 @@ function serializeLine(line: BomCostingCaseDetailRecord['skus'][number]['bomLine
lossFactor: decimalToNumber(line.lossFactor) ?? 1,
sourceConfidence: decimalToNumber(line.sourceConfidence),
evidence: line.evidence,
matchStatus: line.matchStatus,
matchedLibraryType: line.matchedLibraryType,
matchedLibraryRecordId: line.matchedLibraryRecordId,
matchedLibraryName: line.matchedLibraryName,
matchedLibrarySpec: line.matchedLibrarySpec,
resolvedUnitPrice: decimalToNumber(line.resolvedUnitPrice),
calculatedQuantity: decimalToNumber(line.calculatedQuantity),
subtotal: decimalToNumber(line.subtotal),
matchStatus: needsPackagingReconfirmation
? BomCostingMatchStatus.NEEDS_CONFIRMATION
: line.matchStatus,
matchedLibraryType: needsPackagingReconfirmation
? ''
: line.matchedLibraryType,
matchedLibraryRecordId: needsPackagingReconfirmation
? ''
: line.matchedLibraryRecordId,
matchedLibraryName: needsPackagingReconfirmation
? ''
: line.matchedLibraryName,
matchedLibrarySpec: needsPackagingReconfirmation
? ''
: line.matchedLibrarySpec,
resolvedUnitPrice: needsPackagingReconfirmation
? null
: decimalToNumber(line.resolvedUnitPrice),
resolvedPriceUnit: needsPackagingReconfirmation
? ''
: line.priceSnapshots[0]?.unit || line.unit,
calculatedQuantity: needsPackagingReconfirmation
? null
: decimalToNumber(line.calculatedQuantity),
subtotal: needsPackagingReconfirmation
? null
: decimalToNumber(line.subtotal),
updatedAt: line.updatedAt.toISOString(),
}
}
@ -166,6 +203,15 @@ function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
: null,
skus: record.skus.map((sku) => {
const bomLines = sku.bomLines.map(serializeLine)
const costSummary = summarizeBomCosting(
bomLines.map((line) => ({
category: line.category,
subtotal: line.subtotal,
})),
)
const hasCompleteCost =
costSummary.unresolvedLineCount === 0 &&
costSummary.missingCostGroups.length === 0
return {
id: sku.id,
externalSkuId: sku.externalSkuId,
@ -181,15 +227,15 @@ function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
packageWidthMm: decimalToNumber(sku.packageWidthMm),
packageHeightMm: decimalToNumber(sku.packageHeightMm),
packageWeightKg: decimalToNumber(sku.packageWeightKg),
estimatedCost: decimalToNumber(sku.estimatedCost),
costCalculatedAt: sku.costCalculatedAt?.toISOString() ?? null,
estimatedCost: hasCompleteCost
? roundBomCostMoney(costSummary.lossIncludedTotal)
: null,
costCalculatedAt:
hasCompleteCost && sku.costCalculatedAt
? sku.costCalculatedAt.toISOString()
: null,
bomLines,
costSummary: summarizeBomCosting(
bomLines.map((line) => ({
category: line.category,
subtotal: line.subtotal,
})),
),
costSummary,
}
}),
taskEvents: [...record.taskEvents]
@ -235,8 +281,21 @@ export class BomCostingService {
skuCount: record.skus.length,
estimatedCostRange: record.skus.reduce<{ min: number; max: number } | null>(
(range, sku) => {
const value = decimalToNumber(sku.estimatedCost)
if (value === null) return range
const summary = summarizeBomCosting(
sku.bomLines.map((line) => ({
category: line.category,
subtotal: isLegacyPackagingPriceSource(line)
? null
: decimalToNumber(line.subtotal),
})),
)
if (
summary.unresolvedLineCount > 0 ||
summary.missingCostGroups.length > 0
) {
return range
}
const value = summary.lossIncludedTotal
return range
? { min: Math.min(range.min, value), max: Math.max(range.max, value) }
: { min: value, max: value }
@ -431,6 +490,7 @@ export class BomCostingService {
spec: line.matchedLibrarySpec,
unit: line.unit,
unitPrice: line.resolvedUnitPrice.toNumber(),
pricingMode: 'UNIT',
}
: null)
} else if (requestedRecordId) {
@ -458,6 +518,7 @@ export class BomCostingService {
const calculation = calculateBomCostingLine({
category: line.category,
packagingKind: normalizePackagingKind(line.packagingKind),
pricingMode: candidate?.pricingMode,
lengthMm,
widthMm,
heightMm,
@ -491,18 +552,84 @@ export class BomCostingService {
},
priceSnapshot: candidate ? createSnapshot(candidate, lossFactor) : null,
})
const allPriced = lineSubtotals.every((item) => item.subtotal !== null)
const summary = summarizeBomCosting(
lineSubtotals.map((item) => ({
category: item.category,
subtotal: isLegacyPackagingPriceSource(item)
? null
: decimalToNumber(item.subtotal),
})),
)
const allPriced =
summary.unresolvedLineCount === 0 &&
summary.missingCostGroups.length === 0
const estimatedCost = allPriced
? roundBomCostMoney(
lineSubtotals.reduce(
(sum, item) => sum + (item.subtotal?.toNumber() ?? 0),
0,
),
)
? roundBomCostMoney(summary.lossIncludedTotal)
: null
await bomCostingRepository.updateSkuEstimatedCost(line.skuId, estimatedCost)
return this.getCase(caseId, user)
}
async listLinePriceCandidates(
caseId: string,
lineId: string,
user: AuthUser,
) {
bomCostingAccessService.assertCanView(user)
const line = await bomCostingRepository.findLineForUpdate(lineId)
if (
!line ||
line.sku.case.id !== caseId ||
line.sku.case.deletedAt ||
(!bomCostingAccessService.canViewAllCases(user) &&
line.sku.case.createdById !== user.id)
) {
throw new AppError(ErrorCode.BOM_COSTING_CASE_NOT_FOUND)
}
const candidates = (
await bomCostingRepository.listPriceCandidates()
)[line.category]
const match = matchBomCostingLibraryCandidate(candidates, {
code: line.materialCode,
name: line.materialName || line.partName,
spec: line.materialSpec,
thicknessMm:
line.category === BomCostingLineCategory.BOARD
? decimalToNumber(line.heightMm)
: null,
packagingKind:
line.category === BomCostingLineCategory.PACKAGING
? normalizePackagingKind(line.packagingKind)
: null,
})
const recommendedIds = new Set(
match.status === 'MATCHED'
? [match.candidate.recordId]
: match.status === 'NEEDS_CONFIRMATION'
? match.candidates.map((candidate) => candidate.recordId)
: [],
)
return candidates
.map((candidate) => ({
recordId: candidate.recordId,
libraryType: candidate.libraryType,
code: candidate.code,
name: candidate.name,
spec: candidate.spec,
unit: candidate.unit,
unitPrice: candidate.unitPrice,
thicknessMm: candidate.thicknessMm ?? null,
recommended: recommendedIds.has(candidate.recordId),
}))
.sort(
(left, right) =>
Number(right.recommended) - Number(left.recommended) ||
left.name.localeCompare(right.name, 'zh-CN') ||
left.spec.localeCompare(right.spec, 'zh-CN'),
)
}
}
export const bomCostingService = new BomCostingService()