// services/batchIntimationStore.js // Batch Issuance Intimation records in SQL: ZBATCH_INTIMATIONS // Doc No format: BII-{MM}-{YY}-{NNNN}. References a Requirement's Ref No. const sql = require('mssql'); const TABLE = `[dbo].[ZBATCH_INTIMATIONS]`; let _conn = null; async function getConn() { if (_conn) return _conn; const config = { 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 }, }; _conn = await sql.connect(config); 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, (match, offset, string) => { const paramIndex = (string.slice(0, offset).match(/\?/g) || []).length; return `@param${paramIndex}`; }); 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'); } async function bootstrap() { console.log('[BII-STORE] Checking table', TABLE, '...'); await exec(` CREATE TABLE ${TABLE} ( ID INT IDENTITY(1,1) PRIMARY KEY, DOC_NO NVARCHAR(30) NOT NULL, REF_NO NVARCHAR(30), REQUIREMENT_ID INT, DOC_DATE DATE, ISSUE_DATE DATE, PRODUCTS NVARCHAR(MAX), REMARKS NVARCHAR(MAX), STATUS NVARCHAR(30) DEFAULT 'SENT_TO_QA', CREATED_BY NVARCHAR(50), CREATED_NAME NVARCHAR(100), CREATED_AT DATETIME2, UPDATED_AT DATETIME2, IS_DELETED BIT DEFAULT 0, COMPANY NVARCHAR(60) ) `).catch(e => { if (isAlreadyExists(e)) { console.log('[BII-STORE] Table exists — OK'); } else throw e; }); console.log('[BII-STORE] ✅ Ready'); } function toTs(isoStr) { if (!isoStr) return null; return isoStr.replace('T', ' ').replace('Z', '').substring(0, 23); } function safeJson(val, fallback) { if (!val) return fallback; try { return JSON.parse(val); } catch { return fallback; } } function fromRow(row) { if (!row) return null; return { id: row.ID, docNo: row.DOC_NO || '', refNo: row.REF_NO || '', requirementId: row.REQUIREMENT_ID || null, date: row.DOC_DATE ? new Date(row.DOC_DATE).toISOString().slice(0, 10) : null, issueDate: row.ISSUE_DATE ? new Date(row.ISSUE_DATE).toISOString().slice(0, 10) : null, products: safeJson(row.PRODUCTS, []), remarks: row.REMARKS || '', status: row.STATUS || 'SENT_TO_QA', createdBy: row.CREATED_BY || '', createdByName: row.CREATED_NAME || '', createdAt: row.CREATED_AT ? new Date(row.CREATED_AT).toISOString() : null, updatedAt: row.UPDATED_AT ? new Date(row.UPDATED_AT).toISOString() : null, isDeleted: !!row.IS_DELETED, company: row.COMPANY || '', }; } async function generateDocNo(company) { const now = new Date(); const mm = String(now.getMonth() + 1).padStart(2, '0'); const yy = String(now.getFullYear()).slice(-2); const prefix = `BII-${mm}-${yy}-`; const rows = await exec( `SELECT DOC_NO FROM ${TABLE} WHERE DOC_NO LIKE ? ${company ? 'AND COMPANY = ?' : ''}`, company ? [prefix + '%', company] : [prefix + '%'] ); let max = 0; rows.forEach(r => { const n = parseInt((r.DOC_NO || '').slice(prefix.length), 10); if (!isNaN(n) && n > max) max = n; }); return prefix + String(max + 1).padStart(4, '0'); } async function insertIntimation(r) { const now = toTs(new Date().toISOString()); const docNo = await generateDocNo(r.company); // INSERT and SELECT SCOPE_IDENTITY() must be one batch — as two separate // exec() calls, a pooled connection can route the second one to a // DIFFERENT physical connection than the one that just inserted, where // SCOPE_IDENTITY() correctly returns NULL (see workOrderStore.js's // insertWorkOrder() for the full write-up of this bug class). const idRows = await exec(` INSERT INTO ${TABLE} ( DOC_NO, REF_NO, REQUIREMENT_ID, DOC_DATE, ISSUE_DATE, PRODUCTS, REMARKS, STATUS, CREATED_BY, CREATED_NAME, CREATED_AT, COMPANY ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?); SELECT SCOPE_IDENTITY() AS ID; `, [ docNo, r.refNo || '', r.requirementId ? parseInt(r.requirementId) : null, r.date || null, r.issueDate || null, JSON.stringify(r.products || []), r.remarks || '', 'SENT_TO_QA', r.createdBy, r.createdByName || r.createdBy, now, r.company || '', ]); return { id: idRows[0].ID, docNo, ...r, status: 'SENT_TO_QA', createdAt: new Date().toISOString() }; } async function listIntimations({ mine, company, status, refNo, includeDeleted } = {}) { const all = await exec(`SELECT * FROM ${TABLE} ORDER BY CREATED_AT DESC`); return all.map(fromRow).filter(r => { if (!includeDeleted && r.isDeleted) return false; if (status && status !== 'ALL' && r.status !== status.toUpperCase()) return false; if (refNo && r.refNo !== refNo) return false; if (mine && r.createdBy !== mine) return false; if (company && r.company && r.company !== company) 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; } async function updateIntimation(id, patch) { const now = toTs(new Date().toISOString()); await exec(` UPDATE ${TABLE} SET DOC_DATE = ?, ISSUE_DATE = ?, PRODUCTS = ?, REMARKS = ?, UPDATED_AT = ? WHERE ID = ? AND (IS_DELETED = 0 OR IS_DELETED IS NULL) `, [ patch.date || null, patch.issueDate || null, JSON.stringify(patch.products || []), patch.remarks || '', now, parseInt(id), ]); return findById(id); } async function softDelete(id) { await exec(`UPDATE ${TABLE} SET IS_DELETED = 1, UPDATED_AT = ? WHERE ID = ?`, [toTs(new Date().toISOString()), parseInt(id)]); } // Sum of intimated Batch Size per requirement, per item. // Returns { [requirementId||refNo]: { docs: , byItem: { itemCode: qty } } } // `excludeId` lets an edit exclude its own current record from the totals. async function intimatedByRequirement({ company, excludeId } = {}) { const list = await listIntimations({ company }); const map = {}; list.forEach(bi => { if (excludeId && String(bi.id) === String(excludeId)) return; const keys = []; if (bi.requirementId != null) keys.push('rid:' + bi.requirementId); if (bi.refNo) keys.push('ref:' + bi.refNo); keys.forEach(k => { if (!map[k]) map[k] = { docs: 0, byItem: {} }; map[k].docs += 1; (bi.products || []).forEach(p => { const qty = (p.batches || []).reduce((s, b) => s + (Number(b.batchSize) || 0), 0); map[k].byItem[p.itemCode] = (map[k].byItem[p.itemCode] || 0) + qty; }); }); }); return map; } // Does this batch number already appear in ANY other (non-deleted) saved // Intimation's products/batches? Used by the "Batch No. Required" uniqueness // check — `excludeId` lets an edit ignore its own current record. async function findBatchNoUsage(batchNo, excludeId) { const needle = (batchNo || '').trim().toUpperCase(); if (!needle) return null; const list = await listIntimations({}); for (const bi of list) { if (excludeId && String(bi.id) === String(excludeId)) continue; for (const p of (bi.products || [])) { for (const b of (p.batches || [])) { if ((b.batchNo || '').trim().toUpperCase() === needle) { return { docNo: bi.docNo, itemCode: p.itemCode }; } } } } return null; } module.exports = { bootstrap, generateDocNo, insertIntimation, listIntimations, findById, updateIntimation, softDelete, intimatedByRequirement, findBatchNoUsage, };