Add material transformation planning

This commit is contained in:
Cauê Faleiros
2026-08-27 10:44:49 -03:00
parent da6f3b2955
commit 442b23dc62
6 changed files with 325 additions and 3 deletions

View File

@@ -559,6 +559,31 @@ const initDB = async () => {
); );
`); `);
// A transformation is a planning/execution record, not a stock movement.
// Physical stock remains controlled only by approved receipts and inventory
// adjustments, so a planned dyeing run can never inflate availability.
await pool.query(`
CREATE TABLE IF NOT EXISTS supply_material_transformations (
id SERIAL PRIMARY KEY,
batch_code VARCHAR(120),
stage VARCHAR(40) NOT NULL,
input_material TEXT NOT NULL,
input_quantity_kg NUMERIC(14, 4) NOT NULL CHECK (input_quantity_kg > 0),
output_material TEXT NOT NULL,
output_color VARCHAR(120),
expected_loss_percent NUMERIC(7, 4) NOT NULL DEFAULT 0 CHECK (expected_loss_percent >= 0 AND expected_loss_percent < 100),
expected_output_kg NUMERIC(14, 4) NOT NULL CHECK (expected_output_kg >= 0),
supplier TEXT,
priority VARCHAR(40) NOT NULL DEFAULT 'Normal',
status VARCHAR(30) NOT NULL DEFAULT 'planned',
notes TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_material_transformations_status ON supply_material_transformations (status, created_at DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_material_transformations_batch ON supply_material_transformations (batch_code, created_at DESC);`);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS supply_purchase_orders ( CREATE TABLE IF NOT EXISTS supply_purchase_orders (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,

View File

@@ -5,15 +5,18 @@ const {
approveReceipt, approveReceipt,
consumeLotForProduction, consumeLotForProduction,
createFabricPlan, createFabricPlan,
createMaterialTransformation,
createPurchaseOrder, createPurchaseOrder,
createReceipt, createReceipt,
deleteFabricPlan, deleteFabricPlan,
deleteReceipt, deleteReceipt,
getSupplySummary, getSupplySummary,
listFabricPlans, listFabricPlans,
listMaterialTransformations,
listKnownSuppliers, listKnownSuppliers,
listPurchaseOrders, listPurchaseOrders,
saveMaterialDetails, saveMaterialDetails,
updateMaterialTransformationStatus,
updatePurchaseOrderStatus, updatePurchaseOrderStatus,
listLots, listLots,
listMovements, listMovements,
@@ -153,6 +156,30 @@ router.delete('/supply/fabric-plans/:id', verifyToken, async (req, res, next) =>
} }
}); });
router.get('/supply/material-transformations', verifyToken, async (req, res, next) => {
try {
res.json(await listMaterialTransformations());
} catch (error) {
next(error);
}
});
router.post('/supply/material-transformations', verifyToken, async (req, res, next) => {
try {
res.status(201).json(await createMaterialTransformation(req.body || {}));
} catch (error) {
next(error);
}
});
router.patch('/supply/material-transformations/:id/status', verifyToken, async (req, res, next) => {
try {
res.json(await updateMaterialTransformationStatus(req.params.id, req.body?.status));
} catch (error) {
next(error);
}
});
router.get('/supply/purchase-needs', verifyToken, async (req, res, next) => { router.get('/supply/purchase-needs', verifyToken, async (req, res, next) => {
try { try {
res.json(await listPurchaseNeeds()); res.json(await listPurchaseNeeds());

View File

@@ -92,6 +92,24 @@ const mapFabricPlan = (row) => ({
updatedAt: row.updated_at updatedAt: row.updated_at
}); });
const mapMaterialTransformation = (row) => ({
id: row.id,
batchCode: row.batch_code || '',
stage: row.stage,
inputMaterial: row.input_material,
inputQuantityKg: Number(row.input_quantity_kg),
outputMaterial: row.output_material,
outputColor: row.output_color || '',
expectedLossPercent: Number(row.expected_loss_percent || 0),
expectedOutputKg: Number(row.expected_output_kg || 0),
supplier: row.supplier || '',
priority: row.priority || 'Normal',
status: row.status || 'planned',
notes: row.notes || '',
createdAt: row.created_at,
updatedAt: row.updated_at
});
const mapMaterialDetails = (row) => ({ const mapMaterialDetails = (row) => ({
materialKey: row.material_key, materialKey: row.material_key,
displayName: row.display_name || '', displayName: row.display_name || '',
@@ -182,6 +200,19 @@ const listFabricPlans = async () => {
return result.rows.map(mapFabricPlan); return result.rows.map(mapFabricPlan);
}; };
const listMaterialTransformations = async () => {
const result = await pool.query(`
SELECT id, batch_code, stage, input_material, input_quantity_kg, output_material, output_color,
expected_loss_percent, expected_output_kg, supplier, priority, status, notes, created_at, updated_at
FROM supply_material_transformations
ORDER BY
CASE status WHEN 'in_progress' THEN 1 WHEN 'planned' THEN 2 WHEN 'completed' THEN 3 ELSE 4 END,
CASE priority WHEN 'Crítico' THEN 1 WHEN 'Atenção' THEN 2 ELSE 3 END,
created_at DESC, id DESC
`);
return result.rows.map(mapMaterialTransformation);
};
const listMaterialDetails = async () => { const listMaterialDetails = async () => {
const result = await pool.query(` const result = await pool.query(`
SELECT material_key, display_name, preferred_supplier, notes, created_at, updated_at SELECT material_key, display_name, preferred_supplier, notes, created_at, updated_at
@@ -1032,6 +1063,49 @@ const createFabricPlan = async (payload) => {
return mapFabricPlan(result.rows[0]); return mapFabricPlan(result.rows[0]);
}; };
const TRANSFORMATION_STAGES = new Set(['knitting', 'dyeing', 'finishing']);
const TRANSFORMATION_STATUSES = new Set(['planned', 'in_progress', 'completed', 'canceled']);
const createMaterialTransformation = async (payload = {}) => {
const stage = normalizeText(payload.stage).toLowerCase();
const inputMaterial = normalizeText(payload.inputMaterial);
const outputMaterial = normalizeText(payload.outputMaterial);
const inputQuantityKg = normalizeNumber(payload.inputQuantityKg);
const expectedLossPercent = normalizeNonNegativeNumber(payload.expectedLossPercent) ?? 0;
if (!TRANSFORMATION_STAGES.has(stage)) throw createValidationError('Etapa de transformação inválida.');
if (!inputMaterial || !outputMaterial) throw createValidationError('Material de entrada e saída são obrigatórios.');
if (!inputQuantityKg) throw createValidationError('Quantidade de entrada deve ser maior que zero.');
if (expectedLossPercent >= 100) throw createValidationError('A perda esperada deve ser menor que 100%.');
const expectedOutputKg = Number((inputQuantityKg * (1 - (expectedLossPercent / 100))).toFixed(4));
const result = await pool.query(`
INSERT INTO supply_material_transformations (
batch_code, stage, input_material, input_quantity_kg, output_material, output_color,
expected_loss_percent, expected_output_kg, supplier, priority, status, notes, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'planned', $11, CURRENT_TIMESTAMP)
RETURNING id, batch_code, stage, input_material, input_quantity_kg, output_material, output_color,
expected_loss_percent, expected_output_kg, supplier, priority, status, notes, created_at, updated_at
`, [
normalizeText(payload.batchCode) || null, stage, inputMaterial, inputQuantityKg, outputMaterial,
normalizeText(payload.outputColor) || null, expectedLossPercent, expectedOutputKg,
normalizeText(payload.supplier) || null, normalizeText(payload.priority) || 'Normal', normalizeText(payload.notes) || null
]);
return mapMaterialTransformation(result.rows[0]);
};
const updateMaterialTransformationStatus = async (id, status) => {
const normalizedStatus = normalizeText(status).toLowerCase();
if (!TRANSFORMATION_STATUSES.has(normalizedStatus)) throw createValidationError('Status da transformação inválido.');
const result = await pool.query(`
UPDATE supply_material_transformations
SET status = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING id, batch_code, stage, input_material, input_quantity_kg, output_material, output_color,
expected_loss_percent, expected_output_kg, supplier, priority, status, notes, created_at, updated_at
`, [id, normalizedStatus]);
if (!result.rows[0]) throw createValidationError('Transformação não encontrada.');
return mapMaterialTransformation(result.rows[0]);
};
const deleteFabricPlan = async (id) => { const deleteFabricPlan = async (id) => {
await pool.query(` await pool.query(`
UPDATE supply_fabric_plans UPDATE supply_fabric_plans
@@ -1243,6 +1317,7 @@ module.exports = {
approveReceipt, approveReceipt,
consumeLotForProduction, consumeLotForProduction,
createFabricPlan, createFabricPlan,
createMaterialTransformation,
createPurchaseOrder, createPurchaseOrder,
createReceipt, createReceipt,
deleteFabricPlan, deleteFabricPlan,
@@ -1251,6 +1326,7 @@ module.exports = {
buildProjectPurchaseNeeds, buildProjectPurchaseNeeds,
buildPurchaseNeeds, buildPurchaseNeeds,
listFabricPlans, listFabricPlans,
listMaterialTransformations,
listMaterialDetails, listMaterialDetails,
listKnownSuppliers, listKnownSuppliers,
listPurchaseOrders, listPurchaseOrders,
@@ -1259,5 +1335,6 @@ module.exports = {
listPurchaseNeeds, listPurchaseNeeds,
listReceipts, listReceipts,
saveMaterialDetails, saveMaterialDetails,
updateMaterialTransformationStatus,
updatePurchaseOrderStatus updatePurchaseOrderStatus
}; };

View File

@@ -1,4 +1,4 @@
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CutPlanLotReservationPayload, CuttingSettings, DashboardAnalytics, DateRange, GraphsStockBalanceHistoryItem, GraphsStockBalancePayload, ManagedUser, OperationalUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderDefectPayload, ProductionOrderExecutionPayload, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, ProductionPlan, ProductionPlanPayload, ProductionWorkstation, ProductionWorkstationPayload, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyPurchaseOrder, SupplyPurchaseOrderPayload, SupplyPurchaseOrderStatus, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CutPlanLotReservationPayload, CuttingSettings, DashboardAnalytics, DateRange, GraphsStockBalanceHistoryItem, GraphsStockBalancePayload, ManagedUser, MaterialTransformationStatus, OperationalUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderDefectPayload, ProductionOrderExecutionPayload, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, ProductionPlan, ProductionPlanPayload, ProductionWorkstation, ProductionWorkstationPayload, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyMaterialTransformation, SupplyMaterialTransformationPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyPurchaseOrder, SupplyPurchaseOrderPayload, SupplyPurchaseOrderStatus, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
import { formatDateParam } from './dateRanges'; import { formatDateParam } from './dateRanges';
const API_URL = import.meta.env.VITE_API_URL || '/api'; const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -732,6 +732,35 @@ export const deleteSupplyFabricPlan = async (id: number): Promise<void> => {
} }
}; };
export const fetchSupplyMaterialTransformations = async (): Promise<SupplyMaterialTransformation[]> => {
try {
const response = await authFetch('/supply/material-transformations');
if (!response.ok) return [];
return await response.json();
} catch (error) {
console.error('Fetch material transformations failed', error);
return [];
}
};
export const createSupplyMaterialTransformation = async (payload: SupplyMaterialTransformationPayload): Promise<SupplyMaterialTransformation> => {
const response = await authFetch('/supply/material-transformations', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível salvar a transformação.');
return data as SupplyMaterialTransformation;
};
export const updateSupplyMaterialTransformationStatus = async (id: number, status: MaterialTransformationStatus): Promise<SupplyMaterialTransformation> => {
const response = await authFetch(`/supply/material-transformations/${id}/status`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status })
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível atualizar a transformação.');
return data as SupplyMaterialTransformation;
};
export const fetchSupplyPurchaseNeeds = async (): Promise<SupplyPurchaseNeed[]> => { export const fetchSupplyPurchaseNeeds = async (): Promise<SupplyPurchaseNeed[]> => {
try { try {
const response = await authFetch('/supply/purchase-needs'); const response = await authFetch('/supply/purchase-needs');

File diff suppressed because one or more lines are too long

View File

@@ -535,6 +535,27 @@ export interface SupplyFabricPlan {
updatedAt: string; updatedAt: string;
} }
export type MaterialTransformationStage = 'knitting' | 'dyeing' | 'finishing';
export type MaterialTransformationStatus = 'planned' | 'in_progress' | 'completed' | 'canceled';
export interface SupplyMaterialTransformation {
id: number;
batchCode: string;
stage: MaterialTransformationStage;
inputMaterial: string;
inputQuantityKg: number;
outputMaterial: string;
outputColor: string;
expectedLossPercent: number;
expectedOutputKg: number;
supplier: string;
priority: string;
status: MaterialTransformationStatus;
notes: string;
createdAt: string;
updatedAt: string;
}
export type SupplyPurchaseOrderStatus = 'draft' | 'sent' | 'approved' | 'received' | 'canceled'; export type SupplyPurchaseOrderStatus = 'draft' | 'sent' | 'approved' | 'received' | 'canceled';
export interface SupplyPurchaseOrderItem { export interface SupplyPurchaseOrderItem {
@@ -635,6 +656,19 @@ export type SupplyFabricPlanPayload = {
priority?: string; priority?: string;
}; };
export type SupplyMaterialTransformationPayload = {
batchCode?: string;
stage: MaterialTransformationStage;
inputMaterial: string;
inputQuantityKg: number | string;
outputMaterial: string;
outputColor?: string;
expectedLossPercent?: number | string;
supplier?: string;
priority?: string;
notes?: string;
};
export type SupplyInventoryAdjustmentPayload = { export type SupplyInventoryAdjustmentPayload = {
countedQuantity: number | string; countedQuantity: number | string;
reason: string; reason: string;