fix(bom): isolate oversized board packing
This commit is contained in:
parent
c1985a2396
commit
5bd554f762
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user