Add local production order workflow
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m40s

This commit is contained in:
Cauê Faleiros
2026-07-20 12:57:28 -03:00
parent 9af5f296a7
commit 88089343a8
6 changed files with 384 additions and 13 deletions

View File

@@ -1,6 +1,6 @@
const express = require('express');
const { verifyToken } = require('../auth');
const { listProductionOrders } = require('../services/productionOrderService');
const { createProductionOrders, listProductionOrders, updateProductionOrderStatus } = require('../services/productionOrderService');
const router = express.Router();
@@ -13,4 +13,23 @@ 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));
} catch (error) {
console.error('Error creating production orders:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.patch('/production-orders/:id/status', verifyToken, async (req, res) => {
try {
res.json(await updateProductionOrderStatus(req.params.id, req.body?.status));
} catch (error) {
console.error('Error updating production order status:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
module.exports = router;

View File

@@ -31,6 +31,14 @@ const formatDate = (value) => {
return String(value).slice(0, 10);
};
const normalizeText = (value) => String(value || '').trim();
const normalizeQuantity = (value) => {
const quantity = Number(value);
if (!Number.isFinite(quantity) || quantity <= 0) return 0;
return quantity;
};
const mapProductionOrderRow = (row) => {
const status = normalizeStatus(row.status);
@@ -54,6 +62,42 @@ const mapProductionOrderRow = (row) => {
};
};
const getOrderById = async (id, client = pool) => {
const result = await client.query(`
SELECT
po.id,
po.tiny_id,
po.number,
po.status,
po.order_reference,
po.issue_date,
po.expected_date,
po.product_sku,
po.product_description,
po.quantity,
po.unit,
po.integration_status,
po.created_at,
po.updated_at,
COALESCE(
JSON_AGG(
JSON_BUILD_OBJECT(
'label', pom.label,
'color', pom.color
)
ORDER BY pom.label
) FILTER (WHERE pom.id IS NOT NULL),
'[]'::json
) as markers
FROM production_orders po
LEFT JOIN production_order_markers pom ON pom.production_order_id = po.id
WHERE po.id = $1
GROUP BY po.id;
`, [id]);
return result.rows[0] ? mapProductionOrderRow(result.rows[0]) : null;
};
const listProductionOrders = async (filters = {}) => {
const params = [];
const where = [];
@@ -133,7 +177,142 @@ const listProductionOrders = async (filters = {}) => {
return { orders, counts };
};
module.exports = {
listProductionOrders,
normalizeStatus
const createProductionOrders = async (orders = []) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const created = [];
const skipped = [];
for (const order of orders) {
const productDescription = normalizeText(order.productDescription);
const productSku = normalizeText(order.productSku);
const orderReference = normalizeText(order.orderReference);
const quantity = normalizeQuantity(order.quantity);
if (!productDescription || !quantity) {
skipped.push({ productSku, productDescription, reason: 'invalid_payload' });
continue;
}
const duplicateResult = await client.query(`
SELECT id
FROM production_orders
WHERE COALESCE(order_reference, '') = $1
AND COALESCE(product_sku, '') = $2
AND status IN ('open', 'in_progress')
LIMIT 1;
`, [orderReference, productSku]);
if (duplicateResult.rows.length) {
skipped.push({ productSku, productDescription, reason: 'duplicate_open_order' });
continue;
}
const insertResult = await client.query(`
INSERT INTO production_orders (
tiny_id,
number,
status,
order_reference,
issue_date,
expected_date,
product_sku,
product_description,
quantity,
unit,
integration_status,
tiny_payload
)
VALUES (
NULL,
NULL,
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9,
$10::jsonb
)
RETURNING id;
`, [
normalizeStatus(order.status || 'open'),
orderReference,
normalizeDateParam(order.issueDate) || formatDate(new Date()),
normalizeDateParam(order.expectedDate),
productSku,
productDescription,
quantity,
normalizeText(order.unit) || 'UN',
normalizeText(order.integrationStatus) || 'Local',
JSON.stringify(order.metadata || {})
]);
const orderId = insertResult.rows[0].id;
await client.query(`
UPDATE production_orders
SET number = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2;
`, [normalizeText(order.number) || `OP-${String(orderId).padStart(5, '0')}`, orderId]);
const markers = Array.isArray(order.markers) ? order.markers : [];
for (const marker of markers) {
const label = normalizeText(marker.label);
if (!label) continue;
await client.query(`
INSERT INTO production_order_markers (production_order_id, label, color)
VALUES ($1, $2, $3)
ON CONFLICT (production_order_id, label) DO UPDATE SET color = EXCLUDED.color;
`, [orderId, label, normalizeText(marker.color) || null]);
}
const createdOrder = await getOrderById(orderId, client);
if (createdOrder) created.push(createdOrder);
}
await client.query('COMMIT');
return { created, skipped };
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
const updateProductionOrderStatus = async (id, status) => {
const normalizedStatus = normalizeStatus(status);
if (!STATUS_LABELS[normalizedStatus]) {
const error = new Error('Invalid production order status');
error.statusCode = 400;
throw error;
}
const result = await pool.query(`
UPDATE production_orders
SET status = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2
RETURNING id;
`, [normalizedStatus, id]);
if (!result.rows.length) {
const error = new Error('Production order not found');
error.statusCode = 404;
throw error;
}
return getOrderById(id);
};
module.exports = {
createProductionOrders,
listProductionOrders,
normalizeStatus,
updateProductionOrderStatus
};

View File

@@ -1,4 +1,4 @@
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, ProductionOrderSummary, RfmAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, 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, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
import { formatDateParam } from './dateRanges';
const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -183,6 +183,30 @@ export const fetchProductionOrders = async (
}, options);
};
export const createProductionOrders = async (orders: ProductionOrderPayload[]): Promise<CreateProductionOrdersResult> => {
const response = await authFetch('/production-orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orders })
});
if (!response.ok) throw new Error('Não foi possível criar as ordens de produção.');
analyticsCache.clear();
return await response.json();
};
export const updateProductionOrderStatus = async (id: number, status: ProductionOrderStatus): Promise<ProductionOrderItem> => {
const response = await authFetch(`/production-orders/${id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
if (!response.ok) throw new Error('Não foi possível atualizar o status da ordem.');
analyticsCache.clear();
return await response.json();
};
export const fetchCuttingSettings = async (): Promise<CuttingSettings> => {
try {
const response = await authFetch('/cutting-settings');

View File

@@ -7,7 +7,7 @@ import ProductColorBadge from '../components/ProductColorBadge';
import RefreshStatus from '../components/RefreshStatus';
import { buildCuttingSkuConfigPath } from '../catalogLinks';
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
import { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService';
import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
type CutFilter = 'need' | 'all' | 'issues' | 'covered';
@@ -65,6 +65,13 @@ const formatNumber = (value: number, maximumFractionDigits = 0) => (
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
);
const formatDateKey = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const formatDays = (value: number | null) => {
if (value === null) return '-';
if (value > 999) return '999+ dias';
@@ -135,6 +142,8 @@ const Cutting = () => {
const [correctionPage, setCorrectionPage] = useState(1);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [isGeneratingOrders, setIsGeneratingOrders] = useState(false);
const [generationMessage, setGenerationMessage] = useState('');
const cutConfigSection = searchParams.get('config');
const targetCorrectionSku = (searchParams.get('sku') || '').trim();
@@ -241,6 +250,10 @@ const Cutting = () => {
});
}, [cutFilter, cutPlan.rows, familyFilter, searchTerm, sortBy]);
const rowsAvailableForOrder = useMemo(() => (
filteredRows.filter(row => row.suggestedCutQuantity > 0)
), [filteredRows]);
const totalPages = Math.ceil(filteredRows.length / itemsPerPage);
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
@@ -400,6 +413,60 @@ const Cutting = () => {
})), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`);
};
const generateProductionOrders = async () => {
if (!rowsAvailableForOrder.length) {
setGenerationMessage('Não há necessidade de corte no filtro atual.');
return;
}
setIsGeneratingOrders(true);
setGenerationMessage('');
const issueDate = formatDateKey(new Date());
const orderReference = `Plano de corte ${formatDateKey(dateRange.start)} a ${formatDateKey(dateRange.end)} · ${targetCoverageDays} dias`;
try {
const result = await createProductionOrders(rowsAvailableForOrder.map(row => ({
status: 'open',
orderReference,
issueDate,
productSku: row.id,
productDescription: row.name,
quantity: row.suggestedCutQuantity,
unit: 'UN',
integrationStatus: 'Local',
markers: [
{ label: row.family.materialLabel, color: '#38bdf8' },
...(row.color ? [{ label: row.color, color: '#52DFA0' }] : []),
{ label: 'Material pendente', color: '#facc15' },
...(row.issues.length ? [{ label: 'Dados pendentes', color: '#f59e0b' }] : [])
],
metadata: {
source: 'cut_plan',
familyKey: row.family.key,
familyLabel: row.family.label,
materialLabel: row.family.materialLabel,
color: row.color,
size: row.size,
targetCoverageDays,
projectedDemand: row.projectedDemand,
stock: row.stock,
openProductionQuantity: row.openProductionQuantity,
availableQuantity: row.availableQuantity,
issues: row.issues
}
})));
const refreshedOrders = await fetchProductionOrders(allProductionOrdersRange, undefined, { force: true });
setProductionOrders(refreshedOrders.orders);
setGenerationMessage(`${result.created.length} OPs criadas${result.skipped.length ? ` · ${result.skipped.length} já existiam abertas` : ''}.`);
} catch (error) {
setGenerationMessage(error instanceof Error ? error.message : 'Não foi possível gerar as ordens de produção.');
} finally {
setIsGeneratingOrders(false);
}
};
const renderIssueBadge = (row: CutPlanSkuRow) => {
if (!row.issues.length) {
return <span className="text-xs font-bold text-emerald-300">OK</span>;
@@ -444,6 +511,17 @@ const Cutting = () => {
}}
/>
<button
type="button"
onClick={() => void generateProductionOrders()}
disabled={isGeneratingOrders || !rowsAvailableForOrder.length}
className="flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-4 py-2.5 text-sm font-medium text-brand-primary shadow-sm transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
title="Gerar ordens locais para as necessidades do filtro atual"
>
<ClipboardList size={16} />
<span className="hidden sm:inline">{isGeneratingOrders ? 'Gerando' : 'Gerar OPs'}</span>
</button>
<button
type="button"
onClick={exportRows}
@@ -466,6 +544,12 @@ const Cutting = () => {
</div>
</div>
{generationMessage && (
<div className="rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-bold text-dark-muted">
{generationMessage}
</div>
)}
<RefreshStatus isRefreshing={isRefreshing} />
{isSettingsOpen && (

View File

@@ -4,7 +4,7 @@ import { ArrowLeft, CalendarDays, CheckCircle2, ClipboardList, Clock3, Download,
import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import RefreshStatus from '../components/RefreshStatus';
import { consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchSupplySummary } from '../dataService';
import { consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchSupplySummary, updateProductionOrderStatus } from '../dataService';
import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary, SupplyLot } from '../types';
type ProductionOrderStatusTab = 'all' | 'open' | 'in_progress' | 'finished' | 'canceled';
@@ -22,6 +22,10 @@ const statusTabs: Array<{ key: ProductionOrderStatusTab; label: string; dotClass
{ key: 'canceled', label: 'Cancelada', dotClass: 'bg-zinc-500' }
];
const editableStatusOptions: Array<{ value: ProductionOrderStatusTab; label: string }> = statusTabs
.filter(tab => tab.key !== 'all')
.map(tab => ({ value: tab.key, label: tab.label }));
const statusStyles: Record<string, { label: string; className: string; dotClass: string }> = {
open: {
label: 'Em aberto',
@@ -121,6 +125,8 @@ const ProductionOrders = () => {
const [isLoading, setIsLoading] = useState(true);
const [isSupplyBusy, setIsSupplyBusy] = useState(false);
const [supplyMessage, setSupplyMessage] = useState('');
const [busyStatusOrderId, setBusyStatusOrderId] = useState<number | null>(null);
const [statusMessage, setStatusMessage] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20);
const [exitForm, setExitForm] = useState({
@@ -190,6 +196,23 @@ const ProductionOrders = () => {
void loadProductionOrders({ force: true });
};
const handleStatusChange = async (order: ProductionOrderItem, status: ProductionOrderStatusTab) => {
if (status === 'all' || status === order.status) return;
setBusyStatusOrderId(order.id);
setStatusMessage('');
try {
await updateProductionOrderStatus(order.id, status);
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, { force: true });
setSummary(nextSummary);
setStatusMessage(`OP ${order.number || `#${order.id}`} atualizada para ${statusStyles[status]?.label || status}.`);
} catch (error) {
setStatusMessage(error instanceof Error ? error.message : 'Não foi possível atualizar o status da OP.');
} finally {
setBusyStatusOrderId(null);
}
};
const refreshSupplyLots = async () => {
const supplySummary = await fetchSupplySummary();
setSupplyLots(supplySummary.lots);
@@ -248,7 +271,7 @@ const ProductionOrders = () => {
Suprimentos
</Link>
<h1 className="text-2xl font-bold text-dark-text">Ordens de Produção</h1>
<p className="mt-2 text-dark-muted font-medium">Acompanhe as ordens de produção sincronizadas do Tiny.</p>
<p className="mt-2 text-dark-muted font-medium">Acompanhe as ordens locais geradas pelo plano de corte.</p>
</div>
<DateRangePicker
dateRange={dateRange}
@@ -399,6 +422,11 @@ const ProductionOrders = () => {
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="border-b border-dark-border p-5">
{statusMessage && (
<div className="mb-4 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">
{statusMessage}
</div>
)}
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="relative w-full xl:max-w-xl">
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-dark-muted" />
@@ -515,10 +543,18 @@ const ProductionOrders = () => {
</span>
</td>
<td className="px-6 py-3">
<span className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide ${statusStyle.className}`}>
<span className={`h-1.5 w-1.5 rounded-full ${statusStyle.dotClass}`} />
{statusStyle.label}
</span>
<label className="sr-only" htmlFor={`production-order-status-${order.id}`}>Status da OP</label>
<select
id={`production-order-status-${order.id}`}
value={order.status}
disabled={busyStatusOrderId === order.id}
onChange={(event) => void handleStatusChange(order, event.target.value as ProductionOrderStatusTab)}
className={`h-8 rounded-lg border bg-dark-input px-2 text-[10px] font-bold uppercase tracking-wide outline-none transition-colors focus:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 ${statusStyle.className}`}
>
{editableStatusOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</td>
</tr>
);
@@ -534,7 +570,7 @@ const ProductionOrders = () => {
</div>
<p className="mt-4 text-sm font-bold text-dark-text">Nenhuma ordem de produção encontrada.</p>
<p className="mt-1 max-w-md text-sm font-medium text-dark-muted">
Quando a sincronização com o Tiny estiver ativa, as ordens aparecerão aqui com status, produto, quantidade e marcadores.
Gere ordens pelo Plano de Corte para acompanhar status, produto, quantidade e baixa de material dentro do Graphs.
</p>
</div>
)}

View File

@@ -67,6 +67,35 @@ export interface ProductionOrderSummary {
counts: ProductionOrderCounts;
}
export type ProductionOrderMarkerPayload = {
label: string;
color?: string | null;
};
export type ProductionOrderPayload = {
number?: string;
status?: ProductionOrderStatus;
orderReference?: string;
issueDate?: string | null;
expectedDate?: string | null;
productSku?: string;
productDescription: string;
quantity: number;
unit?: string;
integrationStatus?: string;
markers?: ProductionOrderMarkerPayload[];
metadata?: Record<string, unknown>;
};
export interface CreateProductionOrdersResult {
created: ProductionOrderItem[];
skipped: Array<{
productSku?: string;
productDescription?: string;
reason: string;
}>;
}
export type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'OUTROS';
export interface CutProductOverride {