Updated email templates and csv
This commit is contained in:
parent
e4d45b4fca
commit
eb3db7cd3a
@ -106,3 +106,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
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
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')`,
|
||||
`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 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',
|
||||
|
||||
@ -2,19 +2,33 @@ 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;
|
||||
/** 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.basePath = process.env.WFM_BASE_PATH ?? (process.platform === 'win32' ? 'C:\\WFM' : path.join(process.cwd(), 'wfm'));
|
||||
this.incomingClaimsPath = process.env.WFM_INCOMING_CLAIMS_PATH || DEFAULT_CLAIMS_INCOMING;
|
||||
this.outgoingClaimsPath = process.env.WFM_OUTGOING_CLAIMS_PATH || DEFAULT_CLAIMS_OUTGOING;
|
||||
this.form16IncomingPath = process.env.WFM_FORM16_INCOMING_PATH || DEFAULT_FORM16_INCOMING;
|
||||
this.form16OutgoingPath = process.env.WFM_FORM16_OUTGOING_PATH || DEFAULT_FORM16_OUTGOING;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -78,6 +92,63 @@ export class WFMFileService {
|
||||
getOutgoingPath(fileName: string): string {
|
||||
return path.join(this.basePath, this.outgoingClaimsPath, fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user