Initial commit: Inventory Barcode System
This commit is contained in:
2
services/.gitkeep
Normal file
2
services/.gitkeep
Normal file
@ -0,0 +1,2 @@
|
||||
# Services directory
|
||||
This directory contains business logic services
|
||||
253
services/CodeGenerationService.js
Normal file
253
services/CodeGenerationService.js
Normal file
@ -0,0 +1,253 @@
|
||||
const JsBarcode = require('jsbarcode');
|
||||
const QRCode = require('qrcode');
|
||||
const { createCanvas } = require('canvas');
|
||||
|
||||
/**
|
||||
* Service for generating barcodes and QR codes for inventory products
|
||||
*/
|
||||
class CodeGenerationService {
|
||||
constructor() {
|
||||
this.supportedBarcodeFormats = ['CODE128', 'CODE39', 'EAN13', 'EAN8', 'UPC'];
|
||||
this.defaultBarcodeOptions = {
|
||||
format: 'CODE128',
|
||||
width: 2,
|
||||
height: 100,
|
||||
displayValue: true,
|
||||
fontSize: 20,
|
||||
textAlign: 'center',
|
||||
textPosition: 'bottom',
|
||||
textMargin: 2,
|
||||
fontOptions: '',
|
||||
font: 'monospace',
|
||||
background: '#ffffff',
|
||||
lineColor: '#000000',
|
||||
margin: 10
|
||||
};
|
||||
|
||||
this.defaultQROptions = {
|
||||
errorCorrectionLevel: 'M',
|
||||
type: 'image/png',
|
||||
quality: 0.92,
|
||||
margin: 1,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
},
|
||||
width: 200
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate barcode for a product code
|
||||
* @param {string} productCode - The product code to encode
|
||||
* @param {string} format - Barcode format (CODE128, CODE39, EAN13, etc.)
|
||||
* @param {Object} options - Additional barcode options
|
||||
* @returns {Promise<string>} Base64 encoded barcode image
|
||||
*/
|
||||
async generateBarcode(productCode, format = 'CODE128', options = {}) {
|
||||
try {
|
||||
if (!productCode || typeof productCode !== 'string') {
|
||||
throw new Error('Product code must be a non-empty string');
|
||||
}
|
||||
|
||||
if (!this.supportedBarcodeFormats.includes(format.toUpperCase())) {
|
||||
throw new Error(`Unsupported barcode format: ${format}. Supported formats: ${this.supportedBarcodeFormats.join(', ')}`);
|
||||
}
|
||||
|
||||
// Validate product code for specific formats
|
||||
let processedCode = this._validateProductCodeForFormat(productCode, format);
|
||||
|
||||
const barcodeOptions = {
|
||||
...this.defaultBarcodeOptions,
|
||||
...options,
|
||||
format: format.toUpperCase()
|
||||
};
|
||||
|
||||
// Create canvas for barcode generation
|
||||
const canvas = createCanvas(400, 200);
|
||||
|
||||
// Generate barcode
|
||||
JsBarcode(canvas, processedCode, barcodeOptions);
|
||||
|
||||
// Convert to base64
|
||||
const base64Image = canvas.toDataURL('image/png');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: base64Image,
|
||||
format: format.toUpperCase(),
|
||||
productCode: productCode,
|
||||
metadata: {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
format: 'PNG'
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
productCode: productCode,
|
||||
format: format
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code with embedded product data
|
||||
* @param {Object} productData - Product information to embed
|
||||
* @param {Object} options - QR code generation options
|
||||
* @returns {Promise<string>} Base64 encoded QR code image
|
||||
*/
|
||||
async generateQRCode(productData, options = {}) {
|
||||
try {
|
||||
if (!productData || typeof productData !== 'object') {
|
||||
throw new Error('Product data must be an object');
|
||||
}
|
||||
|
||||
if (!productData.product_code) {
|
||||
throw new Error('Product data must include product_code');
|
||||
}
|
||||
|
||||
// Create structured data for QR code
|
||||
const qrData = {
|
||||
code: productData.product_code,
|
||||
desc: productData.description || '',
|
||||
cat: productData.category || '',
|
||||
uom: productData.unit_of_measure || '',
|
||||
ts: new Date().toISOString()
|
||||
};
|
||||
|
||||
const qrOptions = {
|
||||
...this.defaultQROptions,
|
||||
...options
|
||||
};
|
||||
|
||||
// Generate QR code
|
||||
const qrCodeDataURL = await QRCode.toDataURL(JSON.stringify(qrData), qrOptions);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: qrCodeDataURL,
|
||||
productCode: productData.product_code,
|
||||
embeddedData: qrData,
|
||||
metadata: {
|
||||
format: 'PNG',
|
||||
errorCorrectionLevel: qrOptions.errorCorrectionLevel,
|
||||
width: qrOptions.width
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
productData: productData
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate both barcode and QR code for a product
|
||||
* @param {Object} productData - Product information
|
||||
* @param {Object} options - Generation options
|
||||
* @returns {Promise<Object>} Object containing both codes
|
||||
*/
|
||||
async generateBothCodes(productData, options = {}) {
|
||||
const barcodeOptions = options.barcode || {};
|
||||
const qrOptions = options.qr || {};
|
||||
const barcodeFormat = options.barcodeFormat || 'CODE128';
|
||||
|
||||
const [barcodeResult, qrResult] = await Promise.all([
|
||||
this.generateBarcode(productData.product_code, barcodeFormat, barcodeOptions),
|
||||
this.generateQRCode(productData, qrOptions)
|
||||
]);
|
||||
|
||||
return {
|
||||
productCode: productData.product_code,
|
||||
barcode: barcodeResult,
|
||||
qrCode: qrResult,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get supported barcode formats
|
||||
* @returns {Array<string>} List of supported formats
|
||||
*/
|
||||
getSupportedFormats() {
|
||||
return [...this.supportedBarcodeFormats];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate product code for specific barcode format
|
||||
* @private
|
||||
* @param {string} productCode - Product code to validate
|
||||
* @param {string} format - Barcode format
|
||||
* @returns {string} Processed product code for barcode generation
|
||||
*/
|
||||
_validateProductCodeForFormat(productCode, format) {
|
||||
const upperFormat = format.toUpperCase();
|
||||
|
||||
switch (upperFormat) {
|
||||
case 'EAN13':
|
||||
if (!/^\d{12,13}$/.test(productCode)) {
|
||||
throw new Error('EAN13 format requires 12-13 digits');
|
||||
}
|
||||
// For 13-digit codes, remove the last digit (check digit) for jsbarcode
|
||||
// jsbarcode will calculate the check digit automatically
|
||||
return productCode.length === 13 ? productCode.substring(0, 12) : productCode;
|
||||
case 'EAN8':
|
||||
if (!/^\d{7,8}$/.test(productCode)) {
|
||||
throw new Error('EAN8 format requires 7-8 digits');
|
||||
}
|
||||
// For 8-digit codes, remove the last digit (check digit) for jsbarcode
|
||||
return productCode.length === 8 ? productCode.substring(0, 7) : productCode;
|
||||
case 'UPC':
|
||||
if (!/^\d{11,12}$/.test(productCode)) {
|
||||
throw new Error('UPC format requires 11-12 digits');
|
||||
}
|
||||
// For 12-digit codes, remove the last digit (check digit) for jsbarcode
|
||||
return productCode.length === 12 ? productCode.substring(0, 11) : productCode;
|
||||
case 'CODE39':
|
||||
if (!/^[A-Z0-9\-. $\/+%]+$/.test(productCode)) {
|
||||
throw new Error('CODE39 format supports only uppercase letters, digits, and specific symbols');
|
||||
}
|
||||
return productCode;
|
||||
case 'CODE128':
|
||||
// CODE128 supports most ASCII characters, so minimal validation
|
||||
if (productCode.length === 0) {
|
||||
throw new Error('Product code cannot be empty');
|
||||
}
|
||||
return productCode;
|
||||
default:
|
||||
return productCode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse QR code data back to product information
|
||||
* @param {string} qrData - Raw QR code data
|
||||
* @returns {Object} Parsed product data
|
||||
*/
|
||||
parseQRCodeData(qrData) {
|
||||
try {
|
||||
const parsed = JSON.parse(qrData);
|
||||
return {
|
||||
success: true,
|
||||
productCode: parsed.code,
|
||||
description: parsed.desc,
|
||||
category: parsed.cat,
|
||||
unitOfMeasure: parsed.uom,
|
||||
timestamp: parsed.ts
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid QR code data format',
|
||||
rawData: qrData
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CodeGenerationService;
|
||||
795
services/ExcelExportService.js
Normal file
795
services/ExcelExportService.js
Normal file
@ -0,0 +1,795 @@
|
||||
const XLSX = require('xlsx');
|
||||
const database = require('../models/database');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
|
||||
/**
|
||||
* Service for exporting inventory data to Excel files
|
||||
*/
|
||||
class ExcelExportService {
|
||||
constructor() {
|
||||
this.exportDirectory = path.join(__dirname, '..', 'data', 'exports');
|
||||
this.ensureExportDirectory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure export directory exists
|
||||
*/
|
||||
async ensureExportDirectory() {
|
||||
try {
|
||||
await fs.mkdir(this.exportDirectory, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to create export directory:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export inventory data to Excel format
|
||||
* @param {Object} options - Export options
|
||||
* @returns {Object} Export results with file path and metadata
|
||||
*/
|
||||
async exportInventoryToExcel(options = {}) {
|
||||
try {
|
||||
const {
|
||||
format = 'xlsx',
|
||||
includeHistory = false,
|
||||
filters = {},
|
||||
filename,
|
||||
originalFileBuffer = null,
|
||||
preserveFormatting = true
|
||||
} = options;
|
||||
|
||||
// Get inventory data
|
||||
const inventoryData = await this.getInventoryData(filters);
|
||||
|
||||
let workbook;
|
||||
let exportResult;
|
||||
|
||||
if (originalFileBuffer && preserveFormatting) {
|
||||
// Preserve original Excel structure and update data
|
||||
exportResult = await this.updateOriginalExcelFile(originalFileBuffer, inventoryData, options);
|
||||
} else {
|
||||
// Create new Excel file with standard format
|
||||
exportResult = await this.createNewExcelFile(inventoryData, options);
|
||||
}
|
||||
|
||||
// Generate filename if not provided
|
||||
const exportFilename = filename || this.generateExportFilename(format);
|
||||
const exportPath = path.join(this.exportDirectory, exportFilename);
|
||||
|
||||
// Write file to disk
|
||||
await this.writeExcelFile(exportResult.workbook, exportPath, format);
|
||||
|
||||
// Create export session record
|
||||
const sessionId = await this.createExportSession({
|
||||
filename: exportFilename,
|
||||
totalRecords: inventoryData.length,
|
||||
filters: filters,
|
||||
includeHistory: includeHistory
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filePath: exportPath,
|
||||
filename: exportFilename,
|
||||
sessionId: sessionId,
|
||||
recordCount: inventoryData.length,
|
||||
exportDate: new Date().toISOString(),
|
||||
metadata: exportResult.metadata
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
filePath: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inventory data with optional filtering
|
||||
* @param {Object} filters - Filter options
|
||||
* @returns {Array} Array of inventory records with product information
|
||||
*/
|
||||
async getInventoryData(filters = {}) {
|
||||
const db = database.getDatabase();
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
p.product_code,
|
||||
p.description,
|
||||
p.category,
|
||||
p.unit_of_measure,
|
||||
i.current_level,
|
||||
i.minimum_level,
|
||||
i.maximum_level,
|
||||
i.last_updated,
|
||||
i.updated_by,
|
||||
p.created_at as product_created_at,
|
||||
p.updated_at as product_updated_at,
|
||||
CASE
|
||||
WHEN i.current_level <= i.minimum_level THEN 'Low Stock'
|
||||
WHEN i.current_level <= i.minimum_level * 1.5 THEN 'Warning'
|
||||
ELSE 'Normal'
|
||||
END as stock_status
|
||||
FROM products p
|
||||
LEFT JOIN inventory i ON p.id = i.product_id
|
||||
`;
|
||||
|
||||
const params = [];
|
||||
const conditions = [];
|
||||
|
||||
// Apply filters
|
||||
if (filters.category) {
|
||||
conditions.push('p.category = ?');
|
||||
params.push(filters.category);
|
||||
}
|
||||
|
||||
if (filters.stockStatus) {
|
||||
switch (filters.stockStatus) {
|
||||
case 'low':
|
||||
conditions.push('i.current_level <= i.minimum_level');
|
||||
break;
|
||||
case 'warning':
|
||||
conditions.push('i.current_level <= i.minimum_level * 1.5 AND i.current_level > i.minimum_level');
|
||||
break;
|
||||
case 'normal':
|
||||
conditions.push('i.current_level > i.minimum_level * 1.5');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.updatedSince) {
|
||||
conditions.push('i.last_updated >= ?');
|
||||
params.push(filters.updatedSince);
|
||||
}
|
||||
|
||||
if (filters.productCodes && filters.productCodes.length > 0) {
|
||||
const placeholders = filters.productCodes.map(() => '?').join(',');
|
||||
conditions.push(`p.product_code IN (${placeholders})`);
|
||||
params.push(...filters.productCodes);
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
|
||||
query += ' ORDER BY p.product_code';
|
||||
|
||||
const stmt = db.prepare(query);
|
||||
return stmt.all(...params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update original Excel file with current inventory data
|
||||
* @param {Buffer} originalFileBuffer - Original Excel file buffer
|
||||
* @param {Array} inventoryData - Current inventory data
|
||||
* @param {Object} options - Update options
|
||||
* @returns {Object} Updated workbook and metadata
|
||||
*/
|
||||
async updateOriginalExcelFile(originalFileBuffer, inventoryData, options = {}) {
|
||||
try {
|
||||
// Read original workbook
|
||||
const workbook = XLSX.read(originalFileBuffer, { type: 'buffer', cellStyles: true });
|
||||
const sheetName = options.sheetName || workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
|
||||
if (!worksheet) {
|
||||
throw new Error(`Sheet "${sheetName}" not found in original file`);
|
||||
}
|
||||
|
||||
// Get original data structure
|
||||
const originalData = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: '' });
|
||||
|
||||
if (originalData.length === 0) {
|
||||
throw new Error('Original Excel file appears to be empty');
|
||||
}
|
||||
|
||||
// Detect column mappings from original file
|
||||
const headerRow = originalData[0];
|
||||
const columnMapping = this.detectColumnMappings(headerRow);
|
||||
|
||||
// Create lookup map for current inventory data
|
||||
const inventoryMap = new Map();
|
||||
inventoryData.forEach(item => {
|
||||
inventoryMap.set(item.product_code, item);
|
||||
});
|
||||
|
||||
// Update data rows while preserving structure
|
||||
const updatedData = [...originalData];
|
||||
let updatedCount = 0;
|
||||
let addedCount = 0;
|
||||
|
||||
// Update existing rows
|
||||
for (let i = 1; i < originalData.length; i++) {
|
||||
const row = [...originalData[i]];
|
||||
const productCode = this.getCellValue(row, columnMapping.productCode);
|
||||
|
||||
if (productCode && inventoryMap.has(productCode)) {
|
||||
const inventoryItem = inventoryMap.get(productCode);
|
||||
|
||||
// Update quantity/current level
|
||||
if (columnMapping.quantity !== null) {
|
||||
row[columnMapping.quantity] = inventoryItem.current_level;
|
||||
}
|
||||
|
||||
// Update description if it exists and is different
|
||||
if (columnMapping.description !== null && inventoryItem.description) {
|
||||
row[columnMapping.description] = inventoryItem.description;
|
||||
}
|
||||
|
||||
// Update category if it exists
|
||||
if (columnMapping.category !== null && inventoryItem.category) {
|
||||
row[columnMapping.category] = inventoryItem.category;
|
||||
}
|
||||
|
||||
// Add timestamp column if requested
|
||||
if (options.includeTimestamp) {
|
||||
if (columnMapping.lastUpdated === null) {
|
||||
// Add new column for timestamp
|
||||
if (i === 1) {
|
||||
// Add header
|
||||
updatedData[0].push('Last Updated');
|
||||
columnMapping.lastUpdated = updatedData[0].length - 1;
|
||||
}
|
||||
row.push(inventoryItem.last_updated || '');
|
||||
} else {
|
||||
row[columnMapping.lastUpdated] = inventoryItem.last_updated || '';
|
||||
}
|
||||
}
|
||||
|
||||
updatedData[i] = row;
|
||||
updatedCount++;
|
||||
inventoryMap.delete(productCode); // Remove from map to track what's been processed
|
||||
}
|
||||
}
|
||||
|
||||
// Add new products that weren't in original file
|
||||
if (options.includeNewProducts && inventoryMap.size > 0) {
|
||||
inventoryMap.forEach(inventoryItem => {
|
||||
const newRow = new Array(headerRow.length).fill('');
|
||||
|
||||
if (columnMapping.productCode !== null) {
|
||||
newRow[columnMapping.productCode] = inventoryItem.product_code;
|
||||
}
|
||||
if (columnMapping.description !== null) {
|
||||
newRow[columnMapping.description] = inventoryItem.description || '';
|
||||
}
|
||||
if (columnMapping.category !== null) {
|
||||
newRow[columnMapping.category] = inventoryItem.category || '';
|
||||
}
|
||||
if (columnMapping.quantity !== null) {
|
||||
newRow[columnMapping.quantity] = inventoryItem.current_level;
|
||||
}
|
||||
if (columnMapping.lastUpdated !== null) {
|
||||
newRow[columnMapping.lastUpdated] = inventoryItem.last_updated || '';
|
||||
}
|
||||
|
||||
updatedData.push(newRow);
|
||||
addedCount++;
|
||||
});
|
||||
}
|
||||
|
||||
// Convert back to worksheet
|
||||
const newWorksheet = XLSX.utils.aoa_to_sheet(updatedData);
|
||||
|
||||
// Preserve column widths and formatting where possible
|
||||
if (worksheet['!cols']) {
|
||||
newWorksheet['!cols'] = worksheet['!cols'];
|
||||
}
|
||||
if (worksheet['!rows']) {
|
||||
newWorksheet['!rows'] = worksheet['!rows'];
|
||||
}
|
||||
|
||||
// Replace the worksheet in workbook
|
||||
workbook.Sheets[sheetName] = newWorksheet;
|
||||
|
||||
return {
|
||||
workbook: workbook,
|
||||
metadata: {
|
||||
originalRows: originalData.length - 1,
|
||||
updatedRows: updatedCount,
|
||||
addedRows: addedCount,
|
||||
preservedFormatting: true,
|
||||
sheetName: sheetName
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to update original Excel file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new Excel file with inventory data
|
||||
* @param {Array} inventoryData - Inventory data to export
|
||||
* @param {Object} options - Export options
|
||||
* @returns {Object} New workbook and metadata
|
||||
*/
|
||||
async createNewExcelFile(inventoryData, options = {}) {
|
||||
const { includeHistory = false, includeAuditInfo = true } = options;
|
||||
|
||||
// Create main inventory sheet
|
||||
const inventorySheet = this.createInventorySheet(inventoryData, includeAuditInfo);
|
||||
|
||||
// Create workbook
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, inventorySheet, 'Inventory');
|
||||
|
||||
// Add history sheet if requested
|
||||
if (includeHistory) {
|
||||
const historySheet = await this.createHistorySheet(inventoryData);
|
||||
XLSX.utils.book_append_sheet(workbook, historySheet, 'History');
|
||||
}
|
||||
|
||||
// Add summary sheet
|
||||
const summarySheet = this.createSummarySheet(inventoryData);
|
||||
XLSX.utils.book_append_sheet(workbook, summarySheet, 'Summary');
|
||||
|
||||
return {
|
||||
workbook: workbook,
|
||||
metadata: {
|
||||
sheets: ['Inventory', ...(includeHistory ? ['History'] : []), 'Summary'],
|
||||
recordCount: inventoryData.length,
|
||||
includeHistory: includeHistory,
|
||||
includeAuditInfo: includeAuditInfo
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create inventory worksheet
|
||||
* @param {Array} inventoryData - Inventory data
|
||||
* @param {boolean} includeAuditInfo - Whether to include audit information
|
||||
* @returns {Object} Worksheet object
|
||||
*/
|
||||
createInventorySheet(inventoryData, includeAuditInfo = true) {
|
||||
const headers = [
|
||||
'Product Code',
|
||||
'Description',
|
||||
'Category',
|
||||
'Unit of Measure',
|
||||
'Current Level',
|
||||
'Minimum Level',
|
||||
'Maximum Level',
|
||||
'Stock Status'
|
||||
];
|
||||
|
||||
if (includeAuditInfo) {
|
||||
headers.push('Last Updated', 'Updated By');
|
||||
}
|
||||
|
||||
// Create data rows
|
||||
const rows = [headers];
|
||||
|
||||
inventoryData.forEach(item => {
|
||||
const row = [
|
||||
item.product_code || '',
|
||||
item.description || '',
|
||||
item.category || '',
|
||||
item.unit_of_measure || '',
|
||||
item.current_level || 0,
|
||||
item.minimum_level || 0,
|
||||
item.maximum_level || '',
|
||||
item.stock_status || 'Normal'
|
||||
];
|
||||
|
||||
if (includeAuditInfo) {
|
||||
row.push(
|
||||
item.last_updated ? new Date(item.last_updated).toLocaleString() : '',
|
||||
item.updated_by || ''
|
||||
);
|
||||
}
|
||||
|
||||
rows.push(row);
|
||||
});
|
||||
|
||||
// Create worksheet
|
||||
const worksheet = XLSX.utils.aoa_to_sheet(rows);
|
||||
|
||||
// Set column widths
|
||||
const columnWidths = [
|
||||
{ wch: 15 }, // Product Code
|
||||
{ wch: 30 }, // Description
|
||||
{ wch: 15 }, // Category
|
||||
{ wch: 12 }, // Unit of Measure
|
||||
{ wch: 12 }, // Current Level
|
||||
{ wch: 12 }, // Minimum Level
|
||||
{ wch: 12 }, // Maximum Level
|
||||
{ wch: 12 } // Stock Status
|
||||
];
|
||||
|
||||
if (includeAuditInfo) {
|
||||
columnWidths.push(
|
||||
{ wch: 18 }, // Last Updated
|
||||
{ wch: 15 } // Updated By
|
||||
);
|
||||
}
|
||||
|
||||
worksheet['!cols'] = columnWidths;
|
||||
|
||||
return worksheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create history worksheet
|
||||
* @param {Array} inventoryData - Inventory data for getting history
|
||||
* @returns {Object} History worksheet
|
||||
*/
|
||||
async createHistorySheet(inventoryData) {
|
||||
const db = database.getDatabase();
|
||||
|
||||
// Get product IDs for history lookup
|
||||
const productCodes = inventoryData.map(item => item.product_code);
|
||||
const placeholders = productCodes.map(() => '?').join(',');
|
||||
|
||||
const historyQuery = `
|
||||
SELECT
|
||||
p.product_code,
|
||||
p.description,
|
||||
h.old_level,
|
||||
h.new_level,
|
||||
h.change_reason,
|
||||
h.updated_by,
|
||||
h.updated_at
|
||||
FROM inventory_history h
|
||||
JOIN products p ON h.product_id = p.id
|
||||
WHERE p.product_code IN (${placeholders})
|
||||
ORDER BY h.updated_at DESC, p.product_code
|
||||
LIMIT 1000
|
||||
`;
|
||||
|
||||
const historyData = db.prepare(historyQuery).all(...productCodes);
|
||||
|
||||
const headers = [
|
||||
'Product Code',
|
||||
'Description',
|
||||
'Old Level',
|
||||
'New Level',
|
||||
'Change Reason',
|
||||
'Updated By',
|
||||
'Updated At'
|
||||
];
|
||||
|
||||
const rows = [headers];
|
||||
|
||||
historyData.forEach(record => {
|
||||
rows.push([
|
||||
record.product_code,
|
||||
record.description || '',
|
||||
record.old_level || 0,
|
||||
record.new_level || 0,
|
||||
record.change_reason || '',
|
||||
record.updated_by || '',
|
||||
new Date(record.updated_at).toLocaleString()
|
||||
]);
|
||||
});
|
||||
|
||||
const worksheet = XLSX.utils.aoa_to_sheet(rows);
|
||||
|
||||
// Set column widths
|
||||
worksheet['!cols'] = [
|
||||
{ wch: 15 }, // Product Code
|
||||
{ wch: 30 }, // Description
|
||||
{ wch: 10 }, // Old Level
|
||||
{ wch: 10 }, // New Level
|
||||
{ wch: 20 }, // Change Reason
|
||||
{ wch: 15 }, // Updated By
|
||||
{ wch: 18 } // Updated At
|
||||
];
|
||||
|
||||
return worksheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create summary worksheet
|
||||
* @param {Array} inventoryData - Inventory data for summary
|
||||
* @returns {Object} Summary worksheet
|
||||
*/
|
||||
createSummarySheet(inventoryData) {
|
||||
const summary = {
|
||||
totalProducts: inventoryData.length,
|
||||
lowStockItems: inventoryData.filter(item => item.stock_status === 'Low Stock').length,
|
||||
warningItems: inventoryData.filter(item => item.stock_status === 'Warning').length,
|
||||
normalItems: inventoryData.filter(item => item.stock_status === 'Normal').length,
|
||||
totalInventoryValue: inventoryData.reduce((sum, item) => sum + (item.current_level || 0), 0),
|
||||
categories: {}
|
||||
};
|
||||
|
||||
// Calculate category breakdown
|
||||
inventoryData.forEach(item => {
|
||||
const category = item.category || 'Uncategorized';
|
||||
if (!summary.categories[category]) {
|
||||
summary.categories[category] = {
|
||||
count: 0,
|
||||
totalStock: 0,
|
||||
lowStock: 0
|
||||
};
|
||||
}
|
||||
summary.categories[category].count++;
|
||||
summary.categories[category].totalStock += item.current_level || 0;
|
||||
if (item.stock_status === 'Low Stock') {
|
||||
summary.categories[category].lowStock++;
|
||||
}
|
||||
});
|
||||
|
||||
// Create summary data
|
||||
const rows = [
|
||||
['Inventory Export Summary'],
|
||||
[''],
|
||||
['Export Date', new Date().toLocaleString()],
|
||||
['Total Products', summary.totalProducts],
|
||||
['Total Inventory Items', summary.totalInventoryValue],
|
||||
[''],
|
||||
['Stock Status Breakdown'],
|
||||
['Low Stock Items', summary.lowStockItems],
|
||||
['Warning Items', summary.warningItems],
|
||||
['Normal Stock Items', summary.normalItems],
|
||||
[''],
|
||||
['Category Breakdown'],
|
||||
['Category', 'Product Count', 'Total Stock', 'Low Stock Items']
|
||||
];
|
||||
|
||||
// Add category data
|
||||
Object.entries(summary.categories).forEach(([category, data]) => {
|
||||
rows.push([category, data.count, data.totalStock, data.lowStock]);
|
||||
});
|
||||
|
||||
const worksheet = XLSX.utils.aoa_to_sheet(rows);
|
||||
|
||||
// Set column widths
|
||||
worksheet['!cols'] = [
|
||||
{ wch: 25 },
|
||||
{ wch: 15 },
|
||||
{ wch: 15 },
|
||||
{ wch: 15 }
|
||||
];
|
||||
|
||||
return worksheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect column mappings from header row
|
||||
* @param {Array} headerRow - Header row from Excel
|
||||
* @returns {Object} Column mapping object
|
||||
*/
|
||||
detectColumnMappings(headerRow) {
|
||||
const mapping = {
|
||||
productCode: null,
|
||||
description: null,
|
||||
quantity: null,
|
||||
category: null,
|
||||
lastUpdated: null
|
||||
};
|
||||
|
||||
const patterns = {
|
||||
productCode: ['product_code', 'productcode', 'product code', 'code', 'item_code', 'sku'],
|
||||
description: ['description', 'desc', 'product_description', 'name', 'product_name'],
|
||||
quantity: ['quantity', 'qty', 'current_quantity', 'inventory', 'stock', 'current_level'],
|
||||
category: ['category', 'cat', 'product_category', 'type', 'group'],
|
||||
lastUpdated: ['last_updated', 'lastupdated', 'updated', 'timestamp', 'date_updated']
|
||||
};
|
||||
|
||||
const normalizedHeaders = headerRow.map(header =>
|
||||
typeof header === 'string' ? header.toLowerCase().trim() : ''
|
||||
);
|
||||
|
||||
Object.keys(patterns).forEach(columnType => {
|
||||
const columnPatterns = patterns[columnType];
|
||||
|
||||
for (let i = 0; i < normalizedHeaders.length; i++) {
|
||||
const header = normalizedHeaders[i];
|
||||
|
||||
if (columnPatterns.includes(header)) {
|
||||
mapping[columnType] = i;
|
||||
break;
|
||||
}
|
||||
|
||||
const partialMatch = columnPatterns.find(pattern =>
|
||||
header.includes(pattern) || pattern.includes(header)
|
||||
);
|
||||
|
||||
if (partialMatch && mapping[columnType] === null) {
|
||||
mapping[columnType] = i;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cell value safely
|
||||
* @param {Array} row - Data row
|
||||
* @param {number} columnIndex - Column index
|
||||
* @returns {string} Cell value or empty string
|
||||
*/
|
||||
getCellValue(row, columnIndex) {
|
||||
if (columnIndex === null || columnIndex >= row.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const value = row[columnIndex];
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
return value.toString().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Write Excel file to disk
|
||||
* @param {Object} workbook - Excel workbook object
|
||||
* @param {string} filePath - Output file path
|
||||
* @param {string} format - File format (xlsx, xls, csv)
|
||||
*/
|
||||
async writeExcelFile(workbook, filePath, format = 'xlsx') {
|
||||
try {
|
||||
const writeOptions = {
|
||||
bookType: format,
|
||||
type: 'buffer'
|
||||
};
|
||||
|
||||
const buffer = XLSX.write(workbook, writeOptions);
|
||||
await fs.writeFile(filePath, buffer);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to write Excel file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate export filename with timestamp
|
||||
* @param {string} format - File format
|
||||
* @returns {string} Generated filename
|
||||
*/
|
||||
generateExportFilename(format = 'xlsx') {
|
||||
const timestamp = new Date().toISOString()
|
||||
.replace(/[:.]/g, '-')
|
||||
.replace('T', '_')
|
||||
.substring(0, 19);
|
||||
|
||||
return `inventory_export_${timestamp}.${format}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create export session record for tracking
|
||||
* @param {Object} sessionData - Export session data
|
||||
* @returns {number} Session ID
|
||||
*/
|
||||
async createExportSession(sessionData) {
|
||||
try {
|
||||
const db = database.getDatabase();
|
||||
|
||||
// Create export_sessions table if it doesn't exist
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS export_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename VARCHAR(255),
|
||||
total_records INTEGER,
|
||||
export_date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
filters TEXT,
|
||||
include_history BOOLEAN DEFAULT 0,
|
||||
status VARCHAR(20) DEFAULT 'completed'
|
||||
)
|
||||
`);
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO export_sessions (filename, total_records, filters, include_history)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
sessionData.filename,
|
||||
sessionData.totalRecords,
|
||||
JSON.stringify(sessionData.filters || {}),
|
||||
sessionData.includeHistory ? 1 : 0
|
||||
);
|
||||
|
||||
return result.lastInsertRowid;
|
||||
} catch (error) {
|
||||
console.error('Failed to create export session:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get export history
|
||||
* @param {Object} options - Query options
|
||||
* @returns {Array} Export history records
|
||||
*/
|
||||
async getExportHistory(options = {}) {
|
||||
try {
|
||||
const db = database.getDatabase();
|
||||
const { limit = 50, offset = 0 } = options;
|
||||
|
||||
const stmt = db.prepare(`
|
||||
SELECT * FROM export_sessions
|
||||
ORDER BY export_date DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`);
|
||||
|
||||
return stmt.all(limit, offset);
|
||||
} catch (error) {
|
||||
console.error('Failed to get export history:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old export files
|
||||
* @param {number} maxAgeHours - Maximum age in hours
|
||||
* @returns {Object} Cleanup results
|
||||
*/
|
||||
async cleanupOldExports(maxAgeHours = 24) {
|
||||
try {
|
||||
const files = await fs.readdir(this.exportDirectory);
|
||||
const cutoffTime = Date.now() - (maxAgeHours * 60 * 60 * 1000);
|
||||
let deletedCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(this.exportDirectory, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
if (stats.mtime.getTime() < cutoffTime) {
|
||||
await fs.unlink(filePath);
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedCount: deletedCount,
|
||||
message: `Cleaned up ${deletedCount} old export files`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Clear export history
|
||||
* @returns {Object} Result with success status and deleted count
|
||||
*/
|
||||
async clearExportHistory() {
|
||||
const db = database.getDatabase();
|
||||
|
||||
try {
|
||||
// Get count before deletion
|
||||
const countStmt = db.prepare('SELECT COUNT(*) as count FROM export_sessions');
|
||||
const countResult = countStmt.get();
|
||||
const deletedCount = countResult.count;
|
||||
|
||||
// Clear export sessions table
|
||||
const deleteStmt = db.prepare('DELETE FROM export_sessions');
|
||||
deleteStmt.run();
|
||||
|
||||
// Clean up export files directory
|
||||
try {
|
||||
const files = await fs.readdir(this.exportDirectory);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.xlsx') || file.endsWith('.xls')) {
|
||||
await fs.unlink(path.join(this.exportDirectory, file));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Warning: Could not clean up export files:', error.message);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedCount
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error clearing export history:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ExcelExportService;
|
||||
898
services/ExcelImportService.js
Normal file
898
services/ExcelImportService.js
Normal file
@ -0,0 +1,898 @@
|
||||
const XLSX = require('xlsx');
|
||||
const Database = require('../models/database');
|
||||
const logger = require('../utils/logger');
|
||||
const { withFileRetry } = require('../utils/retry');
|
||||
const {
|
||||
ValidationError,
|
||||
BusinessLogicError,
|
||||
AppError
|
||||
} = require('../middleware/errorHandler');
|
||||
|
||||
/**
|
||||
* Service for parsing and importing Excel files containing inventory data
|
||||
*/
|
||||
class ExcelImportService {
|
||||
constructor() {
|
||||
// Common column name patterns for auto-detection
|
||||
this.columnPatterns = {
|
||||
productCode: [
|
||||
'product_code', 'productcode', 'product code', 'code', 'item_code',
|
||||
'itemcode', 'item code', 'sku', 'part_number', 'partnumber', 'part number',
|
||||
'item_id', 'itemid', 'item id'
|
||||
],
|
||||
description: [
|
||||
'description', 'desc', 'product_description', 'productdescription',
|
||||
'product description', 'name', 'product_name', 'productname', 'product name',
|
||||
'item_name', 'itemname', 'item name', 'title'
|
||||
],
|
||||
quantity: [
|
||||
'quantity', 'qty', 'current_quantity', 'currentquantity', 'current quantity',
|
||||
'inventory', 'stock', 'current_stock', 'currentstock', 'current stock',
|
||||
'level', 'inventory_level', 'inventorylevel', 'inventory level',
|
||||
'stock_count', 'stockcount', 'stock count'
|
||||
],
|
||||
category: [
|
||||
'category', 'cat', 'product_category', 'productcategory', 'product category',
|
||||
'type', 'group', 'classification', 'product_type', 'producttype', 'product type'
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Excel file buffer and extract inventory data
|
||||
* @param {Buffer} fileBuffer - Excel file buffer
|
||||
* @param {Object} options - Parsing options
|
||||
* @returns {Object} Parsed data with products and metadata
|
||||
*/
|
||||
async parseExcelFile(fileBuffer, options = {}) {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
logger.info('Starting Excel file parsing', {
|
||||
fileSize: fileBuffer.length,
|
||||
options: options
|
||||
});
|
||||
|
||||
// Validate file buffer
|
||||
if (!fileBuffer || fileBuffer.length === 0) {
|
||||
throw new ValidationError('Empty or invalid file buffer provided');
|
||||
}
|
||||
|
||||
if (fileBuffer.length > 50 * 1024 * 1024) { // 50MB limit
|
||||
throw new ValidationError('File size exceeds maximum limit of 50MB');
|
||||
}
|
||||
|
||||
// Parse Excel file with retry logic for file operations
|
||||
const workbook = await withFileRetry(async () => {
|
||||
return XLSX.read(fileBuffer, {
|
||||
type: 'buffer',
|
||||
cellDates: true,
|
||||
cellNF: false,
|
||||
cellText: false
|
||||
});
|
||||
});
|
||||
|
||||
// Validate workbook
|
||||
if (!workbook || !workbook.SheetNames || workbook.SheetNames.length === 0) {
|
||||
throw new ValidationError('Invalid Excel file: No worksheets found');
|
||||
}
|
||||
|
||||
// Get the first worksheet (or specified sheet)
|
||||
const sheetName = options.sheetName || workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
|
||||
if (!worksheet) {
|
||||
throw new ValidationError(`Sheet "${sheetName}" not found in Excel file. Available sheets: ${workbook.SheetNames.join(', ')}`);
|
||||
}
|
||||
|
||||
logger.debug('Excel worksheet selected', {
|
||||
sheetName,
|
||||
availableSheets: workbook.SheetNames
|
||||
});
|
||||
|
||||
// Convert worksheet to JSON
|
||||
const rawData = XLSX.utils.sheet_to_json(worksheet, {
|
||||
header: 1, // Use array format to preserve original structure
|
||||
defval: '' // Default value for empty cells
|
||||
});
|
||||
|
||||
if (rawData.length === 0) {
|
||||
throw new ValidationError('Excel file appears to be empty');
|
||||
}
|
||||
|
||||
if (rawData.length === 1) {
|
||||
throw new ValidationError('Excel file contains only headers, no data rows found');
|
||||
}
|
||||
|
||||
// Extract header row and data rows
|
||||
const headerRow = rawData[0];
|
||||
const dataRows = rawData.slice(1);
|
||||
|
||||
logger.debug('Excel data extracted', {
|
||||
headerCount: headerRow.length,
|
||||
dataRowCount: dataRows.length,
|
||||
headers: headerRow
|
||||
});
|
||||
|
||||
// Detect column mappings
|
||||
const columnMapping = this.detectColumns(headerRow);
|
||||
|
||||
// Validate that required columns were found
|
||||
if (!columnMapping.productCode) {
|
||||
throw new ValidationError('Could not detect product code column. Please ensure your Excel file has a column for product codes.');
|
||||
}
|
||||
|
||||
// Parse data rows
|
||||
const parsedProducts = this.parseDataRows(dataRows, columnMapping, headerRow);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
logger.info('Excel file parsing completed', {
|
||||
duration: `${duration}ms`,
|
||||
totalRows: dataRows.length,
|
||||
parsedProducts: parsedProducts.length,
|
||||
sheetName
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
products: parsedProducts,
|
||||
totalRows: dataRows.length,
|
||||
columnMapping: columnMapping,
|
||||
sheetName: sheetName,
|
||||
availableSheets: workbook.SheetNames
|
||||
},
|
||||
errors: []
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
logger.error('Excel file parsing failed', {
|
||||
duration: `${duration}ms`,
|
||||
error: error.message,
|
||||
fileSize: fileBuffer.length,
|
||||
options
|
||||
});
|
||||
|
||||
// Re-throw validation errors as-is
|
||||
if (error instanceof ValidationError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Convert other errors to appropriate types
|
||||
if (error.message.includes('Unsupported file')) {
|
||||
throw new ValidationError('Unsupported file format. Please upload a valid Excel file (.xlsx or .xls)');
|
||||
}
|
||||
|
||||
if (error.message.includes('file is encrypted')) {
|
||||
throw new ValidationError('Encrypted Excel files are not supported. Please remove password protection and try again.');
|
||||
}
|
||||
|
||||
// Generic parsing error
|
||||
throw new ValidationError(`Failed to parse Excel file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect column mappings based on header row
|
||||
* @param {Array} headerRow - Array of column headers
|
||||
* @returns {Object} Column mapping object
|
||||
*/
|
||||
detectColumns(headerRow) {
|
||||
const mapping = {
|
||||
productCode: null,
|
||||
description: null,
|
||||
quantity: null,
|
||||
category: null
|
||||
};
|
||||
|
||||
// Convert headers to lowercase for comparison
|
||||
const normalizedHeaders = headerRow.map(header =>
|
||||
typeof header === 'string' ? header.toLowerCase().trim() : ''
|
||||
);
|
||||
|
||||
// Find matches for each column type
|
||||
Object.keys(this.columnPatterns).forEach(columnType => {
|
||||
const patterns = this.columnPatterns[columnType];
|
||||
|
||||
for (let i = 0; i < normalizedHeaders.length; i++) {
|
||||
const header = normalizedHeaders[i];
|
||||
|
||||
// Check for exact matches first
|
||||
if (patterns.includes(header)) {
|
||||
mapping[columnType] = i;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for partial matches
|
||||
const partialMatch = patterns.find(pattern =>
|
||||
header.includes(pattern) || pattern.includes(header)
|
||||
);
|
||||
|
||||
if (partialMatch && mapping[columnType] === null) {
|
||||
mapping[columnType] = i;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse data rows using column mapping
|
||||
* @param {Array} dataRows - Array of data rows
|
||||
* @param {Object} columnMapping - Column mapping object
|
||||
* @param {Array} headerRow - Original header row for reference
|
||||
* @returns {Array} Array of parsed product objects
|
||||
*/
|
||||
parseDataRows(dataRows, columnMapping, headerRow) {
|
||||
const products = [];
|
||||
|
||||
dataRows.forEach((row, index) => {
|
||||
// Skip empty rows
|
||||
if (!row || row.every(cell => !cell || cell.toString().trim() === '')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const product = {
|
||||
rowNumber: index + 2, // +2 because we start from row 2 (after header)
|
||||
productCode: this.getCellValue(row, columnMapping.productCode),
|
||||
description: this.getCellValue(row, columnMapping.description),
|
||||
quantity: this.parseQuantity(this.getCellValue(row, columnMapping.quantity)),
|
||||
category: this.getCellValue(row, columnMapping.category),
|
||||
originalRow: row,
|
||||
errors: []
|
||||
};
|
||||
|
||||
// Basic validation
|
||||
if (!product.productCode) {
|
||||
product.errors.push({
|
||||
type: 'MISSING_PRODUCT_CODE',
|
||||
message: 'Product code is required but missing'
|
||||
});
|
||||
}
|
||||
|
||||
if (product.quantity === null) {
|
||||
product.errors.push({
|
||||
type: 'INVALID_QUANTITY',
|
||||
message: 'Quantity must be a valid number'
|
||||
});
|
||||
}
|
||||
|
||||
products.push(product);
|
||||
});
|
||||
|
||||
return products;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cell value safely
|
||||
* @param {Array} row - Data row
|
||||
* @param {number} columnIndex - Column index
|
||||
* @returns {string} Cell value or empty string
|
||||
*/
|
||||
getCellValue(row, columnIndex) {
|
||||
if (columnIndex === null || columnIndex >= row.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const value = row[columnIndex];
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
return value.toString().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse quantity value to number
|
||||
* @param {string} value - Raw quantity value
|
||||
* @returns {number|null} Parsed quantity or null if invalid
|
||||
*/
|
||||
parseQuantity(value) {
|
||||
if (!value || value === '') {
|
||||
return 0; // Default to 0 for empty quantities
|
||||
}
|
||||
|
||||
// Remove common non-numeric characters
|
||||
const cleanValue = value.toString().replace(/[,\s]/g, '');
|
||||
const parsed = parseFloat(cleanValue);
|
||||
|
||||
return isNaN(parsed) ? null : Math.max(0, Math.floor(parsed)); // Ensure non-negative integer
|
||||
}
|
||||
|
||||
/**
|
||||
* Get column mapping suggestions for manual mapping
|
||||
* @param {Array} headerRow - Array of column headers
|
||||
* @returns {Object} Suggestions for each column type
|
||||
*/
|
||||
getColumnSuggestions(headerRow) {
|
||||
const suggestions = {};
|
||||
|
||||
Object.keys(this.columnPatterns).forEach(columnType => {
|
||||
suggestions[columnType] = [];
|
||||
|
||||
headerRow.forEach((header, index) => {
|
||||
if (typeof header === 'string' && header.trim()) {
|
||||
const normalizedHeader = header.toLowerCase().trim();
|
||||
const patterns = this.columnPatterns[columnType];
|
||||
|
||||
// Calculate relevance score
|
||||
let score = 0;
|
||||
patterns.forEach(pattern => {
|
||||
if (normalizedHeader === pattern) {
|
||||
score += 10; // Exact match
|
||||
} else if (normalizedHeader.includes(pattern)) {
|
||||
score += 5; // Contains pattern
|
||||
} else if (pattern.includes(normalizedHeader)) {
|
||||
score += 3; // Pattern contains header
|
||||
}
|
||||
});
|
||||
|
||||
if (score > 0) {
|
||||
suggestions[columnType].push({
|
||||
index: index,
|
||||
header: header,
|
||||
score: score
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by score descending
|
||||
suggestions[columnType].sort((a, b) => b.score - a.score);
|
||||
});
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate parsed data for common issues
|
||||
* @param {Array} products - Array of parsed products
|
||||
* @returns {Object} Validation results
|
||||
*/
|
||||
validateParsedData(products) {
|
||||
const validation = {
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
duplicates: [],
|
||||
statistics: {
|
||||
totalProducts: products.length,
|
||||
validProducts: 0,
|
||||
invalidProducts: 0,
|
||||
duplicateProducts: 0
|
||||
}
|
||||
};
|
||||
|
||||
const productCodes = new Set();
|
||||
const duplicateCodes = new Set();
|
||||
|
||||
products.forEach(product => {
|
||||
// Check for duplicates
|
||||
if (product.productCode) {
|
||||
if (productCodes.has(product.productCode)) {
|
||||
duplicateCodes.add(product.productCode);
|
||||
validation.duplicates.push({
|
||||
productCode: product.productCode,
|
||||
rows: [product.rowNumber] // Will be expanded when we find all duplicates
|
||||
});
|
||||
} else {
|
||||
productCodes.add(product.productCode);
|
||||
}
|
||||
}
|
||||
|
||||
// Count valid/invalid products
|
||||
if (product.errors.length === 0) {
|
||||
validation.statistics.validProducts++;
|
||||
} else {
|
||||
validation.statistics.invalidProducts++;
|
||||
validation.isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Update duplicate statistics
|
||||
validation.statistics.duplicateProducts = duplicateCodes.size;
|
||||
|
||||
// Add warnings for duplicates
|
||||
if (duplicateCodes.size > 0) {
|
||||
validation.warnings.push({
|
||||
type: 'DUPLICATE_PRODUCT_CODES',
|
||||
message: `Found ${duplicateCodes.size} duplicate product codes`,
|
||||
details: Array.from(duplicateCodes)
|
||||
});
|
||||
}
|
||||
|
||||
return validation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive validation of parsed data with detailed error reporting
|
||||
* @param {Array} products - Array of parsed products
|
||||
* @param {Object} options - Validation options
|
||||
* @returns {Object} Detailed validation results
|
||||
*/
|
||||
async validateImportData(products, options = {}) {
|
||||
const validation = {
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
duplicates: [],
|
||||
statistics: {
|
||||
totalProducts: products.length,
|
||||
validProducts: 0,
|
||||
invalidProducts: 0,
|
||||
duplicateProducts: 0,
|
||||
existingProducts: 0
|
||||
},
|
||||
validatedProducts: []
|
||||
};
|
||||
|
||||
const productCodes = new Map(); // Track duplicates with row numbers
|
||||
const duplicateCodes = new Set();
|
||||
|
||||
// Check for existing products in database if requested
|
||||
let existingProducts = new Set();
|
||||
if (options.checkExisting) {
|
||||
try {
|
||||
const db = Database.getInstance();
|
||||
const existing = db.prepare('SELECT product_code FROM products').all();
|
||||
existingProducts = new Set(existing.map(p => p.product_code));
|
||||
} catch (error) {
|
||||
validation.warnings.push({
|
||||
type: 'DATABASE_CHECK_FAILED',
|
||||
message: 'Could not check for existing products in database',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate each product
|
||||
for (let i = 0; i < products.length; i++) {
|
||||
const product = products[i];
|
||||
const validatedProduct = { ...product };
|
||||
|
||||
// Reset errors for comprehensive validation
|
||||
validatedProduct.errors = [];
|
||||
validatedProduct.warnings = [];
|
||||
|
||||
// 1. Product Code Validation
|
||||
if (!product.productCode || product.productCode.trim() === '') {
|
||||
validatedProduct.errors.push({
|
||||
type: 'MISSING_PRODUCT_CODE',
|
||||
message: 'Product code is required but missing',
|
||||
field: 'productCode'
|
||||
});
|
||||
} else {
|
||||
// Check product code format
|
||||
const codeValidation = this.validateProductCode(product.productCode);
|
||||
if (!codeValidation.isValid) {
|
||||
validatedProduct.errors.push({
|
||||
type: 'INVALID_PRODUCT_CODE_FORMAT',
|
||||
message: codeValidation.message,
|
||||
field: 'productCode'
|
||||
});
|
||||
}
|
||||
|
||||
// Check for duplicates within the import
|
||||
if (productCodes.has(product.productCode)) {
|
||||
duplicateCodes.add(product.productCode);
|
||||
const existingRows = productCodes.get(product.productCode);
|
||||
existingRows.push(product.rowNumber);
|
||||
|
||||
validatedProduct.errors.push({
|
||||
type: 'DUPLICATE_PRODUCT_CODE',
|
||||
message: `Product code "${product.productCode}" appears in multiple rows: ${existingRows.join(', ')}`,
|
||||
field: 'productCode'
|
||||
});
|
||||
} else {
|
||||
productCodes.set(product.productCode, [product.rowNumber]);
|
||||
}
|
||||
|
||||
// Check if product exists in database
|
||||
if (existingProducts.has(product.productCode)) {
|
||||
validation.statistics.existingProducts++;
|
||||
validatedProduct.warnings.push({
|
||||
type: 'PRODUCT_EXISTS',
|
||||
message: `Product code "${product.productCode}" already exists in database`,
|
||||
field: 'productCode'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Description Validation
|
||||
if (product.description && product.description.length > 500) {
|
||||
validatedProduct.errors.push({
|
||||
type: 'DESCRIPTION_TOO_LONG',
|
||||
message: 'Description cannot exceed 500 characters',
|
||||
field: 'description'
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Quantity Validation
|
||||
if (product.quantity === null) {
|
||||
validatedProduct.errors.push({
|
||||
type: 'INVALID_QUANTITY',
|
||||
message: 'Quantity must be a valid non-negative number',
|
||||
field: 'quantity'
|
||||
});
|
||||
} else if (product.quantity < 0) {
|
||||
validatedProduct.errors.push({
|
||||
type: 'NEGATIVE_QUANTITY',
|
||||
message: 'Quantity cannot be negative',
|
||||
field: 'quantity'
|
||||
});
|
||||
} else if (product.quantity > 1000000) {
|
||||
validatedProduct.warnings.push({
|
||||
type: 'LARGE_QUANTITY',
|
||||
message: 'Quantity is unusually large, please verify',
|
||||
field: 'quantity'
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Category Validation
|
||||
if (product.category && product.category.length > 100) {
|
||||
validatedProduct.errors.push({
|
||||
type: 'CATEGORY_TOO_LONG',
|
||||
message: 'Category cannot exceed 100 characters',
|
||||
field: 'category'
|
||||
});
|
||||
}
|
||||
|
||||
// Count valid/invalid products
|
||||
if (validatedProduct.errors.length === 0) {
|
||||
validation.statistics.validProducts++;
|
||||
} else {
|
||||
validation.statistics.invalidProducts++;
|
||||
validation.isValid = false;
|
||||
}
|
||||
|
||||
validation.validatedProducts.push(validatedProduct);
|
||||
}
|
||||
|
||||
// Process duplicates
|
||||
validation.statistics.duplicateProducts = duplicateCodes.size;
|
||||
duplicateCodes.forEach(code => {
|
||||
const rows = productCodes.get(code);
|
||||
validation.duplicates.push({
|
||||
productCode: code,
|
||||
rows: rows,
|
||||
count: rows.length
|
||||
});
|
||||
});
|
||||
|
||||
// Add summary warnings
|
||||
if (duplicateCodes.size > 0) {
|
||||
validation.warnings.push({
|
||||
type: 'DUPLICATE_PRODUCT_CODES',
|
||||
message: `Found ${duplicateCodes.size} duplicate product codes affecting ${Array.from(duplicateCodes).reduce((sum, code) => sum + productCodes.get(code).length, 0)} rows`,
|
||||
details: Array.from(duplicateCodes)
|
||||
});
|
||||
}
|
||||
|
||||
if (validation.statistics.existingProducts > 0) {
|
||||
validation.warnings.push({
|
||||
type: 'EXISTING_PRODUCTS_FOUND',
|
||||
message: `${validation.statistics.existingProducts} products already exist in the database`,
|
||||
details: validation.statistics.existingProducts
|
||||
});
|
||||
}
|
||||
|
||||
return validation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate product code format
|
||||
* @param {string} productCode - Product code to validate
|
||||
* @returns {Object} Validation result
|
||||
*/
|
||||
validateProductCode(productCode) {
|
||||
const code = productCode.trim();
|
||||
|
||||
if (code.length === 0) {
|
||||
return { isValid: false, message: 'Product code cannot be empty' };
|
||||
}
|
||||
|
||||
if (code.length > 50) {
|
||||
return { isValid: false, message: 'Product code cannot exceed 50 characters' };
|
||||
}
|
||||
|
||||
// Check for invalid characters (allow alphanumeric, hyphens, underscores)
|
||||
if (!/^[A-Za-z0-9\-_]+$/.test(code)) {
|
||||
return { isValid: false, message: 'Product code can only contain letters, numbers, hyphens, and underscores' };
|
||||
}
|
||||
|
||||
return { isValid: true, message: 'Valid product code' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle duplicate products based on specified strategy
|
||||
* @param {Array} products - Array of validated products
|
||||
* @param {string} duplicateStrategy - 'skip', 'update', or 'rename'
|
||||
* @returns {Array} Processed products array
|
||||
*/
|
||||
handleDuplicates(products, duplicateStrategy = 'skip') {
|
||||
const processedProducts = [];
|
||||
const seenCodes = new Set();
|
||||
const duplicateCounters = new Map();
|
||||
|
||||
products.forEach(product => {
|
||||
if (!product.productCode) {
|
||||
processedProducts.push(product);
|
||||
return;
|
||||
}
|
||||
|
||||
const isDuplicate = seenCodes.has(product.productCode);
|
||||
|
||||
if (!isDuplicate) {
|
||||
seenCodes.add(product.productCode);
|
||||
processedProducts.push(product);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle duplicate based on strategy
|
||||
switch (duplicateStrategy) {
|
||||
case 'skip':
|
||||
// Skip duplicate, add warning
|
||||
product.warnings = product.warnings || [];
|
||||
product.warnings.push({
|
||||
type: 'DUPLICATE_SKIPPED',
|
||||
message: `Duplicate product code "${product.productCode}" skipped`,
|
||||
field: 'productCode'
|
||||
});
|
||||
product.skipped = true;
|
||||
processedProducts.push(product);
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
// Mark for update instead of insert
|
||||
product.warnings = product.warnings || [];
|
||||
product.warnings.push({
|
||||
type: 'DUPLICATE_WILL_UPDATE',
|
||||
message: `Duplicate product code "${product.productCode}" will update existing record`,
|
||||
field: 'productCode'
|
||||
});
|
||||
product.updateExisting = true;
|
||||
processedProducts.push(product);
|
||||
break;
|
||||
|
||||
case 'rename':
|
||||
// Rename with suffix
|
||||
const baseCode = product.productCode;
|
||||
const counter = (duplicateCounters.get(baseCode) || 1) + 1;
|
||||
duplicateCounters.set(baseCode, counter);
|
||||
|
||||
const newCode = `${baseCode}_${counter}`;
|
||||
product.originalProductCode = product.productCode;
|
||||
product.productCode = newCode;
|
||||
product.warnings = product.warnings || [];
|
||||
product.warnings.push({
|
||||
type: 'DUPLICATE_RENAMED',
|
||||
message: `Duplicate product code renamed from "${baseCode}" to "${newCode}"`,
|
||||
field: 'productCode'
|
||||
});
|
||||
|
||||
seenCodes.add(newCode);
|
||||
processedProducts.push(product);
|
||||
break;
|
||||
|
||||
default:
|
||||
processedProducts.push(product);
|
||||
}
|
||||
});
|
||||
|
||||
return processedProducts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import products to database with transaction support
|
||||
* @param {Array} products - Array of validated products
|
||||
* @param {Object} options - Import options
|
||||
* @returns {Object} Import results
|
||||
*/
|
||||
async importToDatabase(products, options = {}) {
|
||||
const results = {
|
||||
success: false,
|
||||
imported: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
sessionId: null
|
||||
};
|
||||
|
||||
try {
|
||||
const db = Database.getInstance();
|
||||
|
||||
// Create import session record
|
||||
const sessionResult = db.prepare(`
|
||||
INSERT INTO import_sessions (filename, total_records, status)
|
||||
VALUES (?, ?, 'in_progress')
|
||||
`).run(options.filename || 'unknown', products.length);
|
||||
|
||||
results.sessionId = sessionResult.lastInsertRowid;
|
||||
|
||||
// Begin transaction
|
||||
const transaction = db.transaction((productsToImport) => {
|
||||
const insertProduct = db.prepare(`
|
||||
INSERT INTO products (product_code, description, category, created_at, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'), datetime('now'))
|
||||
`);
|
||||
|
||||
const updateProduct = db.prepare(`
|
||||
UPDATE products
|
||||
SET description = ?, category = ?, updated_at = datetime('now')
|
||||
WHERE product_code = ?
|
||||
`);
|
||||
|
||||
const insertInventory = db.prepare(`
|
||||
INSERT INTO inventory (product_id, current_level, last_updated, updated_by)
|
||||
VALUES (?, ?, datetime('now'), ?)
|
||||
`);
|
||||
|
||||
const updateInventory = db.prepare(`
|
||||
UPDATE inventory
|
||||
SET current_level = ?, last_updated = datetime('now'), updated_by = ?
|
||||
WHERE product_id = (SELECT id FROM products WHERE product_code = ?)
|
||||
`);
|
||||
|
||||
const getProductId = db.prepare(`
|
||||
SELECT id FROM products WHERE product_code = ?
|
||||
`);
|
||||
|
||||
productsToImport.forEach(product => {
|
||||
try {
|
||||
// Skip products with errors or marked as skipped
|
||||
if (product.errors?.length > 0 || product.skipped) {
|
||||
results.skipped++;
|
||||
return;
|
||||
}
|
||||
|
||||
let productId;
|
||||
|
||||
if (product.updateExisting) {
|
||||
// Update existing product
|
||||
updateProduct.run(
|
||||
product.description || '',
|
||||
product.category || '',
|
||||
product.productCode
|
||||
);
|
||||
|
||||
const existingProduct = getProductId.get(product.productCode);
|
||||
if (existingProduct) {
|
||||
productId = existingProduct.id;
|
||||
results.updated++;
|
||||
} else {
|
||||
throw new Error(`Product ${product.productCode} not found for update`);
|
||||
}
|
||||
} else {
|
||||
// Insert new product
|
||||
const productResult = insertProduct.run(
|
||||
product.productCode,
|
||||
product.description || '',
|
||||
product.category || ''
|
||||
);
|
||||
productId = productResult.lastInsertRowid;
|
||||
results.imported++;
|
||||
}
|
||||
|
||||
// Handle inventory
|
||||
if (product.quantity !== null && product.quantity !== undefined) {
|
||||
if (product.updateExisting) {
|
||||
updateInventory.run(
|
||||
product.quantity,
|
||||
options.updatedBy || 'system',
|
||||
product.productCode
|
||||
);
|
||||
} else {
|
||||
insertInventory.run(
|
||||
productId,
|
||||
product.quantity,
|
||||
options.updatedBy || 'system'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
results.errors.push({
|
||||
productCode: product.productCode,
|
||||
rowNumber: product.rowNumber,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Execute transaction
|
||||
transaction(products);
|
||||
|
||||
// Update import session
|
||||
db.prepare(`
|
||||
UPDATE import_sessions
|
||||
SET successful_imports = ?, failed_imports = ?, status = 'completed'
|
||||
WHERE id = ?
|
||||
`).run(results.imported + results.updated, results.failed, results.sessionId);
|
||||
|
||||
results.success = true;
|
||||
|
||||
} catch (error) {
|
||||
results.errors.push({
|
||||
type: 'TRANSACTION_ERROR',
|
||||
message: error.message
|
||||
});
|
||||
|
||||
// Update session as failed if it was created
|
||||
if (results.sessionId) {
|
||||
try {
|
||||
const db = Database.getInstance();
|
||||
db.prepare(`
|
||||
UPDATE import_sessions
|
||||
SET status = 'failed'
|
||||
WHERE id = ?
|
||||
`).run(results.sessionId);
|
||||
} catch (updateError) {
|
||||
// Ignore update errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete import process with validation and error handling
|
||||
* @param {Buffer} fileBuffer - Excel file buffer
|
||||
* @param {Object} options - Import options
|
||||
* @returns {Object} Complete import results
|
||||
*/
|
||||
async processImport(fileBuffer, options = {}) {
|
||||
const results = {
|
||||
success: false,
|
||||
parseResults: null,
|
||||
validationResults: null,
|
||||
importResults: null,
|
||||
errors: []
|
||||
};
|
||||
|
||||
try {
|
||||
// Step 1: Parse Excel file
|
||||
results.parseResults = await this.parseExcelFile(fileBuffer, options);
|
||||
|
||||
if (!results.parseResults.success) {
|
||||
results.errors.push(...results.parseResults.errors);
|
||||
return results;
|
||||
}
|
||||
|
||||
// Step 2: Validate data
|
||||
results.validationResults = await this.validateImportData(
|
||||
results.parseResults.data.products,
|
||||
{ checkExisting: options.checkExisting !== false }
|
||||
);
|
||||
|
||||
// Step 3: Handle duplicates if strategy specified
|
||||
let productsToImport = results.validationResults.validatedProducts;
|
||||
if (options.duplicateStrategy && options.duplicateStrategy !== 'error') {
|
||||
productsToImport = this.handleDuplicates(productsToImport, options.duplicateStrategy);
|
||||
}
|
||||
|
||||
// Step 4: Import to database if requested and validation passed
|
||||
if (options.importToDatabase && (results.validationResults.isValid || options.forceImport)) {
|
||||
results.importResults = await this.importToDatabase(productsToImport, options);
|
||||
}
|
||||
|
||||
results.success = true;
|
||||
|
||||
} catch (error) {
|
||||
results.errors.push({
|
||||
type: 'PROCESS_ERROR',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}}
|
||||
|
||||
|
||||
module.exports = ExcelImportService;
|
||||
462
services/PrintableLayoutService.js
Normal file
462
services/PrintableLayoutService.js
Normal file
@ -0,0 +1,462 @@
|
||||
const { jsPDF } = require('jspdf');
|
||||
const CodeGenerationService = require('./CodeGenerationService');
|
||||
|
||||
/**
|
||||
* Service for generating printable layouts with barcodes and QR codes
|
||||
*/
|
||||
class PrintableLayoutService {
|
||||
constructor() {
|
||||
this.codeGenService = new CodeGenerationService();
|
||||
|
||||
// Standard label sizes in mm
|
||||
this.labelSizes = {
|
||||
'avery-5160': { width: 66.7, height: 25.4, columns: 3, rows: 10 },
|
||||
'avery-5161': { width: 101.6, height: 25.4, columns: 2, rows: 10 },
|
||||
'avery-5162': { width: 101.6, height: 33.9, columns: 2, rows: 7 },
|
||||
'avery-5163': { width: 101.6, height: 50.8, columns: 2, rows: 5 },
|
||||
'avery-5164': { width: 101.6, height: 67.7, columns: 2, rows: 3 },
|
||||
'custom': { width: 50, height: 25, columns: 4, rows: 8 }
|
||||
};
|
||||
|
||||
this.defaultLayoutOptions = {
|
||||
labelSize: 'avery-5160',
|
||||
includeBarcode: true,
|
||||
includeQRCode: false,
|
||||
includeProductCode: true,
|
||||
includeDescription: true,
|
||||
fontSize: 8,
|
||||
barcodeHeight: 15,
|
||||
qrCodeSize: 20,
|
||||
margin: 2,
|
||||
orientation: 'portrait'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate printable PDF layout with barcodes/QR codes
|
||||
* @param {Array} products - Array of product objects
|
||||
* @param {Object} options - Layout options
|
||||
* @returns {Promise<Buffer>} PDF buffer
|
||||
*/
|
||||
async generatePrintableLayout(products, options = {}) {
|
||||
try {
|
||||
if (!Array.isArray(products) || products.length === 0) {
|
||||
throw new Error('Products array is required and cannot be empty');
|
||||
}
|
||||
|
||||
const layoutOptions = { ...this.defaultLayoutOptions, ...options };
|
||||
const labelSize = this.labelSizes[layoutOptions.labelSize] || this.labelSizes['custom'];
|
||||
|
||||
// Create PDF document
|
||||
const pdf = new jsPDF({
|
||||
orientation: layoutOptions.orientation,
|
||||
unit: 'mm',
|
||||
format: 'a4'
|
||||
});
|
||||
|
||||
// Calculate page dimensions
|
||||
const pageWidth = pdf.internal.pageSize.getWidth();
|
||||
const pageHeight = pdf.internal.pageSize.getHeight();
|
||||
|
||||
// Calculate starting positions
|
||||
const startX = (pageWidth - (labelSize.columns * labelSize.width)) / 2;
|
||||
const startY = 10;
|
||||
|
||||
let currentProduct = 0;
|
||||
let currentPage = 1;
|
||||
|
||||
while (currentProduct < products.length) {
|
||||
if (currentPage > 1) {
|
||||
pdf.addPage();
|
||||
}
|
||||
|
||||
// Generate codes for current page products
|
||||
const pageProducts = products.slice(
|
||||
currentProduct,
|
||||
currentProduct + (labelSize.columns * labelSize.rows)
|
||||
);
|
||||
|
||||
const generatedCodes = await this._generateCodesForProducts(pageProducts, layoutOptions);
|
||||
|
||||
// Draw labels on current page
|
||||
await this._drawLabelsOnPage(pdf, generatedCodes, labelSize, layoutOptions, startX, startY);
|
||||
|
||||
currentProduct += pageProducts.length;
|
||||
currentPage++;
|
||||
}
|
||||
|
||||
// Return PDF as buffer
|
||||
const pdfBuffer = Buffer.from(pdf.output('arraybuffer'));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: pdfBuffer,
|
||||
metadata: {
|
||||
totalProducts: products.length,
|
||||
totalPages: currentPage - 1,
|
||||
labelSize: layoutOptions.labelSize,
|
||||
format: 'PDF'
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
products: products
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate custom layout template
|
||||
* @param {Object} templateOptions - Template configuration
|
||||
* @returns {Promise<Object>} Template configuration
|
||||
*/
|
||||
async generateCustomTemplate(templateOptions) {
|
||||
try {
|
||||
const {
|
||||
width = 50,
|
||||
height = 25,
|
||||
columns = 4,
|
||||
rows = 8,
|
||||
name = 'custom-template'
|
||||
} = templateOptions;
|
||||
|
||||
const template = {
|
||||
name,
|
||||
width,
|
||||
height,
|
||||
columns,
|
||||
rows,
|
||||
totalLabels: columns * rows,
|
||||
pageSize: 'a4'
|
||||
};
|
||||
|
||||
// Validate template fits on page
|
||||
const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
||||
const pageWidth = pdf.internal.pageSize.getWidth();
|
||||
const pageHeight = pdf.internal.pageSize.getHeight();
|
||||
|
||||
if (width * columns > pageWidth - 10) {
|
||||
throw new Error('Template width exceeds page width');
|
||||
}
|
||||
|
||||
if (height * rows > pageHeight - 20) {
|
||||
throw new Error('Template height exceeds page height');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
template,
|
||||
validation: {
|
||||
fitsOnPage: true,
|
||||
maxLabelsPerPage: columns * rows
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
templateOptions
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available label sizes
|
||||
* @returns {Object} Available label sizes
|
||||
*/
|
||||
getAvailableLabelSizes() {
|
||||
return { ...this.labelSizes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate preview of layout
|
||||
* @param {Array} sampleProducts - Sample products for preview
|
||||
* @param {Object} options - Layout options
|
||||
* @returns {Promise<Object>} Preview information
|
||||
*/
|
||||
async generateLayoutPreview(sampleProducts, options = {}) {
|
||||
try {
|
||||
const layoutOptions = { ...this.defaultLayoutOptions, ...options };
|
||||
const labelSize = this.labelSizes[layoutOptions.labelSize] || this.labelSizes['custom'];
|
||||
|
||||
const preview = {
|
||||
labelSize: layoutOptions.labelSize,
|
||||
dimensions: labelSize,
|
||||
labelsPerPage: labelSize.columns * labelSize.rows,
|
||||
totalPages: Math.ceil(sampleProducts.length / (labelSize.columns * labelSize.rows)),
|
||||
includeBarcode: layoutOptions.includeBarcode,
|
||||
includeQRCode: layoutOptions.includeQRCode,
|
||||
includeProductCode: layoutOptions.includeProductCode,
|
||||
includeDescription: layoutOptions.includeDescription
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
preview
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate codes for products
|
||||
* @private
|
||||
* @param {Array} products - Products to generate codes for
|
||||
* @param {Object} options - Generation options
|
||||
* @returns {Promise<Array>} Generated codes
|
||||
*/
|
||||
async _generateCodesForProducts(products, options) {
|
||||
const generatedCodes = [];
|
||||
|
||||
for (const product of products) {
|
||||
const codeData = {
|
||||
product,
|
||||
barcode: null,
|
||||
qrCode: null
|
||||
};
|
||||
|
||||
if (options.includeBarcode) {
|
||||
const barcodeResult = await this.codeGenService.generateBarcode(
|
||||
product.product_code,
|
||||
options.barcodeFormat || 'CODE128'
|
||||
);
|
||||
codeData.barcode = barcodeResult.success ? barcodeResult : null;
|
||||
}
|
||||
|
||||
if (options.includeQRCode) {
|
||||
const qrResult = await this.codeGenService.generateQRCode(product);
|
||||
codeData.qrCode = qrResult.success ? qrResult : null;
|
||||
}
|
||||
|
||||
generatedCodes.push(codeData);
|
||||
}
|
||||
|
||||
return generatedCodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw labels on PDF page
|
||||
* @private
|
||||
* @param {jsPDF} pdf - PDF document
|
||||
* @param {Array} generatedCodes - Generated codes data
|
||||
* @param {Object} labelSize - Label dimensions
|
||||
* @param {Object} options - Layout options
|
||||
* @param {number} startX - Starting X position
|
||||
* @param {number} startY - Starting Y position
|
||||
*/
|
||||
async _drawLabelsOnPage(pdf, generatedCodes, labelSize, options, startX, startY) {
|
||||
let labelIndex = 0;
|
||||
|
||||
for (let row = 0; row < labelSize.rows && labelIndex < generatedCodes.length; row++) {
|
||||
for (let col = 0; col < labelSize.columns && labelIndex < generatedCodes.length; col++) {
|
||||
const x = startX + (col * labelSize.width);
|
||||
const y = startY + (row * labelSize.height);
|
||||
|
||||
await this._drawSingleLabel(
|
||||
pdf,
|
||||
generatedCodes[labelIndex],
|
||||
x,
|
||||
y,
|
||||
labelSize.width,
|
||||
labelSize.height,
|
||||
options
|
||||
);
|
||||
|
||||
labelIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a single label
|
||||
* @private
|
||||
* @param {jsPDF} pdf - PDF document
|
||||
* @param {Object} codeData - Code data for the label
|
||||
* @param {number} x - X position
|
||||
* @param {number} y - Y position
|
||||
* @param {number} width - Label width
|
||||
* @param {number} height - Label height
|
||||
* @param {Object} options - Layout options
|
||||
*/
|
||||
async _drawSingleLabel(pdf, codeData, x, y, width, height, options) {
|
||||
const { product, barcode, qrCode } = codeData;
|
||||
const margin = options.margin;
|
||||
|
||||
// Draw label border (optional, for debugging)
|
||||
if (options.showBorders) {
|
||||
pdf.setDrawColor(200, 200, 200);
|
||||
pdf.rect(x, y, width, height);
|
||||
}
|
||||
|
||||
let currentY = y + margin;
|
||||
const contentWidth = width - (2 * margin);
|
||||
|
||||
// Draw product code
|
||||
if (options.includeProductCode && product.product_code) {
|
||||
pdf.setFontSize(options.fontSize + 2);
|
||||
pdf.setFont('helvetica', 'bold');
|
||||
const codeText = this._truncateText(product.product_code, contentWidth, pdf);
|
||||
pdf.text(codeText, x + margin, currentY + 4);
|
||||
currentY += 6;
|
||||
}
|
||||
|
||||
// Draw barcode
|
||||
if (options.includeBarcode && barcode && barcode.data) {
|
||||
try {
|
||||
const barcodeHeight = options.barcodeHeight;
|
||||
const barcodeWidth = Math.min(contentWidth, 40);
|
||||
|
||||
// Convert base64 to image and add to PDF
|
||||
pdf.addImage(
|
||||
barcode.data,
|
||||
'PNG',
|
||||
x + margin + (contentWidth - barcodeWidth) / 2,
|
||||
currentY,
|
||||
barcodeWidth,
|
||||
barcodeHeight
|
||||
);
|
||||
currentY += barcodeHeight + 2;
|
||||
} catch (error) {
|
||||
console.warn('Failed to add barcode to PDF:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw QR code
|
||||
if (options.includeQRCode && qrCode && qrCode.data) {
|
||||
try {
|
||||
const qrSize = options.qrCodeSize;
|
||||
|
||||
pdf.addImage(
|
||||
qrCode.data,
|
||||
'PNG',
|
||||
x + margin + (contentWidth - qrSize) / 2,
|
||||
currentY,
|
||||
qrSize,
|
||||
qrSize
|
||||
);
|
||||
currentY += qrSize + 2;
|
||||
} catch (error) {
|
||||
console.warn('Failed to add QR code to PDF:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw description
|
||||
if (options.includeDescription && product.description) {
|
||||
pdf.setFontSize(options.fontSize - 1);
|
||||
pdf.setFont('helvetica', 'normal');
|
||||
const descText = this._truncateText(product.description, contentWidth, pdf);
|
||||
|
||||
// Handle multi-line description
|
||||
const lines = pdf.splitTextToSize(descText, contentWidth);
|
||||
const maxLines = Math.floor((y + height - currentY - margin) / 3);
|
||||
const displayLines = lines.slice(0, maxLines);
|
||||
|
||||
displayLines.forEach((line, index) => {
|
||||
if (currentY + (index * 3) < y + height - margin) {
|
||||
pdf.text(line, x + margin, currentY + (index * 3) + 3);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text to fit within specified width
|
||||
* @private
|
||||
* @param {string} text - Text to truncate
|
||||
* @param {number} maxWidth - Maximum width in mm
|
||||
* @param {jsPDF} pdf - PDF document for text measurement
|
||||
* @returns {string} Truncated text
|
||||
*/
|
||||
_truncateText(text, maxWidth, pdf) {
|
||||
if (!text) return '';
|
||||
|
||||
const textWidth = pdf.getTextWidth(text);
|
||||
if (textWidth <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Binary search for optimal length
|
||||
let left = 0;
|
||||
let right = text.length;
|
||||
let result = text;
|
||||
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
const truncated = text.substring(0, mid) + '...';
|
||||
const truncatedWidth = pdf.getTextWidth(truncated);
|
||||
|
||||
if (truncatedWidth <= maxWidth) {
|
||||
result = truncated;
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export layout configuration
|
||||
* @param {Object} layoutConfig - Layout configuration to export
|
||||
* @returns {Object} Exportable configuration
|
||||
*/
|
||||
exportLayoutConfiguration(layoutConfig) {
|
||||
return {
|
||||
version: '1.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
configuration: {
|
||||
...layoutConfig,
|
||||
availableSizes: Object.keys(this.labelSizes)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import layout configuration
|
||||
* @param {Object} configData - Configuration data to import
|
||||
* @returns {Object} Validation result
|
||||
*/
|
||||
importLayoutConfiguration(configData) {
|
||||
try {
|
||||
if (!configData.configuration) {
|
||||
throw new Error('Invalid configuration format');
|
||||
}
|
||||
|
||||
const config = configData.configuration;
|
||||
|
||||
// Validate required fields
|
||||
const requiredFields = ['labelSize'];
|
||||
for (const field of requiredFields) {
|
||||
if (!(field in config)) {
|
||||
throw new Error(`Missing required field: ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate label size
|
||||
if (!this.labelSizes[config.labelSize]) {
|
||||
throw new Error(`Unsupported label size: ${config.labelSize}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
configuration: config,
|
||||
message: 'Configuration imported successfully'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
configData
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PrintableLayoutService;
|
||||
Reference in New Issue
Block a user