Compare commits

...

3 Commits

Author SHA1 Message Date
9c003e9a16 csv filed issue on unique transaction no and also dealer claim templates enhanced 2026-03-13 14:31:27 +05:30
Aaditya Jaiswal
89beffee2e debit CSV and details page fixed 2026-03-13 14:15:55 +05:30
Aaditya Jaiswal
b3dcaca697 credit note format change, handeling versions 2026-03-12 18:37:05 +05:30
23 changed files with 388 additions and 276 deletions

3
.gitignore vendored
View File

@ -135,4 +135,5 @@ uploads/
# GCP Service Account Key # GCP Service Account Key
config/gcp-key.json config/gcp-key.json
Jenkinsfile Jenkinsfile
clear-26as-data.ts

View File

@ -1 +1 @@
import{a as s}from"./index-DDuRVIKn.js";import"./radix-vendor-CYvDqP9X.js";import"./charts-vendor-BVfwAPj-.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-CxsBWvVP.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-BATWUvr6.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion}; import{a as s}from"./index-DK9CP9m9.js";import"./radix-vendor-CYvDqP9X.js";import"./charts-vendor-BVfwAPj-.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-CxsBWvVP.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-BATWUvr6.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};

File diff suppressed because one or more lines are too long

View File

@ -13,7 +13,7 @@
<!-- Preload essential fonts and icons --> <!-- Preload essential fonts and icons -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<script type="module" crossorigin src="/assets/index-DDuRVIKn.js"></script> <script type="module" crossorigin src="/assets/index-DK9CP9m9.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-BVfwAPj-.js"> <link rel="modulepreload" crossorigin href="/assets/charts-vendor-BVfwAPj-.js">
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CYvDqP9X.js"> <link rel="modulepreload" crossorigin href="/assets/radix-vendor-CYvDqP9X.js">
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js"> <link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js">

View File

@ -113,12 +113,15 @@ SAP_REQUESTER=REFMS
# WARNING: Only use in development/testing environments # WARNING: Only use in development/testing environments
SAP_DISABLE_SSL_VERIFY=false SAP_DISABLE_SSL_VERIFY=false
# WFM file paths (base path; dealer claims use DLR_INC_CLAIMS, Form 16 uses FORM_16) # WFM file paths (base path; dealer claims use DLR_INC_CLAIMS, Form 16 uses FORM16_CRDT / FORM16_DEBT)
# If unset: Windows defaults to C:\WFM; Linux/Mac defaults to <cwd>/wfm (paths are cross-platform). # If unset: Windows defaults to C:\WFM; Linux/Mac defaults to <cwd>/wfm (paths are cross-platform).
# WFM_BASE_PATH=C:\WFM # WFM_BASE_PATH=C:\WFM
# WFM_INCOMING_CLAIMS_PATH=WFM-QRE\INCOMING\WFM_MAIN\DLR_INC_CLAIMS # WFM_INCOMING_CLAIMS_PATH=WFM-QRE\INCOMING\WFM_MAIN\DLR_INC_CLAIMS
# WFM_OUTGOING_CLAIMS_PATH=WFM-QRE\OUTGOING\WFM_SAP_MAIN\DLR_INC_CLAIMS # WFM_OUTGOING_CLAIMS_PATH=WFM-QRE\OUTGOING\WFM_SAP_MAIN\DLR_INC_CLAIMS
# Form 16 credit/debit note CSV: INCOMING/WFM_MAIN/FORM_16, OUTGOING/WFM_SAP_MAIN/FORM_16 # Form 16 credit note CSV (incoming): INCOMING/WFM_MAIN/FORM16_CRDT
# WFM_FORM16_INCOMING_PATH=WFM-QRE\INCOMING\WFM_MAIN\Form_16 # Form 16 debit note CSV (incoming): INCOMING/WFM_MAIN/FORM16_DEBT
# WFM_FORM16_OUTGOING_PATH=WFM-QRE\OUTGOING\WFM_SAP_MAIN\Form_16 # Form 16 SAP responses (outgoing): OUTGOING/WFM_SAP_MAIN/FORM16_CRDT
# WFM_FORM16_CREDIT_INCOMING_PATH=WFM-QRE\INCOMING\WFM_MAIN\FORM16_CRDT
# WFM_FORM16_DEBIT_INCOMING_PATH=WFM-QRE\INCOMING\WFM_MAIN\FORM16_DEBT
# WFM_FORM16_OUTGOING_PATH=WFM-QRE\OUTGOING\WFM_SAP_MAIN\FORM16_CRDT

View File

@ -26,6 +26,7 @@
"seed:demo-dealers": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-dealers.ts", "seed:demo-dealers": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-dealers.ts",
"cleanup:dealer-claims": "ts-node -r tsconfig-paths/register src/scripts/cleanup-dealer-claims.ts", "cleanup:dealer-claims": "ts-node -r tsconfig-paths/register src/scripts/cleanup-dealer-claims.ts",
"clear:form16-and-demo": "ts-node -r tsconfig-paths/register src/scripts/clear-form16-and-demo-data.ts", "clear:form16-and-demo": "ts-node -r tsconfig-paths/register src/scripts/clear-form16-and-demo-data.ts",
"clear:26as": "ts-node -r tsconfig-paths/register src/scripts/clear-26as-data.ts",
"redis:start": "docker run -d --name redis-workflow -p 6379:6379 redis:7-alpine", "redis:start": "docker run -d --name redis-workflow -p 6379:6379 redis:7-alpine",
"redis:stop": "docker rm -f redis-workflow", "redis:stop": "docker rm -f redis-workflow",
"test": "jest --passWithNoTests --forceExit", "test": "jest --passWithNoTests --forceExit",

View File

