GWM : Export excell format
This commit is contained in:
parent
12b26b38e1
commit
ee79d1f546
@ -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 = {
|
||||
|
||||
@ -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' },
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@ -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: [
|
||||
{
|
||||
|
||||
@ -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)
|
||||
);
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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
|
||||
);
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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 };
|
||||
|
||||
Loading…
Reference in New Issue
Block a user