diff --git a/docs/MODULE_DATA_FLOW.html b/docs/MODULE_DATA_FLOW.html index 8197f48..dee5fbb 100644 --- a/docs/MODULE_DATA_FLOW.html +++ b/docs/MODULE_DATA_FLOW.html @@ -747,7 +747,7 @@ flowchart TB V2[plant / dept / warehouse / user refs] V3[vendor / PO / GRN / grn_item refs] V4[disposal_date required if DISPOSED/SCRAPPED] - V5[depreciation_rate required if method=OTHER] + V5[depreciation_rate required if method=CUSTOM] V6[resolve rate from category defaults] end @@ -888,7 +888,7 @@ flowchart TD subgraph Methods["depreciation_method"] SLM[SLM Straight Line] WDV[WDV Written Down Value] - OTHER[OTHER manual rate required] + CUSTOM[CUSTOM manual rate required] end PREVIEW --> CALC[calculateDepreciation] @@ -900,7 +900,7 @@ flowchart TD SLM --> CALC WDV --> CALC - OTHER --> CALC + CUSTOM --> CALC @@ -909,12 +909,13 @@ flowchart TD FieldMeaningRole in formula purchase_costAsset cost / amountStarting book value; base for rate & annual dep - salvage_valueResidual value at end of lifeFloor for book value; used in rate auto-calc + salvage_valueResidual value at end of life (amount)Floor for book value; used in rate auto-calc + salvage_percentageResidual as % of purchase_cost (0–100)Synced with amount; FE may send either useful_life_yearsExpected life in yearsRate auto-calc + cap on years elapsed commencement_dateUsage / put-to-use datePrimary start date for years elapsed purchase_datePurchase dateFallback start date if commencement is null - depreciation_methodSLM / WDV / OTHERWhich formula path to run - depreciation_rate% per yearOptional for SLM/WDV (auto); required for OTHER + depreciation_methodSLM / WDV / CUSTOMWhich formula path to run + depreciation_rate% per yearOptional for SLM/WDV (auto); required for CUSTOM as_of_dateCalculate as of (preview only)Defaults to today @@ -971,7 +972,7 @@ annual_depreciation = book_value × rate / 100 // next year's dep on curr
  • Next annual (on WDV) ≈ 79430 × 20.57% ≈ ₹16,340
  • -

    OTHER method

    +

    CUSTOM method

    Same accumulation style as SLM, but depreciation_rate is mandatory (no auto-calc).

    @@ -979,7 +980,7 @@ annual_depreciation = book_value × rate / 100 // next year's dep on curr - +
    SLMYes (if rate omitted)Original purchase_cost every yearIn rate formula + book-value floor
    WDVYes (if rate omitted)Reducing book value each yearIn rate formula + book-value floor
    OTHERNo — send depreciation_rateOriginal cost × rate (like SLM)Book-value floor only
    CUSTOMNo — send depreciation_rateOriginal cost × rate (like SLM)Book-value floor only
    diff --git a/docs/MODULE_DATA_FLOW.md b/docs/MODULE_DATA_FLOW.md index 6b4a66b..6b67c53 100644 --- a/docs/MODULE_DATA_FLOW.md +++ b/docs/MODULE_DATA_FLOW.md @@ -353,7 +353,7 @@ flowchart TB V2[location / dept / user refs] V3[vendor / PO / GRN / grn_item refs] V4[disposal_date required if DISPOSED/SCRAPPED] - V5[depreciation_rate required if method=OTHER] + V5[depreciation_rate required if method=CUSTOM] V6[resolve rate from category defaults] end @@ -497,7 +497,7 @@ flowchart TD subgraph Methods["depreciation_method"] SLM[SLM Straight Line] WDV[WDV Written Down Value] - OTHER[OTHER manual rate required] + CUSTOM[CUSTOM manual rate required] end PREVIEW --> CALC[calculateDepreciation] @@ -509,7 +509,7 @@ flowchart TD SLM --> CALC WDV --> CALC - OTHER --> CALC + CUSTOM --> CALC ``` ### Input fields used in calculation @@ -517,12 +517,13 @@ flowchart TD | Field | Meaning | Role in formula | |---|---|---| | `purchase_cost` | Asset cost / amount | Starting book value; base for rate & annual dep | -| `salvage_value` | Residual value at end of life | Floor for book value; used in rate auto-calc | +| `salvage_value` | Residual value at end of life (amount) | Floor for book value; used in rate auto-calc | +| `salvage_percentage` | Residual as % of purchase_cost (0–100) | Synced with amount; FE may send either | | `useful_life_years` | Expected life in years | Rate auto-calc + cap on years elapsed | | `commencement_date` | Usage / put-to-use date | **Primary** start date for years elapsed | | `purchase_date` | Purchase date | Fallback start date if commencement is null | -| `depreciation_method` | `SLM` / `WDV` / `OTHER` | Which formula path to run | -| `depreciation_rate` | % per year | Optional for SLM/WDV (auto); required for OTHER | +| `depreciation_method` | `SLM` / `WDV` / `CUSTOM` | Which formula path to run | +| `depreciation_rate` | % per year | Optional for SLM/WDV (auto); required for CUSTOM | | `as_of_date` | Calculate as of (preview only) | Defaults to today | ### Date → years elapsed @@ -594,7 +595,7 @@ annual_depreciation = book_value × rate / 100 // next year's dep on curr - Accumulated ≈ **₹20,570** - Next annual (on WDV) ≈ 79430 × 20.57% ≈ **₹16,340** -### OTHER method +### CUSTOM method Same accumulation style as SLM, but `depreciation_rate` is **mandatory** (no auto-calc). @@ -602,7 +603,7 @@ Same accumulation style as SLM, but `depreciation_rate` is **mandatory** (no aut |---|---|---|---| | **SLM** | Yes (if rate omitted) | Original `purchase_cost` every year | In rate formula + book-value floor | | **WDV** | Yes (if rate omitted) | Reducing book value each year | In rate formula + book-value floor | -| **OTHER** | No — send `depreciation_rate` | Original cost × rate (like SLM) | Book-value floor only | +| **CUSTOM** | No — send `depreciation_rate` | Original cost × rate (like SLM) | Book-value floor only | **FE usage:** diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6eb71b1..47b3ab6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -189,6 +189,7 @@ model assets { depreciation_method String? @db.VarChar(20) depreciation_rate Decimal? @db.Decimal(8, 4) salvage_value Decimal? @default(0) @db.Decimal(15, 4) + salvage_percentage Decimal? @default(0) @db.Decimal(8, 4) warranty_expiry_date DateTime? @db.Date condition String @default("NEW") @db.VarChar(20) status String @default("IN_USE") @db.VarChar(30) diff --git a/scripts/patch-assets-salvage-percentage.sql b/scripts/patch-assets-salvage-percentage.sql new file mode 100644 index 0000000..73899fe --- /dev/null +++ b/scripts/patch-assets-salvage-percentage.sql @@ -0,0 +1,27 @@ +-- Rename depreciation method OTHER → CUSTOM; add salvage_percentage +-- Run on each environment after deploy. + +ALTER TABLE assets + ADD COLUMN IF NOT EXISTS salvage_percentage DECIMAL(8, 4) DEFAULT 0; + +COMMENT ON COLUMN assets.salvage_percentage IS 'Salvage as % of purchase_cost (0–100). Synced with salvage_value.'; +COMMENT ON COLUMN assets.depreciation_method IS 'SLM, WDV, CUSTOM'; + +UPDATE assets +SET depreciation_method = 'CUSTOM' +WHERE depreciation_method = 'OTHER' + AND deleted_at IS NULL; + +UPDATE assets +SET salvage_percentage = ROUND( + (COALESCE(salvage_value, 0) / purchase_cost) * 100, + 4 +) +WHERE purchase_cost > 0 + AND (salvage_percentage IS NULL OR salvage_percentage = 0) + AND COALESCE(salvage_value, 0) > 0 + AND deleted_at IS NULL; + +UPDATE item_categories +SET default_depreciation_method = 'CUSTOM' +WHERE default_depreciation_method = 'OTHER'; diff --git a/src/docs/assets-routes.yaml b/src/docs/assets-routes.yaml index 8ed260d..99c69fe 100644 --- a/src/docs/assets-routes.yaml +++ b/src/docs/assets-routes.yaml @@ -38,9 +38,10 @@ components: purchase_date: { type: string, format: date, nullable: true } purchase_cost: { type: number, example: 850000 } useful_life_years: { type: integer, example: 10 } - depreciation_method: { type: string, enum: [SLM, WDV, OTHER], example: SLM } - depreciation_rate: { type: number, nullable: true, example: 10, description: 'Required for OTHER; auto-calculated for SLM/WDV when omitted' } - salvage_value: { type: number, example: 50000 } + depreciation_method: { type: string, enum: [SLM, WDV, CUSTOM], example: SLM } + depreciation_rate: { type: number, nullable: true, example: 10, description: 'Required for CUSTOM; auto-calculated for SLM/WDV when omitted' } + salvage_value: { type: number, example: 50000, description: 'Salvage amount. Send this OR salvage_percentage (not both required).' } + salvage_percentage: { type: number, example: 5, description: 'Salvage as % of purchase_cost (0–100). If both sent, percentage wins and amount is derived.' } warranty_expiry_date: { type: string, format: date, nullable: true } condition: { type: string, enum: [NEW, GOOD, FAIR, POOR] } status: { type: string, enum: [IN_USE, IDLE, UNDER_MAINTENANCE, DISPOSED, SCRAPPED] } @@ -84,9 +85,10 @@ components: purchase_date: { type: string, format: date, nullable: true } purchase_cost: { type: number } useful_life_years: { type: integer } - depreciation_method: { type: string, enum: [SLM, WDV, OTHER] } + depreciation_method: { type: string, enum: [SLM, WDV, CUSTOM] } depreciation_rate: { type: number, nullable: true } salvage_value: { type: number } + salvage_percentage: { type: number, description: 'Salvage as % of purchase_cost (0–100)' } warranty_expiry_date: { type: string, format: date, nullable: true } condition: { type: string, enum: [NEW, GOOD, FAIR, POOR] } status: { type: string, enum: [IN_USE, IDLE, UNDER_MAINTENANCE, DISPOSED, SCRAPPED] } @@ -100,10 +102,11 @@ components: type: object required: [depreciation_method] properties: - depreciation_method: { type: string, enum: [SLM, WDV, OTHER] } + depreciation_method: { type: string, enum: [SLM, WDV, CUSTOM] } depreciation_rate: { type: number, nullable: true } purchase_cost: { type: number, example: 850000 } salvage_value: { type: number, example: 50000 } + salvage_percentage: { type: number, example: 5, description: 'Optional; if sent with/without salvage_value, % wins when both present' } useful_life_years: { type: integer, example: 10 } commencement_date: { type: string, format: date, description: 'Preferred depreciation start date' } purchase_date: { type: string, format: date } @@ -418,7 +421,7 @@ paths: /assets/depreciation-methods: get: tags: [Assets] - summary: List depreciation method dropdown options (SLM, WDV, OTHER) + summary: List depreciation method dropdown options (SLM, WDV, CUSTOM) responses: '200': description: Depreciation methods fetched diff --git a/src/docs/completed-routes.yaml b/src/docs/completed-routes.yaml index 5074136..7dfefe2 100644 --- a/src/docs/completed-routes.yaml +++ b/src/docs/completed-routes.yaml @@ -116,7 +116,7 @@ components: category_type: { type: string, enum: [STOCK, ASSET], example: STOCK, description: 'STOCK categories are shown in the Items module; ASSET categories in the Assets module' } code_prefix: { type: string, nullable: true, example: 'MCH', description: Optional; used for asset code series when category is used for assets } default_useful_life_years: { type: integer, nullable: true, example: 15 } - default_depreciation_method: { type: string, nullable: true, enum: [SLM, WDV, OTHER], example: SLM } + default_depreciation_method: { type: string, nullable: true, enum: [SLM, WDV, CUSTOM], example: SLM } is_active: { type: boolean, example: true } ItemCategoriesUpdateBody: type: object @@ -127,7 +127,7 @@ components: category_type: { type: string, enum: [STOCK, ASSET], example: STOCK } code_prefix: { type: string, nullable: true, example: 'MCH' } default_useful_life_years: { type: integer, nullable: true, example: 15 } - default_depreciation_method: { type: string, nullable: true, enum: [SLM, WDV, OTHER], example: SLM } + default_depreciation_method: { type: string, nullable: true, enum: [SLM, WDV, CUSTOM], example: SLM } is_active: { type: boolean, example: true } ItemSubcategoriesCreateBody: type: object diff --git a/src/docs/reports-routes.yaml b/src/docs/reports-routes.yaml index 4cb2f06..00821d2 100644 --- a/src/docs/reports-routes.yaml +++ b/src/docs/reports-routes.yaml @@ -142,7 +142,7 @@ paths: schema: { type: string } - in: query name: depreciation_method - schema: { type: string, enum: [SLM, WDV, OTHER] } + schema: { type: string, enum: [SLM, WDV, CUSTOM] } - in: query name: item_category_id schema: { type: integer } @@ -197,7 +197,7 @@ paths: schema: { type: string } - in: query name: depreciation_method - schema: { type: string, enum: [SLM, WDV, OTHER] } + schema: { type: string, enum: [SLM, WDV, CUSTOM] } - in: query name: item_category_id schema: { type: integer } diff --git a/src/modules/assets/assets.constants.js b/src/modules/assets/assets.constants.js index 9c48714..bdca53e 100644 --- a/src/modules/assets/assets.constants.js +++ b/src/modules/assets/assets.constants.js @@ -16,7 +16,7 @@ const ASSET_STATUS_OPTIONS = toOptions([ ['SCRAPPED', 'Scrapped'], ]); -const DEPRECIATION_METHOD_VALUES = ['SLM', 'WDV', 'OTHER']; +const DEPRECIATION_METHOD_VALUES = ['SLM', 'WDV', 'CUSTOM']; const AMC_CONTRACT_TYPE_OPTIONS = toOptions([ ['COMPREHENSIVE', 'Comprehensive'], diff --git a/src/modules/assets/assets.depreciation.js b/src/modules/assets/assets.depreciation.js index ab08bfa..64871d5 100644 --- a/src/modules/assets/assets.depreciation.js +++ b/src/modules/assets/assets.depreciation.js @@ -14,12 +14,57 @@ const DEPRECIATION_METHOD_OPTIONS = [ 'Depreciation on reducing book value. Rate auto-calculated from cost, salvage and useful life when not provided.', }, { - value: 'OTHER', - label: 'Other', + value: 'CUSTOM', + label: 'Custom', description: 'Custom method. Depreciation rate must be entered manually.', }, ]; +/** Map legacy OTHER → CUSTOM for reads/writes. */ +const normalizeDepreciationMethod = (method) => { + if (method === 'OTHER') return 'CUSTOM'; + return method || null; +}; + +/** + * Resolve salvage amount and % from either input. + * - If only salvage_percentage → derive salvage_value from purchase_cost + * - If only salvage_value → derive salvage_percentage + * - If both → prefer salvage_percentage and recompute salvage_value + * - If neither → both 0 + */ +const resolveSalvageFields = ({ + purchase_cost: cost, + salvage_value: salvageAmount, + salvage_percentage: salvagePct, +} = {}) => { + const purchaseCost = Number(cost || 0); + const hasPct = salvagePct !== undefined && salvagePct !== null && salvagePct !== ''; + const hasAmount = + salvageAmount !== undefined && salvageAmount !== null && salvageAmount !== ''; + + let salvagePercentage = 0; + let salvageValue = 0; + + if (hasPct && !hasAmount) { + salvagePercentage = round4(Math.min(Math.max(Number(salvagePct), 0), 100)); + salvageValue = purchaseCost > 0 ? round4((purchaseCost * salvagePercentage) / 100) : 0; + } else if (hasAmount && !hasPct) { + salvageValue = round4(Math.max(Number(salvageAmount), 0)); + salvagePercentage = + purchaseCost > 0 ? round4(Math.min((salvageValue / purchaseCost) * 100, 100)) : 0; + } else if (hasPct && hasAmount) { + // Both sent: percentage is source of truth (FE amount/% toggle) + salvagePercentage = round4(Math.min(Math.max(Number(salvagePct), 0), 100)); + salvageValue = purchaseCost > 0 ? round4((purchaseCost * salvagePercentage) / 100) : 0; + } + + return { + salvage_value: salvageValue, + salvage_percentage: salvagePercentage, + }; +}; + const yearsElapsed = (purchaseDate, asOfDate = new Date()) => { if (!purchaseDate) return 0; const start = new Date(purchaseDate); @@ -43,16 +88,17 @@ const resolveDepreciationRate = ({ const purchaseCost = Number(cost || 0); const salvageValue = Number(salvage || 0); const years = Number(lifeYears || 0); + const normalizedMethod = normalizeDepreciationMethod(method); - if (!method || method === 'OTHER' || years <= 0) return null; + if (!normalizedMethod || normalizedMethod === 'CUSTOM' || years <= 0) return null; - if (method === 'SLM') { + if (normalizedMethod === 'SLM') { if (purchaseCost <= 0) return round4(100 / years); const depreciable = Math.max(purchaseCost - salvageValue, 0); return round4((depreciable / purchaseCost / years) * 100); } - if (method === 'WDV') { + if (normalizedMethod === 'WDV') { if (purchaseCost <= 0 || salvageValue <= 0 || salvageValue >= purchaseCost) return null; return round4((1 - (salvageValue / purchaseCost) ** (1 / years)) * 100); } @@ -65,15 +111,23 @@ const calculateDepreciation = ({ depreciation_rate: rateInput, purchase_cost: cost, salvage_value: salvage, + salvage_percentage: salvagePct, useful_life_years: lifeYears, commencement_date: commencementDate, purchase_date: purchaseDate, as_of_date: asOfDate, } = {}) => { const purchaseCost = Number(cost || 0); - const salvageValue = Number(salvage || 0); + const { salvage_value: salvageValue, salvage_percentage: salvagePercentage } = + resolveSalvageFields({ + purchase_cost: purchaseCost, + salvage_value: salvage, + salvage_percentage: salvagePct, + }); + + const normalizedMethod = normalizeDepreciationMethod(method); const rate = resolveDepreciationRate({ - method, + method: normalizedMethod, depreciation_rate: rateInput, purchase_cost: purchaseCost, salvage_value: salvageValue, @@ -91,12 +145,12 @@ const calculateDepreciation = ({ let accumulatedDepreciation = 0; let bookValue = purchaseCost; - if (method === 'SLM' && rate !== null) { + if (normalizedMethod === 'SLM' && rate !== null) { annualDepreciation = round4((purchaseCost * rate) / 100); const maxDepreciable = Math.max(purchaseCost - salvageValue, 0); accumulatedDepreciation = round4(Math.min(annualDepreciation * cappedYears, maxDepreciable)); bookValue = round4(Math.max(purchaseCost - accumulatedDepreciation, salvageValue)); - } else if (method === 'WDV' && rate !== null) { + } else if (normalizedMethod === 'WDV' && rate !== null) { annualDepreciation = round4((purchaseCost * rate) / 100); bookValue = purchaseCost; const fullYears = Math.floor(cappedYears); @@ -116,7 +170,7 @@ const calculateDepreciation = ({ } annualDepreciation = round4((bookValue * rate) / 100); - } else if (method === 'OTHER' && rate !== null) { + } else if (normalizedMethod === 'CUSTOM' && rate !== null) { annualDepreciation = round4((purchaseCost * rate) / 100); const maxDepreciable = Math.max(purchaseCost - salvageValue, 0); accumulatedDepreciation = round4(Math.min(annualDepreciation * cappedYears, maxDepreciable)); @@ -124,7 +178,7 @@ const calculateDepreciation = ({ } return { - depreciation_method: method || null, + depreciation_method: normalizedMethod, depreciation_rate: rate, annual_depreciation: annualDepreciation, accumulated_depreciation: accumulatedDepreciation, @@ -132,6 +186,7 @@ const calculateDepreciation = ({ years_elapsed: elapsed, purchase_cost: purchaseCost, salvage_value: salvageValue, + salvage_percentage: salvagePercentage, useful_life_years: lifeYears ?? null, depreciation_start_date: depreciationStartDate || null, }; @@ -140,6 +195,8 @@ const calculateDepreciation = ({ module.exports = { DEPRECIATION_METHOD_OPTIONS, resolveDepreciationRate, + resolveSalvageFields, + normalizeDepreciationMethod, calculateDepreciation, yearsElapsed, round4, diff --git a/src/modules/assets/assets.service.js b/src/modules/assets/assets.service.js index e97d60c..e7a2940 100644 --- a/src/modules/assets/assets.service.js +++ b/src/modules/assets/assets.service.js @@ -10,6 +10,8 @@ const repository = require('./assets.repository'); const { DEPRECIATION_METHOD_OPTIONS, resolveDepreciationRate, + resolveSalvageFields, + normalizeDepreciationMethod, calculateDepreciation, } = require('./assets.depreciation'); const { assertReference, toDateOnly, normalizeChecklistTemplate, presentChecklistTemplate } = require('./assets.helpers'); @@ -143,13 +145,24 @@ const sanitizeAsset = (asset) => { rest.salvage_value !== null && rest.salvage_value !== undefined ? Number(rest.salvage_value) : 0; + let salvagePercentage = + rest.salvage_percentage !== null && rest.salvage_percentage !== undefined + ? Number(rest.salvage_percentage) + : null; + if (salvagePercentage === null) { + salvagePercentage = + purchaseCost > 0 ? Math.round((salvageValue / purchaseCost) * 10000) / 10000 : 0; + } const depreciationRate = rest.depreciation_rate !== null && rest.depreciation_rate !== undefined ? Number(rest.depreciation_rate) : null; + const depreciationMethod = normalizeDepreciationMethod(rest.depreciation_method); + + // Use stored salvage_value for calc (do not re-derive from % on read) const depreciation = calculateDepreciation({ - depreciation_method: rest.depreciation_method, + depreciation_method: depreciationMethod, depreciation_rate: depreciationRate, purchase_cost: purchaseCost, salvage_value: salvageValue, @@ -163,6 +176,8 @@ const sanitizeAsset = (asset) => { maintenance_checklist_json: presentChecklistTemplate(rest.maintenance_checklist_json), purchase_cost: purchaseCost, salvage_value: salvageValue, + salvage_percentage: salvagePercentage, + depreciation_method: depreciationMethod, depreciation_rate: depreciationRate, // Current written-down / book value after depreciation (same as depreciation.book_value) current_value: depreciation.book_value, @@ -286,16 +301,27 @@ const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => { assertDisposalFields(status, disposalDate); const purchaseCost = payload.purchase_cost ?? 0; - const salvageValue = payload.salvage_value ?? 0; + const { salvage_value: salvageValue, salvage_percentage: salvagePercentage } = + resolveSalvageFields({ + purchase_cost: purchaseCost, + salvage_value: payload.salvage_value, + salvage_percentage: payload.salvage_percentage, + }); 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; + const depreciationMethod = normalizeDepreciationMethod( + 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'); + if ( + depreciationMethod === 'CUSTOM' && + (payload.depreciation_rate === undefined || + payload.depreciation_rate === null || + payload.depreciation_rate === '') + ) { + throw new ApiError(422, 'depreciation_rate is required when depreciation_method is CUSTOM'); } const depreciationRate = resolveDepreciationRate({ @@ -342,6 +368,7 @@ const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => { depreciation_method: depreciationMethod, depreciation_rate: depreciationRate, salvage_value: salvageValue, + salvage_percentage: salvagePercentage, warranty_expiry_date: payload.warranty_expiry_date ? toDateOnly(payload.warranty_expiry_date) : null, @@ -498,6 +525,8 @@ const exportAssets = async (query) => { { 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: 'salvage_value', header: 'Salvage Value' }, + { key: 'salvage_percentage', header: 'Salvage %' }, { key: 'warranty_expiry_date', header: 'Warranty Expiry', type: 'date' }, { key: (row) => row.maintenance_incharge_user?.full_name || '', @@ -574,7 +603,20 @@ const updateAsset = async (id, payload, userId, requestId) => { : existing.depreciation_rate !== null && existing.depreciation_rate !== undefined ? Number(existing.depreciation_rate) : null, - salvage_value: payload.salvage_value ?? Number(existing.salvage_value ?? 0), + salvage_value: + payload.salvage_value !== undefined + ? payload.salvage_value + : payload.salvage_percentage !== undefined + ? undefined + : Number(existing.salvage_value ?? 0), + salvage_percentage: + payload.salvage_percentage !== undefined + ? payload.salvage_percentage + : payload.salvage_value !== undefined + ? undefined + : existing.salvage_percentage !== null && existing.salvage_percentage !== undefined + ? Number(existing.salvage_percentage) + : undefined, warranty_expiry_date: payload.warranty_expiry_date !== undefined ? payload.warranty_expiry_date @@ -732,10 +774,14 @@ const listVisitConditionsAfter = () => getAssetDropdownOptions().visit_condition 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'); + const method = normalizeDepreciationMethod(payload.depreciation_method); + if ( + method === 'CUSTOM' && + (payload.depreciation_rate === undefined || payload.depreciation_rate === null) + ) { + throw new ApiError(422, 'depreciation_rate is required when depreciation_method is CUSTOM'); } - return calculateDepreciation(payload); + return calculateDepreciation({ ...payload, depreciation_method: method }); }; module.exports = { diff --git a/src/modules/assets/assets.validation.js b/src/modules/assets/assets.validation.js index f7700af..7b75c45 100644 --- a/src/modules/assets/assets.validation.js +++ b/src/modules/assets/assets.validation.js @@ -57,11 +57,12 @@ const assetFields = { purchase_cost: Joi.number().min(0).default(0), useful_life_years: Joi.number().integer().min(0).allow(null).optional(), depreciation_method: Joi.string() - .valid(...DEPRECIATION_METHODS) + .valid(...DEPRECIATION_METHODS, 'OTHER') // OTHER accepted as alias → stored as CUSTOM .allow(null) .optional(), depreciation_rate: Joi.number().min(0).max(100).allow(null).optional(), salvage_value: Joi.number().min(0).allow(null).optional(), + salvage_percentage: Joi.number().min(0).max(100).allow(null).optional(), warranty_expiry_date: Joi.date().iso().allow(null).optional(), condition: Joi.string() .valid(...ASSET_CONDITIONS) @@ -105,6 +106,7 @@ const updateAssetSchema = Joi.object({ depreciation_method: assetFields.depreciation_method, depreciation_rate: assetFields.depreciation_rate, salvage_value: assetFields.salvage_value, + salvage_percentage: assetFields.salvage_percentage, warranty_expiry_date: assetFields.warranty_expiry_date, condition: assetFields.condition.optional(), status: assetFields.status.optional(), @@ -304,11 +306,12 @@ const renewInsurancePolicySchema = Joi.object({ const depreciationCalculateSchema = Joi.object({ depreciation_method: Joi.string() - .valid(...DEPRECIATION_METHODS) + .valid(...DEPRECIATION_METHODS, 'OTHER') .required(), depreciation_rate: Joi.number().min(0).max(100).allow(null).optional(), purchase_cost: Joi.number().min(0).default(0), - salvage_value: Joi.number().min(0).default(0), + salvage_value: Joi.number().min(0).allow(null).optional(), + salvage_percentage: Joi.number().min(0).max(100).allow(null).optional(), useful_life_years: Joi.number().integer().min(0).allow(null).optional(), commencement_date: Joi.date().iso().allow(null).optional(), purchase_date: Joi.date().iso().allow(null).optional(), diff --git a/src/modules/masters/item-categories/item-categories.service.js b/src/modules/masters/item-categories/item-categories.service.js index 1225951..8e71566 100644 --- a/src/modules/masters/item-categories/item-categories.service.js +++ b/src/modules/masters/item-categories/item-categories.service.js @@ -1,4 +1,5 @@ const { buildMasterService } = require('../_shared/master.factory'); +const { normalizeDepreciationMethod } = require('../../assets/assets.depreciation'); const emptyToNull = (value) => (value === '' ? null : value); @@ -75,7 +76,9 @@ const normalizeAssetDefaults = (payload) => { out.code_prefix = emptyToNull(out.code_prefix); } if (Object.prototype.hasOwnProperty.call(out, 'default_depreciation_method')) { - out.default_depreciation_method = emptyToNull(out.default_depreciation_method); + out.default_depreciation_method = normalizeDepreciationMethod( + emptyToNull(out.default_depreciation_method) + ); } return out; }; diff --git a/src/modules/masters/item-categories/item-categories.validation.js b/src/modules/masters/item-categories/item-categories.validation.js index 228ec19..1c8431f 100644 --- a/src/modules/masters/item-categories/item-categories.validation.js +++ b/src/modules/masters/item-categories/item-categories.validation.js @@ -13,7 +13,7 @@ const createSchema = Joi.object({ code_prefix: masterCode({ max: 10 }).allow(null, ''), default_useful_life_years: Joi.number().integer().min(0).allow(null).optional(), default_depreciation_method: Joi.string() - .valid(...DEPRECIATION_METHODS) + .valid(...DEPRECIATION_METHODS, 'OTHER') .allow(null, '') .optional(), is_active: Joi.boolean().optional(), @@ -29,7 +29,7 @@ const updateSchema = Joi.object({ code_prefix: masterCode({ max: 10 }).allow(null, ''), default_useful_life_years: Joi.number().integer().min(0).allow(null).optional(), default_depreciation_method: Joi.string() - .valid(...DEPRECIATION_METHODS) + .valid(...DEPRECIATION_METHODS, 'OTHER') .allow(null, '') .optional(), is_active: Joi.boolean().optional(), diff --git a/src/modules/reports/reports-assets-depreciation.service.js b/src/modules/reports/reports-assets-depreciation.service.js index 18d3878..270febd 100644 --- a/src/modules/reports/reports-assets-depreciation.service.js +++ b/src/modules/reports/reports-assets-depreciation.service.js @@ -11,10 +11,16 @@ const depreciationInclude = { }; const buildDepreciationWhere = (query) => { + const methodFilter = query.depreciation_method + ? query.depreciation_method === 'CUSTOM' || query.depreciation_method === 'OTHER' + ? { depreciation_method: { in: ['CUSTOM', 'OTHER'] } } + : { depreciation_method: query.depreciation_method } + : {}; + const where = { deleted_at: null, ...(query.status ? { status: query.status } : {}), - ...(query.depreciation_method ? { depreciation_method: query.depreciation_method } : {}), + ...methodFilter, ...(query.item_category_id ? { item_category_id: BigInt(query.item_category_id) } : {}), ...(query.item_subcategory_id ? { item_subcategory_id: BigInt(query.item_subcategory_id) } @@ -70,8 +76,14 @@ const sanitizeDepreciationRow = (asset, asOfDate) => { purchase_date: asset.purchase_date, purchase_cost: purchaseCost, salvage_value: salvageValue, + salvage_percentage: + asset.salvage_percentage !== null && asset.salvage_percentage !== undefined + ? toNumber(asset.salvage_percentage) + : purchaseCost > 0 + ? round4((salvageValue / purchaseCost) * 100) + : 0, useful_life_years: asset.useful_life_years, - depreciation_method: asset.depreciation_method, + depreciation_method: asset.depreciation_method === 'OTHER' ? 'CUSTOM' : asset.depreciation_method, depreciation_rate: depreciationRate, item_category: asset.item_categories || null, item_subcategory: asset.item_subcategories || null, @@ -174,7 +186,7 @@ const getDepreciationFilterOptions = async () => { depreciation_methods: [ { value: 'SLM', label: 'Straight Line Method (SLM)' }, { value: 'WDV', label: 'Written Down Value (WDV)' }, - { value: 'OTHER', label: 'Other' }, + { value: 'CUSTOM', label: 'Custom' }, ], statuses: [ { value: 'IN_USE', label: 'In Use' }, @@ -209,6 +221,7 @@ const exportAssetDepreciation = async (query) => { { key: 'purchase_date', header: 'Purchase Date', type: 'date' }, { key: 'purchase_cost', header: 'Purchase Cost' }, { key: 'salvage_value', header: 'Salvage Value' }, + { key: 'salvage_percentage', header: 'Salvage %' }, { 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 (%)' }, diff --git a/src/modules/reports/reports.validation.js b/src/modules/reports/reports.validation.js index d21d189..9a7f1f0 100644 --- a/src/modules/reports/reports.validation.js +++ b/src/modules/reports/reports.validation.js @@ -9,7 +9,7 @@ const depreciationReportQuerySchema = Joi.object({ status: Joi.string() .valid(...ASSET_STATUSES) .optional(), - depreciation_method: Joi.string().valid('SLM', 'WDV', 'OTHER').optional(), + depreciation_method: Joi.string().valid('SLM', 'WDV', 'CUSTOM', 'OTHER').optional(), item_category_id: Joi.number().integer().positive().optional(), item_subcategory_id: Joi.number().integer().positive().optional(), location_id: Joi.number().integer().positive().optional(),