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 |
|
||||
| 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 |
|
||||
| 13 | **assets** | [x] Done | CRUD + transfer + transfer history |
|
||||
| 13 | **assets** | [x] Done | CRUD + transfer + AMC/service visits/insurance + expiry alerts |
|
||||
|
||||
Current progress:
|
||||
|
||||
@ -80,7 +80,7 @@ Current progress:
|
||||
- [x] `vendors` module (CRUD, status, addresses, contacts, bank-details)
|
||||
- [x] `purchase-orders` module (CRUD, workflow, PDF)
|
||||
- [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)
|
||||
|
||||
### 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` | view | Paginated role list |
|
||||
| [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] | DELETE | `/roles/:id` | delete | Soft delete (blocked if users assigned) |
|
||||
| [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] | 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
|
||||
@ -264,8 +303,8 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL
|
||||
| Vendors | 20 | 20 | [x] Done |
|
||||
| Purchase Orders | 11 | 11 | [x] Done |
|
||||
| GRN | 6 | 6 | [x] Done |
|
||||
| Assets | 7 | 7 | [x] Done |
|
||||
| **Total** | **139** | **139** | **[x] Phase 1 APIs complete** |
|
||||
| Assets | 24 | 24 | [x] Done |
|
||||
| **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.
|
||||
model asset_attachments {
|
||||
id BigInt @id @default(autoincrement())
|
||||
id BigInt @id @default(autoincrement())
|
||||
asset_id BigInt
|
||||
attachment_type String @default("DOCUMENT") @db.VarChar(50)
|
||||
file_name String @db.VarChar(255)
|
||||
file_path String @db.VarChar(500)
|
||||
file_type String? @db.VarChar(100)
|
||||
attachment_type String @default("DOCUMENT") @db.VarChar(50)
|
||||
file_name String @db.VarChar(255)
|
||||
file_path String @db.VarChar(500)
|
||||
file_type String? @db.VarChar(100)
|
||||
file_size Int?
|
||||
uploaded_by BigInt?
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
assets assets @relation(fields: [asset_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
users users? @relation(fields: [uploaded_by], references: [id], onUpdate: NoAction)
|
||||
amc_contract_id BigInt?
|
||||
service_visit_id BigInt?
|
||||
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.
|
||||
@ -95,11 +208,6 @@ model assets {
|
||||
depreciation_method String? @db.VarChar(10)
|
||||
salvage_value Decimal? @default(0) @db.Decimal(15, 4)
|
||||
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)
|
||||
status String @default("IN_USE") @db.VarChar(30)
|
||||
qr_code_value String? @db.VarChar(100)
|
||||
@ -113,9 +221,11 @@ model assets {
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
deleted_at DateTime? @db.Timestamptz(6)
|
||||
asset_amc_contracts asset_amc_contracts[]
|
||||
asset_attachments asset_attachments[]
|
||||
asset_insurance_policies asset_insurance_policies[]
|
||||
asset_service_visits asset_service_visits[]
|
||||
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)
|
||||
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)
|
||||
@ -669,7 +779,8 @@ model users {
|
||||
employee_code String @unique @db.VarChar(30)
|
||||
full_name String @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)
|
||||
role_id BigInt?
|
||||
department_id BigInt?
|
||||
@ -687,6 +798,12 @@ model users {
|
||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
deleted_at DateTime? @db.Timestamptz(6)
|
||||
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_updated_byTousers asset_categories[] @relation("asset_categories_updated_byTousers")
|
||||
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)
|
||||
updated_at DateTime @default(now()) @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")
|
||||
asset_amc_contracts asset_amc_contracts[]
|
||||
asset_service_visits asset_service_visits[]
|
||||
grn grn[]
|
||||
purchase_orders purchase_orders[]
|
||||
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:
|
||||
asset_name: { type: string, example: 'CNC Lathe Machine' }
|
||||
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 }
|
||||
department_id: { type: integer, nullable: true, example: 1 }
|
||||
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 }
|
||||
warranty_expiry_date: { type: string, format: date, nullable: true }
|
||||
purchase_cost: { type: number, example: 850000 }
|
||||
useful_life_years: { type: integer, example: 15 }
|
||||
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:
|
||||
AmcContractBody:
|
||||
type: object
|
||||
minProperties: 1
|
||||
required: [vendor_id, start_date, end_date]
|
||||
properties:
|
||||
asset_name: { type: string }
|
||||
asset_category_id: { type: integer }
|
||||
brand_model: { type: string }
|
||||
manufacturer: { type: string }
|
||||
serial_number: { type: string }
|
||||
part_number: { type: string }
|
||||
plant_id: { type: integer }
|
||||
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:
|
||||
vendor_id: { type: integer, example: 1 }
|
||||
contract_no: { type: string, example: AMC-2026-001 }
|
||||
contract_type: { type: string, enum: [COMPREHENSIVE, LABOUR_ONLY, PARTS_ONLY, PREVENTIVE_ONLY] }
|
||||
start_date: { type: string, format: date }
|
||||
end_date: { type: string, format: date }
|
||||
annual_cost: { type: number, example: 50000 }
|
||||
ServiceVisitBody:
|
||||
type: object
|
||||
required: [transfer_date]
|
||||
required: [visit_type, visit_date]
|
||||
properties:
|
||||
transfer_date: { type: string, format: date, example: '2026-06-18' }
|
||||
to_plant_id: { type: integer, nullable: true, example: 2 }
|
||||
to_department_id: { type: integer, nullable: true, example: 3 }
|
||||
to_user_id: { type: integer, nullable: true, example: 5 }
|
||||
to_warehouse_id: { type: integer, nullable: true, example: 1 }
|
||||
reason: { type: string, example: 'Moved to new production line' }
|
||||
visit_type: { type: string, enum: [PREVENTIVE, BREAKDOWN, INSPECTION, INSTALLATION, CALIBRATION, OTHER] }
|
||||
visit_date: { type: string, format: date }
|
||||
complaint_no: { type: string, example: CMP-001 }
|
||||
work_done: { type: string }
|
||||
next_service_date: { type: string, format: date, nullable: true }
|
||||
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:
|
||||
/assets:
|
||||
/assets/alerts/expiry:
|
||||
get:
|
||||
tags: [Assets]
|
||||
summary: List assets
|
||||
summary: Expiry alerts (AMC, Insurance, Warranty)
|
||||
parameters:
|
||||
- { name: page, in: query, schema: { type: integer, example: 1 } }
|
||||
- { name: limit, in: query, schema: { type: integer, example: 20 } }
|
||||
- { name: search, in: query, schema: { type: string, example: MCH- } }
|
||||
- { name: status, in: query, schema: { type: string, example: IN_USE } }
|
||||
- { 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 } }
|
||||
- { name: days, in: query, schema: { type: integer, example: 30 } }
|
||||
- { name: type, in: query, schema: { type: string, enum: [AMC, INSURANCE, WARRANTY] } }
|
||||
- { name: page, in: query, schema: { type: integer } }
|
||||
- { name: limit, in: query, schema: { type: integer } }
|
||||
responses:
|
||||
'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:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ApiResponse' }
|
||||
post:
|
||||
tags: [Assets]
|
||||
summary: Create asset
|
||||
summary: Create AMC contract
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string } }
|
||||
requestBody:
|
||||
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:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/AssetsCreateBody' }
|
||||
responses:
|
||||
'201':
|
||||
description: Asset created
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ApiResponse' }
|
||||
/assets/{id}:
|
||||
get:
|
||||
tags: [Assets]
|
||||
summary: Get asset detail
|
||||
summary: Get asset
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, example: '1' } }
|
||||
- { name: id, in: path, required: true, schema: { type: string } }
|
||||
responses:
|
||||
'200':
|
||||
description: Asset fetched
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ApiResponse' }
|
||||
put:
|
||||
tags: [Assets]
|
||||
summary: Update asset
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, example: '1' } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/AssetsUpdateBody' }
|
||||
- { name: id, in: path, required: true, schema: { type: string } }
|
||||
responses:
|
||||
'200':
|
||||
description: Asset updated
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ApiResponse' }
|
||||
delete:
|
||||
tags: [Assets]
|
||||
summary: Soft delete asset
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, example: '1' } }
|
||||
- { name: id, in: path, required: true, schema: { type: string } }
|
||||
responses:
|
||||
'200':
|
||||
description: Asset deleted
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ApiResponse' }
|
||||
/assets/{id}/transfer:
|
||||
post:
|
||||
tags: [Assets]
|
||||
summary: Transfer asset location/assignment
|
||||
summary: Transfer asset
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, example: '1' } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/AssetsTransferBody' }
|
||||
- { name: id, in: path, required: true, schema: { type: string } }
|
||||
responses:
|
||||
'200':
|
||||
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' }
|
||||
description: { type: string, example: 'Manages purchase orders' }
|
||||
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:
|
||||
type: object
|
||||
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 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 = {
|
||||
ASSET_CONDITIONS,
|
||||
ASSET_STATUSES,
|
||||
DEPRECIATION_METHODS,
|
||||
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 ApiResponse = require('../../utils/ApiResponse');
|
||||
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 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'));
|
||||
});
|
||||
|
||||
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,
|
||||
listAssetsQuerySchema,
|
||||
transferAssetSchema,
|
||||
amcContractSchema,
|
||||
updateAmcContractSchema,
|
||||
renewAmcContractSchema,
|
||||
serviceVisitSchema,
|
||||
updateServiceVisitSchema,
|
||||
serviceVisitStatusSchema,
|
||||
listServiceVisitsQuerySchema,
|
||||
insurancePolicySchema,
|
||||
updateInsurancePolicySchema,
|
||||
renewInsurancePolicySchema,
|
||||
expiryAlertsQuerySchema,
|
||||
serviceAlertsQuerySchema,
|
||||
} = require('./assets.validation');
|
||||
|
||||
const router = express.Router();
|
||||
@ -22,6 +34,87 @@ router.get(
|
||||
);
|
||||
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.post(
|
||||
'/:id/transfer',
|
||||
|
||||
@ -34,9 +34,6 @@ const assetInclude = {
|
||||
|
||||
const assetDetailInclude = {
|
||||
...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 } },
|
||||
};
|
||||
|
||||
@ -73,7 +70,6 @@ const sanitizeAsset = (asset) => {
|
||||
warehouses,
|
||||
users_assets_assigned_to_user_idTousers,
|
||||
vendors_assets_vendor_idTovendors,
|
||||
vendors_assets_amc_vendor_idTovendors,
|
||||
purchase_orders,
|
||||
grn,
|
||||
users_assets_created_byTousers,
|
||||
@ -89,7 +85,6 @@ const sanitizeAsset = (asset) => {
|
||||
warehouse: warehouses || null,
|
||||
assigned_to_user: users_assets_assigned_to_user_idTousers || null,
|
||||
vendor: vendors_assets_vendor_idTovendors || null,
|
||||
amc_vendor: vendors_assets_amc_vendor_idTovendors || null,
|
||||
purchase_order: purchase_orders || null,
|
||||
grn: grn || null,
|
||||
created_by_user: users_assets_created_byTousers || null,
|
||||
@ -100,7 +95,6 @@ const sanitizeAsset = (asset) => {
|
||||
warehouses: undefined,
|
||||
users_assets_assigned_to_user_idTousers: undefined,
|
||||
vendors_assets_vendor_idTovendors: undefined,
|
||||
vendors_assets_amc_vendor_idTovendors: undefined,
|
||||
purchase_orders: undefined,
|
||||
users_assets_created_byTousers: undefined,
|
||||
users_assets_updated_byTousers: undefined,
|
||||
@ -169,8 +163,6 @@ const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => {
|
||||
if (payload.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.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.grn_id) await assertReference('grn', payload.grn_id, 'grn_id');
|
||||
if (payload.grn_item_id)
|
||||
@ -210,13 +202,6 @@ const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => {
|
||||
warranty_expiry_date: payload.warranty_expiry_date
|
||||
? toDateOnly(payload.warranty_expiry_date)
|
||||
: 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),
|
||||
status,
|
||||
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
|
||||
: 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,
|
||||
status: payload.status ?? existing.status,
|
||||
qr_code_value:
|
||||
|
||||
@ -1,5 +1,18 @@
|
||||
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 = {
|
||||
asset_name: Joi.string().max(200).required(),
|
||||
@ -26,11 +39,6 @@ const assetFields = {
|
||||
.optional(),
|
||||
salvage_value: Joi.number().min(0).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()
|
||||
.valid(...ASSET_CONDITIONS)
|
||||
.default('NEW'),
|
||||
@ -69,11 +77,6 @@ const updateAssetSchema = Joi.object({
|
||||
depreciation_method: assetFields.depreciation_method,
|
||||
salvage_value: assetFields.salvage_value,
|
||||
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(),
|
||||
status: assetFields.status.optional(),
|
||||
qr_code_value: assetFields.qr_code_value,
|
||||
@ -109,9 +112,193 @@ const transferAssetSchema = Joi.object({
|
||||
reason: Joi.string().allow(null, '').optional(),
|
||||
}).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 = {
|
||||
createAssetSchema,
|
||||
updateAssetSchema,
|
||||
listAssetsQuerySchema,
|
||||
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 existing = await prisma.roles.findFirst({
|
||||
where: { name: payload.name, deleted_at: null },
|
||||
});
|
||||
if (existing) throw new ApiError(409, 'Role name already exists');
|
||||
|
||||
const created = await prisma.roles.create({
|
||||
data: {
|
||||
name: payload.name.trim(),
|
||||
description: payload.description ?? null,
|
||||
is_active: payload.is_active ?? true,
|
||||
created_by: userId ? BigInt(userId) : null,
|
||||
updated_by: userId ? BigInt(userId) : null,
|
||||
},
|
||||
include: rolePermissionsInclude,
|
||||
const permissionIds = await resolveInitialPermissionIds(payload);
|
||||
|
||||
const created = await prisma.$transaction(async (tx) => {
|
||||
const role = await tx.roles.create({
|
||||
data: {
|
||||
name: payload.name.trim(),
|
||||
description: payload.description ?? null,
|
||||
is_active: payload.is_active ?? true,
|
||||
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({
|
||||
tableName: 'roles',
|
||||
recordId: created.id,
|
||||
action: 'CREATE',
|
||||
oldValue: null,
|
||||
newValue: sanitizeRole(created),
|
||||
newValue: sanitized,
|
||||
userId,
|
||||
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) => {
|
||||
@ -207,18 +281,7 @@ const assignPermissions = async (id, permissionIds, userId, requestId) => {
|
||||
const role = await prisma.roles.findFirst({ where: { id: BigInt(id), deleted_at: null } });
|
||||
if (!role) throw new ApiError(404, 'Role not found');
|
||||
|
||||
const uniqueIds = [...new Set(permissionIds.map((pid) => BigInt(pid)))];
|
||||
|
||||
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 uniqueIds = await validatePermissionIds(permissionIds);
|
||||
|
||||
const before = await prisma.role_permissions.findMany({
|
||||
where: { role_id: BigInt(id) },
|
||||
|
||||
@ -4,6 +4,8 @@ const createRoleSchema = Joi.object({
|
||||
name: Joi.string().max(100).required(),
|
||||
description: Joi.string().allow(null, '').optional(),
|
||||
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({
|
||||
|
||||
@ -4,7 +4,7 @@ const env = require('../../config/env');
|
||||
const ApiError = require('../../utils/ApiError');
|
||||
const auditLog = require('../../utils/auditLog');
|
||||
const { getPagination } = require('../../utils/pagination');
|
||||
const { encrypt, decrypt } = require('../../utils/encryption');
|
||||
const { encrypt, decrypt, blindIndex } = require('../../utils/encryption');
|
||||
|
||||
const userInclude = {
|
||||
roles_users_role_idToroles: { select: { id: true, name: true } },
|
||||
@ -25,6 +25,7 @@ const sanitizeUser = (user) => {
|
||||
...rest
|
||||
} = user;
|
||||
delete rest.password_hash;
|
||||
delete rest.mobile_index;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
@ -125,7 +126,13 @@ const buildUserData = async (payload, { hashPassword = false } = {}) => {
|
||||
}
|
||||
|
||||
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']) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user