Download scripts/update_cost_calculator.py from phucsd/PrintCal: direct link, hf CLI and curl.
- Browser
- Download file 29.8 kB
-
https://huggingface.co/spaces/phucsd/PrintCal/resolve/main/scripts/update_cost_calculator.py
- Command line
-
hf download hf://spaces/phucsd/PrintCal/scripts/update_cost_calculator.py
-
curl -L -o update_cost_calculator.py https://huggingface.co/spaces/phucsd/PrintCal/resolve/main/scripts/update_cost_calculator.py
29.8 kB
| new_content = """import { | |
| CalculationInput, | |
| CalculationResult, | |
| ComparisonItem, | |
| DigitalMachine, | |
| DigitalMode, | |
| DigitalPrintGiaCong, | |
| DigitalPrintKemGiay, | |
| FinishingCostDetail, | |
| FinishingService, | |
| OffsetMachine, | |
| PaperType, | |
| } from '@/types'; | |
| import { calculateImposition } from './imposition'; | |
| export const DEFAULT_DIGITAL_GIA_CONG: DigitalPrintGiaCong[] = [ | |
| { id: 1, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: 'A4', widthMm: 210, heightMm: 297, paperLt249: 600, paper250To349: 800, paper350To450: 1000, decalPaperPlastic: 1200, decalClear: 1500, syntheticPaper: 1000, pvcPlastic: 1500 }, | |
| { id: 2, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: '330x355', widthMm: 330, heightMm: 355, paperLt249: 800, paper250To349: 1000, paper350To450: 1800, decalPaperPlastic: 1500, decalClear: 1700, syntheticPaper: 1800, pvcPlastic: 2000 }, | |
| { id: 3, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: 'A3', widthMm: 297, heightMm: 420, paperLt249: 1200, paper250To349: 1600, paper350To450: 2100, decalPaperPlastic: 2100, decalClear: 2300, syntheticPaper: 2100, pvcPlastic: 2500 }, | |
| { id: 4, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: '330x483', widthMm: 330, heightMm: 483, paperLt249: 2000, paper250To349: 2500, paper350To450: 2700, decalPaperPlastic: 2500, decalClear: 2800, syntheticPaper: 2700, pvcPlastic: 3000 }, | |
| { id: 5, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: '330x1200', widthMm: 330, heightMm: 1200, paperLt249: 4000, paper250To349: 7000, paper350To450: 10000, decalPaperPlastic: 6000, decalClear: 7500, syntheticPaper: 15000, pvcPlastic: 0 }, | |
| ]; | |
| export const DEFAULT_DIGITAL_KEM_GIAY: DigitalPrintKemGiay[] = [ | |
| { id: 1, paperCode: 'C100', paperName: 'Couche 100gsm', gsm: 100, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1600, price2Side: 2800 }, | |
| { id: 2, paperCode: 'C120', paperName: 'Couche 120gsm', gsm: 120, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1700, price2Side: 2900 }, | |
| { id: 3, paperCode: 'C150', paperName: 'Couche 150gsm', gsm: 150, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1800, price2Side: 3000 }, | |
| { id: 4, paperCode: 'C200', paperName: 'Couche 200gsm', gsm: 200, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1900, price2Side: 3100 }, | |
| { id: 5, paperCode: 'C250', paperName: 'Couche 250gsm', gsm: 250, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2100, price2Side: 3300 }, | |
| { id: 6, paperCode: 'C300', paperName: 'Couche 300gsm', gsm: 300, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2700, price2Side: 4300 }, | |
| { id: 7, paperCode: 'I250', paperName: 'Ivory 250gsm', gsm: 250, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2100, price2Side: 3300 }, | |
| { id: 8, paperCode: 'I300', paperName: 'Ivory 300gsm', gsm: 300, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2600, price2Side: 4200 }, | |
| { id: 9, paperCode: 'I350', paperName: 'Ivory 350gsm', gsm: 350, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 3300, price2Side: 5300 }, | |
| { id: 10, paperCode: 'F100', paperName: 'Fort 100gsm', gsm: 100, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1600, price2Side: 2800 }, | |
| { id: 11, paperCode: 'F120', paperName: 'Fort 120gsm', gsm: 120, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1700, price2Side: 2900 }, | |
| { id: 12, paperCode: 'F180', paperName: 'Fort 180gsm', gsm: 180, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1900, price2Side: 3100 }, | |
| { id: 13, paperCode: 'F230', paperName: 'Fort 230gsm', gsm: 230, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2100, price2Side: 3300 }, | |
| { id: 14, paperCode: 'F250', paperName: 'Fort 250gsm', gsm: 250, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2300, price2Side: 3500 }, | |
| { id: 15, paperCode: 'F180_355', paperName: 'Fort 180gsm (325x355)', gsm: 180, sheetSize: '325x355', widthMm: 325, heightMm: 355, price1Side: 1400, price2Side: 2200 }, | |
| { id: 16, paperCode: 'F230_355', paperName: 'Fort 230gsm (325x355)', gsm: 230, sheetSize: '325x355', widthMm: 325, heightMm: 355, price1Side: 1500, price2Side: 2300 }, | |
| ]; | |
| interface CostCalculatorOptions { | |
| input: CalculationInput; | |
| paperType: PaperType; | |
| offsetMachine?: OffsetMachine; | |
| allOffsetMachines?: OffsetMachine[]; | |
| digitalMachine?: DigitalMachine; | |
| digitalPricingGiaCong?: DigitalPrintGiaCong[]; | |
| digitalPricingKemGiay?: DigitalPrintKemGiay[]; | |
| allFinishingServices: FinishingService[]; | |
| } | |
| export function matchOffsetMachine( | |
| printSheet: { widthMm: number; heightMm: number }, | |
| allMachines?: OffsetMachine[], | |
| selectedMachineId?: number | |
| ): OffsetMachine { | |
| if (selectedMachineId && allMachines) { | |
| const found = allMachines.find((m) => m.id === selectedMachineId); | |
| if (found) return found; | |
| } | |
| const dimMin = Math.min(printSheet.widthMm, printSheet.heightMm); | |
| const dimMax = Math.max(printSheet.widthMm, printSheet.heightMm); | |
| if (allMachines && allMachines.length > 0) { | |
| // Khổ nhỏ dưới 65x43cm (nếu vừa trong 650 x 430 mm) | |
| if (dimMin <= 435 && dimMax <= 655) { | |
| const small = allMachines.find( | |
| (m) => | |
| m.name.toLowerCase().includes('nhỏ') || | |
| (m.maxWidthMm <= 655 && m.maxHeightMm <= 435) || | |
| (m.maxWidthMm <= 435 && m.maxHeightMm <= 655) | |
| ); | |
| if (small) return small; | |
| } | |
| // Khổ 65x86cm bắt nhíp chiều 86 | |
| const large = allMachines.find( | |
| (m) => m.name.includes('65x86') || m.maxWidthMm >= 800 || m.maxHeightMm >= 800 | |
| ); | |
| if (large) return large; | |
| } | |
| // Fallback mặc định chuẩn theo yêu cầu xưởng in | |
| if (dimMin <= 435 && dimMax <= 655) { | |
| return { | |
| id: 1, | |
| name: 'Máy Offset Khổ Nhỏ (dưới 65x43cm)', | |
| maxWidthMm: 650, | |
| maxHeightMm: 430, | |
| minWidthMm: 210, | |
| minHeightMm: 297, | |
| platePrice: 75000, | |
| setupCost: 900000, | |
| stepCost: 150000, | |
| defaultWasteSheets: 80, | |
| gripperMarginMm: 10, | |
| baseImpressions: 3000, | |
| includesPlate: true, | |
| }; | |
| } else { | |
| return { | |
| id: 2, | |
| name: 'Máy Offset Khổ 65x86cm (Bắt nhíp chiều 86)', | |
| maxWidthMm: 860, | |
| maxHeightMm: 650, | |
| minWidthMm: 430, | |
| minHeightMm: 650, | |
| platePrice: 100000, | |
| setupCost: 1200000, | |
| stepCost: 200000, | |
| defaultWasteSheets: 100, | |
| gripperMarginMm: 10, | |
| baseImpressions: 3000, | |
| includesPlate: true, | |
| }; | |
| } | |
| } | |
| export function calculatePrintCost({ | |
| input, | |
| paperType, | |
| offsetMachine, | |
| allOffsetMachines, | |
| digitalMachine, | |
| digitalPricingGiaCong = DEFAULT_DIGITAL_GIA_CONG, | |
| digitalPricingKemGiay = DEFAULT_DIGITAL_KEM_GIAY, | |
| allFinishingServices, | |
| }: CostCalculatorOptions): CalculationResult { | |
| const digitalMode: DigitalMode = input.digitalMode || 'with_paper'; | |
| // 1. Xác định công nghệ in mục tiêu | |
| let chosenTech: 'offset' | 'digital' = 'offset'; | |
| if (input.printTech === 'digital') { | |
| chosenTech = 'digital'; | |
| } else if (input.printTech === 'offset') { | |
| chosenTech = 'offset'; | |
| } else { | |
| // Tự động: số lượng < 300 con thường in nhanh rẻ hơn, >= 300 in offset rẻ hơn | |
| chosenTech = input.quantity < 300 ? 'digital' : 'offset'; | |
| } | |
| // 2. Tính bình trang & số con | |
| const preferredSheet = chosenTech === 'offset' && offsetMachine | |
| ? { widthMm: offsetMachine.maxWidthMm, heightMm: offsetMachine.maxHeightMm, name: offsetMachine.name } | |
| : undefined; | |
| const imposition = calculateImposition({ | |
| widthMm: input.widthMm, | |
| heightMm: input.heightMm, | |
| bleedMm: input.bleedMm ?? 2, | |
| paperType, | |
| printTech: chosenTech, | |
| digitalMode, | |
| preferredMachineSheet: preferredSheet, | |
| gripperMarginMm: offsetMachine?.gripperMarginMm ?? 10, | |
| }); | |
| const ups = Math.max(1, imposition.upsPerPrintSheet); | |
| const netPrintSheets = Math.ceil(input.quantity / ups); | |
| // 3. Tính bù hao gia công | |
| let finishingWasteSheets = 0; | |
| let totalFinishingWastePercent = 0; | |
| const activeFinishingDetails: FinishingCostDetail[] = []; | |
| for (const sel of (input.selectedFinishing || [])) { | |
| const service = allFinishingServices.find((s) => s.id === sel.serviceId); | |
| if (!service) continue; | |
| totalFinishingWastePercent += service.wastePercent; | |
| finishingWasteSheets += service.wasteSheets; | |
| // Tính chi phí từng mục gia công | |
| let subtotal = 0; | |
| let formula = ''; | |
| if (service.calcType === 'per_m2') { | |
| const areaM2 = ((input.widthMm + (input.bleedMm || 2) * 2) / 1000) * | |
| ((input.heightMm + (input.bleedMm || 2) * 2) / 1000) * | |
| input.quantity * (sel.sides || 1); | |
| const rawCost = areaM2 * service.unitPrice; | |
| subtotal = Math.max(service.minPrice, Math.round(rawCost)) + service.setupFee; | |
| formula = `${areaM2.toFixed(2)} m² × ${service.unitPrice.toLocaleString('vi-VN')}đ (tối thiểu ${service.minPrice.toLocaleString('vi-VN')}đ)`; | |
| } else if (service.calcType === 'die_cut') { | |
| const runCost = input.quantity * service.unitPrice; | |
| subtotal = Math.max(service.minPrice, runCost) + service.setupFee; | |
| formula = `Khuôn bế ${service.setupFee.toLocaleString('vi-VN')}đ + Công bế (${input.quantity} con × ${service.unitPrice}đ)`; | |
| } else if (service.calcType === 'per_product') { | |
| const rawCost = input.quantity * service.unitPrice; | |
| subtotal = Math.max(service.minPrice, rawCost) + service.setupFee; | |
| formula = `${input.quantity} con × ${service.unitPrice.toLocaleString('vi-VN')}đ`; | |
| } else if (service.calcType === 'per_sheet') { | |
| const rawCost = netPrintSheets * service.unitPrice; | |
| subtotal = Math.max(service.minPrice, rawCost) + service.setupFee; | |
| formula = `${netPrintSheets} tờ in × ${service.unitPrice.toLocaleString('vi-VN')}đ`; | |
| } else { | |
| // Fixed | |
| subtotal = service.unitPrice + service.setupFee; | |
| formula = `Trọn gói ${service.unitPrice.toLocaleString('vi-VN')}đ`; | |
| } | |
| activeFinishingDetails.push({ | |
| serviceId: service.id, | |
| name: service.name, | |
| category: service.category, | |
| calcType: service.calcType, | |
| quantity: input.quantity, | |
| unitPrice: service.unitPrice, | |
| setupFee: service.setupFee, | |
| subtotal, | |
| wasteSheets: service.wasteSheets, | |
| formula, | |
| }); | |
| } | |
| // Bù hao gia công theo % | |
| finishingWasteSheets += Math.ceil((netPrintSheets * totalFinishingWastePercent) / 100); | |
| // 4. Bù hao in ấn & Tổng số tờ in | |
| let printWasteSheets = 0; | |
| if (chosenTech === 'offset') { | |
| printWasteSheets = offsetMachine?.defaultWasteSheets ?? 100; | |
| } else { | |
| printWasteSheets = digitalMachine?.defaultWasteSheets ?? 4; | |
| } | |
| const totalPrintSheets = netPrintSheets + printWasteSheets + finishingWasteSheets; | |
| const cuts = Math.max(1, imposition.cutsPerParentSheet); | |
| const parentSheetsNeeded = Math.ceil(totalPrintSheets / cuts); | |
| const ramsNeeded = Math.round((parentSheetsNeeded / 500) * 100) / 100; | |
| // Tính kg giấy = Số tờ lớn * Dài (m) * Rộng (m) * (gsm / 1000) | |
| const parentAreaM2 = (paperType.parentWidthCm / 100) * (paperType.parentHeightCm / 100); | |
| const kgNeeded = Math.round(((parentSheetsNeeded * parentAreaM2 * paperType.gsm) / 1000) * 10) / 10; | |
| // 5. Tiền giấy: Bỏ giá /kg, chỉ áp dụng giá trên 500 tờ (trên ram) và giá dưới 500 tờ (dưới ram) | |
| const priceAbove500 = paperType.priceAbove500 || paperType.pricePerRam; | |
| const priceBelow500 = paperType.priceBelow500 || priceAbove500; | |
| // Nếu số tờ mẹ >= 500 (trên 1 ram) áp dụng giá sỉ nguyên ram, nếu < 500 tờ áp dụng giá lẻ dưới ram | |
| const isWholesale = parentSheetsNeeded >= 500; | |
| const effectiveRamPrice = isWholesale ? priceAbove500 : priceBelow500; | |
| let paperCost = Math.round(parentSheetsNeeded * (effectiveRamPrice / 500)); | |
| // 6. Tiền in ấn | |
| let plateCost = 0; | |
| let platesCount = 0; | |
| let printRunCost = 0; | |
| let shortRunFee = 0; | |
| let digitalDetails: CalculationResult['costs']['digitalDetails'] | undefined; | |
| if (chosenTech === 'offset') { | |
| const machine = offsetMachine || matchOffsetMachine(imposition.printSheet, allOffsetMachines, input.offsetMachineId); | |
| const is2Sides = input.printSides === '2_side'; | |
| const workType = input.offsetWorkType || 'self_turn'; | |
| const baseImpressions = machine.baseImpressions || 3000; | |
| const includesPlate = machine.includesPlate ?? true; | |
| if (!is2Sides) { | |
| // In 1 mặt: 1 ca máy, 1 bộ kẽm (chuẩn 4 màu) | |
| const colors = input.colorsFront || 4; | |
| platesCount = colors; | |
| if (includesPlate) { | |
| // Gói mở máy đã bao gồm kẽm 4 màu, chỉ tính thêm tiền kẽm nếu khách in màu thứ 5+ | |
| const extraColors = Math.max(0, colors - 4); | |
| plateCost = extraColors * machine.platePrice; | |
| } else { | |
| plateCost = platesCount * machine.platePrice; | |
| } | |
| const impressions = totalPrintSheets; | |
| if (impressions <= baseImpressions) { | |
| printRunCost = machine.setupCost; | |
| } else { | |
| const extraThousands = Math.ceil((impressions - baseImpressions) / 1000); | |
| printRunCost = machine.setupCost + extraThousands * machine.stepCost; | |
| } | |
| } else { | |
| // In 2 mặt | |
| if (workType === 'sheetwise') { | |
| // In 2 bài riêng: 2 ca in độc lập, 2 bộ kẽm | |
| const colors = (input.colorsFront || 4) + (input.colorsBack || 4); | |
| platesCount = colors; | |
| if (includesPlate) { | |
| // Gói mở máy đã bao gồm 1 bộ kẽm 4 màu cơ bản, bài thứ 2 hoặc màu thứ 5+ tính riêng kẽm | |
| const extraColors = Math.max(0, colors - 4); | |
| plateCost = extraColors * machine.platePrice; | |
| } else { | |
| plateCost = platesCount * machine.platePrice; | |
| } | |
| // 2 ca in độc lập | |
| const impressionsPerSide = totalPrintSheets; | |
| const costSide1 = impressionsPerSide <= baseImpressions | |
| ? machine.setupCost | |
| : machine.setupCost + Math.ceil((impressionsPerSide - baseImpressions) / 1000) * machine.stepCost; | |
| const costSide2 = impressionsPerSide <= baseImpressions | |
| ? machine.setupCost | |
| : machine.setupCost + Math.ceil((impressionsPerSide - baseImpressions) / 1000) * machine.stepCost; | |
| printRunCost = costSide1 + costSide2; | |
| } else { | |
| // Tự trở (Work & Turn) hoặc Trở nhíp (Tumble): Dùng chung 1 bộ kẽm ghép cả 2 mặt (1 ca máy) | |
| const colors = Math.max(input.colorsFront || 4, input.colorsBack || 4); | |
| platesCount = colors; | |
| if (includesPlate) { | |
| const extraColors = Math.max(0, colors - 4); | |
| plateCost = extraColors * machine.platePrice; | |
| } else { | |
| plateCost = platesCount * machine.platePrice; | |
| } | |
| // Chạy 2 lượt in (lượt ép x 2) | |
| const totalImpressions = totalPrintSheets * 2; | |
| if (totalImpressions <= baseImpressions) { | |
| printRunCost = machine.setupCost; | |
| } else { | |
| const extraThousands = Math.ceil((totalImpressions - baseImpressions) / 1000); | |
| printRunCost = machine.setupCost + extraThousands * machine.stepCost; | |
| } | |
| } | |
| } | |
| } else { | |
| // In nhanh kỹ thuật số (Digital) | |
| const is2Sides = input.printSides === '2_side'; | |
| let ratePerSheet = 0; | |
| let machineName = ''; | |
| let sheetSizeName = imposition.printSheet.name; | |
| let whiteInkCost = 0; | |
| // Phụ phí số lượng ít: | |
| // < 50 tờ: +30.000đ | |
| // < 100 tờ: +20.000đ | |
| // >= 100 tờ: 0đ | |
| if (totalPrintSheets < 50) { | |
| shortRunFee = 30000; | |
| } else if (totalPrintSheets < 100) { | |
| shortRunFee = 20000; | |
| } else { | |
| shortRunFee = 0; | |
| } | |
| if (digitalMode === 'with_paper') { | |
| // 1. In nhanh kèm giấy (INTC) - Đã gồm tiền giấy trọn gói | |
| machineName = 'In Nhanh Kèm Giấy (INTC)'; | |
| const kemGiayList = digitalPricingKemGiay && digitalPricingKemGiay.length > 0 | |
| ? digitalPricingKemGiay | |
| : DEFAULT_DIGITAL_KEM_GIAY; | |
| // Tìm dòng giá phù hợp theo mã giấy | |
| let matchedPricing = kemGiayList.find((kg) => | |
| paperType.code.toUpperCase().includes(kg.paperCode.toUpperCase()) | |
| ); | |
| // Nếu không khớp chính xác mã, so khớp theo tiền tố chất liệu (C, I, F) + GSM gần nhất | |
| if (!matchedPricing) { | |
| const pName = paperType.name.toLowerCase(); | |
| let prefix = 'C'; | |
| if (pName.includes('ivory') || paperType.code.toUpperCase().startsWith('I')) prefix = 'I'; | |
| else if (pName.includes('fort') || pName.includes('ford') || paperType.code.toUpperCase().startsWith('F')) prefix = 'F'; | |
| const sameType = kemGiayList.filter((kg) => kg.paperCode.startsWith(prefix)); | |
| if (sameType.length > 0) { | |
| matchedPricing = sameType.reduce((prev, curr) => | |
| Math.abs(curr.gsm - paperType.gsm) < Math.abs(prev.gsm - paperType.gsm) ? curr : prev | |
| ); | |
| } else { | |
| matchedPricing = kemGiayList[0]; | |
| } | |
| } | |
| if (matchedPricing) { | |
| ratePerSheet = is2Sides ? matchedPricing.price2Side : matchedPricing.price1Side; | |
| sheetSizeName = `${matchedPricing.sheetSize} mm (${matchedPricing.paperName})`; | |
| } else { | |
| ratePerSheet = is2Sides ? 3000 : 1800; | |
| } | |
| printRunCost = Math.round(totalPrintSheets * ratePerSheet) + shortRunFee; | |
| // Đã kèm giấy nên tiền giấy = 0 | |
| paperCost = 0; | |
| plateCost = 0; | |
| platesCount = 0; | |
| digitalDetails = { | |
| mode: 'with_paper', | |
| machineName, | |
| sheetSizeName, | |
| ratePerSheet, | |
| shortRunFee, | |
| note: `Bảng giá In Nhanh Kèm Giấy INTC (Đã gồm công in + giấy ${matchedPricing?.paperName || ''}). Phí SL ít: +${shortRunFee.toLocaleString('vi-VN')}đ`, | |
| }; | |
| } else { | |
| // 2. In nhanh gia công (Konica C12000 / C12010S 5 màu - Trang 2 PDF) | |
| machineName = 'Konica C12000 / C12010S 5 Màu'; | |
| const giaCongList = digitalPricingGiaCong && digitalPricingGiaCong.length > 0 | |
| ? digitalPricingGiaCong | |
| : DEFAULT_DIGITAL_GIA_CONG; | |
| // Nhận diện khổ in máy | |
| const pW = imposition.printSheet.widthMm; | |
| const pH = imposition.printSheet.heightMm; | |
| const maxDim = Math.max(pW, pH); | |
| const minDim = Math.min(pW, pH); | |
| let sheetRow: DigitalPrintGiaCong | undefined; | |
| if (maxDim > 500) { | |
| sheetRow = giaCongList.find((g) => g.sheetSize === '330x1200'); | |
| } else if (maxDim > 430 || minDim > 300) { | |
| sheetRow = giaCongList.find((g) => g.sheetSize === '330x483'); | |
| } else if (maxDim > 360 || minDim > 250) { | |
| sheetRow = giaCongList.find((g) => g.sheetSize === 'A3'); | |
| } else if (maxDim > 300) { | |
| sheetRow = giaCongList.find((g) => g.sheetSize === '330x355'); | |
| } else { | |
| sheetRow = giaCongList.find((g) => g.sheetSize === 'A4'); | |
| } | |
| if (!sheetRow) { | |
| sheetRow = giaCongList.find((g) => g.sheetSize === '330x483') || giaCongList[0]; | |
| } | |
| // Nhận diện nhóm chất liệu | |
| const pName = paperType.name.toLowerCase(); | |
| let click1Side = 0; | |
| if (sheetRow) { | |
| if (pName.includes('pvc')) { | |
| click1Side = sheetRow.pvcPlastic || sheetRow.paper350To450; | |
| } else if (pName.includes('nhựa') || pName.includes('synthetic')) { | |
| click1Side = sheetRow.syntheticPaper; | |
| } else if (pName.includes('trong') && pName.includes('decal')) { | |
| click1Side = sheetRow.decalClear; | |
| } else if (pName.includes('decal')) { | |
| click1Side = sheetRow.decalPaperPlastic; | |
| } else { | |
| // Giấy thường theo định lượng GSM | |
| if (paperType.gsm < 250) { | |
| click1Side = sheetRow.paperLt249; | |
| } else if (paperType.gsm <= 349) { | |
| click1Side = sheetRow.paper250To349; | |
| } else { | |
| click1Side = sheetRow.paper350To450; | |
| } | |
| } | |
| sheetSizeName = `${sheetRow.sheetSize} (${sheetRow.widthMm}x${sheetRow.heightMm}mm)`; | |
| } else { | |
| click1Side = is2Sides ? 2000 : 1000; | |
| } | |
| // Giá in 2 mặt = x2 giá 1 mặt | |
| ratePerSheet = is2Sides ? (click1Side * 2) : click1Side; | |
| // In mực trắng nếu có tùy chọn | |
| if (input.whiteInk) { | |
| whiteInkCost = totalPrintSheets * (is2Sides ? 4000 : 2000); | |
| } | |
| printRunCost = Math.round(totalPrintSheets * ratePerSheet) + whiteInkCost + shortRunFee; | |
| // Nếu khách tự cấp giấy -> tiền giấy = 0 | |
| if (input.customerSuppliedPaper) { | |
| paperCost = 0; | |
| } | |
| plateCost = 0; | |
| platesCount = 0; | |
| digitalDetails = { | |
| mode: 'without_paper', | |
| machineName, | |
| sheetSizeName, | |
| ratePerSheet, | |
| shortRunFee, | |
| whiteInkCost, | |
| note: `In Gia Công Konica C12000 (Khổ ${sheetSizeName}, Đơn giá ${ratePerSheet.toLocaleString('vi-VN')}đ/tờ). Phí SL ít: +${shortRunFee.toLocaleString('vi-VN')}đ${input.customerSuppliedPaper ? ' • Khách cấp giấy' : ''}`, | |
| }; | |
| } | |
| } | |
| const totalPrintCost = plateCost + printRunCost; | |
| const totalFinishingCost = activeFinishingDetails.reduce((sum, item) => sum + item.subtotal, 0); | |
| // 7. Tổng chi phí cơ sở & Giá bán | |
| const totalBaseCost = paperCost + totalPrintCost + totalFinishingCost; | |
| const profitMarginPercent = input.profitMarginPercent ?? 25; | |
| const profitAmount = Math.round((totalBaseCost * profitMarginPercent) / 100); | |
| const discountAmount = input.discountAmount ?? 0; | |
| const preTaxTotal = Math.max(0, totalBaseCost + profitAmount - discountAmount); | |
| const vatPercent = input.vatPercent ?? 0; | |
| const vatAmount = Math.round((preTaxTotal * vatPercent) / 100); | |
| const finalPrice = preTaxTotal + vatAmount; | |
| const unitPrice = input.quantity > 0 ? Math.round(finalPrice / input.quantity) : 0; | |
| // 8. Thuật toán So sánh In Nhanh vs In Offset & Tìm điểm hòa vốn | |
| const comparisonTable: ComparisonItem[] = []; | |
| const testQuantities = [50, 100, 200, 300, 500, 1000, 2000, 5000]; | |
| for (const qty of testQuantities) { | |
| // Tính thử chi phí digital | |
| const digResult = runQuickEstimate({ | |
| ...input, | |
| quantity: qty, | |
| printTech: 'digital', | |
| }, paperType, offsetMachine, digitalMachine, allFinishingServices, digitalPricingGiaCong, digitalPricingKemGiay); | |
| // Tính thử chi phí offset | |
| const offResult = runQuickEstimate({ | |
| ...input, | |
| quantity: qty, | |
| printTech: 'offset', | |
| }, paperType, offsetMachine, digitalMachine, allFinishingServices, digitalPricingGiaCong, digitalPricingKemGiay); | |
| comparisonTable.push({ | |
| quantity: qty, | |
| digitalTotal: digResult.finalPrice, | |
| digitalUnit: digResult.unitPrice, | |
| offsetTotal: offResult.finalPrice, | |
| offsetUnit: offResult.unitPrice, | |
| recommended: digResult.finalPrice <= offResult.finalPrice ? 'digital' : 'offset', | |
| }); | |
| } | |
| // Điểm hòa vốn (Break-even): Tìm mốc số lượng mà offset bắt đầu rẻ hơn digital | |
| let breakEvenQty = 300; | |
| for (let i = 0; i < comparisonTable.length - 1; i++) { | |
| if (comparisonTable[i].recommended === 'digital' && comparisonTable[i + 1].recommended === 'offset') { | |
| breakEvenQty = Math.round((comparisonTable[i].quantity + comparisonTable[i + 1].quantity) / 2); | |
| break; | |
| } | |
| } | |
| // Tính lại cho số lượng hiện tại nếu người dùng chọn 'auto' | |
| const curDigital = runQuickEstimate({ ...input, printTech: 'digital' }, paperType, offsetMachine, digitalMachine, allFinishingServices, digitalPricingGiaCong, digitalPricingKemGiay); | |
| const curOffset = runQuickEstimate({ ...input, printTech: 'offset' }, paperType, offsetMachine, digitalMachine, allFinishingServices, digitalPricingGiaCong, digitalPricingKemGiay); | |
| let recommendationText = ''; | |
| const diff = Math.abs(curDigital.finalPrice - curOffset.finalPrice); | |
| if (curDigital.finalPrice < curOffset.finalPrice) { | |
| recommendationText = `Với số lượng ${input.quantity.toLocaleString('vi-VN')} sản phẩm, In Nhanh KTS tiết kiệm hơn In Offset ${diff.toLocaleString('vi-VN')}đ (${Math.round((diff / curOffset.finalPrice) * 100)}%), không tốn phí mở máy và kẽm CTP, lấy nhanh trong ngày.`; | |
| } else { | |
| recommendationText = `Với số lượng ${input.quantity.toLocaleString('vi-VN')} sản phẩm, In Offset tối ưu chi phí hơn In Nhanh ${diff.toLocaleString('vi-VN')}đ (${Math.round((diff / curDigital.finalPrice) * 100)}%), giá đơn vị giảm sâu theo số lượng lớn.`; | |
| } | |
| return { | |
| imposition, | |
| quantities: { | |
| productQty: input.quantity, | |
| netPrintSheets, | |
| printWasteSheets, | |
| finishingWasteSheets, | |
| totalPrintSheets, | |
| parentSheetsNeeded, | |
| ramsNeeded, | |
| kgNeeded, | |
| }, | |
| costs: { | |
| paperCost, | |
| plateCost, | |
| platesCount, | |
| printRunCost, | |
| totalPrintCost, | |
| finishingCost: totalFinishingCost, | |
| finishingDetails: activeFinishingDetails, | |
| totalBaseCost, | |
| profitAmount, | |
| preTaxTotal, | |
| discountAmount, | |
| vatAmount, | |
| finalPrice, | |
| unitPrice, | |
| shortRunFee, | |
| digitalDetails, | |
| }, | |
| chosenTech, | |
| recommendation: { | |
| bestTech: curDigital.finalPrice <= curOffset.finalPrice ? 'digital' : 'offset', | |
| reason: recommendationText, | |
| digitalTotal: curDigital.finalPrice, | |
| offsetTotal: curOffset.finalPrice, | |
| difference: diff, | |
| breakEvenQty, | |
| comparisonTable, | |
| }, | |
| }; | |
| } | |
| // Hàm tính nhanh phục vụ bảng so sánh (tránh đệ quy) | |
| function runQuickEstimate( | |
| input: CalculationInput, | |
| paperType: PaperType, | |
| offsetMachine?: OffsetMachine, | |
| digitalMachine?: DigitalMachine, | |
| allFinishingServices: FinishingService[] = [], | |
| digitalPricingGiaCong: DigitalPrintGiaCong[] = DEFAULT_DIGITAL_GIA_CONG, | |
| digitalPricingKemGiay: DigitalPrintKemGiay[] = DEFAULT_DIGITAL_KEM_GIAY | |
| ): { finalPrice: number; unitPrice: number } { | |
| const isDigital = input.printTech === 'digital'; | |
| const digitalMode: DigitalMode = input.digitalMode || 'with_paper'; | |
| const preferredSheet = !isDigital && offsetMachine | |
| ? { widthMm: offsetMachine.maxWidthMm, heightMm: offsetMachine.maxHeightMm, name: offsetMachine.name } | |
| : undefined; | |
| const imposition = calculateImposition({ | |
| widthMm: input.widthMm, | |
| heightMm: input.heightMm, | |
| bleedMm: input.bleedMm ?? 2, | |
| paperType, | |
| printTech: input.printTech, | |
| digitalMode, | |
| preferredMachineSheet: preferredSheet, | |
| }); | |
| const ups = Math.max(1, imposition.upsPerPrintSheet); | |
| const netSheets = Math.ceil(input.quantity / ups); | |
| const printWaste = isDigital ? 4 : (offsetMachine?.defaultWasteSheets ?? 100); | |
| const totalSheets = netSheets + printWaste + 10; | |
| const cuts = Math.max(1, imposition.cutsPerParentSheet); | |
| const parentSheets = Math.ceil(totalSheets / cuts); | |
| const pAbove = paperType.priceAbove500 || paperType.pricePerRam; | |
| const pBelow = paperType.priceBelow500 || pAbove; | |
| const effectiveRamPrice = parentSheets >= 500 ? pAbove : pBelow; | |
| let paperCost = Math.round(parentSheets * (effectiveRamPrice / 500)); | |
| let printCost = 0; | |
| if (isDigital) { | |
| const is2Sides = input.printSides === '2_side'; | |
| let shortFee = 0; | |
| if (totalSheets < 50) shortFee = 30000; | |
| else if (totalSheets < 100) shortFee = 20000; | |
| if (digitalMode === 'with_paper') { | |
| const matched = digitalPricingKemGiay.find((kg) => | |
| paperType.code.toUpperCase().includes(kg.paperCode.toUpperCase()) | |
| ) || digitalPricingKemGiay[0]; | |
| const rate = is2Sides ? matched.price2Side : matched.price1Side; | |
| printCost = Math.round(totalSheets * rate) + shortFee; | |
| paperCost = 0; | |
| } else { | |
| const isA3 = imposition.printSheet.widthMm > 220 || imposition.printSheet.heightMm > 310; | |
| const rate = isA3 ? (is2Sides ? 4000 : 2000) : (is2Sides ? 1600 : 800); | |
| printCost = Math.round(totalSheets * rate) + shortFee; | |
| if (input.customerSuppliedPaper) paperCost = 0; | |
| } | |
| } else { | |
| const machine = offsetMachine || matchOffsetMachine(imposition.printSheet); | |
| const baseImpressions = machine.baseImpressions || 3000; | |
| const includesPlate = machine.includesPlate ?? true; | |
| const platePrice = includesPlate ? 0 : 4 * machine.platePrice; | |
| const impressions = input.printSides === '2_side' ? totalSheets * 2 : totalSheets; | |
| const runPrice = impressions <= baseImpressions | |
| ? machine.setupCost | |
| : machine.setupCost + Math.ceil((impressions - baseImpressions) / 1000) * machine.stepCost; | |
| printCost = platePrice + runPrice; | |
| } | |
| // Ước tính gia công cơ bản | |
| let finishingCost = 0; | |
| for (const sel of (input.selectedFinishing || [])) { | |
| const service = allFinishingServices.find((s) => s.id === sel.serviceId); | |
| if (service) { | |
| if (service.calcType === 'per_m2') { | |
| const area = ((input.widthMm + 4) / 1000) * ((input.heightMm + 4) / 1000) * input.quantity * (sel.sides || 1); | |
| finishingCost += Math.max(service.minPrice, Math.round(area * service.unitPrice)) + service.setupFee; | |
| } else { | |
| finishingCost += Math.max(service.minPrice, input.quantity * service.unitPrice) + service.setupFee; | |
| } | |
| } | |
| } | |
| const baseCost = paperCost + printCost + finishingCost; | |
| const margin = input.profitMarginPercent ?? 25; | |
| const finalPrice = Math.round(baseCost * (1 + margin / 100)); | |
| const unitPrice = input.quantity > 0 ? Math.round(finalPrice / input.quantity) : 0; | |
| return { finalPrice, unitPrice }; | |
| } | |
| """ | |
| with open("src/lib/engine/costCalculator.ts", "w", encoding="utf-8") as f: | |
| f.write(new_content) | |
| print("Updated costCalculator.ts successfully!") | |