Files
sap-erp/services/productionPlanningStore.js
John 69b4e68baf
SAP-ERP Portal CI/CD / build (push) Failing after 5m20s
first commit
2026-09-23 17:31:02 +05:30

263 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// services/productionPlanningStore.js
// Daily Production Planning — one row per (Date, Product) plan: how many
// packages × quantity per package are planned, the per-cycle quantity and
// cycles/day that implies, and whether it's actually been done yet.
const sql = require('mssql');
const TABLE = `[dbo].[ZPRODUCTION_PLANNING]`;
let _conn = null;
async function getConn() {
if (_conn) return _conn;
_conn = await sql.connect({
server: process.env.APP_SQL_HOST,
port: parseInt(process.env.APP_SQL_PORT),
user: process.env.APP_SQL_USER,
password: process.env.APP_SQL_PASSWORD,
database: process.env.APP_SQL_DATABASE,
options: { encrypt: true, trustServerCertificate: true },
});
return _conn;
}
async function exec(sqlQuery, params = []) {
const conn = await getConn();
const request = conn.request();
params.forEach((param, index) => { request.input(`param${index}`, param); });
const replacedSql = sqlQuery.replace(/\?/g, (m, offset, string) => {
const i = (string.slice(0, offset).match(/\?/g) || []).length;
return `@param${i}`;
});
const result = await request.query(replacedSql);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('duplicate') || m.includes('existing object') || m.includes('there is already an object');
}
function nowTs() { return new Date().toISOString().replace('T', ' ').substring(0, 23); }
async function bootstrap() {
console.log('[PRODUCTION-PLANNING] Checking table', TABLE, '...');
await exec(`
CREATE TABLE ${TABLE} (
ID INT IDENTITY(1,1) PRIMARY KEY,
PLAN_DATE NVARCHAR(20) NOT NULL,
PRODUCT_NAME NVARCHAR(200) NOT NULL,
PRODUCT_TYPE NVARCHAR(50),
MARKET NVARCHAR(100),
NO_OF_PACKAGE FLOAT DEFAULT 0,
QUANTITY FLOAT DEFAULT 0,
PROD_AS_PER_SINGLE FLOAT DEFAULT 0,
PER_CYCLE_QTY FLOAT DEFAULT 0,
NO_OF_CYCLE_DAY FLOAT DEFAULT 0,
STATUS NVARCHAR(20) DEFAULT 'PLANNED',
BATCH_NO NVARCHAR(60),
COMPANY NVARCHAR(60),
CREATED_BY NVARCHAR(50),
CREATED_NAME NVARCHAR(100),
CREATED_AT DATETIME2,
UPDATED_BY NVARCHAR(50),
UPDATED_NAME NVARCHAR(100),
UPDATED_AT DATETIME2,
APPROVED_BY NVARCHAR(50),
APPROVED_NAME NVARCHAR(100),
APPROVED_AT DATETIME2,
REJECTED_BY NVARCHAR(50),
REJECTED_NAME NVARCHAR(100),
REJECTED_AT DATETIME2,
REJECT_REASON NVARCHAR(500),
IS_DELETED BIT DEFAULT 0
)
`).catch(e => { if (!isAlreadyExists(e)) throw e; });
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_PLANNING' AND COLUMN_NAME='PRODUCT_TYPE') ALTER TABLE ${TABLE} ADD [PRODUCT_TYPE] NVARCHAR(50)`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_PLANNING' AND COLUMN_NAME='MARKET') ALTER TABLE ${TABLE} ADD [MARKET] NVARCHAR(100)`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_PLANNING' AND COLUMN_NAME='APPROVED_BY') ALTER TABLE ${TABLE} ADD [APPROVED_BY] NVARCHAR(50)`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_PLANNING' AND COLUMN_NAME='APPROVED_NAME') ALTER TABLE ${TABLE} ADD [APPROVED_NAME] NVARCHAR(100)`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_PLANNING' AND COLUMN_NAME='APPROVED_AT') ALTER TABLE ${TABLE} ADD [APPROVED_AT] DATETIME2`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_PLANNING' AND COLUMN_NAME='REJECTED_BY') ALTER TABLE ${TABLE} ADD [REJECTED_BY] NVARCHAR(50)`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_PLANNING' AND COLUMN_NAME='REJECTED_NAME') ALTER TABLE ${TABLE} ADD [REJECTED_NAME] NVARCHAR(100)`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_PLANNING' AND COLUMN_NAME='REJECTED_AT') ALTER TABLE ${TABLE} ADD [REJECTED_AT] DATETIME2`).catch(() => {});
await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZPRODUCTION_PLANNING' AND COLUMN_NAME='REJECT_REASON') ALTER TABLE ${TABLE} ADD [REJECT_REASON] NVARCHAR(500)`).catch(() => {});
console.log('[PRODUCTION-PLANNING] ✅ Ready');
}
function fromRow(row) {
return {
id: row.ID,
planDate: row.PLAN_DATE || '',
productName: row.PRODUCT_NAME || '',
productType: row.PRODUCT_TYPE || '',
market: row.MARKET || '',
noOfPackage: row.NO_OF_PACKAGE || 0,
quantity: row.QUANTITY || 0,
prodAsPerSingle: row.PROD_AS_PER_SINGLE || 0,
perCycleQty: row.PER_CYCLE_QTY || 0,
noOfCycleDay: row.NO_OF_CYCLE_DAY || 0,
status: row.STATUS || 'PLANNED',
batchNo: row.BATCH_NO || '',
company: row.COMPANY || '',
createdBy: row.CREATED_BY || '',
createdByName: row.CREATED_NAME || '',
createdAt: row.CREATED_AT ? new Date(row.CREATED_AT).toISOString() : null,
updatedBy: row.UPDATED_BY || '',
updatedByName: row.UPDATED_NAME || '',
updatedAt: row.UPDATED_AT ? new Date(row.UPDATED_AT).toISOString() : null,
approvedBy: row.APPROVED_BY || '',
approvedByName: row.APPROVED_NAME || '',
approvedAt: row.APPROVED_AT ? new Date(row.APPROVED_AT).toISOString() : null,
rejectedBy: row.REJECTED_BY || '',
rejectedByName: row.REJECTED_NAME || '',
rejectedAt: row.REJECTED_AT ? new Date(row.REJECTED_AT).toISOString() : null,
rejectReason: row.REJECT_REASON || '',
isDeleted: !!row.IS_DELETED,
};
}
async function listPlans({ company, from, to, status, productType, market, q, includeDeleted } = {}) {
const rows = await exec(`SELECT * FROM ${TABLE} ORDER BY PLAN_DATE DESC, ID DESC`);
const qLower = q ? String(q).trim().toLowerCase() : '';
return rows.map(fromRow).filter(p => {
if (!includeDeleted && p.isDeleted) return false;
if (company && p.company && p.company !== company) return false;
if (status && status !== 'ALL' && p.status !== status.toUpperCase()) return false;
if (from && p.planDate && p.planDate < from) return false;
if (to && p.planDate && p.planDate > to) return false;
if (productType && productType !== 'ALL' && p.productType !== productType) return false;
if (market && p.market !== market) return false;
if (qLower && !(p.productName || '').toLowerCase().includes(qLower) && !(p.batchNo || '').toLowerCase().includes(qLower)) return false;
return true;
});
}
async function findById(id) {
const rows = await exec(`SELECT * FROM ${TABLE} WHERE ID=?`, [parseInt(id)]);
return rows.length ? fromRow(rows[0]) : null;
}
// "Prod. As per Single" is always No. of Package × Quantity — computed
// server-side so a client can never submit a mismatched figure.
function computeProdAsPerSingle(noOfPackage, quantity) {
return (parseFloat(noOfPackage) || 0) * (parseFloat(quantity) || 0);
}
// Quantity may never exceed the day's capacity (Per Cycle Qty × No. of Cycle/Day).
function validateQuantity(quantity, perCycleQty, noOfCycleDay) {
const totalPerDay = (parseFloat(perCycleQty) || 0) * (parseFloat(noOfCycleDay) || 0);
const qty = parseFloat(quantity) || 0;
if (totalPerDay > 0 && qty > totalPerDay) {
throw new Error(`Quantity (${qty}) cannot exceed Total Qty/Day (${totalPerDay})`);
}
}
async function createPlan(p) {
if (!p.planDate) throw new Error('Date is required');
if (!p.productName || !String(p.productName).trim()) throw new Error('Product Name is required');
validateQuantity(p.quantity, p.perCycleQty, p.noOfCycleDay);
const prodAsPerSingle = computeProdAsPerSingle(p.noOfPackage, p.quantity);
const now = nowTs();
const idRows = await exec(`
INSERT INTO ${TABLE} (
PLAN_DATE, PRODUCT_NAME, PRODUCT_TYPE, MARKET, NO_OF_PACKAGE, QUANTITY, PROD_AS_PER_SINGLE,
PER_CYCLE_QTY, NO_OF_CYCLE_DAY, STATUS, BATCH_NO, COMPANY,
CREATED_BY, CREATED_NAME, CREATED_AT
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);
SELECT SCOPE_IDENTITY() AS ID;
`, [
p.planDate, String(p.productName).trim(), p.productType || '', p.market || '', parseFloat(p.noOfPackage) || 0, parseFloat(p.quantity) || 0, prodAsPerSingle,
parseFloat(p.perCycleQty) || 0, parseFloat(p.noOfCycleDay) || 0, (p.status === 'DONE' ? 'DONE' : 'PLANNED'), p.batchNo || '', p.company || '',
p.createdBy || '', p.createdByName || p.createdBy || '', now,
]);
return findById(idRows[0].ID);
}
async function updatePlan(id, p, { by, byName } = {}) {
const existing = await findById(id);
if (!existing) throw new Error('Plan not found');
// An approved plan is locked — no silent re-editing that would leave an
// old sign-off attached to changed numbers (or quietly invalidate it
// without the approver noticing). It must be rejected first, which
// reopens it for editing; editing a REJECTED plan is exactly how that
// rejection gets resolved, clearing it back to "pending review" so the
// approver sees it again.
if (existing.approvedBy) throw new Error('This plan is approved and locked. It must be rejected before it can be edited again.');
const noOfPackage = p.noOfPackage !== undefined ? p.noOfPackage : existing.noOfPackage;
const quantity = p.quantity !== undefined ? p.quantity : existing.quantity;
const perCycleQty = p.perCycleQty !== undefined ? p.perCycleQty : existing.perCycleQty;
const noOfCycleDay = p.noOfCycleDay !== undefined ? p.noOfCycleDay : existing.noOfCycleDay;
validateQuantity(quantity, perCycleQty, noOfCycleDay);
const prodAsPerSingle = computeProdAsPerSingle(noOfPackage, quantity);
await exec(`
UPDATE ${TABLE} SET
PLAN_DATE=?, PRODUCT_NAME=?, PRODUCT_TYPE=?, MARKET=?, NO_OF_PACKAGE=?, QUANTITY=?, PROD_AS_PER_SINGLE=?,
PER_CYCLE_QTY=?, NO_OF_CYCLE_DAY=?, STATUS=?, BATCH_NO=?,
UPDATED_BY=?, UPDATED_NAME=?, UPDATED_AT=?,
REJECTED_BY=NULL, REJECTED_NAME=NULL, REJECTED_AT=NULL, REJECT_REASON=NULL
WHERE ID=?
`, [
p.planDate !== undefined ? p.planDate : existing.planDate,
p.productName !== undefined ? String(p.productName).trim() : existing.productName,
p.productType !== undefined ? p.productType : existing.productType,
p.market !== undefined ? p.market : existing.market,
parseFloat(noOfPackage) || 0, parseFloat(quantity) || 0, prodAsPerSingle,
p.perCycleQty !== undefined ? (parseFloat(p.perCycleQty) || 0) : existing.perCycleQty,
p.noOfCycleDay !== undefined ? (parseFloat(p.noOfCycleDay) || 0) : existing.noOfCycleDay,
p.status !== undefined ? (p.status === 'DONE' ? 'DONE' : 'PLANNED') : existing.status,
p.batchNo !== undefined ? p.batchNo : existing.batchNo,
by || '', byName || by || '', nowTs(),
parseInt(id),
]);
return findById(id);
}
async function approvePlan(id, { by, byName } = {}) {
const existing = await findById(id);
if (!existing) throw new Error('Plan not found');
await exec(`
UPDATE ${TABLE} SET APPROVED_BY=?, APPROVED_NAME=?, APPROVED_AT=?,
REJECTED_BY=NULL, REJECTED_NAME=NULL, REJECTED_AT=NULL, REJECT_REASON=NULL
WHERE ID=?
`, [
by || '', byName || by || '', nowTs(), parseInt(id),
]);
return findById(id);
}
async function rejectPlan(id, reason, { by, byName } = {}) {
const existing = await findById(id);
if (!existing) throw new Error('Plan not found');
if (!reason || !String(reason).trim()) throw new Error('A reason is required to reject a plan');
await exec(`
UPDATE ${TABLE} SET REJECTED_BY=?, REJECTED_NAME=?, REJECTED_AT=?, REJECT_REASON=?,
APPROVED_BY=NULL, APPROVED_NAME=NULL, APPROVED_AT=NULL
WHERE ID=?
`, [
by || '', byName || by || '', nowTs(), String(reason).trim(), parseInt(id),
]);
return findById(id);
}
// Marking a plan Planned/Done is an operational progress flag, not a
// content edit — it must stay changeable even on an approved (locked)
// plan, otherwise nobody could ever record that an approved plan actually
// ran. Deliberately does NOT touch APPROVED_BY/REJECTED_BY — the approval
// itself still stands.
async function updateStatus(id, status, { by, byName } = {}) {
const existing = await findById(id);
if (!existing) throw new Error('Plan not found');
const s = status === 'DONE' ? 'DONE' : 'PLANNED';
await exec(`UPDATE ${TABLE} SET STATUS=?, UPDATED_BY=?, UPDATED_NAME=?, UPDATED_AT=? WHERE ID=?`, [
s, by || '', byName || by || '', nowTs(), parseInt(id),
]);
return findById(id);
}
async function softDeletePlan(id) {
const existing = await findById(id);
if (!existing) throw new Error('Plan not found');
if (existing.approvedBy) throw new Error('This plan is approved and locked. It must be rejected before it can be deleted.');
await exec(`UPDATE ${TABLE} SET IS_DELETED=1 WHERE ID=?`, [parseInt(id)]);
}
module.exports = {
bootstrap, listPlans, findById, createPlan, updatePlan, approvePlan, rejectPlan, updateStatus, softDeletePlan,
};