Connect supplies to project demand

This commit is contained in:
Cauê Faleiros
2026-07-13 15:17:33 -03:00
parent 8f3f2f3e93
commit 060b4da907
5 changed files with 334 additions and 17 deletions

View File

@@ -93,11 +93,11 @@ npm run dev
Default local URLs:
```text
Frontend: http://127.0.0.1:3001
Frontend: http://127.0.0.1:3002
Backend: http://127.0.0.1:3004
```
Vite may choose a different frontend port if `3001` is already in use.
Vite may choose a different frontend port if `3002` is already in use.
## Environment

View File

@@ -1,5 +1,8 @@
const { pool } = require('../db');
const DEFAULT_SUPPLY_LOOKBACK_DAYS = 30;
const DEFAULT_SUPPLY_COVERAGE_DAYS = 30;
const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
const normalizeNumber = (value) => {
@@ -19,6 +22,8 @@ const normalizeKey = (value) => normalizeText(value)
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
const normalizeSku = (value) => normalizeText(value).toUpperCase();
const mapReceipt = (row) => ({
id: row.id,
category: row.category,
@@ -187,6 +192,268 @@ const buildPurchaseNeeds = (plans, lots, receipts) => {
});
};
const getReferenceYield = (reference) => {
if (reference.general_yield !== null && Number(reference.general_yield) > 0) {
return Number(reference.general_yield);
}
const sizeYields = reference.size_yields && typeof reference.size_yields === 'object'
? Object.values(reference.size_yields).map(Number).filter(value => Number.isFinite(value) && value > 0)
: [];
if (!sizeYields.length) return null;
return sizeYields.reduce((total, value) => total + value, 0) / sizeYields.length;
};
const listProjectDemandRows = async () => {
const result = await pool.query(`
WITH bounds AS (
SELECT MAX(data_pedido_date) AS end_date
FROM orders
WHERE data_pedido_date IS NOT NULL
),
period_orders AS (
SELECT
produto_id,
MAX(produto_descricao) AS product_name,
SUM(quantidade)::numeric AS quantity_sold
FROM orders, bounds
WHERE data_pedido_date IS NOT NULL
AND bounds.end_date IS NOT NULL
AND data_pedido_date >= (bounds.end_date - ($1::int - 1) * INTERVAL '1 day')::date
AND data_pedido_date <= bounds.end_date
GROUP BY produto_id
)
SELECT
COALESCE(period_orders.produto_id, stock.produto_id) AS product_id,
COALESCE(NULLIF(period_orders.product_name, ''), NULLIF(stock.nome, ''), 'Produto sem nome') AS product_name,
COALESCE(period_orders.quantity_sold, 0)::numeric AS quantity_sold,
COALESCE(stock.saldo, 0)::numeric AS stock_quantity
FROM period_orders
FULL OUTER JOIN stock ON stock.produto_id = period_orders.produto_id
WHERE COALESCE(period_orders.produto_id, stock.produto_id, '') <> ''
ORDER BY quantity_sold DESC, product_name;
`, [DEFAULT_SUPPLY_LOOKBACK_DAYS]);
return result.rows;
};
const listConsumptionReferenceRows = async () => {
const result = await pool.query(`
SELECT
r.product_id,
p.sku AS product_sku,
p.name AS product_name,
r.material_product_id,
m.sku AS material_sku,
m.name AS material_name,
r.color,
r.general_yield,
r.size_yields
FROM consumption_references r
JOIN catalog_products p ON p.id = r.product_id
LEFT JOIN catalog_products m ON m.id = r.material_product_id
ORDER BY p.sku, r.color NULLS FIRST;
`);
return result.rows;
};
const mergeNeedLine = (needsByMaterial, key, patch) => {
const current = needsByMaterial.get(key) || {
material: patch.material,
plannedKg: 0,
stockKg: 0,
pendingKg: 0,
purchaseKg: 0,
priority: patch.priority || 'Normal',
suppliers: new Set(),
colors: new Set(),
unit: patch.unit || 'kg',
source: patch.source || 'manual_plan',
missingReference: Boolean(patch.missingReference),
products: []
};
current.plannedKg += patch.plannedKg || 0;
current.priority = patch.priority === 'Crítico' || current.priority === 'Crítico'
? 'Crítico'
: patch.priority === 'Atenção' || current.priority === 'Atenção'
? 'Atenção'
: current.priority;
current.missingReference = current.missingReference || Boolean(patch.missingReference);
current.source = current.source === patch.source ? current.source : 'mixed';
if (patch.supplier) current.suppliers.add(patch.supplier);
if (patch.color) current.colors.add(patch.color);
if (patch.product) current.products.push(patch.product);
needsByMaterial.set(key, current);
return current;
};
const buildProjectPurchaseNeeds = async (lots, receipts) => {
const [demandRows, referenceRows] = await Promise.all([
listProjectDemandRows(),
listConsumptionReferenceRows()
]);
const referencesBySku = new Map(referenceRows.map(reference => [normalizeSku(reference.product_sku), reference]));
const needsByMaterial = new Map();
demandRows.forEach(row => {
const productId = normalizeSku(row.product_id);
if (!productId) return;
const quantitySold = Number(row.quantity_sold || 0);
const stockQuantity = Number(row.stock_quantity || 0);
const projectedDemand = quantitySold * (DEFAULT_SUPPLY_COVERAGE_DAYS / DEFAULT_SUPPLY_LOOKBACK_DAYS);
const suggestedQuantity = Math.max(Math.ceil(projectedDemand - stockQuantity), 0);
if (suggestedQuantity <= 0) return;
const reference = referencesBySku.get(productId);
if (!reference) {
mergeNeedLine(needsByMaterial, `missing:${productId}`, {
material: `Cadastrar consumo: ${normalizeText(row.product_name) || productId}`,
plannedKg: suggestedQuantity,
priority: 'Crítico',
unit: 'un.',
source: 'project_demand',
missingReference: true,
product: {
productId,
name: normalizeText(row.product_name),
suggestedQuantity,
quantitySold,
stockQuantity
}
});
return;
}
const yieldPerKg = getReferenceYield(reference);
if (!yieldPerKg) {
mergeNeedLine(needsByMaterial, `missing-yield:${productId}`, {
material: `Cadastrar rendimento: ${normalizeText(row.product_name) || productId}`,
plannedKg: suggestedQuantity,
priority: 'Crítico',
unit: 'un.',
source: 'project_demand',
missingReference: true,
color: reference.color,
product: {
productId,
name: normalizeText(row.product_name),
suggestedQuantity,
quantitySold,
stockQuantity
}
});
return;
}
const materialName = normalizeText(reference.material_name) || normalizeText(reference.material_sku) || 'Material sem cadastro';
mergeNeedLine(needsByMaterial, normalizeKey(materialName), {
material: materialName,
plannedKg: suggestedQuantity / yieldPerKg,
priority: 'Atenção',
unit: 'kg',
source: 'project_demand',
color: reference.color,
product: {
productId,
name: normalizeText(row.product_name),
suggestedQuantity,
quantitySold,
stockQuantity,
yieldPerKg
}
});
});
lots.forEach(lot => {
if (lot.unit !== 'kg') return;
const need = needsByMaterial.get(normalizeKey(lot.product));
if (need && need.unit === 'kg') 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.unit === 'kg') 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),
unit: need.unit,
source: need.source,
missingReference: need.missingReference,
products: need.products
};
});
};
const mergePurchaseNeeds = (manualNeeds, projectNeeds) => {
const mergedByKey = new Map();
[...manualNeeds, ...projectNeeds].forEach(need => {
const key = `${need.unit || 'kg'}:${normalizeKey(need.material)}:${need.missingReference ? 'missing' : 'mapped'}`;
const current = mergedByKey.get(key);
if (!current) {
mergedByKey.set(key, {
...need,
suppliers: new Set(need.suppliers || []),
colors: new Set(need.colors || []),
products: [...(need.products || [])]
});
return;
}
current.plannedKg += need.plannedKg;
current.stockKg += need.stockKg;
current.pendingKg += need.pendingKg;
current.purchaseKg += need.purchaseKg;
current.priority = need.priority === 'Crítico' || current.priority === 'Crítico'
? 'Crítico'
: need.priority === 'Atenção' || current.priority === 'Atenção'
? 'Atenção'
: current.priority;
current.status = current.status === 'critical' || need.status === 'critical'
? 'critical'
: current.status === 'attention' || need.status === 'attention'
? 'attention'
: 'ok';
current.source = current.source === need.source ? current.source : 'mixed';
current.missingReference = current.missingReference || Boolean(need.missingReference);
(need.suppliers || []).forEach(supplier => current.suppliers.add(supplier));
(need.colors || []).forEach(color => current.colors.add(color));
current.products.push(...(need.products || []));
});
return Array.from(mergedByKey.values())
.map(need => ({
...need,
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
@@ -209,7 +476,9 @@ const getSupplySummary = async () => {
listMovements(),
listFabricPlans()
]);
const purchaseNeeds = buildPurchaseNeeds(fabricPlans, lots, receipts);
const manualPurchaseNeeds = buildPurchaseNeeds(fabricPlans, lots, receipts);
const projectPurchaseNeeds = await buildProjectPurchaseNeeds(lots, receipts);
const purchaseNeeds = mergePurchaseNeeds(manualPurchaseNeeds, projectPurchaseNeeds);
return {
receipts,
@@ -228,7 +497,10 @@ const listPurchaseNeeds = async () => {
listReceipts()
]);
return buildPurchaseNeeds(plans, lots, receipts);
return mergePurchaseNeeds(
buildPurchaseNeeds(plans, lots, receipts),
await buildProjectPurchaseNeeds(lots, receipts)
);
};
const createReceipt = async (payload) => {
@@ -513,6 +785,8 @@ module.exports = {
deleteFabricPlan,
deleteReceipt,
getSupplySummary,
buildProjectPurchaseNeeds,
buildPurchaseNeeds,
listFabricPlans,
listLots,
listMovements,

View File

@@ -37,7 +37,7 @@ services:
image: gitea.blyzer.com.br/blyzer/graphs-frontend:latest
container_name: graph_frontend
ports:
- "3005:80"
- "3002:80"
depends_on:
- backend
restart: unless-stopped

View File

@@ -1124,6 +1124,24 @@ const purchaseStatusLabels: Record<SupplyPurchaseNeed['status'], string> = {
ok: 'Coberto',
};
const getNeedUnit = (need: SupplyPurchaseNeed) => need.unit || 'kg';
const formatNeedQuantity = (need: SupplyPurchaseNeed, value: number) => (
`${formatNumber(value)} ${getNeedUnit(need)}`
);
const summarizePurchaseNeeds = (needs: SupplyPurchaseNeed[]) => {
const totalsByUnit = needs.reduce<Record<string, number>>((totals, need) => {
if (need.purchaseKg <= 0) return totals;
const unit = getNeedUnit(need);
totals[unit] = (totals[unit] || 0) + need.purchaseKg;
return totals;
}, {});
const summaries = Object.entries(totalsByUnit).map(([unit, total]) => `${formatNumber(total)} ${unit}`);
return summaries.length ? summaries.join(' + ') : '0 kg';
};
const PurchaseNeedsScreen = () => {
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
const [isLoading, setIsLoading] = useState(true);
@@ -1159,17 +1177,19 @@ const PurchaseNeedsScreen = () => {
!normalizedSearch || normalizeSearch(`${need.material} ${need.suppliers.join(' ')} ${need.colors.join(' ')}`).includes(normalizedSearch)
));
const purchaseItemCount = summary.purchaseNeeds.filter(need => need.purchaseKg > 0).length;
const suggestedPurchaseKg = summary.purchaseNeeds.reduce((total, need) => total + need.purchaseKg, 0);
const suggestedPurchaseSummary = summarizePurchaseNeeds(summary.purchaseNeeds);
const pendingSupplierCount = new Set(summary.purchaseNeeds.flatMap(need => need.suppliers)).size;
const missingReferenceCount = summary.purchaseNeeds.filter(need => need.missingReference).length;
return (
<div className={pageClassName}>
<Header title="Necessidade de Compra" subtitle="Materiais abaixo do mínimo e necessidade projetada para compra." backTo="/supplies" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{[
{ label: 'Itens a comprar', value: `${purchaseItemCount}` },
{ label: 'Compra sugerida', value: `${formatNumber(suggestedPurchaseKg)} kg` },
{ label: 'Fornecedores envolvidos', value: `${pendingSupplierCount}` },
{ label: 'Compra sugerida', value: suggestedPurchaseSummary },
{ label: 'Fornecedores', value: `${pendingSupplierCount}` },
{ label: 'Sem referência', value: `${missingReferenceCount}` },
].map(stat => (
<div key={stat.label} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{stat.label}</p>
@@ -1193,10 +1213,12 @@ const PurchaseNeedsScreen = () => {
estoque_kg: need.stockKg,
pendente_kg: need.pendingKg,
comprar_kg: need.purchaseKg,
unidade: getNeedUnit(need),
prioridade: need.priority,
cobertura: purchaseStatusLabels[need.status],
cobertura: need.missingReference ? 'Sem referência de consumo' : purchaseStatusLabels[need.status],
fornecedores: need.suppliers.join(' | '),
cores: need.colors.join(' | '),
produtos: (need.products || []).map(product => `${product.productId} ${product.name}`).join(' | '),
})))}
className={buttonClassName}
>
@@ -1229,21 +1251,31 @@ const PurchaseNeedsScreen = () => {
<div>
<p className="text-sm font-bold text-dark-text">{need.material}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">
{(need.colors.length ? need.colors.join(', ') : 'Todas as cores')} · {(need.suppliers.length ? need.suppliers.join(', ') : 'Sem fornecedor')}
{need.missingReference
? 'Cadastre produto/material em Cadastros > Referência de Consumo'
: `${(need.colors.length ? need.colors.join(', ') : 'Todas as cores')} · ${(need.suppliers.length ? need.suppliers.join(', ') : 'Sem fornecedor')}`}
</p>
{need.products?.length ? (
<p className="mt-1 text-xs font-semibold text-dark-muted">
{need.products.slice(0, 2).map(product => product.productId).join(', ')}
{need.products.length > 2 ? ` +${need.products.length - 2}` : ''}
</p>
) : null}
</div>
<p className="text-sm font-bold text-dark-text">{formatNumber(need.plannedKg)} kg</p>
<p className="text-sm font-bold text-dark-text">{formatNumber(need.stockKg)} kg</p>
<p className="text-sm font-bold text-dark-text">{formatNumber(need.pendingKg)} kg</p>
<p className={`text-sm font-bold ${need.purchaseKg > 0 ? 'text-red-300' : 'text-emerald-300'}`}>{formatNumber(need.purchaseKg)} kg</p>
<p className="text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.plannedKg)}</p>
<p className="text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.stockKg)}</p>
<p className="text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.pendingKg)}</p>
<p className={`text-sm font-bold ${need.purchaseKg > 0 ? 'text-red-300' : 'text-emerald-300'}`}>{formatNeedQuantity(need, need.purchaseKg)}</p>
<span className={`w-fit rounded-full border px-2.5 py-1 text-xs font-bold ${
need.status === 'critical'
need.missingReference
? 'border-red-400/30 bg-red-400/10 text-red-300'
: need.status === 'critical'
? 'border-red-400/30 bg-red-400/10 text-red-300'
: need.status === 'attention'
? 'border-amber-400/30 bg-amber-400/10 text-amber-300'
: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
}`}>
{purchaseStatusLabels[need.status]}
{need.missingReference ? 'Sem referência' : purchaseStatusLabels[need.status]}
</span>
</div>
))}

View File

@@ -232,6 +232,17 @@ export interface SupplyPurchaseNeed {
status: 'critical' | 'attention' | 'ok';
suppliers: string[];
colors: string[];
unit?: string;
source?: string;
missingReference?: boolean;
products?: Array<{
productId: string;
name: string;
suggestedQuantity: number;
quantitySold: number;
stockQuantity: number;
yieldPerKg?: number;
}>;
}
export interface SupplyStats {