Add production plan release checklist
This commit is contained in:
@@ -189,6 +189,21 @@ const initDB = async () => {
|
||||
);
|
||||
`);
|
||||
|
||||
// The release decision remains auditable when the plan changes status.
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS production_plan_reviews (
|
||||
id SERIAL PRIMARY KEY,
|
||||
production_plan_id INTEGER NOT NULL UNIQUE REFERENCES production_plans(id) ON DELETE CASCADE,
|
||||
decision VARCHAR(30) NOT NULL DEFAULT 'pending',
|
||||
checklist JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
notes TEXT,
|
||||
reviewed_by TEXT,
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE production_orders
|
||||
ADD COLUMN IF NOT EXISTS production_plan_id INTEGER REFERENCES production_plans(id) ON DELETE SET NULL,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const express = require('express');
|
||||
const { authenticateAPIKey, verifyToken } = require('../auth');
|
||||
const { addProductionOrderDefect, createProductionOrders, createProductionWorkstation, importTinyProductCompositionExport, listProductionOrders, listProductionPlans, listProductionWorkstations, updateProductionOrderExecution, updateProductionOrderStatus, updateProductionPlan, updateProductionPlanStatus, updateProductionWorkstation, upsertTinyProductionOrderDetail } = require('../services/productionOrderService');
|
||||
const { addProductionOrderDefect, createProductionOrders, createProductionWorkstation, importTinyProductCompositionExport, listProductionOrders, listProductionPlans, listProductionWorkstations, saveProductionPlanReview, updateProductionOrderExecution, updateProductionOrderStatus, updateProductionPlan, updateProductionPlanStatus, updateProductionWorkstation, upsertTinyProductionOrderDetail } = require('../services/productionOrderService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -75,6 +75,14 @@ router.patch('/production-plans/:id/status', verifyToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/production-plans/:id/review', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await saveProductionPlanReview(req.params.id, req.body || {}));
|
||||
} catch (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 || {}));
|
||||
|
||||
@@ -351,10 +351,57 @@ const mapProductionPlanRow = (row) => ({
|
||||
actualCutQuantity: Number(row.actual_cut_quantity || 0),
|
||||
finishedQuantity: Number(row.finished_quantity || 0),
|
||||
statusCounts: row.status_counts || {},
|
||||
review: row.review_id ? {
|
||||
id: Number(row.review_id),
|
||||
decision: row.review_decision || 'pending',
|
||||
checklist: row.review_checklist || {},
|
||||
notes: row.review_notes || '',
|
||||
reviewedBy: row.reviewed_by || '',
|
||||
reviewedAt: row.reviewed_at || null,
|
||||
updatedAt: row.review_updated_at || null
|
||||
} : null,
|
||||
createdAt: row.created_at || null,
|
||||
updatedAt: row.updated_at || null
|
||||
});
|
||||
|
||||
const REVIEW_DECISIONS = new Set(['pending', 'approved', 'adjustments_required']);
|
||||
const REVIEW_CHECKLIST_KEYS = new Set(['materialsReserved', 'cutSpecificationReady', 'sizeAndColorReviewed', 'responsibleAndDateDefined']);
|
||||
|
||||
const normalizeProductionPlanReview = (payload = {}) => {
|
||||
const decision = normalizeText(payload.decision).toLowerCase() || 'pending';
|
||||
if (!REVIEW_DECISIONS.has(decision)) {
|
||||
const error = new Error('Decisão de revisão inválida.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
const rawChecklist = payload.checklist && typeof payload.checklist === 'object' ? payload.checklist : {};
|
||||
const checklist = {};
|
||||
REVIEW_CHECKLIST_KEYS.forEach(key => { checklist[key] = Boolean(rawChecklist[key]); });
|
||||
return { decision, checklist, notes: normalizeText(payload.notes), reviewedBy: normalizeText(payload.reviewedBy) };
|
||||
};
|
||||
|
||||
const saveProductionPlanReview = async (id, payload = {}) => {
|
||||
const review = normalizeProductionPlanReview(payload);
|
||||
const planExists = await pool.query('SELECT id FROM production_plans WHERE id = $1', [id]);
|
||||
if (!planExists.rowCount) {
|
||||
const error = new Error('Plano de produção não encontrado.');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
await pool.query(`
|
||||
INSERT INTO production_plan_reviews (production_plan_id, decision, checklist, notes, reviewed_by, reviewed_at, updated_at)
|
||||
VALUES ($1, $2, $3::jsonb, NULLIF($4, ''), NULLIF($5, ''), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (production_plan_id) DO UPDATE
|
||||
SET decision = EXCLUDED.decision,
|
||||
checklist = EXCLUDED.checklist,
|
||||
notes = EXCLUDED.notes,
|
||||
reviewed_by = EXCLUDED.reviewed_by,
|
||||
reviewed_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, [id, review.decision, JSON.stringify(review.checklist), review.notes, review.reviewedBy]);
|
||||
return getProductionPlanById(id);
|
||||
};
|
||||
|
||||
const normalizePlanPayload = (payload = {}, fallback = {}) => ({
|
||||
id: normalizeInteger(payload.id),
|
||||
name: normalizeText(payload.name) || normalizeText(fallback.name),
|
||||
@@ -388,12 +435,16 @@ const getProductionPlanById = async (id, client = pool) => {
|
||||
WHERE production_plan_id = plan.id
|
||||
GROUP BY status
|
||||
) grouped_statuses
|
||||
), '{}'::json) AS status_counts
|
||||
), '{}'::json) AS status_counts,
|
||||
review.id AS review_id, review.decision AS review_decision,
|
||||
review.checklist AS review_checklist, review.notes AS review_notes,
|
||||
review.reviewed_by, review.reviewed_at, review.updated_at AS review_updated_at
|
||||
FROM production_plans plan
|
||||
LEFT JOIN production_orders po ON po.production_plan_id = plan.id
|
||||
LEFT JOIN app_users responsible ON responsible.id = plan.responsible_user_id
|
||||
LEFT JOIN production_plan_reviews review ON review.production_plan_id = plan.id
|
||||
WHERE plan.id = $1
|
||||
GROUP BY plan.id, responsible.id;
|
||||
GROUP BY plan.id, responsible.id, review.id;
|
||||
`, [id]);
|
||||
return result.rows[0] ? mapProductionPlanRow(result.rows[0]) : null;
|
||||
};
|
||||
@@ -789,11 +840,15 @@ const listProductionPlans = async () => {
|
||||
WHERE production_plan_id = plan.id
|
||||
GROUP BY status
|
||||
) grouped_statuses
|
||||
), '{}'::json) AS status_counts
|
||||
), '{}'::json) AS status_counts,
|
||||
review.id AS review_id, review.decision AS review_decision,
|
||||
review.checklist AS review_checklist, review.notes AS review_notes,
|
||||
review.reviewed_by, review.reviewed_at, review.updated_at AS review_updated_at
|
||||
FROM production_plans plan
|
||||
LEFT JOIN production_orders po ON po.production_plan_id = plan.id
|
||||
LEFT JOIN app_users responsible ON responsible.id = plan.responsible_user_id
|
||||
GROUP BY plan.id, responsible.id
|
||||
LEFT JOIN production_plan_reviews review ON review.production_plan_id = plan.id
|
||||
GROUP BY plan.id, responsible.id, review.id
|
||||
ORDER BY COALESCE(plan.planned_start_date, plan.created_at::date) DESC, plan.id DESC;
|
||||
`);
|
||||
return result.rows.map(mapProductionPlanRow);
|
||||
@@ -1942,6 +1997,7 @@ module.exports = {
|
||||
getProductComposition,
|
||||
importTinyProductCompositionExport,
|
||||
listProductionPlans,
|
||||
saveProductionPlanReview,
|
||||
listProductionWorkstations,
|
||||
listProductCompositions,
|
||||
listProductionOrders,
|
||||
|
||||
Reference in New Issue
Block a user