fix(bom-costing): align material and packaging cost truth
This commit is contained in:
parent
c1151b6af5
commit
91a5ce30ce
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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'>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
154
furniture-3d-system/src/lib/bom-costing/packaging-candidate.ts
Normal file
154
furniture-3d-system/src/lib/bom-costing/packaging-candidate.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
@ -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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user