507 lines
16 KiB
JavaScript
507 lines
16 KiB
JavaScript
const db = require("../models");
|
|
const { Op } = require("sequelize");
|
|
const { Sequelize } = require("sequelize");
|
|
const { UnitMaster } = require("../models");
|
|
const fs = require("fs");
|
|
const csv = require("csv-parser");
|
|
const path = require("path");
|
|
|
|
// Create new Unit
|
|
exports.createUnit = async (req, res) => {
|
|
try {
|
|
const { uom } = req.body;
|
|
|
|
if (!uom || typeof uom !== "string" || !uom.trim()) {
|
|
return res.status(400).json({
|
|
status: "error",
|
|
message: "UOM (Unit Name) is required and must be a non-empty string.",
|
|
});
|
|
}
|
|
const cleaned = uom.replace(/[^a-zA-Z]/g, "").toUpperCase();
|
|
if (!cleaned) {
|
|
return res.status(400).json({
|
|
status: "error",
|
|
message: "UOM must contain at least one letter (A-Z).",
|
|
});
|
|
}
|
|
const existingUOM = await UnitMaster.findOne({
|
|
where: Sequelize.where(
|
|
Sequelize.fn("LOWER", Sequelize.col("uom")),
|
|
uom.toLowerCase()
|
|
),
|
|
});
|
|
|
|
if (existingUOM)
|
|
return res.status(400).json({
|
|
status: "error",
|
|
message: `Unit '${uom}' already exists with short key '${existingUOM.uom_short_name}'.`,
|
|
});
|
|
|
|
//Generate short name (same rule as CSV)
|
|
const abbreviationMap = {
|
|
METER: "MT",
|
|
METRE: "MT",
|
|
KILOGRAM: "KG",
|
|
GRAM: "GM",
|
|
LITER: "LTR",
|
|
LITRE: "LTR",
|
|
CENTIMETER: "CM",
|
|
MILLIMETER: "MM",
|
|
SECOND: "SEC",
|
|
MINUTE: "MIN",
|
|
HOUR: "HR",
|
|
DAY: "DAY",
|
|
PIECE: "PC",
|
|
BOX: "BX",
|
|
USER: "USR",
|
|
ITEM: "ITM",
|
|
UNIT: "UNT",
|
|
};
|
|
|
|
let uomShort;
|
|
|
|
if (cleaned.length <= 5) {
|
|
uomShort = cleaned;
|
|
} else {
|
|
// If >5 letters, use abbreviation or auto-generate
|
|
const baseShort = abbreviationMap[cleaned] || cleaned.substring(0, 3);
|
|
const randomLetters = () =>
|
|
Array.from({ length: 2 }, () =>
|
|
String.fromCharCode(65 + Math.floor(Math.random() * 26))
|
|
).join("");
|
|
uomShort = `${baseShort}${randomLetters()}`.substring(0, 5);
|
|
}
|
|
|
|
let exists = await UnitMaster.findOne({
|
|
where: Sequelize.where(
|
|
Sequelize.fn("LOWER", Sequelize.col("uom_short_name")),
|
|
uomShort.toLowerCase()
|
|
),
|
|
});
|
|
|
|
while (exists) {
|
|
const randomSuffix = Math.random().toString(36).substring(2, 3).toUpperCase();
|
|
uomShort = (uomShort.substring(0, 4) + randomSuffix).substring(0, 5);
|
|
exists = await UnitMaster.findOne({
|
|
where: Sequelize.where(
|
|
Sequelize.fn("LOWER", Sequelize.col("uom_short_name")),
|
|
uomShort.toLowerCase()
|
|
),
|
|
});
|
|
}
|
|
req.body.uom_short_name = uomShort;
|
|
req.body.created_by = req.body.created_by || req.user.id;
|
|
|
|
const unit = await UnitMaster.create(req.body);
|
|
res.status(201).json({ status: "success", data: unit });
|
|
} catch (err) {
|
|
res.status(400).json({ status: "error", message: err.message });
|
|
}
|
|
};
|
|
|
|
// Get all Units
|
|
exports.getAllUnits = async (req, res) => {
|
|
try {
|
|
const units = await UnitMaster.findAll({
|
|
attributes: {
|
|
include: [
|
|
[
|
|
Sequelize.literal(
|
|
`(SELECT COUNT(*) FROM products AS P WHERE P.unit_id = UnitMaster.id)`
|
|
),
|
|
"mapped_products_count",
|
|
],
|
|
[
|
|
Sequelize.literal(
|
|
`(SELECT name FROM admin_users AU WHERE AU.id = UnitMaster.created_by)`
|
|
),
|
|
"created_by_name"
|
|
],
|
|
[
|
|
Sequelize.literal(
|
|
`(SELECT name FROM admin_users AU WHERE AU.id = UnitMaster.updated_by)`
|
|
),
|
|
"updated_by_name"
|
|
]
|
|
],
|
|
},
|
|
});
|
|
|
|
res.status(200).json({ status: "success",unit_count : units.length, data: units });
|
|
} catch (err) {
|
|
res.status(500).json({ status: "error", message: err.message });
|
|
}
|
|
};
|
|
|
|
|
|
// Get Unit by ID
|
|
exports.getUnitById = async (req, res) => {
|
|
try {
|
|
const unit = await UnitMaster.findByPk(req.params.id);
|
|
if (!unit) return res.status(404).json({ status: "error", message: "Unit not found" });
|
|
res.status(200).json({ status: "success", data: unit });
|
|
} catch (err) {
|
|
res.status(500).json({ status: "error", message: err.message });
|
|
}
|
|
};
|
|
|
|
// Update Unit
|
|
exports.updateUnit = async (req, res) => {
|
|
try {
|
|
const unit = await UnitMaster.findByPk(req.params.id);
|
|
if (!unit) return res.status(404).json({ status: "error", message: "Unit not found" });
|
|
|
|
req.body.updated_by = req.body.updated_by || req.user.id;
|
|
req.body.updated_at = req.body.updated_at || new Date();
|
|
|
|
await unit.update(req.body);
|
|
res.status(200).json({ status: "success", data: unit });
|
|
} catch (err) {
|
|
res.status(400).json({ status: "error", message: err.message });
|
|
}
|
|
};
|
|
|
|
// Delete Unit
|
|
exports.deleteUnit = async (req, res) => {
|
|
try {
|
|
const unit = await UnitMaster.findByPk(req.params.id);
|
|
if (!unit) return res.status(404).json({ status: "error", message: "Unit not found" });
|
|
|
|
await unit.destroy();
|
|
res.status(200).json({ status: "success", message: "Unit deleted successfully" });
|
|
} catch (err) {
|
|
res.status(500).json({ status: "error", message: err.message });
|
|
}
|
|
};
|
|
|
|
exports.uploadUnitMasterFromCSV = async (req, res) => {
|
|
try {
|
|
if (!req.file)
|
|
return res.status(400).send({ status: "failed", message: "No file uploaded." });
|
|
|
|
const filePath = path.resolve(req.file.path);
|
|
|
|
// Validate file type
|
|
if (!req.file.originalname.endsWith(".csv")) {
|
|
fs.unlinkSync(filePath);
|
|
return res.status(400).send({
|
|
status: "failed",
|
|
message: "Invalid file type. Only CSV allowed.",
|
|
});
|
|
}
|
|
|
|
// Validate user
|
|
if (!req.user?.id || isNaN(req.user.id)) {
|
|
fs.unlinkSync(filePath);
|
|
return res.status(400).send({
|
|
status: "failed",
|
|
message: "Invalid or missing User Id.",
|
|
});
|
|
}
|
|
|
|
// Validate file not empty
|
|
const stats = fs.statSync(filePath);
|
|
if (stats.size === 0) {
|
|
fs.unlinkSync(filePath);
|
|
return res.status(400).send({
|
|
status: "failed",
|
|
message: "Uploaded file is empty.",
|
|
});
|
|
}
|
|
|
|
let results = [];
|
|
|
|
fs.createReadStream(filePath)
|
|
.pipe(csv())
|
|
.on("data", (row) => results.push(row))
|
|
.on("end", async () => {
|
|
try {
|
|
if (results.length === 0) {
|
|
fs.unlinkSync(filePath);
|
|
return res.status(400).send({
|
|
status: "failed",
|
|
message: "CSV file is empty or invalid.",
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// NORMALIZE HEADERS (Very Important!)
|
|
// -------------------------------------------------------------------
|
|
const normalizeHeader = (h) => {
|
|
return h
|
|
.replace(/\*/g, "") // remove *
|
|
.replace(/\(.*?\)/g, "") // remove (Mandatory)
|
|
.trim()
|
|
.replace(/[\s\W]+/g, "_") // spaces & special chars -> _
|
|
.toLowerCase();
|
|
};
|
|
|
|
// Required normalized fields
|
|
const mappedRequiredCols = {
|
|
unit_name: "Unit Name",
|
|
description: "Description",
|
|
};
|
|
|
|
const incomingHeaders = Object.keys(results[0] || {});
|
|
const normalizedIncoming = incomingHeaders.map(h => normalizeHeader(h));
|
|
|
|
// Check missing columns
|
|
const missingCols = Object.keys(mappedRequiredCols).filter(
|
|
req => !normalizedIncoming.includes(req)
|
|
);
|
|
|
|
// Check unexpected columns
|
|
const extraCols = normalizedIncoming.filter(
|
|
col => !Object.keys(mappedRequiredCols).includes(col)
|
|
);
|
|
|
|
if (missingCols.length > 0 || extraCols.length > 0) {
|
|
fs.unlinkSync(filePath);
|
|
|
|
let msg = "";
|
|
if (missingCols.length > 0)
|
|
msg += `Missing required columns: ${missingCols.map(c => mappedRequiredCols[c]).join(", ")}. `;
|
|
|
|
if (extraCols.length > 0)
|
|
msg += `Unexpected columns found: ${extraCols.join(", ")}. Only 'Unit Name' and 'Description' are allowed.`;
|
|
|
|
return res.status(400).send({
|
|
status: "failed",
|
|
message: msg.trim(),
|
|
});
|
|
}
|
|
|
|
// Remap row keys to clean headers
|
|
results = results.map(row => {
|
|
const newRow = {};
|
|
for (const key in row) {
|
|
const normalized = normalizeHeader(key);
|
|
const mapped = mappedRequiredCols[normalized];
|
|
if (mapped) newRow[mapped] = row[key];
|
|
}
|
|
return newRow;
|
|
});
|
|
|
|
// -------------------------------------------------------------------
|
|
// VALIDATION AND PROCESSING
|
|
// -------------------------------------------------------------------
|
|
|
|
const inserted = [];
|
|
const duplicates = [];
|
|
const errors = [];
|
|
const seenUnitNames = new Set();
|
|
const seenShortNames = new Set();
|
|
const fileDuplicates = [];
|
|
|
|
const generateShortName = (unitName, existingShorts = new Set()) => {
|
|
const cleaned = unitName.replace(/[^a-zA-Z]/g, "").toUpperCase();
|
|
|
|
const abbreviationMap = {
|
|
METER: "MT",
|
|
METRE: "MT",
|
|
KILOGRAM: "KG",
|
|
GRAM: "GM",
|
|
LITER: "LTR",
|
|
LITRE: "LTR",
|
|
CENTIMETER: "CM",
|
|
MILLIMETER: "MM",
|
|
SECOND: "SEC",
|
|
MINUTE: "MIN",
|
|
HOUR: "HR",
|
|
DAY: "DY",
|
|
PIECE: "PC",
|
|
BOX: "BX",
|
|
USER: "USR",
|
|
ITEM: "ITM",
|
|
UNIT: "UNT",
|
|
};
|
|
|
|
if (cleaned.length <= 5) return cleaned;
|
|
|
|
let shortName = abbreviationMap[cleaned] || cleaned.substring(0, 3);
|
|
|
|
if (shortName.length < 2) shortName = shortName.padEnd(2, "X");
|
|
|
|
const randomLetters = () =>
|
|
Array.from({ length: 2 }, () =>
|
|
String.fromCharCode(65 + Math.floor(Math.random() * 26))
|
|
).join("");
|
|
|
|
let final = `${shortName}${randomLetters()}`;
|
|
while (existingShorts.has(final.toLowerCase())) {
|
|
final = `${shortName}${randomLetters()}`;
|
|
}
|
|
|
|
return final.substring(0, 5);
|
|
};
|
|
|
|
// Row validation
|
|
for (const [i, row] of results.entries()) {
|
|
const uom = row["Unit Name"]?.trim();
|
|
const description = row["Description"]?.trim() || null;
|
|
|
|
if (description.length > 1000) {
|
|
errors.push({ row: i + 1, reason: "HS Description is too long. Maximum allowed length is 1000 characters." });
|
|
continue;
|
|
}
|
|
|
|
if (!uom) {
|
|
errors.push({ row: i + 1, reason: "Missing 'Unit Name'." });
|
|
continue;
|
|
}
|
|
|
|
const uomKey = uom.toLowerCase();
|
|
if (seenUnitNames.has(uomKey)) {
|
|
fileDuplicates.push({
|
|
row: i + 1,
|
|
reason: "Duplicate Unit Name in file.",
|
|
uom,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const uomShort = generateShortName(uom, seenShortNames);
|
|
|
|
seenUnitNames.add(uomKey);
|
|
seenShortNames.add(uomShort.toLowerCase());
|
|
|
|
results[i]._generatedShort = uomShort;
|
|
results[i]._description = description;
|
|
}
|
|
|
|
if (fileDuplicates.length > 0) {
|
|
fs.unlinkSync(filePath);
|
|
return res.status(400).send({
|
|
status: "failed",
|
|
message: "Duplicate Unit Names found within uploaded file.",
|
|
duplicate_rows: fileDuplicates,
|
|
});
|
|
}
|
|
|
|
// Insert into DB
|
|
for (const [i, row] of results.entries()) {
|
|
try {
|
|
const uom = row["Unit Name"].trim();
|
|
let uomShort = row._generatedShort;
|
|
const description = row._description;
|
|
|
|
const existing = await UnitMaster.findOne({
|
|
where: {
|
|
[Op.or]: [
|
|
Sequelize.where(
|
|
Sequelize.fn("LOWER", Sequelize.col("uom")),
|
|
uom.toLowerCase()
|
|
),
|
|
Sequelize.where(
|
|
Sequelize.fn("LOWER", Sequelize.col("uom_short_name")),
|
|
uomShort.toLowerCase()
|
|
),
|
|
],
|
|
},
|
|
});
|
|
|
|
if (existing) {
|
|
duplicates.push({
|
|
id: existing.id,
|
|
uom: existing.uom,
|
|
uom_short_name: existing.uom_short_name,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const newUnit = await UnitMaster.create({
|
|
uom_short_name: uomShort,
|
|
uom,
|
|
description,
|
|
created_by: parseInt(req.user.id),
|
|
created_at: new Date(),
|
|
});
|
|
|
|
inserted.push(newUnit);
|
|
|
|
} catch (err) {
|
|
errors.push({ row: i + 1, reason: err.message });
|
|
}
|
|
}
|
|
|
|
fs.unlinkSync(filePath);
|
|
let finalStatus = "success";
|
|
let message = `${inserted.length} units inserted successfully.`;
|
|
let httpCode = 200;
|
|
|
|
// Case 1: all good
|
|
if (errors.length === 0 && duplicates.length === 0) {
|
|
finalStatus = "success";
|
|
httpCode = 200;
|
|
}
|
|
|
|
// Case 2: partial success
|
|
else if (inserted.length > 0 && (duplicates.length > 0 || errors.length > 0)) {
|
|
finalStatus = "partial_success";
|
|
message = `${inserted.length} inserted, ${duplicates.length} duplicates skipped, ${errors.length} errors found.`;
|
|
httpCode = 206; // Partial Content
|
|
}
|
|
|
|
// Case 3: failed (no imports)
|
|
else if (inserted.length === 0) {
|
|
finalStatus = "failed";
|
|
message = `No units imported. ${duplicates.length} duplicates and ${errors.length} errors found.`;
|
|
httpCode = 400;
|
|
}
|
|
|
|
return res.status(httpCode).send({
|
|
status: finalStatus,
|
|
message,
|
|
summary: {
|
|
total_records: results.length,
|
|
imported: inserted.length,
|
|
skipped: duplicates.length,
|
|
errors: errors.length,
|
|
},
|
|
duplicates,
|
|
error_details: errors,
|
|
});
|
|
|
|
} catch (err) {
|
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
return res.status(500).send({
|
|
status: "failed",
|
|
message: err.message,
|
|
});
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error("Error uploading Unit Master CSV:", error);
|
|
if (req.file && fs.existsSync(path.resolve(req.file.path)))
|
|
fs.unlinkSync(path.resolve(req.file.path));
|
|
|
|
return res.status(500).send({
|
|
status: "failed",
|
|
message: "Error processing CSV file.",
|
|
error: error.message,
|
|
});
|
|
}
|
|
};
|
|
|
|
exports.downloadUnitMasterFile = async (req, res) => {
|
|
try {
|
|
const safeBasePath = path.resolve(__dirname, "../downloads_csv");
|
|
const safeFilePath = path.join(safeBasePath, "unit_master_sample.csv");
|
|
|
|
// Verify file exists BEFORE sending
|
|
if (!fs.existsSync(safeFilePath)) {
|
|
return res.status(404).send({
|
|
status: "failed",
|
|
message: "File not found",
|
|
});
|
|
}
|
|
|
|
return res.download(safeFilePath, "unit_master_sample.csv");
|
|
} catch (error) {
|
|
return res.status(500).send({
|
|
status: "failed",
|
|
message: error.message,
|
|
});
|
|
}
|
|
};
|