764 lines
29 KiB
JavaScript
764 lines
29 KiB
JavaScript
const prisma = require('../../config/prisma');
|
|
const ApiError = require('../../utils/ApiError');
|
|
const auditLog = require('../../utils/auditLog');
|
|
const { getPagination, isDropdownCall } = require('../../utils/pagination');
|
|
const { nextDocumentNumber } = require('../../utils/generateCode');
|
|
const { rowsToCsv } = require('../../utils/csv');
|
|
const { DISPOSAL_STATUSES, getAssetDropdownOptions } = require('./assets.constants');
|
|
const { assertAnyLocation } = require('../../utils/locations');
|
|
const repository = require('./assets.repository');
|
|
const {
|
|
DEPRECIATION_METHOD_OPTIONS,
|
|
resolveDepreciationRate,
|
|
calculateDepreciation,
|
|
} = require('./assets.depreciation');
|
|
const { assertReference, toDateOnly, normalizeChecklistTemplate, presentChecklistTemplate } = require('./assets.helpers');
|
|
const { sanitizeAttachment } = require('./assets.attachments.service');
|
|
|
|
const assetInclude = {
|
|
item_categories: {
|
|
select: {
|
|
id: true,
|
|
code: true,
|
|
name: true,
|
|
code_prefix: true,
|
|
default_useful_life_years: true,
|
|
default_depreciation_method: true,
|
|
},
|
|
},
|
|
item_subcategories: { select: { id: true, code: true, name: true, item_category_id: true } },
|
|
location: { select: { id: true, code: true, name: true, type: true } },
|
|
departments: { select: { id: true, name: true } },
|
|
users_assets_assigned_to_user_idTousers: {
|
|
select: { id: true, full_name: true, employee_code: true },
|
|
},
|
|
users_assets_maintenance_incharge_idTousers: {
|
|
select: { id: true, full_name: true, employee_code: true },
|
|
},
|
|
asset_maintenance_logs: {
|
|
where: { deleted_at: null },
|
|
orderBy: [{ performed_date: 'desc' }, { created_at: 'desc' }],
|
|
take: 1,
|
|
select: { id: true, performed_date: true, next_due_date: true },
|
|
},
|
|
vendors_assets_vendor_idTovendors: { select: { id: true, vendor_code: true, vendor_name: true } },
|
|
purchase_orders: { select: { id: true, po_number: true } },
|
|
grn: { select: { id: true, grn_number: true } },
|
|
users_assets_created_byTousers: { select: { id: true, full_name: true } },
|
|
};
|
|
|
|
const assetDetailInclude = {
|
|
...assetInclude,
|
|
users_assets_updated_byTousers: { select: { id: true, full_name: true } },
|
|
asset_attachments: {
|
|
orderBy: { created_at: 'desc' },
|
|
include: {
|
|
users: { select: { id: true, full_name: true, employee_code: true } },
|
|
},
|
|
},
|
|
};
|
|
|
|
const transferInclude = {
|
|
from_location: { select: { id: true, code: true, name: true, type: true } },
|
|
to_location: { select: { id: true, code: true, name: true, type: true } },
|
|
departments_asset_transfers_from_department_idTodepartments: { select: { id: true, name: true } },
|
|
departments_asset_transfers_to_department_idTodepartments: { select: { id: true, name: true } },
|
|
users_asset_transfers_from_user_idTousers: { select: { id: true, full_name: true } },
|
|
users_asset_transfers_to_user_idTousers: { select: { id: true, full_name: true } },
|
|
users_asset_transfers_transferred_byTousers: { select: { id: true, full_name: true } },
|
|
};
|
|
|
|
const assetSeriesCode = (categoryCode) => `ASSET_${categoryCode}`;
|
|
|
|
const startOfUtcDay = (value) => {
|
|
const d = new Date(value);
|
|
if (Number.isNaN(d.getTime())) return null;
|
|
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
|
};
|
|
|
|
const addDays = (value, days) => {
|
|
const base = startOfUtcDay(value);
|
|
if (!base || days === null || days === undefined) return null;
|
|
base.setUTCDate(base.getUTCDate() + Number(days));
|
|
return base;
|
|
};
|
|
|
|
// Derive maintenance due state from the configured frequency, the latest log and
|
|
// the asset's usage start date (commencement_date, falling back to purchase_date).
|
|
const buildMaintenanceSummary = (rest, logs) => {
|
|
const frequency =
|
|
rest.maintenance_frequency_in_days !== null && rest.maintenance_frequency_in_days !== undefined
|
|
? Number(rest.maintenance_frequency_in_days)
|
|
: null;
|
|
const lastLog = Array.isArray(logs) && logs.length ? logs[0] : null;
|
|
const lastMaintenanceDate = lastLog?.performed_date ?? null;
|
|
const baseline = lastMaintenanceDate || rest.commencement_date || rest.purchase_date || null;
|
|
|
|
let nextDueDate = lastLog?.next_due_date ?? null;
|
|
if (!nextDueDate && frequency && baseline) {
|
|
nextDueDate = addDays(baseline, frequency);
|
|
}
|
|
|
|
const today = startOfUtcDay(new Date());
|
|
const isDue = Boolean(frequency && nextDueDate && new Date(nextDueDate) <= today);
|
|
const daysUntilDue =
|
|
nextDueDate && today
|
|
? Math.round((new Date(nextDueDate).getTime() - today.getTime()) / 86400000)
|
|
: null;
|
|
|
|
return {
|
|
frequency_in_days: frequency,
|
|
checklist: presentChecklistTemplate(rest.maintenance_checklist_json),
|
|
last_maintenance_date: lastMaintenanceDate,
|
|
next_due_date: nextDueDate,
|
|
is_due: isDue,
|
|
days_until_due: daysUntilDue,
|
|
};
|
|
};
|
|
|
|
const sanitizeAsset = (asset) => {
|
|
if (!asset) return null;
|
|
const {
|
|
item_categories,
|
|
item_subcategories,
|
|
location,
|
|
departments,
|
|
users_assets_assigned_to_user_idTousers,
|
|
users_assets_maintenance_incharge_idTousers,
|
|
asset_maintenance_logs,
|
|
vendors_assets_vendor_idTovendors,
|
|
purchase_orders,
|
|
grn,
|
|
users_assets_created_byTousers,
|
|
users_assets_updated_byTousers,
|
|
asset_attachments,
|
|
...rest
|
|
} = asset;
|
|
|
|
const purchaseCost =
|
|
rest.purchase_cost !== null && rest.purchase_cost !== undefined
|
|
? Number(rest.purchase_cost)
|
|
: 0;
|
|
const salvageValue =
|
|
rest.salvage_value !== null && rest.salvage_value !== undefined
|
|
? Number(rest.salvage_value)
|
|
: 0;
|
|
const depreciationRate =
|
|
rest.depreciation_rate !== null && rest.depreciation_rate !== undefined
|
|
? Number(rest.depreciation_rate)
|
|
: null;
|
|
|
|
const depreciation = calculateDepreciation({
|
|
depreciation_method: rest.depreciation_method,
|
|
depreciation_rate: depreciationRate,
|
|
purchase_cost: purchaseCost,
|
|
salvage_value: salvageValue,
|
|
useful_life_years: rest.useful_life_years,
|
|
commencement_date: rest.commencement_date,
|
|
purchase_date: rest.purchase_date,
|
|
});
|
|
|
|
return {
|
|
...rest,
|
|
maintenance_checklist_json: presentChecklistTemplate(rest.maintenance_checklist_json),
|
|
purchase_cost: purchaseCost,
|
|
salvage_value: salvageValue,
|
|
depreciation_rate: depreciationRate,
|
|
// Current written-down / book value after depreciation (same as depreciation.book_value)
|
|
current_value: depreciation.book_value,
|
|
item_category: item_categories || null,
|
|
item_subcategory: item_subcategories || null,
|
|
location: location || null,
|
|
department: departments || null,
|
|
assigned_to_user: users_assets_assigned_to_user_idTousers || null,
|
|
maintenance_incharge_user: users_assets_maintenance_incharge_idTousers || null,
|
|
maintenance: buildMaintenanceSummary(rest, asset_maintenance_logs),
|
|
vendor: vendors_assets_vendor_idTovendors || null,
|
|
purchase_order: purchase_orders || null,
|
|
grn: grn || null,
|
|
created_by_user: users_assets_created_byTousers || null,
|
|
updated_by_user: users_assets_updated_byTousers || null,
|
|
attachments: (asset_attachments || []).map(sanitizeAttachment),
|
|
depreciation: {
|
|
...depreciation,
|
|
current_value: depreciation.book_value,
|
|
},
|
|
item_categories: undefined,
|
|
item_subcategories: undefined,
|
|
departments: undefined,
|
|
users_assets_assigned_to_user_idTousers: undefined,
|
|
users_assets_maintenance_incharge_idTousers: undefined,
|
|
asset_maintenance_logs: undefined,
|
|
vendors_assets_vendor_idTovendors: undefined,
|
|
purchase_orders: undefined,
|
|
users_assets_created_byTousers: undefined,
|
|
users_assets_updated_byTousers: undefined,
|
|
asset_attachments: undefined,
|
|
};
|
|
};
|
|
|
|
const sanitizeTransfer = (row) => {
|
|
if (!row) return null;
|
|
const {
|
|
from_location,
|
|
to_location,
|
|
departments_asset_transfers_from_department_idTodepartments,
|
|
departments_asset_transfers_to_department_idTodepartments,
|
|
users_asset_transfers_from_user_idTousers,
|
|
users_asset_transfers_to_user_idTousers,
|
|
users_asset_transfers_transferred_byTousers,
|
|
...rest
|
|
} = row;
|
|
|
|
return {
|
|
...rest,
|
|
from_location: from_location || null,
|
|
to_location: to_location || null,
|
|
from_department: departments_asset_transfers_from_department_idTodepartments || null,
|
|
to_department: departments_asset_transfers_to_department_idTodepartments || null,
|
|
from_user: users_asset_transfers_from_user_idTousers || null,
|
|
to_user: users_asset_transfers_to_user_idTousers || null,
|
|
transferred_by_user: users_asset_transfers_transferred_byTousers || null,
|
|
};
|
|
};
|
|
|
|
const assertDisposalFields = (status, disposalDate) => {
|
|
if (DISPOSAL_STATUSES.includes(status) && !disposalDate) {
|
|
throw new ApiError(422, 'disposal_date is required when status is DISPOSED or SCRAPPED');
|
|
}
|
|
};
|
|
|
|
const assertItemSubcategory = async (subcategoryId, categoryId) => {
|
|
const subcategory = await assertReference(
|
|
'item_subcategories',
|
|
subcategoryId,
|
|
'item_subcategory_id'
|
|
);
|
|
if (subcategory.item_category_id.toString() !== BigInt(categoryId).toString()) {
|
|
throw new ApiError(422, 'item_subcategory_id does not belong to the selected item_category_id');
|
|
}
|
|
return subcategory;
|
|
};
|
|
|
|
const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => {
|
|
const category = await assertReference(
|
|
'item_categories',
|
|
payload.item_category_id,
|
|
'item_category_id'
|
|
);
|
|
if (isCreate && !payload.item_subcategory_id) {
|
|
throw new ApiError(422, 'item_subcategory_id is required');
|
|
}
|
|
if (payload.item_subcategory_id) {
|
|
await assertItemSubcategory(payload.item_subcategory_id, payload.item_category_id);
|
|
}
|
|
await assertAnyLocation(payload.location_id, 'location_id');
|
|
if (payload.department_id)
|
|
await assertReference('departments', payload.department_id, 'department_id');
|
|
if (payload.assigned_to_user_id)
|
|
await assertReference('users', payload.assigned_to_user_id, 'assigned_to_user_id');
|
|
if (payload.maintenance_incharge_user_id)
|
|
await assertReference(
|
|
'users',
|
|
payload.maintenance_incharge_user_id,
|
|
'maintenance_incharge_user_id'
|
|
);
|
|
|
|
let checklistJson = null;
|
|
if (payload.maintenance_checklist_json !== undefined) {
|
|
if (payload.maintenance_checklist_json === null) {
|
|
checklistJson = null;
|
|
} else {
|
|
checklistJson = normalizeChecklistTemplate(payload.maintenance_checklist_json);
|
|
}
|
|
}
|
|
|
|
if (payload.vendor_id) await assertReference('vendors', payload.vendor_id, 'vendor_id');
|
|
if (payload.po_id) await assertReference('purchase_orders', payload.po_id, 'po_id');
|
|
if (payload.grn_id) await assertReference('grn', payload.grn_id, 'grn_id');
|
|
if (payload.grn_item_id)
|
|
await assertReference('grn_items', payload.grn_item_id, 'grn_item_id', {
|
|
requireActive: false,
|
|
});
|
|
|
|
const status = payload.status ?? 'IN_USE';
|
|
const disposalDate = payload.disposal_date ? toDateOnly(payload.disposal_date) : null;
|
|
assertDisposalFields(status, disposalDate);
|
|
|
|
const purchaseCost = payload.purchase_cost ?? 0;
|
|
const salvageValue = payload.salvage_value ?? 0;
|
|
const usefulLifeYears =
|
|
payload.useful_life_years !== undefined && payload.useful_life_years !== null
|
|
? payload.useful_life_years
|
|
: category.default_useful_life_years;
|
|
const depreciationMethod =
|
|
payload.depreciation_method || category.default_depreciation_method || null;
|
|
|
|
if (depreciationMethod === 'OTHER' && (payload.depreciation_rate === undefined || payload.depreciation_rate === null || payload.depreciation_rate === '')) {
|
|
throw new ApiError(422, 'depreciation_rate is required when depreciation_method is OTHER');
|
|
}
|
|
|
|
const depreciationRate = resolveDepreciationRate({
|
|
method: depreciationMethod,
|
|
depreciation_rate: payload.depreciation_rate,
|
|
purchase_cost: purchaseCost,
|
|
salvage_value: salvageValue,
|
|
useful_life_years: usefulLifeYears,
|
|
});
|
|
|
|
return {
|
|
asset_name: payload.asset_name,
|
|
item_category_id: BigInt(payload.item_category_id),
|
|
item_subcategory_id: payload.item_subcategory_id
|
|
? BigInt(payload.item_subcategory_id)
|
|
: null,
|
|
brand_model: payload.brand_model || null,
|
|
manufacturer: payload.manufacturer || null,
|
|
serial_number: payload.serial_number || null,
|
|
part_number: payload.part_number || null,
|
|
location_id: BigInt(payload.location_id),
|
|
department_id: payload.department_id ? BigInt(payload.department_id) : null,
|
|
location_detail: payload.location_detail || null,
|
|
assigned_to_user_id: payload.assigned_to_user_id ? BigInt(payload.assigned_to_user_id) : null,
|
|
maintenance_incharge_user_id: payload.maintenance_incharge_user_id
|
|
? BigInt(payload.maintenance_incharge_user_id)
|
|
: null,
|
|
maintenance_frequency_in_days:
|
|
payload.maintenance_frequency_in_days !== undefined &&
|
|
payload.maintenance_frequency_in_days !== null &&
|
|
payload.maintenance_frequency_in_days !== ''
|
|
? Number(payload.maintenance_frequency_in_days)
|
|
: null,
|
|
maintenance_checklist_json:
|
|
payload.maintenance_checklist_json !== undefined ? checklistJson : null,
|
|
vendor_id: payload.vendor_id ? BigInt(payload.vendor_id) : null,
|
|
po_id: payload.po_id ? BigInt(payload.po_id) : null,
|
|
grn_id: payload.grn_id ? BigInt(payload.grn_id) : null,
|
|
grn_item_id: payload.grn_item_id ? BigInt(payload.grn_item_id) : null,
|
|
commencement_date: payload.commencement_date ? toDateOnly(payload.commencement_date) : null,
|
|
purchase_date: payload.purchase_date ? toDateOnly(payload.purchase_date) : null,
|
|
purchase_cost: purchaseCost,
|
|
useful_life_years: usefulLifeYears,
|
|
depreciation_method: depreciationMethod,
|
|
depreciation_rate: depreciationRate,
|
|
salvage_value: salvageValue,
|
|
warranty_expiry_date: payload.warranty_expiry_date
|
|
? toDateOnly(payload.warranty_expiry_date)
|
|
: null,
|
|
condition: payload.condition ?? (isCreate ? 'NEW' : undefined),
|
|
status,
|
|
qr_code_value: payload.qr_code_value || null,
|
|
disposal_date: disposalDate,
|
|
disposal_reason: payload.disposal_reason || null,
|
|
disposal_value: payload.disposal_value ?? null,
|
|
remarks: payload.remarks || null,
|
|
is_active: payload.is_active ?? (isCreate ? true : undefined),
|
|
category,
|
|
};
|
|
};
|
|
|
|
const getAssetOrThrow = async (id) => {
|
|
const asset = await prisma.assets.findFirst({
|
|
where: { id: BigInt(id), deleted_at: null },
|
|
include: assetDetailInclude,
|
|
});
|
|
if (!asset) throw new ApiError(404, 'Asset not found');
|
|
return asset;
|
|
};
|
|
|
|
const createAsset = async (payload, userId, requestId) => {
|
|
const normalized = await normalizeAssetPayload(payload, { isCreate: true });
|
|
const { category, ...data } = normalized;
|
|
void category;
|
|
|
|
const assetCode = await nextDocumentNumber(assetSeriesCode(normalized.category.code));
|
|
const created = await prisma.assets.create({
|
|
data: {
|
|
...data,
|
|
asset_code: assetCode,
|
|
created_by: userId ? BigInt(userId) : null,
|
|
updated_by: userId ? BigInt(userId) : null,
|
|
},
|
|
include: assetDetailInclude,
|
|
});
|
|
|
|
await auditLog({
|
|
tableName: 'assets',
|
|
recordId: created.id,
|
|
action: 'CREATE',
|
|
oldValue: null,
|
|
newValue: sanitizeAsset(created),
|
|
userId,
|
|
requestId,
|
|
});
|
|
|
|
return sanitizeAsset(created);
|
|
};
|
|
|
|
const buildAssetsWhere = (query) => ({
|
|
deleted_at: null,
|
|
...(query.status ? { status: query.status } : {}),
|
|
...(query.condition ? { condition: query.condition } : {}),
|
|
...(query.item_category_id ? { item_category_id: BigInt(query.item_category_id) } : {}),
|
|
...(query.item_subcategory_id
|
|
? { item_subcategory_id: BigInt(query.item_subcategory_id) }
|
|
: {}),
|
|
...(query.location_id ? { location_id: BigInt(query.location_id) } : {}),
|
|
...(query.department_id ? { department_id: BigInt(query.department_id) } : {}),
|
|
...(query.maintenance_incharge_user_id
|
|
? { maintenance_incharge_user_id: BigInt(query.maintenance_incharge_user_id) }
|
|
: {}),
|
|
...(query.is_active !== undefined ? { is_active: query.is_active } : {}),
|
|
...(query.search
|
|
? {
|
|
OR: [
|
|
{ asset_code: { contains: query.search, mode: 'insensitive' } },
|
|
{ asset_name: { contains: query.search, mode: 'insensitive' } },
|
|
{ serial_number: { contains: query.search, mode: 'insensitive' } },
|
|
{ qr_code_value: { contains: query.search, mode: 'insensitive' } },
|
|
],
|
|
}
|
|
: {}),
|
|
});
|
|
|
|
const listAssets = async (query) => {
|
|
const where = buildAssetsWhere(query);
|
|
|
|
if (isDropdownCall(query)) {
|
|
where.is_active = true;
|
|
const rows = await prisma.assets.findMany({
|
|
where,
|
|
include: assetInclude,
|
|
orderBy: { created_at: 'desc' },
|
|
});
|
|
let data = rows.map(sanitizeAsset);
|
|
if (query.due_only === true) {
|
|
data = data.filter((row) => row.maintenance?.is_due === true);
|
|
}
|
|
return { data, meta: { page: 1, limit: data.length, total: data.length, dropdown: true } };
|
|
}
|
|
|
|
if (query.due_only === true) {
|
|
const rows = await prisma.assets.findMany({
|
|
where,
|
|
include: assetInclude,
|
|
orderBy: { created_at: 'desc' },
|
|
});
|
|
const due = rows.map(sanitizeAsset).filter((row) => row.maintenance?.is_due === true);
|
|
const { page, limit, skip } = getPagination(query);
|
|
return {
|
|
data: due.slice(skip, skip + limit),
|
|
meta: { page, limit, total: due.length },
|
|
};
|
|
}
|
|
|
|
const { page, limit, skip } = getPagination(query);
|
|
|
|
const [rows, total] = await Promise.all([
|
|
prisma.assets.findMany({
|
|
where,
|
|
include: assetInclude,
|
|
orderBy: { created_at: 'desc' },
|
|
skip,
|
|
take: limit,
|
|
}),
|
|
prisma.assets.count({ where }),
|
|
]);
|
|
|
|
return { data: rows.map(sanitizeAsset), meta: { page, limit, total } };
|
|
};
|
|
|
|
const exportAssets = async (query) => {
|
|
const rows = await prisma.assets.findMany({
|
|
where: buildAssetsWhere(query),
|
|
include: assetInclude,
|
|
orderBy: { created_at: 'desc' },
|
|
});
|
|
|
|
return rowsToCsv(
|
|
[
|
|
{ key: 'asset_code', header: 'Asset Code' },
|
|
{ key: 'asset_name', header: 'Asset Name' },
|
|
{ key: (row) => row.item_category?.name || '', header: 'Category' },
|
|
{ key: (row) => row.item_subcategory?.name || '', header: 'Subcategory' },
|
|
{ key: 'serial_number', header: 'Serial Number' },
|
|
{ key: 'condition', header: 'Condition' },
|
|
{ key: 'status', header: 'Status' },
|
|
{ key: (row) => row.location?.name || '', header: 'Location' },
|
|
{ key: (row) => row.department?.name || '', header: 'Department' },
|
|
{ key: (row) => row.assigned_to_user?.full_name || '', header: 'Assigned To' },
|
|
{ key: (row) => row.vendor?.vendor_name || '', header: 'Vendor' },
|
|
{ key: (row) => row.purchase_order?.po_number || '', header: 'PO Number' },
|
|
{ key: (row) => row.grn?.grn_number || '', header: 'GRN Number' },
|
|
{ key: 'commencement_date', header: 'Commencement Date', type: 'date' },
|
|
{ key: 'purchase_date', header: 'Purchase Date', type: 'date' },
|
|
{ key: 'purchase_cost', header: 'Purchase Cost' },
|
|
{ key: 'current_value', header: 'Current Value' },
|
|
{ key: (row) => row.depreciation?.accumulated_depreciation ?? '', header: 'Accumulated Depreciation' },
|
|
{ key: (row) => row.depreciation?.annual_depreciation ?? '', header: 'Annual Depreciation' },
|
|
{ key: (row) => row.depreciation?.depreciation_method || '', header: 'Depreciation Method' },
|
|
{ key: (row) => row.depreciation?.depreciation_rate ?? '', header: 'Depreciation Rate %' },
|
|
{ key: 'warranty_expiry_date', header: 'Warranty Expiry', type: 'date' },
|
|
{
|
|
key: (row) => row.maintenance_incharge_user?.full_name || '',
|
|
header: 'Maintenance Incharge',
|
|
},
|
|
{ key: (row) => row.maintenance?.frequency_in_days ?? '', header: 'Maintenance Freq (days)' },
|
|
{ key: (row) => row.maintenance?.next_due_date || '', header: 'Maintenance Next Due', type: 'date' },
|
|
{ key: 'is_active', header: 'Active' },
|
|
{ key: 'created_at', header: 'Created At', type: 'datetime' },
|
|
],
|
|
rows.map(sanitizeAsset)
|
|
);
|
|
};
|
|
|
|
const getAssetById = async (id) => sanitizeAsset(await getAssetOrThrow(id));
|
|
|
|
const updateAsset = async (id, payload, userId, requestId) => {
|
|
const existing = await getAssetOrThrow(id);
|
|
const merged = {
|
|
asset_name: payload.asset_name ?? existing.asset_name,
|
|
item_category_id: payload.item_category_id ?? existing.item_category_id,
|
|
item_subcategory_id:
|
|
payload.item_subcategory_id !== undefined
|
|
? payload.item_subcategory_id
|
|
: existing.item_subcategory_id,
|
|
brand_model: payload.brand_model !== undefined ? payload.brand_model : existing.brand_model,
|
|
manufacturer: payload.manufacturer !== undefined ? payload.manufacturer : existing.manufacturer,
|
|
serial_number:
|
|
payload.serial_number !== undefined ? payload.serial_number : existing.serial_number,
|
|
part_number: payload.part_number !== undefined ? payload.part_number : existing.part_number,
|
|
location_id: payload.location_id ?? existing.location_id,
|
|
department_id:
|
|
payload.department_id !== undefined ? payload.department_id : existing.department_id,
|
|
location_detail:
|
|
payload.location_detail !== undefined ? payload.location_detail : existing.location_detail,
|
|
assigned_to_user_id:
|
|
payload.assigned_to_user_id !== undefined
|
|
? payload.assigned_to_user_id
|
|
: existing.assigned_to_user_id,
|
|
maintenance_incharge_user_id:
|
|
payload.maintenance_incharge_user_id !== undefined
|
|
? payload.maintenance_incharge_user_id
|
|
: existing.maintenance_incharge_user_id,
|
|
maintenance_frequency_in_days:
|
|
payload.maintenance_frequency_in_days !== undefined
|
|
? payload.maintenance_frequency_in_days
|
|
: existing.maintenance_frequency_in_days,
|
|
maintenance_checklist_json:
|
|
payload.maintenance_checklist_json !== undefined
|
|
? payload.maintenance_checklist_json
|
|
: existing.maintenance_checklist_json,
|
|
vendor_id: payload.vendor_id !== undefined ? payload.vendor_id : existing.vendor_id,
|
|
po_id: payload.po_id !== undefined ? payload.po_id : existing.po_id,
|
|
grn_id: payload.grn_id !== undefined ? payload.grn_id : existing.grn_id,
|
|
grn_item_id: payload.grn_item_id !== undefined ? payload.grn_item_id : existing.grn_item_id,
|
|
commencement_date:
|
|
payload.commencement_date !== undefined
|
|
? payload.commencement_date
|
|
: existing.commencement_date,
|
|
purchase_date:
|
|
payload.purchase_date !== undefined ? payload.purchase_date : existing.purchase_date,
|
|
purchase_cost: payload.purchase_cost ?? Number(existing.purchase_cost),
|
|
useful_life_years:
|
|
payload.useful_life_years !== undefined
|
|
? payload.useful_life_years
|
|
: existing.useful_life_years,
|
|
depreciation_method:
|
|
payload.depreciation_method !== undefined
|
|
? payload.depreciation_method
|
|
: existing.depreciation_method,
|
|
depreciation_rate:
|
|
payload.depreciation_rate !== undefined
|
|
? payload.depreciation_rate
|
|
: existing.depreciation_rate !== null && existing.depreciation_rate !== undefined
|
|
? Number(existing.depreciation_rate)
|
|
: null,
|
|
salvage_value: payload.salvage_value ?? Number(existing.salvage_value ?? 0),
|
|
warranty_expiry_date:
|
|
payload.warranty_expiry_date !== undefined
|
|
? payload.warranty_expiry_date
|
|
: existing.warranty_expiry_date,
|
|
condition: payload.condition ?? existing.condition,
|
|
status: payload.status ?? existing.status,
|
|
qr_code_value:
|
|
payload.qr_code_value !== undefined ? payload.qr_code_value : existing.qr_code_value,
|
|
disposal_date:
|
|
payload.disposal_date !== undefined ? payload.disposal_date : existing.disposal_date,
|
|
disposal_reason:
|
|
payload.disposal_reason !== undefined ? payload.disposal_reason : existing.disposal_reason,
|
|
disposal_value:
|
|
payload.disposal_value !== undefined ? payload.disposal_value : existing.disposal_value,
|
|
remarks: payload.remarks !== undefined ? payload.remarks : existing.remarks,
|
|
is_active: payload.is_active !== undefined ? payload.is_active : existing.is_active,
|
|
};
|
|
|
|
const normalized = await normalizeAssetPayload(merged);
|
|
const { category, ...data } = normalized;
|
|
void category;
|
|
|
|
const updated = await prisma.assets.update({
|
|
where: { id: BigInt(id) },
|
|
data: {
|
|
...data,
|
|
updated_by: userId ? BigInt(userId) : null,
|
|
},
|
|
include: assetDetailInclude,
|
|
});
|
|
|
|
await auditLog({
|
|
tableName: 'assets',
|
|
recordId: id,
|
|
action: 'UPDATE',
|
|
oldValue: sanitizeAsset(existing),
|
|
newValue: sanitizeAsset(updated),
|
|
userId,
|
|
requestId,
|
|
});
|
|
|
|
return sanitizeAsset(updated);
|
|
};
|
|
|
|
const deleteAsset = async (id, userId, requestId) => {
|
|
const existing = await getAssetOrThrow(id);
|
|
if (DISPOSAL_STATUSES.includes(existing.status)) {
|
|
throw new ApiError(409, 'Disposed or scrapped assets cannot be deleted');
|
|
}
|
|
|
|
const deleted = await prisma.assets.update({
|
|
where: { id: BigInt(id) },
|
|
data: {
|
|
deleted_at: new Date(),
|
|
is_active: false,
|
|
updated_by: userId ? BigInt(userId) : null,
|
|
},
|
|
});
|
|
|
|
await auditLog({
|
|
tableName: 'assets',
|
|
recordId: id,
|
|
action: 'DELETE',
|
|
oldValue: sanitizeAsset(existing),
|
|
newValue: deleted,
|
|
userId,
|
|
requestId,
|
|
});
|
|
};
|
|
|
|
const transferAsset = async (id, payload, userId, requestId) => {
|
|
const existing = await getAssetOrThrow(id);
|
|
if (DISPOSAL_STATUSES.includes(existing.status)) {
|
|
throw new ApiError(409, 'Cannot transfer disposed or scrapped assets');
|
|
}
|
|
|
|
if (payload.to_location_id) await assertAnyLocation(payload.to_location_id, 'to_location_id');
|
|
if (payload.to_department_id) {
|
|
await assertReference('departments', payload.to_department_id, 'to_department_id');
|
|
}
|
|
if (payload.to_user_id) await assertReference('users', payload.to_user_id, 'to_user_id');
|
|
|
|
const transfer = {
|
|
transfer_date: toDateOnly(payload.transfer_date),
|
|
from_location_id: existing.location_id,
|
|
to_location_id: payload.to_location_id ? BigInt(payload.to_location_id) : null,
|
|
from_department_id: existing.department_id,
|
|
to_department_id: payload.to_department_id ? BigInt(payload.to_department_id) : null,
|
|
from_user_id: existing.assigned_to_user_id,
|
|
to_user_id: payload.to_user_id ? BigInt(payload.to_user_id) : null,
|
|
reason: payload.reason || null,
|
|
};
|
|
|
|
const updates = {
|
|
...(payload.to_location_id ? { location_id: BigInt(payload.to_location_id) } : {}),
|
|
...(payload.to_department_id !== undefined
|
|
? { department_id: payload.to_department_id ? BigInt(payload.to_department_id) : null }
|
|
: {}),
|
|
...(payload.to_user_id !== undefined
|
|
? { assigned_to_user_id: payload.to_user_id ? BigInt(payload.to_user_id) : null }
|
|
: {}),
|
|
};
|
|
|
|
const { transferRow } = await repository.transferAsset({
|
|
assetId: id,
|
|
transfer,
|
|
updates,
|
|
userId,
|
|
});
|
|
|
|
const detail = await prisma.asset_transfers.findFirst({
|
|
where: { id: transferRow.id },
|
|
include: transferInclude,
|
|
});
|
|
|
|
const updatedAsset = await getAssetOrThrow(id);
|
|
|
|
await auditLog({
|
|
tableName: 'asset_transfers',
|
|
recordId: transferRow.id,
|
|
action: 'TRANSFER',
|
|
oldValue: sanitizeAsset(existing),
|
|
newValue: { transfer: sanitizeTransfer(detail), asset: sanitizeAsset(updatedAsset) },
|
|
userId,
|
|
requestId,
|
|
});
|
|
|
|
return {
|
|
asset: sanitizeAsset(updatedAsset),
|
|
transfer: sanitizeTransfer(detail),
|
|
};
|
|
};
|
|
|
|
const getTransferHistory = async (id) => {
|
|
await getAssetOrThrow(id);
|
|
const rows = await prisma.asset_transfers.findMany({
|
|
where: { asset_id: BigInt(id) },
|
|
include: transferInclude,
|
|
orderBy: [{ transfer_date: 'desc' }, { created_at: 'desc' }],
|
|
});
|
|
return rows.map(sanitizeTransfer);
|
|
};
|
|
|
|
const listDepreciationMethods = () => DEPRECIATION_METHOD_OPTIONS;
|
|
|
|
const listContractTypes = () => getAssetDropdownOptions().contract_types;
|
|
const listVisitTypes = () => getAssetDropdownOptions().visit_types;
|
|
const listPolicyTypes = () => getAssetDropdownOptions().policy_types;
|
|
const listAssetConditions = () => getAssetDropdownOptions().asset_conditions;
|
|
const listAssetStatuses = () => getAssetDropdownOptions().asset_statuses;
|
|
const listPaymentFrequencies = () => getAssetDropdownOptions().payment_frequencies;
|
|
const listServiceFrequencies = () => getAssetDropdownOptions().service_frequencies;
|
|
const listVisitStatuses = () => getAssetDropdownOptions().visit_statuses;
|
|
const listVisitConditionsAfter = () => getAssetDropdownOptions().visit_conditions_after;
|
|
const listAssetOptions = () => getAssetDropdownOptions();
|
|
|
|
const previewDepreciation = (payload) => {
|
|
if (payload.depreciation_method === 'OTHER' && (payload.depreciation_rate === undefined || payload.depreciation_rate === null)) {
|
|
throw new ApiError(422, 'depreciation_rate is required when depreciation_method is OTHER');
|
|
}
|
|
return calculateDepreciation(payload);
|
|
};
|
|
|
|
module.exports = {
|
|
createAsset,
|
|
listAssets,
|
|
exportAssets,
|
|
getAssetById,
|
|
updateAsset,
|
|
deleteAsset,
|
|
transferAsset,
|
|
getTransferHistory,
|
|
listDepreciationMethods,
|
|
listContractTypes,
|
|
listVisitTypes,
|
|
listPolicyTypes,
|
|
listAssetConditions,
|
|
listAssetStatuses,
|
|
listPaymentFrequencies,
|
|
listServiceFrequencies,
|
|
listVisitStatuses,
|
|
listVisitConditionsAfter,
|
|
listAssetOptions,
|
|
previewDepreciation,
|
|
sanitizeAsset,
|
|
};
|