Compare commits
No commits in common. "9c003e9a16d2f88204af223623b7760bf1e00eb0" and "9f3327ce38edd94bd91e1fcc264a6e1b97cc0927" have entirely different histories.
9c003e9a16
...
9f3327ce38
1
.gitignore
vendored
1
.gitignore
vendored
@ -136,4 +136,3 @@ uploads/
|
|||||||
# GCP Service Account Key
|
# GCP Service Account Key
|
||||||
config/gcp-key.json
|
config/gcp-key.json
|
||||||
Jenkinsfile
|
Jenkinsfile
|
||||||
clear-26as-data.ts
|
|
||||||
@ -1 +1 @@
|
|||||||
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};
|
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};
|
||||||
File diff suppressed because one or more lines are too long
@ -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-DK9CP9m9.js"></script>
|
<script type="module" crossorigin src="/assets/index-DDuRVIKn.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">
|
||||||
|
|||||||
11
env.example
11
env.example
@ -113,15 +113,12 @@ 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 FORM16_CRDT / FORM16_DEBT)
|
# WFM file paths (base path; dealer claims use DLR_INC_CLAIMS, Form 16 uses FORM_16)
|
||||||
# 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 note CSV (incoming): INCOMING/WFM_MAIN/FORM16_CRDT
|
# Form 16 credit/debit note CSV: INCOMING/WFM_MAIN/FORM_16, OUTGOING/WFM_SAP_MAIN/FORM_16
|
||||||
# Form 16 debit note CSV (incoming): INCOMING/WFM_MAIN/FORM16_DEBT
|
# WFM_FORM16_INCOMING_PATH=WFM-QRE\INCOMING\WFM_MAIN\Form_16
|
||||||
# Form 16 SAP responses (outgoing): OUTGOING/WFM_SAP_MAIN/FORM16_CRDT
|
# WFM_FORM16_OUTGOING_PATH=WFM-QRE\OUTGOING\WFM_SAP_MAIN\Form_16
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|||||||
@ -26,7 +26,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@ -442,6 +442,33 @@ 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.
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ApprovalRequestData } from './types';
|
import { ApprovalRequestData } from './types';
|
||||||
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles, getCustomMessageSection } from './helpers';
|
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } 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,9 +102,6 @@ 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>
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { DealerProposalRequiredData } from './types';
|
import { DealerProposalRequiredData } from './types';
|
||||||
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles, getCustomMessageSection } from './helpers';
|
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } 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,9 +103,6 @@ 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;">
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { DealerProposalRequiredData } from './types';
|
import { DealerProposalRequiredData } from './types';
|
||||||
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles, getCustomMessageSection } from './helpers';
|
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } 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,9 +152,6 @@ 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;">
|
||||||
|
|||||||
@ -32,8 +32,7 @@ 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',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -799,22 +799,6 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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>${data.isReturnedForRevision ? 'Request Returned for Revision' : 'Request Rejected'}</title>
|
<title>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,65 +24,63 @@ 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: data.isReturnedForRevision ? 'Revision Required' : 'Request Rejected',
|
title: 'Request Rejected',
|
||||||
...(data.isReturnedForRevision ? HeaderStyles.warning : HeaderStyles.error)
|
...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: ${data.isReturnedForRevision ? '#856404' : '#dc3545'};">${data.initiatorName}</strong>,
|
Dear <strong style="color: #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;">
|
||||||
${data.isReturnedForRevision
|
We regret to inform you that your request has been <strong style="color: #dc3545;">rejected</strong> by <strong>${data.approverName}</strong>.
|
||||||
? `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: ${data.isReturnedForRevision ? '#fff3cd' : '#f8d7da'}; border: 1px solid ${data.isReturnedForRevision ? '#ffeeba' : '#f5c6cb'}; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
|
<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">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 25px;">
|
<td style="padding: 25px;">
|
||||||
<h2 style="margin: 0 0 20px; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 18px; font-weight: 600;">Request Summary</h2>
|
<h2 style="margin: 0 0 20px; color: #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: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px; width: 140px;">
|
<td style="padding: 8px 0; color: #721c24; font-size: 14px; width: 140px;">
|
||||||
<strong>Request ID:</strong>
|
<strong>Request ID:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
|
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||||
${data.requestId}
|
${data.requestId}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
|
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||||
<strong>Action By:</strong>
|
<strong>Rejected By:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
|
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||||
${data.approverName}
|
${data.approverName}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
|
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||||
<strong>Action On:</strong>
|
<strong>Rejected On:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
|
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||||
${data.rejectionDate}
|
${data.rejectionDate}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
|
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||||
<strong>Time:</strong>
|
<strong>Time:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
|
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||||
${data.rejectionTime}
|
${data.rejectionTime}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
|
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||||
<strong>Request Type:</strong>
|
<strong>Request Type:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
|
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||||
${data.requestType}
|
${data.requestType}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -92,8 +90,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;">${data.isReturnedForRevision ? 'Reason for Revision:' : 'Reason for Rejection:'}</h3>
|
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Reason for Rejection:</h3>
|
||||||
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid ${data.isReturnedForRevision ? '#ffc107' : '#dc3545'}; border-radius: 4px; overflow-x: auto;">
|
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #dc3545; border-radius: 4px; overflow-x: auto;">
|
||||||
${wrapRichText(data.rejectionReason)}
|
${wrapRichText(data.rejectionReason)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -101,13 +99,9 @@ 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;">
|
||||||
${data.isReturnedForRevision
|
<li>Review the rejection reason carefully</li>
|
||||||
? `<li>Review the requested changes carefully</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>Make necessary adjustments to your request</li>
|
||||||
<li>Submit a new request with the required changes</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>
|
||||||
|
|||||||
@ -47,7 +47,6 @@ 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 {
|
||||||
@ -81,7 +80,6 @@ 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 {
|
||||||
@ -252,7 +250,6 @@ 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 {
|
||||||
|
|||||||
@ -111,6 +111,14 @@ 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',
|
||||||
|
|||||||
@ -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: item.transactionCode || '',
|
TRNS_UNIQ_NO: isNonGst ? '' : (item.transactionCode || ''),
|
||||||
CLAIM_NUMBER: requestNumber,
|
CLAIM_NUMBER: requestNumber,
|
||||||
INV_NUMBER: invoice.invoiceNumber || '',
|
INV_NUMBER: invoice.invoiceNumber || '',
|
||||||
DEALER_CODE: claimDetails.dealerCode,
|
DEALER_CODE: claimDetails.dealerCode,
|
||||||
|
|||||||
@ -849,23 +849,6 @@ 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
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -905,10 +888,10 @@ export class DealerClaimApprovalService {
|
|||||||
userAgent: requestMetadata?.userAgent || undefined
|
userAgent: requestMetadata?.userAgent || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify the approver of the target level (skip if specific re-quotation notification was already sent)
|
// Notify the approver of the target level
|
||||||
if (targetLevel.approverId && !isReQuotation) {
|
if (targetLevel.approverId) {
|
||||||
await notificationService.sendToUsers([targetLevel.approverId], {
|
await notificationService.sendToUsers([targetLevel.approverId], {
|
||||||
title: `Request Returned for Revision: ${(wf as any).requestNumber}`,
|
title: `Request Returned: ${(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,
|
||||||
@ -920,12 +903,9 @@ export class DealerClaimApprovalService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Notify initiator when request is returned
|
// Notify initiator when request is returned
|
||||||
// Skip if they are the same as the dealer (who already got a more specific notification)
|
|
||||||
const dealerId = targetLevel.approverId;
|
|
||||||
if ((wf as any).initiatorId && (wf as any).initiatorId !== dealerId) {
|
|
||||||
await notificationService.sendToUsers([(wf as any).initiatorId], {
|
await notificationService.sendToUsers([(wf as any).initiatorId], {
|
||||||
title: `Request Returned: ${(wf as any).requestNumber}`,
|
title: `Request Returned: ${(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'}`,
|
body: `Request "${(wf as any).title}" has been returned to level ${targetLevel.levelNumber} 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,
|
||||||
url: `/request/${(wf as any).requestNumber}`,
|
url: `/request/${(wf as any).requestNumber}`,
|
||||||
@ -933,7 +913,6 @@ export class DealerClaimApprovalService {
|
|||||||
priority: 'MEDIUM'
|
priority: 'MEDIUM'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Emit real-time update to all users viewing this request
|
// Emit real-time update to all users viewing this request
|
||||||
emitToRequestRoom(level.requestId, 'request:updated', {
|
emitToRequestRoom(level.requestId, 'request:updated', {
|
||||||
|
|||||||
@ -92,6 +92,26 @@ 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`);
|
||||||
@ -204,18 +224,6 @@ 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,
|
||||||
@ -248,8 +256,7 @@ 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)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -144,8 +144,7 @@ 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
|
||||||
@ -186,8 +185,7 @@ 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);
|
||||||
@ -216,8 +214,7 @@ 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);
|
||||||
@ -301,14 +298,6 @@ 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,
|
||||||
@ -330,14 +319,11 @@ 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 = isReturnedForRevision
|
const subject = `${requestData.requestNumber} - ${requestData.title} - Request Rejected`;
|
||||||
? `${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,
|
||||||
@ -941,8 +927,7 @@ 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(
|
||||||
@ -959,7 +944,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');
|
dueDate = dueDateObj.format('MMMM D, YYYY [at] h:mm A');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: DealerProposalRequiredData = {
|
const data: DealerProposalRequiredData = {
|
||||||
@ -980,8 +965,7 @@ 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);
|
||||||
@ -1003,76 +987,6 @@ export class EmailNotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 12a. Send Re-Quotation Required Email
|
|
||||||
* (Uses Proposal Required template with custom subject and rejection reason)
|
|
||||||
*/
|
|
||||||
async sendReQuotationRequired(
|
|
||||||
requestData: any,
|
|
||||||
dealerData: any,
|
|
||||||
initiatorData: any,
|
|
||||||
claimData: any,
|
|
||||||
rejectionReason: string
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const canSend = await shouldSendEmail(
|
|
||||||
dealerData.userId,
|
|
||||||
EmailNotificationType.RE_QUOTATION
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!canSend) {
|
|
||||||
logger.info(`Email skipped (preferences): Re-Quotation 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: 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
|
* 12b. Send Dealer Completion Documents Required Email
|
||||||
*/
|
*/
|
||||||
@ -1080,8 +994,7 @@ 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(
|
||||||
@ -1098,7 +1011,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');
|
dueDate = dueDateObj.format('MMMM D, YYYY [at] h:mm A');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: DealerProposalRequiredData = {
|
const data: DealerProposalRequiredData = {
|
||||||
@ -1119,8 +1032,7 @@ 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 = getDealerCompletionRequiredEmail(data);
|
const html = getDealerCompletionRequiredEmail(data);
|
||||||
|
|||||||
@ -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: run26asMatchAndCreditNote only (on Form 16A submit, match 26AS → CN-F-16-{...}, ledger, CSV to WFM FORM_16).
|
* 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).
|
||||||
* Debit: process26asUploadAggregation only (when 26AS total drops for a SETTLED quarter); DN-F-16-{...}, CSV to WFM FORM_16.
|
* Debit: generateForm16DebitNoteForCreditNote (manual) and process26asUploadAggregation (auto when 26AS total drops); DN-F-16-{dc}-{fy}-{q}.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
@ -416,49 +416,23 @@ function form16FyCompact(financialYear: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize certificate number for use in note numbers (alphanumeric and single hyphens only).
|
* 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)
|
||||||
*/
|
*/
|
||||||
function sanitizeCertificateNumber(raw: string): string {
|
export function formatForm16CreditNoteNumber(dealerCode: string, financialYear: string, quarter: string): string {
|
||||||
const s = (raw || '').trim().replace(/\s+/g, '-').replace(/[^A-Za-z0-9-]/g, '') || '';
|
|
||||||
return s || 'XX';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Form 16 credit note number: CN-F-16-{certificateNumber}-{dealerCode}-{FY}-{quarter}-V{version}
|
|
||||||
* Supports revised 26AS / Form 16 resubmission versioning.
|
|
||||||
*/
|
|
||||||
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';
|
||||||
const v = Math.max(1, Math.floor(version));
|
return `CN-F-16-${dc}-${fy}-${q}`;
|
||||||
return `CN-F-16-${cert}-${dc}-${fy}-${q}-V${v}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Form 16 debit note number: DN-F-16-{creditNoteCertificateNumber}-{dc}-{fy}-{q}-V{version}
|
* 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)
|
||||||
* Uses the certificate number of the credit note being reversed (same Form 16A certificate that led to that credit note).
|
|
||||||
*/
|
*/
|
||||||
export function formatForm16DebitNoteNumber(
|
export function formatForm16DebitNoteNumber(dealerCode: string, financialYear: string, quarter: string): string {
|
||||||
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 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';
|
||||||
const v = Math.max(1, Math.floor(version));
|
return `DN-F-16-${dc}-${fy}-${q}`;
|
||||||
return `DN-F-16-${cert}-${dc}-${fy}-${q}-V${v}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -546,11 +520,9 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dealer code, certificate number and version from submission (for revised 26AS / Form 16 versioning)
|
// Dealer code from submission (set at create from users.employee_number)
|
||||||
const dealerCode = (sub.dealerCode || '').toString().trim();
|
const dealerCode = (sub.dealerCode || '').toString().trim();
|
||||||
const certificateNumber = (sub.form16aNumber || '').toString().trim();
|
const cnNumber = formatForm16CreditNoteNumber(dealerCode, financialYear, quarter);
|
||||||
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,
|
||||||
@ -579,27 +551,31 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
|
|||||||
validationNotes: null,
|
validationNotes: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Push Form 16 credit note incoming CSV to WFM INCOMING/WFM_MAIN/FORM16_CRDT (SAP credit note generation – exact fields only)
|
// Push Form 16 credit note CSV to WFM INCOMING/WFM_MAIN/FORM_16 (pipe | separator, no double quotes)
|
||||||
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 docDate = now.toISOString().slice(0, 10).replace(/-/g, '');
|
const claimDate = now.toISOString().slice(0, 10).replace(/-/g, '');
|
||||||
const fyCompact = form16FyCompact(financialYear) || '';
|
const csvRow = {
|
||||||
const finYearAndQuarter = fyCompact && quarter ? `FY_${fyCompact}_${quarter}` : '';
|
CREDIT_TYPE: 'Form16',
|
||||||
const csvRow: Record<string, string | number> = {
|
|
||||||
TRNS_UNIQ_NO: trnsUniqNo,
|
|
||||||
TDS_TRNS_ID: cnNumber,
|
|
||||||
DEALER_CODE: dealerCode,
|
DEALER_CODE: dealerCode,
|
||||||
TDS_TRNS_DOC_TYP: 'ZTDS',
|
DEALER_NAME: dealerName,
|
||||||
DLR_TAN_NO: tanNumber,
|
AMOUNT: tdsAmount,
|
||||||
'FIN_YEAR & QUARTER': finYearAndQuarter,
|
FINANCIAL_YEAR: financialYear,
|
||||||
DOC_DATE: docDate,
|
QUARTER: quarter,
|
||||||
TDS_AMT: Number(tdsAmount).toFixed(2),
|
CREDIT_NOTE_NUMBER: cnNumber,
|
||||||
|
TRNS_UNIQ_NO: trnsUniqNo,
|
||||||
|
CLAIM_DATE: claimDate,
|
||||||
};
|
};
|
||||||
const fileName = `${cnNumber}.csv`;
|
const fileName = `${cnNumber}.csv`;
|
||||||
await wfmFileService.generateForm16IncomingCSV([csvRow], fileName, 'credit');
|
await wfmFileService.generateForm16IncomingCSV([csvRow], fileName);
|
||||||
logger.info(`[Form16] Credit note CSV pushed to WFM FORM16_CRDT: ${cnNumber}`);
|
logger.info(`[Form16] Credit note CSV pushed to WFM FORM_16: ${cnNumber}`);
|
||||||
} catch (csvErr: any) {
|
} catch (csvErr: any) {
|
||||||
logger.error('[Form16] Failed to push credit note CSV to WFM FORM16_CRDT:', csvErr?.message || csvErr);
|
logger.error('[Form16] Failed to push credit note CSV to WFM FORM_16:', 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -758,14 +734,6 @@ 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:`,
|
||||||
@ -1303,6 +1271,72 @@ 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;
|
||||||
|
|
||||||
@ -1998,14 +2032,12 @@ 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', 'version', 'form16aNumber'] });
|
const submission = await Form16aSubmission.findByPk((creditNote as any).submissionId, { attributes: ['dealerCode'] });
|
||||||
// Dealer code, version and certificate number from submission (DN uses same cert as the credit note being reversed)
|
// Dealer code from submission (set at Form 16 submit from users.employee_number)
|
||||||
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, version, creditNoteCertNumber);
|
const debitNum = formatForm16DebitNoteNumber(dealerCode || 'XX', cnFy, cnQuarter);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const debit = await Form16DebitNote.create({
|
const debit = await Form16DebitNote.create({
|
||||||
creditNoteId: creditNote.id,
|
creditNoteId: creditNote.id,
|
||||||
@ -2028,28 +2060,35 @@ 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/FORM16_DEBT (same column set as credit note / SAP expectation)
|
// Push Form 16 debit note CSV to WFM INCOMING/WFM_MAIN/FORM_16
|
||||||
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 docDate = now.toISOString().slice(0, 10).replace(/-/g, '');
|
const claimDate = now.toISOString().slice(0, 10).replace(/-/g, '');
|
||||||
const fyCompact = form16FyCompact(cnFy) || '';
|
const creditNoteIssueDate = (creditNote as any).issueDate
|
||||||
const finYearAndQuarter = fyCompact && cnQuarter ? `FY ${fyCompact}_${cnQuarter}` : '';
|
? new Date((creditNote as any).issueDate).toISOString().slice(0, 10).replace(/-/g, '')
|
||||||
const csvRow: Record<string, string | number> = {
|
: '';
|
||||||
TRNS_UNIQ_NO: trnsUniqNo,
|
const csvRow = {
|
||||||
TDS_TRNS_ID: debitNum,
|
CREDIT_NOTE_NUMBER: (creditNote as any).creditNoteNumber,
|
||||||
DEALER_CODE: dealerCode || 'XX',
|
DEALER_CODE: dealerCode || 'XX',
|
||||||
TDS_TRNS_DOC_TYP: 'ZTDS',
|
DEALER_NAME: dealerName,
|
||||||
'Org.Document Number': debit.id,
|
AMOUNT: amount,
|
||||||
DLR_TAN_NO: tanNumber,
|
FINANCIAL_YEAR: cnFy,
|
||||||
'FIN_YEAR & QUARTER': finYearAndQuarter,
|
QUARTER: cnQuarter,
|
||||||
DOC_DATE: docDate,
|
DEBIT_NOTE_NUMBER: debitNum,
|
||||||
TDS_AMT: Number(amount).toFixed(2),
|
TRNS_UNIQ_NO: trnsUniqNo,
|
||||||
|
CLAIM_DATE: claimDate,
|
||||||
|
CREDIT_NOTE_DATE: creditNoteIssueDate,
|
||||||
};
|
};
|
||||||
const fileName = `${debitNum}.csv`;
|
const fileName = `${debitNum}.csv`;
|
||||||
await wfmFileService.generateForm16IncomingCSV([csvRow], fileName, 'debit');
|
await wfmFileService.generateForm16IncomingCSV([csvRow], fileName);
|
||||||
logger.info(`[Form16] Debit note CSV pushed to WFM FORM16_DEBT: ${debitNum}`);
|
logger.info(`[Form16] Debit note CSV pushed to WFM FORM_16: ${debitNum}`);
|
||||||
} catch (csvErr: any) {
|
} catch (csvErr: any) {
|
||||||
logger.error('[Form16] Failed to push debit note CSV to WFM FORM16_DEBT:', csvErr?.message || csvErr);
|
logger.error('[Form16] Failed to push debit note CSV to WFM FORM_16:', csvErr?.message || csvErr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,13 +67,7 @@ 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) {
|
||||||
|
|||||||
@ -315,7 +315,6 @@ 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 || ''];
|
||||||
@ -483,16 +482,12 @@ class NotificationService {
|
|||||||
order: [['levelNumber', 'ASC']]
|
order: [['levelNumber', 'ASC']]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Find the level that matches this approver and the current request level
|
// Find the level that matches this approver - PRIORITIZE PENDING LEVEL
|
||||||
// This ensures we pick the step that actually needs action (e.g. Step 1 for re-quotation)
|
// This ensures that if a user has multiple steps (e.g., Step 1 and Step 2),
|
||||||
const currentLevelNumber = requestData.currentLevel;
|
// we pick the one that actually needs action (Step 2) rather than the first one (Step 1)
|
||||||
let matchingLevel = allLevels.find((l: any) => l.approverId === userId && l.levelNumber === currentLevelNumber);
|
let matchingLevel = allLevels.find((l: any) => l.approverId === userId && l.status === 'PENDING');
|
||||||
|
|
||||||
// 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);
|
||||||
}
|
}
|
||||||
@ -1143,25 +1138,6 @@ 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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,14 +5,13 @@ 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_CREDIT_INCOMING = path.join('WFM-QRE', 'INCOMING', 'WFM_MAIN', 'FORM16_CRDT');
|
const DEFAULT_FORM16_INCOMING = path.join('WFM-QRE', 'INCOMING', 'WFM_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', 'FORM_16');
|
||||||
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 FORM16_CRDT (credit) and FORM16_DEBT (debit) under INCOMING/WFM_MAIN.
|
* Dealer claims use DLR_INC_CLAIMS; Form 16 uses FORM_16 under INCOMING/WFM_MAIN and OUTGOING/WFM_SAP_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 {
|
||||||
@ -21,11 +20,9 @@ export class WFMFileService {
|
|||||||
private incomingNonGstClaimsPath: string;
|
private incomingNonGstClaimsPath: string;
|
||||||
private outgoingGstClaimsPath: string;
|
private outgoingGstClaimsPath: string;
|
||||||
private outgoingNonGstClaimsPath: string;
|
private outgoingNonGstClaimsPath: string;
|
||||||
/** Form 16 credit notes: INCOMING/WFM_MAIN/FORM16_CRDT */
|
/** Form 16: INCOMING/WFM_MAIN/FORM_16 */
|
||||||
private form16IncomingCreditPath: string;
|
private form16IncomingPath: string;
|
||||||
/** Form 16 debit notes: INCOMING/WFM_MAIN/FORM16_DEBT */
|
/** Form 16: OUTGOING/WFM_SAP_MAIN/FORM_16 */
|
||||||
private form16IncomingDebitPath: string;
|
|
||||||
/** Form 16: OUTGOING/WFM_SAP_MAIN/FORM16_CRDT (SAP responses) */
|
|
||||||
private form16OutgoingPath: string;
|
private form16OutgoingPath: string;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -34,14 +31,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -143,22 +133,18 @@ export class WFMFileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a CSV file for Form 16 (credit/debit note) and store in the appropriate INCOMING/WFM_MAIN folder.
|
* Generate a CSV file for Form 16 (credit/debit note) and store in INCOMING/WFM_MAIN/FORM_16.
|
||||||
* - 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, type: 'credit' | 'debit' = 'credit'): Promise<string> {
|
async generateForm16IncomingCSV(data: any[], fileName: string): Promise<string> {
|
||||||
const maxRetries = 3;
|
const maxRetries = 3;
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
|
|
||||||
while (retryCount <= maxRetries) {
|
while (retryCount <= maxRetries) {
|
||||||
try {
|
try {
|
||||||
const targetPath = type === 'debit' ? this.form16IncomingDebitPath : this.form16IncomingCreditPath;
|
const targetDir = path.join(this.basePath, this.form16IncomingPath);
|
||||||
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`);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user