9 Commits

15 changed files with 1199 additions and 5930 deletions

1
.gitignore vendored
View File

@@ -26,6 +26,7 @@ dist-ssr
dashboard/build/** dashboard/build/**
dashboard-server/frontend/build/** dashboard-server/frontend/build/**
**/build/** **/build/**
.fuse_hidden**
._* ._*
# Build directories # Build directories

View File

@@ -51,13 +51,15 @@ CREATE TABLE products (
baskets INT UNSIGNED DEFAULT 0, baskets INT UNSIGNED DEFAULT 0,
notifies INT UNSIGNED DEFAULT 0, notifies INT UNSIGNED DEFAULT 0,
date_last_sold DATE, date_last_sold DATE,
updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (pid), PRIMARY KEY (pid),
INDEX idx_sku (SKU), INDEX idx_sku (SKU),
INDEX idx_vendor (vendor), INDEX idx_vendor (vendor),
INDEX idx_brand (brand), INDEX idx_brand (brand),
INDEX idx_location (location), INDEX idx_location (location),
INDEX idx_total_sold (total_sold), INDEX idx_total_sold (total_sold),
INDEX idx_date_last_sold (date_last_sold) INDEX idx_date_last_sold (date_last_sold),
INDEX idx_updated (updated)
) ENGINE=InnoDB; ) ENGINE=InnoDB;
-- Create categories table with hierarchy support -- Create categories table with hierarchy support
@@ -118,6 +120,7 @@ CREATE TABLE IF NOT EXISTS orders (
customer_name VARCHAR(100), customer_name VARCHAR(100),
status VARCHAR(20) DEFAULT 'pending', status VARCHAR(20) DEFAULT 'pending',
canceled TINYINT(1) DEFAULT 0, canceled TINYINT(1) DEFAULT 0,
updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY unique_order_line (order_number, pid), UNIQUE KEY unique_order_line (order_number, pid),
KEY order_number (order_number), KEY order_number (order_number),
@@ -125,7 +128,8 @@ CREATE TABLE IF NOT EXISTS orders (
KEY customer (customer), KEY customer (customer),
KEY date (date), KEY date (date),
KEY status (status), KEY status (status),
INDEX idx_orders_metrics (pid, date, canceled) INDEX idx_orders_metrics (pid, date, canceled),
INDEX idx_updated (updated)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Create purchase_orders table with its indexes -- Create purchase_orders table with its indexes
@@ -148,8 +152,9 @@ CREATE TABLE purchase_orders (
received INT DEFAULT 0, received INT DEFAULT 0,
received_date DATE COMMENT 'Date of first receiving', received_date DATE COMMENT 'Date of first receiving',
last_received_date DATE COMMENT 'Date of most recent receiving', last_received_date DATE COMMENT 'Date of most recent receiving',
received_by INT, received_by VARCHAR(100) COMMENT 'Name of person who first received this PO line',
receiving_history JSON COMMENT 'Array of receiving records with qty, date, cost, receiving_id, and alt_po flag', receiving_history JSON COMMENT 'Array of receiving records with qty, date, cost, receiving_id, and alt_po flag',
updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (pid) REFERENCES products(pid), FOREIGN KEY (pid) REFERENCES products(pid),
INDEX idx_po_id (po_id), INDEX idx_po_id (po_id),
INDEX idx_vendor (vendor), INDEX idx_vendor (vendor),
@@ -159,6 +164,7 @@ CREATE TABLE purchase_orders (
INDEX idx_po_metrics (pid, date, receiving_status, received_date), INDEX idx_po_metrics (pid, date, receiving_status, received_date),
INDEX idx_po_product_date (pid, date), INDEX idx_po_product_date (pid, date),
INDEX idx_po_product_status (pid, status), INDEX idx_po_product_status (pid, status),
INDEX idx_updated (updated),
UNIQUE KEY unique_po_product (po_id, pid) UNIQUE KEY unique_po_product (po_id, pid)
) ENGINE=InnoDB; ) ENGINE=InnoDB;

File diff suppressed because it is too large Load Diff

View File

@@ -7,13 +7,13 @@ require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') });
// Configuration flags for controlling which metrics to calculate // Configuration flags for controlling which metrics to calculate
// Set to 1 to skip the corresponding calculation, 0 to run it // Set to 1 to skip the corresponding calculation, 0 to run it
const SKIP_PRODUCT_METRICS = 1; // Skip all product metrics const SKIP_PRODUCT_METRICS = 1;
const SKIP_TIME_AGGREGATES = 1; // Skip time aggregates const SKIP_TIME_AGGREGATES = 1;
const SKIP_FINANCIAL_METRICS = 1; // Skip financial metrics const SKIP_FINANCIAL_METRICS = 1;
const SKIP_VENDOR_METRICS = 1; // Skip vendor metrics const SKIP_VENDOR_METRICS = 1;
const SKIP_CATEGORY_METRICS = 1; // Skip category metrics const SKIP_CATEGORY_METRICS = 1;
const SKIP_BRAND_METRICS = 1; // Skip brand metrics const SKIP_BRAND_METRICS = 1;
const SKIP_SALES_FORECASTS = 1; // Skip sales forecasts const SKIP_SALES_FORECASTS = 0;
// Add error handler for uncaught exceptions // Add error handler for uncaught exceptions
process.on('uncaughtException', (error) => { process.on('uncaughtException', (error) => {
@@ -115,7 +115,12 @@ async function calculateMetrics() {
elapsed: '0s', elapsed: '0s',
remaining: 'Calculating...', remaining: 'Calculating...',
rate: 0, rate: 0,
percentage: '0' percentage: '0',
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
// Get total number of products // Get total number of products
@@ -139,7 +144,12 @@ async function calculateMetrics() {
elapsed: global.formatElapsedTime(startTime), elapsed: global.formatElapsedTime(startTime),
remaining: global.estimateRemaining(startTime, processedCount, totalProducts), remaining: global.estimateRemaining(startTime, processedCount, totalProducts),
rate: global.calculateRate(startTime, processedCount), rate: global.calculateRate(startTime, processedCount),
percentage: '60' percentage: '60',
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
} }
@@ -194,7 +204,12 @@ async function calculateMetrics() {
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
if (isCancelled) return processedCount; if (isCancelled) return processedCount;
@@ -223,7 +238,12 @@ async function calculateMetrics() {
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
if (isCancelled) return processedCount; if (isCancelled) return processedCount;
@@ -247,6 +267,7 @@ async function calculateMetrics() {
// Get total count for percentage calculation // Get total count for percentage calculation
const [rankingCount] = await connection.query('SELECT MAX(rank_num) as total_count FROM temp_revenue_ranks'); const [rankingCount] = await connection.query('SELECT MAX(rank_num) as total_count FROM temp_revenue_ranks');
const totalCount = rankingCount[0].total_count || 1; const totalCount = rankingCount[0].total_count || 1;
const max_rank = totalCount; // Store max_rank for use in classification
outputProgress({ outputProgress({
status: 'running', status: 'running',
@@ -256,7 +277,12 @@ async function calculateMetrics() {
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
if (isCancelled) return processedCount; if (isCancelled) return processedCount;
@@ -282,8 +308,8 @@ async function calculateMetrics() {
ELSE 'C' ELSE 'C'
END END
LIMIT ? LIMIT ?
`, [totalCount, abcThresholds.a_threshold, `, [max_rank, abcThresholds.a_threshold,
totalCount, abcThresholds.b_threshold, max_rank, abcThresholds.b_threshold,
batchSize]); batchSize]);
if (pids.length === 0) { if (pids.length === 0) {
@@ -303,8 +329,8 @@ async function calculateMetrics() {
END, END,
pm.last_calculated_at = NOW() pm.last_calculated_at = NOW()
WHERE pm.pid IN (?) WHERE pm.pid IN (?)
`, [totalCount, abcThresholds.a_threshold, `, [max_rank, abcThresholds.a_threshold,
totalCount, abcThresholds.b_threshold, max_rank, abcThresholds.b_threshold,
pids.map(row => row.pid)]); pids.map(row => row.pid)]);
abcProcessedCount += result.affectedRows; abcProcessedCount += result.affectedRows;
@@ -318,7 +344,12 @@ async function calculateMetrics() {
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
// Small delay between batches to allow other transactions // Small delay between batches to allow other transactions
@@ -337,7 +368,12 @@ async function calculateMetrics() {
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: '0s', remaining: '0s',
rate: calculateRate(startTime, totalProducts), rate: calculateRate(startTime, totalProducts),
percentage: '100' percentage: '100',
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
// Clear progress file on successful completion // Clear progress file on successful completion
@@ -353,7 +389,12 @@ async function calculateMetrics() {
elapsed: global.formatElapsedTime(startTime), elapsed: global.formatElapsedTime(startTime),
remaining: null, remaining: null,
rate: global.calculateRate(startTime, processedCount), rate: global.calculateRate(startTime, processedCount),
percentage: ((processedCount / (totalProducts || 1)) * 100).toFixed(1) percentage: ((processedCount / (totalProducts || 1)) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
} else { } else {
global.outputProgress({ global.outputProgress({
@@ -364,7 +405,12 @@ async function calculateMetrics() {
elapsed: global.formatElapsedTime(startTime), elapsed: global.formatElapsedTime(startTime),
remaining: null, remaining: null,
rate: global.calculateRate(startTime, processedCount), rate: global.calculateRate(startTime, processedCount),
percentage: ((processedCount / (totalProducts || 1)) * 100).toFixed(1) percentage: ((processedCount / (totalProducts || 1)) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
} }
throw error; throw error;

View File

@@ -28,9 +28,18 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
let cumulativeProcessedOrders = 0; let cumulativeProcessedOrders = 0;
try { try {
// Insert temporary table creation queries // Clean up any existing temp tables first
await localConnection.query(` await localConnection.query(`
CREATE TABLE IF NOT EXISTS temp_order_items ( DROP TEMPORARY TABLE IF EXISTS temp_order_items;
DROP TEMPORARY TABLE IF EXISTS temp_order_meta;
DROP TEMPORARY TABLE IF EXISTS temp_order_discounts;
DROP TEMPORARY TABLE IF EXISTS temp_order_taxes;
DROP TEMPORARY TABLE IF EXISTS temp_order_costs;
`);
// Create all temp tables with correct schema
await localConnection.query(`
CREATE TEMPORARY TABLE temp_order_items (
order_id INT UNSIGNED NOT NULL, order_id INT UNSIGNED NOT NULL,
pid INT UNSIGNED NOT NULL, pid INT UNSIGNED NOT NULL,
SKU VARCHAR(50) NOT NULL, SKU VARCHAR(50) NOT NULL,
@@ -40,35 +49,41 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
PRIMARY KEY (order_id, pid) PRIMARY KEY (order_id, pid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8; ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
`); `);
await localConnection.query(` await localConnection.query(`
CREATE TABLE IF NOT EXISTS temp_order_meta ( CREATE TEMPORARY TABLE temp_order_meta (
order_id INT UNSIGNED NOT NULL, order_id INT UNSIGNED NOT NULL,
date DATE NOT NULL, date DATE NOT NULL,
customer VARCHAR(100) NOT NULL, customer VARCHAR(100) NOT NULL,
customer_name VARCHAR(150) NOT NULL, customer_name VARCHAR(150) NOT NULL,
status INT, status INT,
canceled TINYINT(1), canceled TINYINT(1),
summary_discount DECIMAL(10,2) DEFAULT 0.00,
summary_subtotal DECIMAL(10,2) DEFAULT 0.00,
PRIMARY KEY (order_id) PRIMARY KEY (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8; ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
`); `);
await localConnection.query(` await localConnection.query(`
CREATE TABLE IF NOT EXISTS temp_order_discounts ( CREATE TEMPORARY TABLE temp_order_discounts (
order_id INT UNSIGNED NOT NULL, order_id INT UNSIGNED NOT NULL,
pid INT UNSIGNED NOT NULL, pid INT UNSIGNED NOT NULL,
discount DECIMAL(10,2) NOT NULL, discount DECIMAL(10,2) NOT NULL,
PRIMARY KEY (order_id, pid) PRIMARY KEY (order_id, pid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8; ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
`); `);
await localConnection.query(` await localConnection.query(`
CREATE TABLE IF NOT EXISTS temp_order_taxes ( CREATE TEMPORARY TABLE temp_order_taxes (
order_id INT UNSIGNED NOT NULL, order_id INT UNSIGNED NOT NULL,
pid INT UNSIGNED NOT NULL, pid INT UNSIGNED NOT NULL,
tax DECIMAL(10,2) NOT NULL, tax DECIMAL(10,2) NOT NULL,
PRIMARY KEY (order_id, pid) PRIMARY KEY (order_id, pid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8; ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
`); `);
await localConnection.query(` await localConnection.query(`
CREATE TABLE IF NOT EXISTS temp_order_costs ( CREATE TEMPORARY TABLE temp_order_costs (
order_id INT UNSIGNED NOT NULL, order_id INT UNSIGNED NOT NULL,
pid INT UNSIGNED NOT NULL, pid INT UNSIGNED NOT NULL,
costeach DECIMAL(10,3) DEFAULT 0.000, costeach DECIMAL(10,3) DEFAULT 0.000,
@@ -81,6 +96,7 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
SELECT COLUMN_NAME SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'orders' WHERE TABLE_NAME = 'orders'
AND COLUMN_NAME != 'updated' -- Exclude the updated column
ORDER BY ORDINAL_POSITION ORDER BY ORDINAL_POSITION
`); `);
const columnNames = columns.map(col => col.COLUMN_NAME); const columnNames = columns.map(col => col.COLUMN_NAME);
@@ -212,7 +228,9 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
o.order_cid as customer, o.order_cid as customer,
CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.lastname, '')) as customer_name, CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.lastname, '')) as customer_name,
o.order_status as status, o.order_status as status,
CASE WHEN o.date_cancelled != '0000-00-00 00:00:00' THEN 1 ELSE 0 END as canceled CASE WHEN o.date_cancelled != '0000-00-00 00:00:00' THEN 1 ELSE 0 END as canceled,
o.summary_discount,
o.summary_subtotal
FROM _order o FROM _order o
LEFT JOIN users u ON o.order_cid = u.cid LEFT JOIN users u ON o.order_cid = u.cid
WHERE o.order_id IN (?) WHERE o.order_id IN (?)
@@ -226,19 +244,37 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
console.log('Found duplicates:', duplicates); console.log('Found duplicates:', duplicates);
} }
const placeholders = orders.map(() => "(?, ?, ?, ?, ?, ?)").join(","); const placeholders = orders.map(() => "(?, ?, ?, ?, ?, ?, ?, ?)").join(",");
const values = orders.flatMap(order => [ const values = orders.flatMap(order => [
order.order_id, order.date, order.customer, order.customer_name, order.status, order.canceled order.order_id,
order.date,
order.customer,
order.customer_name,
order.status,
order.canceled,
order.summary_discount,
order.summary_subtotal
]); ]);
await localConnection.query(` await localConnection.query(`
INSERT INTO temp_order_meta VALUES ${placeholders} INSERT INTO temp_order_meta (
order_id,
date,
customer,
customer_name,
status,
canceled,
summary_discount,
summary_subtotal
) VALUES ${placeholders}
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
date = VALUES(date), date = VALUES(date),
customer = VALUES(customer), customer = VALUES(customer),
customer_name = VALUES(customer_name), customer_name = VALUES(customer_name),
status = VALUES(status), status = VALUES(status),
canceled = VALUES(canceled) canceled = VALUES(canceled),
summary_discount = VALUES(summary_discount),
summary_subtotal = VALUES(summary_subtotal)
`, values); `, values);
processedCount = i + orders.length; processedCount = i + orders.length;
@@ -317,14 +353,25 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
for (let i = 0; i < orderIds.length; i += 5000) { for (let i = 0; i < orderIds.length; i += 5000) {
const batchIds = orderIds.slice(i, i + 5000); const batchIds = orderIds.slice(i, i + 5000);
const [costs] = await prodConnection.query(` const [costs] = await prodConnection.query(`
SELECT orderid as order_id, pid, costeach SELECT
FROM order_costs oc.orderid as order_id,
WHERE orderid IN (?) oc.pid,
COALESCE(
oc.costeach,
(SELECT pi.costeach
FROM product_inventory pi
WHERE pi.pid = oc.pid
AND pi.daterec <= o.date_placed
ORDER BY pi.daterec DESC LIMIT 1)
) as costeach
FROM order_costs oc
JOIN _order o ON oc.orderid = o.order_id
WHERE oc.orderid IN (?)
`, [batchIds]); `, [batchIds]);
if (costs.length > 0) { if (costs.length > 0) {
const placeholders = costs.map(() => '(?, ?, ?)').join(","); const placeholders = costs.map(() => '(?, ?, ?)').join(",");
const values = costs.flatMap(c => [c.order_id, c.pid, c.costeach]); const values = costs.flatMap(c => [c.order_id, c.pid, c.costeach || 0]);
await localConnection.query(` await localConnection.query(`
INSERT INTO temp_order_costs (order_id, pid, costeach) INSERT INTO temp_order_costs (order_id, pid, costeach)
VALUES ${placeholders} VALUES ${placeholders}
@@ -355,7 +402,13 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
om.date, om.date,
oi.price, oi.price,
oi.quantity, oi.quantity,
oi.base_discount + COALESCE(od.discount, 0) as discount, oi.base_discount + COALESCE(od.discount, 0) +
CASE
WHEN om.summary_discount > 0 THEN
ROUND((om.summary_discount * (oi.price * oi.quantity)) /
NULLIF(om.summary_subtotal, 0), 2)
ELSE 0
END as discount,
COALESCE(ot.tax, 0) as tax, COALESCE(ot.tax, 0) as tax,
0 as tax_included, 0 as tax_included,
0 as shipping, 0 as shipping,
@@ -455,7 +508,13 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
om.date, om.date,
oi.price, oi.price,
oi.quantity, oi.quantity,
oi.base_discount + COALESCE(od.discount, 0) as discount, oi.base_discount + COALESCE(od.discount, 0) +
CASE
WHEN o.summary_discount > 0 THEN
ROUND((o.summary_discount * (oi.price * oi.quantity)) /
NULLIF(o.summary_subtotal, 0), 2)
ELSE 0
END as discount,
COALESCE(ot.tax, 0) as tax, COALESCE(ot.tax, 0) as tax,
0 as tax_included, 0 as tax_included,
0 as shipping, 0 as shipping,
@@ -466,6 +525,7 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
COALESCE(tc.costeach, 0) as costeach COALESCE(tc.costeach, 0) as costeach
FROM temp_order_items oi FROM temp_order_items oi
JOIN temp_order_meta om ON oi.order_id = om.order_id JOIN temp_order_meta om ON oi.order_id = om.order_id
LEFT JOIN _order o ON oi.order_id = o.order_id
LEFT JOIN temp_order_discounts od ON oi.order_id = od.order_id AND oi.pid = od.pid LEFT JOIN temp_order_discounts od ON oi.order_id = od.order_id AND oi.pid = od.pid
LEFT JOIN temp_order_taxes ot ON oi.order_id = ot.order_id AND oi.pid = ot.pid LEFT JOIN temp_order_taxes ot ON oi.order_id = ot.order_id AND oi.pid = ot.pid
LEFT JOIN temp_order_costs tc ON oi.order_id = tc.order_id AND oi.pid = tc.pid LEFT JOIN temp_order_costs tc ON oi.order_id = tc.order_id AND oi.pid = tc.pid

View File

@@ -339,6 +339,7 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
SELECT COLUMN_NAME SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'products' WHERE TABLE_NAME = 'products'
AND COLUMN_NAME != 'updated' -- Exclude the updated column
ORDER BY ORDINAL_POSITION ORDER BY ORDINAL_POSITION
`); `);
const columnNames = columns.map(col => col.COLUMN_NAME); const columnNames = columns.map(col => col.COLUMN_NAME);
@@ -470,7 +471,9 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
// Process category relationships // Process category relationships
if (batch.some(p => p.category_ids)) { if (batch.some(p => p.category_ids)) {
const categoryRelationships = batch // First get all valid categories
const allCategoryIds = [...new Set(
batch
.filter(p => p.category_ids) .filter(p => p.category_ids)
.flatMap(product => .flatMap(product =>
product.category_ids product.category_ids
@@ -479,33 +482,92 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
.filter(id => id) .filter(id => id)
.map(Number) .map(Number)
.filter(id => !isNaN(id)) .filter(id => !isNaN(id))
.map(catId => [catId, product.pid]) )
); )];
// Verify categories exist and get their hierarchy
const [categories] = await localConnection.query(`
WITH RECURSIVE category_hierarchy AS (
SELECT
cat_id,
parent_id,
type,
1 as level,
CAST(cat_id AS CHAR(200)) as path
FROM categories
WHERE cat_id IN (?)
UNION ALL
SELECT
c.cat_id,
c.parent_id,
c.type,
ch.level + 1,
CONCAT(ch.path, ',', c.cat_id)
FROM categories c
JOIN category_hierarchy ch ON c.parent_id = ch.cat_id
WHERE ch.level < 10 -- Prevent infinite recursion
)
SELECT
h.cat_id,
h.parent_id,
h.type,
h.path,
h.level
FROM (
SELECT DISTINCT cat_id, parent_id, type, path, level
FROM category_hierarchy
WHERE cat_id IN (?)
) h
ORDER BY h.level DESC
`, [allCategoryIds, allCategoryIds]);
const validCategories = new Map(categories.map(c => [c.cat_id, c]));
const validCategoryIds = new Set(categories.map(c => c.cat_id));
// Build category relationships ensuring proper hierarchy
const categoryRelationships = [];
batch
.filter(p => p.category_ids)
.forEach(product => {
const productCategories = product.category_ids
.split(',')
.map(id => id.trim())
.filter(id => id)
.map(Number)
.filter(id => !isNaN(id))
.filter(id => validCategoryIds.has(id))
.map(id => validCategories.get(id))
.sort((a, b) => a.type - b.type); // Sort by type to ensure proper hierarchy
// Only add relationships that maintain proper hierarchy
productCategories.forEach(category => {
if (category.path.split(',').every(parentId =>
validCategoryIds.has(Number(parentId))
)) {
categoryRelationships.push([category.cat_id, product.pid]);
}
});
});
if (categoryRelationships.length > 0) { if (categoryRelationships.length > 0) {
// Verify categories exist before inserting relationships // First remove any existing relationships that will be replaced
const uniqueCatIds = [...new Set(categoryRelationships.map(([catId]) => catId))]; await localConnection.query(`
const [existingCats] = await localConnection.query( DELETE FROM product_categories
"SELECT cat_id FROM categories WHERE cat_id IN (?)", WHERE pid IN (?) AND cat_id IN (?)
[uniqueCatIds] `, [
); [...new Set(categoryRelationships.map(([_, pid]) => pid))],
const existingCatIds = new Set(existingCats.map(c => c.cat_id)); [...new Set(categoryRelationships.map(([catId, _]) => catId))]
]);
// Filter relationships to only include existing categories // Then insert the new relationships
const validRelationships = categoryRelationships.filter(([catId]) => const placeholders = categoryRelationships
existingCatIds.has(catId)
);
if (validRelationships.length > 0) {
const catPlaceholders = validRelationships
.map(() => "(?, ?)") .map(() => "(?, ?)")
.join(","); .join(",");
await localConnection.query(
`INSERT IGNORE INTO product_categories (cat_id, pid) await localConnection.query(`
VALUES ${catPlaceholders}`, INSERT INTO product_categories (cat_id, pid)
validRelationships.flat() VALUES ${placeholders}
); `, categoryRelationships.flat());
}
} }
} }
} }
@@ -554,6 +616,7 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
SELECT COLUMN_NAME SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'products' WHERE TABLE_NAME = 'products'
AND COLUMN_NAME != 'updated' -- Exclude the updated column
ORDER BY ORDINAL_POSITION ORDER BY ORDINAL_POSITION
`); `);
const columnNames = columns.map((col) => col.COLUMN_NAME); const columnNames = columns.map((col) => col.COLUMN_NAME);

View File

@@ -33,16 +33,15 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
status: "running", status: "running",
}); });
// Get column names for the insert // Get column names first
const [columns] = await localConnection.query(` const [columns] = await localConnection.query(`
SELECT COLUMN_NAME SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'purchase_orders' WHERE TABLE_NAME = 'purchase_orders'
AND COLUMN_NAME != 'updated' -- Exclude the updated column
ORDER BY ORDINAL_POSITION ORDER BY ORDINAL_POSITION
`); `);
const columnNames = columns const columnNames = columns.map(col => col.COLUMN_NAME);
.map((col) => col.COLUMN_NAME)
.filter((name) => name !== "id");
// Build incremental conditions // Build incremental conditions
const incrementalWhereClause = incrementalUpdate const incrementalWhereClause = incrementalUpdate
@@ -321,10 +320,16 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
let lastFulfillmentReceiving = null; let lastFulfillmentReceiving = null;
for (const receiving of allReceivings) { for (const receiving of allReceivings) {
const qtyToApply = Math.min(remainingToFulfill, receiving.qty_each); // Convert quantities to base units using supplier data
const baseQtyReceived = receiving.qty_each * (
receiving.type === 'original' ? 1 :
Math.max(1, product.supplier_qty_per_unit || 1)
);
const qtyToApply = Math.min(remainingToFulfill, baseQtyReceived);
if (qtyToApply > 0) { if (qtyToApply > 0) {
// If this is the first receiving being applied, use its cost // If this is the first receiving being applied, use its cost
if (actualCost === null) { if (actualCost === null && receiving.cost_each > 0) {
actualCost = receiving.cost_each; actualCost = receiving.cost_each;
firstFulfillmentReceiving = receiving; firstFulfillmentReceiving = receiving;
} }
@@ -332,13 +337,13 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
fulfillmentTracking.push({ fulfillmentTracking.push({
receiving_id: receiving.receiving_id, receiving_id: receiving.receiving_id,
qty_applied: qtyToApply, qty_applied: qtyToApply,
qty_total: receiving.qty_each, qty_total: baseQtyReceived,
cost: receiving.cost_each, cost: receiving.cost_each || actualCost || product.cost_each,
date: receiving.received_date, date: receiving.received_date,
received_by: receiving.received_by, received_by: receiving.received_by,
received_by_name: receiving.received_by_name || 'Unknown', received_by_name: receiving.received_by_name || 'Unknown',
type: receiving.type, type: receiving.type,
remaining_qty: receiving.qty_each - qtyToApply remaining_qty: baseQtyReceived - qtyToApply
}); });
remainingToFulfill -= qtyToApply; remainingToFulfill -= qtyToApply;
} else { } else {
@@ -346,8 +351,8 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
fulfillmentTracking.push({ fulfillmentTracking.push({
receiving_id: receiving.receiving_id, receiving_id: receiving.receiving_id,
qty_applied: 0, qty_applied: 0,
qty_total: receiving.qty_each, qty_total: baseQtyReceived,
cost: receiving.cost_each, cost: receiving.cost_each || actualCost || product.cost_each,
date: receiving.received_date, date: receiving.received_date,
received_by: receiving.received_by, received_by: receiving.received_by,
received_by_name: receiving.received_by_name || 'Unknown', received_by_name: receiving.received_by_name || 'Unknown',
@@ -355,7 +360,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
is_excess: true is_excess: true
}); });
} }
totalReceived += receiving.qty_each; totalReceived += baseQtyReceived;
} }
const receiving_status = !totalReceived ? 1 : // created const receiving_status = !totalReceived ? 1 : // created

View File

@@ -1,82 +0,0 @@
// Split into inserts and updates
const insertsAndUpdates = batch.reduce((acc, po) => {
const key = `${po.po_id}-${po.pid}`;
if (existingPOMap.has(key)) {
const existing = existingPOMap.get(key);
// Check if any values are different
const hasChanges = columnNames.some(col => {
const newVal = po[col] ?? null;
const oldVal = existing[col] ?? null;
// Special handling for numbers to avoid type coercion issues
if (typeof newVal === 'number' && typeof oldVal === 'number') {
return Math.abs(newVal - oldVal) > 0.00001; // Allow for tiny floating point differences
}
// Special handling for receiving_history JSON
if (col === 'receiving_history') {
return JSON.stringify(newVal) !== JSON.stringify(oldVal);
}
return newVal !== oldVal;
});
if (hasChanges) {
console.log(`PO line changed: ${key}`, {
po_id: po.po_id,
pid: po.pid,
changes: columnNames.filter(col => {
const newVal = po[col] ?? null;
const oldVal = existing[col] ?? null;
if (typeof newVal === 'number' && typeof oldVal === 'number') {
return Math.abs(newVal - oldVal) > 0.00001;
}
if (col === 'receiving_history') {
return JSON.stringify(newVal) !== JSON.stringify(oldVal);
}
return newVal !== oldVal;
})
});
acc.updates.push({
po_id: po.po_id,
pid: po.pid,
values: columnNames.map(col => po[col] ?? null)
});
}
} else {
console.log(`New PO line: ${key}`);
acc.inserts.push({
po_id: po.po_id,
pid: po.pid,
values: columnNames.map(col => po[col] ?? null)
});
}
return acc;
}, { inserts: [], updates: [] });
// Handle inserts
if (insertsAndUpdates.inserts.length > 0) {
const insertPlaceholders = Array(insertsAndUpdates.inserts.length).fill(placeholderGroup).join(",");
const insertResult = await localConnection.query(`
INSERT INTO purchase_orders (${columnNames.join(",")})
VALUES ${insertPlaceholders}
`, insertsAndUpdates.inserts.map(i => i.values).flat());
recordsAdded += insertResult[0].affectedRows;
}
// Handle updates
if (insertsAndUpdates.updates.length > 0) {
const updatePlaceholders = Array(insertsAndUpdates.updates.length).fill(placeholderGroup).join(",");
const updateResult = await localConnection.query(`
INSERT INTO purchase_orders (${columnNames.join(",")})
VALUES ${updatePlaceholders}
ON DUPLICATE KEY UPDATE
${columnNames
.filter(col => col !== "po_id" && col !== "pid")
.map(col => `${col} = VALUES(${col})`)
.join(",")};
`, insertsAndUpdates.updates.map(u => u.values).flat());
// Each update affects 2 rows in affectedRows, so we divide by 2 to get actual count
recordsUpdated += insertsAndUpdates.updates.length;
}

View File

@@ -13,7 +13,12 @@ async function calculateBrandMetrics(startTime, totalProducts, processedCount, i
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: null, remaining: null,
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
return processedCount; return processedCount;
} }
@@ -26,7 +31,12 @@ async function calculateBrandMetrics(startTime, totalProducts, processedCount, i
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
// Calculate brand metrics with optimized queries // Calculate brand metrics with optimized queries
@@ -45,10 +55,21 @@ async function calculateBrandMetrics(startTime, totalProducts, processedCount, i
WITH filtered_products AS ( WITH filtered_products AS (
SELECT SELECT
p.*, p.*,
CASE WHEN p.stock_quantity <= 5000 THEN p.pid END as valid_pid,
CASE WHEN p.visible = true AND p.stock_quantity <= 5000 THEN p.pid END as active_pid,
CASE CASE
WHEN p.stock_quantity IS NULL OR p.stock_quantity < 0 OR p.stock_quantity > 5000 THEN 0 WHEN p.stock_quantity <= 5000 AND p.stock_quantity >= 0
THEN p.pid
END as valid_pid,
CASE
WHEN p.visible = true
AND p.stock_quantity <= 5000
AND p.stock_quantity >= 0
THEN p.pid
END as active_pid,
CASE
WHEN p.stock_quantity IS NULL
OR p.stock_quantity < 0
OR p.stock_quantity > 5000
THEN 0
ELSE p.stock_quantity ELSE p.stock_quantity
END as valid_stock END as valid_stock
FROM products p FROM products p
@@ -57,10 +78,13 @@ async function calculateBrandMetrics(startTime, totalProducts, processedCount, i
sales_periods AS ( sales_periods AS (
SELECT SELECT
p.brand, p.brand,
SUM(o.quantity * o.price) as period_revenue, SUM(o.quantity * (o.price - COALESCE(o.discount, 0))) as period_revenue,
SUM(o.quantity * (o.price - COALESCE(o.discount, 0) - p.cost_price)) as period_margin,
COUNT(DISTINCT DATE(o.date)) as period_days,
CASE CASE
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 3 MONTH) THEN 'current' WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 3 MONTH) THEN 'current'
WHEN o.date BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 15 MONTH) AND DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH) THEN 'previous' WHEN o.date BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 15 MONTH)
AND DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH) THEN 'previous'
END as period_type END as period_type
FROM filtered_products p FROM filtered_products p
JOIN orders o ON p.pid = o.pid JOIN orders o ON p.pid = o.pid
@@ -76,10 +100,20 @@ async function calculateBrandMetrics(startTime, totalProducts, processedCount, i
SUM(p.valid_stock) as total_stock_units, SUM(p.valid_stock) as total_stock_units,
SUM(p.valid_stock * p.cost_price) as total_stock_cost, SUM(p.valid_stock * p.cost_price) as total_stock_cost,
SUM(p.valid_stock * p.price) as total_stock_retail, SUM(p.valid_stock * p.price) as total_stock_retail,
COALESCE(SUM(o.quantity * o.price), 0) as total_revenue, COALESCE(SUM(o.quantity * (o.price - COALESCE(o.discount, 0))), 0) as total_revenue,
CASE CASE
WHEN SUM(o.quantity * o.price) > 0 THEN WHEN SUM(o.quantity * o.price) > 0
(SUM((o.price - p.cost_price) * o.quantity) * 100.0) / SUM(o.price * o.quantity) THEN GREATEST(
-100.0,
LEAST(
100.0,
(
SUM(o.quantity * o.price) - -- Use gross revenue (before discounts)
SUM(o.quantity * COALESCE(p.cost_price, 0)) -- Total costs
) * 100.0 /
NULLIF(SUM(o.quantity * o.price), 0) -- Divide by gross revenue
)
)
ELSE 0 ELSE 0
END as avg_margin END as avg_margin
FROM filtered_products p FROM filtered_products p
@@ -97,17 +131,19 @@ async function calculateBrandMetrics(startTime, totalProducts, processedCount, i
bd.avg_margin, bd.avg_margin,
CASE CASE
WHEN MAX(CASE WHEN sp.period_type = 'previous' THEN sp.period_revenue END) = 0 WHEN MAX(CASE WHEN sp.period_type = 'previous' THEN sp.period_revenue END) = 0
AND MAX(CASE WHEN sp.period_type = 'current' THEN sp.period_revenue END) > 0 THEN 100.0 AND MAX(CASE WHEN sp.period_type = 'current' THEN sp.period_revenue END) > 0
WHEN MAX(CASE WHEN sp.period_type = 'previous' THEN sp.period_revenue END) = 0 THEN 0.0 THEN 100.0
ELSE LEAST( WHEN MAX(CASE WHEN sp.period_type = 'previous' THEN sp.period_revenue END) = 0
GREATEST( THEN 0.0
ELSE GREATEST(
-100.0,
LEAST(
((MAX(CASE WHEN sp.period_type = 'current' THEN sp.period_revenue END) - ((MAX(CASE WHEN sp.period_type = 'current' THEN sp.period_revenue END) -
MAX(CASE WHEN sp.period_type = 'previous' THEN sp.period_revenue END)) / MAX(CASE WHEN sp.period_type = 'previous' THEN sp.period_revenue END)) /
NULLIF(MAX(CASE WHEN sp.period_type = 'previous' THEN sp.period_revenue END), 0)) * 100.0, NULLIF(ABS(MAX(CASE WHEN sp.period_type = 'previous' THEN sp.period_revenue END)), 0)) * 100.0,
-100.0
),
999.99 999.99
) )
)
END as growth_rate END as growth_rate
FROM brand_data bd FROM brand_data bd
LEFT JOIN sales_periods sp ON bd.brand = sp.brand LEFT JOIN sales_periods sp ON bd.brand = sp.brand
@@ -134,7 +170,12 @@ async function calculateBrandMetrics(startTime, totalProducts, processedCount, i
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
if (isCancelled) return processedCount; if (isCancelled) return processedCount;
@@ -177,8 +218,18 @@ async function calculateBrandMetrics(startTime, totalProducts, processedCount, i
SUM(p.valid_stock * p.price) as total_stock_retail, SUM(p.valid_stock * p.price) as total_stock_retail,
SUM(o.quantity * o.price) as total_revenue, SUM(o.quantity * o.price) as total_revenue,
CASE CASE
WHEN SUM(o.quantity * o.price) > 0 THEN WHEN SUM(o.quantity * o.price) > 0
(SUM((o.price - p.cost_price) * o.quantity) * 100.0) / SUM(o.price * o.quantity) THEN GREATEST(
-100.0,
LEAST(
100.0,
(
SUM(o.quantity * o.price) - -- Use gross revenue (before discounts)
SUM(o.quantity * COALESCE(p.cost_price, 0)) -- Total costs
) * 100.0 /
NULLIF(SUM(o.quantity * o.price), 0) -- Divide by gross revenue
)
)
ELSE 0 ELSE 0
END as avg_margin END as avg_margin
FROM filtered_products p FROM filtered_products p
@@ -207,7 +258,12 @@ async function calculateBrandMetrics(startTime, totalProducts, processedCount, i
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
return processedCount; return processedCount;

View File

@@ -13,7 +13,12 @@ async function calculateCategoryMetrics(startTime, totalProducts, processedCount
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: null, remaining: null,
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
return processedCount; return processedCount;
} }
@@ -26,7 +31,12 @@ async function calculateCategoryMetrics(startTime, totalProducts, processedCount
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
// First, calculate base category metrics // First, calculate base category metrics
@@ -67,7 +77,12 @@ async function calculateCategoryMetrics(startTime, totalProducts, processedCount
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
if (isCancelled) return processedCount; if (isCancelled) return processedCount;
@@ -80,19 +95,35 @@ async function calculateCategoryMetrics(startTime, totalProducts, processedCount
SUM(o.quantity * o.price) as total_sales, SUM(o.quantity * o.price) as total_sales,
SUM(o.quantity * (o.price - p.cost_price)) as total_margin, SUM(o.quantity * (o.price - p.cost_price)) as total_margin,
SUM(o.quantity) as units_sold, SUM(o.quantity) as units_sold,
AVG(GREATEST(p.stock_quantity, 0)) as avg_stock AVG(GREATEST(p.stock_quantity, 0)) as avg_stock,
COUNT(DISTINCT DATE(o.date)) as active_days
FROM product_categories pc FROM product_categories pc
JOIN products p ON pc.pid = p.pid JOIN products p ON pc.pid = p.pid
JOIN orders o ON p.pid = o.pid JOIN orders o ON p.pid = o.pid
LEFT JOIN turnover_config tc ON
(tc.category_id = pc.cat_id AND tc.vendor = p.vendor) OR
(tc.category_id = pc.cat_id AND tc.vendor IS NULL) OR
(tc.category_id IS NULL AND tc.vendor = p.vendor) OR
(tc.category_id IS NULL AND tc.vendor IS NULL)
WHERE o.canceled = false WHERE o.canceled = false
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 1 YEAR) AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL COALESCE(tc.calculation_period_days, 30) DAY)
GROUP BY pc.cat_id GROUP BY pc.cat_id
) )
UPDATE category_metrics cm UPDATE category_metrics cm
JOIN category_sales cs ON cm.category_id = cs.cat_id JOIN category_sales cs ON cm.category_id = cs.cat_id
LEFT JOIN turnover_config tc ON
(tc.category_id = cm.category_id AND tc.vendor IS NULL) OR
(tc.category_id IS NULL AND tc.vendor IS NULL)
SET SET
cm.avg_margin = COALESCE(cs.total_margin * 100.0 / NULLIF(cs.total_sales, 0), 0), cm.avg_margin = COALESCE(cs.total_margin * 100.0 / NULLIF(cs.total_sales, 0), 0),
cm.turnover_rate = LEAST(COALESCE(cs.units_sold / NULLIF(cs.avg_stock, 0), 0), 999.99), cm.turnover_rate = CASE
WHEN cs.avg_stock > 0 AND cs.active_days > 0
THEN LEAST(
(cs.units_sold / cs.avg_stock) * (365.0 / cs.active_days),
999.99
)
ELSE 0
END,
cm.last_calculated_at = NOW() cm.last_calculated_at = NOW()
`); `);
@@ -105,7 +136,12 @@ async function calculateCategoryMetrics(startTime, totalProducts, processedCount
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
if (isCancelled) return processedCount; if (isCancelled) return processedCount;
@@ -115,10 +151,14 @@ async function calculateCategoryMetrics(startTime, totalProducts, processedCount
WITH current_period AS ( WITH current_period AS (
SELECT SELECT
pc.cat_id, pc.cat_id,
SUM(o.quantity * o.price) as revenue SUM(o.quantity * (o.price - COALESCE(o.discount, 0)) /
(1 + COALESCE(ss.seasonality_factor, 0))) as revenue,
SUM(o.quantity * (o.price - COALESCE(o.discount, 0) - p.cost_price)) as gross_profit,
COUNT(DISTINCT DATE(o.date)) as days
FROM product_categories pc FROM product_categories pc
JOIN products p ON pc.pid = p.pid JOIN products p ON pc.pid = p.pid
JOIN orders o ON p.pid = o.pid JOIN orders o ON p.pid = o.pid
LEFT JOIN sales_seasonality ss ON MONTH(o.date) = ss.month
WHERE o.canceled = false WHERE o.canceled = false
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 3 MONTH) AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 3 MONTH)
GROUP BY pc.cat_id GROUP BY pc.cat_id
@@ -126,30 +166,106 @@ async function calculateCategoryMetrics(startTime, totalProducts, processedCount
previous_period AS ( previous_period AS (
SELECT SELECT
pc.cat_id, pc.cat_id,
SUM(o.quantity * o.price) as revenue SUM(o.quantity * (o.price - COALESCE(o.discount, 0)) /
(1 + COALESCE(ss.seasonality_factor, 0))) as revenue,
COUNT(DISTINCT DATE(o.date)) as days
FROM product_categories pc
JOIN products p ON pc.pid = p.pid
JOIN orders o ON p.pid = o.pid
LEFT JOIN sales_seasonality ss ON MONTH(o.date) = ss.month
WHERE o.canceled = false
AND o.date BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 15 MONTH)
AND DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
GROUP BY pc.cat_id
),
trend_data AS (
SELECT
pc.cat_id,
MONTH(o.date) as month,
SUM(o.quantity * (o.price - COALESCE(o.discount, 0)) /
(1 + COALESCE(ss.seasonality_factor, 0))) as revenue,
COUNT(DISTINCT DATE(o.date)) as days_in_month
FROM product_categories pc
JOIN products p ON pc.pid = p.pid
JOIN orders o ON p.pid = o.pid
LEFT JOIN sales_seasonality ss ON MONTH(o.date) = ss.month
WHERE o.canceled = false
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 15 MONTH)
GROUP BY pc.cat_id, MONTH(o.date)
),
trend_stats AS (
SELECT
cat_id,
COUNT(*) as n,
AVG(month) as avg_x,
AVG(revenue / NULLIF(days_in_month, 0)) as avg_y,
SUM(month * (revenue / NULLIF(days_in_month, 0))) as sum_xy,
SUM(month * month) as sum_xx
FROM trend_data
GROUP BY cat_id
HAVING COUNT(*) >= 6
),
trend_analysis AS (
SELECT
cat_id,
((n * sum_xy) - (avg_x * n * avg_y)) /
NULLIF((n * sum_xx) - (n * avg_x * avg_x), 0) as trend_slope,
avg_y as avg_daily_revenue
FROM trend_stats
),
margin_calc AS (
SELECT
pc.cat_id,
CASE
WHEN SUM(o.quantity * o.price) > 0 THEN
GREATEST(
-100.0,
LEAST(
100.0,
(
SUM(o.quantity * o.price) - -- Use gross revenue (before discounts)
SUM(o.quantity * COALESCE(p.cost_price, 0)) -- Total costs
) * 100.0 /
NULLIF(SUM(o.quantity * o.price), 0) -- Divide by gross revenue
)
)
ELSE NULL
END as avg_margin
FROM product_categories pc FROM product_categories pc
JOIN products p ON pc.pid = p.pid JOIN products p ON pc.pid = p.pid
JOIN orders o ON p.pid = o.pid JOIN orders o ON p.pid = o.pid
WHERE o.canceled = false WHERE o.canceled = false
AND o.date BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 15 MONTH) AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 3 MONTH)
AND DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
GROUP BY pc.cat_id GROUP BY pc.cat_id
) )
UPDATE category_metrics cm UPDATE category_metrics cm
LEFT JOIN current_period cp ON cm.category_id = cp.cat_id LEFT JOIN current_period cp ON cm.category_id = cp.cat_id
LEFT JOIN previous_period pp ON cm.category_id = pp.cat_id LEFT JOIN previous_period pp ON cm.category_id = pp.cat_id
LEFT JOIN trend_analysis ta ON cm.category_id = ta.cat_id
LEFT JOIN margin_calc mc ON cm.category_id = mc.cat_id
SET SET
cm.growth_rate = CASE cm.growth_rate = CASE
WHEN pp.revenue = 0 AND COALESCE(cp.revenue, 0) > 0 THEN 100.0 WHEN pp.revenue = 0 AND COALESCE(cp.revenue, 0) > 0 THEN 100.0
WHEN pp.revenue = 0 THEN 0.0 WHEN pp.revenue = 0 OR cp.revenue IS NULL THEN 0.0
ELSE LEAST( WHEN ta.trend_slope IS NOT NULL THEN
GREATEST( GREATEST(
((COALESCE(cp.revenue, 0) - pp.revenue) / pp.revenue) * 100.0, -100.0,
-100.0 LEAST(
), (ta.trend_slope / NULLIF(ta.avg_daily_revenue, 0)) * 365 * 100,
999.99 999.99
) )
)
ELSE
GREATEST(
-100.0,
LEAST(
((COALESCE(cp.revenue, 0) - pp.revenue) /
NULLIF(ABS(pp.revenue), 0)) * 100.0,
999.99
)
)
END, END,
cm.avg_margin = COALESCE(mc.avg_margin, cm.avg_margin),
cm.last_calculated_at = NOW() cm.last_calculated_at = NOW()
WHERE cp.cat_id IS NOT NULL OR pp.cat_id IS NOT NULL WHERE cp.cat_id IS NOT NULL OR pp.cat_id IS NOT NULL
`); `);
@@ -163,7 +279,12 @@ async function calculateCategoryMetrics(startTime, totalProducts, processedCount
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
if (isCancelled) return processedCount; if (isCancelled) return processedCount;
@@ -189,13 +310,23 @@ async function calculateCategoryMetrics(startTime, totalProducts, processedCount
COUNT(DISTINCT CASE WHEN p.visible = true THEN p.pid END) as active_products, COUNT(DISTINCT CASE WHEN p.visible = true THEN p.pid END) as active_products,
SUM(p.stock_quantity * p.cost_price) as total_value, SUM(p.stock_quantity * p.cost_price) as total_value,
SUM(o.quantity * o.price) as total_revenue, SUM(o.quantity * o.price) as total_revenue,
CASE
WHEN SUM(o.quantity * o.price) > 0 THEN
LEAST(
GREATEST(
SUM(o.quantity * (o.price - GREATEST(p.cost_price, 0))) * 100.0 /
SUM(o.quantity * o.price),
-100
),
100
)
ELSE 0
END as avg_margin,
COALESCE( COALESCE(
SUM(o.quantity * (o.price - p.cost_price)) * 100.0 / LEAST(
NULLIF(SUM(o.quantity * o.price), 0),
0
) as avg_margin,
COALESCE(
SUM(o.quantity) / NULLIF(AVG(GREATEST(p.stock_quantity, 0)), 0), SUM(o.quantity) / NULLIF(AVG(GREATEST(p.stock_quantity, 0)), 0),
999.99
),
0 0
) as turnover_rate ) as turnover_rate
FROM product_categories pc FROM product_categories pc
@@ -216,13 +347,112 @@ async function calculateCategoryMetrics(startTime, totalProducts, processedCount
processedCount = Math.floor(totalProducts * 0.99); processedCount = Math.floor(totalProducts * 0.99);
outputProgress({ outputProgress({
status: 'running', status: 'running',
operation: 'Time-based metrics calculated', operation: 'Time-based metrics calculated, updating category-sales metrics',
current: processedCount, current: processedCount,
total: totalProducts, total: totalProducts,
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
});
if (isCancelled) return processedCount;
// Calculate category-sales metrics
await connection.query(`
INSERT INTO category_sales_metrics (
category_id,
brand,
period_start,
period_end,
avg_daily_sales,
total_sold,
num_products,
avg_price,
last_calculated_at
)
WITH date_ranges AS (
SELECT
DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY) as period_start,
CURRENT_DATE as period_end
UNION ALL
SELECT
DATE_SUB(CURRENT_DATE, INTERVAL 90 DAY),
DATE_SUB(CURRENT_DATE, INTERVAL 31 DAY)
UNION ALL
SELECT
DATE_SUB(CURRENT_DATE, INTERVAL 180 DAY),
DATE_SUB(CURRENT_DATE, INTERVAL 91 DAY)
UNION ALL
SELECT
DATE_SUB(CURRENT_DATE, INTERVAL 365 DAY),
DATE_SUB(CURRENT_DATE, INTERVAL 181 DAY)
),
sales_data AS (
SELECT
pc.cat_id,
COALESCE(p.brand, 'Unknown') as brand,
dr.period_start,
dr.period_end,
COUNT(DISTINCT p.pid) as num_products,
SUM(o.quantity) as total_sold,
SUM(o.quantity * o.price) as total_revenue,
COUNT(DISTINCT DATE(o.date)) as num_days
FROM products p
JOIN product_categories pc ON p.pid = pc.pid
JOIN orders o ON p.pid = o.pid
CROSS JOIN date_ranges dr
WHERE o.canceled = false
AND o.date BETWEEN dr.period_start AND dr.period_end
GROUP BY pc.cat_id, p.brand, dr.period_start, dr.period_end
)
SELECT
cat_id as category_id,
brand,
period_start,
period_end,
CASE
WHEN num_days > 0
THEN total_sold / num_days
ELSE 0
END as avg_daily_sales,
total_sold,
num_products,
CASE
WHEN total_sold > 0
THEN total_revenue / total_sold
ELSE 0
END as avg_price,
NOW() as last_calculated_at
FROM sales_data
ON DUPLICATE KEY UPDATE
avg_daily_sales = VALUES(avg_daily_sales),
total_sold = VALUES(total_sold),
num_products = VALUES(num_products),
avg_price = VALUES(avg_price),
last_calculated_at = VALUES(last_calculated_at)
`);
processedCount = Math.floor(totalProducts * 1.0);
outputProgress({
status: 'running',
operation: 'Category-sales metrics calculated',
current: processedCount,
total: totalProducts,
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
return processedCount; return processedCount;

View File

@@ -13,7 +13,12 @@ async function calculateFinancialMetrics(startTime, totalProducts, processedCoun
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: null, remaining: null,
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
return processedCount; return processedCount;
} }
@@ -26,7 +31,12 @@ async function calculateFinancialMetrics(startTime, totalProducts, processedCoun
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
// Calculate financial metrics with optimized query // Calculate financial metrics with optimized query
@@ -59,7 +69,8 @@ async function calculateFinancialMetrics(startTime, totalProducts, processedCoun
WHEN COALESCE(pf.inventory_value, 0) > 0 AND pf.active_days > 0 THEN WHEN COALESCE(pf.inventory_value, 0) > 0 AND pf.active_days > 0 THEN
(COALESCE(pf.gross_profit, 0) * (365.0 / pf.active_days)) / COALESCE(pf.inventory_value, 0) (COALESCE(pf.gross_profit, 0) * (365.0 / pf.active_days)) / COALESCE(pf.inventory_value, 0)
ELSE 0 ELSE 0
END END,
pm.last_calculated_at = CURRENT_TIMESTAMP
`); `);
processedCount = Math.floor(totalProducts * 0.65); processedCount = Math.floor(totalProducts * 0.65);
@@ -71,7 +82,12 @@ async function calculateFinancialMetrics(startTime, totalProducts, processedCoun
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
if (isCancelled) return processedCount; if (isCancelled) return processedCount;
@@ -115,7 +131,12 @@ async function calculateFinancialMetrics(startTime, totalProducts, processedCoun
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
return processedCount; return processedCount;

View File

@@ -25,11 +25,23 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: null, remaining: null,
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
return processedCount; return processedCount;
} }
// First ensure all products have a metrics record
await connection.query(`
INSERT IGNORE INTO product_metrics (pid, last_calculated_at)
SELECT pid, NOW()
FROM products
`);
// Calculate base product metrics // Calculate base product metrics
if (!SKIP_PRODUCT_BASE_METRICS) { if (!SKIP_PRODUCT_BASE_METRICS) {
outputProgress({ outputProgress({
@@ -40,7 +52,12 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
// Calculate base metrics // Calculate base metrics
@@ -49,6 +66,8 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount
JOIN ( JOIN (
SELECT SELECT
p.pid, p.pid,
p.stock_quantity,
p.cost_price,
p.cost_price * p.stock_quantity as inventory_value, p.cost_price * p.stock_quantity as inventory_value,
SUM(o.quantity) as total_quantity, SUM(o.quantity) as total_quantity,
COUNT(DISTINCT o.order_number) as number_of_orders, COUNT(DISTINCT o.order_number) as number_of_orders,
@@ -61,7 +80,7 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount
COUNT(DISTINCT DATE(o.date)) as active_days COUNT(DISTINCT DATE(o.date)) as active_days
FROM products p FROM products p
LEFT JOIN orders o ON p.pid = o.pid AND o.canceled = false LEFT JOIN orders o ON p.pid = o.pid AND o.canceled = false
GROUP BY p.pid GROUP BY p.pid, p.stock_quantity, p.cost_price
) stats ON pm.pid = stats.pid ) stats ON pm.pid = stats.pid
SET SET
pm.inventory_value = COALESCE(stats.inventory_value, 0), pm.inventory_value = COALESCE(stats.inventory_value, 0),
@@ -77,6 +96,16 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount
END, END,
pm.first_sale_date = stats.first_sale_date, pm.first_sale_date = stats.first_sale_date,
pm.last_sale_date = stats.last_sale_date, pm.last_sale_date = stats.last_sale_date,
pm.days_of_inventory = CASE
WHEN COALESCE(stats.total_quantity / NULLIF(stats.active_days, 0), 0) > 0
THEN FLOOR(stats.stock_quantity / (stats.total_quantity / stats.active_days))
ELSE NULL
END,
pm.weeks_of_inventory = CASE
WHEN COALESCE(stats.total_quantity / NULLIF(stats.active_days, 0), 0) > 0
THEN FLOOR(stats.stock_quantity / (stats.total_quantity / stats.active_days) / 7)
ELSE NULL
END,
pm.gmroi = CASE pm.gmroi = CASE
WHEN COALESCE(stats.inventory_value, 0) > 0 WHEN COALESCE(stats.inventory_value, 0) > 0
THEN (stats.total_revenue - stats.cost_of_goods_sold) / stats.inventory_value THEN (stats.total_revenue - stats.cost_of_goods_sold) / stats.inventory_value
@@ -85,6 +114,38 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount
pm.last_calculated_at = NOW() pm.last_calculated_at = NOW()
`); `);
// Calculate forecast accuracy and bias
await connection.query(`
WITH forecast_accuracy AS (
SELECT
sf.pid,
AVG(CASE
WHEN o.quantity > 0
THEN ABS(sf.forecast_units - o.quantity) / o.quantity * 100
ELSE 100
END) as avg_forecast_error,
AVG(CASE
WHEN o.quantity > 0
THEN (sf.forecast_units - o.quantity) / o.quantity * 100
ELSE 0
END) as avg_forecast_bias,
MAX(sf.forecast_date) as last_forecast_date
FROM sales_forecasts sf
JOIN orders o ON sf.pid = o.pid
AND DATE(o.date) = sf.forecast_date
WHERE o.canceled = false
AND sf.forecast_date >= DATE_SUB(CURRENT_DATE, INTERVAL 90 DAY)
GROUP BY sf.pid
)
UPDATE product_metrics pm
JOIN forecast_accuracy fa ON pm.pid = fa.pid
SET
pm.forecast_accuracy = GREATEST(0, 100 - LEAST(fa.avg_forecast_error, 100)),
pm.forecast_bias = GREATEST(-100, LEAST(fa.avg_forecast_bias, 100)),
pm.last_forecast_date = fa.last_forecast_date,
pm.last_calculated_at = NOW()
`);
processedCount = Math.floor(totalProducts * 0.4); processedCount = Math.floor(totalProducts * 0.4);
outputProgress({ outputProgress({
status: 'running', status: 'running',
@@ -94,7 +155,12 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
} else { } else {
processedCount = Math.floor(totalProducts * 0.4); processedCount = Math.floor(totalProducts * 0.4);
@@ -106,7 +172,12 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
} }
@@ -122,7 +193,12 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
// Calculate time-based aggregates // Calculate time-based aggregates
@@ -184,7 +260,12 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
} else { } else {
processedCount = Math.floor(totalProducts * 0.6); processedCount = Math.floor(totalProducts * 0.6);
@@ -196,10 +277,155 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
} }
// Calculate ABC classification
outputProgress({
status: 'running',
operation: 'Starting ABC classification',
current: processedCount,
total: totalProducts,
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
});
if (isCancelled) return processedCount;
const [abcConfig] = await connection.query('SELECT a_threshold, b_threshold FROM abc_classification_config WHERE id = 1');
const abcThresholds = abcConfig[0] || { a_threshold: 20, b_threshold: 50 };
// First, create and populate the rankings table with an index
await connection.query('DROP TEMPORARY TABLE IF EXISTS temp_revenue_ranks');
await connection.query(`
CREATE TEMPORARY TABLE temp_revenue_ranks (
pid BIGINT NOT NULL,
total_revenue DECIMAL(10,3),
rank_num INT,
dense_rank_num INT,
percentile DECIMAL(5,2),
total_count INT,
PRIMARY KEY (pid),
INDEX (rank_num),
INDEX (dense_rank_num),
INDEX (percentile)
) ENGINE=MEMORY
`);
// Calculate rankings with proper tie handling
await connection.query(`
INSERT INTO temp_revenue_ranks
WITH revenue_data AS (
SELECT
pid,
total_revenue,
COUNT(*) OVER () as total_count,
PERCENT_RANK() OVER (ORDER BY total_revenue DESC) * 100 as percentile,
RANK() OVER (ORDER BY total_revenue DESC) as rank_num,
DENSE_RANK() OVER (ORDER BY total_revenue DESC) as dense_rank_num
FROM product_metrics
WHERE total_revenue > 0
)
SELECT
pid,
total_revenue,
rank_num,
dense_rank_num,
percentile,
total_count
FROM revenue_data
`);
// Get total count for percentage calculation
const [rankingCount] = await connection.query('SELECT MAX(rank_num) as total_count FROM temp_revenue_ranks');
const totalCount = rankingCount[0].total_count || 1;
const max_rank = totalCount;
// Process updates in batches
let abcProcessedCount = 0;
const batchSize = 5000;
while (true) {
if (isCancelled) return processedCount;
// Get a batch of PIDs that need updating
const [pids] = await connection.query(`
SELECT pm.pid
FROM product_metrics pm
LEFT JOIN temp_revenue_ranks tr ON pm.pid = tr.pid
WHERE pm.abc_class IS NULL
OR pm.abc_class !=
CASE
WHEN tr.pid IS NULL THEN 'C'
WHEN tr.percentile <= ? THEN 'A'
WHEN tr.percentile <= ? THEN 'B'
ELSE 'C'
END
LIMIT ?
`, [abcThresholds.a_threshold, abcThresholds.b_threshold, batchSize]);
if (pids.length === 0) break;
await connection.query(`
UPDATE product_metrics pm
LEFT JOIN temp_revenue_ranks tr ON pm.pid = tr.pid
SET pm.abc_class =
CASE
WHEN tr.pid IS NULL THEN 'C'
WHEN tr.percentile <= ? THEN 'A'
WHEN tr.percentile <= ? THEN 'B'
ELSE 'C'
END,
pm.last_calculated_at = NOW()
WHERE pm.pid IN (?)
`, [abcThresholds.a_threshold, abcThresholds.b_threshold, pids.map(row => row.pid)]);
// Now update turnover rate with proper handling of zero inventory periods
await connection.query(`
UPDATE product_metrics pm
JOIN (
SELECT
o.pid,
SUM(o.quantity) as total_sold,
COUNT(DISTINCT DATE(o.date)) as active_days,
AVG(CASE
WHEN p.stock_quantity > 0 THEN p.stock_quantity
ELSE NULL
END) as avg_nonzero_stock
FROM orders o
JOIN products p ON o.pid = p.pid
WHERE o.canceled = false
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 90 DAY)
AND o.pid IN (?)
GROUP BY o.pid
) sales ON pm.pid = sales.pid
SET
pm.turnover_rate = CASE
WHEN sales.avg_nonzero_stock > 0 AND sales.active_days > 0
THEN LEAST(
(sales.total_sold / sales.avg_nonzero_stock) * (365.0 / sales.active_days),
999.99
)
ELSE 0
END,
pm.last_calculated_at = NOW()
WHERE pm.pid IN (?)
`, [pids.map(row => row.pid), pids.map(row => row.pid)]);
}
return processedCount; return processedCount;
} catch (error) { } catch (error) {
logError(error, 'Error calculating product metrics'); logError(error, 'Error calculating product metrics');

View File

@@ -13,7 +13,12 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount,
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: null, remaining: null,
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
return processedCount; return processedCount;
} }
@@ -26,7 +31,12 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount,
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
// First, create a temporary table for forecast dates // First, create a temporary table for forecast dates
@@ -65,7 +75,12 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount,
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
if (isCancelled) return processedCount; if (isCancelled) return processedCount;
@@ -94,7 +109,12 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount,
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
if (isCancelled) return processedCount; if (isCancelled) return processedCount;
@@ -119,7 +139,12 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount,
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
if (isCancelled) return processedCount; if (isCancelled) return processedCount;
@@ -134,37 +159,76 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount,
confidence_level, confidence_level,
last_calculated_at last_calculated_at
) )
WITH daily_stats AS (
SELECT
ds.pid,
AVG(ds.daily_quantity) as avg_daily_qty,
STDDEV(ds.daily_quantity) as std_daily_qty,
COUNT(DISTINCT ds.day_count) as data_points,
SUM(ds.day_count) as total_days,
AVG(ds.daily_revenue) as avg_daily_revenue,
STDDEV(ds.daily_revenue) as std_daily_revenue,
MIN(ds.daily_quantity) as min_daily_qty,
MAX(ds.daily_quantity) as max_daily_qty,
-- Calculate variance without using LAG
COALESCE(
STDDEV(ds.daily_quantity) / NULLIF(AVG(ds.daily_quantity), 0),
0
) as daily_variance_ratio
FROM temp_daily_sales ds
GROUP BY ds.pid
HAVING AVG(ds.daily_quantity) > 0
)
SELECT SELECT
ds.pid, ds.pid,
fd.forecast_date, fd.forecast_date,
GREATEST(0, GREATEST(0,
AVG(ds.daily_quantity) * ROUND(
(1 + COALESCE(sf.seasonality_factor, 0)) ds.avg_daily_qty *
(1 + COALESCE(sf.seasonality_factor, 0)) *
CASE
WHEN ds.std_daily_qty / NULLIF(ds.avg_daily_qty, 0) > 1.5 THEN 0.85
WHEN ds.std_daily_qty / NULLIF(ds.avg_daily_qty, 0) > 1.0 THEN 0.9
WHEN ds.std_daily_qty / NULLIF(ds.avg_daily_qty, 0) > 0.5 THEN 0.95
ELSE 1.0
END,
2
)
) as forecast_units, ) as forecast_units,
GREATEST(0, GREATEST(0,
ROUND(
COALESCE( COALESCE(
CASE CASE
WHEN SUM(ds.day_count) >= 4 THEN AVG(ds.daily_revenue) WHEN ds.data_points >= 4 THEN ds.avg_daily_revenue
ELSE ps.overall_avg_revenue ELSE ps.overall_avg_revenue
END * END *
(1 + COALESCE(sf.seasonality_factor, 0)) * (1 + COALESCE(sf.seasonality_factor, 0)) *
(0.95 + (RAND() * 0.1)), CASE
WHEN ds.std_daily_revenue / NULLIF(ds.avg_daily_revenue, 0) > 1.5 THEN 0.85
WHEN ds.std_daily_revenue / NULLIF(ds.avg_daily_revenue, 0) > 1.0 THEN 0.9
WHEN ds.std_daily_revenue / NULLIF(ds.avg_daily_revenue, 0) > 0.5 THEN 0.95
ELSE 1.0
END,
0 0
),
2
) )
) as forecast_revenue, ) as forecast_revenue,
CASE CASE
WHEN ps.total_days >= 60 THEN 90 WHEN ds.total_days >= 60 AND ds.daily_variance_ratio < 0.5 THEN 90
WHEN ps.total_days >= 30 THEN 80 WHEN ds.total_days >= 60 THEN 85
WHEN ps.total_days >= 14 THEN 70 WHEN ds.total_days >= 30 AND ds.daily_variance_ratio < 0.5 THEN 80
WHEN ds.total_days >= 30 THEN 75
WHEN ds.total_days >= 14 AND ds.daily_variance_ratio < 0.5 THEN 70
WHEN ds.total_days >= 14 THEN 65
ELSE 60 ELSE 60
END as confidence_level, END as confidence_level,
NOW() as last_calculated_at NOW() as last_calculated_at
FROM temp_daily_sales ds FROM daily_stats ds
JOIN temp_product_stats ps ON ds.pid = ps.pid JOIN temp_product_stats ps ON ds.pid = ps.pid
CROSS JOIN temp_forecast_dates fd CROSS JOIN temp_forecast_dates fd
LEFT JOIN sales_seasonality sf ON fd.month = sf.month LEFT JOIN sales_seasonality sf ON fd.month = sf.month
GROUP BY ds.pid, fd.forecast_date, ps.overall_avg_revenue, ps.total_days, sf.seasonality_factor GROUP BY ds.pid, fd.forecast_date, ps.overall_avg_revenue, sf.seasonality_factor
HAVING AVG(ds.daily_quantity) > 0
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
forecast_units = VALUES(forecast_units), forecast_units = VALUES(forecast_units),
forecast_revenue = VALUES(forecast_revenue), forecast_revenue = VALUES(forecast_revenue),
@@ -181,7 +245,12 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount,
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
if (isCancelled) return processedCount; if (isCancelled) return processedCount;
@@ -221,7 +290,12 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount,
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
if (isCancelled) return processedCount; if (isCancelled) return processedCount;
@@ -292,7 +366,12 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount,
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
return processedCount; return processedCount;

View File

@@ -13,7 +13,12 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount,
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: null, remaining: null,
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
return processedCount; return processedCount;
} }
@@ -26,7 +31,12 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount,
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
// Initial insert of time-based aggregates // Initial insert of time-based aggregates
@@ -42,9 +52,11 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount,
stock_received, stock_received,
stock_ordered, stock_ordered,
avg_price, avg_price,
profit_margin profit_margin,
inventory_value,
gmroi
) )
WITH sales_data AS ( WITH monthly_sales AS (
SELECT SELECT
o.pid, o.pid,
YEAR(o.date) as year, YEAR(o.date) as year,
@@ -55,17 +67,19 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount,
COUNT(DISTINCT o.order_number) as order_count, COUNT(DISTINCT o.order_number) as order_count,
AVG(o.price - COALESCE(o.discount, 0)) as avg_price, AVG(o.price - COALESCE(o.discount, 0)) as avg_price,
CASE CASE
WHEN SUM((o.price - COALESCE(o.discount, 0)) * o.quantity) = 0 THEN 0 WHEN SUM((o.price - COALESCE(o.discount, 0)) * o.quantity) > 0
ELSE ((SUM((o.price - COALESCE(o.discount, 0)) * o.quantity) - THEN ((SUM((o.price - COALESCE(o.discount, 0)) * o.quantity) - SUM(COALESCE(p.cost_price, 0) * o.quantity))
SUM(COALESCE(p.cost_price, 0) * o.quantity)) / / SUM((o.price - COALESCE(o.discount, 0)) * o.quantity)) * 100
SUM((o.price - COALESCE(o.discount, 0)) * o.quantity)) * 100 ELSE 0
END as profit_margin END as profit_margin,
p.cost_price * p.stock_quantity as inventory_value,
COUNT(DISTINCT DATE(o.date)) as active_days
FROM orders o FROM orders o
JOIN products p ON o.pid = p.pid JOIN products p ON o.pid = p.pid
WHERE o.canceled = 0 WHERE o.canceled = false
GROUP BY o.pid, YEAR(o.date), MONTH(o.date) GROUP BY o.pid, YEAR(o.date), MONTH(o.date)
), ),
purchase_data AS ( monthly_stock AS (
SELECT SELECT
pid, pid,
YEAR(date) as year, YEAR(date) as year,
@@ -73,7 +87,6 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount,
SUM(received) as stock_received, SUM(received) as stock_received,
SUM(ordered) as stock_ordered SUM(ordered) as stock_ordered
FROM purchase_orders FROM purchase_orders
WHERE status = 50
GROUP BY pid, YEAR(date), MONTH(date) GROUP BY pid, YEAR(date), MONTH(date)
) )
SELECT SELECT
@@ -84,15 +97,21 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount,
s.total_revenue, s.total_revenue,
s.total_cost, s.total_cost,
s.order_count, s.order_count,
COALESCE(p.stock_received, 0) as stock_received, COALESCE(ms.stock_received, 0) as stock_received,
COALESCE(p.stock_ordered, 0) as stock_ordered, COALESCE(ms.stock_ordered, 0) as stock_ordered,
s.avg_price, s.avg_price,
s.profit_margin s.profit_margin,
FROM sales_data s s.inventory_value,
LEFT JOIN purchase_data p CASE
ON s.pid = p.pid WHEN s.inventory_value > 0 THEN
AND s.year = p.year (s.total_revenue - s.total_cost) / s.inventory_value
AND s.month = p.month ELSE 0
END as gmroi
FROM monthly_sales s
LEFT JOIN monthly_stock ms
ON s.pid = ms.pid
AND s.year = ms.year
AND s.month = ms.month
UNION UNION
SELECT SELECT
p.pid, p.pid,
@@ -105,9 +124,11 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount,
p.stock_received, p.stock_received,
p.stock_ordered, p.stock_ordered,
0 as avg_price, 0 as avg_price,
0 as profit_margin 0 as profit_margin,
FROM purchase_data p (SELECT cost_price * stock_quantity FROM products WHERE pid = p.pid) as inventory_value,
LEFT JOIN sales_data s 0 as gmroi
FROM monthly_stock p
LEFT JOIN monthly_sales s
ON p.pid = s.pid ON p.pid = s.pid
AND p.year = s.year AND p.year = s.year
AND p.month = s.month AND p.month = s.month
@@ -120,7 +141,9 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount,
stock_received = VALUES(stock_received), stock_received = VALUES(stock_received),
stock_ordered = VALUES(stock_ordered), stock_ordered = VALUES(stock_ordered),
avg_price = VALUES(avg_price), avg_price = VALUES(avg_price),
profit_margin = VALUES(profit_margin) profit_margin = VALUES(profit_margin),
inventory_value = VALUES(inventory_value),
gmroi = VALUES(gmroi)
`); `);
processedCount = Math.floor(totalProducts * 0.60); processedCount = Math.floor(totalProducts * 0.60);
@@ -132,7 +155,12 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount,
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
if (isCancelled) return processedCount; if (isCancelled) return processedCount;
@@ -173,7 +201,12 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount,
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
return processedCount; return processedCount;

View File

@@ -13,7 +13,12 @@ async function calculateVendorMetrics(startTime, totalProducts, processedCount,
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: null, remaining: null,
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
return processedCount; return processedCount;
} }
@@ -26,7 +31,12 @@ async function calculateVendorMetrics(startTime, totalProducts, processedCount,
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
// First ensure all vendors exist in vendor_details // First ensure all vendors exist in vendor_details
@@ -50,7 +60,12 @@ async function calculateVendorMetrics(startTime, totalProducts, processedCount,
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
if (isCancelled) return processedCount; if (isCancelled) return processedCount;
@@ -68,6 +83,8 @@ async function calculateVendorMetrics(startTime, totalProducts, processedCount,
avg_order_value, avg_order_value,
active_products, active_products,
total_products, total_products,
total_purchase_value,
avg_margin_percent,
status, status,
last_calculated_at last_calculated_at
) )
@@ -76,7 +93,8 @@ async function calculateVendorMetrics(startTime, totalProducts, processedCount,
p.vendor, p.vendor,
SUM(o.quantity * o.price) as total_revenue, SUM(o.quantity * o.price) as total_revenue,
COUNT(DISTINCT o.id) as total_orders, COUNT(DISTINCT o.id) as total_orders,
COUNT(DISTINCT p.pid) as active_products COUNT(DISTINCT p.pid) as active_products,
SUM(o.quantity * (o.price - p.cost_price)) as total_margin
FROM products p FROM products p
JOIN orders o ON p.pid = o.pid JOIN orders o ON p.pid = o.pid
WHERE o.canceled = false WHERE o.canceled = false
@@ -91,7 +109,8 @@ async function calculateVendorMetrics(startTime, totalProducts, processedCount,
AVG(CASE AVG(CASE
WHEN po.receiving_status = 40 WHEN po.receiving_status = 40
THEN DATEDIFF(po.received_date, po.date) THEN DATEDIFF(po.received_date, po.date)
END) as avg_lead_time_days END) as avg_lead_time_days,
SUM(po.ordered * po.po_cost_price) as total_purchase_value
FROM products p FROM products p
JOIN purchase_orders po ON p.pid = po.pid JOIN purchase_orders po ON p.pid = po.pid
WHERE po.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH) WHERE po.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
@@ -127,6 +146,12 @@ async function calculateVendorMetrics(startTime, totalProducts, processedCount,
END as avg_order_value, END as avg_order_value,
COALESCE(vs.active_products, 0) as active_products, COALESCE(vs.active_products, 0) as active_products,
COALESCE(vpr.total_products, 0) as total_products, COALESCE(vpr.total_products, 0) as total_products,
COALESCE(vp.total_purchase_value, 0) as total_purchase_value,
CASE
WHEN vs.total_revenue > 0
THEN (vs.total_margin / vs.total_revenue) * 100
ELSE 0
END as avg_margin_percent,
'active' as status, 'active' as status,
NOW() as last_calculated_at NOW() as last_calculated_at
FROM vendor_sales vs FROM vendor_sales vs
@@ -143,6 +168,8 @@ async function calculateVendorMetrics(startTime, totalProducts, processedCount,
avg_order_value = VALUES(avg_order_value), avg_order_value = VALUES(avg_order_value),
active_products = VALUES(active_products), active_products = VALUES(active_products),
total_products = VALUES(total_products), total_products = VALUES(total_products),
total_purchase_value = VALUES(total_purchase_value),
avg_margin_percent = VALUES(avg_margin_percent),
status = VALUES(status), status = VALUES(status),
last_calculated_at = VALUES(last_calculated_at) last_calculated_at = VALUES(last_calculated_at)
`); `);
@@ -150,13 +177,129 @@ async function calculateVendorMetrics(startTime, totalProducts, processedCount,
processedCount = Math.floor(totalProducts * 0.9); processedCount = Math.floor(totalProducts * 0.9);
outputProgress({ outputProgress({
status: 'running', status: 'running',
operation: 'Vendor metrics calculated', operation: 'Vendor metrics calculated, updating time-based metrics',
current: processedCount, current: processedCount,
total: totalProducts, total: totalProducts,
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
});
if (isCancelled) return processedCount;
// Calculate time-based metrics
await connection.query(`
INSERT INTO vendor_time_metrics (
vendor,
year,
month,
total_orders,
late_orders,
avg_lead_time_days,
total_purchase_value,
total_revenue,
avg_margin_percent
)
WITH monthly_orders AS (
SELECT
p.vendor,
YEAR(o.date) as year,
MONTH(o.date) as month,
COUNT(DISTINCT o.id) as total_orders,
SUM(o.quantity * o.price) as total_revenue,
SUM(o.quantity * (o.price - p.cost_price)) as total_margin
FROM products p
JOIN orders o ON p.pid = o.pid
WHERE o.canceled = false
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
AND p.vendor IS NOT NULL
GROUP BY p.vendor, YEAR(o.date), MONTH(o.date)
),
monthly_po AS (
SELECT
p.vendor,
YEAR(po.date) as year,
MONTH(po.date) as month,
COUNT(DISTINCT po.id) as total_po,
COUNT(DISTINCT CASE
WHEN po.receiving_status = 40 AND po.received_date > po.expected_date
THEN po.id
END) as late_orders,
AVG(CASE
WHEN po.receiving_status = 40
THEN DATEDIFF(po.received_date, po.date)
END) as avg_lead_time_days,
SUM(po.ordered * po.po_cost_price) as total_purchase_value
FROM products p
JOIN purchase_orders po ON p.pid = po.pid
WHERE po.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
AND p.vendor IS NOT NULL
GROUP BY p.vendor, YEAR(po.date), MONTH(po.date)
)
SELECT
mo.vendor,
mo.year,
mo.month,
COALESCE(mp.total_po, 0) as total_orders,
COALESCE(mp.late_orders, 0) as late_orders,
COALESCE(mp.avg_lead_time_days, 0) as avg_lead_time_days,
COALESCE(mp.total_purchase_value, 0) as total_purchase_value,
mo.total_revenue,
CASE
WHEN mo.total_revenue > 0
THEN (mo.total_margin / mo.total_revenue) * 100
ELSE 0
END as avg_margin_percent
FROM monthly_orders mo
LEFT JOIN monthly_po mp ON mo.vendor = mp.vendor
AND mo.year = mp.year
AND mo.month = mp.month
UNION
SELECT
mp.vendor,
mp.year,
mp.month,
mp.total_po as total_orders,
mp.late_orders,
mp.avg_lead_time_days,
mp.total_purchase_value,
0 as total_revenue,
0 as avg_margin_percent
FROM monthly_po mp
LEFT JOIN monthly_orders mo ON mp.vendor = mo.vendor
AND mp.year = mo.year
AND mp.month = mo.month
WHERE mo.vendor IS NULL
ON DUPLICATE KEY UPDATE
total_orders = VALUES(total_orders),
late_orders = VALUES(late_orders),
avg_lead_time_days = VALUES(avg_lead_time_days),
total_purchase_value = VALUES(total_purchase_value),
total_revenue = VALUES(total_revenue),
avg_margin_percent = VALUES(avg_margin_percent)
`);
processedCount = Math.floor(totalProducts * 0.95);
outputProgress({
status: 'running',
operation: 'Time-based vendor metrics calculated',
current: processedCount,
total: totalProducts,
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1),
timing: {
start_time: new Date(startTime).toISOString(),
end_time: new Date().toISOString(),
elapsed_seconds: Math.round((Date.now() - startTime) / 1000)
}
}); });
return processedCount; return processedCount;