Compare commits
8 Commits
cf95347fd7
...
9f3327ce38
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f3327ce38 | |||
| e9ed4ca4d3 | |||
| e4948e5cab | |||
|
|
eb3db7cd3a | ||
| 07577b4156 | |||
| e4d45b4fca | |||
| c50d481698 | |||
| 7b1df12a5b |
@ -1 +1 @@
|
||||
import{a as s}from"./index-CYKuaJ7k.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};
|
||||
1
build/assets/index-Bgoo5ePN.css
Normal file
1
build/assets/index-Bgoo5ePN.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
64
build/assets/index-DDuRVIKn.js
Normal file
64
build/assets/index-DDuRVIKn.js
Normal file
File diff suppressed because one or more lines are too long
@ -13,7 +13,7 @@
|
||||
<!-- Preload essential fonts and icons -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<script type="module" crossorigin src="/assets/index-CYKuaJ7k.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/radix-vendor-CYvDqP9X.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js">
|
||||
@ -21,7 +21,7 @@
|
||||
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/router-vendor-BATWUvr6.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bue1DC_k.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bgoo5ePN.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
16
env.example
16
env.example
@ -73,6 +73,13 @@ RATE_LIMIT_MAX_REQUESTS=100
|
||||
MAX_FILE_SIZE_MB=10
|
||||
ALLOWED_FILE_TYPES=pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif
|
||||
|
||||
# WFM Folder Structure Configuration
|
||||
WFM_BASE_PATH=C:\\WFM
|
||||
WFM_INCOMING_GST_CLAIMS_PATH=WFM-QRE\\INCOMING\\WFM_MAIN\\DLR_INC_CLAIMS_GST
|
||||
WFM_INCOMING_NON_GST_CLAIMS_PATH=WFM-QRE\\INCOMING\\WFM_MAIN\\DLR_INC_CLAIMS_NON_GST
|
||||
WFM_OUTGOING_GST_CLAIMS_PATH=WFM-QRE\\OUTGOING\\WFM_SAP_MAIN\\DLR_INC_CLAIMS_GST
|
||||
WFM_OUTGOING_NON_GST_CLAIMS_PATH=WFM-QRE\\OUTGOING\\WFM_SAP_MAIN\\DLR_INC_CLAIMS_NON_GST
|
||||
|
||||
# TAT Monitoring
|
||||
TAT_CHECK_INTERVAL_MINUTES=30
|
||||
TAT_REMINDER_THRESHOLD_1=50
|
||||
@ -106,3 +113,12 @@ SAP_REQUESTER=REFMS
|
||||
# WARNING: Only use in development/testing environments
|
||||
SAP_DISABLE_SSL_VERIFY=false
|
||||
|
||||
# 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).
|
||||
# WFM_BASE_PATH=C:\WFM
|
||||
# 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
|
||||
# Form 16 credit/debit note CSV: INCOMING/WFM_MAIN/FORM_16, OUTGOING/WFM_SAP_MAIN/FORM_16
|
||||
# WFM_FORM16_INCOMING_PATH=WFM-QRE\INCOMING\WFM_MAIN\Form_16
|
||||
# WFM_FORM16_OUTGOING_PATH=WFM-QRE\OUTGOING\WFM_SAP_MAIN\Form_16
|
||||
|
||||
|
||||
@ -1024,7 +1024,9 @@ export class DealerClaimController {
|
||||
taxationType = activity?.taxationType || (claimDetails.activityType.toLowerCase().includes('non') ? 'Non GST' : 'GST');
|
||||
}
|
||||
|
||||
// Construct CSV
|
||||
const isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
|
||||
|
||||
// Construct CSV with pipe separator
|
||||
const headers = [
|
||||
'TRNS_UNIQ_NO',
|
||||
'CLAIM_NUMBER',
|
||||
@ -1032,44 +1034,48 @@ export class DealerClaimController {
|
||||
'DEALER_CODE',
|
||||
'IO_NUMBER',
|
||||
'CLAIM_DOC_TYP',
|
||||
'CLAIM_TYPE',
|
||||
'CLAIM_DATE',
|
||||
'CLAIM_AMT',
|
||||
'GST_AMT',
|
||||
'GST_PERCENTAG'
|
||||
'CLAIM_AMT'
|
||||
];
|
||||
|
||||
const rows = items.map(item => {
|
||||
const isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
|
||||
if (!isNonGst) {
|
||||
headers.push('GST_AMT', 'GST_PERCENTAGE');
|
||||
}
|
||||
|
||||
// For Non-GST, we hide HSN (often stored in transactionCode) and GST details
|
||||
const rows = items.map(item => {
|
||||
// For Non-GST, we hide HSN (often stored in transactionCode)
|
||||
const trnsUniqNo = isNonGst ? '' : (item.transactionCode || '');
|
||||
const claimNumber = requestNumber;
|
||||
const invNumber = invoice?.invoiceNumber || '';
|
||||
const dealerCode = claimDetails?.dealerCode || '';
|
||||
const ioNumber = internalOrder?.ioNumber || '';
|
||||
const claimDocTyp = sapRefNo;
|
||||
const claimType = claimDetails?.activityType || '';
|
||||
const claimDate = invoice?.createdAt ? new Date(invoice.createdAt).toISOString().split('T')[0] : '';
|
||||
const claimAmt = item.assAmt;
|
||||
|
||||
// Zero out tax for Non-GST
|
||||
const totalTax = isNonGst ? 0 : (Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0) + Number(item.utgstAmt || 0));
|
||||
const gstPercentag = isNonGst ? 0 : (item.gstRt || 0);
|
||||
|
||||
return [
|
||||
const rowItems = [
|
||||
trnsUniqNo,
|
||||
claimNumber,
|
||||
invNumber,
|
||||
dealerCode,
|
||||
ioNumber,
|
||||
claimDocTyp,
|
||||
claimType,
|
||||
claimDate,
|
||||
claimAmt,
|
||||
totalTax.toFixed(2),
|
||||
gstPercentag
|
||||
].join(',');
|
||||
claimAmt
|
||||
];
|
||||
|
||||
if (!isNonGst) {
|
||||
const totalTax = Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0) + Number(item.utgstAmt || 0);
|
||||
rowItems.push(totalTax.toFixed(2), item.gstRt || 0);
|
||||
}
|
||||
|
||||
return rowItems.join('|');
|
||||
});
|
||||
|
||||
const csvContent = [headers.join(','), ...rows].join('\n');
|
||||
const csvContent = [headers.join('|'), ...rows].join('\n');
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="Invoice_${requestNumber}.csv"`);
|
||||
@ -1109,4 +1115,43 @@ export class DealerClaimController {
|
||||
return ResponseHandler.error(res, 'Failed to re-trigger WFM push', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch parsed WFM credit note CSV from outgoing folder
|
||||
* GET /api/v1/dealer-claims/:requestId/credit-note-wfm
|
||||
*/
|
||||
async fetchCreditNoteWfm(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const identifier = req.params.requestId;
|
||||
|
||||
const workflow = await this.findWorkflowByIdentifier(identifier);
|
||||
if (!workflow) {
|
||||
return ResponseHandler.error(res, 'Workflow request not found', 404);
|
||||
}
|
||||
|
||||
const requestId = (workflow as any).requestId || (workflow as any).request_id;
|
||||
const requestNumber = (workflow as any).requestNumber || (workflow as any).request_number;
|
||||
|
||||
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } });
|
||||
if (!claimDetails) {
|
||||
return ResponseHandler.error(res, 'Dealer claim details not found', 404);
|
||||
}
|
||||
|
||||
let isNonGst = false;
|
||||
if (claimDetails.activityType) {
|
||||
const activity = await ActivityType.findOne({ where: { title: claimDetails.activityType } });
|
||||
const taxationType = activity?.taxationType || (claimDetails.activityType.toLowerCase().includes('non') ? 'Non GST' : 'GST');
|
||||
isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
|
||||
}
|
||||
|
||||
const { wfmFileService } = await import('../services/wfmFile.service');
|
||||
const creditNoteData = await wfmFileService.getCreditNoteDetails(claimDetails.dealerCode, requestNumber, isNonGst);
|
||||
|
||||
return ResponseHandler.success(res, creditNoteData, 'Credit note data fetched successfully');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[DealerClaimController] Error fetching credit note WFM data:', error);
|
||||
return ResponseHandler.error(res, 'Failed to fetch credit note CSV data', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -391,38 +391,6 @@ export class Form16Controller {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/form16/requests/:requestId/generate-credit-note
|
||||
* RE only. Manually generate credit note (e.g. when OCR was partial). Body: { amount: number }.
|
||||
*/
|
||||
async generateForm16CreditNote(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).user?.userId;
|
||||
if (!userId) return ResponseHandler.unauthorized(res, 'Authentication required');
|
||||
const requestId = (req.params as { requestId: string }).requestId;
|
||||
if (!requestId) return ResponseHandler.error(res, 'Request ID 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.generateForm16CreditNoteManually(requestId, userId, amount);
|
||||
const { triggerForm16ManualCreditNoteNotification } = await import('../services/form16Notification.service');
|
||||
const cnNumber = (result.creditNote as any)?.creditNoteNumber;
|
||||
if (cnNumber) {
|
||||
triggerForm16ManualCreditNoteNotification(requestId, cnNumber).catch((err) =>
|
||||
logger.error('[Form16Controller] Manual credit note notification failed:', err)
|
||||
);
|
||||
}
|
||||
return ResponseHandler.success(
|
||||
res,
|
||||
{ creditNote: result.creditNote, submission: result.submission },
|
||||
'Credit note generated (manually approved)'
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] generateForm16CreditNote error:', error);
|
||||
return ResponseHandler.error(res, errorMessage, 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/form16/sap-simulate/credit-note
|
||||
* Form 16 only. Simulate SAP credit note generation (dealer details + amount → JSON response).
|
||||
@ -636,6 +604,7 @@ export class Form16Controller {
|
||||
}
|
||||
|
||||
const body = req.body as Record<string, string>;
|
||||
const dealerCode = (body.dealerCode || '').trim(); // optional: required when user is not mapped as dealer
|
||||
const financialYear = (body.financialYear || '').trim();
|
||||
const quarter = (body.quarter || '').trim();
|
||||
const form16aNumber = (body.form16aNumber || '').trim();
|
||||
@ -665,6 +634,7 @@ export class Form16Controller {
|
||||
file.buffer,
|
||||
file.originalname || 'form16a.pdf',
|
||||
{
|
||||
dealerCode: dealerCode || undefined,
|
||||
financialYear,
|
||||
quarter,
|
||||
form16aNumber,
|
||||
@ -695,7 +665,7 @@ export class Form16Controller {
|
||||
} catch (error: any) {
|
||||
const message = error?.message || 'Unknown error';
|
||||
logger.error('[Form16Controller] createSubmission error:', error);
|
||||
if (message.includes('Dealer not found')) {
|
||||
if (message.includes('Dealer not found') || message.includes('dealerCode is required') || message.includes('Invalid dealerCode')) {
|
||||
return ResponseHandler.error(res, message, 403);
|
||||
}
|
||||
// No validation on certificate number: revised Form 16A can reuse same certificate number for same quarter.
|
||||
|
||||
102
src/emailtemplates/form_16_email.template.ts
Normal file
102
src/emailtemplates/form_16_email.template.ts
Normal file
@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Form 16 Email Template (generic wrapper for Form 16 notification types)
|
||||
*
|
||||
* Used by notification.service.ts when payload.type starts with `form16_`.
|
||||
* Payload body comes from Form 16 admin-config templates (plain text with placeholders already substituted).
|
||||
*/
|
||||
|
||||
import { Form16EmailData } from './types';
|
||||
import { getEmailFooter, getEmailHeader, getEmailContainerStyles, getResponsiveStyles, HeaderStyles } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getForm16Email(data: Form16EmailData): string {
|
||||
const headerStyle =
|
||||
data.variant === 'success'
|
||||
? HeaderStyles.success
|
||||
: data.variant === 'warning'
|
||||
? HeaderStyles.warning
|
||||
: data.variant === 'error'
|
||||
? HeaderStyles.error
|
||||
: HeaderStyles.info;
|
||||
|
||||
const requestBlock = data.requestId
|
||||
? `
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 26px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 18px 20px;">
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #666666; font-size: 13px; width: 110px;"><strong>Request ID:</strong></td>
|
||||
<td style="padding: 4px 0; color: #333333; font-size: 13px;">${data.requestId}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`
|
||||
: '';
|
||||
|
||||
const ctaBlock = data.viewDetailsLink
|
||||
? `
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 10px 0 6px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="text-align: center;">
|
||||
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 14px 34px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 15px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
|
||||
View Request Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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 name="format-detection" content="telephone=no">
|
||||
<title>${data.title}</title>
|
||||
${getResponsiveStyles()}
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 40px 0;">
|
||||
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||
${getEmailHeader(getBrandedHeader({ title: data.title, ...headerStyle }))}
|
||||
|
||||
<tr>
|
||||
<td class="email-content" style="padding: 40px 30px;">
|
||||
<p style="margin: 0 0 18px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||
Dear <strong style="color: #667eea;">${data.recipientName}</strong>,
|
||||
</p>
|
||||
|
||||
${requestBlock}
|
||||
|
||||
<div style="padding: 18px 18px; background-color: #ffffff; border: 1px solid #e9ecef; border-radius: 6px; margin-bottom: 24px;">
|
||||
<div style="margin: 0; color: #333333; font-size: 14px; line-height: 1.8;">
|
||||
${data.messageHtml}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${ctaBlock}
|
||||
|
||||
<p style="margin: 18px 0 0; color: #666666; font-size: 13px; line-height: 1.6; text-align: center;">
|
||||
Thank you for using the ${data.companyName} Workflow System.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
${getEmailFooter(data.companyName)}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -36,4 +36,5 @@ export { getCompletionDocumentsSubmittedEmail } from './completionDocumentsSubmi
|
||||
export { getEInvoiceGeneratedEmail } from './einvoiceGenerated.template';
|
||||
export { getCreditNoteSentEmail } from './creditNoteSent.template';
|
||||
export { getAdditionalDocumentAddedEmail } from './additionalDocumentAdded.template';
|
||||
export { getForm16Email } from './form_16_email.template';
|
||||
|
||||
|
||||
@ -12,6 +12,22 @@ export interface BaseEmailData {
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
export interface Form16EmailData {
|
||||
recipientName: string;
|
||||
/** Email title shown in header + subject */
|
||||
title: string;
|
||||
/** Already-sanitized HTML (escaped) message body */
|
||||
messageHtml: string;
|
||||
/** Optional: request UUID for link + context */
|
||||
requestId?: string;
|
||||
/** Optional: deep link to /request/:requestId */
|
||||
viewDetailsLink?: string;
|
||||
/** Brand name */
|
||||
companyName: string;
|
||||
/** Controls header color */
|
||||
variant?: 'info' | 'success' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
export interface RequestCreatedData extends BaseEmailData {
|
||||
initiatorName: string;
|
||||
firstApproverName: string;
|
||||
|
||||
@ -107,6 +107,7 @@ router.post('/:requestId/wfm/retrigger', authenticateToken, validateParams(reque
|
||||
*/
|
||||
router.get('/:requestId/e-invoice/csv', authenticateToken, validateParams(requestIdParamsSchema), asyncHandler(dealerClaimController.downloadInvoiceCsv.bind(dealerClaimController)));
|
||||
router.post('/:requestId/credit-note', authenticateToken, validateParams(requestIdParamsSchema), upload.single('creditNoteFile'), asyncHandler(malwareScanMiddleware), asyncHandler(dealerClaimController.updateCreditNote.bind(dealerClaimController)));
|
||||
router.get('/:requestId/credit-note-wfm', authenticateToken, validateParams(requestIdParamsSchema), asyncHandler(dealerClaimController.fetchCreditNoteWfm.bind(dealerClaimController)));
|
||||
|
||||
/**
|
||||
* @route POST /api/v1/dealer-claims/:requestId/credit-note/send
|
||||
|
||||
@ -83,7 +83,7 @@ router.get(
|
||||
requireForm16SubmissionAccess,
|
||||
asyncHandler(form16Controller.getCreditNoteByRequest.bind(form16Controller))
|
||||
);
|
||||
// RE only: Form 16 request actions (cancel, resubmission needed, manual credit note)
|
||||
// RE only: Form 16 request actions (cancel, resubmission needed)
|
||||
router.post(
|
||||
'/requests/:requestId/cancel-submission',
|
||||
requireForm16ReOnly,
|
||||
@ -96,12 +96,6 @@ router.post(
|
||||
requireForm16SubmissionAccess,
|
||||
asyncHandler(form16Controller.setForm16ResubmissionNeeded.bind(form16Controller))
|
||||
);
|
||||
router.post(
|
||||
'/requests/:requestId/generate-credit-note',
|
||||
requireForm16ReOnly,
|
||||
requireForm16SubmissionAccess,
|
||||
asyncHandler(form16Controller.generateForm16CreditNote.bind(form16Controller))
|
||||
);
|
||||
|
||||
// Form 16 SAP simulation (credit note / debit note). Replace with real SAP when integrating.
|
||||
router.post(
|
||||
|
||||
@ -3596,9 +3596,13 @@ export class DealerClaimService {
|
||||
|
||||
if (invoiceItems.length > 0) {
|
||||
let sapRefNo = '';
|
||||
let isNonGst = false;
|
||||
|
||||
if (claimDetails.activityType) {
|
||||
const activity = await ActivityType.findOne({ where: { title: claimDetails.activityType } });
|
||||
sapRefNo = activity?.sapRefNo || '';
|
||||
const taxationType = activity?.taxationType || (claimDetails.activityType.toLowerCase().includes('non') ? 'Non GST' : 'GST');
|
||||
isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
|
||||
}
|
||||
|
||||
const formatDate = (date: any) => {
|
||||
@ -3610,23 +3614,28 @@ export class DealerClaimService {
|
||||
};
|
||||
|
||||
const csvData = invoiceItems.map((item: any) => {
|
||||
const totalTax = Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0) + Number(item.utgstAmt || 0);
|
||||
|
||||
return {
|
||||
TRNS_UNIQ_NO: item.transactionCode || '',
|
||||
const row: any = {
|
||||
TRNS_UNIQ_NO: isNonGst ? '' : (item.transactionCode || ''),
|
||||
CLAIM_NUMBER: requestNumber,
|
||||
INV_NUMBER: invoice.invoiceNumber || '',
|
||||
DEALER_CODE: claimDetails.dealerCode,
|
||||
IO_NUMBER: internalOrder?.ioNumber || '',
|
||||
CLAIM_DOC_TYP: sapRefNo,
|
||||
CLAIM_TYPE: claimDetails.activityType,
|
||||
CLAIM_DATE: formatDate(invoice.invoiceDate || new Date()),
|
||||
CLAIM_AMT: item.assAmt,
|
||||
GST_AMT: totalTax.toFixed(2),
|
||||
GST_PERCENTAG: item.gstRt
|
||||
CLAIM_AMT: item.assAmt
|
||||
};
|
||||
|
||||
if (!isNonGst) {
|
||||
const totalTax = Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0) + Number(item.utgstAmt || 0);
|
||||
row.GST_AMT = totalTax.toFixed(2);
|
||||
row.GST_PERCENTAGE = item.gstRt;
|
||||
}
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
await wfmFileService.generateIncomingClaimCSV(csvData, `CN_${claimDetails.dealerCode}_${requestNumber}.csv`);
|
||||
await wfmFileService.generateIncomingClaimCSV(csvData, `CN_${claimDetails.dealerCode}_${requestNumber}.csv`, isNonGst);
|
||||
|
||||
await invoice.update({
|
||||
wfmPushStatus: 'SUCCESS',
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
/**
|
||||
* Form 16 (Form 16A TDS Credit) service.
|
||||
* 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).
|
||||
* Debit: generateForm16DebitNoteForCreditNote (manual) and process26asUploadAggregation (auto when 26AS total drops); DN-F-16-{dc}-{fy}-{q}.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
@ -25,17 +28,26 @@ import { Priority, WorkflowStatus } from '../types/common.types';
|
||||
import { generateRequestNumber } from '../utils/helpers';
|
||||
import { gcsStorageService } from './gcsStorage.service';
|
||||
import { activityService } from './activity.service';
|
||||
import { simulateCreditNoteFromSap, simulateDebitNoteFromSap } from './form16SapSimulation.service';
|
||||
import { wfmFileService } from './wfmFile.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Resolve dealer_code for the current user (by email match with dealers.dealer_principal_email_id).
|
||||
* Returns null if user is not a dealer or no dealer found.
|
||||
* Resolve dealer_code for the current user.
|
||||
* Uses users.employee_number (DB column) first — dealer code saved at login; else falls back to email match with dealers.dealer_principal_email_id.
|
||||
* Same dealer code is used for submission, credit note, and debit note generation.
|
||||
* Returns null if no dealer code found.
|
||||
*/
|
||||
export async function getDealerCodeForUser(userId: string): Promise<string | null> {
|
||||
const user = await User.findByPk(userId, { attributes: ['userId', 'email'] });
|
||||
if (!user || !user.email) return null;
|
||||
const [row] = await sequelize.query<{ employee_number: string | null }>(
|
||||
`SELECT employee_number FROM users WHERE user_id = :userId LIMIT 1`,
|
||||
{ replacements: { userId }, type: QueryTypes.SELECT }
|
||||
);
|
||||
if (row?.employee_number != null && String(row.employee_number).trim()) {
|
||||
return String(row.employee_number).trim();
|
||||
}
|
||||
|
||||
const user = await User.findByPk(userId, { attributes: ['userId', 'email'] });
|
||||
if (!user?.email) return null;
|
||||
const dealer = await Dealer.findOne({
|
||||
where: {
|
||||
dealerPrincipalEmailId: { [Op.iLike]: user.email },
|
||||
@ -52,7 +64,12 @@ export async function getDealerCodeForUser(userId: string): Promise<string | nul
|
||||
/** 26AS: only Section 194Q and Booking Status F or O are considered for aggregation and matching. */
|
||||
const SECTION_26AS_194Q = '194Q';
|
||||
|
||||
/** Get aggregated TDS amount for (tan, fy, quarter) from all 26AS entries (Section 194Q, Booking F/O). */
|
||||
/**
|
||||
* Get aggregated TDS amount for (tan, fy, quarter) from the LATEST 26AS upload only (Section 194Q, Booking F/O).
|
||||
* Use case: "Always match Form 16A only with the latest 26AS version." Each upload can be full cumulative;
|
||||
* we sum only rows from the most recent upload for that quarter to avoid double-counting across uploads.
|
||||
* If no rows have upload_log_id (legacy), falls back to summing all rows for that quarter.
|
||||
*/
|
||||
export async function getLatest26asAggregatedForQuarter(
|
||||
tanNumber: string,
|
||||
financialYear: string,
|
||||
@ -62,12 +79,24 @@ export async function getLatest26asAggregatedForQuarter(
|
||||
const fy = normalizeFinancialYear(financialYear) || financialYear;
|
||||
const q = normalizeQuarter(quarter) || quarter;
|
||||
const [row] = await sequelize.query<{ sum: string }>(
|
||||
`SELECT COALESCE(SUM(tax_deducted), 0)::text AS sum
|
||||
FROM tds_26as_entries
|
||||
`WITH latest_upload AS (
|
||||
SELECT MAX(upload_log_id) AS mid FROM tds_26as_entries
|
||||
WHERE LOWER(REPLACE(TRIM(tan_number), ' ', '')) = LOWER(REPLACE(TRIM(:tan), ' ', ''))
|
||||
AND financial_year = :fy AND quarter = :qtr
|
||||
AND section_code = :section
|
||||
AND (status_oltas = 'F' OR status_oltas = 'O')`,
|
||||
AND (status_oltas = 'F' OR status_oltas = 'O')
|
||||
AND upload_log_id IS NOT NULL
|
||||
)
|
||||
SELECT COALESCE(SUM(e.tax_deducted), 0)::text AS sum
|
||||
FROM tds_26as_entries e
|
||||
WHERE LOWER(REPLACE(TRIM(e.tan_number), ' ', '')) = LOWER(REPLACE(TRIM(:tan), ' ', ''))
|
||||
AND e.financial_year = :fy AND e.quarter = :qtr
|
||||
AND e.section_code = :section
|
||||
AND (e.status_oltas = 'F' OR e.status_oltas = 'O')
|
||||
AND (
|
||||
e.upload_log_id = (SELECT mid FROM latest_upload)
|
||||
OR (SELECT mid FROM latest_upload) IS NULL
|
||||
)`,
|
||||
{ replacements: { tan: normalized, fy, qtr: q, section: SECTION_26AS_194Q }, type: QueryTypes.SELECT }
|
||||
);
|
||||
return parseFloat(row?.sum ?? '0') || 0;
|
||||
@ -272,6 +301,11 @@ export async function listCreditNotesForDealer(userId: string, filters?: { finan
|
||||
}
|
||||
|
||||
export interface CreateForm16SubmissionBody {
|
||||
/**
|
||||
* Optional override for RE/UAT users who are not mapped as a dealer by email.
|
||||
* If user is a dealer, this (when provided) must match the resolved dealerCode.
|
||||
*/
|
||||
dealerCode?: string;
|
||||
financialYear: string;
|
||||
quarter: string;
|
||||
form16aNumber: string;
|
||||
@ -369,6 +403,38 @@ function normalizeQuarter(raw: string): string {
|
||||
return (raw || '').trim() || '';
|
||||
}
|
||||
|
||||
/** Compact FY for Form 16 note numbers: "2024-25" -> "24-25" */
|
||||
function form16FyCompact(financialYear: string): string {
|
||||
const fy = normalizeFinancialYear(financialYear) || (financialYear || '').trim();
|
||||
if (!fy) return '';
|
||||
const m = fy.match(/^(\d{2,4})-(\d{2})$/);
|
||||
if (m) {
|
||||
const start = m[1].length === 2 ? m[1] : m[1].slice(-2);
|
||||
return `${start}-${m[2]}`;
|
||||
}
|
||||
return fy;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
export function formatForm16CreditNoteNumber(dealerCode: string, financialYear: string, quarter: string): string {
|
||||
const dc = (dealerCode || '').trim().replace(/\s+/g, '-') || 'XX';
|
||||
const fy = form16FyCompact(financialYear) || '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)
|
||||
*/
|
||||
export function formatForm16DebitNoteNumber(dealerCode: string, financialYear: string, quarter: string): string {
|
||||
const dc = (dealerCode || '').trim().replace(/\s+/g, '-') || 'XX';
|
||||
const fy = form16FyCompact(financialYear) || 'XX';
|
||||
const q = normalizeQuarter(quarter) || 'X';
|
||||
return `DN-F-16-${dc}-${fy}-${q}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match submission against latest 26AS aggregated amount (quarter-level). Only Section 194Q, Booking F/O.
|
||||
* Reject if no 26AS data, amount mismatch, or duplicate (already settled with same amount).
|
||||
@ -454,7 +520,9 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
|
||||
}
|
||||
}
|
||||
|
||||
const cnNumber = `CN-${new Date().getFullYear()}-${submission.id}-${Date.now().toString(36).toUpperCase()}`;
|
||||
// Dealer code from submission (set at create from users.employee_number)
|
||||
const dealerCode = (sub.dealerCode || '').toString().trim();
|
||||
const cnNumber = formatForm16CreditNoteNumber(dealerCode, financialYear, quarter);
|
||||
const now = new Date();
|
||||
const creditNote = await Form16CreditNote.create({
|
||||
submissionId: submission.id,
|
||||
@ -482,6 +550,35 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
|
||||
validationStatus: 'success',
|
||||
validationNotes: null,
|
||||
});
|
||||
|
||||
// Push Form 16 credit note CSV to WFM INCOMING/WFM_MAIN/FORM_16 (pipe | separator, no double quotes)
|
||||
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 claimDate = now.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const csvRow = {
|
||||
CREDIT_TYPE: 'Form16',
|
||||
DEALER_CODE: dealerCode,
|
||||
DEALER_NAME: dealerName,
|
||||
AMOUNT: tdsAmount,
|
||||
FINANCIAL_YEAR: financialYear,
|
||||
QUARTER: quarter,
|
||||
CREDIT_NOTE_NUMBER: cnNumber,
|
||||
TRNS_UNIQ_NO: trnsUniqNo,
|
||||
CLAIM_DATE: claimDate,
|
||||
};
|
||||
const fileName = `${cnNumber}.csv`;
|
||||
await wfmFileService.generateForm16IncomingCSV([csvRow], fileName);
|
||||
logger.info(`[Form16] Credit note CSV pushed to WFM FORM_16: ${cnNumber}`);
|
||||
} catch (csvErr: any) {
|
||||
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
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[Form16] 26AS MATCH RESULT: SUCCESS – Credit note issued. Form 16A: TAN=${tanNumber}, FY=${financialYear}, Quarter=${quarter}, TDS amount=${tdsAmount} | 26AS aggregated=${aggregated26as} | Credit note=${cnNumber}.`
|
||||
);
|
||||
@ -494,9 +591,12 @@ export async function createSubmission(
|
||||
originalName: string,
|
||||
body: CreateForm16SubmissionBody
|
||||
): Promise<CreateForm16SubmissionResult> {
|
||||
const dealerCode = await getDealerCodeForUser(userId);
|
||||
// Dealer: dealer code from users.employee_number (getDealerCodeForUser) or body.dealerCode for RE/UAT. Used for submission, credit note, and debit note.
|
||||
const resolvedDealerCode = await getDealerCodeForUser(userId);
|
||||
const overrideDealerCode = (body.dealerCode || '').trim() || null;
|
||||
const dealerCode = resolvedDealerCode || overrideDealerCode;
|
||||
if (!dealerCode) {
|
||||
throw new Error('Dealer not found for this user. Only dealers can submit Form 16.');
|
||||
throw new Error('dealerCode is required to submit Form 16.');
|
||||
}
|
||||
|
||||
const version = await getNextVersionForDealerFyQuarter(dealerCode, body.financialYear, body.quarter);
|
||||
@ -1065,62 +1165,6 @@ export async function setForm16ResubmissionNeeded(requestId: string, _userId: st
|
||||
return { submission };
|
||||
}
|
||||
|
||||
/**
|
||||
* RE only. Manually generate credit note for a Form 16 request (e.g. when OCR was partial but RE verified).
|
||||
* Sets validationStatus to 'manually_approved'.
|
||||
*/
|
||||
export async function generateForm16CreditNoteManually(
|
||||
requestId: string,
|
||||
userId: string,
|
||||
amount: number
|
||||
) {
|
||||
if (!amount || amount <= 0) throw new Error('Valid amount is required to generate credit note.');
|
||||
const submission = await Form16aSubmission.findOne({
|
||||
where: { requestId },
|
||||
attributes: ['id', 'requestId', 'dealerCode', 'financialYear', 'quarter', 'tdsAmount'],
|
||||
});
|
||||
if (!submission) throw new Error('Form 16 submission not found for this request.');
|
||||
const sub = submission as any;
|
||||
const existing = await Form16CreditNote.findOne({ where: { submissionId: submission.id }, attributes: ['id'] });
|
||||
if (existing) throw new Error('A credit note already exists for this submission.');
|
||||
const dealerCode = (sub.dealerCode || '').toString().trim();
|
||||
const financialYear = (sub.financialYear || '').trim();
|
||||
const quarter = (sub.quarter || '').trim();
|
||||
if (dealerCode && (await hasActiveCreditNoteForDealerFyQuarter(dealerCode, financialYear, quarter))) {
|
||||
throw new Error(
|
||||
'A credit note has already been issued for this financial year and quarter (e.g. from another submission or a later upload that matched 26AS). You cannot generate another credit note. If the previous credit note was withdrawn (debit note issued), the dealer must submit Form 16 again to generate a new credit note.'
|
||||
);
|
||||
}
|
||||
const dealer = await Dealer.findOne({
|
||||
where: { [Op.or]: [{ salesCode: dealerCode }, { dlrcode: dealerCode }], isActive: true },
|
||||
attributes: ['dealership', 'dealerPrincipalName', 'dealerPrincipalEmailId', 'dpContactNumber'],
|
||||
});
|
||||
const dealerDetails = {
|
||||
dealerCode: dealerCode || 'UNKNOWN',
|
||||
dealerName: dealer ? ((dealer as any).dealership || (dealer as any).dealerPrincipalName || dealerCode) : dealerCode,
|
||||
dealerEmail: (dealer as any)?.dealerPrincipalEmailId ?? undefined,
|
||||
dealerContact: (dealer as any)?.dpContactNumber ?? undefined,
|
||||
};
|
||||
const sapResponse = simulateCreditNoteFromSap(dealerDetails, amount);
|
||||
const creditNote = await Form16CreditNote.create({
|
||||
submissionId: submission.id,
|
||||
creditNoteNumber: sapResponse.creditNoteNumber,
|
||||
sapDocumentNumber: sapResponse.sapDocumentNumber ?? undefined,
|
||||
amount,
|
||||
issueDate: new Date(sapResponse.issueDate),
|
||||
financialYear,
|
||||
quarter,
|
||||
status: sapResponse.status || 'issued',
|
||||
remarks: 'Manually approved; credit note generated via SAP (simulation).',
|
||||
issuedBy: userId,
|
||||
});
|
||||
await submission.update({
|
||||
validationStatus: 'manually_approved',
|
||||
validationNotes: 'Credit note manually generated by RE user.',
|
||||
});
|
||||
return { creditNote, submission };
|
||||
}
|
||||
|
||||
/** Get credit note linked to a Form 16 request (by requestId). Returns null if none. */
|
||||
export async function getCreditNoteByRequestId(requestId: string) {
|
||||
const submission = await Form16aSubmission.findOne({
|
||||
@ -1228,8 +1272,7 @@ export async function getCreditNoteById(creditNoteId: number) {
|
||||
}
|
||||
|
||||
/**
|
||||
* RE only. Generate debit note for a credit note (Form 16). Calls SAP simulation with dealer code, dealer info, credit note number, amount; creates Form16DebitNote from response.
|
||||
* When real SAP is integrated, replace simulateDebitNoteFromSap with the actual SAP API call.
|
||||
* 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,
|
||||
@ -1238,38 +1281,59 @@ export async function generateForm16DebitNoteForCreditNote(
|
||||
): 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 dealer = await Dealer.findOne({
|
||||
where: { [Op.or]: [{ salesCode: dealerCode }, { dlrcode: dealerCode }], isActive: true },
|
||||
attributes: ['dealership', 'dealerPrincipalName', 'dealerPrincipalEmailId', 'dpContactNumber'],
|
||||
});
|
||||
const dealerInfo = {
|
||||
dealerCode: dealerCode || 'UNKNOWN',
|
||||
dealerName: dealer ? ((dealer as any).dealership || (dealer as any).dealerPrincipalName || dealerCode) : dealerCode,
|
||||
dealerEmail: (dealer as any)?.dealerPrincipalEmailId ?? undefined,
|
||||
dealerContact: (dealer as any)?.dpContactNumber ?? undefined,
|
||||
};
|
||||
const sapResponse = simulateDebitNoteFromSap({
|
||||
dealerCode: dealerCode || 'UNKNOWN',
|
||||
dealerInfo,
|
||||
creditNoteNumber: (creditNote as any).creditNoteNumber,
|
||||
amount,
|
||||
});
|
||||
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: sapResponse.debitNoteNumber,
|
||||
sapDocumentNumber: sapResponse.sapDocumentNumber ?? undefined,
|
||||
debitNoteNumber: dnNumber,
|
||||
amount,
|
||||
issueDate: new Date(sapResponse.issueDate),
|
||||
status: sapResponse.status || 'issued',
|
||||
reason: 'Debit note generated via SAP (simulation).',
|
||||
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 };
|
||||
}
|
||||
|
||||
@ -1894,9 +1958,11 @@ function build26asCreatePayload(row: Record<string, unknown>, uploadLogId?: numb
|
||||
const v = row[k];
|
||||
if (v !== undefined && v !== null) payload[k] = v;
|
||||
}
|
||||
payload.tanNumber = row.tanNumber ?? '';
|
||||
payload.quarter = row.quarter ?? 'Q1';
|
||||
payload.financialYear = row.financialYear ?? '';
|
||||
payload.tanNumber = (row.tanNumber != null ? String(row.tanNumber).trim() : '') || '';
|
||||
const rawFy = (row.financialYear != null ? String(row.financialYear).trim() : '') || '';
|
||||
const rawQ = (row.quarter != null ? String(row.quarter).trim() : '') || 'Q1';
|
||||
payload.financialYear = normalizeFinancialYear(rawFy) || rawFy;
|
||||
payload.quarter = normalizeQuarter(rawQ) || rawQ;
|
||||
payload.taxDeducted = row.taxDeducted ?? 0;
|
||||
if (uploadLogId != null) payload.uploadLogId = uploadLogId;
|
||||
return payload;
|
||||
@ -1955,16 +2021,23 @@ export async function process26asUploadAggregation(uploadLogId: number): Promise
|
||||
for (const [, { tanNumber, financialYear, quarter }] of keys) {
|
||||
const fy = normalizeFinancialYear(financialYear) || financialYear;
|
||||
const q = normalizeQuarter(quarter) || quarter;
|
||||
const newTotal = await getLatest26asAggregatedForQuarter(tanNumber, fy, quarter);
|
||||
const latest = await getLatest26asSnapshot(tanNumber, fy, quarter);
|
||||
const newTotal = await getLatest26asAggregatedForQuarter(tanNumber, fy, q);
|
||||
const latest = await getLatest26asSnapshot(tanNumber, fy, q);
|
||||
const prevTotal = latest ? parseFloat(String((latest as any).aggregatedAmount ?? 0)) : 0;
|
||||
if (Math.abs(newTotal - prevTotal) < 0.01) continue; // duplicate 26AS: no change
|
||||
const status = await getQuarterStatus(tanNumber, fy, quarter);
|
||||
const status = await getQuarterStatus(tanNumber, fy, q);
|
||||
if (status?.status === 'SETTLED' && status.lastCreditNoteId) {
|
||||
const creditNote = await Form16CreditNote.findByPk(status.lastCreditNoteId, { attributes: ['id', 'amount'] });
|
||||
const creditNote = await Form16CreditNote.findByPk(status.lastCreditNoteId, {
|
||||
attributes: ['id', 'amount', 'financialYear', 'quarter', 'creditNoteNumber', 'submissionId', 'issueDate'],
|
||||
});
|
||||
if (creditNote) {
|
||||
const amount = parseFloat(String((creditNote as any).amount ?? 0));
|
||||
const debitNum = `DN-${new Date().getFullYear()}-${creditNote.id}-${Date.now().toString(36).toUpperCase()}`;
|
||||
const submission = await Form16aSubmission.findByPk((creditNote as any).submissionId, { attributes: ['dealerCode'] });
|
||||
// Dealer code from submission (set at Form 16 submit from users.employee_number)
|
||||
const dealerCode = submission ? ((submission as any).dealerCode || '').toString().trim() : '';
|
||||
const cnFy = (creditNote as any).financialYear || fy;
|
||||
const cnQuarter = (creditNote as any).quarter || q;
|
||||
const debitNum = formatForm16DebitNoteNumber(dealerCode || 'XX', cnFy, cnQuarter);
|
||||
const now = new Date();
|
||||
const debit = await Form16DebitNote.create({
|
||||
creditNoteId: creditNote.id,
|
||||
@ -1984,8 +2057,39 @@ export async function process26asUploadAggregation(uploadLogId: number): Promise
|
||||
amount,
|
||||
debitNoteId: debit.id,
|
||||
});
|
||||
await setQuarterStatusDebitIssued(tanNumber, fy, quarter, debit.id);
|
||||
await setQuarterStatusDebitIssued(tanNumber, fy, q, debit.id);
|
||||
debitsCreated++;
|
||||
|
||||
// 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-${creditNote.id}-${debit.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 || 'XX',
|
||||
DEALER_NAME: dealerName,
|
||||
AMOUNT: amount,
|
||||
FINANCIAL_YEAR: cnFy,
|
||||
QUARTER: cnQuarter,
|
||||
DEBIT_NOTE_NUMBER: debitNum,
|
||||
TRNS_UNIQ_NO: trnsUniqNo,
|
||||
CLAIM_DATE: claimDate,
|
||||
CREDIT_NOTE_DATE: creditNoteIssueDate,
|
||||
};
|
||||
const fileName = `${debitNum}.csv`;
|
||||
await wfmFileService.generateForm16IncomingCSV([csvRow], fileName);
|
||||
logger.info(`[Form16] Debit note CSV pushed to WFM FORM_16: ${debitNum}`);
|
||||
} catch (csvErr: any) {
|
||||
logger.error('[Form16] Failed to push debit note CSV to WFM FORM_16:', csvErr?.message || csvErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
const normalized = (tanNumber || '').trim().replace(/\s+/g, ' ');
|
||||
|
||||
@ -55,8 +55,8 @@ export async function getReUserIdsFor26As(): Promise<string[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger notifications when 26AS data is uploaded: RE users get templateRe, dealers get templateDealers.
|
||||
* Called after successful 26AS upload (fire-and-forget or await in controller).
|
||||
* Trigger notifications when 26AS data is uploaded: sent only to RE users (admins / 26AS viewers / submission viewers).
|
||||
* Dealers no longer receive this notification.
|
||||
*/
|
||||
export async function trigger26AsDataAddedNotification(): Promise<void> {
|
||||
try {
|
||||
@ -68,7 +68,6 @@ export async function trigger26AsDataAddedNotification(): Promise<void> {
|
||||
}
|
||||
const { notificationService } = await import('./notification.service');
|
||||
const reUserIds = await getReUserIdsFor26As();
|
||||
const dealerIds = await getDealerUserIds();
|
||||
|
||||
const title = 'Form 16 – 26AS data updated';
|
||||
if (reUserIds.length > 0 && n.templateRe) {
|
||||
@ -79,14 +78,6 @@ export async function trigger26AsDataAddedNotification(): Promise<void> {
|
||||
});
|
||||
logger.info(`[Form16Notification] 26AS notification sent to ${reUserIds.length} RE user(s)`);
|
||||
}
|
||||
if (dealerIds.length > 0 && n.templateDealers) {
|
||||
await notificationService.sendToUsers(dealerIds, {
|
||||
title,
|
||||
body: n.templateDealers,
|
||||
type: 'form16_26as_added',
|
||||
});
|
||||
logger.info(`[Form16Notification] 26AS notification sent to ${dealerIds.length} dealer user(s)`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('[Form16Notification] trigger26AsDataAddedNotification failed:', e);
|
||||
}
|
||||
@ -148,23 +139,6 @@ export async function triggerForm16SubmissionResultNotification(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify dealer when RE manually generates a credit note (so they see success).
|
||||
*/
|
||||
export async function triggerForm16ManualCreditNoteNotification(requestId: string, creditNoteNumber: string): Promise<void> {
|
||||
try {
|
||||
const req = await WorkflowRequest.findByPk(requestId, { attributes: ['initiatorId'], raw: true });
|
||||
const initiatorId = (req as any)?.initiatorId;
|
||||
if (!initiatorId) return;
|
||||
await triggerForm16SubmissionResultNotification(initiatorId, 'manually_approved', {
|
||||
creditNoteNumber,
|
||||
requestId,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('[Form16Notification] triggerForm16ManualCreditNoteNotification failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify dealer when RE marks submission as resubmission needed or cancels (unsuccessful outcome).
|
||||
*/
|
||||
|
||||
@ -319,7 +319,7 @@ class NotificationService {
|
||||
|
||||
const emailType = emailTypeMap[payload.type || ''];
|
||||
|
||||
// Form 16: send email via same transport as workflow (Ethereal when SMTP not set); templates come from payload
|
||||
// Form 16: send email via same transport as workflow (Ethereal when SMTP not set); template comes from emailtemplates (HTML)
|
||||
if (payload.type && payload.type.startsWith('form16_') && user?.email) {
|
||||
if (user.emailNotificationsEnabled === false) {
|
||||
logger.info(`[Email] Form 16 email skipped for user ${userId} (email notifications disabled)`);
|
||||
@ -327,12 +327,37 @@ class NotificationService {
|
||||
}
|
||||
try {
|
||||
const { emailService } = await import('./email.service');
|
||||
const { getForm16Email, CompanyInfo } = await import('../emailtemplates');
|
||||
|
||||
const escaped = (payload.body || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br/>');
|
||||
const html = `<!DOCTYPE html><html><body><p>${escaped}</p></body></html>`;
|
||||
|
||||
const variant =
|
||||
payload.type === 'form16_success_credit_note'
|
||||
? 'success'
|
||||
: payload.type === 'form16_unsuccessful'
|
||||
? 'error'
|
||||
: payload.type === 'form16_alert_submit' || payload.type === 'form16_reminder' || payload.type === 'form16_debit_note'
|
||||
? 'warning'
|
||||
: 'info';
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||
const requestId = payload.requestId || undefined;
|
||||
const viewDetailsLink = requestId ? `${frontendUrl.replace(/\/$/, '')}/request/${encodeURIComponent(requestId)}` : undefined;
|
||||
|
||||
const html = getForm16Email({
|
||||
recipientName: user.displayName || user.email,
|
||||
title: payload.title || 'Form 16 Notification',
|
||||
messageHtml: escaped,
|
||||
requestId,
|
||||
viewDetailsLink,
|
||||
companyName: CompanyInfo.name,
|
||||
variant,
|
||||
});
|
||||
|
||||
await emailService.sendEmail({
|
||||
to: user.email,
|
||||
subject: payload.title || 'Form 16 Notification',
|
||||
@ -631,7 +656,7 @@ class NotificationService {
|
||||
const currentLevel = await ApprovalLevel.findOne({
|
||||
where: {
|
||||
requestId: payload.requestId,
|
||||
status: 'PENDING'
|
||||
status: 'IN_PROGRESS'
|
||||
},
|
||||
order: [['levelNumber', 'ASC']]
|
||||
});
|
||||
@ -718,7 +743,7 @@ class NotificationService {
|
||||
const currentLevel = await ApprovalLevel.findOne({
|
||||
where: {
|
||||
requestId: payload.requestId,
|
||||
status: 'PENDING'
|
||||
status: 'IN_PROGRESS'
|
||||
},
|
||||
order: [['levelNumber', 'ASC']]
|
||||
});
|
||||
|
||||
@ -2,19 +2,37 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
/** 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_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_OUTGOING = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'FORM_16');
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Paths are cross-platform; set WFM_BASE_PATH (and optional path overrides) in .env for production.
|
||||
*/
|
||||
export class WFMFileService {
|
||||
private basePath: string;
|
||||
private incomingClaimsPath: string;
|
||||
private outgoingClaimsPath: string;
|
||||
private incomingGstClaimsPath: string;
|
||||
private incomingNonGstClaimsPath: string;
|
||||
private outgoingGstClaimsPath: string;
|
||||
private outgoingNonGstClaimsPath: string;
|
||||
/** Form 16: INCOMING/WFM_MAIN/FORM_16 */
|
||||
private form16IncomingPath: string;
|
||||
/** Form 16: OUTGOING/WFM_SAP_MAIN/FORM_16 */
|
||||
private form16OutgoingPath: string;
|
||||
|
||||
constructor() {
|
||||
this.basePath = process.env.WFM_BASE_PATH || 'C:\\WFM';
|
||||
this.incomingClaimsPath = process.env.WFM_INCOMING_CLAIMS_PATH || 'WFM-QRE\\INCOMING\\WFM_MAIN\\DLR_INC_CLAIMS';
|
||||
this.outgoingClaimsPath = process.env.WFM_OUTGOING_CLAIMS_PATH || 'WFM-QRE\\OUTGOING\\WFM_SAP_MAIN\\DLR_INC_CLAIMS';
|
||||
this.incomingGstClaimsPath = process.env.WFM_INCOMING_GST_CLAIMS_PATH || 'WFM-QRE\\INCOMING\\WFM_MAIN\\DLR_INC_CLAIMS_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.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;
|
||||
this.form16OutgoingPath = process.env.WFM_FORM16_OUTGOING_PATH || DEFAULT_FORM16_OUTGOING;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -32,20 +50,21 @@ export class WFMFileService {
|
||||
* @param data The data to be written to the CSV
|
||||
* @param fileName The name of the file (e.g., CLAIM_12345.csv)
|
||||
*/
|
||||
async generateIncomingClaimCSV(data: any[], fileName: string): Promise<string> {
|
||||
async generateIncomingClaimCSV(data: any[], fileName: string, isNonGst: boolean = false): Promise<string> {
|
||||
const maxRetries = 3;
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount <= maxRetries) {
|
||||
try {
|
||||
const targetDir = path.join(this.basePath, this.incomingClaimsPath);
|
||||
const targetPath = isNonGst ? this.incomingNonGstClaimsPath : this.incomingGstClaimsPath;
|
||||
const targetDir = path.join(this.basePath, targetPath);
|
||||
this.ensureDirectoryExists(targetDir);
|
||||
|
||||
const filePath = path.join(targetDir, fileName.endsWith('.csv') ? fileName : `${fileName}.csv`);
|
||||
|
||||
// Simple CSV generation logic
|
||||
const headers = Object.keys(data[0] || {}).join(',');
|
||||
const rows = data.map(item => Object.values(item).map(val => `"${val}"`).join(',')).join('\n');
|
||||
// Simple CSV generation logic with pipe separator and no quotes
|
||||
const headers = Object.keys(data[0] || {}).join('|');
|
||||
const rows = data.map(item => Object.values(item).map(val => val === null || val === undefined ? '' : String(val)).join('|')).join('\n');
|
||||
const csvContent = `${headers}\n${rows}`;
|
||||
|
||||
fs.writeFileSync(filePath, csvContent);
|
||||
@ -75,8 +94,99 @@ export class WFMFileService {
|
||||
/**
|
||||
* Get the absolute path for an outgoing claim file
|
||||
*/
|
||||
getOutgoingPath(fileName: string): string {
|
||||
return path.join(this.basePath, this.outgoingClaimsPath, fileName);
|
||||
getOutgoingPath(fileName: string, isNonGst: boolean = false): string {
|
||||
const targetPath = isNonGst ? this.outgoingNonGstClaimsPath : this.outgoingGstClaimsPath;
|
||||
return path.join(this.basePath, targetPath, fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get credit note details from outgoing CSV
|
||||
*/
|
||||
async getCreditNoteDetails(dealerCode: string, requestNumber: string, isNonGst: boolean = false): Promise<any[]> {
|
||||
const fileName = `CN_${dealerCode}_${requestNumber}.csv`;
|
||||
const filePath = this.getOutgoingPath(fileName, isNonGst);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = fileContent.split('\n').filter(line => line.trim() !== '');
|
||||
if (lines.length <= 1) return []; // Only headers or empty
|
||||
|
||||
const headers = lines[0].split('|');
|
||||
const data = lines.slice(1).map(line => {
|
||||
const values = line.split('|');
|
||||
const row: any = {};
|
||||
headers.forEach((header, index) => {
|
||||
row[header.trim()] = values[index]?.trim() || '';
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error(`[WFMFileService] Error reading credit note CSV: ${fileName}`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a CSV file for Form 16 (credit/debit note) and store in INCOMING/WFM_MAIN/FORM_16.
|
||||
* 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 fileName File name (e.g. CN-F-16-6282-24-25-Q1.csv or DN-F-16-6282-24-25-Q1.csv)
|
||||
*/
|
||||
async generateForm16IncomingCSV(data: any[], fileName: string): Promise<string> {
|
||||
const maxRetries = 3;
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount <= maxRetries) {
|
||||
try {
|
||||
const targetDir = path.join(this.basePath, this.form16IncomingPath);
|
||||
this.ensureDirectoryExists(targetDir);
|
||||
|
||||
const filePath = path.join(targetDir, fileName.endsWith('.csv') ? fileName : `${fileName}.csv`);
|
||||
|
||||
// Pipe separator, no double quotes (values as plain strings)
|
||||
const keys = Object.keys(data[0] || {});
|
||||
const headers = keys.join('|');
|
||||
const rows = data.map(item =>
|
||||
keys.map(key => {
|
||||
const val = item[key];
|
||||
return val === null || val === undefined ? '' : String(val);
|
||||
}).join('|')
|
||||
).join('\n');
|
||||
const csvContent = `${headers}\n${rows}`;
|
||||
|
||||
fs.writeFileSync(filePath, csvContent);
|
||||
logger.info(`[WFMFileService] Form 16 CSV generated at: ${filePath}`);
|
||||
|
||||
return filePath;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'EBUSY' && retryCount < maxRetries) {
|
||||
retryCount++;
|
||||
const delay = retryCount * 1000;
|
||||
logger.warn(`[WFMFileService] Form 16 file busy, retrying in ${delay}ms (${retryCount}/${maxRetries}): ${fileName}`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
if (error.code === 'EBUSY') {
|
||||
throw new Error(`Form 16 file is locked. Please close '${fileName}' and try again.`);
|
||||
}
|
||||
logger.error('[WFMFileService] Error generating Form 16 incoming CSV:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw new Error(`Failed to generate Form 16 CSV after ${maxRetries} retries.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the absolute path for a Form 16 outgoing (response) file
|
||||
*/
|
||||
getForm16OutgoingPath(fileName: string): string {
|
||||
return path.join(this.basePath, this.form16OutgoingPath, fileName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user