Connect supply planning to purchase needs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
This commit is contained in:
@@ -236,6 +236,20 @@ const initDB = async () => {
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS supply_fabric_plans (
|
||||
id SERIAL PRIMARY KEY,
|
||||
material TEXT NOT NULL,
|
||||
color VARCHAR(120),
|
||||
quantity_kg NUMERIC(14, 4) NOT NULL,
|
||||
supplier TEXT,
|
||||
priority VARCHAR(40) NOT NULL DEFAULT 'Normal',
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE production_orders
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
@@ -303,6 +317,14 @@ const initDB = async () => {
|
||||
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||
`).catch(() => {});
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE supply_fabric_plans
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||
`).catch(() => {});
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -379,6 +401,7 @@ const initDB = async () => {
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_receipts_created_at ON supply_receipts (created_at DESC);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_stock_lots_status ON supply_stock_lots (status);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_movements_created_at ON supply_movements (created_at DESC);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_fabric_plans_status ON supply_fabric_plans (status);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_cliente_fone ON orders (cliente_fone);`);
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_normalized_cliente_nome
|
||||
|
||||
@@ -2,11 +2,15 @@ const express = require('express');
|
||||
const { verifyToken } = require('../auth');
|
||||
const {
|
||||
approveReceipt,
|
||||
createFabricPlan,
|
||||
createReceipt,
|
||||
deleteFabricPlan,
|
||||
deleteReceipt,
|
||||
getSupplySummary,
|
||||
listFabricPlans,
|
||||
listLots,
|
||||
listMovements,
|
||||
listPurchaseNeeds,
|
||||
listReceipts
|
||||
} = require('../services/supplyService');
|
||||
|
||||
@@ -69,4 +73,37 @@ router.get('/supply/movements', verifyToken, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/supply/fabric-plans', verifyToken, async (req, res, next) => {
|
||||
try {
|
||||
res.json(await listFabricPlans());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/supply/fabric-plans', verifyToken, async (req, res, next) => {
|
||||
try {
|
||||
res.status(201).json(await createFabricPlan(req.body || {}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/supply/fabric-plans/:id', verifyToken, async (req, res, next) => {
|
||||
try {
|
||||
await deleteFabricPlan(req.params.id);
|
||||
res.status(204).end();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/supply/purchase-needs', verifyToken, async (req, res, next) => {
|
||||
try {
|
||||
res.json(await listPurchaseNeeds());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -8,6 +8,11 @@ const normalizeNumber = (value) => {
|
||||
return Number.isFinite(number) && number > 0 ? number : null;
|
||||
};
|
||||
|
||||
const normalizeKey = (value) => normalizeText(value)
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase();
|
||||
|
||||
const mapReceipt = (row) => ({
|
||||
id: row.id,
|
||||
category: row.category,
|
||||
@@ -50,6 +55,18 @@ const mapMovement = (row) => ({
|
||||
createdAt: row.created_at
|
||||
});
|
||||
|
||||
const mapFabricPlan = (row) => ({
|
||||
id: row.id,
|
||||
material: row.material,
|
||||
color: row.color || '',
|
||||
quantityKg: Number(row.quantity_kg),
|
||||
supplier: row.supplier || '',
|
||||
priority: row.priority,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
});
|
||||
|
||||
const createValidationError = (message) => {
|
||||
const error = new Error(message);
|
||||
error.statusCode = 400;
|
||||
@@ -84,7 +101,87 @@ const listMovements = async () => {
|
||||
return result.rows.map(mapMovement);
|
||||
};
|
||||
|
||||
const buildStats = (receipts, lots) => {
|
||||
const listFabricPlans = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT id, material, color, quantity_kg, supplier, priority, status, created_at, updated_at
|
||||
FROM supply_fabric_plans
|
||||
WHERE status = 'active'
|
||||
ORDER BY
|
||||
CASE priority
|
||||
WHEN 'Crítico' THEN 1
|
||||
WHEN 'Atenção' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
created_at DESC,
|
||||
id DESC
|
||||
`);
|
||||
return result.rows.map(mapFabricPlan);
|
||||
};
|
||||
|
||||
const buildPurchaseNeeds = (plans, lots, receipts) => {
|
||||
const needsByMaterial = new Map();
|
||||
|
||||
plans.forEach(plan => {
|
||||
const key = normalizeKey(plan.material);
|
||||
if (!key) return;
|
||||
|
||||
const current = needsByMaterial.get(key) || {
|
||||
material: plan.material,
|
||||
plannedKg: 0,
|
||||
stockKg: 0,
|
||||
pendingKg: 0,
|
||||
purchaseKg: 0,
|
||||
priority: 'Normal',
|
||||
suppliers: new Set(),
|
||||
colors: new Set()
|
||||
};
|
||||
|
||||
current.plannedKg += plan.quantityKg;
|
||||
if (plan.supplier) current.suppliers.add(plan.supplier);
|
||||
if (plan.color) current.colors.add(plan.color);
|
||||
if (plan.priority === 'Crítico') current.priority = 'Crítico';
|
||||
if (plan.priority === 'Atenção' && current.priority !== 'Crítico') current.priority = 'Atenção';
|
||||
needsByMaterial.set(key, current);
|
||||
});
|
||||
|
||||
lots.forEach(lot => {
|
||||
if (lot.unit !== 'kg') return;
|
||||
const need = needsByMaterial.get(normalizeKey(lot.product));
|
||||
if (need) need.stockKg += lot.quantity;
|
||||
});
|
||||
|
||||
receipts.forEach(receipt => {
|
||||
if (receipt.status !== 'pending' || receipt.unit !== 'kg') return;
|
||||
const need = needsByMaterial.get(normalizeKey(receipt.product));
|
||||
if (need) need.pendingKg += receipt.quantity;
|
||||
});
|
||||
|
||||
return Array.from(needsByMaterial.values())
|
||||
.map(need => {
|
||||
const purchaseKg = Math.max(need.plannedKg - need.stockKg - need.pendingKg, 0);
|
||||
let status = 'ok';
|
||||
if (purchaseKg > 0 && (need.priority === 'Crítico' || need.stockKg === 0)) status = 'critical';
|
||||
else if (purchaseKg > 0) status = 'attention';
|
||||
|
||||
return {
|
||||
material: need.material,
|
||||
plannedKg: need.plannedKg,
|
||||
stockKg: need.stockKg,
|
||||
pendingKg: need.pendingKg,
|
||||
purchaseKg,
|
||||
priority: need.priority,
|
||||
status,
|
||||
suppliers: Array.from(need.suppliers),
|
||||
colors: Array.from(need.colors)
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const statusOrder = { critical: 1, attention: 2, ok: 3 };
|
||||
return statusOrder[a.status] - statusOrder[b.status] || b.purchaseKg - a.purchaseKg || a.material.localeCompare(b.material);
|
||||
});
|
||||
};
|
||||
|
||||
const buildStats = (receipts, lots, purchaseNeeds) => {
|
||||
const totalQuantityKg = lots.reduce((total, lot) => (
|
||||
lot.unit === 'kg' ? total + lot.quantity : total
|
||||
), 0);
|
||||
@@ -93,27 +190,41 @@ const buildStats = (receipts, lots) => {
|
||||
totalQuantityKg,
|
||||
activeLots: lots.length,
|
||||
rolls: lots.filter(lot => lot.unit === 'rolos').reduce((total, lot) => total + lot.quantity, 0),
|
||||
alerts: 0,
|
||||
alerts: purchaseNeeds.filter(need => need.status !== 'ok').length,
|
||||
pendingReceipts: receipts.filter(receipt => receipt.status === 'pending').length,
|
||||
approvedReceipts: receipts.filter(receipt => receipt.status === 'approved').length
|
||||
};
|
||||
};
|
||||
|
||||
const getSupplySummary = async () => {
|
||||
const [receipts, lots, movements] = await Promise.all([
|
||||
const [receipts, lots, movements, fabricPlans] = await Promise.all([
|
||||
listReceipts(),
|
||||
listLots(),
|
||||
listMovements()
|
||||
listMovements(),
|
||||
listFabricPlans()
|
||||
]);
|
||||
const purchaseNeeds = buildPurchaseNeeds(fabricPlans, lots, receipts);
|
||||
|
||||
return {
|
||||
receipts,
|
||||
lots,
|
||||
movements,
|
||||
stats: buildStats(receipts, lots)
|
||||
fabricPlans,
|
||||
purchaseNeeds,
|
||||
stats: buildStats(receipts, lots, purchaseNeeds)
|
||||
};
|
||||
};
|
||||
|
||||
const listPurchaseNeeds = async () => {
|
||||
const [plans, lots, receipts] = await Promise.all([
|
||||
listFabricPlans(),
|
||||
listLots(),
|
||||
listReceipts()
|
||||
]);
|
||||
|
||||
return buildPurchaseNeeds(plans, lots, receipts);
|
||||
};
|
||||
|
||||
const createReceipt = async (payload) => {
|
||||
const category = normalizeText(payload.category);
|
||||
const product = normalizeText(payload.product);
|
||||
@@ -233,12 +344,49 @@ const deleteReceipt = async (id) => {
|
||||
}
|
||||
};
|
||||
|
||||
const createFabricPlan = async (payload) => {
|
||||
const material = normalizeText(payload.material);
|
||||
const quantityKg = normalizeNumber(payload.quantityKg);
|
||||
|
||||
if (!material) throw createValidationError('Malha ou tecido é obrigatório.');
|
||||
if (!quantityKg) throw createValidationError('Quantidade deve ser maior que zero.');
|
||||
|
||||
const result = await pool.query(`
|
||||
INSERT INTO supply_fabric_plans (
|
||||
material, color, quantity_kg, supplier, priority, status, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, 'active', CURRENT_TIMESTAMP)
|
||||
RETURNING id, material, color, quantity_kg, supplier, priority, status, created_at, updated_at
|
||||
`, [
|
||||
material,
|
||||
normalizeText(payload.color) || 'Todas as cores',
|
||||
quantityKg,
|
||||
normalizeText(payload.supplier) || null,
|
||||
normalizeText(payload.priority) || 'Normal'
|
||||
]);
|
||||
|
||||
return mapFabricPlan(result.rows[0]);
|
||||
};
|
||||
|
||||
const deleteFabricPlan = async (id) => {
|
||||
await pool.query(`
|
||||
UPDATE supply_fabric_plans
|
||||
SET status = 'removed',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`, [id]);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
approveReceipt,
|
||||
createFabricPlan,
|
||||
createReceipt,
|
||||
deleteFabricPlan,
|
||||
deleteReceipt,
|
||||
getSupplySummary,
|
||||
listFabricPlans,
|
||||
listLots,
|
||||
listMovements,
|
||||
listPurchaseNeeds,
|
||||
listReceipts
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user