263 lines
13 KiB
JavaScript
263 lines
13 KiB
JavaScript
// 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,
|
||
};
|