PrintCal / scripts /update_cost_calculator.py
phucsd's picture
feat: cap nhat bang gia in nhanh kem giay INTC va in gia cong Konica C12000
973422f
Raw History Blame Contribute Delete
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!")