some form16 code related to WFM pulled from remote

This commit is contained in:
laxmanhalaki 2026-03-12 17:00:25 +05:30
commit e9ed4ca4d3
10 changed files with 445 additions and 179 deletions

View File

@ -113,3 +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 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

View File

@ -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 * POST /api/v1/form16/sap-simulate/credit-note
* Form 16 only. Simulate SAP credit note generation (dealer details + amount JSON response). * 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 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 financialYear = (body.financialYear || '').trim();
const quarter = (body.quarter || '').trim(); const quarter = (body.quarter || '').trim();
const form16aNumber = (body.form16aNumber || '').trim(); const form16aNumber = (body.form16aNumber || '').trim();
@ -665,6 +634,7 @@ export class Form16Controller {
file.buffer, file.buffer,
file.originalname || 'form16a.pdf', file.originalname || 'form16a.pdf',
{ {
dealerCode: dealerCode || undefined,
financialYear, financialYear,
quarter, quarter,
form16aNumber, form16aNumber,
@ -695,7 +665,7 @@ export class Form16Controller {
} catch (error: any) { } catch (error: any) {
const message = error?.message || 'Unknown error'; const message = error?.message || 'Unknown error';
logger.error('[Form16Controller] createSubmission error:', 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); return ResponseHandler.error(res, message, 403);
} }
// No validation on certificate number: revised Form 16A can reuse same certificate number for same quarter. // No validation on certificate number: revised Form 16A can reuse same certificate number for same quarter.

View 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>
`;
}

View File

@ -36,4 +36,5 @@ export { getCompletionDocumentsSubmittedEmail } from './completionDocumentsSubmi
export { getEInvoiceGeneratedEmail } from './einvoiceGenerated.template'; export { getEInvoiceGeneratedEmail } from './einvoiceGenerated.template';
export { getCreditNoteSentEmail } from './creditNoteSent.template'; export { getCreditNoteSentEmail } from './creditNoteSent.template';
export { getAdditionalDocumentAddedEmail } from './additionalDocumentAdded.template'; export { getAdditionalDocumentAddedEmail } from './additionalDocumentAdded.template';
export { getForm16Email } from './form_16_email.template';

View File

@ -12,6 +12,22 @@ export interface BaseEmailData {
companyName: string; 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 { export interface RequestCreatedData extends BaseEmailData {
initiatorName: string; initiatorName: string;
firstApproverName: string; firstApproverName: string;

View File

@ -83,7 +83,7 @@ router.get(
requireForm16SubmissionAccess, requireForm16SubmissionAccess,
asyncHandler(form16Controller.getCreditNoteByRequest.bind(form16Controller)) 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( router.post(
'/requests/:requestId/cancel-submission', '/requests/:requestId/cancel-submission',
requireForm16ReOnly, requireForm16ReOnly,
@ -96,12 +96,6 @@ router.post(
requireForm16SubmissionAccess, requireForm16SubmissionAccess,
asyncHandler(form16Controller.setForm16ResubmissionNeeded.bind(form16Controller)) 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. // Form 16 SAP simulation (credit note / debit note). Replace with real SAP when integrating.
router.post( router.post(

View File

@ -1,6 +1,9 @@
/** /**
* Form 16 (Form 16A TDS Credit) service. * Form 16 (Form 16A TDS Credit) service.
* Quarter-based reconciliation: 26AS (aggregated by tan+fy+quarter), Form 16A match, credit/debit, ledger. * Quarter-based reconciliation: 26AS (aggregated by tan+fy+quarter), Form 16A match, credit/debit, ledger.
*
* Credit note generation: run26asMatchAndCreditNote only (on Form 16A submit, match 26AS Section 194Q/Booking F/O CN-F-16-{dealerCode}-{FY}-{quarter}, ledger, CSV to WFM FORM_16).
* Debit: generateForm16DebitNoteForCreditNote (manual) and process26asUploadAggregation (auto when 26AS total drops); DN-F-16-{dc}-{fy}-{q}.
*/ */
import crypto from 'crypto'; import crypto from 'crypto';
@ -25,17 +28,26 @@ import { Priority, WorkflowStatus } from '../types/common.types';
import { generateRequestNumber } from '../utils/helpers'; import { generateRequestNumber } from '../utils/helpers';
import { gcsStorageService } from './gcsStorage.service'; import { gcsStorageService } from './gcsStorage.service';
import { activityService } from './activity.service'; import { activityService } from './activity.service';
import { simulateCreditNoteFromSap, simulateDebitNoteFromSap } from './form16SapSimulation.service'; import { wfmFileService } from './wfmFile.service';
import logger from '../utils/logger'; import logger from '../utils/logger';
/** /**
* Resolve dealer_code for the current user (by email match with dealers.dealer_principal_email_id). * Resolve dealer_code for the current user.
* Returns null if user is not a dealer or no dealer found. * 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> { export async function getDealerCodeForUser(userId: string): Promise<string | null> {
const user = await User.findByPk(userId, { attributes: ['userId', 'email'] }); const [row] = await sequelize.query<{ employee_number: string | null }>(
if (!user || !user.email) return 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({ const dealer = await Dealer.findOne({
where: { where: {
dealerPrincipalEmailId: { [Op.iLike]: user.email }, 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. */ /** 26AS: only Section 194Q and Booking Status F or O are considered for aggregation and matching. */
const SECTION_26AS_194Q = '194Q'; 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( export async function getLatest26asAggregatedForQuarter(
tanNumber: string, tanNumber: string,
financialYear: string, financialYear: string,
@ -62,12 +79,24 @@ export async function getLatest26asAggregatedForQuarter(
const fy = normalizeFinancialYear(financialYear) || financialYear; const fy = normalizeFinancialYear(financialYear) || financialYear;
const q = normalizeQuarter(quarter) || quarter; const q = normalizeQuarter(quarter) || quarter;
const [row] = await sequelize.query<{ sum: string }>( const [row] = await sequelize.query<{ sum: string }>(
`SELECT COALESCE(SUM(tax_deducted), 0)::text AS sum `WITH latest_upload AS (
FROM tds_26as_entries SELECT MAX(upload_log_id) AS mid FROM tds_26as_entries
WHERE LOWER(REPLACE(TRIM(tan_number), ' ', '')) = LOWER(REPLACE(TRIM(:tan), ' ', '')) WHERE LOWER(REPLACE(TRIM(tan_number), ' ', '')) = LOWER(REPLACE(TRIM(:tan), ' ', ''))
AND financial_year = :fy AND quarter = :qtr AND financial_year = :fy AND quarter = :qtr
AND section_code = :section 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 } { replacements: { tan: normalized, fy, qtr: q, section: SECTION_26AS_194Q }, type: QueryTypes.SELECT }
); );
return parseFloat(row?.sum ?? '0') || 0; return parseFloat(row?.sum ?? '0') || 0;
@ -272,6 +301,11 @@ export async function listCreditNotesForDealer(userId: string, filters?: { finan
} }
export interface CreateForm16SubmissionBody { 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; financialYear: string;
quarter: string; quarter: string;
form16aNumber: string; form16aNumber: string;
@ -369,6 +403,38 @@ function normalizeQuarter(raw: string): string {
return (raw || '').trim() || ''; 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. * 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). * 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 now = new Date();
const creditNote = await Form16CreditNote.create({ const creditNote = await Form16CreditNote.create({
submissionId: submission.id, submissionId: submission.id,
@ -482,6 +550,35 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
validationStatus: 'success', validationStatus: 'success',
validationNotes: null, 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( 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}.` `[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, originalName: string,
body: CreateForm16SubmissionBody body: CreateForm16SubmissionBody
): Promise<CreateForm16SubmissionResult> { ): 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) { 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); const version = await getNextVersionForDealerFyQuarter(dealerCode, body.financialYear, body.quarter);
@ -1065,62 +1165,6 @@ export async function setForm16ResubmissionNeeded(requestId: string, _userId: st
return { submission }; 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. */ /** Get credit note linked to a Form 16 request (by requestId). Returns null if none. */
export async function getCreditNoteByRequestId(requestId: string) { export async function getCreditNoteByRequestId(requestId: string) {
const submission = await Form16aSubmission.findOne({ 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. * 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.
* When real SAP is integrated, replace simulateDebitNoteFromSap with the actual SAP API call.
*/ */
export async function generateForm16DebitNoteForCreditNote( export async function generateForm16DebitNoteForCreditNote(
creditNoteId: number, creditNoteId: number,
@ -1238,38 +1281,59 @@ export async function generateForm16DebitNoteForCreditNote(
): Promise<{ debitNote: Form16DebitNote; creditNote: Form16CreditNote }> { ): Promise<{ debitNote: Form16DebitNote; creditNote: Form16CreditNote }> {
if (!amount || amount <= 0) throw new Error('Valid amount is required to generate debit note.'); if (!amount || amount <= 0) throw new Error('Valid amount is required to generate debit note.');
const creditNote = await Form16CreditNote.findByPk(creditNoteId, { const creditNote = await Form16CreditNote.findByPk(creditNoteId, {
attributes: ['id', 'creditNoteNumber', 'amount', 'financialYear', 'quarter', 'issueDate'],
include: [{ model: Form16aSubmission, as: 'submission', attributes: ['id', 'dealerCode'] }], include: [{ model: Form16aSubmission, as: 'submission', attributes: ['id', 'dealerCode'] }],
}); });
if (!creditNote || !(creditNote as any).submission) throw new Error('Credit note not found.'); if (!creditNote || !(creditNote as any).submission) throw new Error('Credit note not found.');
const existing = await Form16DebitNote.findOne({ where: { creditNoteId }, attributes: ['id'] }); const existing = await Form16DebitNote.findOne({ where: { creditNoteId }, attributes: ['id'] });
if (existing) throw new Error('A debit note already exists for this credit note.'); 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 dealerCode = ((creditNote as any).submission?.dealerCode || '').toString().trim();
const dealer = await Dealer.findOne({ const financialYear = (creditNote as any).financialYear || '';
where: { [Op.or]: [{ salesCode: dealerCode }, { dlrcode: dealerCode }], isActive: true }, const quarter = (creditNote as any).quarter || '';
attributes: ['dealership', 'dealerPrincipalName', 'dealerPrincipalEmailId', 'dpContactNumber'], const dnNumber = formatForm16DebitNoteNumber(dealerCode || 'UNKNOWN', financialYear, quarter);
}); const now = new Date();
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 debitNote = await Form16DebitNote.create({ const debitNote = await Form16DebitNote.create({
creditNoteId, creditNoteId,
debitNoteNumber: sapResponse.debitNoteNumber, debitNoteNumber: dnNumber,
sapDocumentNumber: sapResponse.sapDocumentNumber ?? undefined,
amount, amount,
issueDate: new Date(sapResponse.issueDate), issueDate: now,
status: sapResponse.status || 'issued', status: 'issued',
reason: 'Debit note generated via SAP (simulation).', reason: 'Debit note pushed to WFM FORM16 for SAP.',
createdBy: userId, 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 }; return { debitNote, creditNote };
} }
@ -1894,9 +1958,11 @@ function build26asCreatePayload(row: Record<string, unknown>, uploadLogId?: numb
const v = row[k]; const v = row[k];
if (v !== undefined && v !== null) payload[k] = v; if (v !== undefined && v !== null) payload[k] = v;
} }
payload.tanNumber = row.tanNumber ?? ''; payload.tanNumber = (row.tanNumber != null ? String(row.tanNumber).trim() : '') || '';
payload.quarter = row.quarter ?? 'Q1'; const rawFy = (row.financialYear != null ? String(row.financialYear).trim() : '') || '';
payload.financialYear = row.financialYear ?? ''; 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; payload.taxDeducted = row.taxDeducted ?? 0;
if (uploadLogId != null) payload.uploadLogId = uploadLogId; if (uploadLogId != null) payload.uploadLogId = uploadLogId;
return payload; return payload;
@ -1955,16 +2021,23 @@ export async function process26asUploadAggregation(uploadLogId: number): Promise
for (const [, { tanNumber, financialYear, quarter }] of keys) { for (const [, { tanNumber, financialYear, quarter }] of keys) {
const fy = normalizeFinancialYear(financialYear) || financialYear; const fy = normalizeFinancialYear(financialYear) || financialYear;
const q = normalizeQuarter(quarter) || quarter; const q = normalizeQuarter(quarter) || quarter;
const newTotal = await getLatest26asAggregatedForQuarter(tanNumber, fy, quarter); const newTotal = await getLatest26asAggregatedForQuarter(tanNumber, fy, q);
const latest = await getLatest26asSnapshot(tanNumber, fy, quarter); const latest = await getLatest26asSnapshot(tanNumber, fy, q);
const prevTotal = latest ? parseFloat(String((latest as any).aggregatedAmount ?? 0)) : 0; const prevTotal = latest ? parseFloat(String((latest as any).aggregatedAmount ?? 0)) : 0;
if (Math.abs(newTotal - prevTotal) < 0.01) continue; // duplicate 26AS: no change 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) { 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) { if (creditNote) {
const amount = parseFloat(String((creditNote as any).amount ?? 0)); 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 now = new Date();
const debit = await Form16DebitNote.create({ const debit = await Form16DebitNote.create({
creditNoteId: creditNote.id, creditNoteId: creditNote.id,
@ -1984,8 +2057,39 @@ export async function process26asUploadAggregation(uploadLogId: number): Promise
amount, amount,
debitNoteId: debit.id, debitNoteId: debit.id,
}); });
await setQuarterStatusDebitIssued(tanNumber, fy, quarter, debit.id); await setQuarterStatusDebitIssued(tanNumber, fy, q, debit.id);
debitsCreated++; 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, ' '); const normalized = (tanNumber || '').trim().replace(/\s+/g, ' ');

View File

@ -55,8 +55,8 @@ export async function getReUserIdsFor26As(): Promise<string[]> {
} }
/** /**
* Trigger notifications when 26AS data is uploaded: RE users get templateRe, dealers get templateDealers. * Trigger notifications when 26AS data is uploaded: sent only to RE users (admins / 26AS viewers / submission viewers).
* Called after successful 26AS upload (fire-and-forget or await in controller). * Dealers no longer receive this notification.
*/ */
export async function trigger26AsDataAddedNotification(): Promise<void> { export async function trigger26AsDataAddedNotification(): Promise<void> {
try { try {
@ -68,7 +68,6 @@ export async function trigger26AsDataAddedNotification(): Promise<void> {
} }
const { notificationService } = await import('./notification.service'); const { notificationService } = await import('./notification.service');
const reUserIds = await getReUserIdsFor26As(); const reUserIds = await getReUserIdsFor26As();
const dealerIds = await getDealerUserIds();
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) {
@ -79,14 +78,6 @@ export async function trigger26AsDataAddedNotification(): Promise<void> {
}); });
logger.info(`[Form16Notification] 26AS notification sent to ${reUserIds.length} RE user(s)`); 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) { } catch (e) {
logger.error('[Form16Notification] trigger26AsDataAddedNotification failed:', 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). * Notify dealer when RE marks submission as resubmission needed or cancels (unsuccessful outcome).
*/ */

View File

@ -319,7 +319,7 @@ class NotificationService {
const emailType = emailTypeMap[payload.type || '']; 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 (payload.type && payload.type.startsWith('form16_') && user?.email) {
if (user.emailNotificationsEnabled === false) { if (user.emailNotificationsEnabled === false) {
logger.info(`[Email] Form 16 email skipped for user ${userId} (email notifications disabled)`); logger.info(`[Email] Form 16 email skipped for user ${userId} (email notifications disabled)`);
@ -327,12 +327,37 @@ class NotificationService {
} }
try { try {
const { emailService } = await import('./email.service'); const { emailService } = await import('./email.service');
const { getForm16Email, CompanyInfo } = await import('../emailtemplates');
const escaped = (payload.body || '') const escaped = (payload.body || '')
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
.replace(/\n/g, '<br/>'); .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({ await emailService.sendEmail({
to: user.email, to: user.email,
subject: payload.title || 'Form 16 Notification', subject: payload.title || 'Form 16 Notification',

View File

@ -2,9 +2,17 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import logger from '../utils/logger'; 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 * 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 { export class WFMFileService {
private basePath: string; private basePath: string;
@ -12,6 +20,10 @@ export class WFMFileService {
private incomingNonGstClaimsPath: string; private incomingNonGstClaimsPath: string;
private outgoingGstClaimsPath: string; private outgoingGstClaimsPath: string;
private outgoingNonGstClaimsPath: string; private outgoingNonGstClaimsPath: string;
/** Form 16: INCOMING/WFM_MAIN/FORM_16 */
private form16IncomingPath: string;
/** Form 16: OUTGOING/WFM_SAP_MAIN/FORM_16 */
private form16OutgoingPath: string;
constructor() { constructor() {
this.basePath = process.env.WFM_BASE_PATH || 'C:\\WFM'; this.basePath = process.env.WFM_BASE_PATH || 'C:\\WFM';
@ -19,6 +31,8 @@ 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;
this.form16OutgoingPath = process.env.WFM_FORM16_OUTGOING_PATH || DEFAULT_FORM16_OUTGOING;
} }
/** /**
@ -39,7 +53,7 @@ export class WFMFileService {
async generateIncomingClaimCSV(data: any[], fileName: string, isNonGst: boolean = false): Promise<string> { async generateIncomingClaimCSV(data: any[], fileName: string, isNonGst: boolean = false): Promise<string> {
const maxRetries = 3; const maxRetries = 3;
let retryCount = 0; let retryCount = 0;
while (retryCount <= maxRetries) { while (retryCount <= maxRetries) {
try { try {
const targetPath = isNonGst ? this.incomingNonGstClaimsPath : this.incomingGstClaimsPath; const targetPath = isNonGst ? this.incomingNonGstClaimsPath : this.incomingGstClaimsPath;
@ -47,7 +61,7 @@ export class WFMFileService {
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`);
// Simple CSV generation logic with pipe separator and no quotes // Simple CSV generation logic with pipe separator and no quotes
const headers = Object.keys(data[0] || {}).join('|'); 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 rows = data.map(item => Object.values(item).map(val => val === null || val === undefined ? '' : String(val)).join('|')).join('\n');
@ -55,7 +69,7 @@ export class WFMFileService {
fs.writeFileSync(filePath, csvContent); fs.writeFileSync(filePath, csvContent);
logger.info(`[WFMFileService] Generated CSV at: ${filePath}`); logger.info(`[WFMFileService] Generated CSV at: ${filePath}`);
return filePath; return filePath;
} catch (error: any) { } catch (error: any) {
if (error.code === 'EBUSY' && retryCount < maxRetries) { if (error.code === 'EBUSY' && retryCount < maxRetries) {
@ -65,11 +79,11 @@ export class WFMFileService {
await new Promise(resolve => setTimeout(resolve, delay)); await new Promise(resolve => setTimeout(resolve, delay));
continue; continue;
} }
if (error.code === 'EBUSY') { if (error.code === 'EBUSY') {
throw new Error(`File is locked or open in another program (e.g., Excel). Please close '${fileName}' and try again.`); throw new Error(`File is locked or open in another program (e.g., Excel). Please close '${fileName}' and try again.`);
} }
logger.error('[WFMFileService] Error generating incoming claim CSV:', error); logger.error('[WFMFileService] Error generating incoming claim CSV:', error);
throw error; throw error;
} }
@ -117,6 +131,63 @@ export class WFMFileService {
return []; 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);
}
} }
export const wfmFileService = new WFMFileService(); export const wfmFileService = new WFMFileService();