Add persistent production plans
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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 || {}));
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user