Compare commits
6 Commits
Improve-da
...
06b0f1251e
| Author | SHA1 | Date | |
|---|---|---|---|
| 06b0f1251e | |||
| 8a43da502a | |||
| bd5bcdd548 | |||
| 0a51328da2 | |||
| b2d7744cc5 | |||
| 8124fc9add |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ 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',
|
||||||
FOREIGN KEY (pid) REFERENCES products(pid),
|
FOREIGN KEY (pid) REFERENCES products(pid),
|
||||||
INDEX idx_po_id (po_id),
|
INDEX idx_po_id (po_id),
|
||||||
|
|||||||
@@ -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 = 0;
|
||||||
const SKIP_TIME_AGGREGATES = 1; // Skip time aggregates
|
const SKIP_TIME_AGGREGATES = 0;
|
||||||
const SKIP_FINANCIAL_METRICS = 1; // Skip financial metrics
|
const SKIP_FINANCIAL_METRICS = 0;
|
||||||
const SKIP_VENDOR_METRICS = 1; // Skip vendor metrics
|
const SKIP_VENDOR_METRICS = 0;
|
||||||
const SKIP_CATEGORY_METRICS = 1; // Skip category metrics
|
const SKIP_CATEGORY_METRICS = 0;
|
||||||
const SKIP_BRAND_METRICS = 1; // Skip brand metrics
|
const SKIP_BRAND_METRICS = 0;
|
||||||
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;
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -212,7 +227,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 +243,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 +352,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 +401,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 +507,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 +524,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
|
||||||
|
|||||||
@@ -470,7 +470,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 +481,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());
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,10 +321,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 +338,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 +352,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 +361,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
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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,11 @@ 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 - COALESCE(o.discount, 0))) > 0
|
||||||
(SUM((o.price - p.cost_price) * o.quantity) * 100.0) / SUM(o.price * o.quantity)
|
THEN (SUM(o.quantity * (o.price - COALESCE(o.discount, 0) - p.cost_price)) * 100.0) /
|
||||||
|
SUM(o.quantity * (o.price - COALESCE(o.discount, 0)))
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END as avg_margin
|
END as avg_margin
|
||||||
FROM filtered_products p
|
FROM filtered_products p
|
||||||
@@ -97,17 +122,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 +161,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;
|
||||||
@@ -207,7 +239,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;
|
||||||
|
|||||||
@@ -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,13 @@ 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,
|
||||||
|
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,29 +165,84 @@ 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
|
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 BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 15 MONTH)
|
AND o.date BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 15 MONTH)
|
||||||
AND DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
|
AND DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
|
||||||
GROUP BY pc.cat_id
|
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
|
||||||
)
|
)
|
||||||
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
|
||||||
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,
|
||||||
|
cm.avg_margin = CASE
|
||||||
|
WHEN cp.revenue > 0 THEN
|
||||||
|
(SUM(o.quantity * (o.price - COALESCE(o.discount, 0) - p.cost_price)) /
|
||||||
|
NULLIF(SUM(o.quantity * (o.price - COALESCE(o.discount, 0))), 0)) * 100
|
||||||
|
ELSE cm.avg_margin
|
||||||
END,
|
END,
|
||||||
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 +257,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 +288,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 +325,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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 INT,
|
||||||
|
percentile DECIMAL(5,2),
|
||||||
|
total_count INT,
|
||||||
|
PRIMARY KEY (pid),
|
||||||
|
INDEX (rank_num),
|
||||||
|
INDEX (dense_rank),
|
||||||
|
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
|
||||||
|
FROM product_metrics
|
||||||
|
WHERE total_revenue > 0
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
pid,
|
||||||
|
total_revenue,
|
||||||
|
rank_num,
|
||||||
|
dense_rank,
|
||||||
|
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');
|
||||||
|
|||||||
@@ -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,72 @@ 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,
|
||||||
|
AVG(ABS(ds.daily_quantity - LAG(ds.daily_quantity) OVER (PARTITION BY ds.pid ORDER BY ds.day_of_week))) as avg_daily_variance
|
||||||
|
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.avg_daily_variance / NULLIF(ds.avg_daily_qty, 0) < 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.avg_daily_variance / NULLIF(ds.avg_daily_qty, 0) < 0.5 THEN 80
|
||||||
|
WHEN ds.total_days >= 30 THEN 75
|
||||||
|
WHEN ds.total_days >= 14 AND ds.avg_daily_variance / NULLIF(ds.avg_daily_qty, 0) < 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 +241,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 +286,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 +362,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;
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -54,18 +66,13 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount,
|
|||||||
SUM(COALESCE(p.cost_price, 0) * o.quantity) as total_cost,
|
SUM(COALESCE(p.cost_price, 0) * o.quantity) as total_cost,
|
||||||
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
|
COUNT(DISTINCT DATE(o.date)) as active_days
|
||||||
WHEN SUM((o.price - COALESCE(o.discount, 0)) * o.quantity) = 0 THEN 0
|
|
||||||
ELSE ((SUM((o.price - COALESCE(o.discount, 0)) * o.quantity) -
|
|
||||||
SUM(COALESCE(p.cost_price, 0) * o.quantity)) /
|
|
||||||
SUM((o.price - COALESCE(o.discount, 0)) * o.quantity)) * 100
|
|
||||||
END as profit_margin
|
|
||||||
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 +80,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
|
||||||
@@ -87,7 +93,13 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount,
|
|||||||
COALESCE(p.stock_received, 0) as stock_received,
|
COALESCE(p.stock_received, 0) as stock_received,
|
||||||
COALESCE(p.stock_ordered, 0) as stock_ordered,
|
COALESCE(p.stock_ordered, 0) as stock_ordered,
|
||||||
s.avg_price,
|
s.avg_price,
|
||||||
s.profit_margin
|
s.profit_margin,
|
||||||
|
s.inventory_value,
|
||||||
|
CASE
|
||||||
|
WHEN s.inventory_value > 0 THEN
|
||||||
|
(s.total_revenue - s.total_cost) / s.inventory_value
|
||||||
|
ELSE 0
|
||||||
|
END as gmroi
|
||||||
FROM sales_data s
|
FROM sales_data s
|
||||||
LEFT JOIN purchase_data p
|
LEFT JOIN purchase_data p
|
||||||
ON s.pid = p.pid
|
ON s.pid = p.pid
|
||||||
@@ -105,7 +117,9 @@ 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,
|
||||||
|
(SELECT cost_price * stock_quantity FROM products WHERE pid = p.pid) as inventory_value,
|
||||||
|
0 as gmroi
|
||||||
FROM purchase_data p
|
FROM purchase_data p
|
||||||
LEFT JOIN sales_data s
|
LEFT JOIN sales_data s
|
||||||
ON p.pid = s.pid
|
ON p.pid = s.pid
|
||||||
@@ -120,7 +134,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 +148,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 +194,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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user