@ -442,33 +442,6 @@ export class Form16Controller {
} }
} }
/**
* POST /api/v1/form16/credit-notes/:id/generate-debit-note
* RE only. Generate debit note for a credit note (dealer + credit note number + amount SAP simulation save debit note).
*/
async generateForm16DebitNote(req: Request, res: Response): Promise<void> {
try {
const userId = (req as AuthenticatedRequest).user?.userId;
if (!userId) return ResponseHandler.unauthorized(res, 'Authentication required');
const creditNoteId = parseInt((req.params as { id: string }).id, 10);
if (Number.isNaN(creditNoteId) || creditNoteId <= 0) {
return ResponseHandler.error(res, 'Valid credit note id is required', 400);
}
const body = (req.body || {}) as { amount?: number };
const amount = typeof body.amount === 'number' ? body.amount : parseFloat(String(body.amount || 0));
const result = await form16Service.generateForm16DebitNoteForCreditNote(creditNoteId, userId, amount);
return ResponseHandler.success(
res,
{ debitNote: result.debitNote, creditNote: result.creditNote },
'Debit note generated'
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] generateForm16DebitNote error:', error);
return ResponseHandler.error(res, errorMessage, 400);
}
}
/** /**
* POST /api/v1/form16/26as/upload * POST /api/v1/form16/26as/upload
* RE only. Upload a single TXT file containing 26AS data (all dealers). Data stored in tds_26as_entries. * RE only. Upload a single TXT file containing 26AS data (all dealers). Data stored in tds_26as_entries.

View File

@ -3,7 +3,7 @@
*/ */
import { ApprovalRequestData } from './types'; import { ApprovalRequestData } from './types';
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers'; import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles, getCustomMessageSection } from './helpers';
import { getBrandedHeader } from './branding.config'; import { getBrandedHeader } from './branding.config';
export function getApprovalRequestEmail(data: ApprovalRequestData): string { export function getApprovalRequestEmail(data: ApprovalRequestData): string {
@ -102,6 +102,9 @@ export function getApprovalRequestEmail(data: ApprovalRequestData): string {
</tr> </tr>
</table> </table>
<!-- Custom Message Section -->
${getCustomMessageSection(data.customMessage)}
<!-- Description (supports rich text HTML including tables) --> <!-- Description (supports rich text HTML including tables) -->
<div style="margin-bottom: 30px;"> <div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Description:</h3> <h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Description:</h3>

View File

@ -5,7 +5,7 @@
*/ */
import { DealerProposalRequiredData } from './types'; import { DealerProposalRequiredData } from './types';
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers'; import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles, getCustomMessageSection } from './helpers';
import { getBrandedHeader } from './branding.config'; import { getBrandedHeader } from './branding.config';
export function getDealerCompletionRequiredEmail(data: DealerProposalRequiredData): string { export function getDealerCompletionRequiredEmail(data: DealerProposalRequiredData): string {
@ -103,6 +103,9 @@ export function getDealerCompletionRequiredEmail(data: DealerProposalRequiredDat
</tr> </tr>
</table> </table>
<!-- Custom Message Section -->
${getCustomMessageSection(data.customMessage)}
<div style="padding: 20px; background-color: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px; margin-bottom: 30px;"> <div style="padding: 20px; background-color: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #856404; font-size: 16px; font-weight: 600;">What You Need to Submit:</h3> <h3 style="margin: 0 0 10px; color: #856404; font-size: 16px; font-weight: 600;">What You Need to Submit:</h3>
<ul style="margin: 0; padding: 0 0 0 20px; color: #666666; font-size: 14px; line-height: 1.6;"> <ul style="margin: 0; padding: 0 0 0 20px; color: #666666; font-size: 14px; line-height: 1.6;">

View File

@ -5,7 +5,7 @@
*/ */
import { DealerProposalRequiredData } from './types'; import { DealerProposalRequiredData } from './types';
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers'; import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles, getCustomMessageSection } from './helpers';
import { getBrandedHeader } from './branding.config'; import { getBrandedHeader } from './branding.config';
export function getDealerProposalRequiredEmail(data: DealerProposalRequiredData): string { export function getDealerProposalRequiredEmail(data: DealerProposalRequiredData): string {
@ -152,6 +152,9 @@ export function getDealerProposalRequiredEmail(data: DealerProposalRequiredData)
</tr> </tr>
</table> </table>
<!-- Custom Message Section -->
${getCustomMessageSection(data.customMessage)}
<!-- Description (supports rich text HTML including tables) --> <!-- Description (supports rich text HTML including tables) -->
${data.requestDescription ? ` ${data.requestDescription ? `
<div style="margin-bottom: 30px;"> <div style="margin-bottom: 30px;">

View File

@ -32,7 +32,8 @@ export enum EmailNotificationType {
COMPLETION_DOCUMENTS_SUBMITTED = 'completion_documents_submitted', COMPLETION_DOCUMENTS_SUBMITTED = 'completion_documents_submitted',
EINVOICE_GENERATED = 'einvoice_generated', EINVOICE_GENERATED = 'einvoice_generated',
CREDIT_NOTE_SENT = 'credit_note_sent', CREDIT_NOTE_SENT = 'credit_note_sent',
ADDITIONAL_DOCUMENT_ADDED = 'additional_document_added' ADDITIONAL_DOCUMENT_ADDED = 'additional_document_added',
RE_QUOTATION = 're_quotation',
} }
/** /**

View File

@ -799,6 +799,22 @@ export function getRoleDescription(role: 'Approver' | 'Spectator'): string {
} }
} }
/**
* Generate custom message section (e.g., for re-quotation notes)
*/
export function getCustomMessageSection(message?: string): string {
if (!message) return '';
return `
<div style="margin-bottom: 30px; padding: 20px; background-color: #f0f7ff; border-left: 4px solid #667eea; border-radius: 4px;">
<h3 style="margin: 0 0 10px; color: #333333; font-size: 16px; font-weight: 600;">Note/Instructions:</h3>
<p style="margin: 0; color: #444444; font-size: 15px; line-height: 1.6; font-style: italic;">
"${message}"
</p>
</div>
`;
}
/** /**
* Generate action required section for workflow resumed * Generate action required section for workflow resumed
*/ */

View File

@ -15,7 +15,7 @@ export function getRejectionNotificationEmail(data: RejectionNotificationData):
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<title>Request Rejected</title> <title>${data.isReturnedForRevision ? 'Request Returned for Revision' : 'Request Rejected'}</title>
${getResponsiveStyles()} ${getResponsiveStyles()}
</head> </head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;"> <body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
@ -24,63 +24,65 @@ export function getRejectionNotificationEmail(data: RejectionNotificationData):
<td style="padding: 40px 0;"> <td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0"> <table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
${getEmailHeader(getBrandedHeader({ ${getEmailHeader(getBrandedHeader({
title: 'Request Rejected', title: data.isReturnedForRevision ? 'Revision Required' : 'Request Rejected',
...HeaderStyles.error ...(data.isReturnedForRevision ? HeaderStyles.warning : HeaderStyles.error)
}))} }))}
<tr> <tr>
<td style="padding: 40px 30px;"> <td style="padding: 40px 30px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;"> <p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Dear <strong style="color: #dc3545;">${data.initiatorName}</strong>, Dear <strong style="color: ${data.isReturnedForRevision ? '#856404' : '#dc3545'};">${data.initiatorName}</strong>,
</p> </p>
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;"> <p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
We regret to inform you that your request has been <strong style="color: #dc3545;">rejected</strong> by <strong>${data.approverName}</strong>. ${data.isReturnedForRevision
? `Your request has been <strong>returned for revision</strong> by <strong>${data.approverName}</strong>.`
: `We regret to inform you that your request has been <strong style="color: #dc3545;">rejected</strong> by <strong>${data.approverName}</strong>.`}
</p> </p>
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8d7da; border: 1px solid #f5c6cb; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 100%; border-collapse: collapse; background-color: ${data.isReturnedForRevision ? '#fff3cd' : '#f8d7da'}; border: 1px solid ${data.isReturnedForRevision ? '#ffeeba' : '#f5c6cb'}; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
<tr> <tr>
<td style="padding: 25px;"> <td style="padding: 25px;">
<h2 style="margin: 0 0 20px; color: #721c24; font-size: 18px; font-weight: 600;">Request Summary</h2> <h2 style="margin: 0 0 20px; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 18px; font-weight: 600;">Request Summary</h2>
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr> <tr>
<td style="padding: 8px 0; color: #721c24; font-size: 14px; width: 140px;"> <td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px; width: 140px;">
<strong>Request ID:</strong> <strong>Request ID:</strong>
</td> </td>
<td style="padding: 8px 0; color: #721c24; font-size: 14px;"> <td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
${data.requestId} ${data.requestId}
</td> </td>
</tr> </tr>
<tr> <tr>
<td style="padding: 8px 0; color: #721c24; font-size: 14px;"> <td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
<strong>Rejected By:</strong> <strong>Action By:</strong>
</td> </td>
<td style="padding: 8px 0; color: #721c24; font-size: 14px;"> <td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
${data.approverName} ${data.approverName}
</td> </td>
</tr> </tr>
<tr> <tr>
<td style="padding: 8px 0; color: #721c24; font-size: 14px;"> <td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
<strong>Rejected On:</strong> <strong>Action On:</strong>
</td> </td>
<td style="padding: 8px 0; color: #721c24; font-size: 14px;"> <td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
${data.rejectionDate} ${data.rejectionDate}
</td> </td>
</tr> </tr>
<tr> <tr>
<td style="padding: 8px 0; color: #721c24; font-size: 14px;"> <td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
<strong>Time:</strong> <strong>Time:</strong>
</td> </td>
<td style="padding: 8px 0; color: #721c24; font-size: 14px;"> <td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
${data.rejectionTime} ${data.rejectionTime}
</td> </td>
</tr> </tr>
<tr> <tr>
<td style="padding: 8px 0; color: #721c24; font-size: 14px;"> <td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
<strong>Request Type:</strong> <strong>Request Type:</strong>
</td> </td>
<td style="padding: 8px 0; color: #721c24; font-size: 14px;"> <td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
${data.requestType} ${data.requestType}
</td> </td>
</tr> </tr>
@ -90,8 +92,8 @@ export function getRejectionNotificationEmail(data: RejectionNotificationData):
</table> </table>
<div style="margin-bottom: 30px;"> <div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Reason for Rejection:</h3> <h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">${data.isReturnedForRevision ? 'Reason for Revision:' : 'Reason for Rejection:'}</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #dc3545; border-radius: 4px; overflow-x: auto;"> <div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid ${data.isReturnedForRevision ? '#ffc107' : '#dc3545'}; border-radius: 4px; overflow-x: auto;">
${wrapRichText(data.rejectionReason)} ${wrapRichText(data.rejectionReason)}
</div> </div>
</div> </div>
@ -99,9 +101,13 @@ export function getRejectionNotificationEmail(data: RejectionNotificationData):
<div style="padding: 20px; background-color: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px; margin-bottom: 30px;"> <div style="padding: 20px; background-color: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #856404; font-size: 16px; font-weight: 600;">What You Can Do:</h3> <h3 style="margin: 0 0 10px; color: #856404; font-size: 16px; font-weight: 600;">What You Can Do:</h3>
<ul style="margin: 10px 0 0 0; padding-left: 20px; color: #856404; font-size: 14px; line-height: 1.8;"> <ul style="margin: 10px 0 0 0; padding-left: 20px; color: #856404; font-size: 14px; line-height: 1.8;">
<li>Review the rejection reason carefully</li> ${data.isReturnedForRevision
<li>Make necessary adjustments to your request</li> ? `<li>Review the requested changes carefully</li>
<li>Submit a new request with the required changes</li> <li>Adjust the proposal or documents as needed</li>
<li>Resubmit the request for approval</li>`
: `<li>Review the rejection reason carefully</li>
<li>Make necessary adjustments to your request</li>
<li>Submit a new request with the required changes</li>`}
<li>Contact ${data.approverName} for more clarification if needed</li> <li>Contact ${data.approverName} for more clarification if needed</li>
</ul> </ul>
</div> </div>

View File

@ -47,6 +47,7 @@ export interface ApprovalRequestData extends BaseEmailData {
requestType: string; requestType: string;
requestDescription: string; requestDescription: string;
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
customMessage?: string;
} }
export interface MultiApproverRequestData extends ApprovalRequestData { export interface MultiApproverRequestData extends ApprovalRequestData {
@ -80,6 +81,7 @@ export interface RejectionNotificationData extends BaseEmailData {
rejectionTime: string; rejectionTime: string;
requestType: string; requestType: string;
rejectionReason: string; rejectionReason: string;
isReturnedForRevision?: boolean;
} }
export interface TATReminderData extends BaseEmailData { export interface TATReminderData extends BaseEmailData {
@ -250,6 +252,7 @@ export interface DealerProposalRequiredData extends BaseEmailData {
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
tatHours?: number; tatHours?: number;
dueDate?: string; dueDate?: string;
customMessage?: string;
} }
export interface AdditionalDocumentAddedData extends BaseEmailData { export interface AdditionalDocumentAddedData extends BaseEmailData {

View File

@ -111,14 +111,6 @@ router.post(
asyncHandler(form16Controller.sapSimulateDebitNote.bind(form16Controller)) asyncHandler(form16Controller.sapSimulateDebitNote.bind(form16Controller))
); );
// RE only: generate debit note for a credit note (hits SAP simulation; replace with real SAP later).
router.post(
'/credit-notes/:id/generate-debit-note',
requireForm16ReOnly,
requireForm16SubmissionAccess,
asyncHandler(form16Controller.generateForm16DebitNote.bind(form16Controller))
);
// Dealer-only: pending submissions and pending quarters (Form 16 Pending Submissions page) // Dealer-only: pending submissions and pending quarters (Form 16 Pending Submissions page)
router.get( router.get(
'/dealer/submissions', '/dealer/submissions',

View File

@ -3615,7 +3615,7 @@ export class DealerClaimService {
const csvData = invoiceItems.map((item: any) => { const csvData = invoiceItems.map((item: any) => {
const row: any = { const row: any = {
TRNS_UNIQ_NO: isNonGst ? '' : (item.transactionCode || ''), TRNS_UNIQ_NO: item.transactionCode || '',
CLAIM_NUMBER: requestNumber, CLAIM_NUMBER: requestNumber,
INV_NUMBER: invoice.invoiceNumber || '', INV_NUMBER: invoice.invoiceNumber || '',
DEALER_CODE: claimDetails.dealerCode, DEALER_CODE: claimDetails.dealerCode,

View File

@ -849,6 +849,23 @@ export class DealerClaimApprovalService {
tatPercentageUsed: 0 tatPercentageUsed: 0
} as any); } as any);
} }
// Trigger Re-Quotation Notification (Email + Push)
if (step1.approverId) {
await notificationService.sendToUsers([step1.approverId], {
title: `Revised Quotation Requested: ${(wf as any).requestNumber}`,
body: action.rejectionReason || action.comments || 'Please revise and resubmit your quotation.',
requestNumber: (wf as any).requestNumber,
requestId: level.requestId,
url: `/request/${(wf as any).requestNumber}`,
type: 're_quotation',
priority: 'HIGH',
actionRequired: true,
metadata: {
rejectionReason: action.rejectionReason || action.comments
}
});
}
} }
} }
@ -888,10 +905,10 @@ export class DealerClaimApprovalService {
userAgent: requestMetadata?.userAgent || undefined userAgent: requestMetadata?.userAgent || undefined
}); });
// Notify the approver of the target level // Notify the approver of the target level (skip if specific re-quotation notification was already sent)
if (targetLevel.approverId) { if (targetLevel.approverId && !isReQuotation) {
await notificationService.sendToUsers([targetLevel.approverId], { await notificationService.sendToUsers([targetLevel.approverId], {
title: `Request Returned: ${(wf as any).requestNumber}`, title: `Request Returned for Revision: ${(wf as any).requestNumber}`,
body: `Request "${(wf as any).title}" has been returned to your level for revision. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`, body: `Request "${(wf as any).title}" has been returned to your level for revision. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`,
requestNumber: (wf as any).requestNumber, requestNumber: (wf as any).requestNumber,
requestId: level.requestId, requestId: level.requestId,
@ -903,15 +920,19 @@ export class DealerClaimApprovalService {
} }
// Notify initiator when request is returned // Notify initiator when request is returned
await notificationService.sendToUsers([(wf as any).initiatorId], { // Skip if they are the same as the dealer (who already got a more specific notification)
title: `Request Returned: ${(wf as any).requestNumber}`, const dealerId = targetLevel.approverId;
body: `Request "${(wf as any).title}" has been returned to level ${targetLevel.levelNumber} for revision. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`, if ((wf as any).initiatorId && (wf as any).initiatorId !== dealerId) {
requestNumber: (wf as any).requestNumber, await notificationService.sendToUsers([(wf as any).initiatorId], {
requestId: level.requestId, title: `Request Returned: ${(wf as any).requestNumber}`,
url: `/request/${(wf as any).requestNumber}`, body: `Request "${(wf as any).title}" has been returned to Step ${targetLevel.levelNumber} for revision. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`,
type: 'rejection', requestNumber: (wf as any).requestNumber,
priority: 'MEDIUM' requestId: level.requestId,
}); url: `/request/${(wf as any).requestNumber}`,
type: 'rejection',
priority: 'MEDIUM'
});
}
} }
// Emit real-time update to all users viewing this request // Emit real-time update to all users viewing this request

View File

@ -92,26 +92,6 @@ export class DealerClaimEmailService implements IWorkflowEmailService {
(levelName.includes('completion') || levelName.includes('documents')) && (levelName.includes('completion') || levelName.includes('documents')) &&
!levelName.includes('proposal'); // Explicitly exclude proposal !levelName.includes('proposal'); // Explicitly exclude proposal
// Safety check: If proposal already submitted, don't send proposal email
// This prevents sending proposal email if levelName somehow matches both conditions
if (isDealerProposalStep && requestData.requestId) {
try {
const { DealerProposalDetails } = await import('@models/DealerProposalDetails');
const existingProposal = await DealerProposalDetails.findOne({
where: { requestId: requestData.requestId }
});
if (existingProposal) {
logger.warn(`[DealerClaimEmail] ⚠️ Proposal already submitted but levelName indicates proposal step. Forcing completion step.`);
// If proposal exists, this MUST be completion step, not proposal
await this.sendDealerCompletionRequiredEmail(requestData, approverUser, initiatorData, level);
return;
}
} catch (e) {
logger.error(`[DealerClaimEmail] Error checking proposal:`, e);
// Continue with normal flow if check fails
}
}
// Route to appropriate template // Route to appropriate template
if (isDealerCompletionStep) { if (isDealerCompletionStep) {
logger.info(`[DealerClaimEmail] ✅ DEALER COMPLETION step - sending completion documents required email`); logger.info(`[DealerClaimEmail] ✅ DEALER COMPLETION step - sending completion documents required email`);
@ -224,6 +204,18 @@ export class DealerClaimEmailService implements IWorkflowEmailService {
where: { requestId: requestData.requestId } where: { requestId: requestData.requestId }
}); });
// Determine stage-specific instructions
let stageInstructions = '';
const name = (currentLevel?.levelName || '').toLowerCase();
if (name.includes('evaluation') || name.includes('requestor evaluation')) {
stageInstructions = 'Please evaluate the proposal submitted by the dealer. Verify the activity details and estimated budget.';
} else if (name.includes('lead') || name.includes('department lead')) {
stageInstructions = 'The requestor has evaluated this proposal and recommended it for your approval. Please review and provide your authorization.';
} else if (name.includes('claim approval') && name.includes('requestor')) {
stageInstructions = 'The dealer has submitted completion documents. Please verify the expenses and documents before providing final claim approval.';
}
// Enrich requestData with dealer claim-specific information // Enrich requestData with dealer claim-specific information
const enrichedRequestData = { const enrichedRequestData = {
...requestData, ...requestData,
@ -256,7 +248,8 @@ export class DealerClaimEmailService implements IWorkflowEmailService {
approverData, approverData,
initiatorData, initiatorData,
false, // isMultiLevel = false for dealer claim workflows false, // isMultiLevel = false for dealer claim workflows
undefined // No approval chain needed undefined, // No approval chain needed
stageInstructions // Pass as customMessage (contextual instruction)
); );
} }

View File

@ -144,7 +144,8 @@ export class EmailNotificationService {
approverData: any, approverData: any,
initiatorData: any, initiatorData: any,
isMultiLevel: boolean, isMultiLevel: boolean,
approvalChain?: any[] approvalChain?: any[],
customMessage?: string
): Promise<void> { ): Promise<void> {
try { try {
// Check preferences // Check preferences
@ -185,7 +186,8 @@ export class EmailNotificationService {
totalApprovers: approvalChain.length, totalApprovers: approvalChain.length,
approversList: chainData, approversList: chainData,
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
companyName: CompanyInfo.name companyName: CompanyInfo.name,
customMessage
}; };
const html = getMultiApproverRequestEmail(data); const html = getMultiApproverRequestEmail(data);
@ -214,7 +216,8 @@ export class EmailNotificationService {
requestDate: this.formatDate(requestData.createdAt), requestDate: this.formatDate(requestData.createdAt),
requestTime: this.formatTime(requestData.createdAt), requestTime: this.formatTime(requestData.createdAt),
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
companyName: CompanyInfo.name companyName: CompanyInfo.name,
customMessage
}; };
const html = getApprovalRequestEmail(data); const html = getApprovalRequestEmail(data);
@ -298,6 +301,14 @@ export class EmailNotificationService {
rejectionReason: string rejectionReason: string
): Promise<void> { ): Promise<void> {
try { try {
// 1. Skip if approver is the same as initiator
if (approverData.userId && initiatorData.userId && approverData.userId === initiatorData.userId) {
logger.info(`Email skipped: Approver ${approverData.userId} is the initiator, no need to notify self of rejection/return`);
return;
}
const isReturnedForRevision = rejectionReason.includes('Revised Quotation Requested');
// Use override for high-priority emails // Use override for high-priority emails
const canSend = await shouldSendEmailWithOverride( const canSend = await shouldSendEmailWithOverride(
initiatorData.userId, initiatorData.userId,
@ -319,11 +330,14 @@ export class EmailNotificationService {
requestType: getTemplateTypeLabel(requestData.templateType || requestData.requestType), requestType: getTemplateTypeLabel(requestData.templateType || requestData.requestType),
rejectionReason, rejectionReason,
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
companyName: CompanyInfo.name companyName: CompanyInfo.name,
isReturnedForRevision
}; };
const html = getRejectionNotificationEmail(data); const html = getRejectionNotificationEmail(data);
const subject = `${requestData.requestNumber} - ${requestData.title} - Request Rejected`; const subject = isReturnedForRevision
? `${requestData.requestNumber} - ${requestData.title} - Request Returned for Revision`
: `${requestData.requestNumber} - ${requestData.title} - Request Rejected`;
const result = await emailService.sendEmail({ const result = await emailService.sendEmail({
to: initiatorData.email, to: initiatorData.email,
@ -927,7 +941,8 @@ export class EmailNotificationService {
requestData: any, requestData: any,
dealerData: any, dealerData: any,
initiatorData: any, initiatorData: any,
claimData?: any claimData?: any,
customMessage?: string
): Promise<void> { ): Promise<void> {
try { try {
const canSend = await shouldSendEmail( const canSend = await shouldSendEmail(
@ -944,7 +959,7 @@ export class EmailNotificationService {
let dueDate: string | undefined; let dueDate: string | undefined;
if (claimData?.tatHours) { if (claimData?.tatHours) {
const dueDateObj = dayjs().add(claimData.tatHours, 'hour'); const dueDateObj = dayjs().add(claimData.tatHours, 'hour');
dueDate = dueDateObj.format('MMMM D, YYYY [at] h:mm A'); dueDate = dueDateObj.format('MMMM D, YYYY');
} }
const data: DealerProposalRequiredData = { const data: DealerProposalRequiredData = {
@ -965,7 +980,8 @@ export class EmailNotificationService {
tatHours: claimData?.tatHours, tatHours: claimData?.tatHours,
dueDate: dueDate, dueDate: dueDate,
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
companyName: CompanyInfo.name companyName: CompanyInfo.name,
customMessage
}; };
const html = getDealerProposalRequiredEmail(data); const html = getDealerProposalRequiredEmail(data);
@ -988,22 +1004,24 @@ export class EmailNotificationService {
} }
/** /**
* 12b. Send Dealer Completion Documents Required Email * 12a. Send Re-Quotation Required Email
* (Uses Proposal Required template with custom subject and rejection reason)
*/ */
async sendDealerCompletionRequired( async sendReQuotationRequired(
requestData: any, requestData: any,
dealerData: any, dealerData: any,
initiatorData: any, initiatorData: any,
claimData?: any claimData: any,
rejectionReason: string
): Promise<void> { ): Promise<void> {
try { try {
const canSend = await shouldSendEmail( const canSend = await shouldSendEmail(
dealerData.userId, dealerData.userId,
EmailNotificationType.APPROVAL_REQUEST // Use approval_request type for preferences EmailNotificationType.RE_QUOTATION
); );
if (!canSend) { if (!canSend) {
logger.info(`Email skipped (preferences): Dealer Completion Required for ${dealerData.email}`); logger.info(`Email skipped (preferences): Re-Quotation Required for ${dealerData.email}`);
return; return;
} }
@ -1011,7 +1029,7 @@ export class EmailNotificationService {
let dueDate: string | undefined; let dueDate: string | undefined;
if (claimData?.tatHours) { if (claimData?.tatHours) {
const dueDateObj = dayjs().add(claimData.tatHours, 'hour'); const dueDateObj = dayjs().add(claimData.tatHours, 'hour');
dueDate = dueDateObj.format('MMMM D, YYYY [at] h:mm A'); dueDate = dueDateObj.format('MMMM D, YYYY');
} }
const data: DealerProposalRequiredData = { const data: DealerProposalRequiredData = {
@ -1032,7 +1050,77 @@ export class EmailNotificationService {
tatHours: claimData?.tatHours, tatHours: claimData?.tatHours,
dueDate: dueDate, dueDate: dueDate,
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
companyName: CompanyInfo.name companyName: CompanyInfo.name,
customMessage: rejectionReason
};
const html = getDealerProposalRequiredEmail(data);
const subject = `[${requestData.requestNumber}] Revised Quotation Requested - ${data.activityName}`;
const result = await emailService.sendEmail({
to: dealerData.email,
subject,
html
});
if (result.previewUrl) {
logger.info(`📧 Re-Quotation Required Email Preview: ${result.previewUrl}`);
}
logger.info(`✅ Re-Quotation Required email sent to ${dealerData.email} for request ${requestData.requestNumber}`);
} catch (error) {
logger.error(`Failed to send Re-Quotation Required email:`, error);
throw error;
}
}
/**
* 12b. Send Dealer Completion Documents Required Email
*/
async sendDealerCompletionRequired(
requestData: any,
dealerData: any,
initiatorData: any,
claimData?: any,
customMessage?: string
): Promise<void> {
try {
const canSend = await shouldSendEmail(
dealerData.userId,
EmailNotificationType.APPROVAL_REQUEST // Use approval_request type for preferences
);
if (!canSend) {
logger.info(`Email skipped (preferences): Dealer Completion Required for ${dealerData.email}`);
return;
}
// Calculate due date from TAT if available
let dueDate: string | undefined;
if (claimData?.tatHours) {
const dueDateObj = dayjs().add(claimData.tatHours, 'hour');
dueDate = dueDateObj.format('MMMM D, YYYY');
}
const data: DealerProposalRequiredData = {
recipientName: dealerData.displayName || dealerData.email,
requestId: requestData.requestNumber,
requestTitle: requestData.title,
dealerName: dealerData.displayName || dealerData.email || claimData?.dealerName || 'Dealer',
initiatorName: initiatorData.displayName || initiatorData.email,
activityName: claimData?.activityName || requestData.title,
activityType: claimData?.activityType || 'N/A',
activityDate: claimData?.activityDate ? this.formatDate(claimData.activityDate) : undefined,
location: claimData?.location,
estimatedBudget: claimData?.estimatedBudget,
requestDate: this.formatDate(requestData.createdAt),
requestTime: this.formatTime(requestData.createdAt),
requestDescription: requestData.description || '',
priority: requestData.priority || 'MEDIUM',
tatHours: claimData?.tatHours,
dueDate: dueDate,
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
companyName: CompanyInfo.name,
customMessage
}; };
const html = getDealerCompletionRequiredEmail(data); const html = getDealerCompletionRequiredEmail(data);

View File

@ -2,8 +2,8 @@
* Form 16 (Form 16A TDS Credit) service. * Form 16 (Form 16A TDS Credit) service.
* Quarter-based reconciliation: 26AS (aggregated by tan+fy+quarter), Form 16A match, credit/debit, ledger. * Quarter-based reconciliation: 26AS (aggregated by tan+fy+quarter), Form 16A match, credit/debit, ledger.
* *
* Credit note generation: run26asMatchAndCreditNote only (on Form 16A submit, match 26AS Section 194Q/Booking F/O CN-F-16-{dealerCode}-{FY}-{quarter}, ledger, CSV to WFM FORM_16). * Credit note: run26asMatchAndCreditNote only (on Form 16A submit, match 26AS CN-F-16-{...}, ledger, CSV to WFM FORM_16).
* Debit: generateForm16DebitNoteForCreditNote (manual) and process26asUploadAggregation (auto when 26AS total drops); DN-F-16-{dc}-{fy}-{q}. * Debit: process26asUploadAggregation only (when 26AS total drops for a SETTLED quarter); DN-F-16-{...}, CSV to WFM FORM_16.
*/ */
import crypto from 'crypto'; import crypto from 'crypto';
@ -416,23 +416,49 @@ function form16FyCompact(financialYear: string): string {
} }
/** /**
* Form 16 credit note number: CN-F-16-DC-FY-Q (CN=credit note, F=form, 16, DC=dealer code, FY=financial year, Q=quarter) * Sanitize certificate number for use in note numbers (alphanumeric and single hyphens only).
*/ */
export function formatForm16CreditNoteNumber(dealerCode: string, financialYear: string, quarter: string): string { function sanitizeCertificateNumber(raw: string): string {
const dc = (dealerCode || '').trim().replace(/\s+/g, '-') || 'XX'; const s = (raw || '').trim().replace(/\s+/g, '-').replace(/[^A-Za-z0-9-]/g, '') || '';
const fy = form16FyCompact(financialYear) || 'XX'; return s || 'XX';
const q = normalizeQuarter(quarter) || 'X';
return `CN-F-16-${dc}-${fy}-${q}`;
} }
/** /**
* Form 16 debit note number: DN-F-16-DC-FY-Q (DN=debit note, F=form, 16, DC=dealer code, FY=financial year, Q=quarter) * Form 16 credit note number: CN-F-16-{certificateNumber}-{dealerCode}-{FY}-{quarter}-V{version}
* Supports revised 26AS / Form 16 resubmission versioning.
*/ */
export function formatForm16DebitNoteNumber(dealerCode: string, financialYear: string, quarter: string): string { export function formatForm16CreditNoteNumber(
dealerCode: string,
financialYear: string,
quarter: string,
certificateNumber: string,
version: number = 1
): string {
const cert = sanitizeCertificateNumber(certificateNumber);
const dc = (dealerCode || '').trim().replace(/\s+/g, '-') || 'XX'; const dc = (dealerCode || '').trim().replace(/\s+/g, '-') || 'XX';
const fy = form16FyCompact(financialYear) || 'XX'; const fy = form16FyCompact(financialYear) || 'XX';
const q = normalizeQuarter(quarter) || 'X'; const q = normalizeQuarter(quarter) || 'X';
return `DN-F-16-${dc}-${fy}-${q}`; const v = Math.max(1, Math.floor(version));
return `CN-F-16-${cert}-${dc}-${fy}-${q}-V${v}`;
}
/**
* Form 16 debit note number: DN-F-16-{creditNoteCertificateNumber}-{dc}-{fy}-{q}-V{version}
* Uses the certificate number of the credit note being reversed (same Form 16A certificate that led to that credit note).
*/
export function formatForm16DebitNoteNumber(
dealerCode: string,
financialYear: string,
quarter: string,
version: number = 1,
creditNoteCertificateNumber: string = ''
): string {
const cert = sanitizeCertificateNumber(creditNoteCertificateNumber) || 'XX';
const dc = (dealerCode || '').trim().replace(/\s+/g, '-') || 'XX';
const fy = form16FyCompact(financialYear) || 'XX';
const q = normalizeQuarter(quarter) || 'X';
const v = Math.max(1, Math.floor(version));
return `DN-F-16-${cert}-${dc}-${fy}-${q}-V${v}`;
} }
/** /**
@ -520,9 +546,11 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
} }
} }
// Dealer code from submission (set at create from users.employee_number) // Dealer code, certificate number and version from submission (for revised 26AS / Form 16 versioning)
const dealerCode = (sub.dealerCode || '').toString().trim(); const dealerCode = (sub.dealerCode || '').toString().trim();
const cnNumber = formatForm16CreditNoteNumber(dealerCode, financialYear, quarter); const certificateNumber = (sub.form16aNumber || '').toString().trim();
const version = typeof sub.version === 'number' && sub.version >= 1 ? sub.version : 1;
const cnNumber = formatForm16CreditNoteNumber(dealerCode, financialYear, quarter, certificateNumber, version);
const now = new Date(); const now = new Date();
const creditNote = await Form16CreditNote.create({ const creditNote = await Form16CreditNote.create({
submissionId: submission.id, submissionId: submission.id,
@ -551,31 +579,27 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
validationNotes: null, validationNotes: null,
}); });
// Push Form 16 credit note CSV to WFM INCOMING/WFM_MAIN/FORM_16 (pipe | separator, no double quotes) // Push Form 16 credit note incoming CSV to WFM INCOMING/WFM_MAIN/FORM16_CRDT (SAP credit note generation exact fields only)
try { try {
const dealer = await Dealer.findOne({
where: { [Op.or]: [{ salesCode: dealerCode }, { dlrcode: dealerCode }], isActive: true },
attributes: ['dealership', 'dealerPrincipalName'],
});
const dealerName = dealer ? ((dealer as any).dealership || (dealer as any).dealerPrincipalName || dealerCode) : dealerCode;
const trnsUniqNo = `F16-CN-${submission.id}-${creditNote.id}-${Date.now()}`; const trnsUniqNo = `F16-CN-${submission.id}-${creditNote.id}-${Date.now()}`;
const claimDate = now.toISOString().slice(0, 10).replace(/-/g, ''); const docDate = now.toISOString().slice(0, 10).replace(/-/g, '');
const csvRow = { const fyCompact = form16FyCompact(financialYear) || '';
CREDIT_TYPE: 'Form16', const finYearAndQuarter = fyCompact && quarter ? `FY_${fyCompact}_${quarter}` : '';
DEALER_CODE: dealerCode, const csvRow: Record<string, string | number> = {
DEALER_NAME: dealerName,
AMOUNT: tdsAmount,
FINANCIAL_YEAR: financialYear,
QUARTER: quarter,
CREDIT_NOTE_NUMBER: cnNumber,
TRNS_UNIQ_NO: trnsUniqNo, TRNS_UNIQ_NO: trnsUniqNo,
CLAIM_DATE: claimDate, TDS_TRNS_ID: cnNumber,
DEALER_CODE: dealerCode,
TDS_TRNS_DOC_TYP: 'ZTDS',
DLR_TAN_NO: tanNumber,
'FIN_YEAR & QUARTER': finYearAndQuarter,
DOC_DATE: docDate,
TDS_AMT: Number(tdsAmount).toFixed(2),
}; };
const fileName = `${cnNumber}.csv`; const fileName = `${cnNumber}.csv`;
await wfmFileService.generateForm16IncomingCSV([csvRow], fileName); await wfmFileService.generateForm16IncomingCSV([csvRow], fileName, 'credit');
logger.info(`[Form16] Credit note CSV pushed to WFM FORM_16: ${cnNumber}`); logger.info(`[Form16] Credit note CSV pushed to WFM FORM16_CRDT: ${cnNumber}`);
} catch (csvErr: any) { } catch (csvErr: any) {
logger.error('[Form16] Failed to push credit note CSV to WFM FORM_16:', csvErr?.message || csvErr); logger.error('[Form16] Failed to push credit note CSV to WFM FORM16_CRDT:', csvErr?.message || csvErr);
// Do not fail the flow; credit note and ledger are already created // Do not fail the flow; credit note and ledger are already created
} }
@ -734,6 +758,14 @@ export async function createSubmission(
logger.info( logger.info(
`[Form16] Submission match complete: requestId=${requestId}, status=${validationStatus}${validationNotes ? `, notes=${validationNotes}` : ''}${creditNoteNumber ? `, creditNote=${creditNoteNumber}` : ''}.` `[Form16] Submission match complete: requestId=${requestId}, status=${validationStatus}${validationNotes ? `, notes=${validationNotes}` : ''}${creditNoteNumber ? `, creditNote=${creditNoteNumber}` : ''}.`
); );
// When credit note is issued (completed), set workflow status to CLOSED so the request appears on Closed requests page
if (validationStatus === 'success' && creditNoteNumber) {
const workflow = await WorkflowRequest.findOne({ where: { requestId }, attributes: ['requestId', 'status'] });
if (workflow && (workflow as any).status !== WorkflowStatus.CLOSED) {
await workflow.update({ status: WorkflowStatus.CLOSED });
logger.info(`[Form16] Request ${requestId} set to CLOSED (credit note issued).`);
}
}
} catch (err: any) { } catch (err: any) {
logger.error( logger.error(
`[Form16] 26AS match/credit note error for requestId=${requestId}: Form 16A TAN=${(submission as any).tanNumber}, FY=${(submission as any).financialYear}, Quarter=${(submission as any).quarter}, TDS amount=${(submission as any).tdsAmount}. Error:`, `[Form16] 26AS match/credit note error for requestId=${requestId}: Form 16A TAN=${(submission as any).tanNumber}, FY=${(submission as any).financialYear}, Quarter=${(submission as any).quarter}, TDS amount=${(submission as any).tdsAmount}. Error:`,
@ -1271,72 +1303,6 @@ export async function getCreditNoteById(creditNoteId: number) {
}; };
} }
/**
* RE only. Generate debit note for a credit note (Form 16). Creates Form16DebitNote with DN-F-16-DC-FY-Q format and pushes CSV to WFM INCOMING/WFM_MAIN/FORM_16 for SAP.
*/
export async function generateForm16DebitNoteForCreditNote(
creditNoteId: number,
userId: string,
amount: number
): Promise<{ debitNote: Form16DebitNote; creditNote: Form16CreditNote }> {
if (!amount || amount <= 0) throw new Error('Valid amount is required to generate debit note.');
const creditNote = await Form16CreditNote.findByPk(creditNoteId, {
attributes: ['id', 'creditNoteNumber', 'amount', 'financialYear', 'quarter', 'issueDate'],
include: [{ model: Form16aSubmission, as: 'submission', attributes: ['id', 'dealerCode'] }],
});
if (!creditNote || !(creditNote as any).submission) throw new Error('Credit note not found.');
const existing = await Form16DebitNote.findOne({ where: { creditNoteId }, attributes: ['id'] });
if (existing) throw new Error('A debit note already exists for this credit note.');
// Dealer code from submission (set at Form 16 submit from users.employee_number)
const dealerCode = ((creditNote as any).submission?.dealerCode || '').toString().trim();
const financialYear = (creditNote as any).financialYear || '';
const quarter = (creditNote as any).quarter || '';
const dnNumber = formatForm16DebitNoteNumber(dealerCode || 'UNKNOWN', financialYear, quarter);
const now = new Date();
const debitNote = await Form16DebitNote.create({
creditNoteId,
debitNoteNumber: dnNumber,
amount,
issueDate: now,
status: 'issued',
reason: 'Debit note pushed to WFM FORM16 for SAP.',
createdBy: userId,
});
// Push Form 16 debit note CSV to WFM INCOMING/WFM_MAIN/FORM_16
try {
const dealer = await Dealer.findOne({
where: { [Op.or]: [{ salesCode: dealerCode }, { dlrcode: dealerCode }], isActive: true },
attributes: ['dealership', 'dealerPrincipalName'],
});
const dealerName = dealer ? ((dealer as any).dealership || (dealer as any).dealerPrincipalName || dealerCode) : dealerCode;
const trnsUniqNo = `F16-DN-${creditNoteId}-${debitNote.id}-${Date.now()}`;
const claimDate = now.toISOString().slice(0, 10).replace(/-/g, '');
const creditNoteIssueDate = (creditNote as any).issueDate
? new Date((creditNote as any).issueDate).toISOString().slice(0, 10).replace(/-/g, '')
: '';
const csvRow = {
CREDIT_NOTE_NUMBER: (creditNote as any).creditNoteNumber,
DEALER_CODE: dealerCode || 'UNKNOWN',
DEALER_NAME: dealerName,
AMOUNT: amount,
FINANCIAL_YEAR: financialYear,
QUARTER: quarter,
DEBIT_NOTE_NUMBER: dnNumber,
TRNS_UNIQ_NO: trnsUniqNo,
CLAIM_DATE: claimDate,
CREDIT_NOTE_DATE: creditNoteIssueDate,
};
const fileName = `${dnNumber}.csv`;
await wfmFileService.generateForm16IncomingCSV([csvRow], fileName);
logger.info(`[Form16] Manual debit note CSV pushed to WFM FORM_16: ${dnNumber}`);
} catch (csvErr: any) {
logger.error('[Form16] Failed to push manual debit note CSV to WFM FORM_16:', csvErr?.message || csvErr);
}
return { debitNote, creditNote };
}
// ---------- Non-submitted dealers (RE only) ---------- // ---------- Non-submitted dealers (RE only) ----------
const QUARTERS = ['Q1', 'Q2', 'Q3', 'Q4'] as const; const QUARTERS = ['Q1', 'Q2', 'Q3', 'Q4'] as const;
@ -2032,12 +1998,14 @@ export async function process26asUploadAggregation(uploadLogId: number): Promise
}); });
if (creditNote) { if (creditNote) {
const amount = parseFloat(String((creditNote as any).amount ?? 0)); const amount = parseFloat(String((creditNote as any).amount ?? 0));
const submission = await Form16aSubmission.findByPk((creditNote as any).submissionId, { attributes: ['dealerCode'] }); const submission = await Form16aSubmission.findByPk((creditNote as any).submissionId, { attributes: ['dealerCode', 'version', 'form16aNumber'] });
// Dealer code from submission (set at Form 16 submit from users.employee_number) // Dealer code, version and certificate number from submission (DN uses same cert as the credit note being reversed)
const dealerCode = submission ? ((submission as any).dealerCode || '').toString().trim() : ''; const dealerCode = submission ? ((submission as any).dealerCode || '').toString().trim() : '';
const version = typeof (submission as any)?.version === 'number' && (submission as any).version >= 1 ? (submission as any).version : 1;
const creditNoteCertNumber = submission ? ((submission as any).form16aNumber || '').toString().trim() : '';
const cnFy = (creditNote as any).financialYear || fy; const cnFy = (creditNote as any).financialYear || fy;
const cnQuarter = (creditNote as any).quarter || q; const cnQuarter = (creditNote as any).quarter || q;
const debitNum = formatForm16DebitNoteNumber(dealerCode || 'XX', cnFy, cnQuarter); const debitNum = formatForm16DebitNoteNumber(dealerCode || 'XX', cnFy, cnQuarter, version, creditNoteCertNumber);
const now = new Date(); const now = new Date();
const debit = await Form16DebitNote.create({ const debit = await Form16DebitNote.create({
creditNoteId: creditNote.id, creditNoteId: creditNote.id,
@ -2060,35 +2028,28 @@ export async function process26asUploadAggregation(uploadLogId: number): Promise
await setQuarterStatusDebitIssued(tanNumber, fy, q, debit.id); await setQuarterStatusDebitIssued(tanNumber, fy, q, debit.id);
debitsCreated++; debitsCreated++;
// Push Form 16 debit note CSV to WFM INCOMING/WFM_MAIN/FORM_16 // Push Form 16 debit note CSV to WFM INCOMING/WFM_MAIN/FORM16_DEBT (same column set as credit note / SAP expectation)
try { try {
const dealer = await Dealer.findOne({
where: { [Op.or]: [{ salesCode: dealerCode }, { dlrcode: dealerCode }], isActive: true },
attributes: ['dealership', 'dealerPrincipalName'],
});
const dealerName = dealer ? ((dealer as any).dealership || (dealer as any).dealerPrincipalName || dealerCode) : dealerCode;
const trnsUniqNo = `F16-DN-${creditNote.id}-${debit.id}-${Date.now()}`; const trnsUniqNo = `F16-DN-${creditNote.id}-${debit.id}-${Date.now()}`;
const claimDate = now.toISOString().slice(0, 10).replace(/-/g, ''); const docDate = now.toISOString().slice(0, 10).replace(/-/g, '');
const creditNoteIssueDate = (creditNote as any).issueDate const fyCompact = form16FyCompact(cnFy) || '';
? new Date((creditNote as any).issueDate).toISOString().slice(0, 10).replace(/-/g, '') const finYearAndQuarter = fyCompact && cnQuarter ? `FY ${fyCompact}_${cnQuarter}` : '';
: ''; const csvRow: Record<string, string | number> = {
const csvRow = {
CREDIT_NOTE_NUMBER: (creditNote as any).creditNoteNumber,
DEALER_CODE: dealerCode || 'XX',
DEALER_NAME: dealerName,
AMOUNT: amount,
FINANCIAL_YEAR: cnFy,
QUARTER: cnQuarter,
DEBIT_NOTE_NUMBER: debitNum,
TRNS_UNIQ_NO: trnsUniqNo, TRNS_UNIQ_NO: trnsUniqNo,
CLAIM_DATE: claimDate, TDS_TRNS_ID: debitNum,
CREDIT_NOTE_DATE: creditNoteIssueDate, DEALER_CODE: dealerCode || 'XX',
TDS_TRNS_DOC_TYP: 'ZTDS',
'Org.Document Number': debit.id,
DLR_TAN_NO: tanNumber,
'FIN_YEAR & QUARTER': finYearAndQuarter,
DOC_DATE: docDate,
TDS_AMT: Number(amount).toFixed(2),
}; };
const fileName = `${debitNum}.csv`; const fileName = `${debitNum}.csv`;
await wfmFileService.generateForm16IncomingCSV([csvRow], fileName); await wfmFileService.generateForm16IncomingCSV([csvRow], fileName, 'debit');
logger.info(`[Form16] Debit note CSV pushed to WFM FORM_16: ${debitNum}`); logger.info(`[Form16] Debit note CSV pushed to WFM FORM16_DEBT: ${debitNum}`);
} catch (csvErr: any) { } catch (csvErr: any) {
logger.error('[Form16] Failed to push debit note CSV to WFM FORM_16:', csvErr?.message || csvErr); logger.error('[Form16] Failed to push debit note CSV to WFM FORM16_DEBT:', csvErr?.message || csvErr);
} }
} }
} }

View File

@ -67,7 +67,13 @@ export async function trigger26AsDataAddedNotification(): Promise<void> {
return; return;
} }
const { notificationService } = await import('./notification.service'); const { notificationService } = await import('./notification.service');
const reUserIds = await getReUserIdsFor26As();
// Base RE audience (admins / RE viewers). This helper already tries to exclude dealers,
// but we defensively re-filter below so that 26AS notifications are never sent to dealers.
const baseReUserIds = await getReUserIdsFor26As();
const dealerUserIds = await getDealerUserIds();
const dealerSet = new Set(dealerUserIds);
const reUserIds = baseReUserIds.filter((id) => !dealerSet.has(id));
const title = 'Form 16 26AS data updated'; const title = 'Form 16 26AS data updated';
if (reUserIds.length > 0 && n.templateRe) { if (reUserIds.length > 0 && n.templateRe) {

View File

@ -315,6 +315,7 @@ class NotificationService {
'form16_debit_note': null, 'form16_debit_note': null,
'form16_alert_submit': null, 'form16_alert_submit': null,
'form16_reminder': null, 'form16_reminder': null,
're_quotation': EmailNotificationType.RE_QUOTATION,
}; };
const emailType = emailTypeMap[payload.type || '']; const emailType = emailTypeMap[payload.type || ''];
@ -482,12 +483,16 @@ class NotificationService {
order: [['levelNumber', 'ASC']] order: [['levelNumber', 'ASC']]
}); });
// Find the level that matches this approver - PRIORITIZE PENDING LEVEL // Find the level that matches this approver and the current request level
// This ensures that if a user has multiple steps (e.g., Step 1 and Step 2), // This ensures we pick the step that actually needs action (e.g. Step 1 for re-quotation)
// we pick the one that actually needs action (Step 2) rather than the first one (Step 1) const currentLevelNumber = requestData.currentLevel;
let matchingLevel = allLevels.find((l: any) => l.approverId === userId && l.status === 'PENDING'); let matchingLevel = allLevels.find((l: any) => l.approverId === userId && l.levelNumber === currentLevelNumber);
// Fallback to any level if no exact match found (e.g. if currentLevel isn't set yet)
if (!matchingLevel) {
matchingLevel = allLevels.find((l: any) => l.approverId === userId && l.status === 'PENDING');
}
// Fallback to any level if no pending level found (though for assignment there should be one)
if (!matchingLevel) { if (!matchingLevel) {
matchingLevel = allLevels.find((l: any) => l.approverId === userId); matchingLevel = allLevels.find((l: any) => l.approverId === userId);
} }
@ -1138,6 +1143,25 @@ class NotificationService {
} }
break; break;
case 're_quotation':
{
const dealerData = payload.metadata?.dealerData || { userId: null, email: payload.metadata?.dealerEmail, displayName: payload.metadata?.dealerName };
const rejectionReason = payload.body || payload.metadata?.rejectionReason || 'Revised Quotation Requested';
// Get claim info if available
const { DealerClaimDetails } = await import('@models/index');
const claim = await DealerClaimDetails.findOne({ where: { requestId: payload.requestId } });
await emailNotificationService.sendReQuotationRequired(
requestData,
dealerData,
initiatorData,
claim ? claim.toJSON() : {},
rejectionReason
);
}
break;
default: default:
logger.info(`[Email] No email configured for notification type: ${notificationType}`); logger.info(`[Email] No email configured for notification type: ${notificationType}`);
} }

View File

@ -5,13 +5,14 @@ import logger from '../utils/logger';
/** Default WFM folder names (joined with path.sep for current OS). */ /** Default WFM folder names (joined with path.sep for current OS). */
const DEFAULT_CLAIMS_INCOMING = path.join('WFM-QRE', 'INCOMING', 'WFM_MAIN', 'DLR_INC_CLAIMS'); const DEFAULT_CLAIMS_INCOMING = path.join('WFM-QRE', 'INCOMING', 'WFM_MAIN', 'DLR_INC_CLAIMS');
const DEFAULT_CLAIMS_OUTGOING = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'DLR_INC_CLAIMS'); const DEFAULT_CLAIMS_OUTGOING = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'DLR_INC_CLAIMS');
const DEFAULT_FORM16_INCOMING = path.join('WFM-QRE', 'INCOMING', 'WFM_MAIN', 'FORM_16'); const DEFAULT_FORM16_CREDIT_INCOMING = path.join('WFM-QRE', 'INCOMING', 'WFM_MAIN', 'FORM16_CRDT');
const DEFAULT_FORM16_OUTGOING = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'FORM_16'); const DEFAULT_FORM16_DEBIT_INCOMING = path.join('WFM-QRE', 'INCOMING', 'WFM_MAIN', 'FORM16_DEBT');
const DEFAULT_FORM16_OUTGOING = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'FORM16_CRDT');
/** /**
* WFM File Service * WFM File Service
* Handles generation and storage of CSV files in the WFM folder structure. * Handles generation and storage of CSV files in the WFM folder structure.
* Dealer claims use DLR_INC_CLAIMS; Form 16 uses FORM_16 under INCOMING/WFM_MAIN and OUTGOING/WFM_SAP_MAIN. * Dealer claims use DLR_INC_CLAIMS; Form 16 uses FORM16_CRDT (credit) and FORM16_DEBT (debit) under INCOMING/WFM_MAIN.
* Paths are cross-platform; set WFM_BASE_PATH (and optional path overrides) in .env for production. * Paths are cross-platform; set WFM_BASE_PATH (and optional path overrides) in .env for production.
*/ */
export class WFMFileService { export class WFMFileService {
@ -20,9 +21,11 @@ export class WFMFileService {
private incomingNonGstClaimsPath: string; private incomingNonGstClaimsPath: string;
private outgoingGstClaimsPath: string; private outgoingGstClaimsPath: string;
private outgoingNonGstClaimsPath: string; private outgoingNonGstClaimsPath: string;
/** Form 16: INCOMING/WFM_MAIN/FORM_16 */ /** Form 16 credit notes: INCOMING/WFM_MAIN/FORM16_CRDT */
private form16IncomingPath: string; private form16IncomingCreditPath: string;
/** Form 16: OUTGOING/WFM_SAP_MAIN/FORM_16 */ /** Form 16 debit notes: INCOMING/WFM_MAIN/FORM16_DEBT */
private form16IncomingDebitPath: string;
/** Form 16: OUTGOING/WFM_SAP_MAIN/FORM16_CRDT (SAP responses) */
private form16OutgoingPath: string; private form16OutgoingPath: string;
constructor() { constructor() {
@ -31,7 +34,14 @@ export class WFMFileService {
this.incomingNonGstClaimsPath = process.env.WFM_INCOMING_NON_GST_CLAIMS_PATH || 'WFM-QRE\\INCOMING\\WFM_MAIN\\DLR_INC_CLAIMS_NON_GST'; this.incomingNonGstClaimsPath = process.env.WFM_INCOMING_NON_GST_CLAIMS_PATH || 'WFM-QRE\\INCOMING\\WFM_MAIN\\DLR_INC_CLAIMS_NON_GST';
this.outgoingGstClaimsPath = process.env.WFM_OUTGOING_GST_CLAIMS_PATH || 'WFM-QRE\\OUTGOING\\WFM_SAP_MAIN\\DLR_INC_CLAIMS_GST'; this.outgoingGstClaimsPath = process.env.WFM_OUTGOING_GST_CLAIMS_PATH || 'WFM-QRE\\OUTGOING\\WFM_SAP_MAIN\\DLR_INC_CLAIMS_GST';
this.outgoingNonGstClaimsPath = process.env.WFM_OUTGOING_NON_GST_CLAIMS_PATH || 'WFM-QRE\\OUTGOING\\WFM_SAP_MAIN\\DLR_INC_CLAIMS_NON_GST'; this.outgoingNonGstClaimsPath = process.env.WFM_OUTGOING_NON_GST_CLAIMS_PATH || 'WFM-QRE\\OUTGOING\\WFM_SAP_MAIN\\DLR_INC_CLAIMS_NON_GST';
this.form16IncomingPath = process.env.WFM_FORM16_INCOMING_PATH || DEFAULT_FORM16_INCOMING;
// Backwards-compatible: support legacy WFM_FORM16_INCOMING_PATH if specific credit/debit paths are not set
const legacyForm16Incoming = process.env.WFM_FORM16_INCOMING_PATH;
this.form16IncomingCreditPath =
process.env.WFM_FORM16_CREDIT_INCOMING_PATH || legacyForm16Incoming || DEFAULT_FORM16_CREDIT_INCOMING;
this.form16IncomingDebitPath =
process.env.WFM_FORM16_DEBIT_INCOMING_PATH || legacyForm16Incoming || DEFAULT_FORM16_DEBIT_INCOMING;
this.form16OutgoingPath = process.env.WFM_FORM16_OUTGOING_PATH || DEFAULT_FORM16_OUTGOING; this.form16OutgoingPath = process.env.WFM_FORM16_OUTGOING_PATH || DEFAULT_FORM16_OUTGOING;
} }
@ -133,18 +143,22 @@ export class WFMFileService {
} }
/** /**
* Generate a CSV file for Form 16 (credit/debit note) and store in INCOMING/WFM_MAIN/FORM_16. * Generate a CSV file for Form 16 (credit/debit note) and store in the appropriate INCOMING/WFM_MAIN folder.
* - Credit: FORM16_CRDT
* - Debit: FORM16_DEBT
* Format: pipe (|) as column separator, no double quotes around values (SAP/WFM requirement). * Format: pipe (|) as column separator, no double quotes around values (SAP/WFM requirement).
* @param data Array of one or more row objects (keys become header; use UPPER_SNAKE_CASE for column names) * @param data Array of one or more row objects (keys become header; use UPPER_SNAKE_CASE for column names)
* @param fileName File name (e.g. CN-F-16-6282-24-25-Q1.csv or DN-F-16-6282-24-25-Q1.csv) * @param fileName File name (e.g. CN-F-16-6282-24-25-Q1.csv or DN-F-16-6282-24-25-Q1.csv)
* @param type 'credit' (default) or 'debit' selects FORM16_CRDT vs FORM16_DEBT
*/ */
async generateForm16IncomingCSV(data: any[], fileName: string): Promise<string> { async generateForm16IncomingCSV(data: any[], fileName: string, type: 'credit' | 'debit' = 'credit'): Promise<string> {
const maxRetries = 3; const maxRetries = 3;
let retryCount = 0; let retryCount = 0;
while (retryCount <= maxRetries) { while (retryCount <= maxRetries) {
try { try {
const targetDir = path.join(this.basePath, this.form16IncomingPath); const targetPath = type === 'debit' ? this.form16IncomingDebitPath : this.form16IncomingCreditPath;
const targetDir = path.join(this.basePath, targetPath);
this.ensureDirectoryExists(targetDir); this.ensureDirectoryExists(targetDir);
const filePath = path.join(targetDir, fileName.endsWith('.csv') ? fileName : `${fileName}.csv`); const filePath = path.join(targetDir, fileName.endsWith('.csv') ? fileName : `${fileName}.csv`);