Reserve material lots from cut plans
This commit is contained in:
@@ -443,6 +443,24 @@ const initDB = async () => {
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS cut_plan_lot_reservations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
cut_plan_reference TEXT NOT NULL,
|
||||
supply_lot_id INTEGER NOT NULL REFERENCES supply_stock_lots(id) ON DELETE RESTRICT,
|
||||
material_key VARCHAR(255) NOT NULL,
|
||||
quantity NUMERIC(14, 4) NOT NULL CHECK (quantity > 0),
|
||||
consumed_quantity NUMERIC(14, 4) NOT NULL DEFAULT 0 CHECK (consumed_quantity >= 0),
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
released_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_cut_plan_lot_reservations_lot_status ON cut_plan_lot_reservations (supply_lot_id, status);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_cut_plan_lot_reservations_reference ON cut_plan_lot_reservations (cut_plan_reference, status);`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS supply_movements (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
@@ -16,7 +16,8 @@ router.get('/production-orders', verifyToken, async (req, res) => {
|
||||
router.post('/production-orders', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const orders = Array.isArray(req.body?.orders) ? req.body.orders : [];
|
||||
res.status(201).json(await createProductionOrders(orders));
|
||||
const lotReservations = Array.isArray(req.body?.lotReservations) ? req.body.lotReservations : [];
|
||||
res.status(201).json(await createProductionOrders(orders, lotReservations));
|
||||
} catch (error) {
|
||||
console.error('Error creating production orders:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
|
||||
|
||||
@@ -255,6 +255,18 @@ const mapStep = (step) => ({
|
||||
color: step.color || ''
|
||||
});
|
||||
|
||||
const mapLotReservation = (reservation) => ({
|
||||
id: reservation.id,
|
||||
lotId: Number(reservation.supply_lot_id),
|
||||
materialKey: reservation.material_key || '',
|
||||
quantity: Number(reservation.quantity || 0),
|
||||
consumedQuantity: Number(reservation.consumed_quantity || 0),
|
||||
status: reservation.status || '',
|
||||
lotProduct: reservation.lot_product || '',
|
||||
lotUnit: reservation.lot_unit || '',
|
||||
lotSupplier: reservation.lot_supplier || ''
|
||||
});
|
||||
|
||||
const mapProductionOrderRow = (row) => {
|
||||
const status = normalizeStatus(row.status);
|
||||
|
||||
@@ -284,6 +296,7 @@ const mapProductionOrderRow = (row) => {
|
||||
rejectedQuantity: Number(row.rejected_quantity || 0),
|
||||
finishedQuantity: row.finished_quantity === null ? null : Number(row.finished_quantity),
|
||||
qualityNotes: row.quality_notes || '',
|
||||
lotReservations: Array.isArray(row.lot_reservations) ? row.lot_reservations.map(mapLotReservation) : [],
|
||||
markers: Array.isArray(row.markers) ? row.markers.filter(Boolean) : [],
|
||||
components: Array.isArray(row.components) ? row.components.map(mapComponent) : [],
|
||||
steps: Array.isArray(row.steps) ? row.steps.map(mapStep) : [],
|
||||
@@ -354,6 +367,29 @@ const getOrderById = async (id, client = pool) => {
|
||||
),
|
||||
'[]'::json
|
||||
) as components,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT JSON_AGG(
|
||||
JSON_BUILD_OBJECT(
|
||||
'id', reservation.id,
|
||||
'supply_lot_id', reservation.supply_lot_id,
|
||||
'material_key', reservation.material_key,
|
||||
'quantity', reservation.quantity,
|
||||
'consumed_quantity', reservation.consumed_quantity,
|
||||
'status', reservation.status,
|
||||
'lot_product', lot.product,
|
||||
'lot_unit', lot.unit,
|
||||
'lot_supplier', lot.supplier
|
||||
)
|
||||
ORDER BY reservation.created_at, reservation.id
|
||||
)
|
||||
FROM cut_plan_lot_reservations reservation
|
||||
JOIN supply_stock_lots lot ON lot.id = reservation.supply_lot_id
|
||||
WHERE reservation.cut_plan_reference = po.order_reference
|
||||
AND reservation.status IN ('active', 'consumed')
|
||||
),
|
||||
'[]'::json
|
||||
) as lot_reservations,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT JSON_AGG(
|
||||
@@ -441,6 +477,29 @@ const baseProductionOrderSelect = `
|
||||
),
|
||||
'[]'::json
|
||||
) as components,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT JSON_AGG(
|
||||
JSON_BUILD_OBJECT(
|
||||
'id', reservation.id,
|
||||
'supply_lot_id', reservation.supply_lot_id,
|
||||
'material_key', reservation.material_key,
|
||||
'quantity', reservation.quantity,
|
||||
'consumed_quantity', reservation.consumed_quantity,
|
||||
'status', reservation.status,
|
||||
'lot_product', lot.product,
|
||||
'lot_unit', lot.unit,
|
||||
'lot_supplier', lot.supplier
|
||||
)
|
||||
ORDER BY reservation.created_at, reservation.id
|
||||
)
|
||||
FROM cut_plan_lot_reservations reservation
|
||||
JOIN supply_stock_lots lot ON lot.id = reservation.supply_lot_id
|
||||
WHERE reservation.cut_plan_reference = po.order_reference
|
||||
AND reservation.status IN ('active', 'consumed')
|
||||
),
|
||||
'[]'::json
|
||||
) as lot_reservations,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT JSON_AGG(
|
||||
@@ -517,7 +576,7 @@ const listProductionOrders = async (filters = {}) => {
|
||||
return { orders, counts };
|
||||
};
|
||||
|
||||
const createProductionOrders = async (orders = []) => {
|
||||
const createProductionOrders = async (orders = [], lotReservations = []) => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
@@ -530,6 +589,7 @@ const createProductionOrders = async (orders = []) => {
|
||||
const productSku = normalizeText(order.productSku);
|
||||
const orderReference = normalizeText(order.orderReference);
|
||||
const quantity = normalizeQuantity(order.quantity);
|
||||
const components = normalizeComponents(order.components, quantity);
|
||||
|
||||
if (!productDescription || !quantity) {
|
||||
skipped.push({ productSku, productDescription, reason: 'invalid_payload' });
|
||||
@@ -611,10 +671,76 @@ const createProductionOrders = async (orders = []) => {
|
||||
`, [orderId, label, normalizeText(marker.color) || null]);
|
||||
}
|
||||
|
||||
for (const component of components) {
|
||||
await client.query(`
|
||||
INSERT INTO production_order_components (
|
||||
production_order_id, component_tiny_id, component_sku, component_name,
|
||||
quantity_per_unit, total_quantity, unit
|
||||
)
|
||||
VALUES ($1, NULLIF($2, ''), NULLIF($3, ''), $4, $5, $6, NULLIF($7, ''));
|
||||
`, [
|
||||
orderId,
|
||||
component.componentTinyId,
|
||||
component.componentSku,
|
||||
component.componentName,
|
||||
component.quantityPerUnit,
|
||||
component.totalQuantity,
|
||||
component.unit
|
||||
]);
|
||||
}
|
||||
|
||||
const createdOrder = await getOrderById(orderId, client);
|
||||
if (createdOrder) created.push(createdOrder);
|
||||
}
|
||||
|
||||
if (created.length && Array.isArray(lotReservations)) {
|
||||
for (const reservation of lotReservations) {
|
||||
const lotId = Number(reservation?.lotId);
|
||||
const quantity = normalizeQuantity(reservation?.quantity);
|
||||
const materialKey = normalizeText(reservation?.materialKey);
|
||||
const cutPlanReference = normalizeText(reservation?.cutPlanReference) || normalizeText(created[0]?.orderReference);
|
||||
if (!Number.isInteger(lotId) || !quantity || !materialKey || !cutPlanReference) {
|
||||
const error = new Error('Reserva de lote inválida.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const lotResult = await client.query(`
|
||||
SELECT id, quantity, unit, status
|
||||
FROM supply_stock_lots
|
||||
WHERE id = $1
|
||||
FOR UPDATE;
|
||||
`, [lotId]);
|
||||
if (!lotResult.rowCount || lotResult.rows[0].status !== 'active') {
|
||||
const error = new Error('O lote selecionado não está disponível para reserva.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
const activeReservations = await client.query(`
|
||||
SELECT quantity, consumed_quantity
|
||||
FROM cut_plan_lot_reservations
|
||||
WHERE supply_lot_id = $1
|
||||
AND status = 'active'
|
||||
FOR UPDATE;
|
||||
`, [lotId]);
|
||||
const alreadyReserved = activeReservations.rows.reduce((total, row) => (
|
||||
total + Number(row.quantity || 0) - Number(row.consumed_quantity || 0)
|
||||
), 0);
|
||||
const availableQuantity = Math.max(0, Number(lotResult.rows[0].quantity || 0) - alreadyReserved);
|
||||
if (quantity > availableQuantity) {
|
||||
const error = new Error(`O lote #${lotId} possui apenas ${availableQuantity} ${lotResult.rows[0].unit} disponível para nova reserva.`);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
await client.query(`
|
||||
INSERT INTO cut_plan_lot_reservations (
|
||||
cut_plan_reference, supply_lot_id, material_key, quantity, consumed_quantity, status, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, 0, 'active', CURRENT_TIMESTAMP);
|
||||
`, [cutPlanReference, lotId, materialKey, quantity]);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
return { created, skipped };
|
||||
} catch (error) {
|
||||
|
||||
@@ -61,6 +61,8 @@ const mapLot = (row) => ({
|
||||
supplier: row.supplier || '',
|
||||
invoice: row.invoice || '',
|
||||
status: row.status,
|
||||
reservedQuantity: Number(row.reserved_quantity || 0),
|
||||
availableQuantity: Number(row.available_quantity === undefined ? row.quantity : row.available_quantity),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
});
|
||||
@@ -116,10 +118,19 @@ const listReceipts = async () => {
|
||||
|
||||
const listLots = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT id, receipt_id, category, product, quantity, unit, supplier, invoice, status, created_at, updated_at
|
||||
FROM supply_stock_lots
|
||||
WHERE status = 'active'
|
||||
ORDER BY created_at DESC, id DESC
|
||||
SELECT
|
||||
lot.id, lot.receipt_id, lot.category, lot.product, lot.quantity, lot.unit, lot.supplier, lot.invoice, lot.status, lot.created_at, lot.updated_at,
|
||||
COALESCE(reservations.reserved_quantity, 0) AS reserved_quantity,
|
||||
GREATEST(0, lot.quantity - COALESCE(reservations.reserved_quantity, 0)) AS available_quantity
|
||||
FROM supply_stock_lots lot
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(quantity - consumed_quantity), 0) AS reserved_quantity
|
||||
FROM cut_plan_lot_reservations reservation
|
||||
WHERE reservation.supply_lot_id = lot.id
|
||||
AND reservation.status = 'active'
|
||||
) reservations ON TRUE
|
||||
WHERE lot.status = 'active'
|
||||
ORDER BY lot.created_at DESC, lot.id DESC
|
||||
`);
|
||||
return result.rows.map(mapLot);
|
||||
};
|
||||
@@ -233,7 +244,7 @@ const buildPurchaseNeeds = (plans, lots, receipts) => {
|
||||
lots.forEach(lot => {
|
||||
if (lot.unit !== 'kg') return;
|
||||
const need = needsByMaterial.get(normalizeKey(lot.product));
|
||||
if (need) need.stockKg += lot.quantity;
|
||||
if (need) need.stockKg += Number(lot.availableQuantity ?? lot.quantity);
|
||||
});
|
||||
|
||||
receipts.forEach(receipt => {
|
||||
@@ -624,7 +635,7 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
|
||||
lots.forEach(lot => {
|
||||
const need = getNeedForInventory(lot.unit, '', lot.product)
|
||||
|| needsByMaterial.get(`${normalizeUnit(lot.unit)}:${normalizeKey(lot.product)}`);
|
||||
if (need) need.stockKg += lot.quantity;
|
||||
if (need) need.stockKg += Number(lot.availableQuantity ?? lot.quantity);
|
||||
});
|
||||
|
||||
receipts.forEach(receipt => {
|
||||
@@ -962,6 +973,19 @@ const updateLotQuantity = async (client, lotId, quantity) => {
|
||||
return mapLot(result.rows[0]);
|
||||
};
|
||||
|
||||
const getActiveReservationQuantity = async (client, lotId) => {
|
||||
const result = await client.query(`
|
||||
SELECT quantity, consumed_quantity
|
||||
FROM cut_plan_lot_reservations
|
||||
WHERE supply_lot_id = $1
|
||||
AND status = 'active'
|
||||
FOR UPDATE;
|
||||
`, [lotId]);
|
||||
return result.rows.reduce((total, reservation) => (
|
||||
total + Number(reservation.quantity || 0) - Number(reservation.consumed_quantity || 0)
|
||||
), 0);
|
||||
};
|
||||
|
||||
const adjustInventoryLot = async (id, payload) => {
|
||||
const countedQuantity = normalizeNonNegativeNumber(payload.countedQuantity);
|
||||
const reason = normalizeText(payload.reason);
|
||||
@@ -985,6 +1009,10 @@ const adjustInventoryLot = async (id, payload) => {
|
||||
|
||||
const lot = lotResult.rows[0];
|
||||
const currentQuantity = Number(lot.quantity);
|
||||
const reservedQuantity = await getActiveReservationQuantity(client, lot.id);
|
||||
if (countedQuantity < reservedQuantity) {
|
||||
throw createValidationError(`A contagem não pode ficar abaixo de ${reservedQuantity} ${lot.unit} já reservados para corte.`);
|
||||
}
|
||||
const difference = countedQuantity - currentQuantity;
|
||||
const updatedLot = await updateLotQuantity(client, lot.id, countedQuantity);
|
||||
|
||||
@@ -1039,6 +1067,35 @@ const consumeLotForProduction = async (id, payload) => {
|
||||
if (lot.status !== 'active' || currentQuantity <= 0) throw createValidationError('Lote sem saldo disponível.');
|
||||
if (quantity > currentQuantity) throw createValidationError('Quantidade de saída maior que o saldo do lote.');
|
||||
|
||||
let reference = '';
|
||||
if (productionOrderNumber) {
|
||||
const orderResult = await client.query(`
|
||||
SELECT order_reference
|
||||
FROM production_orders
|
||||
WHERE number = $1
|
||||
LIMIT 1;
|
||||
`, [productionOrderNumber]);
|
||||
reference = normalizeText(orderResult.rows[0]?.order_reference);
|
||||
}
|
||||
const allReservationsResult = await client.query(`
|
||||
SELECT id, cut_plan_reference, quantity, consumed_quantity
|
||||
FROM cut_plan_lot_reservations
|
||||
WHERE supply_lot_id = $1
|
||||
AND status = 'active'
|
||||
ORDER BY created_at, id
|
||||
FOR UPDATE;
|
||||
`, [lot.id]);
|
||||
const totalReserved = allReservationsResult.rows.reduce((total, reservation) => (
|
||||
total + Number(reservation.quantity) - Number(reservation.consumed_quantity)
|
||||
), 0);
|
||||
const reservedByThisPlan = allReservationsResult.rows
|
||||
.filter(reservation => reference && reservation.cut_plan_reference === reference)
|
||||
.reduce((total, reservation) => total + Number(reservation.quantity) - Number(reservation.consumed_quantity), 0);
|
||||
const permittedQuantity = Math.max(0, currentQuantity - totalReserved + reservedByThisPlan);
|
||||
if (quantity > permittedQuantity) {
|
||||
throw createValidationError(`O lote possui ${Math.max(0, currentQuantity - totalReserved)} ${lot.unit} livres e ${reservedByThisPlan} ${lot.unit} reservados para esta OP.`);
|
||||
}
|
||||
|
||||
const updatedLot = await updateLotQuantity(client, lot.id, currentQuantity - quantity);
|
||||
const movementReason = [
|
||||
productionOrderNumber ? `OP ${productionOrderNumber}` : '',
|
||||
@@ -1059,6 +1116,36 @@ const consumeLotForProduction = async (id, payload) => {
|
||||
movementReason || 'Saída para produção'
|
||||
]);
|
||||
|
||||
if (productionOrderNumber) {
|
||||
if (reference) {
|
||||
const reservationsResult = await client.query(`
|
||||
SELECT id, quantity, consumed_quantity
|
||||
FROM cut_plan_lot_reservations
|
||||
WHERE supply_lot_id = $1
|
||||
AND cut_plan_reference = $2
|
||||
AND status = 'active'
|
||||
ORDER BY created_at, id
|
||||
FOR UPDATE;
|
||||
`, [lot.id, reference]);
|
||||
let remainingToApply = quantity;
|
||||
for (const reservation of reservationsResult.rows) {
|
||||
if (remainingToApply <= 0) break;
|
||||
const reservedRemaining = Number(reservation.quantity) - Number(reservation.consumed_quantity);
|
||||
const consumed = Math.min(reservedRemaining, remainingToApply);
|
||||
if (consumed <= 0) continue;
|
||||
const nextConsumedQuantity = Number(reservation.consumed_quantity) + consumed;
|
||||
await client.query(`
|
||||
UPDATE cut_plan_lot_reservations
|
||||
SET consumed_quantity = $1,
|
||||
status = CASE WHEN $1 >= quantity THEN 'consumed' ELSE 'active' END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2;
|
||||
`, [nextConsumedQuantity, reservation.id]);
|
||||
remainingToApply -= consumed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
return updatedLot;
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, GraphsStockBalanceHistoryItem, GraphsStockBalancePayload, ManagedUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderExecutionPayload, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, 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, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderExecutionPayload, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
|
||||
import { formatDateParam } from './dateRanges';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
@@ -239,11 +239,11 @@ export const fetchProductionOrders = async (
|
||||
}, options);
|
||||
};
|
||||
|
||||
export const createProductionOrders = async (orders: ProductionOrderPayload[]): Promise<CreateProductionOrdersResult> => {
|
||||
export const createProductionOrders = async (orders: ProductionOrderPayload[], lotReservations: CutPlanLotReservationPayload[] = []): Promise<CreateProductionOrdersResult> => {
|
||||
const response = await authFetch('/production-orders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ orders })
|
||||
body: JSON.stringify({ orders, lotReservations })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Não foi possível criar as ordens de produção.');
|
||||
|
||||
@@ -6,8 +6,8 @@ import PaginationControls from '../components/PaginationControls';
|
||||
import ProductColorBadge from '../components/ProductColorBadge';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
|
||||
import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchProductionOrderImportData, fetchStock, saveCuttingSettings } from '../dataService';
|
||||
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, ProductionYieldRecommendation, StockData } from '../types';
|
||||
import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchProductionOrderImportData, fetchStock, fetchSupplySummary, saveCuttingSettings } from '../dataService';
|
||||
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, ProductionYieldRecommendation, StockData, SupplyLot } from '../types';
|
||||
import { getPlanningStock } from '../planningStock';
|
||||
|
||||
type CutFilter = 'need' | 'all' | 'issues' | 'covered';
|
||||
@@ -19,6 +19,7 @@ type CorrectionIssueFilter = CutProductIssue | 'all';
|
||||
type SettingsSection = 'rules' | 'corrections';
|
||||
type CuttingView = 'plan' | 'families' | 'issues';
|
||||
type MaterialReadiness = { status: 'ready' | 'material_ready' | 'blocked' | 'missing'; blockers: string[] };
|
||||
type CutMaterialRequirement = { key: string; label: string; unit: string; quantity: number };
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings';
|
||||
const coverageTargetOptions = [7, 15, 30, 60];
|
||||
@@ -142,6 +143,7 @@ const Cutting = () => {
|
||||
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
|
||||
const [compositions, setCompositions] = useState<ProductComposition[]>([]);
|
||||
const [materialStock, setMaterialStock] = useState<StockData[]>([]);
|
||||
const [supplyLots, setSupplyLots] = useState<SupplyLot[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [targetCoverageDays, setTargetCoverageDays] = useState(30);
|
||||
@@ -164,6 +166,7 @@ const Cutting = () => {
|
||||
const [previewPage, setPreviewPage] = useState(1);
|
||||
const [previewItemsPerPage, setPreviewItemsPerPage] = useState(10);
|
||||
const [selectedPreviewRowIds, setSelectedPreviewRowIds] = useState<string[]>([]);
|
||||
const [lotSelections, setLotSelections] = useState<Record<string, { lotId: string; quantity: string }>>({});
|
||||
const [editingCutRow, setEditingCutRow] = useState<CutPlanSkuRow | null>(null);
|
||||
const [quickCutEdit, setQuickCutEdit] = useState<CutProductOverride>({});
|
||||
const [isQuickCutEditSaving, setIsQuickCutEditSaving] = useState(false);
|
||||
@@ -213,17 +216,19 @@ const Cutting = () => {
|
||||
|
||||
const loadProducts = async () => {
|
||||
setIsLoading(true);
|
||||
const [productData, productionOrderData, compositionData, stockData] = await Promise.all([
|
||||
const [productData, productionOrderData, compositionData, stockData, supplySummary] = await Promise.all([
|
||||
fetchProductAnalytics(dateRange),
|
||||
fetchProductionOrders(allProductionOrdersRange),
|
||||
fetchProductCompositions(),
|
||||
fetchStock()
|
||||
fetchStock(),
|
||||
fetchSupplySummary()
|
||||
]);
|
||||
if (isMounted) {
|
||||
setProducts(productData);
|
||||
setProductionOrders(productionOrderData.orders);
|
||||
setCompositions(compositionData);
|
||||
setMaterialStock(stockData);
|
||||
setSupplyLots(supplySummary.lots);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -245,7 +250,7 @@ const Cutting = () => {
|
||||
[cuttingSettings, dateRange, openProductionByProductId, products, targetCoverageDays]
|
||||
);
|
||||
|
||||
const materialReadinessByProductId = useMemo(() => {
|
||||
const compositionsByProductId = useMemo(() => {
|
||||
const compositionsById = new Map<string, ProductComposition>();
|
||||
compositions.forEach(composition => {
|
||||
[composition.finishedTinyProductId, composition.finishedProductSku]
|
||||
@@ -253,6 +258,10 @@ const Cutting = () => {
|
||||
.filter(Boolean)
|
||||
.forEach(key => compositionsById.set(key, composition));
|
||||
});
|
||||
return compositionsById;
|
||||
}, [compositions]);
|
||||
|
||||
const materialReadinessByProductId = useMemo(() => {
|
||||
const stockIndexByKey = new Map<string, number>();
|
||||
materialStock.forEach((stock, index) => {
|
||||
[stock.produto_id, stock.nome].map(normalizeMaterialKey).filter(Boolean).forEach(key => stockIndexByKey.set(key, index));
|
||||
@@ -262,7 +271,7 @@ const Cutting = () => {
|
||||
// Shared stock is allocated to the largest current requirement first. This
|
||||
// prevents every SKU from claiming the same material balance independently.
|
||||
[...cutPlan.rows].sort((a, b) => b.suggestedCutQuantity - a.suggestedCutQuantity || a.name.localeCompare(b.name, 'pt-BR')).forEach(row => {
|
||||
const composition = compositionsById.get(normalizeMaterialKey(row.id));
|
||||
const composition = compositionsByProductId.get(normalizeMaterialKey(row.id));
|
||||
if (!composition?.components.length) {
|
||||
// Products without an imported composition remain eligible for cutting.
|
||||
// They are excluded from material-demand calculations instead of
|
||||
@@ -299,7 +308,7 @@ const Cutting = () => {
|
||||
readinessByProductId.set(row.id, { status: 'ready', blockers: [] });
|
||||
});
|
||||
return readinessByProductId;
|
||||
}, [compositions, cutPlan.rows, materialStock]);
|
||||
}, [compositionsByProductId, cutPlan.rows, materialStock]);
|
||||
|
||||
const issueSummaries = useMemo(() => {
|
||||
const counts = new Map<CutIssue, number>();
|
||||
@@ -383,6 +392,43 @@ const Cutting = () => {
|
||||
selectedPreviewRows.reduce((total, row) => total + row.suggestedCutQuantity, 0)
|
||||
), [selectedPreviewRows]);
|
||||
|
||||
const cutPlanReference = useMemo(() => (
|
||||
`Plano de corte ${formatDateKey(dateRange.start)} a ${formatDateKey(dateRange.end)} · ${targetCoverageDays} dias`
|
||||
), [dateRange.end, dateRange.start, targetCoverageDays]);
|
||||
|
||||
const selectedMaterialRequirements = useMemo<CutMaterialRequirement[]>(() => {
|
||||
const requirements = new Map<string, CutMaterialRequirement>();
|
||||
selectedPreviewRows.forEach(row => {
|
||||
const composition = compositionsByProductId.get(normalizeMaterialKey(row.id));
|
||||
composition?.components
|
||||
.filter(component => !isServiceComponent(component.componentName) && component.quantityPerUnit > 0)
|
||||
.forEach(component => {
|
||||
const key = normalizeMaterialKey(component.componentSku || component.componentTinyId || component.componentName);
|
||||
if (!key) return;
|
||||
const current = requirements.get(key) || {
|
||||
key,
|
||||
label: component.componentName,
|
||||
unit: component.unit || 'UN',
|
||||
quantity: 0
|
||||
};
|
||||
current.quantity += row.suggestedCutQuantity * component.quantityPerUnit;
|
||||
requirements.set(key, current);
|
||||
});
|
||||
});
|
||||
return Array.from(requirements.values()).sort((a, b) => a.label.localeCompare(b.label, 'pt-BR'));
|
||||
}, [compositionsByProductId, selectedPreviewRows]);
|
||||
|
||||
const selectedLotReservations = useMemo(() => (
|
||||
selectedMaterialRequirements.flatMap(requirement => {
|
||||
const selection = lotSelections[requirement.key];
|
||||
const lotId = Number(selection?.lotId);
|
||||
const quantity = Number((selection?.quantity || '').replace(',', '.'));
|
||||
return Number.isInteger(lotId) && Number.isFinite(quantity) && quantity > 0
|
||||
? [{ lotId, materialKey: requirement.key, quantity, cutPlanReference }]
|
||||
: [];
|
||||
})
|
||||
), [cutPlanReference, lotSelections, selectedMaterialRequirements]);
|
||||
|
||||
const previewRowsWithIssues = useMemo(() => (
|
||||
selectedPreviewRows.filter(row => row.issues.length).length
|
||||
), [selectedPreviewRows]);
|
||||
@@ -619,6 +665,7 @@ const Cutting = () => {
|
||||
setGenerationMessage('');
|
||||
setPreviewPage(1);
|
||||
setSelectedPreviewRowIds(rowsAvailableForOrder.map(row => row.id));
|
||||
setLotSelections({});
|
||||
setIsOrderPreviewOpen(true);
|
||||
};
|
||||
|
||||
@@ -657,7 +704,7 @@ const Cutting = () => {
|
||||
setGenerationMessage('');
|
||||
|
||||
const issueDate = formatDateKey(new Date());
|
||||
const orderReference = `Plano de corte ${formatDateKey(dateRange.start)} a ${formatDateKey(dateRange.end)} · ${targetCoverageDays} dias`;
|
||||
const orderReference = cutPlanReference;
|
||||
|
||||
try {
|
||||
const result = await createProductionOrders(selectedPreviewRows.map(row => ({
|
||||
@@ -669,6 +716,16 @@ const Cutting = () => {
|
||||
quantity: row.suggestedCutQuantity,
|
||||
unit: 'UN',
|
||||
integrationStatus: 'Local',
|
||||
components: (compositionsByProductId.get(normalizeMaterialKey(row.id))?.components || [])
|
||||
.filter(component => component.quantityPerUnit > 0)
|
||||
.map(component => ({
|
||||
componentTinyId: component.componentTinyId,
|
||||
componentSku: component.componentSku,
|
||||
componentName: component.componentName,
|
||||
quantityPerUnit: component.quantityPerUnit,
|
||||
totalQuantity: row.suggestedCutQuantity * component.quantityPerUnit,
|
||||
unit: component.unit
|
||||
})),
|
||||
markers: [
|
||||
{ label: row.family.materialLabel, color: '#38bdf8' },
|
||||
...(row.color ? [{ label: row.color, color: '#52DFA0' }] : []),
|
||||
@@ -689,7 +746,7 @@ const Cutting = () => {
|
||||
availableQuantity: row.availableQuantity,
|
||||
issues: row.issues
|
||||
}
|
||||
})));
|
||||
})), selectedLotReservations);
|
||||
|
||||
const refreshedOrders = await fetchProductionOrders(allProductionOrdersRange, undefined, { force: true });
|
||||
setProductionOrders(refreshedOrders.orders);
|
||||
@@ -898,6 +955,17 @@ const Cutting = () => {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<section className="border-t border-dark-border bg-dark-input/30 p-4">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-dark-text">Reservar lotes para este corte</h3>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">A reserva é opcional quando o saldo vem do Tiny. Ao selecionar um lote, ela bloqueia somente o saldo disponível para outros planos; a baixa física continua na OP.</p>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-brand-primary">{formatNumber(selectedLotReservations.length)} lote(s) reservado(s)</span>
|
||||
</div>
|
||||
{selectedMaterialRequirements.length ? <div className="mt-4 overflow-x-auto rounded-xl border border-dark-border bg-dark-card"><table className="w-full min-w-[760px] text-left text-xs"><thead className="bg-dark-header text-dark-muted"><tr><th className="px-3 py-2.5 font-bold">Material necessário</th><th className="px-3 py-2.5 text-right font-bold">Necessário</th><th className="px-3 py-2.5 font-bold">Lote reservado</th><th className="px-3 py-2.5 text-right font-bold">Quantidade reservada</th></tr></thead><tbody className="divide-y divide-dark-border">{selectedMaterialRequirements.map(requirement => { const selection = lotSelections[requirement.key] || { lotId: '', quantity: '' }; const lots = supplyLots.filter(lot => { const lotKey = normalizeMaterialKey(lot.product); const materialMatch = lotKey === requirement.key || lotKey === normalizeMaterialKey(requirement.label); return lot.availableQuantity > 0 && materialMatch && lot.unit.toLowerCase() === requirement.unit.toLowerCase(); }); return <tr key={requirement.key}><td className="px-3 py-3"><p className="font-bold text-dark-text">{requirement.label}</p><p className="mt-0.5 font-mono text-[10px] text-dark-muted">{requirement.key}</p></td><td className="px-3 py-3 text-right font-bold text-dark-text">{formatNumber(requirement.quantity, 4)} {requirement.unit}</td><td className="px-3 py-3"><select value={selection.lotId} onChange={(event) => setLotSelections(current => ({ ...current, [requirement.key]: { lotId: event.target.value, quantity: current[requirement.key]?.quantity || '' } }))} className="h-9 w-full min-w-[230px] rounded-lg border border-dark-border bg-dark-input px-2 text-xs font-semibold text-dark-text outline-none focus:border-brand-primary"><option value="">{lots.length ? 'Não reservar agora' : 'Sem lote compatível'}</option>{lots.map(lot => <option key={lot.id} value={lot.id}>#{lot.id} · {lot.product} · disp. {formatNumber(lot.availableQuantity, 4)} {lot.unit}</option>)}</select></td><td className="px-3 py-3"><input inputMode="decimal" value={selection.quantity} onChange={(event) => setLotSelections(current => ({ ...current, [requirement.key]: { lotId: current[requirement.key]?.lotId || '', quantity: event.target.value } }))} disabled={!selection.lotId} className="h-9 w-32 rounded-lg border border-dark-border bg-dark-input px-2 text-right text-xs font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50" placeholder={formatNumber(requirement.quantity, 4)} /></td></tr>; })}</tbody></table></div> : <p className="mt-3 rounded-xl border border-dark-border bg-dark-card px-3 py-3 text-xs font-semibold text-dark-muted">Os SKUs selecionados não possuem componentes de material sincronizados. Eles podem seguir para OP, mas não geram reserva.</p>}
|
||||
</section>
|
||||
|
||||
<PaginationControls
|
||||
totalItems={rowsAvailableForOrder.length}
|
||||
currentPage={safePreviewPage}
|
||||
|
||||
@@ -397,6 +397,11 @@ const ProductionOrders = () => {
|
||||
{order.components.length ? <div className="mt-3 overflow-hidden rounded-xl border border-dark-border"><table className="w-full text-xs"><thead className="bg-dark-header text-dark-muted"><tr><th className="px-3 py-2.5 text-left font-bold">Material</th><th className="px-3 py-2.5 text-right font-bold">Por unidade</th><th className="px-3 py-2.5 text-right font-bold">Total</th></tr></thead><tbody className="divide-y divide-dark-border">{order.components.map(component => <tr key={component.id}><td className="px-3 py-3"><p className="font-bold text-dark-text">{component.componentName}</p><p className="mt-0.5 font-mono text-[10px] font-semibold text-dark-muted">{component.componentSku || 'Sem SKU'}</p></td><td className="px-3 py-3 text-right font-semibold text-dark-muted">{formatQuantity(component.quantityPerUnit)} {component.unit}</td><td className="px-3 py-3 text-right font-bold text-dark-text">{formatQuantity(component.totalQuantity)} {component.unit}</td></tr>)}</tbody></table></div> : <p className="mt-3 rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-semibold text-dark-muted">Sem composição sincronizada.</p>}
|
||||
</section>
|
||||
|
||||
{order.lotReservations.length > 0 && <section>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Lotes reservados pelo plano</h3>
|
||||
<div className="mt-3 overflow-hidden rounded-xl border border-dark-border"><table className="w-full text-xs"><thead className="bg-dark-header text-dark-muted"><tr><th className="px-3 py-2.5 text-left font-bold">Lote</th><th className="px-3 py-2.5 text-left font-bold">Material</th><th className="px-3 py-2.5 text-right font-bold">Reservado</th><th className="px-3 py-2.5 text-right font-bold">Consumido</th></tr></thead><tbody className="divide-y divide-dark-border">{order.lotReservations.map(reservation => <tr key={reservation.id}><td className="px-3 py-3"><p className="font-bold text-dark-text">#{reservation.lotId}</p><p className="mt-0.5 text-[10px] font-semibold text-dark-muted">{reservation.lotSupplier || 'Sem fornecedor'}</p></td><td className="px-3 py-3"><p className="font-bold text-dark-text">{reservation.lotProduct}</p><p className="mt-0.5 font-mono text-[10px] font-semibold text-dark-muted">{reservation.materialKey}</p></td><td className="px-3 py-3 text-right font-bold text-brand-primary">{formatQuantity(reservation.quantity)} {reservation.lotUnit}</td><td className="px-3 py-3 text-right font-semibold text-dark-muted">{formatQuantity(reservation.consumedQuantity)} {reservation.lotUnit}</td></tr>)}</tbody></table></div>
|
||||
</section>}
|
||||
|
||||
<section>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Baixa de material</h3>
|
||||
{order.status === 'cutting' ? <form onSubmit={handleProductionExit} className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4"><p className="text-sm font-semibold text-dark-muted">Registre uma saída somente quando o material for realmente consumido pela OP.</p><div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2"><label className="text-xs font-bold text-dark-muted">Lote de material<select value={exitForm.lotId} onChange={(event) => setExitForm(current => ({ ...current, lotId: event.target.value }))} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary"><option value="">Selecione...</option>{supplyLots.map(lot => <option key={lot.id} value={lot.id}>#{lot.id} · {lot.product} · {formatQuantity(lot.quantity)} {lot.unit}</option>)}</select></label><label className="text-xs font-bold text-dark-muted">Quantidade<input inputMode="decimal" value={exitForm.quantity} onChange={(event) => setExitForm(current => ({ ...current, quantity: event.target.value }))} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="kg" /></label><label className="sm:col-span-2 text-xs font-bold text-dark-muted">Motivo (opcional)<input value={exitForm.reason} onChange={(event) => setExitForm(current => ({ ...current, reason: event.target.value }))} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: corte do pedido" /></label></div>{selectedLot && <p className="mt-3 text-xs font-bold text-dark-muted">Saldo do lote #{selectedLot.id}: {formatQuantity(selectedLot.quantity)} {selectedLot.unit}</p>}<button type="submit" disabled={isSupplyBusy || !supplyLots.length || !exitForm.lotId || !exitForm.quantity} className="mt-4 inline-flex h-11 items-center gap-2 rounded-lg border border-brand-primary/35 bg-brand-primary/10 px-4 text-sm font-bold text-brand-primary transition-colors hover:bg-brand-primary/20 disabled:cursor-not-allowed disabled:opacity-50"><PackageCheck className="h-4 w-4" />{isSupplyBusy ? 'Registrando...' : 'Registrar baixa'}</button>{supplyMessage && <p className="mt-3 rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">{supplyMessage}</p>}</form> : <div className="mt-3 rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-semibold text-dark-muted">Altere a OP para <span className="font-bold text-amber-200">Em corte</span> antes de registrar a baixa de materiais.</div>}
|
||||
|
||||
30
src/types.ts
30
src/types.ts
@@ -78,6 +78,18 @@ export interface ProductionOrderStep {
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface ProductionOrderLotReservation {
|
||||
id: number;
|
||||
lotId: number;
|
||||
materialKey: string;
|
||||
quantity: number;
|
||||
consumedQuantity: number;
|
||||
status: string;
|
||||
lotProduct: string;
|
||||
lotUnit: string;
|
||||
lotSupplier: string;
|
||||
}
|
||||
|
||||
export interface ProductionOrderItem {
|
||||
id: number;
|
||||
tinyId: string;
|
||||
@@ -104,6 +116,7 @@ export interface ProductionOrderItem {
|
||||
rejectedQuantity: number;
|
||||
finishedQuantity: number | null;
|
||||
qualityNotes: string;
|
||||
lotReservations: ProductionOrderLotReservation[];
|
||||
markers: ProductionOrderMarker[];
|
||||
components: ProductionOrderComponent[];
|
||||
steps: ProductionOrderStep[];
|
||||
@@ -143,10 +156,25 @@ export type ProductionOrderPayload = {
|
||||
quantity: number;
|
||||
unit?: string;
|
||||
integrationStatus?: string;
|
||||
components?: Array<{
|
||||
componentTinyId?: string;
|
||||
componentSku?: string;
|
||||
componentName: string;
|
||||
quantityPerUnit: number;
|
||||
totalQuantity?: number;
|
||||
unit?: string;
|
||||
}>;
|
||||
markers?: ProductionOrderMarkerPayload[];
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CutPlanLotReservationPayload = {
|
||||
lotId: number;
|
||||
materialKey: string;
|
||||
quantity: number;
|
||||
cutPlanReference?: string;
|
||||
};
|
||||
|
||||
export type ProductionOrderExecutionPayload = {
|
||||
actualCutQuantity?: number | null;
|
||||
rejectedQuantity?: number | null;
|
||||
@@ -395,6 +423,8 @@ export interface SupplyLot {
|
||||
supplier: string;
|
||||
invoice: string;
|
||||
status: string;
|
||||
reservedQuantity: number;
|
||||
availableQuantity: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user