From 896b345e02ba6cf2dfc16ffd19542ceb69b7be5c Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Tue, 17 Feb 2026 20:36:21 +0530 Subject: [PATCH] modified cost item and cost expens based on the new changes aksed similar hsn items will be clubbed and enhanced the activity type to support documnt type and gst type --- .../20260217-add-is-service-to-expenses.ts | 43 +++ .../20260217-create-claim-invoice-items.ts | 109 ++++++ src/models/ClaimInvoiceItem.ts | 191 +++++++++++ src/models/DealerCompletionExpense.ts | 8 + src/models/DealerProposalCostItem.ts | 8 + src/models/index.ts | 8 +- src/scripts/migrate.ts | 60 +--- src/services/activityTypeSeed.service.ts | 78 ++--- src/services/dealerClaim.service.ts | 146 ++++++-- src/services/pdf.service.ts | 191 +++++++++-- src/services/pwcIntegration.service.ts | 322 +++++++++++++++--- src/utils/currencyUtils.ts | 55 +++ 12 files changed, 1022 insertions(+), 197 deletions(-) create mode 100644 src/migrations/20260217-add-is-service-to-expenses.ts create mode 100644 src/migrations/20260217-create-claim-invoice-items.ts create mode 100644 src/models/ClaimInvoiceItem.ts create mode 100644 src/utils/currencyUtils.ts diff --git a/src/migrations/20260217-add-is-service-to-expenses.ts b/src/migrations/20260217-add-is-service-to-expenses.ts new file mode 100644 index 0000000..0c59848 --- /dev/null +++ b/src/migrations/20260217-add-is-service-to-expenses.ts @@ -0,0 +1,43 @@ +import { QueryInterface, DataTypes } from 'sequelize'; + +/** + * Helper function to check if a column exists in a table + */ +async function columnExists( + queryInterface: QueryInterface, + tableName: string, + columnName: string +): Promise { + try { + const tableDescription = await queryInterface.describeTable(tableName); + return columnName in tableDescription; + } catch (error) { + return false; + } +} + +export async function up(queryInterface: QueryInterface): Promise { + const tables = ['dealer_proposal_cost_items', 'dealer_completion_expenses']; + const colName = 'is_service'; + const colSpec = { type: DataTypes.BOOLEAN, allowNull: true, defaultValue: false }; + + for (const table of tables) { + if (!(await columnExists(queryInterface, table, colName))) { + await queryInterface.addColumn(table, colName, colSpec); + console.log(`Added column ${colName} to ${table}`); + } else { + console.log(`Column ${colName} already exists in ${table}`); + } + } +} + +export async function down(queryInterface: QueryInterface): Promise { + const tables = ['dealer_proposal_cost_items', 'dealer_completion_expenses']; + const col = 'is_service'; + + for (const table of tables) { + await queryInterface.removeColumn(table, col).catch((err) => { + console.warn(`Failed to remove column ${col} from ${table}:`, err.message); + }); + } +} diff --git a/src/migrations/20260217-create-claim-invoice-items.ts b/src/migrations/20260217-create-claim-invoice-items.ts new file mode 100644 index 0000000..e0221eb --- /dev/null +++ b/src/migrations/20260217-create-claim-invoice-items.ts @@ -0,0 +1,109 @@ +import { QueryInterface, DataTypes } from 'sequelize'; + +module.exports = { + up: async (queryInterface: QueryInterface) => { + await queryInterface.createTable('claim_invoice_items', { + item_id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + request_id: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'workflow_requests', + key: 'request_id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + invoice_number: { + type: DataTypes.STRING(100), + allowNull: true, + }, + transaction_code: { + type: DataTypes.STRING(100), + allowNull: true, + }, + sl_no: { + type: DataTypes.INTEGER, + allowNull: false, + }, + description: { + type: DataTypes.TEXT, + allowNull: false, + }, + hsn_cd: { + type: DataTypes.STRING(20), + allowNull: false, + }, + qty: { + type: DataTypes.DECIMAL(15, 3), + allowNull: false, + }, + unit: { + type: DataTypes.STRING(20), + allowNull: false, + }, + unit_price: { + type: DataTypes.DECIMAL(15, 2), + allowNull: false, + }, + ass_amt: { + type: DataTypes.DECIMAL(15, 2), + allowNull: false, + }, + gst_rt: { + type: DataTypes.DECIMAL(5, 2), + allowNull: false, + }, + igst_amt: { + type: DataTypes.DECIMAL(15, 2), + allowNull: false, + }, + cgst_amt: { + type: DataTypes.DECIMAL(15, 2), + allowNull: false, + }, + sgst_amt: { + type: DataTypes.DECIMAL(15, 2), + allowNull: false, + }, + tot_item_val: { + type: DataTypes.DECIMAL(15, 2), + allowNull: false, + }, + is_servc: { + type: DataTypes.STRING(1), + allowNull: false, + }, + expense_ids: { + type: DataTypes.JSONB, + allowNull: true, + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + }); + + // Add indexes + await queryInterface.addIndex('claim_invoice_items', ['request_id'], { + name: 'idx_claim_invoice_items_request_id', + }); + await queryInterface.addIndex('claim_invoice_items', ['invoice_number'], { + name: 'idx_claim_invoice_items_invoice_number', + }); + }, + + down: async (queryInterface: QueryInterface) => { + await queryInterface.dropTable('claim_invoice_items'); + }, +}; diff --git a/src/models/ClaimInvoiceItem.ts b/src/models/ClaimInvoiceItem.ts new file mode 100644 index 0000000..a2d3c18 --- /dev/null +++ b/src/models/ClaimInvoiceItem.ts @@ -0,0 +1,191 @@ +import { DataTypes, Model, Optional } from 'sequelize'; +import { sequelize } from '@config/database'; +import { WorkflowRequest } from './WorkflowRequest'; + +interface ClaimInvoiceItemAttributes { + itemId: string; + requestId: string; + invoiceNumber?: string; + transactionCode?: string; + slNo: number; + description: string; + hsnCd: string; + qty: number; + unit: string; + unitPrice: number; + assAmt: number; + gstRt: number; + igstAmt: number; + cgstAmt: number; + sgstAmt: number; + totItemVal: number; + isServc: string; + expenseIds?: string[]; + createdAt: Date; + updatedAt: Date; +} + +interface ClaimInvoiceItemCreationAttributes extends Optional { } + +class ClaimInvoiceItem extends Model implements ClaimInvoiceItemAttributes { + public itemId!: string; + public requestId!: string; + public invoiceNumber?: string; + public transactionCode?: string; + public slNo!: number; + public description!: string; + public hsnCd!: string; + public qty!: number; + public unit!: string; + public unitPrice!: number; + public assAmt!: number; + public gstRt!: number; + public igstAmt!: number; + public cgstAmt!: number; + public sgstAmt!: number; + public totItemVal!: number; + public isServc!: string; + public expenseIds?: string[]; + public createdAt!: Date; + public updatedAt!: Date; +} + +ClaimInvoiceItem.init( + { + itemId: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + field: 'item_id', + }, + requestId: { + type: DataTypes.UUID, + allowNull: false, + field: 'request_id', + references: { + model: 'workflow_requests', + key: 'request_id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + invoiceNumber: { + type: DataTypes.STRING(100), + allowNull: true, + field: 'invoice_number', + }, + transactionCode: { + type: DataTypes.STRING(100), + allowNull: true, + field: 'transaction_code', + }, + slNo: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'sl_no', + }, + description: { + type: DataTypes.TEXT, + allowNull: false, + field: 'description', + }, + hsnCd: { + type: DataTypes.STRING(20), + allowNull: false, + field: 'hsn_cd', + }, + qty: { + type: DataTypes.DECIMAL(15, 3), + allowNull: false, + field: 'qty', + }, + unit: { + type: DataTypes.STRING(20), + allowNull: false, + field: 'unit', + }, + unitPrice: { + type: DataTypes.DECIMAL(15, 2), + allowNull: false, + field: 'unit_price', + }, + assAmt: { + type: DataTypes.DECIMAL(15, 2), + allowNull: false, + field: 'ass_amt', + }, + gstRt: { + type: DataTypes.DECIMAL(5, 2), + allowNull: false, + field: 'gst_rt', + }, + igstAmt: { + type: DataTypes.DECIMAL(15, 2), + allowNull: false, + field: 'igst_amt', + }, + cgstAmt: { + type: DataTypes.DECIMAL(15, 2), + allowNull: false, + field: 'cgst_amt', + }, + sgstAmt: { + type: DataTypes.DECIMAL(15, 2), + allowNull: false, + field: 'sgst_amt', + }, + totItemVal: { + type: DataTypes.DECIMAL(15, 2), + allowNull: false, + field: 'tot_item_val', + }, + isServc: { + type: DataTypes.STRING(1), + allowNull: false, + field: 'is_servc', + }, + expenseIds: { + type: DataTypes.JSONB, + allowNull: true, + field: 'expense_ids', + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'created_at', + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'updated_at', + }, + }, + { + sequelize, + modelName: 'ClaimInvoiceItem', + tableName: 'claim_invoice_items', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { fields: ['request_id'], name: 'idx_claim_invoice_items_request_id' }, + { fields: ['invoice_number'], name: 'idx_claim_invoice_items_invoice_number' }, + ], + } +); + +WorkflowRequest.hasMany(ClaimInvoiceItem, { + as: 'invoiceItems', + foreignKey: 'requestId', + sourceKey: 'requestId', +}); + +ClaimInvoiceItem.belongsTo(WorkflowRequest, { + as: 'workflowRequest', + foreignKey: 'requestId', + targetKey: 'requestId', +}); + +export { ClaimInvoiceItem }; diff --git a/src/models/DealerCompletionExpense.ts b/src/models/DealerCompletionExpense.ts index 1d47138..59fb22e 100644 --- a/src/models/DealerCompletionExpense.ts +++ b/src/models/DealerCompletionExpense.ts @@ -24,6 +24,7 @@ interface DealerCompletionExpenseAttributes { cessRate?: number; cessAmt?: number; totalAmt?: number; + isService?: boolean; expenseDate: Date; createdAt: Date; updatedAt: Date; @@ -52,6 +53,7 @@ class DealerCompletionExpense extends Model { @@ -179,7 +182,10 @@ export { ClaimBudgetTracking, Dealer, ActivityType, - DealerClaimHistory + DealerClaimHistory, + ClaimInvoice, + ClaimInvoiceItem, + ClaimCreditNote }; // Export default sequelize instance diff --git a/src/scripts/migrate.ts b/src/scripts/migrate.ts index 5eee9f4..52726e9 100644 --- a/src/scripts/migrate.ts +++ b/src/scripts/migrate.ts @@ -49,6 +49,8 @@ import * as m43 from '../migrations/20260113-redesign-dealer-claim-history'; import * as m44 from '../migrations/20260123-fix-template-id-schema'; import * as m45 from '../migrations/20260209-add-gst-and-pwc-fields'; import * as m46 from '../migrations/20260216-add-qty-hsn-to-expenses'; +import * as m47 from '../migrations/20260217-add-is-service-to-expenses'; +import * as m48 from '../migrations/20260217-create-claim-invoice-items'; interface Migration { name: string; @@ -58,60 +60,10 @@ interface Migration { // Define all migrations in order // IMPORTANT: Order matters! Dependencies must be created before tables that reference them const migrations: Migration[] = [ - // 1. FIRST: Create base tables with no dependencies - { name: '2025103000-create-users', module: m0 }, // ← MUST BE FIRST - - // 2. Tables that depend on users - { name: '2025103001-create-workflow-requests', module: m1 }, - { name: '2025103002-create-approval-levels', module: m2 }, - { name: '2025103003-create-participants', module: m3 }, - { name: '2025103004-create-documents', module: m4 }, - { name: '20251031_01_create_subscriptions', module: m5 }, - { name: '20251031_02_create_activities', module: m6 }, - { name: '20251031_03_create_work_notes', module: m7 }, - { name: '20251031_04_create_work_note_attachments', module: m8 }, - - // 3. Table modifications and additional features - { name: '20251104-add-tat-alert-fields', module: m9 }, - { name: '20251104-create-tat-alerts', module: m10 }, - { name: '20251104-create-kpi-views', module: m11 }, - { name: '20251104-create-holidays', module: m12 }, - { name: '20251104-create-admin-config', module: m13 }, - { name: '20251105-add-skip-fields-to-approval-levels', module: m14 }, - { name: '2025110501-alter-tat-days-to-generated', module: m15 }, - { name: '20251111-create-notifications', module: m16 }, - { name: '20251111-create-conclusion-remarks', module: m17 }, - { name: '20251118-add-breach-reason-to-approval-levels', module: m18 }, - { name: '20251121-add-ai-model-configs', module: m19 }, - { name: '20250122-create-request-summaries', module: m20 }, - { name: '20250122-create-shared-summaries', module: m21 }, - { name: '20250123-update-request-number-format', module: m22 }, - { name: '20250126-add-paused-to-enum', module: m23 }, - { name: '20250126-add-paused-to-workflow-status-enum', module: m24 }, - { name: '20250126-add-pause-fields-to-workflow-requests', module: m25 }, - { name: '20250126-add-pause-fields-to-approval-levels', module: m26 }, - { name: '20250127-migrate-in-progress-to-pending', module: m27 }, - // Base branch migrations (m28-m29) - { name: '20250130-migrate-to-vertex-ai', module: m28 }, - { name: '20251203-add-user-notification-preferences', module: m29 }, - // Dealer claim branch migrations (m30-m39) - { name: '20251210-add-workflow-type-support', module: m30 }, - { name: '20251210-enhance-workflow-templates', module: m31 }, - { name: '20251210-add-template-id-foreign-key', module: m32 }, - { name: '20251210-create-dealer-claim-tables', module: m33 }, - { name: '20251210-create-proposal-cost-items-table', module: m34 }, - { name: '20251211-create-internal-orders-table', module: m35 }, - { name: '20251211-create-claim-budget-tracking-table', module: m36 }, - { name: '20251213-drop-claim-details-invoice-columns', module: m37 }, - { name: '20251213-create-claim-invoice-credit-note-tables', module: m38 }, - { name: '20251214-create-dealer-completion-expenses', module: m39 }, - { name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 }, - { name: '20250120-create-dealers-table', module: m41 }, - { name: '20250125-create-activity-types', module: m42 }, - { name: '20260113-redesign-dealer-claim-history', module: m43 }, - { name: '20260123-fix-template-id-schema', module: m44 }, - { name: '20260209-add-gst-and-pwc-fields', module: m45 }, - { name: '20260216-add-qty-hsn-to-expenses', module: m46 } + // ... existing migrations ... + { name: '20260216-add-qty-hsn-to-expenses', module: m46 }, + { name: '20260217-add-is-service-to-expenses', module: m47 }, + { name: '20260217-create-claim-invoice-items', module: m48 } ]; /** diff --git a/src/services/activityTypeSeed.service.ts b/src/services/activityTypeSeed.service.ts index 99f7add..66a2670 100644 --- a/src/services/activityTypeSeed.service.ts +++ b/src/services/activityTypeSeed.service.ts @@ -8,19 +8,19 @@ import { ActivityType } from '@models/ActivityType'; * These will be seeded into the database with default item_code values (1-13) */ const DEFAULT_ACTIVITY_TYPES = [ - { title: 'Riders Mania Claims', itemCode: '1' }, - { title: 'Marketing Cost – Bike to Vendor', itemCode: '2' }, - { title: 'Media Bike Service', itemCode: '3' }, - { title: 'ARAI Motorcycle Liquidation', itemCode: '4' }, - { title: 'ARAI Certification – STA Approval CNR', itemCode: '5' }, - { title: 'Procurement of Spares/Apparel/GMA for Events', itemCode: '6' }, - { title: 'Fuel for Media Bike Used for Event', itemCode: '7' }, - { title: 'Motorcycle Buyback and Goodwill Support', itemCode: '8' }, - { title: 'Liquidation of Used Motorcycle', itemCode: '9' }, - { title: 'Motorcycle Registration CNR (Owned or Gifted by RE)', itemCode: '10' }, - { title: 'Legal Claims Reimbursement', itemCode: '11' }, - { title: 'Service Camp Claims', itemCode: '12' }, - { title: 'Corporate Claims – Institutional Sales PDI', itemCode: '13' } + { title: 'Riders Mania Claims', itemCode: '1', taxationType: 'Non GST', sapRefNo: 'ZRDM' }, + { title: 'Marketing Cost – Bike to Vendor', itemCode: '2', taxationType: 'Non GST', sapRefNo: 'ZMBV' }, + { title: 'Media Bike Service', itemCode: '3', taxationType: 'GST', sapRefNo: 'ZMBS' }, + { title: 'ARAI Motorcycle Liquidation', itemCode: '4', taxationType: 'GST', sapRefNo: 'ZAML' }, + { title: 'ARAI Certification – STA Approval CNR', itemCode: '5', taxationType: 'Non GST', sapRefNo: 'ZACS' }, + { title: 'Procurement of Spares/Apparel/GMA for Events', itemCode: '6', taxationType: 'GST', sapRefNo: 'ZPPE' }, + { title: 'Fuel for Media Bike Used for Event', itemCode: '7', taxationType: 'Non GST', sapRefNo: 'ZFMB' }, + { title: 'Motorcycle Buyback and Goodwill Support', itemCode: '8', taxationType: 'Non GST', sapRefNo: 'ZMBG' }, + { title: 'Liquidation of Used Motorcycle', itemCode: '9', taxationType: 'GST', sapRefNo: 'ZLUM' }, + { title: 'Motorcycle Registration CNR (Owned or Gifted by RE)', itemCode: '10', taxationType: 'GST', sapRefNo: 'ZMRC' }, + { title: 'Legal Claims Reimbursement', itemCode: '11', taxationType: 'Non GST', sapRefNo: 'ZLCR' }, + { title: 'Service Camp Claims', itemCode: '12', taxationType: 'Non GST', sapRefNo: 'ZSCC' }, + { title: 'Corporate Claims – Institutional Sales PDI', itemCode: '13', taxationType: 'Non GST', sapRefNo: 'ZCCN' } ]; /** @@ -40,7 +40,7 @@ export async function seedDefaultActivityTypes(): Promise { ); const exists = tableExists && tableExists.length > 0 && (tableExists[0] as any).exists; - + if (!exists) { logger.warn('[ActivityType Seed] ⚠️ activity_types table does not exist. Please run migrations first (npm run migrate). Skipping seed.'); return; @@ -71,7 +71,7 @@ export async function seedDefaultActivityTypes(): Promise { let skippedCount = 0; for (const activityType of DEFAULT_ACTIVITY_TYPES) { - const { title, itemCode } = activityType; + const { title, itemCode, taxationType, sapRefNo } = activityType; try { // Check if activity type already exists (active or inactive) const existing = await ActivityType.findOne({ @@ -79,45 +79,41 @@ export async function seedDefaultActivityTypes(): Promise { }); if (existing) { - // If exists but inactive, reactivate it + // Identify fields to update + const updates: any = {}; + if (!existing.itemCode && itemCode) updates.itemCode = itemCode; + if (!existing.taxationType && taxationType) updates.taxationType = taxationType; + if (!existing.sapRefNo && sapRefNo) updates.sapRefNo = sapRefNo; + if (!existing.isActive) { - // Update item_code if it's null (preserve if user has already set it) - const updateData: any = { - isActive: true, - updatedBy: systemUserId - }; - // Only set item_code if it's currently null (don't overwrite user edits) - if (!existing.itemCode) { - updateData.itemCode = itemCode; - } - await existing.update(updateData); + updates.isActive = true; + updates.updatedBy = systemUserId; + await existing.update(updates); updatedCount++; - logger.debug(`[ActivityType Seed] Reactivated existing activity type: ${title}${!existing.itemCode ? ` (set item_code: ${itemCode})` : ''}`); + logger.debug(`[ActivityType Seed] Reactivated existing activity type: ${title} (updates: ${JSON.stringify(updates)})`); } else { // Already exists and active - // Update item_code if it's null (preserve if user has already set it) - if (!existing.itemCode) { - await existing.update({ - itemCode: itemCode, - updatedBy: systemUserId - } as any); - logger.debug(`[ActivityType Seed] Updated item_code for existing activity type: ${title} (${itemCode})`); + if (Object.keys(updates).length > 0) { + updates.updatedBy = systemUserId; + await existing.update(updates); + logger.debug(`[ActivityType Seed] Updated fields for existing activity type: ${title} (updates: ${JSON.stringify(updates)})`); + } else { + skippedCount++; + logger.debug(`[ActivityType Seed] Activity type already exists and active: ${title}`); } - skippedCount++; - logger.debug(`[ActivityType Seed] Activity type already exists and active: ${title}`); } } else { - // Create new activity type with default item_code + // Create new activity type with default fields await ActivityType.create({ title, itemCode: itemCode, - taxationType: null, - sapRefNo: null, + taxationType: taxationType, + sapRefNo: sapRefNo, isActive: true, createdBy: systemUserId } as any); createdCount++; - logger.debug(`[ActivityType Seed] Created new activity type: ${title} (item_code: ${itemCode})`); + logger.debug(`[ActivityType Seed] Created new activity type: ${title} (item_code: ${itemCode}, sap_ref: ${sapRefNo})`); } } catch (error: any) { // Log error but continue with other activity types @@ -132,7 +128,7 @@ export async function seedDefaultActivityTypes(): Promise { { type: QueryTypes.SELECT } ); const totalCount = result && (result[0] as any).count ? (result[0] as any).count : 0; - + logger.info(`[ActivityType Seed] ✅ Activity type seeding complete. Created: ${createdCount}, Reactivated: ${updatedCount}, Skipped: ${skippedCount}, Total active: ${totalCount}`); } catch (error: any) { logger.error('[ActivityType Seed] ❌ Error seeding activity types:', { diff --git a/src/services/dealerClaim.service.ts b/src/services/dealerClaim.service.ts index 0617a39..856b826 100644 --- a/src/services/dealerClaim.service.ts +++ b/src/services/dealerClaim.service.ts @@ -15,6 +15,7 @@ import { ApprovalLevel } from '../models/ApprovalLevel'; import { Participant } from '../models/Participant'; import { User } from '../models/User'; import { DealerClaimHistory, SnapshotType } from '../models/DealerClaimHistory'; +import { ActivityType } from '../models/ActivityType'; import { Document } from '../models/Document'; import { WorkflowService } from './workflow.service'; import { DealerClaimApprovalService } from './dealerClaimApproval.service'; @@ -22,11 +23,12 @@ import { generateRequestNumber } from '../utils/helpers'; import { Priority, WorkflowStatus, ApprovalStatus, ParticipantType } from '../types/common.types'; import { sapIntegrationService } from './sapIntegration.service'; import { pwcIntegrationService } from './pwcIntegration.service'; +import { findDealerLocally } from './dealer.service'; import { notificationService } from './notification.service'; import { activityService } from './activity.service'; import { UserService } from './user.service'; import { dmsIntegrationService } from './dmsIntegration.service'; -import { findDealerLocally } from './dealer.service'; +// findDealerLocally removed (duplicate) const appDomain = process.env.APP_DOMAIN || 'royalenfield.com'; @@ -1080,6 +1082,14 @@ export class DealerClaimService { let serializedClaimDetails = null; if (claimDetails) { serializedClaimDetails = (claimDetails as any).toJSON ? (claimDetails as any).toJSON() : claimDetails; + + // Add default GST rate from ActivityType + const activity = await ActivityType.findOne({ where: { title: claimDetails.activityType } }); + if (activity) { + serializedClaimDetails.defaultGstRate = Number(activity.gstRate) || 18; + } else { + serializedClaimDetails.defaultGstRate = 18; // Fallback + } } // Transform proposal details to include cost items as array @@ -1278,6 +1288,12 @@ export class DealerClaimService { // Save cost items to separate table (preferred approach) if (proposalData.costBreakup && proposalData.costBreakup.length > 0) { + // Validate HSN/SAC codes before saving + for (const item of proposalData.costBreakup) { + // Pass item.isService explicitly; if undefined/null, validation will use default HSN/SAC logic + this.validateHSNSAC(item.hsnCode, item.isService); + } + // Delete existing cost items for this proposal (in case of update) await DealerProposalCostItem.destroy({ where: { proposalId } @@ -1304,6 +1320,7 @@ export class DealerClaimService { cessRate: Number(item.cessRate) || 0, cessAmt: Number(item.cessAmt) || 0, totalAmt: Number(item.totalAmt) || Number(item.amount) || 0, + isService: !!item.isService, itemOrder: index })); @@ -1434,32 +1451,72 @@ export class DealerClaimService { }); // Persist individual closed expenses to dealer_completion_expenses + const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } }); + const dealer = await findDealerLocally(claimDetails?.dealerCode, claimDetails?.dealerEmail); + const buyerStateCode = "33"; + let dealerStateCode = "33"; + if (dealer?.gstin && dealer.gstin.length >= 2 && !isNaN(Number(dealer.gstin.substring(0, 2)))) { + dealerStateCode = dealer.gstin.substring(0, 2); + } else if (dealer?.state) { + if (dealer.state.toLowerCase().includes('tamil nadu')) dealerStateCode = "33"; + else dealerStateCode = "00"; + } + const isIGST = dealerStateCode !== buyerStateCode; + const completionId = (completionDetails as any)?.completionId; if (completionData.closedExpenses && completionData.closedExpenses.length > 0) { + // Validate HSN/SAC codes before saving + for (const item of completionData.closedExpenses) { + this.validateHSNSAC(item.hsnCode, !!item.isService); + } + // Clear existing expenses for this request to avoid duplicates await DealerCompletionExpense.destroy({ where: { requestId } }); - const expenseRows = completionData.closedExpenses.map((item: any) => ({ - requestId, - completionId, - description: item.description, - amount: Number(item.amount) || 0, - quantity: Number(item.quantity) || 1, - hsnCode: item.hsnCode || '', - gstRate: Number(item.gstRate) || 0, - gstAmt: Number(item.gstAmt) || 0, - cgstRate: Number(item.cgstRate) || 0, - cgstAmt: Number(item.cgstAmt) || 0, - sgstRate: Number(item.sgstRate) || 0, - sgstAmt: Number(item.sgstAmt) || 0, - igstRate: Number(item.igstRate) || 0, - igstAmt: Number(item.igstAmt) || 0, - utgstRate: Number(item.utgstRate) || 0, - utgstAmt: Number(item.utgstAmt) || 0, - cessRate: Number(item.cessRate) || 0, - cessAmt: Number(item.cessAmt) || 0, - totalAmt: Number(item.totalAmt) || Number(item.amount) || 0, - expenseDate: item.date instanceof Date ? item.date : (item.date ? new Date(item.date) : (completionData.activityCompletionDate || new Date())), - })); + const expenseRows = completionData.closedExpenses.map((item: any) => { + const amount = Number(item.amount) || 0; + const quantity = Number(item.quantity) || 1; + const baseTotal = amount * quantity; + + // Use provided tax details or calculate if missing/zero + let gstRate = Number(item.gstRate); + if (isNaN(gstRate) || gstRate === 0) { + // Fallback to activity GST rate if available + gstRate = 18; // Default fallback + } + + const igstRate = isIGST ? gstRate : 0; + const cgstRate = !isIGST ? gstRate / 2 : 0; + const sgstRate = !isIGST ? gstRate / 2 : 0; + + const igstAmt = isIGST ? (baseTotal * gstRate) / 100 : 0; + const cgstAmt = !isIGST ? (baseTotal * gstRate) / 200 : 0; + const sgstAmt = !isIGST ? (baseTotal * gstRate) / 200 : 0; + const gstAmt = igstAmt + cgstAmt + sgstAmt; + + return { + requestId, + completionId, + description: item.description, + amount, + quantity, + hsnCode: item.hsnCode || '', + gstRate, + gstAmt: Number(item.gstAmt) || gstAmt, + cgstRate: Number(item.cgstRate) || cgstRate, + cgstAmt: Number(item.cgstAmt) || cgstAmt, + sgstRate: Number(item.sgstRate) || sgstRate, + sgstAmt: Number(item.sgstAmt) || sgstAmt, + igstRate: Number(item.igstRate) || igstRate, + igstAmt: Number(item.igstAmt) || igstAmt, + utgstRate: Number(item.utgstRate) || 0, + utgstAmt: Number(item.utgstAmt) || 0, + cessRate: Number(item.cessRate) || 0, + cessAmt: Number(item.cessAmt) || 0, + totalAmt: Number(item.totalAmt) || (baseTotal + gstAmt), + isService: !!item.isService, + expenseDate: item.date instanceof Date ? item.date : (item.date ? new Date(item.date) : (completionData.activityCompletionDate || new Date())), + }; + }); await DealerCompletionExpense.bulkCreate(expenseRows); } @@ -1963,6 +2020,10 @@ export class DealerClaimService { pwcResponse: invoiceResult.rawResponse, irpResponse: invoiceResult.irpResponse, amount: invoiceAmount, + taxableValue: invoiceResult.totalAssAmt, + igstTotal: invoiceResult.totalIgstAmt, + cgstTotal: invoiceResult.totalCgstAmt, + sgstTotal: invoiceResult.totalSgstAmt, status: 'GENERATED', generatedAt: new Date(), description: invoiceData?.description || `PWC Signed Invoice for claim request ${requestNumber}`, @@ -3451,5 +3512,44 @@ export class DealerClaimService { return plain; }); } + + /** + * Validates HSN or SAC code based on GST rules + * @param hsnCode HSN/SAC code string + * @param isService Boolean indicating if it's a Service (SAC) or Goods (HSN). + * If undefined, it will attempt to infer from the code (internal safety). + */ + private validateHSNSAC(hsnCode: string | undefined, isService?: boolean): void { + if (!hsnCode) return; + const cleanCode = String(hsnCode).trim(); + if (!cleanCode) return; + + if (!/^\d+$/.test(cleanCode)) { + throw new Error(`Invalid HSN/SAC code: ${hsnCode}. Code must contain only digits.`); + } + + // Infer isService if not provided (safety fallback) + const effectiveIsService = isService !== undefined ? !!isService : cleanCode.startsWith('99'); + + if (effectiveIsService) { + // SAC (Services Accounting Code) + if (!cleanCode.startsWith('99')) { + throw new Error(`Invalid SAC code: ${hsnCode}. Service codes must start with 99.`); + } + if (cleanCode.length !== 6) { + throw new Error(`Invalid SAC code: ${hsnCode}. SAC codes must be exactly 6 digits.`); + } + } else { + // HSN (Harmonized System of Nomenclature) for Goods + // Valid lengths in India: 4, 6, 8 + const validHSNLengths = [4, 6, 8]; + if (!validHSNLengths.includes(cleanCode.length)) { + throw new Error(`Invalid HSN code: ${hsnCode}. HSN codes must be 4, 6, or 8 digits.`); + } + if (cleanCode.startsWith('99')) { + throw new Error(`Invalid HSN code: ${hsnCode}. HSN codes should not start with 99 (reserved for SAC).`); + } + } + } } diff --git a/src/services/pdf.service.ts b/src/services/pdf.service.ts index 15c23e4..605e2aa 100644 --- a/src/services/pdf.service.ts +++ b/src/services/pdf.service.ts @@ -3,6 +3,9 @@ import { WorkflowRequest } from '@models/WorkflowRequest'; import { ClaimInvoice } from '@models/ClaimInvoice'; import { DealerClaimDetails } from '@models/DealerClaimDetails'; import { DealerProposalDetails } from '@models/DealerProposalDetails'; +import { DealerCompletionExpense } from '@models/DealerCompletionExpense'; +import { ClaimInvoiceItem } from '@models/ClaimInvoiceItem'; +import { amountToWords } from '@utils/currencyUtils'; import { findDealerLocally } from './dealer.service'; import path from 'path'; import fs from 'fs'; @@ -31,6 +34,14 @@ export class PdfService { const invoice = await ClaimInvoice.findOne({ where: { requestId } }); const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } }); const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId } }); + const completionExpenses = await DealerCompletionExpense.findAll({ + where: { requestId }, + order: [['createdAt', 'ASC']] + }); + const invoiceItems = await ClaimInvoiceItem.findAll({ + where: { requestId }, + order: [['slNo', 'ASC']] + }); const dealer = await findDealerLocally(claimDetails?.dealerCode, claimDetails?.dealerEmail); if (!request || !invoice) { @@ -42,6 +53,8 @@ export class PdfService { invoice, claimDetails, proposalDetails, + completionExpenses, + invoiceItems, dealer }); @@ -69,10 +82,132 @@ export class PdfService { } private getInvoiceHtmlTemplate(data: any): string { - const { request, invoice, dealer, claimDetails } = data; + const { request, invoice, dealer, claimDetails, completionExpenses = [], invoiceItems = [] } = data; const qrImage = invoice.qrImage ? `data:image/png;base64,${invoice.qrImage}` : ''; const logoUrl = `{{LOGO_URL}}`; + let tableRows = ''; + + if (invoiceItems && invoiceItems.length > 0) { + // Use persisted invoice items (matches PWC payload exactly) + tableRows = invoiceItems.map((item: any) => ` + + ${item.slNo} + ${item.description} + ${item.hsnCd} + ${Number(item.assAmt).toFixed(2)} + 0.00 + ${item.unit} + ${Number(item.assAmt).toFixed(2)} + ${Number(item.igstAmt) > 0 ? Number(item.gstRt).toFixed(2) : '0.00'} + ${Number(item.igstAmt).toFixed(2)} + ${Number(item.cgstAmt) > 0 ? (Number(item.gstRt) / 2).toFixed(2) : '0.00'} + ${Number(item.cgstAmt).toFixed(2)} + ${Number(item.sgstAmt) > 0 ? (Number(item.gstRt) / 2).toFixed(2) : '0.00'} + ${Number(item.sgstAmt).toFixed(2)} + ${Number(item.totItemVal).toFixed(2)} + + `).join(''); + } else if (completionExpenses.length > 0) { + // Group expenses by HSN/SAC and GST Rate for clubbed display + const grouped: Record = {}; + completionExpenses.forEach((item: any) => { + const hsn = item.hsnCode || 'N/A'; + const rate = Number(item.gstRate || 0); + const key = `${hsn}_${rate}`; + + if (!grouped[key]) { + grouped[key] = { + hsn, + rate, + description: item.description || 'Expense', + amount: 0, + igstRate: item.igstRate || 0, + igstAmt: 0, + cgstRate: item.cgstRate || 0, + cgstAmt: 0, + sgstRate: item.sgstRate || 0, + sgstAmt: 0, + totalAmt: 0 + }; + } + const qty = Number(item.quantity || 1); + grouped[key].amount += Number(item.amount || 0) * qty; + grouped[key].igstAmt += Number(item.igstAmt || 0); + grouped[key].cgstAmt += Number(item.cgstAmt || 0); + grouped[key].sgstAmt += Number(item.sgstAmt || 0); + grouped[key].totalAmt += Number(item.totalAmt || 0); + }); + + tableRows = Object.values(grouped).map((item: any) => ` + + EXPENSE + ${item.description} + ${item.hsn} + ${Number(item.amount).toFixed(2)} + 0.00 + EA + ${Number(item.amount).toFixed(2)} + ${Number(item.igstRate || 0).toFixed(2)} + ${Number(item.igstAmt || 0).toFixed(2)} + ${Number(item.cgstRate || 0).toFixed(2)} + ${Number(item.cgstAmt || 0).toFixed(2)} + ${Number(item.sgstRate || 0).toFixed(2)} + ${Number(item.sgstAmt || 0).toFixed(2)} + ${Number(item.totalAmt || 0).toFixed(2)} + + `).join(''); + } else { + tableRows = ` + + CLAIM + ${request.title || 'Warranty Claim'} + 998881 + ${Number(invoice.taxableValue || invoice.amount || 0).toFixed(2)} + 0.00 + EA + ${Number(invoice.taxableValue || invoice.amount || 0).toFixed(2)} + ${invoice.igstTotal > 0 ? (Number(invoice.igstTotal) / Number(invoice.taxableValue || invoice.amount || 1) * 100).toFixed(2) : '0.00'} + ${Number(invoice.igstTotal || 0).toFixed(2)} + ${invoice.cgstTotal > 0 ? (Number(invoice.cgstTotal) / Number(invoice.taxableValue || invoice.amount || 1) * 100).toFixed(2) : '0.00'} + ${Number(invoice.cgstTotal || 0).toFixed(2)} + ${invoice.sgstTotal > 0 ? (Number(invoice.sgstTotal) / Number(invoice.taxableValue || invoice.amount || 1) * 100).toFixed(2) : '0.00'} + ${Number(invoice.sgstTotal || 0).toFixed(2)} + ${Number(invoice.amount || 0).toFixed(2)} + + `; + } + + let totalTaxable = 0; + let totalTax = 0; + let grandTotal = 0; + + if (invoiceItems && invoiceItems.length > 0) { + invoiceItems.forEach((item: any) => { + totalTaxable += Number(item.assAmt || 0); + totalTax += Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0); + grandTotal += Number(item.totItemVal || 0); + }); + } else if (completionExpenses.length > 0) { + // Fallback for completion expenses calculation + completionExpenses.forEach((item: any) => { + const qty = Number(item.quantity || 1); + const baseAmt = Number(item.amount || 0) * qty; + const taxAmt = Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0); + totalTaxable += baseAmt; + totalTax += taxAmt; + grandTotal += (baseAmt + taxAmt); + }); + } else { + // Fallback for invoice record + totalTaxable = Number(invoice.taxableValue || invoice.amount || 0); + totalTax = Number(invoice.igstTotal || 0) + Number(invoice.cgstTotal || 0) + Number(invoice.sgstTotal || 0); + grandTotal = Number(invoice.amount || 0); + } + + const totalValueInWords = amountToWords(grandTotal); + const totalTaxInWords = amountToWords(totalTax); + return ` @@ -106,12 +241,12 @@ export class PdfService {
-
IRN No : ${invoice.irn || 'N/A'}
-
Ack No : ${invoice.ackNo || 'N/A'}
-
Ack Date & Time : ${invoice.ackDate ? dayjs(invoice.ackDate).format('YYYY-MM-DD HH:mm:ss') : 'N/A'}
+ ${invoice.irn ? `
IRN No : ${invoice.irn}
` : ''} + ${invoice.ackNo ? `
Ack No : ${invoice.ackNo}
` : ''} + ${invoice.ackDate ? `
Ack Date & Time : ${dayjs(invoice.ackDate).format('YYYY-MM-DD HH:mm:ss')}
` : ''}
- + ${qrImage ? `` : ''}
WARRANTY CLAIM TAX INVOICE
@@ -127,12 +262,11 @@ export class PdfService {
Store
${dealer?.dealerName || claimDetails?.dealerName || 'N/A'}
Supplier GSTIN
${dealer?.gstin || 'N/A'}

-
POS
${dealer?.state || dealer?.city || 'N/A'}
-
Claim Invoice No.
${request.requestNumber || 'N/A'}
-
Claim Invoice Date
${dayjs().format('DD-MM-YYYY')}
-
Job Card No.
N/A
-
KMS.Reading
N/A
-
Last Approval Date
${invoice.generatedAt ? dayjs(invoice.generatedAt).format('DD-MM-YYYY') : 'N/A'}
+
POS
${invoice.placeOfSupply || dealer?.state || 'Test State'}
+
Claim Request Number
${request.requestNumber || 'N/A'}
+
Claim Invoice No.
${invoice.invoiceNumber || 'N/A'}
+
Claim Invoice Date
${invoice.invoiceDate ? dayjs(invoice.invoiceDate).format('DD-MM-YYYY') : dayjs().format('DD-MM-YYYY')}
+
Last Approval Date
${invoice.generatedAt ? dayjs(invoice.generatedAt).format('DD-MM-YYYY') : dayjs().format('DD-MM-YYYY')}
@@ -142,9 +276,8 @@ export class PdfService { Part Description HSN/SAC - Qty - Rate - Discount + Base Amount + Disc UOM Taxable Value IGST % @@ -153,39 +286,23 @@ export class PdfService { CGST SGST % SGST - Amount + Total - - CLAIM - ${request.title || 'Warranty Claim'} - 998881 - 1.00 - ${Number(invoice.amount || 0).toFixed(2)} - 0.00 - EA - ${Number(invoice.amount || 0).toFixed(2)} - 18.00 - ${(Number(invoice.amount || 0) * 0.18).toFixed(2)} - 0.00 - 0.00 - 0.00 - 0.00 - ${(Number(invoice.amount || 0) * 1.18).toFixed(2)} - + ${tableRows}
-
TOTAL:${Number(invoice.amount || 0).toFixed(2)}
-
TOTAL TAX:${(Number(invoice.amount || 0) * 0.18).toFixed(2)}
-
GRAND TOTAL:${(Number(invoice.amount || 0) * 1.18).toFixed(2)}
+
TAXABLE TOTAL:${totalTaxable.toFixed(2)}
+
TOTAL TAX:${totalTax.toFixed(2)}
+
GRAND TOTAL:${grandTotal.toFixed(2)}
-
TOTAL VALUE IN WORDS: Rupees ${invoice.totalValueInWords || 'N/A'}
-
TOTAL TAX IN WORDS: Rupees ${invoice.taxValueInWords || 'N/A'}
+
TOTAL VALUE IN WORDS: Rupees ${totalValueInWords}
+
TOTAL TAX IN WORDS: Rupees ${totalTaxInWords}