Asset module
This commit is contained in:
parent
944238d39a
commit
5eb853bcfe
@ -68,7 +68,7 @@ Build in this order. Each module: `*.routes.js` → `*.controller.js` → `*.ser
|
|||||||
| 10 | **vendors** | [x] Done | CRUD + addresses/contacts/bank-details; encrypt bank accounts |
|
| 10 | **vendors** | [x] Done | CRUD + addresses/contacts/bank-details; encrypt bank accounts |
|
||||||
| 11 | **purchase-orders** | [x] Done | Header + line items; submit/approve/reject/amend/cancel; PDF export |
|
| 11 | **purchase-orders** | [x] Done | Header + line items; submit/approve/reject/amend/cancel; PDF export |
|
||||||
| 12 | **grn** | [x] Done | Receipt + PO status recalc + asset auto-creation |
|
| 12 | **grn** | [x] Done | Receipt + PO status recalc + asset auto-creation |
|
||||||
| 13 | **assets** | [x] Done | CRUD + transfer + transfer history |
|
| 13 | **assets** | [x] Done | CRUD + transfer + AMC/service visits/insurance + expiry alerts |
|
||||||
|
|
||||||
Current progress:
|
Current progress:
|
||||||
|
|
||||||
@ -80,7 +80,7 @@ Current progress:
|
|||||||
- [x] `vendors` module (CRUD, status, addresses, contacts, bank-details)
|
- [x] `vendors` module (CRUD, status, addresses, contacts, bank-details)
|
||||||
- [x] `purchase-orders` module (CRUD, workflow, PDF)
|
- [x] `purchase-orders` module (CRUD, workflow, PDF)
|
||||||
- [x] `grn` module (transactional receipt, PO status recalc, asset auto-creation)
|
- [x] `grn` module (transactional receipt, PO status recalc, asset auto-creation)
|
||||||
- [x] `assets` module (CRUD, transfer, transfer history)
|
- [x] `assets` module (CRUD, transfer, AMC, service visits, insurance, alerts)
|
||||||
- [x] `GET /auth/me` (current user + permissions for FE)
|
- [x] `GET /auth/me` (current user + permissions for FE)
|
||||||
|
|
||||||
### Layer rules
|
### Layer rules
|
||||||
@ -145,7 +145,7 @@ Base path: `/api/v1` · Auth: `Authorization: Bearer <accessToken>` (except publ
|
|||||||
| [x] | GET | `/roles/cards` | view | Role cards (name, description, user/permission counts) |
|
| [x] | GET | `/roles/cards` | view | Role cards (name, description, user/permission counts) |
|
||||||
| [x] | GET | `/roles` | view | Paginated role list |
|
| [x] | GET | `/roles` | view | Paginated role list |
|
||||||
| [x] | GET | `/roles/:id` | view | Role detail with permissions |
|
| [x] | GET | `/roles/:id` | view | Role detail with permissions |
|
||||||
| [x] | POST | `/roles` | create | Create role |
|
| [x] | POST | `/roles` | create | Create role with optional initial permissions (`permission_ids[]` and/or `view_modules[]`) |
|
||||||
| [x] | PUT | `/roles/:id` | edit | Update role |
|
| [x] | PUT | `/roles/:id` | edit | Update role |
|
||||||
| [x] | DELETE | `/roles/:id` | delete | Soft delete (blocked if users assigned) |
|
| [x] | DELETE | `/roles/:id` | delete | Soft delete (blocked if users assigned) |
|
||||||
| [x] | PUT | `/roles/:id/permissions` | edit | Assign by `permission_ids[]` (replaces all) |
|
| [x] | PUT | `/roles/:id/permissions` | edit | Assign by `permission_ids[]` (replaces all) |
|
||||||
@ -250,6 +250,45 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL
|
|||||||
| [x] | POST | `/assets/:id/transfer` | edit | Transfer asset |
|
| [x] | POST | `/assets/:id/transfer` | edit | Transfer asset |
|
||||||
| [x] | GET | `/assets/:id/transfer-history` | view | Transfer history |
|
| [x] | GET | `/assets/:id/transfer-history` | view | Transfer history |
|
||||||
|
|
||||||
|
**AMC contracts** (`/assets/:id/amc`)
|
||||||
|
|
||||||
|
| Status | Method | Endpoint | RBAC | Notes |
|
||||||
|
|--------|--------|----------|------|-------|
|
||||||
|
| [x] | GET | `/assets/:id/amc` | view | List AMC contracts |
|
||||||
|
| [x] | POST | `/assets/:id/amc` | create | Add AMC contract |
|
||||||
|
| [x] | GET | `/assets/:id/amc/:contractId` | view | AMC detail |
|
||||||
|
| [x] | PUT | `/assets/:id/amc/:contractId` | edit | Update AMC |
|
||||||
|
| [x] | PATCH | `/assets/:id/amc/:contractId/renew` | edit | Renew AMC (deactivate old, create new) |
|
||||||
|
|
||||||
|
**Service visits** (`/assets/:id/service-visits`)
|
||||||
|
|
||||||
|
| Status | Method | Endpoint | RBAC | Notes |
|
||||||
|
|--------|--------|----------|------|-------|
|
||||||
|
| [x] | GET | `/assets/:id/service-visits` | view | List visits |
|
||||||
|
| [x] | POST | `/assets/:id/service-visits` | create | Log visit |
|
||||||
|
| [x] | GET | `/assets/:id/service-visits/:visitId` | view | Visit detail |
|
||||||
|
| [x] | PUT | `/assets/:id/service-visits/:visitId` | edit | Update visit |
|
||||||
|
| [x] | PATCH | `/assets/:id/service-visits/:visitId/status` | edit | Update visit status |
|
||||||
|
|
||||||
|
**Insurance** (`/assets/:id/insurance`)
|
||||||
|
|
||||||
|
| Status | Method | Endpoint | RBAC | Notes |
|
||||||
|
|--------|--------|----------|------|-------|
|
||||||
|
| [x] | GET | `/assets/:id/insurance` | view | List policies |
|
||||||
|
| [x] | POST | `/assets/:id/insurance` | create | Add policy |
|
||||||
|
| [x] | GET | `/assets/:id/insurance/:policyId` | view | Policy detail |
|
||||||
|
| [x] | PUT | `/assets/:id/insurance/:policyId` | edit | Update policy |
|
||||||
|
| [x] | PATCH | `/assets/:id/insurance/:policyId/renew` | edit | Renew policy |
|
||||||
|
|
||||||
|
**Alerts** (cross-asset)
|
||||||
|
|
||||||
|
| Status | Method | Endpoint | RBAC | Notes |
|
||||||
|
|--------|--------|----------|------|-------|
|
||||||
|
| [x] | GET | `/assets/alerts/expiry` | view | AMC/Insurance/Warranty expiry (`?days=30/60/90&type=`) |
|
||||||
|
| [x] | GET | `/assets/alerts/service` | view | Overdue/upcoming service (`?status=OVERDUE`) |
|
||||||
|
|
||||||
|
**DB patch:** run `scripts/patch-assets-amc-insurance.sql` then `scripts/patch-assets-views.sql` on deployed DB.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### API progress summary
|
### API progress summary
|
||||||
@ -264,8 +303,8 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL
|
|||||||
| Vendors | 20 | 20 | [x] Done |
|
| Vendors | 20 | 20 | [x] Done |
|
||||||
| Purchase Orders | 11 | 11 | [x] Done |
|
| Purchase Orders | 11 | 11 | [x] Done |
|
||||||
| GRN | 6 | 6 | [x] Done |
|
| GRN | 6 | 6 | [x] Done |
|
||||||
| Assets | 7 | 7 | [x] Done |
|
| Assets | 24 | 24 | [x] Done |
|
||||||
| **Total** | **139** | **139** | **[x] Phase 1 APIs complete** |
|
| **Total** | **156** | **156** | **[x] Phase 1 APIs + Asset extensions** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -9,17 +9,130 @@ datasource db {
|
|||||||
|
|
||||||
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||||
model asset_attachments {
|
model asset_attachments {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
asset_id BigInt
|
asset_id BigInt
|
||||||
attachment_type String @default("DOCUMENT") @db.VarChar(50)
|
attachment_type String @default("DOCUMENT") @db.VarChar(50)
|
||||||
file_name String @db.VarChar(255)
|
file_name String @db.VarChar(255)
|
||||||
file_path String @db.VarChar(500)
|
file_path String @db.VarChar(500)
|
||||||
file_type String? @db.VarChar(100)
|
file_type String? @db.VarChar(100)
|
||||||
file_size Int?
|
file_size Int?
|
||||||
uploaded_by BigInt?
|
uploaded_by BigInt?
|
||||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
amc_contract_id BigInt?
|
||||||
assets assets @relation(fields: [asset_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
service_visit_id BigInt?
|
||||||
users users? @relation(fields: [uploaded_by], references: [id], onUpdate: NoAction)
|
insurance_id BigInt?
|
||||||
|
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
|
asset_amc_contracts asset_amc_contracts? @relation(fields: [amc_contract_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
asset_insurance_policies asset_insurance_policies? @relation(fields: [insurance_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
asset_service_visits asset_service_visits? @relation(fields: [service_visit_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
assets assets @relation(fields: [asset_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
users users? @relation(fields: [uploaded_by], references: [id], onUpdate: NoAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
model asset_amc_contracts {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
asset_id BigInt
|
||||||
|
vendor_id BigInt
|
||||||
|
contract_no String? @db.VarChar(100)
|
||||||
|
contract_type String @default("COMPREHENSIVE") @db.VarChar(30)
|
||||||
|
start_date DateTime @db.Date
|
||||||
|
end_date DateTime @db.Date
|
||||||
|
renewal_date DateTime? @db.Date
|
||||||
|
annual_cost Decimal @default(0) @db.Decimal(12, 2)
|
||||||
|
payment_frequency String @default("ANNUAL") @db.VarChar(20)
|
||||||
|
service_frequency String? @db.VarChar(20)
|
||||||
|
visits_per_year Int?
|
||||||
|
contact_person String? @db.VarChar(150)
|
||||||
|
contact_phone String? @db.VarChar(15)
|
||||||
|
contact_email String? @db.VarChar(150)
|
||||||
|
scope_of_work String?
|
||||||
|
exclusions String?
|
||||||
|
remarks String?
|
||||||
|
is_active Boolean @default(true)
|
||||||
|
created_by BigInt?
|
||||||
|
updated_by BigInt?
|
||||||
|
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
|
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
|
deleted_at DateTime? @db.Timestamptz(6)
|
||||||
|
asset_attachments asset_attachments[]
|
||||||
|
assets assets @relation(fields: [asset_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
users_asset_amc_contracts_created_byTousers users? @relation("asset_amc_contracts_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
|
||||||
|
users_asset_amc_contracts_updated_byTousers users? @relation("asset_amc_contracts_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
|
||||||
|
vendors vendors @relation(fields: [vendor_id], references: [id], onUpdate: NoAction)
|
||||||
|
asset_service_visits asset_service_visits[]
|
||||||
|
|
||||||
|
@@index([asset_id], map: "idx_amc_asset_id")
|
||||||
|
@@index([vendor_id], map: "idx_amc_vendor_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
model asset_insurance_policies {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
asset_id BigInt
|
||||||
|
policy_no String @db.VarChar(100)
|
||||||
|
insurer_name String @db.VarChar(200)
|
||||||
|
insurer_branch String? @db.VarChar(150)
|
||||||
|
insurer_contact String? @db.VarChar(150)
|
||||||
|
insurer_phone String? @db.VarChar(15)
|
||||||
|
insurer_email String? @db.VarChar(150)
|
||||||
|
policy_type String @default("FIRE_AND_ALLIED") @db.VarChar(50)
|
||||||
|
sum_insured Decimal @default(0) @db.Decimal(15, 2)
|
||||||
|
annual_premium Decimal @default(0) @db.Decimal(12, 2)
|
||||||
|
policy_start_date DateTime @db.Date
|
||||||
|
policy_end_date DateTime @db.Date
|
||||||
|
renewal_date DateTime? @db.Date
|
||||||
|
is_auto_renewal Boolean @default(false)
|
||||||
|
premium_paid Boolean @default(false)
|
||||||
|
premium_paid_date DateTime? @db.Date
|
||||||
|
remarks String?
|
||||||
|
is_active Boolean @default(true)
|
||||||
|
created_by BigInt?
|
||||||
|
updated_by BigInt?
|
||||||
|
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
|
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
|
deleted_at DateTime? @db.Timestamptz(6)
|
||||||
|
asset_attachments asset_attachments[]
|
||||||
|
assets assets @relation(fields: [asset_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
users_asset_insurance_policies_created_byTousers users? @relation("asset_insurance_policies_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
|
||||||
|
users_asset_insurance_policies_updated_byTousers users? @relation("asset_insurance_policies_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([asset_id], map: "idx_insurance_asset_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
model asset_service_visits {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
asset_id BigInt
|
||||||
|
amc_contract_id BigInt?
|
||||||
|
visit_type String @db.VarChar(30)
|
||||||
|
visit_date DateTime @db.Date
|
||||||
|
visit_number Int?
|
||||||
|
complaint_no String? @db.VarChar(50)
|
||||||
|
complaint_date DateTime? @db.Date
|
||||||
|
complaint_desc String?
|
||||||
|
engineer_name String? @db.VarChar(150)
|
||||||
|
engineer_phone String? @db.VarChar(15)
|
||||||
|
vendor_id BigInt?
|
||||||
|
work_done String?
|
||||||
|
parts_replaced String?
|
||||||
|
next_service_date DateTime? @db.Date
|
||||||
|
status String @default("COMPLETED") @db.VarChar(20)
|
||||||
|
downtime_hours Decimal? @default(0) @db.Decimal(6, 2)
|
||||||
|
service_cost Decimal? @default(0) @db.Decimal(12, 2)
|
||||||
|
is_under_amc Boolean @default(false)
|
||||||
|
asset_condition_after String? @db.VarChar(20)
|
||||||
|
remarks String?
|
||||||
|
created_by BigInt?
|
||||||
|
updated_by BigInt?
|
||||||
|
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
|
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
|
deleted_at DateTime? @db.Timestamptz(6)
|
||||||
|
asset_attachments asset_attachments[]
|
||||||
|
asset_amc_contracts asset_amc_contracts? @relation(fields: [amc_contract_id], references: [id], onUpdate: NoAction)
|
||||||
|
assets assets @relation(fields: [asset_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
users_asset_service_visits_created_byTousers users? @relation("asset_service_visits_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
|
||||||
|
users_asset_service_visits_updated_byTousers users? @relation("asset_service_visits_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
|
||||||
|
vendors vendors? @relation(fields: [vendor_id], references: [id], onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([asset_id], map: "idx_service_asset_id")
|
||||||
|
@@index([amc_contract_id], map: "idx_service_amc_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||||
@ -95,11 +208,6 @@ model assets {
|
|||||||
depreciation_method String? @db.VarChar(10)
|
depreciation_method String? @db.VarChar(10)
|
||||||
salvage_value Decimal? @default(0) @db.Decimal(15, 4)
|
salvage_value Decimal? @default(0) @db.Decimal(15, 4)
|
||||||
warranty_expiry_date DateTime? @db.Date
|
warranty_expiry_date DateTime? @db.Date
|
||||||
amc_start_date DateTime? @db.Date
|
|
||||||
amc_end_date DateTime? @db.Date
|
|
||||||
amc_vendor_id BigInt?
|
|
||||||
insurance_policy_no String? @db.VarChar(100)
|
|
||||||
insurance_expiry_date DateTime? @db.Date
|
|
||||||
condition String @default("NEW") @db.VarChar(20)
|
condition String @default("NEW") @db.VarChar(20)
|
||||||
status String @default("IN_USE") @db.VarChar(30)
|
status String @default("IN_USE") @db.VarChar(30)
|
||||||
qr_code_value String? @db.VarChar(100)
|
qr_code_value String? @db.VarChar(100)
|
||||||
@ -113,9 +221,11 @@ model assets {
|
|||||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
deleted_at DateTime? @db.Timestamptz(6)
|
deleted_at DateTime? @db.Timestamptz(6)
|
||||||
|
asset_amc_contracts asset_amc_contracts[]
|
||||||
asset_attachments asset_attachments[]
|
asset_attachments asset_attachments[]
|
||||||
|
asset_insurance_policies asset_insurance_policies[]
|
||||||
|
asset_service_visits asset_service_visits[]
|
||||||
asset_transfers asset_transfers[]
|
asset_transfers asset_transfers[]
|
||||||
vendors_assets_amc_vendor_idTovendors vendors? @relation("assets_amc_vendor_idTovendors", fields: [amc_vendor_id], references: [id], onUpdate: NoAction)
|
|
||||||
asset_categories asset_categories @relation(fields: [asset_category_id], references: [id], onUpdate: NoAction)
|
asset_categories asset_categories @relation(fields: [asset_category_id], references: [id], onUpdate: NoAction)
|
||||||
users_assets_assigned_to_user_idTousers users? @relation("assets_assigned_to_user_idTousers", fields: [assigned_to_user_id], references: [id], onUpdate: NoAction)
|
users_assets_assigned_to_user_idTousers users? @relation("assets_assigned_to_user_idTousers", fields: [assigned_to_user_id], references: [id], onUpdate: NoAction)
|
||||||
users_assets_created_byTousers users? @relation("assets_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
|
users_assets_created_byTousers users? @relation("assets_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
|
||||||
@ -669,7 +779,8 @@ model users {
|
|||||||
employee_code String @unique @db.VarChar(30)
|
employee_code String @unique @db.VarChar(30)
|
||||||
full_name String @db.VarChar(200)
|
full_name String @db.VarChar(200)
|
||||||
email String @unique @db.VarChar(200)
|
email String @unique @db.VarChar(200)
|
||||||
mobile String? @db.VarChar(20)
|
mobile String? @db.VarChar(255)
|
||||||
|
mobile_index String? @db.VarChar(64)
|
||||||
password_hash String @db.VarChar(255)
|
password_hash String @db.VarChar(255)
|
||||||
role_id BigInt?
|
role_id BigInt?
|
||||||
department_id BigInt?
|
department_id BigInt?
|
||||||
@ -687,6 +798,12 @@ model users {
|
|||||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
deleted_at DateTime? @db.Timestamptz(6)
|
deleted_at DateTime? @db.Timestamptz(6)
|
||||||
asset_attachments asset_attachments[]
|
asset_attachments asset_attachments[]
|
||||||
|
asset_amc_contracts_asset_amc_contracts_created_byTousers asset_amc_contracts[] @relation("asset_amc_contracts_created_byTousers")
|
||||||
|
asset_amc_contracts_asset_amc_contracts_updated_byTousers asset_amc_contracts[] @relation("asset_amc_contracts_updated_byTousers")
|
||||||
|
asset_insurance_policies_asset_insurance_policies_created_byTousers asset_insurance_policies[] @relation("asset_insurance_policies_created_byTousers")
|
||||||
|
asset_insurance_policies_asset_insurance_policies_updated_byTousers asset_insurance_policies[] @relation("asset_insurance_policies_updated_byTousers")
|
||||||
|
asset_service_visits_asset_service_visits_created_byTousers asset_service_visits[] @relation("asset_service_visits_created_byTousers")
|
||||||
|
asset_service_visits_asset_service_visits_updated_byTousers asset_service_visits[] @relation("asset_service_visits_updated_byTousers")
|
||||||
asset_categories_asset_categories_created_byTousers asset_categories[] @relation("asset_categories_created_byTousers")
|
asset_categories_asset_categories_created_byTousers asset_categories[] @relation("asset_categories_created_byTousers")
|
||||||
asset_categories_asset_categories_updated_byTousers asset_categories[] @relation("asset_categories_updated_byTousers")
|
asset_categories_asset_categories_updated_byTousers asset_categories[] @relation("asset_categories_updated_byTousers")
|
||||||
asset_transfers_asset_transfers_from_user_idTousers asset_transfers[] @relation("asset_transfers_from_user_idTousers")
|
asset_transfers_asset_transfers_from_user_idTousers asset_transfers[] @relation("asset_transfers_from_user_idTousers")
|
||||||
@ -853,8 +970,9 @@ model vendors {
|
|||||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
deleted_at DateTime? @db.Timestamptz(6)
|
deleted_at DateTime? @db.Timestamptz(6)
|
||||||
assets_assets_amc_vendor_idTovendors assets[] @relation("assets_amc_vendor_idTovendors")
|
|
||||||
assets_assets_vendor_idTovendors assets[] @relation("assets_vendor_idTovendors")
|
assets_assets_vendor_idTovendors assets[] @relation("assets_vendor_idTovendors")
|
||||||
|
asset_amc_contracts asset_amc_contracts[]
|
||||||
|
asset_service_visits asset_service_visits[]
|
||||||
grn grn[]
|
grn grn[]
|
||||||
purchase_orders purchase_orders[]
|
purchase_orders purchase_orders[]
|
||||||
vendor_addresses vendor_addresses[]
|
vendor_addresses vendor_addresses[]
|
||||||
|
|||||||
149
scripts/patch-assets-amc-insurance.sql
Normal file
149
scripts/patch-assets-amc-insurance.sql
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
-- Asset module Phase 1.1: AMC / Service Visits / Insurance child tables
|
||||||
|
-- Run once: psql "$DATABASE_URL" -f scripts/patch-assets-amc-insurance.sql
|
||||||
|
|
||||||
|
-- Remove inline AMC/insurance from assets
|
||||||
|
ALTER TABLE assets
|
||||||
|
DROP COLUMN IF EXISTS amc_start_date,
|
||||||
|
DROP COLUMN IF EXISTS amc_end_date,
|
||||||
|
DROP COLUMN IF EXISTS amc_vendor_id,
|
||||||
|
DROP COLUMN IF EXISTS insurance_policy_no,
|
||||||
|
DROP COLUMN IF EXISTS insurance_expiry_date;
|
||||||
|
|
||||||
|
-- AMC contracts
|
||||||
|
CREATE TABLE IF NOT EXISTS asset_amc_contracts (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
asset_id BIGINT NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||||||
|
vendor_id BIGINT NOT NULL REFERENCES vendors(id) ON DELETE RESTRICT,
|
||||||
|
contract_no VARCHAR(100),
|
||||||
|
contract_type VARCHAR(30) NOT NULL DEFAULT 'COMPREHENSIVE'
|
||||||
|
CHECK (contract_type IN ('COMPREHENSIVE','LABOUR_ONLY','PARTS_ONLY','PREVENTIVE_ONLY')),
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
end_date DATE NOT NULL,
|
||||||
|
renewal_date DATE,
|
||||||
|
annual_cost NUMERIC(12,2) NOT NULL DEFAULT 0,
|
||||||
|
payment_frequency VARCHAR(20) NOT NULL DEFAULT 'ANNUAL'
|
||||||
|
CHECK (payment_frequency IN ('MONTHLY','QUARTERLY','HALF_YEARLY','ANNUAL')),
|
||||||
|
service_frequency VARCHAR(20)
|
||||||
|
CHECK (service_frequency IN ('MONTHLY','QUARTERLY','HALF_YEARLY','ANNUAL')),
|
||||||
|
visits_per_year INT,
|
||||||
|
contact_person VARCHAR(150),
|
||||||
|
contact_phone VARCHAR(15),
|
||||||
|
contact_email VARCHAR(150),
|
||||||
|
scope_of_work TEXT,
|
||||||
|
exclusions TEXT,
|
||||||
|
remarks TEXT,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_amc_asset_id ON asset_amc_contracts(asset_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_amc_vendor_id ON asset_amc_contracts(vendor_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_amc_end_date ON asset_amc_contracts(end_date) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_amc_is_active ON asset_amc_contracts(asset_id, is_active);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_asset_amc_contracts_updated_at ON asset_amc_contracts;
|
||||||
|
CREATE TRIGGER trg_asset_amc_contracts_updated_at
|
||||||
|
BEFORE UPDATE ON asset_amc_contracts
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION fn_set_updated_at();
|
||||||
|
|
||||||
|
-- Service visits
|
||||||
|
CREATE TABLE IF NOT EXISTS asset_service_visits (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
asset_id BIGINT NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||||||
|
amc_contract_id BIGINT REFERENCES asset_amc_contracts(id) ON DELETE SET NULL,
|
||||||
|
visit_type VARCHAR(30) NOT NULL
|
||||||
|
CHECK (visit_type IN ('PREVENTIVE','BREAKDOWN','INSPECTION','INSTALLATION','CALIBRATION','OTHER')),
|
||||||
|
visit_date DATE NOT NULL,
|
||||||
|
visit_number INT,
|
||||||
|
complaint_no VARCHAR(50),
|
||||||
|
complaint_date DATE,
|
||||||
|
complaint_desc TEXT,
|
||||||
|
engineer_name VARCHAR(150),
|
||||||
|
engineer_phone VARCHAR(15),
|
||||||
|
vendor_id BIGINT REFERENCES vendors(id) ON DELETE SET NULL,
|
||||||
|
work_done TEXT,
|
||||||
|
parts_replaced TEXT,
|
||||||
|
next_service_date DATE,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'COMPLETED'
|
||||||
|
CHECK (status IN ('SCHEDULED','IN_PROGRESS','COMPLETED','CANCELLED','PENDING_PARTS')),
|
||||||
|
downtime_hours NUMERIC(6,2) DEFAULT 0,
|
||||||
|
service_cost NUMERIC(12,2) DEFAULT 0,
|
||||||
|
is_under_amc BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
asset_condition_after VARCHAR(20)
|
||||||
|
CHECK (asset_condition_after IN ('GOOD','FAIR','POOR','NEEDS_REPLACEMENT')),
|
||||||
|
remarks TEXT,
|
||||||
|
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_service_asset_id ON asset_service_visits(asset_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_service_amc_id ON asset_service_visits(amc_contract_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_service_visit_date ON asset_service_visits(visit_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_service_status ON asset_service_visits(status) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_service_next_date ON asset_service_visits(next_service_date) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_asset_service_visits_updated_at ON asset_service_visits;
|
||||||
|
CREATE TRIGGER trg_asset_service_visits_updated_at
|
||||||
|
BEFORE UPDATE ON asset_service_visits
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION fn_set_updated_at();
|
||||||
|
|
||||||
|
-- Insurance policies
|
||||||
|
CREATE TABLE IF NOT EXISTS asset_insurance_policies (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
asset_id BIGINT NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||||||
|
policy_no VARCHAR(100) NOT NULL,
|
||||||
|
insurer_name VARCHAR(200) NOT NULL,
|
||||||
|
insurer_branch VARCHAR(150),
|
||||||
|
insurer_contact VARCHAR(150),
|
||||||
|
insurer_phone VARCHAR(15),
|
||||||
|
insurer_email VARCHAR(150),
|
||||||
|
policy_type VARCHAR(50) NOT NULL DEFAULT 'FIRE_AND_ALLIED'
|
||||||
|
CHECK (policy_type IN ('FIRE_AND_ALLIED','MACHINERY_BREAKDOWN','COMPREHENSIVE','THIRD_PARTY','VEHICLE','OTHER')),
|
||||||
|
sum_insured NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||||
|
annual_premium NUMERIC(12,2) NOT NULL DEFAULT 0,
|
||||||
|
policy_start_date DATE NOT NULL,
|
||||||
|
policy_end_date DATE NOT NULL,
|
||||||
|
renewal_date DATE,
|
||||||
|
is_auto_renewal BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
premium_paid BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
premium_paid_date DATE,
|
||||||
|
remarks TEXT,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_insurance_asset_id ON asset_insurance_policies(asset_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_insurance_end_date ON asset_insurance_policies(policy_end_date) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_insurance_is_active ON asset_insurance_policies(asset_id, is_active);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_asset_insurance_policies_updated_at ON asset_insurance_policies;
|
||||||
|
CREATE TRIGGER trg_asset_insurance_policies_updated_at
|
||||||
|
BEFORE UPDATE ON asset_insurance_policies
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION fn_set_updated_at();
|
||||||
|
|
||||||
|
-- Extend asset_attachments
|
||||||
|
ALTER TABLE asset_attachments
|
||||||
|
ADD COLUMN IF NOT EXISTS amc_contract_id BIGINT REFERENCES asset_amc_contracts(id) ON DELETE CASCADE,
|
||||||
|
ADD COLUMN IF NOT EXISTS service_visit_id BIGINT REFERENCES asset_service_visits(id) ON DELETE CASCADE,
|
||||||
|
ADD COLUMN IF NOT EXISTS insurance_id BIGINT REFERENCES asset_insurance_policies(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE asset_attachments DROP CONSTRAINT IF EXISTS asset_attachments_attachment_type_check;
|
||||||
|
ALTER TABLE asset_attachments
|
||||||
|
ADD CONSTRAINT asset_attachments_attachment_type_check
|
||||||
|
CHECK (attachment_type IN (
|
||||||
|
'DOCUMENT','PHOTO','WARRANTY','INVOICE',
|
||||||
|
'AMC_CONTRACT','SERVICE_REPORT','INSURANCE_POLICY','OTHER'
|
||||||
|
));
|
||||||
|
|
||||||
|
-- Views: run scripts/patch-assets-views.sql after this file if not using \i
|
||||||
164
scripts/patch-assets-views.sql
Normal file
164
scripts/patch-assets-views.sql
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
-- Asset alert and summary views (run from patch-assets-amc-insurance.sql or standalone)
|
||||||
|
|
||||||
|
DROP VIEW IF EXISTS v_asset_expiry_alerts CASCADE;
|
||||||
|
DROP VIEW IF EXISTS v_asset_next_service CASCADE;
|
||||||
|
DROP VIEW IF EXISTS v_assets CASCADE;
|
||||||
|
|
||||||
|
CREATE VIEW v_asset_expiry_alerts AS
|
||||||
|
SELECT
|
||||||
|
a.id AS asset_id,
|
||||||
|
a.asset_code,
|
||||||
|
a.asset_name,
|
||||||
|
ac_cat.name AS category_name,
|
||||||
|
p.name AS plant_name,
|
||||||
|
d.name AS department_name,
|
||||||
|
'AMC'::text AS alert_type,
|
||||||
|
amc.id AS reference_id,
|
||||||
|
amc.contract_no AS reference_no,
|
||||||
|
v.vendor_name AS party_name,
|
||||||
|
amc.end_date AS expiry_date,
|
||||||
|
(amc.end_date - CURRENT_DATE) AS days_remaining,
|
||||||
|
CASE
|
||||||
|
WHEN (amc.end_date - CURRENT_DATE) <= 0 THEN 'EXPIRED'
|
||||||
|
WHEN (amc.end_date - CURRENT_DATE) <= 30 THEN 'CRITICAL'
|
||||||
|
WHEN (amc.end_date - CURRENT_DATE) <= 60 THEN 'WARNING'
|
||||||
|
WHEN (amc.end_date - CURRENT_DATE) <= 90 THEN 'INFO'
|
||||||
|
END AS alert_level
|
||||||
|
FROM asset_amc_contracts amc
|
||||||
|
JOIN assets a ON a.id = amc.asset_id
|
||||||
|
JOIN asset_categories ac_cat ON ac_cat.id = a.asset_category_id
|
||||||
|
JOIN plants p ON p.id = a.plant_id
|
||||||
|
LEFT JOIN departments d ON d.id = a.department_id
|
||||||
|
LEFT JOIN vendors v ON v.id = amc.vendor_id
|
||||||
|
WHERE amc.is_active = TRUE AND amc.deleted_at IS NULL AND a.deleted_at IS NULL
|
||||||
|
AND (amc.end_date - CURRENT_DATE) <= 90
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
a.id, a.asset_code, a.asset_name, ac_cat.name, p.name, d.name,
|
||||||
|
'INSURANCE'::text, ins.id, ins.policy_no, ins.insurer_name,
|
||||||
|
ins.policy_end_date, (ins.policy_end_date - CURRENT_DATE),
|
||||||
|
CASE
|
||||||
|
WHEN (ins.policy_end_date - CURRENT_DATE) <= 0 THEN 'EXPIRED'
|
||||||
|
WHEN (ins.policy_end_date - CURRENT_DATE) <= 30 THEN 'CRITICAL'
|
||||||
|
WHEN (ins.policy_end_date - CURRENT_DATE) <= 60 THEN 'WARNING'
|
||||||
|
WHEN (ins.policy_end_date - CURRENT_DATE) <= 90 THEN 'INFO'
|
||||||
|
END
|
||||||
|
FROM asset_insurance_policies ins
|
||||||
|
JOIN assets a ON a.id = ins.asset_id
|
||||||
|
JOIN asset_categories ac_cat ON ac_cat.id = a.asset_category_id
|
||||||
|
JOIN plants p ON p.id = a.plant_id
|
||||||
|
LEFT JOIN departments d ON d.id = a.department_id
|
||||||
|
WHERE ins.is_active = TRUE AND ins.deleted_at IS NULL AND a.deleted_at IS NULL
|
||||||
|
AND (ins.policy_end_date - CURRENT_DATE) <= 90
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
a.id, a.asset_code, a.asset_name, ac_cat.name, p.name, d.name,
|
||||||
|
'WARRANTY'::text, a.id, a.asset_code, 'Manufacturer Warranty'::varchar,
|
||||||
|
a.warranty_expiry_date, (a.warranty_expiry_date - CURRENT_DATE),
|
||||||
|
CASE
|
||||||
|
WHEN (a.warranty_expiry_date - CURRENT_DATE) <= 0 THEN 'EXPIRED'
|
||||||
|
WHEN (a.warranty_expiry_date - CURRENT_DATE) <= 30 THEN 'CRITICAL'
|
||||||
|
WHEN (a.warranty_expiry_date - CURRENT_DATE) <= 60 THEN 'WARNING'
|
||||||
|
WHEN (a.warranty_expiry_date - CURRENT_DATE) <= 90 THEN 'INFO'
|
||||||
|
END
|
||||||
|
FROM assets a
|
||||||
|
JOIN asset_categories ac_cat ON ac_cat.id = a.asset_category_id
|
||||||
|
JOIN plants p ON p.id = a.plant_id
|
||||||
|
LEFT JOIN departments d ON d.id = a.department_id
|
||||||
|
WHERE a.warranty_expiry_date IS NOT NULL AND a.deleted_at IS NULL
|
||||||
|
AND (a.warranty_expiry_date - CURRENT_DATE) <= 90;
|
||||||
|
|
||||||
|
CREATE VIEW v_asset_next_service AS
|
||||||
|
SELECT
|
||||||
|
a.id AS asset_id,
|
||||||
|
a.asset_code,
|
||||||
|
a.asset_name,
|
||||||
|
ac_cat.name AS category_name,
|
||||||
|
p.name AS plant_name,
|
||||||
|
d.name AS department_name,
|
||||||
|
sv.id AS last_visit_id,
|
||||||
|
sv.visit_date AS last_service_date,
|
||||||
|
sv.visit_type AS last_visit_type,
|
||||||
|
sv.next_service_date,
|
||||||
|
(sv.next_service_date - CURRENT_DATE) AS days_to_next_service,
|
||||||
|
CASE
|
||||||
|
WHEN sv.next_service_date < CURRENT_DATE THEN 'OVERDUE'
|
||||||
|
WHEN (sv.next_service_date - CURRENT_DATE) <= 7 THEN 'DUE_THIS_WEEK'
|
||||||
|
WHEN (sv.next_service_date - CURRENT_DATE) <= 30 THEN 'DUE_THIS_MONTH'
|
||||||
|
ELSE 'UPCOMING'
|
||||||
|
END AS service_status,
|
||||||
|
v.vendor_name AS service_vendor,
|
||||||
|
amc.contract_no AS amc_contract_no,
|
||||||
|
amc.end_date AS amc_end_date
|
||||||
|
FROM assets a
|
||||||
|
JOIN asset_categories ac_cat ON ac_cat.id = a.asset_category_id
|
||||||
|
JOIN plants p ON p.id = a.plant_id
|
||||||
|
LEFT JOIN departments d ON d.id = a.department_id
|
||||||
|
JOIN LATERAL (
|
||||||
|
SELECT * FROM asset_service_visits
|
||||||
|
WHERE asset_id = a.id AND deleted_at IS NULL AND next_service_date IS NOT NULL
|
||||||
|
ORDER BY visit_date DESC
|
||||||
|
LIMIT 1
|
||||||
|
) sv ON TRUE
|
||||||
|
LEFT JOIN vendors v ON v.id = sv.vendor_id
|
||||||
|
LEFT JOIN asset_amc_contracts amc
|
||||||
|
ON amc.asset_id = a.id AND amc.is_active = TRUE AND amc.deleted_at IS NULL
|
||||||
|
WHERE a.deleted_at IS NULL AND a.status NOT IN ('DISPOSED','SCRAPPED');
|
||||||
|
|
||||||
|
CREATE VIEW v_assets AS
|
||||||
|
SELECT
|
||||||
|
a.id,
|
||||||
|
a.asset_code,
|
||||||
|
a.asset_name,
|
||||||
|
ac.name AS category_name,
|
||||||
|
a.brand_model,
|
||||||
|
a.serial_number,
|
||||||
|
a.status,
|
||||||
|
a.condition,
|
||||||
|
a.purchase_date,
|
||||||
|
a.purchase_cost,
|
||||||
|
a.warranty_expiry_date,
|
||||||
|
p.name AS plant_name,
|
||||||
|
d.name AS department_name,
|
||||||
|
w.name AS warehouse_name,
|
||||||
|
a.location_detail,
|
||||||
|
u.full_name AS assigned_to,
|
||||||
|
v.vendor_name AS supplier,
|
||||||
|
a.qr_code_value,
|
||||||
|
amc.contract_no AS amc_contract_no,
|
||||||
|
amc.end_date AS amc_expiry_date,
|
||||||
|
amc.contract_type AS amc_type,
|
||||||
|
amcv.vendor_name AS amc_vendor,
|
||||||
|
(amc.end_date - CURRENT_DATE) AS amc_days_remaining,
|
||||||
|
ins.policy_no AS insurance_policy_no,
|
||||||
|
ins.policy_end_date AS insurance_expiry_date,
|
||||||
|
ins.insurer_name,
|
||||||
|
ins.sum_insured,
|
||||||
|
(ins.policy_end_date - CURRENT_DATE) AS insurance_days_remaining,
|
||||||
|
lsv.visit_date AS last_service_date,
|
||||||
|
lsv.next_service_date,
|
||||||
|
(lsv.next_service_date - CURRENT_DATE) AS service_due_in_days,
|
||||||
|
a.created_at
|
||||||
|
FROM assets a
|
||||||
|
JOIN asset_categories ac ON ac.id = a.asset_category_id
|
||||||
|
JOIN plants p ON p.id = a.plant_id
|
||||||
|
LEFT JOIN departments d ON d.id = a.department_id
|
||||||
|
LEFT JOIN warehouses w ON w.id = a.warehouse_id
|
||||||
|
LEFT JOIN users u ON u.id = a.assigned_to_user_id
|
||||||
|
LEFT JOIN vendors v ON v.id = a.vendor_id
|
||||||
|
LEFT JOIN asset_amc_contracts amc
|
||||||
|
ON amc.asset_id = a.id AND amc.is_active = TRUE AND amc.deleted_at IS NULL
|
||||||
|
LEFT JOIN vendors amcv ON amcv.id = amc.vendor_id
|
||||||
|
LEFT JOIN asset_insurance_policies ins
|
||||||
|
ON ins.asset_id = a.id AND ins.is_active = TRUE AND ins.deleted_at IS NULL
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT visit_date, next_service_date
|
||||||
|
FROM asset_service_visits
|
||||||
|
WHERE asset_id = a.id AND deleted_at IS NULL
|
||||||
|
ORDER BY visit_date DESC LIMIT 1
|
||||||
|
) lsv ON TRUE
|
||||||
|
WHERE a.deleted_at IS NULL;
|
||||||
12
scripts/patch-users-mobile-encryption.sql
Normal file
12
scripts/patch-users-mobile-encryption.sql
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
-- Expand users.mobile for AES-256-GCM ciphertext and add blind index for lookup.
|
||||||
|
-- Run once on deployed DB: psql "$DATABASE_URL" -f scripts/patch-users-mobile-encryption.sql
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ALTER COLUMN mobile TYPE VARCHAR(255);
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS mobile_index VARCHAR(64);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_mobile_index
|
||||||
|
ON users (mobile_index)
|
||||||
|
WHERE deleted_at IS NULL AND mobile_index IS NOT NULL;
|
||||||
@ -9,187 +9,225 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
asset_name: { type: string, example: 'CNC Lathe Machine' }
|
asset_name: { type: string, example: 'CNC Lathe Machine' }
|
||||||
asset_category_id: { type: integer, example: 1 }
|
asset_category_id: { type: integer, example: 1 }
|
||||||
brand_model: { type: string, example: 'Haas ST-20' }
|
|
||||||
manufacturer: { type: string, example: 'Haas Automation' }
|
|
||||||
serial_number: { type: string, example: SN-12345 }
|
|
||||||
part_number: { type: string, example: PN-9876 }
|
|
||||||
plant_id: { type: integer, example: 1 }
|
plant_id: { type: integer, example: 1 }
|
||||||
department_id: { type: integer, nullable: true, example: 1 }
|
warranty_expiry_date: { type: string, format: date, nullable: true }
|
||||||
warehouse_id: { type: integer, nullable: true, example: 1 }
|
|
||||||
location_detail: { type: string, example: 'Production Bay 2' }
|
|
||||||
assigned_to_user_id: { type: integer, nullable: true, example: 1 }
|
|
||||||
vendor_id: { type: integer, nullable: true, example: 1 }
|
|
||||||
po_id: { type: integer, nullable: true }
|
|
||||||
grn_id: { type: integer, nullable: true }
|
|
||||||
grn_item_id: { type: integer, nullable: true }
|
|
||||||
purchase_date: { type: string, format: date, nullable: true }
|
|
||||||
purchase_cost: { type: number, example: 850000 }
|
purchase_cost: { type: number, example: 850000 }
|
||||||
useful_life_years: { type: integer, example: 15 }
|
AmcContractBody:
|
||||||
depreciation_method: { type: string, enum: [SLM, WDV], example: WDV }
|
|
||||||
salvage_value: { type: number, example: 50000 }
|
|
||||||
warranty_expiry_date: { type: string, format: date, nullable: true }
|
|
||||||
amc_start_date: { type: string, format: date, nullable: true }
|
|
||||||
amc_end_date: { type: string, format: date, nullable: true }
|
|
||||||
amc_vendor_id: { type: integer, nullable: true }
|
|
||||||
insurance_policy_no: { type: string, example: POL-2026-001 }
|
|
||||||
insurance_expiry_date: { type: string, format: date, nullable: true }
|
|
||||||
condition: { type: string, enum: [NEW, GOOD, FAIR, POOR], example: NEW }
|
|
||||||
status: { type: string, enum: [IN_USE, IDLE, UNDER_MAINTENANCE, DISPOSED, SCRAPPED], example: IN_USE }
|
|
||||||
qr_code_value: { type: string, example: QR-ASSET-001 }
|
|
||||||
disposal_date: { type: string, format: date, nullable: true }
|
|
||||||
disposal_reason: { type: string, nullable: true }
|
|
||||||
disposal_value: { type: number, nullable: true }
|
|
||||||
remarks: { type: string, example: 'Capital asset' }
|
|
||||||
is_active: { type: boolean, example: true }
|
|
||||||
AssetsUpdateBody:
|
|
||||||
type: object
|
type: object
|
||||||
minProperties: 1
|
required: [vendor_id, start_date, end_date]
|
||||||
properties:
|
properties:
|
||||||
asset_name: { type: string }
|
vendor_id: { type: integer, example: 1 }
|
||||||
asset_category_id: { type: integer }
|
contract_no: { type: string, example: AMC-2026-001 }
|
||||||
brand_model: { type: string }
|
contract_type: { type: string, enum: [COMPREHENSIVE, LABOUR_ONLY, PARTS_ONLY, PREVENTIVE_ONLY] }
|
||||||
manufacturer: { type: string }
|
start_date: { type: string, format: date }
|
||||||
serial_number: { type: string }
|
end_date: { type: string, format: date }
|
||||||
part_number: { type: string }
|
annual_cost: { type: number, example: 50000 }
|
||||||
plant_id: { type: integer }
|
ServiceVisitBody:
|
||||||
department_id: { type: integer, nullable: true }
|
|
||||||
warehouse_id: { type: integer, nullable: true }
|
|
||||||
location_detail: { type: string }
|
|
||||||
assigned_to_user_id: { type: integer, nullable: true }
|
|
||||||
vendor_id: { type: integer, nullable: true }
|
|
||||||
po_id: { type: integer, nullable: true }
|
|
||||||
grn_id: { type: integer, nullable: true }
|
|
||||||
grn_item_id: { type: integer, nullable: true }
|
|
||||||
purchase_date: { type: string, format: date, nullable: true }
|
|
||||||
purchase_cost: { type: number }
|
|
||||||
useful_life_years: { type: integer, nullable: true }
|
|
||||||
depreciation_method: { type: string, enum: [SLM, WDV], nullable: true }
|
|
||||||
salvage_value: { type: number }
|
|
||||||
warranty_expiry_date: { type: string, format: date, nullable: true }
|
|
||||||
amc_start_date: { type: string, format: date, nullable: true }
|
|
||||||
amc_end_date: { type: string, format: date, nullable: true }
|
|
||||||
amc_vendor_id: { type: integer, nullable: true }
|
|
||||||
insurance_policy_no: { type: string }
|
|
||||||
insurance_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] }
|
|
||||||
qr_code_value: { type: string }
|
|
||||||
disposal_date: { type: string, format: date, nullable: true }
|
|
||||||
disposal_reason: { type: string }
|
|
||||||
disposal_value: { type: number, nullable: true }
|
|
||||||
remarks: { type: string }
|
|
||||||
is_active: { type: boolean }
|
|
||||||
AssetsTransferBody:
|
|
||||||
type: object
|
type: object
|
||||||
required: [transfer_date]
|
required: [visit_type, visit_date]
|
||||||
properties:
|
properties:
|
||||||
transfer_date: { type: string, format: date, example: '2026-06-18' }
|
visit_type: { type: string, enum: [PREVENTIVE, BREAKDOWN, INSPECTION, INSTALLATION, CALIBRATION, OTHER] }
|
||||||
to_plant_id: { type: integer, nullable: true, example: 2 }
|
visit_date: { type: string, format: date }
|
||||||
to_department_id: { type: integer, nullable: true, example: 3 }
|
complaint_no: { type: string, example: CMP-001 }
|
||||||
to_user_id: { type: integer, nullable: true, example: 5 }
|
work_done: { type: string }
|
||||||
to_warehouse_id: { type: integer, nullable: true, example: 1 }
|
next_service_date: { type: string, format: date, nullable: true }
|
||||||
reason: { type: string, example: 'Moved to new production line' }
|
status: { type: string, enum: [SCHEDULED, IN_PROGRESS, COMPLETED, CANCELLED, PENDING_PARTS] }
|
||||||
|
InsurancePolicyBody:
|
||||||
|
type: object
|
||||||
|
required: [policy_no, insurer_name, policy_start_date, policy_end_date]
|
||||||
|
properties:
|
||||||
|
policy_no: { type: string, example: POL-2026-001 }
|
||||||
|
insurer_name: { type: string, example: 'ICICI Lombard' }
|
||||||
|
policy_type: { type: string, enum: [FIRE_AND_ALLIED, MACHINERY_BREAKDOWN, COMPREHENSIVE, THIRD_PARTY, VEHICLE, OTHER] }
|
||||||
|
sum_insured: { type: number, example: 1000000 }
|
||||||
|
policy_start_date: { type: string, format: date }
|
||||||
|
policy_end_date: { type: string, format: date }
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
/assets:
|
/assets/alerts/expiry:
|
||||||
get:
|
get:
|
||||||
tags: [Assets]
|
tags: [Assets]
|
||||||
summary: List assets
|
summary: Expiry alerts (AMC, Insurance, Warranty)
|
||||||
parameters:
|
parameters:
|
||||||
- { name: page, in: query, schema: { type: integer, example: 1 } }
|
- { name: days, in: query, schema: { type: integer, example: 30 } }
|
||||||
- { name: limit, in: query, schema: { type: integer, example: 20 } }
|
- { name: type, in: query, schema: { type: string, enum: [AMC, INSURANCE, WARRANTY] } }
|
||||||
- { name: search, in: query, schema: { type: string, example: MCH- } }
|
- { name: page, in: query, schema: { type: integer } }
|
||||||
- { name: status, in: query, schema: { type: string, example: IN_USE } }
|
- { name: limit, in: query, schema: { type: integer } }
|
||||||
- { name: condition, in: query, schema: { type: string, example: NEW } }
|
|
||||||
- { name: asset_category_id, in: query, schema: { type: integer } }
|
|
||||||
- { name: plant_id, in: query, schema: { type: integer } }
|
|
||||||
- { name: department_id, in: query, schema: { type: integer } }
|
|
||||||
- { name: is_active, in: query, schema: { type: boolean } }
|
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Assets fetched
|
description: Expiry alerts fetched
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/ApiResponse' }
|
||||||
|
/assets/alerts/service:
|
||||||
|
get:
|
||||||
|
tags: [Assets]
|
||||||
|
summary: Service due/overdue alerts
|
||||||
|
parameters:
|
||||||
|
- { name: status, in: query, schema: { type: string, enum: [OVERDUE, DUE_THIS_WEEK, DUE_THIS_MONTH, UPCOMING] } }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Service alerts fetched
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/ApiResponse' }
|
||||||
|
/assets/{id}/amc:
|
||||||
|
get:
|
||||||
|
tags: [Assets]
|
||||||
|
summary: List AMC contracts for asset
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: AMC contracts fetched
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/ApiResponse' }
|
schema: { $ref: '#/components/schemas/ApiResponse' }
|
||||||
post:
|
post:
|
||||||
tags: [Assets]
|
tags: [Assets]
|
||||||
summary: Create asset
|
summary: Create AMC contract
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AmcContractBody' }
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: AMC contract created
|
||||||
|
/assets/{id}/amc/{contractId}/renew:
|
||||||
|
patch:
|
||||||
|
tags: [Assets]
|
||||||
|
summary: Renew AMC contract
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
- { name: contractId, in: path, required: true, schema: { type: string } }
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [start_date, end_date]
|
||||||
|
properties:
|
||||||
|
start_date: { type: string, format: date }
|
||||||
|
end_date: { type: string, format: date }
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: AMC renewed
|
||||||
|
/assets/{id}/service-visits:
|
||||||
|
get:
|
||||||
|
tags: [Assets]
|
||||||
|
summary: List service visits
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Service visits fetched
|
||||||
|
post:
|
||||||
|
tags: [Assets]
|
||||||
|
summary: Log service visit
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/ServiceVisitBody' }
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Service visit created
|
||||||
|
/assets/{id}/insurance:
|
||||||
|
get:
|
||||||
|
tags: [Assets]
|
||||||
|
summary: List insurance policies
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Insurance policies fetched
|
||||||
|
post:
|
||||||
|
tags: [Assets]
|
||||||
|
summary: Create insurance policy
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/InsurancePolicyBody' }
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Insurance policy created
|
||||||
|
/assets/{id}/insurance/{policyId}/renew:
|
||||||
|
patch:
|
||||||
|
tags: [Assets]
|
||||||
|
summary: Renew insurance policy
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
- { name: policyId, in: path, required: true, schema: { type: string } }
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [policy_start_date, policy_end_date]
|
||||||
|
properties:
|
||||||
|
policy_start_date: { type: string, format: date }
|
||||||
|
policy_end_date: { type: string, format: date }
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Insurance renewed
|
||||||
|
/assets:
|
||||||
|
get:
|
||||||
|
tags: [Assets]
|
||||||
|
summary: List assets
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Assets fetched
|
||||||
|
post:
|
||||||
|
tags: [Assets]
|
||||||
|
summary: Create asset
|
||||||
|
requestBody:
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/AssetsCreateBody' }
|
schema: { $ref: '#/components/schemas/AssetsCreateBody' }
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: Asset created
|
description: Asset created
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/ApiResponse' }
|
|
||||||
/assets/{id}:
|
/assets/{id}:
|
||||||
get:
|
get:
|
||||||
tags: [Assets]
|
tags: [Assets]
|
||||||
summary: Get asset detail
|
summary: Get asset
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string, example: '1' } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Asset fetched
|
description: Asset fetched
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/ApiResponse' }
|
|
||||||
put:
|
put:
|
||||||
tags: [Assets]
|
tags: [Assets]
|
||||||
summary: Update asset
|
summary: Update asset
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string, example: '1' } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/AssetsUpdateBody' }
|
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Asset updated
|
description: Asset updated
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/ApiResponse' }
|
|
||||||
delete:
|
delete:
|
||||||
tags: [Assets]
|
tags: [Assets]
|
||||||
summary: Soft delete asset
|
summary: Soft delete asset
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string, example: '1' } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Asset deleted
|
description: Asset deleted
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/ApiResponse' }
|
|
||||||
/assets/{id}/transfer:
|
/assets/{id}/transfer:
|
||||||
post:
|
post:
|
||||||
tags: [Assets]
|
tags: [Assets]
|
||||||
summary: Transfer asset location/assignment
|
summary: Transfer asset
|
||||||
parameters:
|
parameters:
|
||||||
- { name: id, in: path, required: true, schema: { type: string, example: '1' } }
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/AssetsTransferBody' }
|
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Asset transferred
|
description: Asset transferred
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/ApiResponse' }
|
|
||||||
/assets/{id}/transfer-history:
|
|
||||||
get:
|
|
||||||
tags: [Assets]
|
|
||||||
summary: Get asset transfer history
|
|
||||||
parameters:
|
|
||||||
- { name: id, in: path, required: true, schema: { type: string, example: '1' } }
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Transfer history fetched
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/ApiResponse' }
|
|
||||||
|
|||||||
@ -361,6 +361,16 @@ components:
|
|||||||
name: { type: string, example: 'Purchase Manager' }
|
name: { type: string, example: 'Purchase Manager' }
|
||||||
description: { type: string, example: 'Manages purchase orders' }
|
description: { type: string, example: 'Manages purchase orders' }
|
||||||
is_active: { type: boolean, example: true }
|
is_active: { type: boolean, example: true }
|
||||||
|
permission_ids:
|
||||||
|
type: array
|
||||||
|
description: Optional initial permissions by permission id (from GET /roles/permissions)
|
||||||
|
items: { type: integer }
|
||||||
|
example: [1, 7, 13]
|
||||||
|
view_modules:
|
||||||
|
type: array
|
||||||
|
description: Optional module codes to grant view-only access (matches create-role UI checkboxes)
|
||||||
|
items: { type: string }
|
||||||
|
example: [USERS, MASTERS, VENDOR, PURCHASE_ORDER]
|
||||||
RolesUpdateBody:
|
RolesUpdateBody:
|
||||||
type: object
|
type: object
|
||||||
minProperties: 1
|
minProperties: 1
|
||||||
|
|||||||
74
src/modules/assets/assets-alerts.service.js
Normal file
74
src/modules/assets/assets-alerts.service.js
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
const { Prisma } = require('@prisma/client');
|
||||||
|
const prisma = require('../../config/prisma');
|
||||||
|
const { getPagination } = require('../../utils/pagination');
|
||||||
|
|
||||||
|
const serializeRow = (row) => {
|
||||||
|
const out = {};
|
||||||
|
for (const [key, value] of Object.entries(row)) {
|
||||||
|
out[key] = typeof value === 'bigint' ? value.toString() : value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getExpiryAlerts = async (query) => {
|
||||||
|
const days = Math.min(Math.max(Number(query.days) || 90, 1), 365);
|
||||||
|
const { page, limit, skip } = getPagination(query);
|
||||||
|
|
||||||
|
const typeClause = query.type ? Prisma.sql`AND alert_type = ${query.type}` : Prisma.empty;
|
||||||
|
|
||||||
|
const [rows, countResult] = await Promise.all([
|
||||||
|
prisma.$queryRaw`
|
||||||
|
SELECT *
|
||||||
|
FROM v_asset_expiry_alerts
|
||||||
|
WHERE days_remaining <= ${days}
|
||||||
|
${typeClause}
|
||||||
|
ORDER BY days_remaining ASC
|
||||||
|
LIMIT ${limit} OFFSET ${skip}
|
||||||
|
`,
|
||||||
|
prisma.$queryRaw`
|
||||||
|
SELECT COUNT(*)::int AS total
|
||||||
|
FROM v_asset_expiry_alerts
|
||||||
|
WHERE days_remaining <= ${days}
|
||||||
|
${typeClause}
|
||||||
|
`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = countResult[0]?.total ?? 0;
|
||||||
|
return {
|
||||||
|
data: rows.map(serializeRow),
|
||||||
|
meta: { page, limit, total, days_filter: days },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getServiceAlerts = async (query) => {
|
||||||
|
const { page, limit, skip } = getPagination(query);
|
||||||
|
|
||||||
|
const statusClause = query.status
|
||||||
|
? Prisma.sql`AND service_status = ${query.status}`
|
||||||
|
: Prisma.empty;
|
||||||
|
|
||||||
|
const [rows, countResult] = await Promise.all([
|
||||||
|
prisma.$queryRaw`
|
||||||
|
SELECT *
|
||||||
|
FROM v_asset_next_service
|
||||||
|
WHERE 1=1
|
||||||
|
${statusClause}
|
||||||
|
ORDER BY days_to_next_service ASC
|
||||||
|
LIMIT ${limit} OFFSET ${skip}
|
||||||
|
`,
|
||||||
|
prisma.$queryRaw`
|
||||||
|
SELECT COUNT(*)::int AS total
|
||||||
|
FROM v_asset_next_service
|
||||||
|
WHERE 1=1
|
||||||
|
${statusClause}
|
||||||
|
`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = countResult[0]?.total ?? 0;
|
||||||
|
return {
|
||||||
|
data: rows.map(serializeRow),
|
||||||
|
meta: { page, limit, total },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { getExpiryAlerts, getServiceAlerts };
|
||||||
217
src/modules/assets/assets-amc.service.js
Normal file
217
src/modules/assets/assets-amc.service.js
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
const prisma = require('../../config/prisma');
|
||||||
|
const ApiError = require('../../utils/ApiError');
|
||||||
|
const auditLog = require('../../utils/auditLog');
|
||||||
|
const {
|
||||||
|
toDateOnly,
|
||||||
|
assertReference,
|
||||||
|
getAssetOrThrow,
|
||||||
|
deactivateOtherActive,
|
||||||
|
} = require('./assets.helpers');
|
||||||
|
|
||||||
|
const amcInclude = {
|
||||||
|
vendors: { select: { id: true, vendor_code: true, vendor_name: true } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeAmc = (row) => {
|
||||||
|
if (!row) return null;
|
||||||
|
const { vendors, ...rest } = row;
|
||||||
|
return { ...rest, vendor: vendors || null };
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildAmcData = async (payload, assetId) => {
|
||||||
|
await assertReference('vendors', payload.vendor_id, 'vendor_id');
|
||||||
|
return {
|
||||||
|
asset_id: BigInt(assetId),
|
||||||
|
vendor_id: BigInt(payload.vendor_id),
|
||||||
|
contract_no: payload.contract_no || null,
|
||||||
|
contract_type: payload.contract_type ?? 'COMPREHENSIVE',
|
||||||
|
start_date: toDateOnly(payload.start_date),
|
||||||
|
end_date: toDateOnly(payload.end_date),
|
||||||
|
renewal_date: payload.renewal_date ? toDateOnly(payload.renewal_date) : null,
|
||||||
|
annual_cost: payload.annual_cost ?? 0,
|
||||||
|
payment_frequency: payload.payment_frequency ?? 'ANNUAL',
|
||||||
|
service_frequency: payload.service_frequency || null,
|
||||||
|
visits_per_year: payload.visits_per_year ?? null,
|
||||||
|
contact_person: payload.contact_person || null,
|
||||||
|
contact_phone: payload.contact_phone || null,
|
||||||
|
contact_email: payload.contact_email || null,
|
||||||
|
scope_of_work: payload.scope_of_work || null,
|
||||||
|
exclusions: payload.exclusions || null,
|
||||||
|
remarks: payload.remarks || null,
|
||||||
|
is_active: payload.is_active ?? true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAmcOrThrow = async (assetId, contractId) => {
|
||||||
|
const row = await prisma.asset_amc_contracts.findFirst({
|
||||||
|
where: {
|
||||||
|
id: BigInt(contractId),
|
||||||
|
asset_id: BigInt(assetId),
|
||||||
|
deleted_at: null,
|
||||||
|
},
|
||||||
|
include: amcInclude,
|
||||||
|
});
|
||||||
|
if (!row) throw new ApiError(404, 'AMC contract not found');
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
|
||||||
|
const listAmcContracts = async (assetId) => {
|
||||||
|
await getAssetOrThrow(assetId);
|
||||||
|
const rows = await prisma.asset_amc_contracts.findMany({
|
||||||
|
where: { asset_id: BigInt(assetId), deleted_at: null },
|
||||||
|
include: amcInclude,
|
||||||
|
orderBy: [{ is_active: 'desc' }, { start_date: 'desc' }],
|
||||||
|
});
|
||||||
|
return rows.map(sanitizeAmc);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAmcContract = async (assetId, payload, userId, requestId) => {
|
||||||
|
await getAssetOrThrow(assetId);
|
||||||
|
const data = await buildAmcData(payload, assetId);
|
||||||
|
|
||||||
|
const created = await prisma.$transaction(async (tx) => {
|
||||||
|
if (data.is_active) {
|
||||||
|
await deactivateOtherActive(tx, 'asset_amc_contracts', assetId);
|
||||||
|
}
|
||||||
|
return tx.asset_amc_contracts.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
created_by: userId ? BigInt(userId) : null,
|
||||||
|
updated_by: userId ? BigInt(userId) : null,
|
||||||
|
},
|
||||||
|
include: amcInclude,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
tableName: 'asset_amc_contracts',
|
||||||
|
recordId: created.id,
|
||||||
|
action: 'CREATE',
|
||||||
|
oldValue: null,
|
||||||
|
newValue: sanitizeAmc(created),
|
||||||
|
userId,
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return sanitizeAmc(created);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAmcContract = async (assetId, contractId) =>
|
||||||
|
sanitizeAmc(await getAmcOrThrow(assetId, contractId));
|
||||||
|
|
||||||
|
const updateAmcContract = async (assetId, contractId, payload, userId, requestId) => {
|
||||||
|
const existing = await getAmcOrThrow(assetId, contractId);
|
||||||
|
const merged = {
|
||||||
|
vendor_id: payload.vendor_id ?? existing.vendor_id,
|
||||||
|
contract_no: payload.contract_no !== undefined ? payload.contract_no : existing.contract_no,
|
||||||
|
contract_type: payload.contract_type ?? existing.contract_type,
|
||||||
|
start_date: payload.start_date ?? existing.start_date,
|
||||||
|
end_date: payload.end_date ?? existing.end_date,
|
||||||
|
renewal_date: payload.renewal_date !== undefined ? payload.renewal_date : existing.renewal_date,
|
||||||
|
annual_cost: payload.annual_cost ?? Number(existing.annual_cost),
|
||||||
|
payment_frequency: payload.payment_frequency ?? existing.payment_frequency,
|
||||||
|
service_frequency:
|
||||||
|
payload.service_frequency !== undefined
|
||||||
|
? payload.service_frequency
|
||||||
|
: existing.service_frequency,
|
||||||
|
visits_per_year:
|
||||||
|
payload.visits_per_year !== undefined ? payload.visits_per_year : existing.visits_per_year,
|
||||||
|
contact_person:
|
||||||
|
payload.contact_person !== undefined ? payload.contact_person : existing.contact_person,
|
||||||
|
contact_phone:
|
||||||
|
payload.contact_phone !== undefined ? payload.contact_phone : existing.contact_phone,
|
||||||
|
contact_email:
|
||||||
|
payload.contact_email !== undefined ? payload.contact_email : existing.contact_email,
|
||||||
|
scope_of_work:
|
||||||
|
payload.scope_of_work !== undefined ? payload.scope_of_work : existing.scope_of_work,
|
||||||
|
exclusions: payload.exclusions !== undefined ? payload.exclusions : existing.exclusions,
|
||||||
|
remarks: payload.remarks !== undefined ? payload.remarks : existing.remarks,
|
||||||
|
is_active: payload.is_active !== undefined ? payload.is_active : existing.is_active,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await buildAmcData(merged, assetId);
|
||||||
|
|
||||||
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
|
if (data.is_active) {
|
||||||
|
await deactivateOtherActive(tx, 'asset_amc_contracts', assetId, contractId);
|
||||||
|
}
|
||||||
|
return tx.asset_amc_contracts.update({
|
||||||
|
where: { id: BigInt(contractId) },
|
||||||
|
data: { ...data, updated_by: userId ? BigInt(userId) : null },
|
||||||
|
include: amcInclude,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
tableName: 'asset_amc_contracts',
|
||||||
|
recordId: contractId,
|
||||||
|
action: 'UPDATE',
|
||||||
|
oldValue: sanitizeAmc(existing),
|
||||||
|
newValue: sanitizeAmc(updated),
|
||||||
|
userId,
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return sanitizeAmc(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renewAmcContract = async (assetId, contractId, payload, userId, requestId) => {
|
||||||
|
const existing = await getAmcOrThrow(assetId, contractId);
|
||||||
|
const renewPayload = {
|
||||||
|
vendor_id: payload.vendor_id ?? existing.vendor_id,
|
||||||
|
contract_no: payload.contract_no ?? existing.contract_no,
|
||||||
|
contract_type: payload.contract_type ?? existing.contract_type,
|
||||||
|
start_date: payload.start_date,
|
||||||
|
end_date: payload.end_date,
|
||||||
|
renewal_date: payload.renewal_date ?? payload.end_date,
|
||||||
|
annual_cost: payload.annual_cost ?? Number(existing.annual_cost),
|
||||||
|
payment_frequency: payload.payment_frequency ?? existing.payment_frequency,
|
||||||
|
service_frequency: payload.service_frequency ?? existing.service_frequency,
|
||||||
|
visits_per_year: payload.visits_per_year ?? existing.visits_per_year,
|
||||||
|
contact_person: payload.contact_person ?? existing.contact_person,
|
||||||
|
contact_phone: payload.contact_phone ?? existing.contact_phone,
|
||||||
|
contact_email: payload.contact_email ?? existing.contact_email,
|
||||||
|
scope_of_work: payload.scope_of_work ?? existing.scope_of_work,
|
||||||
|
exclusions: payload.exclusions ?? existing.exclusions,
|
||||||
|
remarks: payload.remarks ?? existing.remarks,
|
||||||
|
is_active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await buildAmcData(renewPayload, assetId);
|
||||||
|
|
||||||
|
const created = await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.asset_amc_contracts.update({
|
||||||
|
where: { id: BigInt(contractId) },
|
||||||
|
data: { is_active: false, updated_by: userId ? BigInt(userId) : null },
|
||||||
|
});
|
||||||
|
await deactivateOtherActive(tx, 'asset_amc_contracts', assetId, contractId);
|
||||||
|
return tx.asset_amc_contracts.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
created_by: userId ? BigInt(userId) : null,
|
||||||
|
updated_by: userId ? BigInt(userId) : null,
|
||||||
|
},
|
||||||
|
include: amcInclude,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
tableName: 'asset_amc_contracts',
|
||||||
|
recordId: contractId,
|
||||||
|
action: 'RENEW',
|
||||||
|
oldValue: sanitizeAmc(existing),
|
||||||
|
newValue: sanitizeAmc(created),
|
||||||
|
userId,
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return sanitizeAmc(created);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
listAmcContracts,
|
||||||
|
createAmcContract,
|
||||||
|
getAmcContract,
|
||||||
|
updateAmcContract,
|
||||||
|
renewAmcContract,
|
||||||
|
};
|
||||||
195
src/modules/assets/assets-insurance.service.js
Normal file
195
src/modules/assets/assets-insurance.service.js
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
const prisma = require('../../config/prisma');
|
||||||
|
const ApiError = require('../../utils/ApiError');
|
||||||
|
const auditLog = require('../../utils/auditLog');
|
||||||
|
const { toDateOnly, getAssetOrThrow, deactivateOtherActive } = require('./assets.helpers');
|
||||||
|
|
||||||
|
const sanitizeInsurance = (row) => (row ? { ...row } : null);
|
||||||
|
|
||||||
|
const buildInsuranceData = async (payload, assetId) => ({
|
||||||
|
asset_id: BigInt(assetId),
|
||||||
|
policy_no: payload.policy_no,
|
||||||
|
insurer_name: payload.insurer_name,
|
||||||
|
insurer_branch: payload.insurer_branch || null,
|
||||||
|
insurer_contact: payload.insurer_contact || null,
|
||||||
|
insurer_phone: payload.insurer_phone || null,
|
||||||
|
insurer_email: payload.insurer_email || null,
|
||||||
|
policy_type: payload.policy_type ?? 'FIRE_AND_ALLIED',
|
||||||
|
sum_insured: payload.sum_insured ?? 0,
|
||||||
|
annual_premium: payload.annual_premium ?? 0,
|
||||||
|
policy_start_date: toDateOnly(payload.policy_start_date),
|
||||||
|
policy_end_date: toDateOnly(payload.policy_end_date),
|
||||||
|
renewal_date: payload.renewal_date ? toDateOnly(payload.renewal_date) : null,
|
||||||
|
is_auto_renewal: payload.is_auto_renewal ?? false,
|
||||||
|
premium_paid: payload.premium_paid ?? false,
|
||||||
|
premium_paid_date: payload.premium_paid_date ? toDateOnly(payload.premium_paid_date) : null,
|
||||||
|
remarks: payload.remarks || null,
|
||||||
|
is_active: payload.is_active ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getInsuranceOrThrow = async (assetId, policyId) => {
|
||||||
|
const row = await prisma.asset_insurance_policies.findFirst({
|
||||||
|
where: {
|
||||||
|
id: BigInt(policyId),
|
||||||
|
asset_id: BigInt(assetId),
|
||||||
|
deleted_at: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!row) throw new ApiError(404, 'Insurance policy not found');
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
|
||||||
|
const listInsurancePolicies = async (assetId) => {
|
||||||
|
await getAssetOrThrow(assetId);
|
||||||
|
const rows = await prisma.asset_insurance_policies.findMany({
|
||||||
|
where: { asset_id: BigInt(assetId), deleted_at: null },
|
||||||
|
orderBy: [{ is_active: 'desc' }, { policy_start_date: 'desc' }],
|
||||||
|
});
|
||||||
|
return rows.map(sanitizeInsurance);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createInsurancePolicy = async (assetId, payload, userId, requestId) => {
|
||||||
|
await getAssetOrThrow(assetId);
|
||||||
|
const data = await buildInsuranceData(payload, assetId);
|
||||||
|
|
||||||
|
const created = await prisma.$transaction(async (tx) => {
|
||||||
|
if (data.is_active) {
|
||||||
|
await deactivateOtherActive(tx, 'asset_insurance_policies', assetId);
|
||||||
|
}
|
||||||
|
return tx.asset_insurance_policies.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
created_by: userId ? BigInt(userId) : null,
|
||||||
|
updated_by: userId ? BigInt(userId) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
tableName: 'asset_insurance_policies',
|
||||||
|
recordId: created.id,
|
||||||
|
action: 'CREATE',
|
||||||
|
oldValue: null,
|
||||||
|
newValue: sanitizeInsurance(created),
|
||||||
|
userId,
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return sanitizeInsurance(created);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInsurancePolicy = async (assetId, policyId) =>
|
||||||
|
sanitizeInsurance(await getInsuranceOrThrow(assetId, policyId));
|
||||||
|
|
||||||
|
const updateInsurancePolicy = async (assetId, policyId, payload, userId, requestId) => {
|
||||||
|
const existing = await getInsuranceOrThrow(assetId, policyId);
|
||||||
|
const merged = {
|
||||||
|
policy_no: payload.policy_no ?? existing.policy_no,
|
||||||
|
insurer_name: payload.insurer_name ?? existing.insurer_name,
|
||||||
|
insurer_branch:
|
||||||
|
payload.insurer_branch !== undefined ? payload.insurer_branch : existing.insurer_branch,
|
||||||
|
insurer_contact:
|
||||||
|
payload.insurer_contact !== undefined ? payload.insurer_contact : existing.insurer_contact,
|
||||||
|
insurer_phone:
|
||||||
|
payload.insurer_phone !== undefined ? payload.insurer_phone : existing.insurer_phone,
|
||||||
|
insurer_email:
|
||||||
|
payload.insurer_email !== undefined ? payload.insurer_email : existing.insurer_email,
|
||||||
|
policy_type: payload.policy_type ?? existing.policy_type,
|
||||||
|
sum_insured: payload.sum_insured ?? Number(existing.sum_insured),
|
||||||
|
annual_premium: payload.annual_premium ?? Number(existing.annual_premium),
|
||||||
|
policy_start_date: payload.policy_start_date ?? existing.policy_start_date,
|
||||||
|
policy_end_date: payload.policy_end_date ?? existing.policy_end_date,
|
||||||
|
renewal_date: payload.renewal_date !== undefined ? payload.renewal_date : existing.renewal_date,
|
||||||
|
is_auto_renewal: payload.is_auto_renewal ?? existing.is_auto_renewal,
|
||||||
|
premium_paid: payload.premium_paid ?? existing.premium_paid,
|
||||||
|
premium_paid_date:
|
||||||
|
payload.premium_paid_date !== undefined
|
||||||
|
? payload.premium_paid_date
|
||||||
|
: existing.premium_paid_date,
|
||||||
|
remarks: payload.remarks !== undefined ? payload.remarks : existing.remarks,
|
||||||
|
is_active: payload.is_active !== undefined ? payload.is_active : existing.is_active,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await buildInsuranceData(merged, assetId);
|
||||||
|
|
||||||
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
|
if (data.is_active) {
|
||||||
|
await deactivateOtherActive(tx, 'asset_insurance_policies', assetId, policyId);
|
||||||
|
}
|
||||||
|
return tx.asset_insurance_policies.update({
|
||||||
|
where: { id: BigInt(policyId) },
|
||||||
|
data: { ...data, updated_by: userId ? BigInt(userId) : null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
tableName: 'asset_insurance_policies',
|
||||||
|
recordId: policyId,
|
||||||
|
action: 'UPDATE',
|
||||||
|
oldValue: sanitizeInsurance(existing),
|
||||||
|
newValue: sanitizeInsurance(updated),
|
||||||
|
userId,
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return sanitizeInsurance(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renewInsurancePolicy = async (assetId, policyId, payload, userId, requestId) => {
|
||||||
|
const existing = await getInsuranceOrThrow(assetId, policyId);
|
||||||
|
const renewPayload = {
|
||||||
|
policy_no: payload.policy_no ?? existing.policy_no,
|
||||||
|
insurer_name: payload.insurer_name ?? existing.insurer_name,
|
||||||
|
insurer_branch: payload.insurer_branch ?? existing.insurer_branch,
|
||||||
|
insurer_contact: payload.insurer_contact ?? existing.insurer_contact,
|
||||||
|
insurer_phone: payload.insurer_phone ?? existing.insurer_phone,
|
||||||
|
insurer_email: payload.insurer_email ?? existing.insurer_email,
|
||||||
|
policy_type: payload.policy_type ?? existing.policy_type,
|
||||||
|
sum_insured: payload.sum_insured ?? Number(existing.sum_insured),
|
||||||
|
annual_premium: payload.annual_premium ?? Number(existing.annual_premium),
|
||||||
|
policy_start_date: payload.policy_start_date,
|
||||||
|
policy_end_date: payload.policy_end_date,
|
||||||
|
renewal_date: payload.renewal_date ?? payload.policy_end_date,
|
||||||
|
is_auto_renewal: payload.is_auto_renewal ?? existing.is_auto_renewal,
|
||||||
|
premium_paid: payload.premium_paid ?? false,
|
||||||
|
premium_paid_date: payload.premium_paid_date ?? null,
|
||||||
|
remarks: payload.remarks ?? existing.remarks,
|
||||||
|
is_active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await buildInsuranceData(renewPayload, assetId);
|
||||||
|
|
||||||
|
const created = await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.asset_insurance_policies.update({
|
||||||
|
where: { id: BigInt(policyId) },
|
||||||
|
data: { is_active: false, updated_by: userId ? BigInt(userId) : null },
|
||||||
|
});
|
||||||
|
await deactivateOtherActive(tx, 'asset_insurance_policies', assetId, policyId);
|
||||||
|
return tx.asset_insurance_policies.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
created_by: userId ? BigInt(userId) : null,
|
||||||
|
updated_by: userId ? BigInt(userId) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
tableName: 'asset_insurance_policies',
|
||||||
|
recordId: policyId,
|
||||||
|
action: 'RENEW',
|
||||||
|
oldValue: sanitizeInsurance(existing),
|
||||||
|
newValue: sanitizeInsurance(created),
|
||||||
|
userId,
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return sanitizeInsurance(created);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
listInsurancePolicies,
|
||||||
|
createInsurancePolicy,
|
||||||
|
getInsurancePolicy,
|
||||||
|
updateInsurancePolicy,
|
||||||
|
renewInsurancePolicy,
|
||||||
|
};
|
||||||
204
src/modules/assets/assets-service-visits.service.js
Normal file
204
src/modules/assets/assets-service-visits.service.js
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
const prisma = require('../../config/prisma');
|
||||||
|
const ApiError = require('../../utils/ApiError');
|
||||||
|
const auditLog = require('../../utils/auditLog');
|
||||||
|
const { toDateOnly, assertReference, getAssetOrThrow } = require('./assets.helpers');
|
||||||
|
|
||||||
|
const visitInclude = {
|
||||||
|
vendors: { select: { id: true, vendor_code: true, vendor_name: true } },
|
||||||
|
asset_amc_contracts: { select: { id: true, contract_no: true, end_date: true } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeVisit = (row) => {
|
||||||
|
if (!row) return null;
|
||||||
|
const { vendors, asset_amc_contracts, ...rest } = row;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
vendor: vendors || null,
|
||||||
|
amc_contract: asset_amc_contracts || null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildVisitData = async (payload, assetId) => {
|
||||||
|
if (payload.amc_contract_id) {
|
||||||
|
const amc = await prisma.asset_amc_contracts.findFirst({
|
||||||
|
where: {
|
||||||
|
id: BigInt(payload.amc_contract_id),
|
||||||
|
asset_id: BigInt(assetId),
|
||||||
|
deleted_at: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!amc) throw new ApiError(422, 'Invalid amc_contract_id for this asset');
|
||||||
|
}
|
||||||
|
if (payload.vendor_id) await assertReference('vendors', payload.vendor_id, 'vendor_id');
|
||||||
|
|
||||||
|
return {
|
||||||
|
asset_id: BigInt(assetId),
|
||||||
|
amc_contract_id: payload.amc_contract_id ? BigInt(payload.amc_contract_id) : null,
|
||||||
|
visit_type: payload.visit_type,
|
||||||
|
visit_date: toDateOnly(payload.visit_date),
|
||||||
|
visit_number: payload.visit_number ?? null,
|
||||||
|
complaint_no: payload.complaint_no || null,
|
||||||
|
complaint_date: payload.complaint_date ? toDateOnly(payload.complaint_date) : null,
|
||||||
|
complaint_desc: payload.complaint_desc || null,
|
||||||
|
engineer_name: payload.engineer_name || null,
|
||||||
|
engineer_phone: payload.engineer_phone || null,
|
||||||
|
vendor_id: payload.vendor_id ? BigInt(payload.vendor_id) : null,
|
||||||
|
work_done: payload.work_done || null,
|
||||||
|
parts_replaced: payload.parts_replaced || null,
|
||||||
|
next_service_date: payload.next_service_date ? toDateOnly(payload.next_service_date) : null,
|
||||||
|
status: payload.status ?? 'COMPLETED',
|
||||||
|
downtime_hours: payload.downtime_hours ?? 0,
|
||||||
|
service_cost: payload.service_cost ?? 0,
|
||||||
|
is_under_amc: payload.is_under_amc ?? false,
|
||||||
|
asset_condition_after: payload.asset_condition_after || null,
|
||||||
|
remarks: payload.remarks || null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVisitOrThrow = async (assetId, visitId) => {
|
||||||
|
const row = await prisma.asset_service_visits.findFirst({
|
||||||
|
where: {
|
||||||
|
id: BigInt(visitId),
|
||||||
|
asset_id: BigInt(assetId),
|
||||||
|
deleted_at: null,
|
||||||
|
},
|
||||||
|
include: visitInclude,
|
||||||
|
});
|
||||||
|
if (!row) throw new ApiError(404, 'Service visit not found');
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
|
||||||
|
const listServiceVisits = async (assetId, query = {}) => {
|
||||||
|
await getAssetOrThrow(assetId);
|
||||||
|
const rows = await prisma.asset_service_visits.findMany({
|
||||||
|
where: {
|
||||||
|
asset_id: BigInt(assetId),
|
||||||
|
deleted_at: null,
|
||||||
|
...(query.status ? { status: query.status } : {}),
|
||||||
|
...(query.visit_type ? { visit_type: query.visit_type } : {}),
|
||||||
|
},
|
||||||
|
include: visitInclude,
|
||||||
|
orderBy: [{ visit_date: 'desc' }, { created_at: 'desc' }],
|
||||||
|
});
|
||||||
|
return rows.map(sanitizeVisit);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createServiceVisit = async (assetId, payload, userId, requestId) => {
|
||||||
|
await getAssetOrThrow(assetId);
|
||||||
|
const data = await buildVisitData(payload, assetId);
|
||||||
|
|
||||||
|
const created = await prisma.asset_service_visits.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
created_by: userId ? BigInt(userId) : null,
|
||||||
|
updated_by: userId ? BigInt(userId) : null,
|
||||||
|
},
|
||||||
|
include: visitInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
tableName: 'asset_service_visits',
|
||||||
|
recordId: created.id,
|
||||||
|
action: 'CREATE',
|
||||||
|
oldValue: null,
|
||||||
|
newValue: sanitizeVisit(created),
|
||||||
|
userId,
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return sanitizeVisit(created);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getServiceVisit = async (assetId, visitId) =>
|
||||||
|
sanitizeVisit(await getVisitOrThrow(assetId, visitId));
|
||||||
|
|
||||||
|
const updateServiceVisit = async (assetId, visitId, payload, userId, requestId) => {
|
||||||
|
const existing = await getVisitOrThrow(assetId, visitId);
|
||||||
|
const merged = {
|
||||||
|
amc_contract_id:
|
||||||
|
payload.amc_contract_id !== undefined ? payload.amc_contract_id : existing.amc_contract_id,
|
||||||
|
visit_type: payload.visit_type ?? existing.visit_type,
|
||||||
|
visit_date: payload.visit_date ?? existing.visit_date,
|
||||||
|
visit_number: payload.visit_number !== undefined ? payload.visit_number : existing.visit_number,
|
||||||
|
complaint_no: payload.complaint_no !== undefined ? payload.complaint_no : existing.complaint_no,
|
||||||
|
complaint_date:
|
||||||
|
payload.complaint_date !== undefined ? payload.complaint_date : existing.complaint_date,
|
||||||
|
complaint_desc:
|
||||||
|
payload.complaint_desc !== undefined ? payload.complaint_desc : existing.complaint_desc,
|
||||||
|
engineer_name:
|
||||||
|
payload.engineer_name !== undefined ? payload.engineer_name : existing.engineer_name,
|
||||||
|
engineer_phone:
|
||||||
|
payload.engineer_phone !== undefined ? payload.engineer_phone : existing.engineer_phone,
|
||||||
|
vendor_id: payload.vendor_id !== undefined ? payload.vendor_id : existing.vendor_id,
|
||||||
|
work_done: payload.work_done !== undefined ? payload.work_done : existing.work_done,
|
||||||
|
parts_replaced:
|
||||||
|
payload.parts_replaced !== undefined ? payload.parts_replaced : existing.parts_replaced,
|
||||||
|
next_service_date:
|
||||||
|
payload.next_service_date !== undefined
|
||||||
|
? payload.next_service_date
|
||||||
|
: existing.next_service_date,
|
||||||
|
status: payload.status ?? existing.status,
|
||||||
|
downtime_hours: payload.downtime_hours ?? Number(existing.downtime_hours ?? 0),
|
||||||
|
service_cost: payload.service_cost ?? Number(existing.service_cost ?? 0),
|
||||||
|
is_under_amc: payload.is_under_amc ?? existing.is_under_amc,
|
||||||
|
asset_condition_after:
|
||||||
|
payload.asset_condition_after !== undefined
|
||||||
|
? payload.asset_condition_after
|
||||||
|
: existing.asset_condition_after,
|
||||||
|
remarks: payload.remarks !== undefined ? payload.remarks : existing.remarks,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await buildVisitData(merged, assetId);
|
||||||
|
|
||||||
|
const updated = await prisma.asset_service_visits.update({
|
||||||
|
where: { id: BigInt(visitId) },
|
||||||
|
data: { ...data, updated_by: userId ? BigInt(userId) : null },
|
||||||
|
include: visitInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
tableName: 'asset_service_visits',
|
||||||
|
recordId: visitId,
|
||||||
|
action: 'UPDATE',
|
||||||
|
oldValue: sanitizeVisit(existing),
|
||||||
|
newValue: sanitizeVisit(updated),
|
||||||
|
userId,
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return sanitizeVisit(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateServiceVisitStatus = async (assetId, visitId, payload, userId, requestId) => {
|
||||||
|
const existing = await getVisitOrThrow(assetId, visitId);
|
||||||
|
|
||||||
|
const updated = await prisma.asset_service_visits.update({
|
||||||
|
where: { id: BigInt(visitId) },
|
||||||
|
data: {
|
||||||
|
status: payload.status,
|
||||||
|
remarks: payload.remarks !== undefined ? payload.remarks : existing.remarks,
|
||||||
|
updated_by: userId ? BigInt(userId) : null,
|
||||||
|
},
|
||||||
|
include: visitInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
tableName: 'asset_service_visits',
|
||||||
|
recordId: visitId,
|
||||||
|
action: 'STATUS_CHANGE',
|
||||||
|
oldValue: sanitizeVisit(existing),
|
||||||
|
newValue: sanitizeVisit(updated),
|
||||||
|
userId,
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return sanitizeVisit(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
listServiceVisits,
|
||||||
|
createServiceVisit,
|
||||||
|
getServiceVisit,
|
||||||
|
updateServiceVisit,
|
||||||
|
updateServiceVisitStatus,
|
||||||
|
};
|
||||||
@ -3,9 +3,47 @@ const ASSET_STATUSES = ['IN_USE', 'IDLE', 'UNDER_MAINTENANCE', 'DISPOSED', 'SCRA
|
|||||||
const DEPRECIATION_METHODS = ['SLM', 'WDV'];
|
const DEPRECIATION_METHODS = ['SLM', 'WDV'];
|
||||||
const DISPOSAL_STATUSES = ['DISPOSED', 'SCRAPPED'];
|
const DISPOSAL_STATUSES = ['DISPOSED', 'SCRAPPED'];
|
||||||
|
|
||||||
|
const AMC_CONTRACT_TYPES = ['COMPREHENSIVE', 'LABOUR_ONLY', 'PARTS_ONLY', 'PREVENTIVE_ONLY'];
|
||||||
|
const PAYMENT_FREQUENCIES = ['MONTHLY', 'QUARTERLY', 'HALF_YEARLY', 'ANNUAL'];
|
||||||
|
const SERVICE_FREQUENCIES = ['MONTHLY', 'QUARTERLY', 'HALF_YEARLY', 'ANNUAL'];
|
||||||
|
|
||||||
|
const VISIT_TYPES = [
|
||||||
|
'PREVENTIVE',
|
||||||
|
'BREAKDOWN',
|
||||||
|
'INSPECTION',
|
||||||
|
'INSTALLATION',
|
||||||
|
'CALIBRATION',
|
||||||
|
'OTHER',
|
||||||
|
];
|
||||||
|
const VISIT_STATUSES = ['SCHEDULED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'PENDING_PARTS'];
|
||||||
|
const VISIT_CONDITION_AFTER = ['GOOD', 'FAIR', 'POOR', 'NEEDS_REPLACEMENT'];
|
||||||
|
|
||||||
|
const INSURANCE_POLICY_TYPES = [
|
||||||
|
'FIRE_AND_ALLIED',
|
||||||
|
'MACHINERY_BREAKDOWN',
|
||||||
|
'COMPREHENSIVE',
|
||||||
|
'THIRD_PARTY',
|
||||||
|
'VEHICLE',
|
||||||
|
'OTHER',
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALERT_TYPES = ['AMC', 'INSURANCE', 'WARRANTY'];
|
||||||
|
const ALERT_LEVELS = ['EXPIRED', 'CRITICAL', 'WARNING', 'INFO'];
|
||||||
|
const SERVICE_ALERT_STATUSES = ['OVERDUE', 'DUE_THIS_WEEK', 'DUE_THIS_MONTH', 'UPCOMING'];
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
ASSET_CONDITIONS,
|
ASSET_CONDITIONS,
|
||||||
ASSET_STATUSES,
|
ASSET_STATUSES,
|
||||||
DEPRECIATION_METHODS,
|
DEPRECIATION_METHODS,
|
||||||
DISPOSAL_STATUSES,
|
DISPOSAL_STATUSES,
|
||||||
|
AMC_CONTRACT_TYPES,
|
||||||
|
PAYMENT_FREQUENCIES,
|
||||||
|
SERVICE_FREQUENCIES,
|
||||||
|
VISIT_TYPES,
|
||||||
|
VISIT_STATUSES,
|
||||||
|
VISIT_CONDITION_AFTER,
|
||||||
|
INSURANCE_POLICY_TYPES,
|
||||||
|
ALERT_TYPES,
|
||||||
|
ALERT_LEVELS,
|
||||||
|
SERVICE_ALERT_STATUSES,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
const asyncHandler = require('../../utils/asyncHandler');
|
const asyncHandler = require('../../utils/asyncHandler');
|
||||||
const ApiResponse = require('../../utils/ApiResponse');
|
const ApiResponse = require('../../utils/ApiResponse');
|
||||||
const service = require('./assets.service');
|
const service = require('./assets.service');
|
||||||
|
const amcService = require('./assets-amc.service');
|
||||||
|
const visitsService = require('./assets-service-visits.service');
|
||||||
|
const insuranceService = require('./assets-insurance.service');
|
||||||
|
const alertsService = require('./assets-alerts.service');
|
||||||
|
|
||||||
const create = asyncHandler(async (req, res) => {
|
const create = asyncHandler(async (req, res) => {
|
||||||
const data = await service.createAsset(req.body, req.user?.id, req.id);
|
const data = await service.createAsset(req.body, req.user?.id, req.id);
|
||||||
@ -37,4 +41,160 @@ const transferHistory = asyncHandler(async (req, res) => {
|
|||||||
res.json(new ApiResponse(200, data, 'Asset transfer history fetched'));
|
res.json(new ApiResponse(200, data, 'Asset transfer history fetched'));
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = { create, list, getOne, update, remove, transfer, transferHistory };
|
const listAmc = asyncHandler(async (req, res) => {
|
||||||
|
const data = await amcService.listAmcContracts(req.params.id);
|
||||||
|
res.json(new ApiResponse(200, data, 'AMC contracts fetched'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const createAmc = asyncHandler(async (req, res) => {
|
||||||
|
const data = await amcService.createAmcContract(req.params.id, req.body, req.user?.id, req.id);
|
||||||
|
res.status(201).json(new ApiResponse(201, data, 'AMC contract created successfully'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const getAmc = asyncHandler(async (req, res) => {
|
||||||
|
const data = await amcService.getAmcContract(req.params.id, req.params.contractId);
|
||||||
|
res.json(new ApiResponse(200, data, 'AMC contract fetched'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateAmc = asyncHandler(async (req, res) => {
|
||||||
|
const data = await amcService.updateAmcContract(
|
||||||
|
req.params.id,
|
||||||
|
req.params.contractId,
|
||||||
|
req.body,
|
||||||
|
req.user?.id,
|
||||||
|
req.id
|
||||||
|
);
|
||||||
|
res.json(new ApiResponse(200, data, 'AMC contract updated successfully'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const renewAmc = asyncHandler(async (req, res) => {
|
||||||
|
const data = await amcService.renewAmcContract(
|
||||||
|
req.params.id,
|
||||||
|
req.params.contractId,
|
||||||
|
req.body,
|
||||||
|
req.user?.id,
|
||||||
|
req.id
|
||||||
|
);
|
||||||
|
res.status(201).json(new ApiResponse(201, data, 'AMC contract renewed successfully'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const listVisits = asyncHandler(async (req, res) => {
|
||||||
|
const data = await visitsService.listServiceVisits(req.params.id, req.query);
|
||||||
|
res.json(new ApiResponse(200, data, 'Service visits fetched'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const createVisit = asyncHandler(async (req, res) => {
|
||||||
|
const data = await visitsService.createServiceVisit(
|
||||||
|
req.params.id,
|
||||||
|
req.body,
|
||||||
|
req.user?.id,
|
||||||
|
req.id
|
||||||
|
);
|
||||||
|
res.status(201).json(new ApiResponse(201, data, 'Service visit logged successfully'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const getVisit = asyncHandler(async (req, res) => {
|
||||||
|
const data = await visitsService.getServiceVisit(req.params.id, req.params.visitId);
|
||||||
|
res.json(new ApiResponse(200, data, 'Service visit fetched'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateVisit = asyncHandler(async (req, res) => {
|
||||||
|
const data = await visitsService.updateServiceVisit(
|
||||||
|
req.params.id,
|
||||||
|
req.params.visitId,
|
||||||
|
req.body,
|
||||||
|
req.user?.id,
|
||||||
|
req.id
|
||||||
|
);
|
||||||
|
res.json(new ApiResponse(200, data, 'Service visit updated successfully'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateVisitStatus = asyncHandler(async (req, res) => {
|
||||||
|
const data = await visitsService.updateServiceVisitStatus(
|
||||||
|
req.params.id,
|
||||||
|
req.params.visitId,
|
||||||
|
req.body,
|
||||||
|
req.user?.id,
|
||||||
|
req.id
|
||||||
|
);
|
||||||
|
res.json(new ApiResponse(200, data, 'Service visit status updated'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const listInsurance = asyncHandler(async (req, res) => {
|
||||||
|
const data = await insuranceService.listInsurancePolicies(req.params.id);
|
||||||
|
res.json(new ApiResponse(200, data, 'Insurance policies fetched'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const createInsurance = asyncHandler(async (req, res) => {
|
||||||
|
const data = await insuranceService.createInsurancePolicy(
|
||||||
|
req.params.id,
|
||||||
|
req.body,
|
||||||
|
req.user?.id,
|
||||||
|
req.id
|
||||||
|
);
|
||||||
|
res.status(201).json(new ApiResponse(201, data, 'Insurance policy created successfully'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const getInsurance = asyncHandler(async (req, res) => {
|
||||||
|
const data = await insuranceService.getInsurancePolicy(req.params.id, req.params.policyId);
|
||||||
|
res.json(new ApiResponse(200, data, 'Insurance policy fetched'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateInsurance = asyncHandler(async (req, res) => {
|
||||||
|
const data = await insuranceService.updateInsurancePolicy(
|
||||||
|
req.params.id,
|
||||||
|
req.params.policyId,
|
||||||
|
req.body,
|
||||||
|
req.user?.id,
|
||||||
|
req.id
|
||||||
|
);
|
||||||
|
res.json(new ApiResponse(200, data, 'Insurance policy updated successfully'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const renewInsurance = asyncHandler(async (req, res) => {
|
||||||
|
const data = await insuranceService.renewInsurancePolicy(
|
||||||
|
req.params.id,
|
||||||
|
req.params.policyId,
|
||||||
|
req.body,
|
||||||
|
req.user?.id,
|
||||||
|
req.id
|
||||||
|
);
|
||||||
|
res.status(201).json(new ApiResponse(201, data, 'Insurance policy renewed successfully'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const expiryAlerts = asyncHandler(async (req, res) => {
|
||||||
|
const result = await alertsService.getExpiryAlerts(req.query);
|
||||||
|
res.json(new ApiResponse(200, result.data, 'Expiry alerts fetched', result.meta));
|
||||||
|
});
|
||||||
|
|
||||||
|
const serviceAlerts = asyncHandler(async (req, res) => {
|
||||||
|
const result = await alertsService.getServiceAlerts(req.query);
|
||||||
|
res.json(new ApiResponse(200, result.data, 'Service alerts fetched', result.meta));
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
create,
|
||||||
|
list,
|
||||||
|
getOne,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
transfer,
|
||||||
|
transferHistory,
|
||||||
|
listAmc,
|
||||||
|
createAmc,
|
||||||
|
getAmc,
|
||||||
|
updateAmc,
|
||||||
|
renewAmc,
|
||||||
|
listVisits,
|
||||||
|
createVisit,
|
||||||
|
getVisit,
|
||||||
|
updateVisit,
|
||||||
|
updateVisitStatus,
|
||||||
|
listInsurance,
|
||||||
|
createInsurance,
|
||||||
|
getInsurance,
|
||||||
|
updateInsurance,
|
||||||
|
renewInsurance,
|
||||||
|
expiryAlerts,
|
||||||
|
serviceAlerts,
|
||||||
|
};
|
||||||
|
|||||||
61
src/modules/assets/assets.helpers.js
Normal file
61
src/modules/assets/assets.helpers.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
const prisma = require('../../config/prisma');
|
||||||
|
const ApiError = require('../../utils/ApiError');
|
||||||
|
|
||||||
|
const SOFT_DELETE_TABLES = new Set([
|
||||||
|
'vendors',
|
||||||
|
'plants',
|
||||||
|
'warehouses',
|
||||||
|
'departments',
|
||||||
|
'users',
|
||||||
|
'purchase_orders',
|
||||||
|
'grn',
|
||||||
|
'grn_items',
|
||||||
|
'asset_categories',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const toDateOnly = (value) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = new Date(value);
|
||||||
|
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertReference = async (table, id, label, { requireActive = true } = {}) => {
|
||||||
|
if (!id) return null;
|
||||||
|
const row = await prisma[table].findFirst({
|
||||||
|
where: {
|
||||||
|
id: BigInt(id),
|
||||||
|
...(SOFT_DELETE_TABLES.has(table) ? { deleted_at: null } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!row) throw new ApiError(422, `Invalid ${label}`);
|
||||||
|
if (requireActive && row.is_active === false) throw new ApiError(422, `${label} is inactive`);
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAssetOrThrow = async (id) => {
|
||||||
|
const asset = await prisma.assets.findFirst({
|
||||||
|
where: { id: BigInt(id), deleted_at: null },
|
||||||
|
});
|
||||||
|
if (!asset) throw new ApiError(404, 'Asset not found');
|
||||||
|
return asset;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deactivateOtherActive = async (tx, table, assetId, excludeId = null) => {
|
||||||
|
await tx[table].updateMany({
|
||||||
|
where: {
|
||||||
|
asset_id: BigInt(assetId),
|
||||||
|
is_active: true,
|
||||||
|
deleted_at: null,
|
||||||
|
...(excludeId ? { id: { not: BigInt(excludeId) } } : {}),
|
||||||
|
},
|
||||||
|
data: { is_active: false },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
toDateOnly,
|
||||||
|
assertReference,
|
||||||
|
getAssetOrThrow,
|
||||||
|
deactivateOtherActive,
|
||||||
|
SOFT_DELETE_TABLES,
|
||||||
|
};
|
||||||
@ -8,6 +8,18 @@ const {
|
|||||||
updateAssetSchema,
|
updateAssetSchema,
|
||||||
listAssetsQuerySchema,
|
listAssetsQuerySchema,
|
||||||
transferAssetSchema,
|
transferAssetSchema,
|
||||||
|
amcContractSchema,
|
||||||
|
updateAmcContractSchema,
|
||||||
|
renewAmcContractSchema,
|
||||||
|
serviceVisitSchema,
|
||||||
|
updateServiceVisitSchema,
|
||||||
|
serviceVisitStatusSchema,
|
||||||
|
listServiceVisitsQuerySchema,
|
||||||
|
insurancePolicySchema,
|
||||||
|
updateInsurancePolicySchema,
|
||||||
|
renewInsurancePolicySchema,
|
||||||
|
expiryAlertsQuerySchema,
|
||||||
|
serviceAlertsQuerySchema,
|
||||||
} = require('./assets.validation');
|
} = require('./assets.validation');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@ -22,6 +34,87 @@ router.get(
|
|||||||
);
|
);
|
||||||
router.post('/', authorize('ASSET', 'create'), validate(createAssetSchema), controller.create);
|
router.post('/', authorize('ASSET', 'create'), validate(createAssetSchema), controller.create);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/alerts/expiry',
|
||||||
|
authorize('ASSET', 'view'),
|
||||||
|
validate(expiryAlertsQuerySchema, 'query'),
|
||||||
|
controller.expiryAlerts
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
'/alerts/service',
|
||||||
|
authorize('ASSET', 'view'),
|
||||||
|
validate(serviceAlertsQuerySchema, 'query'),
|
||||||
|
controller.serviceAlerts
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get('/:id/amc', authorize('ASSET', 'view'), controller.listAmc);
|
||||||
|
router.post(
|
||||||
|
'/:id/amc',
|
||||||
|
authorize('ASSET', 'create'),
|
||||||
|
validate(amcContractSchema),
|
||||||
|
controller.createAmc
|
||||||
|
);
|
||||||
|
router.get('/:id/amc/:contractId', authorize('ASSET', 'view'), controller.getAmc);
|
||||||
|
router.put(
|
||||||
|
'/:id/amc/:contractId',
|
||||||
|
authorize('ASSET', 'edit'),
|
||||||
|
validate(updateAmcContractSchema),
|
||||||
|
controller.updateAmc
|
||||||
|
);
|
||||||
|
router.patch(
|
||||||
|
'/:id/amc/:contractId/renew',
|
||||||
|
authorize('ASSET', 'edit'),
|
||||||
|
validate(renewAmcContractSchema),
|
||||||
|
controller.renewAmc
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:id/service-visits',
|
||||||
|
authorize('ASSET', 'view'),
|
||||||
|
validate(listServiceVisitsQuerySchema, 'query'),
|
||||||
|
controller.listVisits
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/:id/service-visits',
|
||||||
|
authorize('ASSET', 'create'),
|
||||||
|
validate(serviceVisitSchema),
|
||||||
|
controller.createVisit
|
||||||
|
);
|
||||||
|
router.get('/:id/service-visits/:visitId', authorize('ASSET', 'view'), controller.getVisit);
|
||||||
|
router.put(
|
||||||
|
'/:id/service-visits/:visitId',
|
||||||
|
authorize('ASSET', 'edit'),
|
||||||
|
validate(updateServiceVisitSchema),
|
||||||
|
controller.updateVisit
|
||||||
|
);
|
||||||
|
router.patch(
|
||||||
|
'/:id/service-visits/:visitId/status',
|
||||||
|
authorize('ASSET', 'edit'),
|
||||||
|
validate(serviceVisitStatusSchema),
|
||||||
|
controller.updateVisitStatus
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get('/:id/insurance', authorize('ASSET', 'view'), controller.listInsurance);
|
||||||
|
router.post(
|
||||||
|
'/:id/insurance',
|
||||||
|
authorize('ASSET', 'create'),
|
||||||
|
validate(insurancePolicySchema),
|
||||||
|
controller.createInsurance
|
||||||
|
);
|
||||||
|
router.get('/:id/insurance/:policyId', authorize('ASSET', 'view'), controller.getInsurance);
|
||||||
|
router.put(
|
||||||
|
'/:id/insurance/:policyId',
|
||||||
|
authorize('ASSET', 'edit'),
|
||||||
|
validate(updateInsurancePolicySchema),
|
||||||
|
controller.updateInsurance
|
||||||
|
);
|
||||||
|
router.patch(
|
||||||
|
'/:id/insurance/:policyId/renew',
|
||||||
|
authorize('ASSET', 'edit'),
|
||||||
|
validate(renewInsurancePolicySchema),
|
||||||
|
controller.renewInsurance
|
||||||
|
);
|
||||||
|
|
||||||
router.get('/:id/transfer-history', authorize('ASSET', 'view'), controller.transferHistory);
|
router.get('/:id/transfer-history', authorize('ASSET', 'view'), controller.transferHistory);
|
||||||
router.post(
|
router.post(
|
||||||
'/:id/transfer',
|
'/:id/transfer',
|
||||||
|
|||||||
@ -34,9 +34,6 @@ const assetInclude = {
|
|||||||
|
|
||||||
const assetDetailInclude = {
|
const assetDetailInclude = {
|
||||||
...assetInclude,
|
...assetInclude,
|
||||||
vendors_assets_amc_vendor_idTovendors: {
|
|
||||||
select: { id: true, vendor_code: true, vendor_name: true },
|
|
||||||
},
|
|
||||||
users_assets_updated_byTousers: { select: { id: true, full_name: true } },
|
users_assets_updated_byTousers: { select: { id: true, full_name: true } },
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -73,7 +70,6 @@ const sanitizeAsset = (asset) => {
|
|||||||
warehouses,
|
warehouses,
|
||||||
users_assets_assigned_to_user_idTousers,
|
users_assets_assigned_to_user_idTousers,
|
||||||
vendors_assets_vendor_idTovendors,
|
vendors_assets_vendor_idTovendors,
|
||||||
vendors_assets_amc_vendor_idTovendors,
|
|
||||||
purchase_orders,
|
purchase_orders,
|
||||||
grn,
|
grn,
|
||||||
users_assets_created_byTousers,
|
users_assets_created_byTousers,
|
||||||
@ -89,7 +85,6 @@ const sanitizeAsset = (asset) => {
|
|||||||
warehouse: warehouses || null,
|
warehouse: warehouses || null,
|
||||||
assigned_to_user: users_assets_assigned_to_user_idTousers || null,
|
assigned_to_user: users_assets_assigned_to_user_idTousers || null,
|
||||||
vendor: vendors_assets_vendor_idTovendors || null,
|
vendor: vendors_assets_vendor_idTovendors || null,
|
||||||
amc_vendor: vendors_assets_amc_vendor_idTovendors || null,
|
|
||||||
purchase_order: purchase_orders || null,
|
purchase_order: purchase_orders || null,
|
||||||
grn: grn || null,
|
grn: grn || null,
|
||||||
created_by_user: users_assets_created_byTousers || null,
|
created_by_user: users_assets_created_byTousers || null,
|
||||||
@ -100,7 +95,6 @@ const sanitizeAsset = (asset) => {
|
|||||||
warehouses: undefined,
|
warehouses: undefined,
|
||||||
users_assets_assigned_to_user_idTousers: undefined,
|
users_assets_assigned_to_user_idTousers: undefined,
|
||||||
vendors_assets_vendor_idTovendors: undefined,
|
vendors_assets_vendor_idTovendors: undefined,
|
||||||
vendors_assets_amc_vendor_idTovendors: undefined,
|
|
||||||
purchase_orders: undefined,
|
purchase_orders: undefined,
|
||||||
users_assets_created_byTousers: undefined,
|
users_assets_created_byTousers: undefined,
|
||||||
users_assets_updated_byTousers: undefined,
|
users_assets_updated_byTousers: undefined,
|
||||||
@ -169,8 +163,6 @@ const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => {
|
|||||||
if (payload.assigned_to_user_id)
|
if (payload.assigned_to_user_id)
|
||||||
await assertReference('users', payload.assigned_to_user_id, 'assigned_to_user_id');
|
await assertReference('users', payload.assigned_to_user_id, 'assigned_to_user_id');
|
||||||
if (payload.vendor_id) await assertReference('vendors', payload.vendor_id, 'vendor_id');
|
if (payload.vendor_id) await assertReference('vendors', payload.vendor_id, 'vendor_id');
|
||||||
if (payload.amc_vendor_id)
|
|
||||||
await assertReference('vendors', payload.amc_vendor_id, 'amc_vendor_id');
|
|
||||||
if (payload.po_id) await assertReference('purchase_orders', payload.po_id, 'po_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_id) await assertReference('grn', payload.grn_id, 'grn_id');
|
||||||
if (payload.grn_item_id)
|
if (payload.grn_item_id)
|
||||||
@ -210,13 +202,6 @@ const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => {
|
|||||||
warranty_expiry_date: payload.warranty_expiry_date
|
warranty_expiry_date: payload.warranty_expiry_date
|
||||||
? toDateOnly(payload.warranty_expiry_date)
|
? toDateOnly(payload.warranty_expiry_date)
|
||||||
: null,
|
: null,
|
||||||
amc_start_date: payload.amc_start_date ? toDateOnly(payload.amc_start_date) : null,
|
|
||||||
amc_end_date: payload.amc_end_date ? toDateOnly(payload.amc_end_date) : null,
|
|
||||||
amc_vendor_id: payload.amc_vendor_id ? BigInt(payload.amc_vendor_id) : null,
|
|
||||||
insurance_policy_no: payload.insurance_policy_no || null,
|
|
||||||
insurance_expiry_date: payload.insurance_expiry_date
|
|
||||||
? toDateOnly(payload.insurance_expiry_date)
|
|
||||||
: null,
|
|
||||||
condition: payload.condition ?? (isCreate ? 'NEW' : undefined),
|
condition: payload.condition ?? (isCreate ? 'NEW' : undefined),
|
||||||
status,
|
status,
|
||||||
qr_code_value: payload.qr_code_value || null,
|
qr_code_value: payload.qr_code_value || null,
|
||||||
@ -345,19 +330,6 @@ const updateAsset = async (id, payload, userId, requestId) => {
|
|||||||
payload.warranty_expiry_date !== undefined
|
payload.warranty_expiry_date !== undefined
|
||||||
? payload.warranty_expiry_date
|
? payload.warranty_expiry_date
|
||||||
: existing.warranty_expiry_date,
|
: existing.warranty_expiry_date,
|
||||||
amc_start_date:
|
|
||||||
payload.amc_start_date !== undefined ? payload.amc_start_date : existing.amc_start_date,
|
|
||||||
amc_end_date: payload.amc_end_date !== undefined ? payload.amc_end_date : existing.amc_end_date,
|
|
||||||
amc_vendor_id:
|
|
||||||
payload.amc_vendor_id !== undefined ? payload.amc_vendor_id : existing.amc_vendor_id,
|
|
||||||
insurance_policy_no:
|
|
||||||
payload.insurance_policy_no !== undefined
|
|
||||||
? payload.insurance_policy_no
|
|
||||||
: existing.insurance_policy_no,
|
|
||||||
insurance_expiry_date:
|
|
||||||
payload.insurance_expiry_date !== undefined
|
|
||||||
? payload.insurance_expiry_date
|
|
||||||
: existing.insurance_expiry_date,
|
|
||||||
condition: payload.condition ?? existing.condition,
|
condition: payload.condition ?? existing.condition,
|
||||||
status: payload.status ?? existing.status,
|
status: payload.status ?? existing.status,
|
||||||
qr_code_value:
|
qr_code_value:
|
||||||
|
|||||||
@ -1,5 +1,18 @@
|
|||||||
const Joi = require('joi');
|
const Joi = require('joi');
|
||||||
const { ASSET_CONDITIONS, ASSET_STATUSES, DEPRECIATION_METHODS } = require('./assets.constants');
|
const {
|
||||||
|
ASSET_CONDITIONS,
|
||||||
|
ASSET_STATUSES,
|
||||||
|
DEPRECIATION_METHODS,
|
||||||
|
AMC_CONTRACT_TYPES,
|
||||||
|
PAYMENT_FREQUENCIES,
|
||||||
|
SERVICE_FREQUENCIES,
|
||||||
|
VISIT_TYPES,
|
||||||
|
VISIT_STATUSES,
|
||||||
|
VISIT_CONDITION_AFTER,
|
||||||
|
INSURANCE_POLICY_TYPES,
|
||||||
|
ALERT_TYPES,
|
||||||
|
SERVICE_ALERT_STATUSES,
|
||||||
|
} = require('./assets.constants');
|
||||||
|
|
||||||
const assetFields = {
|
const assetFields = {
|
||||||
asset_name: Joi.string().max(200).required(),
|
asset_name: Joi.string().max(200).required(),
|
||||||
@ -26,11 +39,6 @@ const assetFields = {
|
|||||||
.optional(),
|
.optional(),
|
||||||
salvage_value: Joi.number().min(0).allow(null).optional(),
|
salvage_value: Joi.number().min(0).allow(null).optional(),
|
||||||
warranty_expiry_date: Joi.date().iso().allow(null).optional(),
|
warranty_expiry_date: Joi.date().iso().allow(null).optional(),
|
||||||
amc_start_date: Joi.date().iso().allow(null).optional(),
|
|
||||||
amc_end_date: Joi.date().iso().allow(null).optional(),
|
|
||||||
amc_vendor_id: Joi.number().integer().positive().allow(null).optional(),
|
|
||||||
insurance_policy_no: Joi.string().max(100).allow(null, '').optional(),
|
|
||||||
insurance_expiry_date: Joi.date().iso().allow(null).optional(),
|
|
||||||
condition: Joi.string()
|
condition: Joi.string()
|
||||||
.valid(...ASSET_CONDITIONS)
|
.valid(...ASSET_CONDITIONS)
|
||||||
.default('NEW'),
|
.default('NEW'),
|
||||||
@ -69,11 +77,6 @@ const updateAssetSchema = Joi.object({
|
|||||||
depreciation_method: assetFields.depreciation_method,
|
depreciation_method: assetFields.depreciation_method,
|
||||||
salvage_value: assetFields.salvage_value,
|
salvage_value: assetFields.salvage_value,
|
||||||
warranty_expiry_date: assetFields.warranty_expiry_date,
|
warranty_expiry_date: assetFields.warranty_expiry_date,
|
||||||
amc_start_date: assetFields.amc_start_date,
|
|
||||||
amc_end_date: assetFields.amc_end_date,
|
|
||||||
amc_vendor_id: assetFields.amc_vendor_id,
|
|
||||||
insurance_policy_no: assetFields.insurance_policy_no,
|
|
||||||
insurance_expiry_date: assetFields.insurance_expiry_date,
|
|
||||||
condition: assetFields.condition.optional(),
|
condition: assetFields.condition.optional(),
|
||||||
status: assetFields.status.optional(),
|
status: assetFields.status.optional(),
|
||||||
qr_code_value: assetFields.qr_code_value,
|
qr_code_value: assetFields.qr_code_value,
|
||||||
@ -109,9 +112,193 @@ const transferAssetSchema = Joi.object({
|
|||||||
reason: Joi.string().allow(null, '').optional(),
|
reason: Joi.string().allow(null, '').optional(),
|
||||||
}).or('to_plant_id', 'to_department_id', 'to_user_id', 'to_warehouse_id');
|
}).or('to_plant_id', 'to_department_id', 'to_user_id', 'to_warehouse_id');
|
||||||
|
|
||||||
|
const amcContractSchema = Joi.object({
|
||||||
|
vendor_id: Joi.number().integer().positive().required(),
|
||||||
|
contract_no: Joi.string().max(100).allow(null, '').optional(),
|
||||||
|
contract_type: Joi.string()
|
||||||
|
.valid(...AMC_CONTRACT_TYPES)
|
||||||
|
.default('COMPREHENSIVE'),
|
||||||
|
start_date: Joi.date().iso().required(),
|
||||||
|
end_date: Joi.date().iso().required(),
|
||||||
|
renewal_date: Joi.date().iso().allow(null).optional(),
|
||||||
|
annual_cost: Joi.number().min(0).default(0),
|
||||||
|
payment_frequency: Joi.string()
|
||||||
|
.valid(...PAYMENT_FREQUENCIES)
|
||||||
|
.default('ANNUAL'),
|
||||||
|
service_frequency: Joi.string()
|
||||||
|
.valid(...SERVICE_FREQUENCIES)
|
||||||
|
.allow(null)
|
||||||
|
.optional(),
|
||||||
|
visits_per_year: Joi.number().integer().min(0).allow(null).optional(),
|
||||||
|
contact_person: Joi.string().max(150).allow(null, '').optional(),
|
||||||
|
contact_phone: Joi.string().max(15).allow(null, '').optional(),
|
||||||
|
contact_email: Joi.string().email().max(150).allow(null, '').optional(),
|
||||||
|
scope_of_work: Joi.string().allow(null, '').optional(),
|
||||||
|
exclusions: Joi.string().allow(null, '').optional(),
|
||||||
|
remarks: Joi.string().allow(null, '').optional(),
|
||||||
|
is_active: Joi.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateAmcContractSchema = amcContractSchema.fork(
|
||||||
|
['vendor_id', 'start_date', 'end_date'],
|
||||||
|
(schema) => schema.optional()
|
||||||
|
);
|
||||||
|
|
||||||
|
const renewAmcContractSchema = Joi.object({
|
||||||
|
start_date: Joi.date().iso().required(),
|
||||||
|
end_date: Joi.date().iso().required(),
|
||||||
|
vendor_id: Joi.number().integer().positive().optional(),
|
||||||
|
contract_no: Joi.string().max(100).allow(null, '').optional(),
|
||||||
|
contract_type: Joi.string()
|
||||||
|
.valid(...AMC_CONTRACT_TYPES)
|
||||||
|
.optional(),
|
||||||
|
renewal_date: Joi.date().iso().allow(null).optional(),
|
||||||
|
annual_cost: Joi.number().min(0).optional(),
|
||||||
|
payment_frequency: Joi.string()
|
||||||
|
.valid(...PAYMENT_FREQUENCIES)
|
||||||
|
.optional(),
|
||||||
|
service_frequency: Joi.string()
|
||||||
|
.valid(...SERVICE_FREQUENCIES)
|
||||||
|
.allow(null)
|
||||||
|
.optional(),
|
||||||
|
visits_per_year: Joi.number().integer().min(0).allow(null).optional(),
|
||||||
|
contact_person: Joi.string().max(150).allow(null, '').optional(),
|
||||||
|
contact_phone: Joi.string().max(15).allow(null, '').optional(),
|
||||||
|
contact_email: Joi.string().email().max(150).allow(null, '').optional(),
|
||||||
|
scope_of_work: Joi.string().allow(null, '').optional(),
|
||||||
|
exclusions: Joi.string().allow(null, '').optional(),
|
||||||
|
remarks: Joi.string().allow(null, '').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const serviceVisitSchema = Joi.object({
|
||||||
|
visit_type: Joi.string()
|
||||||
|
.valid(...VISIT_TYPES)
|
||||||
|
.required(),
|
||||||
|
visit_date: Joi.date().iso().required(),
|
||||||
|
amc_contract_id: Joi.number().integer().positive().allow(null).optional(),
|
||||||
|
visit_number: Joi.number().integer().min(1).allow(null).optional(),
|
||||||
|
complaint_no: Joi.string().max(50).allow(null, '').optional(),
|
||||||
|
complaint_date: Joi.date().iso().allow(null).optional(),
|
||||||
|
complaint_desc: Joi.string().allow(null, '').optional(),
|
||||||
|
engineer_name: Joi.string().max(150).allow(null, '').optional(),
|
||||||
|
engineer_phone: Joi.string().max(15).allow(null, '').optional(),
|
||||||
|
vendor_id: Joi.number().integer().positive().allow(null).optional(),
|
||||||
|
work_done: Joi.string().allow(null, '').optional(),
|
||||||
|
parts_replaced: Joi.string().allow(null, '').optional(),
|
||||||
|
next_service_date: Joi.date().iso().allow(null).optional(),
|
||||||
|
status: Joi.string()
|
||||||
|
.valid(...VISIT_STATUSES)
|
||||||
|
.default('COMPLETED'),
|
||||||
|
downtime_hours: Joi.number().min(0).default(0),
|
||||||
|
service_cost: Joi.number().min(0).default(0),
|
||||||
|
is_under_amc: Joi.boolean().default(false),
|
||||||
|
asset_condition_after: Joi.string()
|
||||||
|
.valid(...VISIT_CONDITION_AFTER)
|
||||||
|
.allow(null)
|
||||||
|
.optional(),
|
||||||
|
remarks: Joi.string().allow(null, '').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateServiceVisitSchema = serviceVisitSchema.fork(['visit_type', 'visit_date'], (s) =>
|
||||||
|
s.optional()
|
||||||
|
);
|
||||||
|
|
||||||
|
const serviceVisitStatusSchema = Joi.object({
|
||||||
|
status: Joi.string()
|
||||||
|
.valid(...VISIT_STATUSES)
|
||||||
|
.required(),
|
||||||
|
remarks: Joi.string().allow(null, '').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const listServiceVisitsQuerySchema = Joi.object({
|
||||||
|
status: Joi.string()
|
||||||
|
.valid(...VISIT_STATUSES)
|
||||||
|
.optional(),
|
||||||
|
visit_type: Joi.string()
|
||||||
|
.valid(...VISIT_TYPES)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const insurancePolicySchema = Joi.object({
|
||||||
|
policy_no: Joi.string().max(100).required(),
|
||||||
|
insurer_name: Joi.string().max(200).required(),
|
||||||
|
insurer_branch: Joi.string().max(150).allow(null, '').optional(),
|
||||||
|
insurer_contact: Joi.string().max(150).allow(null, '').optional(),
|
||||||
|
insurer_phone: Joi.string().max(15).allow(null, '').optional(),
|
||||||
|
insurer_email: Joi.string().email().max(150).allow(null, '').optional(),
|
||||||
|
policy_type: Joi.string()
|
||||||
|
.valid(...INSURANCE_POLICY_TYPES)
|
||||||
|
.default('FIRE_AND_ALLIED'),
|
||||||
|
sum_insured: Joi.number().min(0).default(0),
|
||||||
|
annual_premium: Joi.number().min(0).default(0),
|
||||||
|
policy_start_date: Joi.date().iso().required(),
|
||||||
|
policy_end_date: Joi.date().iso().required(),
|
||||||
|
renewal_date: Joi.date().iso().allow(null).optional(),
|
||||||
|
is_auto_renewal: Joi.boolean().default(false),
|
||||||
|
premium_paid: Joi.boolean().default(false),
|
||||||
|
premium_paid_date: Joi.date().iso().allow(null).optional(),
|
||||||
|
remarks: Joi.string().allow(null, '').optional(),
|
||||||
|
is_active: Joi.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateInsurancePolicySchema = insurancePolicySchema.fork(
|
||||||
|
['policy_no', 'insurer_name', 'policy_start_date', 'policy_end_date'],
|
||||||
|
(s) => s.optional()
|
||||||
|
);
|
||||||
|
|
||||||
|
const renewInsurancePolicySchema = Joi.object({
|
||||||
|
policy_start_date: Joi.date().iso().required(),
|
||||||
|
policy_end_date: Joi.date().iso().required(),
|
||||||
|
policy_no: Joi.string().max(100).optional(),
|
||||||
|
insurer_name: Joi.string().max(200).optional(),
|
||||||
|
insurer_branch: Joi.string().max(150).allow(null, '').optional(),
|
||||||
|
insurer_contact: Joi.string().max(150).allow(null, '').optional(),
|
||||||
|
insurer_phone: Joi.string().max(15).allow(null, '').optional(),
|
||||||
|
insurer_email: Joi.string().email().max(150).allow(null, '').optional(),
|
||||||
|
policy_type: Joi.string()
|
||||||
|
.valid(...INSURANCE_POLICY_TYPES)
|
||||||
|
.optional(),
|
||||||
|
sum_insured: Joi.number().min(0).optional(),
|
||||||
|
annual_premium: Joi.number().min(0).optional(),
|
||||||
|
renewal_date: Joi.date().iso().allow(null).optional(),
|
||||||
|
is_auto_renewal: Joi.boolean().optional(),
|
||||||
|
premium_paid: Joi.boolean().optional(),
|
||||||
|
premium_paid_date: Joi.date().iso().allow(null).optional(),
|
||||||
|
remarks: Joi.string().allow(null, '').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const expiryAlertsQuerySchema = Joi.object({
|
||||||
|
page: Joi.number().integer().min(1).default(1),
|
||||||
|
limit: Joi.number().integer().min(1).max(100).default(20),
|
||||||
|
days: Joi.number().integer().min(1).max(365).default(90),
|
||||||
|
type: Joi.string()
|
||||||
|
.valid(...ALERT_TYPES)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const serviceAlertsQuerySchema = Joi.object({
|
||||||
|
page: Joi.number().integer().min(1).default(1),
|
||||||
|
limit: Joi.number().integer().min(1).max(100).default(20),
|
||||||
|
status: Joi.string()
|
||||||
|
.valid(...SERVICE_ALERT_STATUSES)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createAssetSchema,
|
createAssetSchema,
|
||||||
updateAssetSchema,
|
updateAssetSchema,
|
||||||
listAssetsQuerySchema,
|
listAssetsQuerySchema,
|
||||||
transferAssetSchema,
|
transferAssetSchema,
|
||||||
|
amcContractSchema,
|
||||||
|
updateAmcContractSchema,
|
||||||
|
renewAmcContractSchema,
|
||||||
|
serviceVisitSchema,
|
||||||
|
updateServiceVisitSchema,
|
||||||
|
serviceVisitStatusSchema,
|
||||||
|
listServiceVisitsQuerySchema,
|
||||||
|
insurancePolicySchema,
|
||||||
|
updateInsurancePolicySchema,
|
||||||
|
renewInsurancePolicySchema,
|
||||||
|
expiryAlertsQuerySchema,
|
||||||
|
serviceAlertsQuerySchema,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -32,34 +32,108 @@ const sanitizeRole = (role) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validatePermissionIds = async (permissionIds) => {
|
||||||
|
const uniqueIds = [...new Set((permissionIds || []).map((pid) => BigInt(pid)))];
|
||||||
|
if (uniqueIds.length === 0) return [];
|
||||||
|
|
||||||
|
const permissions = await prisma.permissions.findMany({
|
||||||
|
where: { id: { in: uniqueIds }, is_active: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (permissions.length !== uniqueIds.length) {
|
||||||
|
throw new ApiError(422, 'One or more permission_ids are invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueIds;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveViewPermissionIdsForModules = async (moduleCodes) => {
|
||||||
|
if (!moduleCodes?.length) return [];
|
||||||
|
|
||||||
|
const uniqueCodes = [...new Set(moduleCodes.map((code) => code.trim().toUpperCase()))];
|
||||||
|
const permissions = await prisma.permissions.findMany({
|
||||||
|
where: {
|
||||||
|
action: 'view',
|
||||||
|
is_active: true,
|
||||||
|
modules: { code: { in: uniqueCodes }, is_active: true },
|
||||||
|
},
|
||||||
|
select: { id: true, modules: { select: { code: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const foundCodes = new Set(permissions.map((p) => p.modules.code));
|
||||||
|
const missing = uniqueCodes.filter((code) => !foundCodes.has(code));
|
||||||
|
if (missing.length) {
|
||||||
|
throw new ApiError(422, `Invalid or inactive view_modules: ${missing.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissions.map((p) => p.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveInitialPermissionIds = async (payload) => {
|
||||||
|
const idsFromModules = payload.view_modules?.length
|
||||||
|
? await resolveViewPermissionIdsForModules(payload.view_modules)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const mergedIds = [...(payload.permission_ids || []), ...idsFromModules.map((id) => Number(id))];
|
||||||
|
return validatePermissionIds(mergedIds);
|
||||||
|
};
|
||||||
|
|
||||||
const createRole = async (payload, userId, requestId) => {
|
const createRole = async (payload, userId, requestId) => {
|
||||||
const existing = await prisma.roles.findFirst({
|
const existing = await prisma.roles.findFirst({
|
||||||
where: { name: payload.name, deleted_at: null },
|
where: { name: payload.name, deleted_at: null },
|
||||||
});
|
});
|
||||||
if (existing) throw new ApiError(409, 'Role name already exists');
|
if (existing) throw new ApiError(409, 'Role name already exists');
|
||||||
|
|
||||||
const created = await prisma.roles.create({
|
const permissionIds = await resolveInitialPermissionIds(payload);
|
||||||
data: {
|
|
||||||
name: payload.name.trim(),
|
const created = await prisma.$transaction(async (tx) => {
|
||||||
description: payload.description ?? null,
|
const role = await tx.roles.create({
|
||||||
is_active: payload.is_active ?? true,
|
data: {
|
||||||
created_by: userId ? BigInt(userId) : null,
|
name: payload.name.trim(),
|
||||||
updated_by: userId ? BigInt(userId) : null,
|
description: payload.description ?? null,
|
||||||
},
|
is_active: payload.is_active ?? true,
|
||||||
include: rolePermissionsInclude,
|
created_by: userId ? BigInt(userId) : null,
|
||||||
|
updated_by: userId ? BigInt(userId) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (permissionIds.length > 0) {
|
||||||
|
await tx.role_permissions.createMany({
|
||||||
|
data: permissionIds.map((permission_id) => ({ role_id: role.id, permission_id })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.roles.findFirst({
|
||||||
|
where: { id: role.id },
|
||||||
|
include: rolePermissionsInclude,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sanitized = sanitizeRole(created);
|
||||||
|
|
||||||
await auditLog({
|
await auditLog({
|
||||||
tableName: 'roles',
|
tableName: 'roles',
|
||||||
recordId: created.id,
|
recordId: created.id,
|
||||||
action: 'CREATE',
|
action: 'CREATE',
|
||||||
oldValue: null,
|
oldValue: null,
|
||||||
newValue: sanitizeRole(created),
|
newValue: sanitized,
|
||||||
userId,
|
userId,
|
||||||
requestId,
|
requestId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return sanitizeRole(created);
|
if (permissionIds.length > 0) {
|
||||||
|
await auditLog({
|
||||||
|
tableName: 'role_permissions',
|
||||||
|
recordId: created.id,
|
||||||
|
action: 'CREATE',
|
||||||
|
oldValue: null,
|
||||||
|
newValue: permissionIds.map((pid) => pid.toString()),
|
||||||
|
userId,
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
};
|
};
|
||||||
|
|
||||||
const listRoles = async (query) => {
|
const listRoles = async (query) => {
|
||||||
@ -207,18 +281,7 @@ const assignPermissions = async (id, permissionIds, userId, requestId) => {
|
|||||||
const role = await prisma.roles.findFirst({ where: { id: BigInt(id), deleted_at: null } });
|
const role = await prisma.roles.findFirst({ where: { id: BigInt(id), deleted_at: null } });
|
||||||
if (!role) throw new ApiError(404, 'Role not found');
|
if (!role) throw new ApiError(404, 'Role not found');
|
||||||
|
|
||||||
const uniqueIds = [...new Set(permissionIds.map((pid) => BigInt(pid)))];
|
const uniqueIds = await validatePermissionIds(permissionIds);
|
||||||
|
|
||||||
if (uniqueIds.length > 0) {
|
|
||||||
const permissions = await prisma.permissions.findMany({
|
|
||||||
where: { id: { in: uniqueIds }, is_active: true },
|
|
||||||
include: { modules: { select: { code: true } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (permissions.length !== uniqueIds.length) {
|
|
||||||
throw new ApiError(422, 'One or more permission_ids are invalid');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const before = await prisma.role_permissions.findMany({
|
const before = await prisma.role_permissions.findMany({
|
||||||
where: { role_id: BigInt(id) },
|
where: { role_id: BigInt(id) },
|
||||||
|
|||||||
@ -4,6 +4,8 @@ const createRoleSchema = Joi.object({
|
|||||||
name: Joi.string().max(100).required(),
|
name: Joi.string().max(100).required(),
|
||||||
description: Joi.string().allow(null, '').optional(),
|
description: Joi.string().allow(null, '').optional(),
|
||||||
is_active: Joi.boolean().default(true),
|
is_active: Joi.boolean().default(true),
|
||||||
|
permission_ids: Joi.array().items(Joi.number().integer().positive()).optional(),
|
||||||
|
view_modules: Joi.array().items(Joi.string().max(50)).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateRoleSchema = Joi.object({
|
const updateRoleSchema = Joi.object({
|
||||||
|
|||||||
@ -4,7 +4,7 @@ const env = require('../../config/env');
|
|||||||
const ApiError = require('../../utils/ApiError');
|
const ApiError = require('../../utils/ApiError');
|
||||||
const auditLog = require('../../utils/auditLog');
|
const auditLog = require('../../utils/auditLog');
|
||||||
const { getPagination } = require('../../utils/pagination');
|
const { getPagination } = require('../../utils/pagination');
|
||||||
const { encrypt, decrypt } = require('../../utils/encryption');
|
const { encrypt, decrypt, blindIndex } = require('../../utils/encryption');
|
||||||
|
|
||||||
const userInclude = {
|
const userInclude = {
|
||||||
roles_users_role_idToroles: { select: { id: true, name: true } },
|
roles_users_role_idToroles: { select: { id: true, name: true } },
|
||||||
@ -25,6 +25,7 @@ const sanitizeUser = (user) => {
|
|||||||
...rest
|
...rest
|
||||||
} = user;
|
} = user;
|
||||||
delete rest.password_hash;
|
delete rest.password_hash;
|
||||||
|
delete rest.mobile_index;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
@ -125,7 +126,13 @@ const buildUserData = async (payload, { hashPassword = false } = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Object.prototype.hasOwnProperty.call(data, 'mobile')) {
|
if (Object.prototype.hasOwnProperty.call(data, 'mobile')) {
|
||||||
data.mobile = data.mobile ? encrypt(data.mobile) : null;
|
if (data.mobile) {
|
||||||
|
data.mobile_index = blindIndex(data.mobile);
|
||||||
|
data.mobile = encrypt(data.mobile);
|
||||||
|
} else {
|
||||||
|
data.mobile = null;
|
||||||
|
data.mobile_index = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key of ['role_id', 'department_id', 'designation_id', 'plant_id', 'reporting_to']) {
|
for (const key of ['role_id', 'department_id', 'designation_id', 'plant_id', 'reporting_to']) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user