Connect supplies to project demand
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
11
src/types.ts
11
src/types.ts
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user