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(`
|
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,
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
34
src/types.ts
34
src/types.ts
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user