Initial commit: Inventory Barcode System

This commit is contained in:
2025-07-22 20:24:51 -04:00
commit 511b01748d
63 changed files with 26932 additions and 0 deletions

2
services/.gitkeep Normal file
View File

@ -0,0 +1,2 @@
# Services directory
This directory contains business logic services

View 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;

View 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;

View 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;

View 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;