Add material transformation planning
This commit is contained in:
@@ -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(`
|
||||
CREATE TABLE IF NOT EXISTS supply_purchase_orders (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
@@ -5,15 +5,18 @@ const {
|
||||
approveReceipt,
|
||||
consumeLotForProduction,
|
||||
createFabricPlan,
|
||||
createMaterialTransformation,
|
||||
createPurchaseOrder,
|
||||
createReceipt,
|
||||
deleteFabricPlan,
|
||||
deleteReceipt,
|
||||
getSupplySummary,
|
||||
listFabricPlans,
|
||||
listMaterialTransformations,
|
||||
listKnownSuppliers,
|
||||
listPurchaseOrders,
|
||||
saveMaterialDetails,
|
||||
updateMaterialTransformationStatus,
|
||||
updatePurchaseOrderStatus,
|
||||
listLots,
|
||||
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) => {
|
||||
try {
|
||||
res.json(await listPurchaseNeeds());
|
||||
|
||||
@@ -92,6 +92,24 @@ const mapFabricPlan = (row) => ({
|
||||
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) => ({
|
||||
materialKey: row.material_key,
|
||||
displayName: row.display_name || '',
|
||||
@@ -182,6 +200,19 @@ const listFabricPlans = async () => {
|
||||
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 result = await pool.query(`
|
||||
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]);
|
||||
};
|
||||
|
||||
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) => {
|
||||
await pool.query(`
|
||||
UPDATE supply_fabric_plans
|
||||
@@ -1243,6 +1317,7 @@ module.exports = {
|
||||
approveReceipt,
|
||||
consumeLotForProduction,
|
||||
createFabricPlan,
|
||||
createMaterialTransformation,
|
||||
createPurchaseOrder,
|
||||
createReceipt,
|
||||
deleteFabricPlan,
|
||||
@@ -1251,6 +1326,7 @@ module.exports = {
|
||||
buildProjectPurchaseNeeds,
|
||||
buildPurchaseNeeds,
|
||||
listFabricPlans,
|
||||
listMaterialTransformations,
|
||||
listMaterialDetails,
|
||||
listKnownSuppliers,
|
||||
listPurchaseOrders,
|
||||
@@ -1259,5 +1335,6 @@ module.exports = {
|
||||
listPurchaseNeeds,
|
||||
listReceipts,
|
||||
saveMaterialDetails,
|
||||
updateMaterialTransformationStatus,
|
||||
updatePurchaseOrderStatus
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
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[]> => {
|
||||
try {
|
||||
const response = await authFetch('/supply/purchase-needs');
|
||||
|
||||
File diff suppressed because one or more lines are too long
34
src/types.ts
34
src/types.ts
@@ -535,6 +535,27 @@ export interface SupplyFabricPlan {
|
||||
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 interface SupplyPurchaseOrderItem {
|
||||
@@ -635,6 +656,19 @@ export type SupplyFabricPlanPayload = {
|
||||
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 = {
|
||||
countedQuantity: number | string;
|
||||
reason: string;
|
||||
|
||||
Reference in New Issue
Block a user