Add persistent production plans

This commit is contained in:
Cauê Faleiros
2026-08-25 11:47:07 -03:00
parent 21d9960145
commit 85afba4e5b
10 changed files with 493 additions and 16 deletions

View File

@@ -170,6 +170,31 @@ const initDB = async () => {
);
`);
// A production plan is the PCP container above the individual OPs. The
// calculated cut screen may change tomorrow; a confirmed plan must not.
await pool.query(`
CREATE TABLE IF NOT EXISTS production_plans (
id SERIAL PRIMARY KEY,
number VARCHAR(40) UNIQUE,
name TEXT NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'open',
contractor TEXT,
planned_start_date DATE,
expected_date DATE,
notes TEXT,
source VARCHAR(40) NOT NULL DEFAULT 'cut_plan',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMPTZ
);
`);
await pool.query(`
ALTER TABLE production_orders
ADD COLUMN IF NOT EXISTS production_plan_id INTEGER REFERENCES production_plans(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS contractor TEXT;
`).catch(() => {});
await pool.query(`
CREATE TABLE IF NOT EXISTS production_order_markers (
id SERIAL PRIMARY KEY,
@@ -447,6 +472,7 @@ const initDB = async () => {
CREATE TABLE IF NOT EXISTS cut_plan_lot_reservations (
id SERIAL PRIMARY KEY,
cut_plan_reference TEXT NOT NULL,
production_plan_id INTEGER REFERENCES production_plans(id) ON DELETE SET 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),
@@ -460,6 +486,7 @@ const initDB = async () => {
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 INDEX IF NOT EXISTS idx_cut_plan_lot_reservations_plan ON cut_plan_lot_reservations (production_plan_id, status);`);
await pool.query(`
CREATE TABLE IF NOT EXISTS supply_movements (

View File

@@ -1,6 +1,6 @@
const express = require('express');
const { authenticateAPIKey, verifyToken } = require('../auth');
const { createProductionOrders, importTinyProductCompositionExport, listProductionOrders, updateProductionOrderExecution, updateProductionOrderStatus, upsertTinyProductionOrderDetail } = require('../services/productionOrderService');
const { createProductionOrders, importTinyProductCompositionExport, listProductionOrders, listProductionPlans, updateProductionOrderExecution, updateProductionOrderStatus, updateProductionPlan, updateProductionPlanStatus, upsertTinyProductionOrderDetail } = require('../services/productionOrderService');
const router = express.Router();
@@ -17,13 +17,40 @@ router.post('/production-orders', verifyToken, async (req, res) => {
try {
const orders = Array.isArray(req.body?.orders) ? req.body.orders : [];
const lotReservations = Array.isArray(req.body?.lotReservations) ? req.body.lotReservations : [];
res.status(201).json(await createProductionOrders(orders, lotReservations));
res.status(201).json(await createProductionOrders(orders, lotReservations, req.body?.plan || null));
} catch (error) {
console.error('Error creating production orders:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.get('/production-plans', verifyToken, async (req, res) => {
try {
res.json(await listProductionPlans());
} catch (error) {
console.error('Error fetching production plans:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.put('/production-plans/:id', verifyToken, async (req, res) => {
try {
res.json(await updateProductionPlan(req.params.id, req.body || {}));
} catch (error) {
console.error('Error updating production plan:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.patch('/production-plans/:id/status', verifyToken, async (req, res) => {
try {
res.json(await updateProductionPlanStatus(req.params.id, req.body?.status));
} catch (error) {
console.error('Error updating production plan status:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.post('/production-orders/tiny-sync', authenticateAPIKey, async (req, res) => {
try {
res.status(201).json(await upsertTinyProductionOrderDetail(req.body || {}));

View File

@@ -276,6 +276,11 @@ const mapProductionOrderRow = (row) => {
number: row.number || '',
status,
statusLabel: STATUS_LABELS[status] || row.status || 'Em aberto',
productionPlanId: row.production_plan_id === null || row.production_plan_id === undefined ? null : Number(row.production_plan_id),
productionPlanNumber: row.production_plan_number || '',
productionPlanName: row.production_plan_name || '',
productionPlanStatus: row.production_plan_status || '',
contractor: row.contractor || '',
orderReference: row.order_reference || '',
issueDate: formatDate(row.issue_date),
expectedDate: formatDate(row.expected_date),
@@ -305,6 +310,87 @@ const mapProductionOrderRow = (row) => {
};
};
const mapProductionPlanRow = (row) => ({
id: Number(row.id),
number: row.number || '',
name: row.name || '',
status: normalizeStatus(row.status),
statusLabel: STATUS_LABELS[normalizeStatus(row.status)] || row.status || 'Em aberto',
contractor: row.contractor || '',
plannedStartDate: formatDate(row.planned_start_date),
expectedDate: formatDate(row.expected_date),
completedAt: row.completed_at || null,
notes: row.notes || '',
source: row.source || 'cut_plan',
orderCount: Number(row.order_count || 0),
totalQuantity: Number(row.total_quantity || 0),
actualCutQuantity: Number(row.actual_cut_quantity || 0),
finishedQuantity: Number(row.finished_quantity || 0),
statusCounts: row.status_counts || {},
createdAt: row.created_at || null,
updatedAt: row.updated_at || null
});
const normalizePlanPayload = (payload = {}, fallback = {}) => ({
id: normalizeInteger(payload.id),
name: normalizeText(payload.name) || normalizeText(fallback.name),
contractor: normalizeText(payload.contractor),
plannedStartDate: normalizeDateParam(payload.plannedStartDate || fallback.plannedStartDate),
expectedDate: normalizeDateParam(payload.expectedDate || fallback.expectedDate),
notes: normalizeText(payload.notes),
source: normalizeText(payload.source) || 'cut_plan'
});
const getProductionPlanById = async (id, client = pool) => {
const result = await client.query(`
SELECT
plan.id, plan.number, plan.name, plan.status, plan.contractor,
plan.planned_start_date, plan.expected_date, plan.completed_at,
plan.notes, plan.source, plan.created_at, plan.updated_at,
COUNT(po.id)::int AS order_count,
COALESCE(SUM(po.quantity), 0) AS total_quantity,
COALESCE(SUM(po.actual_cut_quantity), 0) AS actual_cut_quantity,
COALESCE(SUM(po.finished_quantity), 0) AS finished_quantity,
COALESCE((
SELECT JSON_OBJECT_AGG(status, count)
FROM (
SELECT status, COUNT(*)::int AS count
FROM production_orders
WHERE production_plan_id = plan.id
GROUP BY status
) grouped_statuses
), '{}'::json) AS status_counts
FROM production_plans plan
LEFT JOIN production_orders po ON po.production_plan_id = plan.id
WHERE plan.id = $1
GROUP BY plan.id;
`, [id]);
return result.rows[0] ? mapProductionPlanRow(result.rows[0]) : null;
};
const createOrGetProductionPlan = async (client, payload, fallback) => {
const plan = normalizePlanPayload(payload, fallback);
if (plan.id) {
const existing = await getProductionPlanById(plan.id, client);
if (!existing) {
const error = new Error('Plano de produção não encontrado.');
error.statusCode = 404;
throw error;
}
return existing;
}
if (!plan.name) return null;
const inserted = await client.query(`
INSERT INTO production_plans (name, status, contractor, planned_start_date, expected_date, notes, source)
VALUES ($1, 'open', NULLIF($2, ''), $3, $4, NULLIF($5, ''), $6)
RETURNING id;
`, [plan.name, plan.contractor, plan.plannedStartDate, plan.expectedDate, plan.notes, plan.source]);
const id = Number(inserted.rows[0].id);
await client.query(`UPDATE production_plans SET number = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2;`, [`PP-${String(id).padStart(5, '0')}`, id]);
return getProductionPlanById(id, client);
};
const getOrderById = async (id, client = pool) => {
const result = await client.query(`
SELECT
@@ -312,6 +398,11 @@ const getOrderById = async (id, client = pool) => {
po.tiny_id,
po.number,
po.status,
po.production_plan_id,
plan.number AS production_plan_number,
plan.name AS production_plan_name,
plan.status AS production_plan_status,
po.contractor,
po.order_reference,
po.issue_date,
po.expected_date,
@@ -385,7 +476,10 @@ const getOrderById = async (id, client = pool) => {
)
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
WHERE (
reservation.production_plan_id = po.production_plan_id
OR (reservation.production_plan_id IS NULL AND reservation.cut_plan_reference = po.order_reference)
)
AND reservation.status IN ('active', 'consumed')
),
'[]'::json
@@ -410,6 +504,7 @@ const getOrderById = async (id, client = pool) => {
'[]'::json
) as steps
FROM production_orders po
LEFT JOIN production_plans plan ON plan.id = po.production_plan_id
WHERE po.id = $1;
`, [id]);
@@ -422,6 +517,11 @@ const baseProductionOrderSelect = `
po.tiny_id,
po.number,
po.status,
po.production_plan_id,
plan.number AS production_plan_number,
plan.name AS production_plan_name,
plan.status AS production_plan_status,
po.contractor,
po.order_reference,
po.issue_date,
po.expected_date,
@@ -495,7 +595,10 @@ const baseProductionOrderSelect = `
)
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
WHERE (
reservation.production_plan_id = po.production_plan_id
OR (reservation.production_plan_id IS NULL AND reservation.cut_plan_reference = po.order_reference)
)
AND reservation.status IN ('active', 'consumed')
),
'[]'::json
@@ -520,6 +623,7 @@ const baseProductionOrderSelect = `
'[]'::json
) as steps
FROM production_orders po
LEFT JOIN production_plans plan ON plan.id = po.production_plan_id
`;
const listProductionOrders = async (filters = {}) => {
@@ -576,13 +680,113 @@ const listProductionOrders = async (filters = {}) => {
return { orders, counts };
};
const createProductionOrders = async (orders = [], lotReservations = []) => {
const listProductionPlans = async () => {
const result = await pool.query(`
SELECT
plan.id, plan.number, plan.name, plan.status, plan.contractor,
plan.planned_start_date, plan.expected_date, plan.completed_at,
plan.notes, plan.source, plan.created_at, plan.updated_at,
COUNT(po.id)::int AS order_count,
COALESCE(SUM(po.quantity), 0) AS total_quantity,
COALESCE(SUM(po.actual_cut_quantity), 0) AS actual_cut_quantity,
COALESCE(SUM(po.finished_quantity), 0) AS finished_quantity,
COALESCE((
SELECT JSON_OBJECT_AGG(status, count)
FROM (
SELECT status, COUNT(*)::int AS count
FROM production_orders
WHERE production_plan_id = plan.id
GROUP BY status
) grouped_statuses
), '{}'::json) AS status_counts
FROM production_plans plan
LEFT JOIN production_orders po ON po.production_plan_id = plan.id
GROUP BY plan.id
ORDER BY COALESCE(plan.planned_start_date, plan.created_at::date) DESC, plan.id DESC;
`);
return result.rows.map(mapProductionPlanRow);
};
const updateProductionPlan = async (id, payload = {}) => {
const plan = normalizePlanPayload(payload);
if (!plan.name) {
const error = new Error('O nome do plano é obrigatório.');
error.statusCode = 400;
throw error;
}
const result = await pool.query(`
UPDATE production_plans
SET name = $1,
contractor = NULLIF($2, ''),
planned_start_date = $3,
expected_date = $4,
notes = NULLIF($5, ''),
updated_at = CURRENT_TIMESTAMP
WHERE id = $6
RETURNING id;
`, [plan.name, plan.contractor, plan.plannedStartDate, plan.expectedDate, plan.notes, id]);
if (!result.rowCount) {
const error = new Error('Plano de produção não encontrado.');
error.statusCode = 404;
throw error;
}
return getProductionPlanById(id);
};
const updateProductionPlanStatus = async (id, status) => {
const normalizedStatus = normalizeStatus(status);
if (!STATUS_LABELS[normalizedStatus]) {
const error = new Error('Invalid production plan status');
error.statusCode = 400;
throw error;
}
const client = await pool.connect();
try {
await client.query('BEGIN');
const planResult = await client.query(`
UPDATE production_plans
SET status = $1,
completed_at = CASE WHEN $1 = 'finished' THEN COALESCE(completed_at, CURRENT_TIMESTAMP) ELSE NULL END,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
RETURNING id;
`, [normalizedStatus, id]);
if (!planResult.rowCount) {
const error = new Error('Plano de produção não encontrado.');
error.statusCode = 404;
throw error;
}
await client.query(`
UPDATE production_orders
SET status = $1,
completed_date = CASE WHEN $1 = 'finished' THEN COALESCE(completed_date, CURRENT_DATE) ELSE completed_date END,
updated_at = CURRENT_TIMESTAMP
WHERE production_plan_id = $2
AND status NOT IN ('finished', 'canceled');
`, [normalizedStatus, id]);
await client.query('COMMIT');
return getProductionPlanById(id);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
const createProductionOrders = async (orders = [], lotReservations = [], planPayload = null) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const created = [];
const skipped = [];
const fallbackPlan = {
name: normalizeText(orders[0]?.orderReference),
plannedStartDate: orders[0]?.issueDate,
expectedDate: orders[0]?.expectedDate
};
const plan = planPayload ? await createOrGetProductionPlan(client, planPayload, fallbackPlan) : null;
for (const order of orders) {
const productDescription = normalizeText(order.productDescription);
@@ -615,6 +819,8 @@ const createProductionOrders = async (orders = [], lotReservations = []) => {
tiny_id,
number,
status,
production_plan_id,
contractor,
order_reference,
issue_date,
expected_date,
@@ -637,11 +843,15 @@ const createProductionOrders = async (orders = [], lotReservations = []) => {
$7,
$8,
$9,
$10::jsonb
$10,
$11,
$12::jsonb
)
RETURNING id;
`, [
normalizeStatus(order.status || 'open'),
plan?.id || null,
normalizeText(order.contractor) || plan?.contractor || null,
orderReference,
normalizeDateParam(order.issueDate) || formatDate(new Date()),
normalizeDateParam(order.expectedDate),
@@ -734,15 +944,15 @@ const createProductionOrders = async (orders = [], lotReservations = []) => {
}
await client.query(`
INSERT INTO cut_plan_lot_reservations (
cut_plan_reference, supply_lot_id, material_key, quantity, consumed_quantity, status, updated_at
cut_plan_reference, production_plan_id, supply_lot_id, material_key, quantity, consumed_quantity, status, updated_at
)
VALUES ($1, $2, $3, $4, 0, 'active', CURRENT_TIMESTAMP);
`, [cutPlanReference, lotId, materialKey, quantity]);
VALUES ($1, $2, $3, $4, $5, 0, 'active', CURRENT_TIMESTAMP);
`, [cutPlanReference, plan?.id || null, lotId, materialKey, quantity]);
}
}
await client.query('COMMIT');
return { created, skipped };
return { created, skipped, plan };
} catch (error) {
await client.query('ROLLBACK');
throw error;
@@ -1534,12 +1744,15 @@ module.exports = {
deleteManualProductComposition,
getProductComposition,
importTinyProductCompositionExport,
listProductionPlans,
listProductCompositions,
listProductionOrders,
normalizeStatus,
saveManualProductComposition,
upsertTinyProductComposition,
upsertTinyProductionOrderDetail,
updateProductionPlan,
updateProductionPlanStatus,
updateProductionOrderExecution,
updateProductionOrderStatus
};