Files
John eead8f5ffd
SAP-ERP Portal CI/CD / build (push) Successful in 3m57s
sale order
2026-10-05 18:45:17 +05:30

290 lines
24 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.
#!/usr/bin/env node
// scripts/migrate-msale.js — one-time migration from the old msale portal
// (MySQL u709187725_msale_db) into the ERP's Sales Order module (app DB, ZSO_*).
//
// Scope (agreed): masters + OPEN orders/samples + customer balances.
// • product_type → ZSO_DIVISIONS (names/codes)
// • product + price_list → ZSO_PRODUCTS (catalog 'domestic'); product_export → catalog 'export'
// • dealer_master → ZSO_CUSTOMERS (portal logins; msale MD5 passwords kept as "md5:" —
// upgraded to bcrypt on first login). Addresses are NOT copied:
// the ERP reads them live from SAP CRD1.
// • employee_master → ZSO_USER_PROFILES for ERP users matched by email (user type,
// divisions, emp code); --apply-templates also writes the user
// type's default Sales steps/pages onto the ERP user.
// • sales_person_master + dealer_sales_person_mapping → ZSO_SALES_PERSONS / ZSO_SP_CUSTOMER_MAP
// • sales_order status 1–8 → ZSO_ORDERS (same IDs, so SAP ORDR.U_WEB_SO_NO keeps matching)
// + items, consignees, documents (files copied), pending payments
// • sample_request (not rejected/completed) and sample_request_export (not dispatched) → ZSO_SAMPLES / ZSO_EXPORT_SAMPLES
// • customer_wallet.balance → one 'opening' wallet transaction per customer
//
// Usage (run against a FRESH COPY of the LIVE database at cutover, with msale stopped):
// node scripts/migrate-msale.js # dry run — reads & reports only
// node scripts/migrate-msale.js --verify-sql # also execute the order/sample INSERTs, then roll back
// node scripts/migrate-msale.js --commit # write
// options: --apply-templates apply user-type permission templates to matched ERP users
// --create-users create an ERP login (role "user") for every active msale employee
// with no ERP account — username = email name part; they log in
// with their CURRENT msale password (re-hashed at first login), and
// get their user type's Sales permissions/pages automatically
// --docs-dir=<path> msale "documents" folder to copy order attachments from
// (default C:\xampp80\htdocs\msale\documents)
// MySQL connection: .env MSALE_MYSQL_HOST / _PORT / _USER / _PASSWORD / _DATABASE
'use strict';
process.chdir(require('path').join(__dirname, '..'));
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const mysql = require('mysql2/promise');
const ARGS = process.argv.slice(2);
const COMMIT = ARGS.includes('--commit');
const APPLY = ARGS.includes('--apply-templates');
const CREATE_USERS = ARGS.includes('--create-users');
// --verify-sql: run every order/sample INSERT for real inside a transaction, then ROLL BACK (proves the SQL against the live schema without writing).
const VERIFY = ARGS.includes('--verify-sql') && !COMMIT;
const ROLLBACK = new Error('verify-sql rollback');
const txv = async fn => { try { await tx(async r => { await fn(r); if (!COMMIT) throw ROLLBACK; }); } catch (e) { if (e !== ROLLBACK) throw e; bump('verify_sql_rolled_back'); } };
const DOCS_DIR = (ARGS.find(a => a.startsWith('--docs-dir=')) || '').slice(11) || 'C:\\xampp80\\htdocs\\msale\\documents';
const { query, one, tx } = require('../services/sales/db');
const schema = require('../services/sales/schema');
const masters = require('../services/sales/masters');
const { refNo } = require('../services/sales/orders');
const DOC_DIR = path.join(__dirname, '..', 'storage', 'sales');
const report = { warnings: [] };
const bump = (k, n = 1) => { report[k] = (report[k] || 0) + n; };
const warn = m => { report.warnings.push(m); };
const s = v => (v == null ? null : String(v));
const d = v => (v && !String(v).startsWith('0000') ? v : null);
const n = v => (v == null || v === '' ? 0 : Number(v));
async function main() {
console.log(`\n=== msale → ERP Sales migration (${COMMIT ? 'COMMIT' : 'DRY RUN — nothing will be written'}) ===\n`);
const my = await mysql.createConnection({
host: process.env.MSALE_MYSQL_HOST || 'localhost', port: parseInt(process.env.MSALE_MYSQL_PORT) || 3306,
user: process.env.MSALE_MYSQL_USER || 'root', password: process.env.MSALE_MYSQL_PASSWORD || '',
database: process.env.MSALE_MYSQL_DATABASE || 'u709187725_msale_db', dateStrings: true,
});
const M = async (sql, p = []) => (await my.query(sql, p))[0];
await require('../services/approvalStepsStore').bootstrap();
await schema.bootstrap();
const W = async (sql, p = [], r) => (COMMIT ? query(sql, p, r) : []);
// ── Divisions ──
for (const t of await M(`SELECT * FROM product_type`)) {
await W(`MERGE dbo.ZSO_DIVISIONS AS t USING (SELECT ? AS ID) x ON t.ID=x.ID WHEN MATCHED THEN UPDATE SET NAME=?, CODE=?, ACTIVE=?
WHEN NOT MATCHED THEN INSERT (ID,NAME,CODE,ITEM_GROUPS,WAREHOUSE,SORT,ACTIVE) VALUES (?,?,?,'','01',99,?);`,
[t.id, t.product_type_name, t.product_type_code, !!t.status, t.id, t.product_type_name, t.product_type_code, !!t.status]);
bump('divisions');
}
// ── Products & prices ──
const prices = {};
for (const p of await M(`SELECT item_id, unit_price FROM price_list WHERE status=1 ORDER BY id`)) prices[p.item_id] = Number(p.unit_price);
const prodMap = {}; // legacy product id → ERP product id (domestic)
for (const [table, catalog] of [['product', 'domestic'], ['product_export', 'export']]) {
for (const p of await M(`SELECT * FROM ${table}`)) {
const ex = await one(`SELECT ID FROM dbo.ZSO_PRODUCTS WHERE CATALOG=? AND LEGACY_ID=?`, [catalog, p.id]);
const vals = [catalog, p.so_type_id, p.item_code_old || p.item_code, p.item_code, p.item_description, p.short_item_description || '',
parseInt(p.no_of_packages) || 1, p.hsn_code || '', catalog === 'domestic' ? (prices[p.id] || 0) : 0, !!p.is_instrument, !!p.status, p.id];
if (catalog === 'domestic' && !prices[p.id] && p.status) warn(`Product ${p.item_code} has no active price`);
if (ex) { await W(`UPDATE dbo.ZSO_PRODUCTS SET CATALOG=?, DIVISION_ID=?, ITEM_CODE=?, DISPLAY_CODE=?, DESCRIPTION=?, SHORT_DESC=?, PACK_SIZE=?, HSN=?, UNIT_PRICE=?, IS_INSTRUMENT=?, ACTIVE=?, LEGACY_ID=?, UPDATED_AT=SYSDATETIME(), UPDATED_BY='msale migration' WHERE ID=?`, [...vals, ex.ID]); if (catalog === 'domestic') prodMap[p.id] = ex.ID; }
else if (COMMIT) { const r = await one(`INSERT INTO dbo.ZSO_PRODUCTS (CATALOG,DIVISION_ID,ITEM_CODE,DISPLAY_CODE,DESCRIPTION,SHORT_DESC,PACK_SIZE,HSN,UNIT_PRICE,IS_INSTRUMENT,ACTIVE,LEGACY_ID,UPDATED_BY) OUTPUT INSERTED.ID VALUES (?,?,?,?,?,?,?,?,?,?,?,?,'msale migration')`, vals); if (catalog === 'domestic') prodMap[p.id] = r.ID; }
bump(`products_${catalog}`);
}
}
// ── Customers (portal logins) ──
for (const c of await M(`SELECT * FROM dealer_master`)) {
const canLogin = c.status == 1 && c.is_login == 1 && c.territory && c.territory !== '0';
const ex = await one(`SELECT PASSWORD_HASH FROM dbo.ZSO_CUSTOMERS WHERE CARD_CODE=?`, [c.cardcode]);
if (ex && ex.PASSWORD_HASH && !ex.PASSWORD_HASH.startsWith('md5:')) { bump('customers_skipped_already_active_in_erp'); continue; }
const vals = [c.cardname, c.e_mail || '', c.password ? `md5:${String(c.password).toLowerCase()}` : null, !!canLogin, !!c.is_lock, n(c.login_attempt), d(c.last_login), n(c.credit_limit), c.id];
if (ex) await W(`UPDATE dbo.ZSO_CUSTOMERS SET CARD_NAME=?, EMAIL=?, PASSWORD_HASH=?, ACTIVE=?, LOCKED=?, LOGIN_ATTEMPTS=?, LAST_LOGIN=?, CREDIT_LIMIT=?, LEGACY_ID=?, UPDATED_AT=SYSDATETIME(), UPDATED_BY='msale migration' WHERE CARD_CODE=?`, [...vals, c.cardcode]);
else await W(`INSERT INTO dbo.ZSO_CUSTOMERS (CARD_NAME,EMAIL,PASSWORD_HASH,ACTIVE,LOCKED,LOGIN_ATTEMPTS,LAST_LOGIN,CREDIT_LIMIT,LEGACY_ID,CARD_CODE,UPDATED_BY) VALUES (?,?,?,?,?,?,?,?,?,?,'msale migration')`, [...vals, c.cardcode]);
bump(canLogin ? 'customers_login_enabled' : 'customers_login_disabled');
}
// ── User types (names only — permission templates stay as seeded/edited) ──
for (const t of await M(`SELECT * FROM user_type`)) {
await W(`UPDATE dbo.ZSO_USER_TYPES SET NAME=? WHERE ID=?`, [t.user_type_name, t.id]);
}
// ── Employees → ERP users (matched by email) ──
const erpUsers = await query(`SELECT ID, USERNAME, EMAIL FROM dbo.ZCUST_USERS`);
const byEmail = new Map(erpUsers.filter(u => u.EMAIL).map(u => [String(u.EMAIL).trim().toLowerCase(), u]));
const unmatched = [], created = [], newEmails = new Set();
const divisionIds = (await query(`SELECT ID FROM dbo.ZSO_DIVISIONS`)).map(r => r.ID);
const usernames = new Set(erpUsers.map(u => String(u.USERNAME).toLowerCase()));
for (const e of await M(`SELECT * FROM employee_master WHERE user_status=1`)) {
const email = String(e.email_id || '').trim().toLowerCase();
let u = byEmail.get(email);
if (!u && CREATE_USERS && email.includes('@')) {
// New ERP login for this employee, keeping their msale password
// (stored "md5:" — re-hashed to bcrypt at their first ERP login).
let un = email.split('@')[0].replace(/[^a-z0-9._-]/g, '') || `emp${e.id}`; const baseUn = un; let k = 1;
while (usernames.has(un)) un = `${baseUn}${++k}`;
usernames.add(un);
if (COMMIT) {
const hu = require('../services/hanaUsers');
const id = await hu.createUser({ username: un, password: require('crypto').randomBytes(18).toString('hex'), fullName: e.name, email: e.email_id.trim(), role: 'user', modules: [], approvalSteps: [] });
if (e.password) await query(`UPDATE dbo.ZCUST_USERS SET PASSWORD=? WHERE ID=?`, [`md5:${String(e.password).toLowerCase()}`, id]);
u = { ID: id, USERNAME: un, EMAIL: e.email_id };
byEmail.set(email, u);
} else u = { ID: 0, USERNAME: un };
created.push(`${e.name} → username "${un}"`);
newEmails.add(email);
}
if (!u) { unmatched.push(`${e.name} <${e.email_id}> (type ${e.user_type})`); continue; }
const isNewUser = newEmails.has(email);
const divs = String(e.permission || '').split(',').map(Number).filter(x => divisionIds.includes(x));
if (COMMIT) await masters.saveProfile({ userId: u.ID, userTypeId: parseInt(e.user_type), divisions: divs, empCode: e.emp_code || '', territory: e.territory || '', ccEmails: e.cc_mark_email_id || '', applyTemplate: APPLY || isNewUser }, 'msale migration');
if (COMMIT) await query(`UPDATE dbo.ZSO_USER_PROFILES SET LEGACY_EMP_ID=? WHERE USER_ID=?`, [e.id, u.ID]);
bump('employees_matched');
}
report.employees_unmatched = unmatched;
report.erp_users_created = created;
// ── Sales persons & mapping ──
const spMap = {};
for (const p of await M(`SELECT * FROM sales_person_master`)) {
const u = byEmail.get(String(p.email_id || '').trim().toLowerCase());
const ex = await one(`SELECT ID FROM dbo.ZSO_SALES_PERSONS WHERE LEGACY_ID=?`, [p.id]);
if (ex) { spMap[p.id] = ex.ID; await W(`UPDATE dbo.ZSO_SALES_PERSONS SET NAME=?, EMAIL=?, DESIGNATION=?, DIVISIONS=?, USER_ID=?, ACTIVE=? WHERE ID=?`, [p.name, p.email_id, p.designation, p.devision, u ? u.ID : null, !!p.status, ex.ID]); }
else if (COMMIT) { const r = await one(`INSERT INTO dbo.ZSO_SALES_PERSONS (NAME,EMAIL,DESIGNATION,DIVISIONS,USER_ID,ACTIVE,LEGACY_ID) OUTPUT INSERTED.ID VALUES (?,?,?,?,?,?,?)`, [p.name, p.email_id, p.designation, p.devision, u ? u.ID : null, !!p.status, p.id]); spMap[p.id] = r.ID; }
bump('sales_persons');
}
for (const m of await M(`SELECT * FROM dealer_sales_person_mapping WHERE status=1`)) {
if (COMMIT && spMap[m.sales_person_id]) await masters.mapCustomers(spMap[m.sales_person_id], [m.card_code], 'msale migration');
bump('customer_mappings');
}
// ── Open orders (same IDs) ──
const states = {};
for (const a of await M(`SELECT id, state FROM dealer_address`)) states[a.id] = a.state;
for (const odd of await M(`SELECT id, order_ref_no, status FROM sales_order WHERE status NOT BETWEEN 1 AND 11`)) warn(`Order ${odd.id} (${odd.order_ref_no}) has invalid msale status ${odd.status} — not migrated, check it manually`);
const openOrders = await M(`SELECT * FROM sales_order WHERE status BETWEEN 1 AND 8 ORDER BY id`);
for (const o of openOrders) {
if (o.id >= 50001) { warn(`Order ${o.id} id ≥ 50001 would collide with ERP numbering — skipped`); continue; }
if (await one(`SELECT ID FROM dbo.ZSO_ORDERS WHERE ID=?`, [o.id])) { bump('orders_already_migrated'); continue; }
const items = await M(`SELECT * FROM sales_order_items WHERE sales_order_id=? AND status=1 ORDER BY id`, [o.id]);
const cons = o.is_multi_consignee ? await M(`SELECT * FROM sales_order_consignee WHERE so_id=? AND status=1`, [o.id]) : [];
const docs = await M(`SELECT * FROM sales_order_documents WHERE so_id=? AND status=1`, [o.id]);
const pend = await M(`SELECT * FROM wallet_transactions WHERE ref_id=? AND status='pending' AND is_active=1`, [o.id]).catch(() => []);
bump(`orders_status_${o.status}`);
if (!COMMIT && !VERIFY) continue;
await txv(async (r) => {
await query(`SET IDENTITY_INSERT dbo.ZSO_ORDERS ON;
INSERT INTO dbo.ZSO_ORDERS (ID,REF_NO,COMPANY,SO_TYPE,ORDER_TYPE,CARD_CODE,CARD_NAME,DIVISION_ID,CUST_ORDER_NO,ORDER_DATE,DELIVERY_DATE,CONTACT_PERSON,SALES_EMPLOYEE,
SHIP_TO_CODE,SHIP_TO_TEXT,SHIP_STATE,BILL_TO_CODE,BILL_TO_TEXT,TAX_CODE,IGST_RATE,CGST_RATE,SGST_RATE,TCS_RATE,IGST_VAL,CGST_VAL,SGST_VAL,TCS_VAL,SUB_TOTAL,GRAND_TOTAL,
CREDIT_DAYS,PAYMENT_MODE,PAYMENT_REF,PAYMENT_DATE,PAYMENT_DETAIL,PAID_AMOUNT,TDS_VAL,REBATE,PAID_STATUS,CUSTOMER_REMARKS,EMPLOYEE_REMARKS,INTERNAL_REMARKS,STATUS,
IS_MULTI_CONSIGNEE,SAP_DOC_NUM,SAP_ORDER_DATE,INVOICE_JSON,CREATED_BY_TYPE,CREATED_BY,CREATED_BY_NAME,CREATED_AT,UPDATED_AT,LEGACY)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1);
SET IDENTITY_INSERT dbo.ZSO_ORDERS OFF;`,
[o.id, o.order_ref_no || refNo(o.id, o.created_date), (await masters.getSettings()).company, o.sales_order_type_id || 1, o.order_type || null, o.cust_code, o.cust_name,
o.product_type_id, o.cust_order_no_old || o.cust_order_no || '', d(o.order_int_date), d(o.delivery_date), o.contact_person, o.sales_employee,
o.shiptocode, o.ship_to, states[o.ship_to_id] || null, o.paytocode, o.bill_to, o.tax_code, n(o.igst_rate), n(o.cgst_rate), n(o.sgst_rate), n(o.tcs_rate),
n(o.igst_value), n(o.cgst_value), n(o.sgst_value), n(o.tcs_value), n(o.sub_total), n(o.grand_total), o.credit_days || null, o.payment_mode, o.payment_ref_no,
d(o.payment_date), o.payment_detail, o.paid_amount == null ? null : n(o.paid_amount), n(o.tds_val), o.general_rebate == null ? null : Math.abs(n(o.general_rebate)),
o.paid_status, o.customer_remarks, o.employee_remarks, o.employee_remarks_internal, o.status, !!o.is_multi_consignee, o.sap_order_no ? parseInt(o.sap_order_no) || null : null,
d(o.sap_order_date), o.invoice_details, o.order_type === 'DIRECT' ? 'user' : 'customer', String(o.created_by || ''), o.order_type === 'DIRECT' ? 'msale employee' : o.cust_name,
o.created_date || new Date(), o.updated_date], r);
for (const [i, it] of items.entries()) {
await query(`INSERT INTO dbo.ZSO_ORDER_ITEMS (ORDER_ID,LINE_NO,PRODUCT_ID,ITEM_CODE,DISPLAY_CODE,DESCRIPTION,QTY,NOP,PRICE,SPECIAL_PRICE,COMMISSION,HSN,LINE_TOTAL) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[o.id, i + 1, prodMap[it.item_id] || null, it.item_name_old || it.item_name, it.item_name, it.item_desc, n(it.item_qty), it.item_nop, n(it.item_price), n(it.item_s_price), n(it.commission), it.item_hsn, n(it.sub_total)], r);
}
const sapCode = Object.fromEntries(items.map(it => [it.item_name, it.item_name_old || it.item_name]));
for (const c of cons) {
let list = []; try { list = JSON.parse(c.item_list || '[]'); } catch (_e) {}
await query(`INSERT INTO dbo.ZSO_ORDER_CONSIGNEES (ORDER_ID,SHIP_TO_CODE,SHIP_TO_TEXT,SE_NAME,SE_EMAIL,ITEMS_JSON) VALUES (?,?,?,?,?,?)`,
[o.id, c.st_name, c.st_add, c.se_name, c.se_email, JSON.stringify(list.map(x => ({ itemCode: sapCode[x.item_code] || x.item_code, qty: n(x.item_qty) })))], r);
}
for (const dc of docs) {
const src = path.join(DOCS_DIR, dc.document_name);
if (!fs.existsSync(src)) { warn(`Order ${o.id}: document file missing — ${dc.document_name}`); continue; }
const dest = `msale_${dc.document_name}`;
if (COMMIT) fs.copyFileSync(src, path.join(DOC_DIR, dest));
await query(`INSERT INTO dbo.ZSO_DOCS (ENTITY,ENTITY_ID,DOC_TYPE,TITLE,FILE_NAME,ORIG_NAME,CREATED_AT,CREATED_BY_NAME) VALUES ('order',?,?,?,?,?,?,'msale migration')`,
[o.id, dc.document_type == 1 ? 'payment' : 'po_copy', dc.document_title, dest, dc.document_name, dc.created_date || new Date()], r);
bump('documents_copied');
}
// Payment awaiting Accounts (status 6): carry the pending money over so it can be verified in the ERP.
if (o.status == 6) {
if (pend.length) {
for (const t of pend) await query(`INSERT INTO dbo.ZSO_WALLET_TXN (CARD_CODE,TXN_TYPE,AMOUNT,TDS,STATUS,REF_TYPE,ORDER_ID,PAYMENT_MODE,PAYMENT_REF,PAYMENT_DATE,PAYMENT_DETAIL,REMARKS,CREATED_AT,CREATED_BY_NAME)
VALUES (?,?,?,?,'pending',?,?,?,?,?,?,?,?,'msale migration')`, [t.cust_code, t.txn_type, t.txn_type === 'debit' ? n(t.amount) - n(t.tds_val) : n(t.amount), n(t.tds_val),
t.ref_type === 'order' ? 'order' : 'topup', o.id, t.payment_mode, t.payment_ref_no, d(t.payment_date), t.payment_detail, t.remarks, t.created_date || new Date()], r);
} else {
if (n(o.paid_amount) > 0) await query(`INSERT INTO dbo.ZSO_WALLET_TXN (CARD_CODE,TXN_TYPE,AMOUNT,STATUS,REF_TYPE,ORDER_ID,PAYMENT_MODE,PAYMENT_REF,PAYMENT_DATE,PAYMENT_DETAIL,CREATED_BY_NAME)
VALUES (?,'credit',?,'pending','topup',?,?,?,?,?,'msale migration')`, [o.cust_code, n(o.paid_amount), o.id, o.payment_mode, o.payment_ref_no, d(o.payment_date), o.payment_detail], r);
await query(`INSERT INTO dbo.ZSO_WALLET_TXN (CARD_CODE,TXN_TYPE,AMOUNT,TDS,STATUS,REF_TYPE,ORDER_ID,CREATED_BY_NAME) VALUES (?,'debit',?,?,'pending','order',?,'msale migration')`,
[o.cust_code, n(o.grand_total) - n(o.tds_val), n(o.tds_val), o.id], r);
}
}
await query(`INSERT INTO dbo.ZSO_LOG (ENTITY,ENTITY_ID,ACTION,FROM_STATUS,TO_STATUS,REMARKS,ACTOR_TYPE,ACTOR,ACTOR_NAME) VALUES ('order',?,'migrated',NULL,?,?,'user','0','msale migration')`,
[o.id, o.status, `Migrated from msale (history: ${o.chandan_status || '—'})`], r);
});
}
// ── Open samples ──
for (const sr of await M(`SELECT * FROM sample_request WHERE status NOT IN (4,9)`)) {
if (await one(`SELECT ID FROM dbo.ZSO_SAMPLES WHERE ID=?`, [sr.id])) { bump('samples_already_migrated'); continue; }
bump('samples_open');
if (!COMMIT && !VERIFY) continue;
const its = await M(`SELECT * FROM sales_order_items_sr WHERE sample_req__id=? AND status=1`, [sr.id]);
const creator = (await M(`SELECT name, email_id FROM employee_master WHERE id=?`, [sr.created_by]))[0] || {};
const cu = byEmail.get(String(creator.email_id || '').toLowerCase());
await txv(async (r) => {
await query(`SET IDENTITY_INSERT dbo.ZSO_SAMPLES ON;
INSERT INTO dbo.ZSO_SAMPLES (ID,REF_NO,CARD_CODE,CARD_NAME,ADDRESS,BB_LICENCE_NO,BB_NAME,DIVISION_ID,REQ_DATE,DELIVERY_DAYS,STATUS,INTERNAL_REMARKS,MODIFICATION_REMARKS,REJECTION_REMARKS,
STOCK_AVAILABLE,QA_DUE_DATE,QA_VERIFIED,OUT_OF_STOCK_DATE,CHALLAN_NO,CHALLAN_DATE,COURIER_NO,DISPATCH_INFO,FEEDBACK,CREATED_BY,CREATED_BY_NAME,CREATED_AT,LEGACY)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1); SET IDENTITY_INSERT dbo.ZSO_SAMPLES OFF;`,
[sr.id, sr.sample_req_no, sr.cust_code, sr.cust_name, sr.cust_address, sr.bb_licence_no, sr.bb_name, sr.product_type_id, d(sr.sample_req_date), sr.sample_delivery_require_in_days,
sr.status, sr.employee_remarks_internal, sr.modification_remarks, sr.rejection_remarks, sr.stock_radio == null ? null : !!sr.stock_radio, d(sr.qa_varification_due_date),
sr.qa_verification_radio == null ? null : !!sr.qa_verification_radio, d(sr.out_of_stock_date), sr.challan_no, d(sr.date_of_challan), sr.courier_no, sr.dispatched_qty_and_batch_no,
sr.feedback_sm, cu ? cu.ID : null, creator.name || 'msale', sr.created_date || new Date()], r);
for (const it of its) await query(`INSERT INTO dbo.ZSO_SAMPLE_ITEMS (SAMPLE_ID,KIND,PRODUCT_ID,ITEM_CODE,DISPLAY_CODE,DESCRIPTION,QTY,NOP,BATCH_NO) VALUES (?,'domestic',?,?,?,?,?,?,?)`,
[sr.id, prodMap[it.item_id] || null, it.item_name_old || it.item_name, it.item_name, it.item_desc, n(it.item_qty), it.item_nop, it.batch_no], r);
});
}
for (const er of await M(`SELECT * FROM sample_request_export WHERE sample_status<>4 OR sample_status IS NULL`)) {
if (await one(`SELECT ID FROM dbo.ZSO_EXPORT_SAMPLES WHERE ID=?`, [er.id])) { bump('export_samples_already_migrated'); continue; }
bump('export_samples_open');
if (!COMMIT && !VERIFY) continue;
const its = await M(`SELECT * FROM sample_request_items_export WHERE sample_req__id=? AND status=1`, [er.id]);
const creator = (await M(`SELECT name, email_id FROM employee_master WHERE id=?`, [er.created_by]))[0] || {};
const cu = byEmail.get(String(creator.email_id || '').toLowerCase());
const expMap = {};
for (const p of await query(`SELECT ID, LEGACY_ID FROM dbo.ZSO_PRODUCTS WHERE CATALOG='export'`)) expMap[p.LEGACY_ID] = p.ID;
await txv(async (r) => {
await query(`SET IDENTITY_INSERT dbo.ZSO_EXPORT_SAMPLES ON;
INSERT INTO dbo.ZSO_EXPORT_SAMPLES (ID,REF_NO,REGION,SAMPLE_AGAINST,CUST_NAME,COUNTRY,SHIPPING_ADDRESS,CONTACT_PERSON,CONTACT_EMAIL,CONTACT_NO,ACCOUNT_DETAILS,COURIER_BY_CUSTOMER,
DIVISION_ID,REQ_DATE,DELIVERY_DATE,DELIVERY_DAYS,REMARKS_QA,REMARKS_LOGISTICS,STATUS,STOCK_AVAILABLE,QA_DUE_DATE,QA_VERIFIED,CHALLAN_NO,CHALLAN_DATE,COURIER_NO,DISPATCH_DETAILS,
CREATED_BY,CREATED_BY_NAME,CREATED_AT,LEGACY) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1); SET IDENTITY_INSERT dbo.ZSO_EXPORT_SAMPLES OFF;`,
[er.id, er.sample_req_no, er.region, er.sample_against, er.cust_name, er.country, er.shipping_address, er.contact_person, er.contact_email, er.contact_no, er.account_details,
!!er.sample_courier_exp, er.product_type_id || null, d(er.sample_req_date), d(er.delivery_date), er.sample_delivery_require_in_days, er.remarks_for_qa, er.remarks_for_logistics,
er.sample_status || 1, er.stock_radio == null ? null : !!er.stock_radio, d(er.qa_varification_due_date), er.qa_verification_radio == null ? null : !!er.qa_verification_radio,
er.challan_no, d(er.date_of_challan), er.courier_no, er.dispatch_details, cu ? cu.ID : null, creator.name || 'msale', er.created_date || new Date()], r);
for (const it of its) await query(`INSERT INTO dbo.ZSO_SAMPLE_ITEMS (SAMPLE_ID,KIND,PRODUCT_ID,ITEM_CODE,DISPLAY_CODE,DESCRIPTION,QTY,NOP,BATCH_NO) VALUES (?,'export',?,?,?,?,?,?,?)`,
[er.id, expMap[it.item_id] || null, it.item_name_old || it.item_name, it.item_name, it.item_desc, n(it.item_qty), it.item_nop, it.batch_no], r);
});
}
// ── Wallet opening balances ──
for (const w of await M(`SELECT * FROM customer_wallet WHERE balance<>0`).catch(() => [])) {
if (await one(`SELECT ID FROM dbo.ZSO_WALLET_TXN WHERE CARD_CODE=? AND REF_TYPE='opening'`, [w.cust_code])) { bump('wallet_opening_already'); continue; }
await W(`INSERT INTO dbo.ZSO_WALLET_TXN (CARD_CODE,TXN_TYPE,AMOUNT,STATUS,REF_TYPE,REMARKS,CREATED_BY_NAME,DECIDED_AT,DECIDED_BY_NAME) VALUES (?,?,?,'approved','opening','Opening balance migrated from msale','msale migration',SYSDATETIME(),'msale migration')`,
[w.cust_code, Number(w.balance) > 0 ? 'credit' : 'debit', Math.abs(Number(w.balance))]);
bump('wallet_opening_balances');
}
await my.end();
console.log(JSON.stringify({ ...report, warnings: report.warnings.length }, null, 2));
if (report.warnings.length) { console.log('\nWarnings:'); report.warnings.slice(0, 200).forEach(w => console.log(' - ' + w)); }
if (unmatched.length) console.log(`\n${unmatched.length} active msale employees have no ERP user with the same email — create them in Admin → Users, then re-run (the run is idempotent).`);
console.log(COMMIT ? '\n✅ Migration committed.' : '\nDry run finished — re-run with --commit to write.');
}
main().then(() => setTimeout(() => process.exit(0), 300)).catch(e => { console.error('\n❌ Migration failed:', e.stack || e.message); process.exit(1); });