GWM : Export excell format

This commit is contained in:
Gowtham M 2026-07-15 11:05:21 +05:30
parent 12b26b38e1
commit ee79d1f546
9 changed files with 218 additions and 145 deletions

View File

@ -1,11 +1,23 @@
const prisma = require('../../config/prisma');
const ApiError = require('../../utils/ApiError');
const { getPagination } = require('../../utils/pagination');
const { rowsToCsv, formatCsvDateTime } = require('../../utils/csv');
const auditInclude = {
users: { select: { id: true, full_name: true, employee_code: true, email: true } },
};
const SKIP_AUDIT_FIELDS = new Set([
'id',
'created_by',
'updated_by',
'created_at',
'updated_at',
'deleted_at',
'password_hash',
'refresh_token_hash',
]);
const sanitizeAuditLog = (row) => {
if (!row) return null;
const { users, ...rest } = row;
@ -26,6 +38,99 @@ const sanitizeAuditLogListItem = (row) => {
};
};
const formatScalar = (value) => {
if (value === null || value === undefined) return '';
if (typeof value === 'bigint') return value.toString();
if (typeof value === 'boolean') return value ? 'true' : 'false';
if (value instanceof Date || (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value))) {
return formatCsvDateTime(value);
}
if (typeof value === 'object') {
if (value.name != null) return String(value.name);
if (value.code != null) return String(value.code);
if (value.full_name != null) return String(value.full_name);
if (value.label != null) return String(value.label);
return '';
}
return String(value);
};
const flattenAuditObject = (value, prefix = '') => {
if (value === null || value === undefined) return {};
if (typeof value !== 'object' || value instanceof Date || Array.isArray(value)) {
return prefix ? { [prefix]: formatScalar(value) } : {};
}
const out = {};
for (const [key, raw] of Object.entries(value)) {
if (SKIP_AUDIT_FIELDS.has(key)) continue;
const path = prefix ? `${prefix}.${key}` : key;
if (raw === null || raw === undefined) {
out[path] = '';
continue;
}
if (Array.isArray(raw)) {
out[path] = raw
.map((item) => formatScalar(item))
.filter(Boolean)
.join('; ');
continue;
}
if (typeof raw === 'object' && !(raw instanceof Date)) {
// Prefer a single readable label for nested relation objects
const label = formatScalar(raw);
if (label) {
out[path] = label;
} else {
Object.assign(out, flattenAuditObject(raw, path));
}
continue;
}
out[path] = formatScalar(raw);
}
return out;
};
const formatAuditValueForExport = (value) => {
if (value === null || value === undefined) return '';
if (typeof value !== 'object' || value instanceof Date || Array.isArray(value)) {
return formatScalar(value);
}
const flat = flattenAuditObject(value);
return Object.entries(flat)
.filter(([, v]) => v !== '')
.map(([key, v]) => `${key}: ${v}`)
.join(' | ');
};
const formatAuditDiffSide = (oldValue, newValue, side) => {
if (side === 'old' && (oldValue === null || oldValue === undefined)) return '';
if (side === 'new' && (newValue === null || newValue === undefined)) return '';
const oldFlat = flattenAuditObject(oldValue);
const newFlat = flattenAuditObject(newValue);
const keys = new Set([...Object.keys(oldFlat), ...Object.keys(newFlat)]);
// When both sides exist (UPDATE), show only changed fields
if (oldValue != null && newValue != null && typeof oldValue === 'object' && typeof newValue === 'object') {
const changed = [];
for (const key of keys) {
const before = oldFlat[key] ?? '';
const after = newFlat[key] ?? '';
if (before === after) continue;
changed.push(`${key}: ${side === 'old' ? before : after}`);
}
return changed.join(' | ');
}
return formatAuditValueForExport(side === 'old' ? oldValue : newValue);
};
const hasActiveFilters = (query) =>
Boolean(
query.table_name?.trim() ||
@ -153,12 +258,6 @@ const getFilterOptions = async () => {
};
};
const escapeCsv = (value) => {
const text = String(value ?? '');
if (/[",\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
return text;
};
const exportAuditLogs = async (query) => {
if (!hasActiveFilters(query)) {
throw new ApiError(422, 'At least one filter is required to export audit logs');
@ -171,38 +270,26 @@ const exportAuditLogs = async (query) => {
orderBy: [{ performed_at: 'desc' }, { id: 'desc' }],
});
const header = [
'ID',
'Table Name',
'Record ID',
'Action',
'Performed At',
'Performed By',
'Employee Code',
'Request ID',
'Old Value',
'New Value',
];
const lines = rows.map((row) => {
const item = sanitizeAuditLog(row);
return [
item.id,
item.table_name,
item.record_id,
item.action,
item.performed_at,
item.performed_by_user?.full_name || '',
item.performed_by_user?.employee_code || '',
item.request_id || '',
item.old_value != null ? JSON.stringify(item.old_value) : '',
item.new_value != null ? JSON.stringify(item.new_value) : '',
]
.map(escapeCsv)
.join(',');
});
return [header.join(','), ...lines].join('\n');
return rowsToCsv(
[
{ key: 'table_name', header: 'Table Name' },
{ key: 'record_id', header: 'Record ID' },
{ key: 'action', header: 'Action' },
{ key: 'performed_at', header: 'Performed At', type: 'datetime' },
{ key: (row) => row.performed_by_user?.full_name || '', header: 'Performed By' },
{ key: (row) => row.performed_by_user?.employee_code || '', header: 'Employee Code' },
{ key: (row) => row.request_id || '', header: 'Request ID' },
{
key: (row) => formatAuditDiffSide(row.old_value, row.new_value, 'old'),
header: 'Old Value',
},
{
key: (row) => formatAuditDiffSide(row.old_value, row.new_value, 'new'),
header: 'New Value',
},
],
rows.map(sanitizeAuditLog)
);
};
module.exports = {

View File

@ -29,11 +29,10 @@ const normalizePayload = (payload, fields) => {
const defaultExportColumns = (fields, columns) => {
if (columns && columns.length) return columns;
return [
{ key: 'id', header: 'ID' },
...fields
.filter((f) => !AUDIT_FIELD_NAMES.has(f.name))
.map((f) => ({ key: f.name, header: f.name })),
{ key: 'created_at', header: 'Created At' },
{ key: 'created_at', header: 'Created At', type: 'datetime' },
];
};

View File

@ -21,13 +21,12 @@ const config = {
include: subcategoryInclude,
mapRow: sanitizeSubcategory,
exportColumns: [
{ key: 'id', header: 'ID' },
{ key: 'code', header: 'Code' },
{ key: 'name', header: 'Name' },
{ key: (row) => row.item_category?.code || '', header: 'Category Code' },
{ key: (row) => row.item_category?.name || '', header: 'Category Name' },
{ key: 'is_active', header: 'Is Active' },
{ key: 'created_at', header: 'Created At' },
{ key: 'created_at', header: 'Created At', type: 'datetime' },
],
fields: [
{

View File

@ -242,7 +242,6 @@ const exportItems = async (query) => {
return rowsToCsv(
[
{ key: 'id', header: 'ID' },
{ key: 'item_code', header: 'Item Code' },
{ key: 'item_name', header: 'Item Name' },
{ key: (row) => row.item_category?.code || '', header: 'Category Code' },
@ -254,7 +253,7 @@ const exportItems = async (query) => {
{ key: (row) => row.brand?.name || '', header: 'Brand' },
{ key: 'is_asset_item', header: 'Is Asset Item' },
{ key: 'is_active', header: 'Is Active' },
{ key: 'created_at', header: 'Created At' },
{ key: 'created_at', header: 'Created At', type: 'datetime' },
],
rows.map(sanitizeItem)
);

View File

@ -166,33 +166,30 @@ const exportLocations = async (query, type = null) => {
const columns =
effectiveType === LOCATION_TYPES.WAREHOUSE
? [
{ key: 'id', header: 'ID' },
{ key: 'code', header: 'Code' },
{ key: 'name', header: 'Name' },
{ key: (row) => row.parent?.code || row.plant?.code || '', header: 'Plant Code' },
{ key: (row) => row.parent?.name || row.plant?.name || '', header: 'Plant Name' },
{ key: 'location', header: 'Location' },
{ key: 'is_active', header: 'Is Active' },
{ key: 'created_at', header: 'Created At' },
{ key: 'created_at', header: 'Created At', type: 'datetime' },
]
: effectiveType === LOCATION_TYPES.PLANT
? [
{ key: 'id', header: 'ID' },
{ key: 'code', header: 'Code' },
{ key: 'name', header: 'Name' },
{ key: 'gstin', header: 'GSTIN' },
{ key: 'city', header: 'City' },
{ key: 'state', header: 'State' },
{ key: 'is_active', header: 'Is Active' },
{ key: 'created_at', header: 'Created At' },
{ key: 'created_at', header: 'Created At', type: 'datetime' },
]
: [
{ key: 'id', header: 'ID' },
{ key: 'type', header: 'Type' },
{ key: 'code', header: 'Code' },
{ key: 'name', header: 'Name' },
{ key: 'is_active', header: 'Is Active' },
{ key: 'created_at', header: 'Created At' },
{ key: 'created_at', header: 'Created At', type: 'datetime' },
];
return rowsToCsv(columns, rows.map(mapRow));

View File

@ -64,11 +64,10 @@ const exportUom = async (query) => {
});
return rowsToCsv(
[
{ key: 'id', header: 'ID' },
{ key: 'code', header: 'Code' },
{ key: 'name', header: 'Name' },
{ key: 'is_active', header: 'Is Active' },
{ key: 'created_at', header: 'Created At' },
{ key: 'created_at', header: 'Created At', type: 'datetime' },
],
rows
);

View File

@ -1,5 +1,6 @@
const prisma = require('../../config/prisma');
const { getPagination } = require('../../utils/pagination');
const { rowsToCsv } = require('../../utils/csv');
const { calculateDepreciation, round4 } = require('../assets/assets.depreciation');
const depreciationInclude = {
@ -183,12 +184,6 @@ const getDepreciationFilterOptions = async () => {
};
};
const escapeCsv = (value) => {
const text = String(value ?? '');
if (/[",\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
return text;
};
const exportAssetDepreciation = async (query) => {
const asOfDate = query.as_of_date ? new Date(query.as_of_date) : new Date();
const where = buildDepreciationWhere(query);
@ -199,54 +194,29 @@ const exportAssetDepreciation = async (query) => {
orderBy: [{ asset_code: 'asc' }],
});
const header = [
'Asset Code',
'Asset Name',
'Status',
'Category',
'Subcategory',
'Plant',
'Department',
'Purchase Date',
'Purchase Cost',
'Salvage Value',
'Useful Life (Years)',
'Depreciation Method',
'Depreciation Rate (%)',
'Annual Depreciation',
'Accumulated Depreciation',
'Book Value',
'Years Elapsed',
'As Of Date',
];
const lines = rows.map((row) => {
const item = sanitizeDepreciationRow(row, asOfDate);
return [
item.asset_code,
item.asset_name,
item.status,
item.item_category?.name || '',
item.item_subcategory?.name || '',
item.plant?.name || '',
item.department?.name || '',
item.purchase_date ? new Date(item.purchase_date).toISOString().slice(0, 10) : '',
item.purchase_cost,
item.salvage_value,
item.useful_life_years ?? '',
item.depreciation_method || '',
item.depreciation_rate ?? '',
item.depreciation.annual_depreciation,
item.depreciation.accumulated_depreciation,
item.depreciation.book_value,
item.depreciation.years_elapsed,
asOfDate.toISOString().slice(0, 10),
]
.map(escapeCsv)
.join(',');
});
return [header.join(','), ...lines].join('\n');
return rowsToCsv(
[
{ key: 'asset_code', header: 'Asset Code' },
{ key: 'asset_name', header: 'Asset Name' },
{ key: 'status', header: 'Status' },
{ key: (row) => row.item_category?.name || '', header: 'Category' },
{ key: (row) => row.item_subcategory?.name || '', header: 'Subcategory' },
{ key: (row) => row.plant?.name || '', header: 'Plant' },
{ key: (row) => row.department?.name || '', header: 'Department' },
{ key: 'purchase_date', header: 'Purchase Date', type: 'date' },
{ key: 'purchase_cost', header: 'Purchase Cost' },
{ key: 'salvage_value', header: 'Salvage Value' },
{ key: (row) => row.useful_life_years ?? '', header: 'Useful Life (Years)' },
{ key: (row) => row.depreciation_method || '', header: 'Depreciation Method' },
{ key: (row) => row.depreciation_rate ?? '', header: 'Depreciation Rate (%)' },
{ key: (row) => row.depreciation.annual_depreciation, header: 'Annual Depreciation' },
{ key: (row) => row.depreciation.accumulated_depreciation, header: 'Accumulated Depreciation' },
{ key: (row) => row.depreciation.book_value, header: 'Book Value' },
{ key: (row) => row.depreciation.years_elapsed, header: 'Years Elapsed' },
{ key: () => asOfDate, header: 'As Of Date', type: 'date' },
],
rows.map((row) => sanitizeDepreciationRow(row, asOfDate))
);
};
module.exports = {

View File

@ -293,11 +293,7 @@ const getFilterOptions = async () => {
};
};
const escapeCsv = (value) => {
const text = String(value ?? '');
if (/[",\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
return text;
};
const { rowsToCsv } = require('../../utils/csv');
const exportUsers = async (query) => {
const where = buildUsersWhere(query);
@ -307,34 +303,19 @@ const exportUsers = async (query) => {
orderBy: { full_name: 'asc' },
});
const header = [
'Full Name',
'Email',
'Employee Code',
'Roles',
'Department',
'Plant',
'Last Login',
'Status',
];
const lines = rows.map((user) => {
const item = toUserListItem(user);
return [
item.full_name,
item.email,
item.employee_code,
item.roles.map((role) => role.name).join('; '),
item.department?.name || '',
item.plant?.name || '',
item.last_login_at ? new Date(item.last_login_at).toISOString() : '',
item.status,
]
.map(escapeCsv)
.join(',');
});
return [header.join(','), ...lines].join('\n');
return rowsToCsv(
[
{ key: 'full_name', header: 'Full Name' },
{ key: 'email', header: 'Email' },
{ key: 'employee_code', header: 'Employee Code' },
{ key: (row) => row.roles.map((role) => role.name).join('; '), header: 'Roles' },
{ key: (row) => row.department?.name || '', header: 'Department' },
{ key: (row) => row.plant?.name || '', header: 'Plant' },
{ key: 'last_login_at', header: 'Last Login', type: 'datetime' },
{ key: 'status', header: 'Status' },
],
rows.map(toUserListItem)
);
};
const getUserById = async (id) => {

View File

@ -1,3 +1,5 @@
const pad2 = (value) => String(value).padStart(2, '0');
const escapeCsv = (value) => {
if (value === null || value === undefined) return '';
const str = String(value);
@ -5,20 +7,60 @@ const escapeCsv = (value) => {
return str;
};
const formatCsvCell = (value) => {
const isDateOnlyString = (value) => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value);
const isIsoDateTimeString = (value) =>
typeof value === 'string' &&
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/.test(value);
/**
* Format date / datetime values for CSV/Excel as dd-mm-yyyy hh:mm.
* Date-only values (YYYY-MM-DD / Prisma @db.Date) use UTC calendar day at 00:00.
* @param {Date|string|number|null|undefined} value
* @returns {string}
*/
const formatCsvDateTime = (value) => {
if (value === null || value === undefined || value === '') return '';
if (isDateOnlyString(value)) {
const [year, month, day] = value.split('-');
return `${day}-${month}-${year} 00:00`;
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
// Prisma @db.Date often arrives as Date at UTC midnight
const looksLikeDateOnly =
value instanceof Date &&
date.getUTCHours() === 0 &&
date.getUTCMinutes() === 0 &&
date.getUTCSeconds() === 0 &&
date.getUTCMilliseconds() === 0;
if (looksLikeDateOnly && !isIsoDateTimeString(value)) {
return `${pad2(date.getUTCDate())}-${pad2(date.getUTCMonth() + 1)}-${date.getUTCFullYear()} 00:00`;
}
return `${pad2(date.getDate())}-${pad2(date.getMonth() + 1)}-${date.getFullYear()} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
};
const formatCsvCell = (value, { type } = {}) => {
if (value === null || value === undefined) return '';
if (type === 'datetime' || type === 'date') return formatCsvDateTime(value);
if (typeof value === 'bigint') return value.toString();
if (value instanceof Date) return value.toISOString();
if (value instanceof Date) return formatCsvDateTime(value);
if (isDateOnlyString(value) || isIsoDateTimeString(value)) return formatCsvDateTime(value);
if (typeof value === 'boolean') return value ? 'true' : 'false';
if (typeof value === 'object') {
if (typeof value.toJSON === 'function') return formatCsvCell(value.toJSON());
if (typeof value.toJSON === 'function') return formatCsvCell(value.toJSON(), { type });
return JSON.stringify(value);
}
return value;
};
/**
* @param {Array<{ key: string|Function, header: string }>} columns
* @param {Array<{ key: string|Function, header: string, type?: 'date'|'datetime' }>} columns
* @param {object[]} rows
* @returns {string}
*/
@ -28,11 +70,11 @@ const rowsToCsv = (columns, rows) => {
columns
.map((col) => {
const raw = typeof col.key === 'function' ? col.key(row) : row[col.key];
return escapeCsv(formatCsvCell(raw));
return escapeCsv(formatCsvCell(raw, { type: col.type }));
})
.join(',')
);
return [header, ...lines].join('\n');
};
module.exports = { escapeCsv, formatCsvCell, rowsToCsv };
module.exports = { escapeCsv, formatCsvCell, formatCsvDateTime, rowsToCsv };