fix(bom): isolate oversized board packing

This commit is contained in:
WuYongJie 2026-08-11 05:40:24 +08:00
parent c1985a2396
commit 5bd554f762
2 changed files with 121 additions and 3 deletions

View File

@ -363,6 +363,79 @@ describe('BomCostingScenarioService', () => {
})
})
it('单个超尺寸板件不会让同一 SKU 的其他板件全部退出批量裁切', async () => {
const bomLines = [
line({
id: 'fit-board',
category: 'BOARD',
partName: '可排版层板',
materialName: '橡胶木',
materialCode: 'RUBBERWOOD',
materialSpec: '明确标注板厚 18mm',
unit: '块',
quantity: 4,
lengthMm: 600,
widthMm: 600,
}),
line({
id: 'oversized-board',
category: 'BOARD',
partName: '超尺寸背板',
materialName: '橡胶木',
materialCode: 'RUBBERWOOD',
materialSpec: '明确标注板厚 18mm',
unit: '块',
quantity: 1,
lengthMm: 2_500,
widthMm: 1_000,
}),
]
mockBase({ bomLines })
jest.mocked(bomCostingScenarioRepository.listSubstrateSheetCandidates).mockResolvedValue([{
id: 'substrate-rubberwood-18',
code: 'RW-18',
name: '橡胶木指接板 18mm',
categoryName: '实木板',
systemName: '橡胶木',
environmentGrade: 'E0',
aliases: ['RW-18', '橡胶木', '橡胶木指接板'],
thicknessMm: 18,
sheetLengthMm: 2_440,
sheetWidthMm: 1_220,
fullSheetPrice: 200,
updatedAt: now,
}])
jest.spyOn(bomCostingScenarioRepository, 'findReferenceCandidates').mockImplementation(async (input) => [
candidate({
id: `historical-${input.partName}`,
category: 'BOARD',
partName: input.partName,
materialName: '橡胶木',
unit: '块',
unitPrice: input.partName === '超尺寸背板' ? 180 : 50,
}),
])
const service = new BomCostingScenarioService()
jest.spyOn(service, 'getCaseDynamicCosting').mockResolvedValue({} as never)
await service.calculate({
caseId: 'case-1',
skuId: 'sku-1',
targetMaterialId: 'material-rubberwood',
})
const saved = jest.mocked(bomCostingScenarioRepository.saveScenarioPair).mock.calls[0]![0]
const sameLines = new Map(saved.sameMaterial.lines.map((item) => [item.sourceLineId, item]))
expect(sameLines.get('fit-board')).toMatchObject({
calculationRule: 'board-sheet-maxrects/v2-batch',
priceSource: expect.stringContaining('批量裁切'),
})
expect(sameLines.get('oversized-board')).toMatchObject({
calculationRule: expect.not.stringContaining('board-sheet-maxrects'),
subtotal: 180,
})
})
it('小板件主成本采用同款批量排版,单件整板成本只保留在区间上限', async () => {
const bomLines = [line({
id: 'small-board',

View File

@ -505,11 +505,29 @@ function readNonNegativeEnvironmentNumber(name: string, fallback: number) {
return Number.isFinite(numeric) && numeric >= 0 ? numeric : fallback
}
function boardPartFitsSheet(input: {
lengthMm: number
widthMm: number
candidate: BomCostingSubstrateSheetCandidate
kerfMm: number
edgeMarginMm: number
}) {
const usableLength = input.candidate.sheetLengthMm - input.edgeMarginMm * 2 + input.kerfMm
const usableWidth = input.candidate.sheetWidthMm - input.edgeMarginMm * 2 + input.kerfMm
if (usableLength <= 0 || usableWidth <= 0) return false
return (
(input.lengthMm + input.kerfMm <= usableLength && input.widthMm + input.kerfMm <= usableWidth) ||
(input.widthMm + input.kerfMm <= usableLength && input.lengthMm + input.kerfMm <= usableWidth)
)
}
function buildBoardSheetPriceQuotes(input: {
lines: BomCostingLine[]
candidates: BomCostingSubstrateSheetCandidate[]
materialNamesForLine: (line: BomCostingLine) => string[]
}) {
const kerfMm = readNonNegativeEnvironmentNumber('BOM_COSTING_BOARD_KERF_MM', 4)
const edgeMarginMm = readNonNegativeEnvironmentNumber('BOM_COSTING_BOARD_EDGE_MARGIN_MM', 5)
const assigned = input.lines.flatMap((line) => {
if (line.category !== 'BOARD') return []
const lengthMm = positiveFinite(line.lengthMm)
@ -521,7 +539,34 @@ function buildBoardSheetPriceQuotes(input: {
input.candidates,
input.materialNamesForLine(line),
)
return selection ? [{ line, selection, candidate: selection.candidate, lengthMm, widthMm, quantity }] : []
if (!selection) return []
const fittingCandidates = selection.compatibleCandidates.filter((candidate) => boardPartFitsSheet({
lengthMm,
widthMm,
candidate,
kerfMm,
edgeMarginMm,
}))
if (fittingCandidates.length === 0) return []
const candidate = [...fittingCandidates].sort((left, right) => (
Math.abs(left.fullSheetPrice - selection.candidate.fullSheetPrice) -
Math.abs(right.fullSheetPrice - selection.candidate.fullSheetPrice) ||
Math.abs(left.thicknessMm - selection.candidate.thicknessMm) -
Math.abs(right.thicknessMm - selection.candidate.thicknessMm) ||
right.updatedAt.getTime() - left.updatedAt.getTime()
))[0]!
return [{
line,
selection: {
...selection,
candidate,
compatibleCandidates: fittingCandidates,
},
candidate,
lengthMm,
widthMm,
quantity,
}]
})
const groups = new Map<string, typeof assigned>()
for (const entry of assigned) {
@ -538,12 +583,12 @@ function buildBoardSheetPriceQuotes(input: {
sheetLengthMm: candidate.sheetLengthMm,
sheetWidthMm: candidate.sheetWidthMm,
fullSheetPrice: candidate.fullSheetPrice,
kerfMm: readNonNegativeEnvironmentNumber('BOM_COSTING_BOARD_KERF_MM', 4),
kerfMm,
// Five millimetres on each edge keeps a real trimming allowance while
// still allowing two 600 mm parts on a common 1220 mm sheet with kerf.
// The former 10 mm-per-edge default falsely doubled sheet counts for
// this very common layout and surfaced the trim as excessive loss.
edgeMarginMm: readNonNegativeEnvironmentNumber('BOM_COSTING_BOARD_EDGE_MARGIN_MM', 5),
edgeMarginMm,
}, group.map(({ line, lengthMm, widthMm, quantity }) => ({
lineId: line.id,
lengthMm,