Compare commits
3 Commits
f0435c47e4
...
975f266640
| Author | SHA1 | Date | |
|---|---|---|---|
| 975f266640 | |||
|
|
0aec45f7aa | ||
|
|
bae0b8017e |
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
|||||||
import{a as s}from"./index-CwFNZe2z.js";import"./radix-vendor-CYvDqP9X.js";import"./charts-vendor-BVfwAPj-.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-BrA5VgBk.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-BATWUvr6.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};
|
import{a as s}from"./index-BiBh7ROe.js";import"./radix-vendor-CLtqm-Ae.js";import"./charts-vendor-CmYZJIYl.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-DgwXkk2Y.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-HW_ujxKo.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};
|
||||||
64
build/assets/index-BiBh7ROe.js
Normal file
64
build/assets/index-BiBh7ROe.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
build/assets/index-ZIAcQZA4.css
Normal file
1
build/assets/index-ZIAcQZA4.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
build/assets/ui-vendor-DgwXkk2Y.js
Normal file
2
build/assets/ui-vendor-DgwXkk2Y.js
Normal file
File diff suppressed because one or more lines are too long
@ -13,15 +13,15 @@
|
|||||||
<!-- Preload essential fonts and icons -->
|
<!-- Preload essential fonts and icons -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<script type="module" crossorigin src="/assets/index-CwFNZe2z.js"></script>
|
<script type="module" crossorigin src="/assets/index-BiBh7ROe.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-BVfwAPj-.js">
|
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-CmYZJIYl.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CYvDqP9X.js">
|
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CLtqm-Ae.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js">
|
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-BrA5VgBk.js">
|
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-DgwXkk2Y.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
|
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
|
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/router-vendor-BATWUvr6.js">
|
<link rel="modulepreload" crossorigin href="/assets/router-vendor-HW_ujxKo.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-C9eBMrZm.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-ZIAcQZA4.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -556,12 +556,12 @@ const DEFAULT_FORM16_CONFIG = {
|
|||||||
alertSubmitForm16FrequencyDays: 0,
|
alertSubmitForm16FrequencyDays: 0,
|
||||||
alertSubmitForm16FrequencyHours: 24,
|
alertSubmitForm16FrequencyHours: 24,
|
||||||
alertSubmitForm16RunAtTime: '09:00',
|
alertSubmitForm16RunAtTime: '09:00',
|
||||||
alertSubmitForm16Template: 'Please submit your Form 16 at your earliest. [Name], due date: [DueDate].',
|
alertSubmitForm16Template: 'Dear [Name], please submit Form 16A for the pending period. Due: [DueDate].',
|
||||||
reminderNotificationEnabled: true,
|
reminderNotificationEnabled: true,
|
||||||
reminderFrequencyDays: 0,
|
reminderFrequencyDays: 0,
|
||||||
reminderFrequencyHours: 12,
|
reminderFrequencyHours: 12,
|
||||||
reminderRunAtTime: '10:00',
|
reminderRunAtTime: '10:00',
|
||||||
reminderNotificationTemplate: 'Reminder: Form 16 submission is pending. [Name], [Request ID]. Please review.',
|
reminderNotificationTemplate: 'Reminder: Dear [Name], your Form 16A submission is pending for request [Request ID]. Please complete it.',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -16,12 +16,32 @@ import { ResponseHandler } from '../utils/responseHandler';
|
|||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
import { WorkflowRequest } from '@models/WorkflowRequest';
|
import { WorkflowRequest } from '@models/WorkflowRequest';
|
||||||
import { Form16aSubmission } from '@models/Form16aSubmission';
|
import { Form16aSubmission } from '@models/Form16aSubmission';
|
||||||
|
import { Dealer } from '@models/Dealer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Form 16 controller: credit notes, OCR extract, and create submission for dealers.
|
* Form 16 controller: credit notes, OCR extract, and create submission for dealers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class Form16Controller {
|
export class Form16Controller {
|
||||||
|
private toSapCsv(sap: {
|
||||||
|
trnsUniqNo?: string | null;
|
||||||
|
tdsTransId?: string | null;
|
||||||
|
sapDocumentNumber?: string | null;
|
||||||
|
msgTyp?: string | null;
|
||||||
|
message?: string | null;
|
||||||
|
}): string {
|
||||||
|
const header = ['TRNS_UNIQ_NO', 'TDS_TRNS_ID', 'DOC_NO', 'MSG_TYP', 'MESSAGE'].join('|');
|
||||||
|
const row = [
|
||||||
|
sap.trnsUniqNo || '',
|
||||||
|
sap.tdsTransId || '',
|
||||||
|
sap.sapDocumentNumber || '',
|
||||||
|
sap.msgTyp || '',
|
||||||
|
sap.message || '',
|
||||||
|
]
|
||||||
|
.map((v) => String(v).replace(/\r?\n/g, ' ').replace(/\|/g, ' '))
|
||||||
|
.join('|');
|
||||||
|
return `${header}\n${row}\n`;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/form16/permissions
|
* GET /api/v1/form16/permissions
|
||||||
* Returns Form 16 permissions for the current user (API-driven from admin config).
|
* Returns Form 16 permissions for the current user (API-driven from admin config).
|
||||||
@ -174,12 +194,31 @@ export class Form16Controller {
|
|||||||
if (!userId) {
|
if (!userId) {
|
||||||
return ResponseHandler.unauthorized(res, 'Authentication required');
|
return ResponseHandler.unauthorized(res, 'Authentication required');
|
||||||
}
|
}
|
||||||
const body = (req.body || {}) as { dealerCode?: string; financialYear?: string };
|
const body = (req.body || {}) as { dealerCode?: string; dealerId?: string; email?: string; financialYear?: string };
|
||||||
const dealerCode = (body.dealerCode || '').trim();
|
const financialYear = (body.financialYear || '').trim() || undefined;
|
||||||
|
let dealerCode = (body.dealerCode || '').trim();
|
||||||
|
const dealerId = (body.dealerId || '').trim();
|
||||||
|
const dealerEmail = (body.email || '').trim().toLowerCase();
|
||||||
|
|
||||||
|
// Fallback 1: resolve by Dealer PK (when FE sends id but dealerCode is empty).
|
||||||
|
if (!dealerCode && dealerId) {
|
||||||
|
const dealer = await Dealer.findByPk(dealerId, { attributes: ['salesCode', 'dlrcode'] });
|
||||||
|
dealerCode = String((dealer as any)?.salesCode || (dealer as any)?.dlrcode || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback 2: resolve from non-submitted list (supports id/email based payloads reliably).
|
||||||
|
if (!dealerCode) {
|
||||||
|
const list = await form16Service.listNonSubmittedDealers(financialYear);
|
||||||
|
const match = list.dealers.find((d) =>
|
||||||
|
(dealerId && String(d.id).trim() === dealerId) ||
|
||||||
|
(dealerEmail && String(d.email || '').trim().toLowerCase() === dealerEmail)
|
||||||
|
);
|
||||||
|
dealerCode = String(match?.dealerCode || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
if (!dealerCode) {
|
if (!dealerCode) {
|
||||||
return ResponseHandler.error(res, 'dealerCode is required', 400);
|
return ResponseHandler.error(res, 'dealerCode is required', 400);
|
||||||
}
|
}
|
||||||
const financialYear = (body.financialYear || '').trim() || undefined;
|
|
||||||
const updated = await form16Service.recordNonSubmittedDealerNotification(dealerCode, financialYear || '', userId);
|
const updated = await form16Service.recordNonSubmittedDealerNotification(dealerCode, financialYear || '', userId);
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
return ResponseHandler.error(res, 'Dealer not found in non-submitted list for this financial year', 404);
|
return ResponseHandler.error(res, 'Dealer not found in non-submitted list for this financial year', 404);
|
||||||
@ -245,6 +284,7 @@ export class Form16Controller {
|
|||||||
}
|
}
|
||||||
const taxDeducted = typeof body.taxDeducted === 'number' ? body.taxDeducted : parseFloat(String(body.taxDeducted ?? 0));
|
const taxDeducted = typeof body.taxDeducted === 'number' ? body.taxDeducted : parseFloat(String(body.taxDeducted ?? 0));
|
||||||
const entry = await form16Service.create26asEntry({
|
const entry = await form16Service.create26asEntry({
|
||||||
|
panNumber: (body.panNumber as string) || undefined,
|
||||||
tanNumber,
|
tanNumber,
|
||||||
deductorName: (body.deductorName as string) || undefined,
|
deductorName: (body.deductorName as string) || undefined,
|
||||||
quarter,
|
quarter,
|
||||||
@ -281,6 +321,7 @@ export class Form16Controller {
|
|||||||
const body = req.body as Record<string, unknown>;
|
const body = req.body as Record<string, unknown>;
|
||||||
const updateData: Record<string, unknown> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
if (body.tanNumber !== undefined) updateData.tanNumber = body.tanNumber;
|
if (body.tanNumber !== undefined) updateData.tanNumber = body.tanNumber;
|
||||||
|
if (body.panNumber !== undefined) updateData.panNumber = body.panNumber;
|
||||||
if (body.deductorName !== undefined) updateData.deductorName = body.deductorName;
|
if (body.deductorName !== undefined) updateData.deductorName = body.deductorName;
|
||||||
if (body.quarter !== undefined) updateData.quarter = body.quarter;
|
if (body.quarter !== undefined) updateData.quarter = body.quarter;
|
||||||
if (body.assessmentYear !== undefined) updateData.assessmentYear = body.assessmentYear;
|
if (body.assessmentYear !== undefined) updateData.assessmentYear = body.assessmentYear;
|
||||||
@ -382,7 +423,8 @@ export class Form16Controller {
|
|||||||
res,
|
res,
|
||||||
{
|
{
|
||||||
sapResponse,
|
sapResponse,
|
||||||
url: sapResponse.storageUrl || null,
|
// Use API-backed CSV URL so View works even when local /uploads file is unavailable in UAT.
|
||||||
|
url: `/api/v1/form16/credit-notes/${id}/sap-response/csv`,
|
||||||
},
|
},
|
||||||
'OK'
|
'OK'
|
||||||
);
|
);
|
||||||
@ -395,8 +437,8 @@ export class Form16Controller {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/form16/credit-notes/:id/download
|
* GET /api/v1/form16/credit-notes/:id/download
|
||||||
* Returns a storage URL for the SAP response CSV if available.
|
* Backward-compatible route that now always returns API-backed CSV URL.
|
||||||
* If not yet available, returns 409 so UI can show "being generated, wait".
|
* The CSV itself is generated from persisted DB fields (no /uploads dependency).
|
||||||
*/
|
*/
|
||||||
async downloadCreditNote(req: Request, res: Response): Promise<void> {
|
async downloadCreditNote(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@ -408,9 +450,9 @@ export class Form16Controller {
|
|||||||
if (Number.isNaN(id)) {
|
if (Number.isNaN(id)) {
|
||||||
return ResponseHandler.error(res, 'Invalid credit note id', 400);
|
return ResponseHandler.error(res, 'Invalid credit note id', 400);
|
||||||
}
|
}
|
||||||
let url: string | null = null;
|
let sapResponse = null;
|
||||||
try {
|
try {
|
||||||
url = await form16Service.getCreditNoteSapResponseUrlForUser(id, userId);
|
sapResponse = await form16Service.getCreditNoteSapResponseForUser(id, userId);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = String(e?.message || '');
|
const msg = String(e?.message || '');
|
||||||
if (msg.toLowerCase().includes('not found')) {
|
if (msg.toLowerCase().includes('not found')) {
|
||||||
@ -418,10 +460,10 @@ export class Form16Controller {
|
|||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
if (!url) {
|
if (!sapResponse) {
|
||||||
return ResponseHandler.error(res, 'The credit note is being generated. Please wait.', 409);
|
return ResponseHandler.error(res, 'The credit note is being generated. Please wait.', 409);
|
||||||
}
|
}
|
||||||
return ResponseHandler.success(res, { url }, 'OK');
|
return ResponseHandler.success(res, { url: `/api/v1/form16/credit-notes/${id}/sap-response/csv` }, 'OK');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
logger.error('[Form16Controller] downloadCreditNote error:', error);
|
logger.error('[Form16Controller] downloadCreditNote error:', error);
|
||||||
@ -448,7 +490,8 @@ export class Form16Controller {
|
|||||||
res,
|
res,
|
||||||
{
|
{
|
||||||
sapResponse,
|
sapResponse,
|
||||||
url: sapResponse.storageUrl || null,
|
// Use API-backed CSV URL so View works even when local /uploads file is unavailable in UAT.
|
||||||
|
url: `/api/v1/form16/debit-notes/${id}/sap-response/csv`,
|
||||||
},
|
},
|
||||||
'OK'
|
'OK'
|
||||||
);
|
);
|
||||||
@ -459,6 +502,64 @@ export class Form16Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/form16/credit-notes/:id/sap-response/csv
|
||||||
|
* Stream SAP response CSV generated from persisted DB fields.
|
||||||
|
*/
|
||||||
|
async downloadCreditNoteSapResponseCsv(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = (req as AuthenticatedRequest).user?.userId;
|
||||||
|
if (!userId) {
|
||||||
|
return ResponseHandler.unauthorized(res, 'Authentication required');
|
||||||
|
}
|
||||||
|
const id = parseInt((req.params as { id: string }).id, 10);
|
||||||
|
if (Number.isNaN(id)) {
|
||||||
|
return ResponseHandler.error(res, 'Invalid credit note id', 400);
|
||||||
|
}
|
||||||
|
const sapResponse = await form16Service.getCreditNoteSapResponseForUser(id, userId);
|
||||||
|
if (!sapResponse) {
|
||||||
|
return ResponseHandler.error(res, 'The credit note is being generated. Please wait.', 409);
|
||||||
|
}
|
||||||
|
const csv = this.toSapCsv(sapResponse);
|
||||||
|
const fileName = (sapResponse.fileName || `credit-note-${id}.csv`).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||||
|
res.status(200).send(csv);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('[Form16Controller] downloadCreditNoteSapResponseCsv error:', error);
|
||||||
|
return ResponseHandler.error(res, 'Failed to fetch credit note SAP response CSV', 500, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/form16/debit-notes/:id/sap-response/csv
|
||||||
|
* Stream SAP response CSV generated from persisted DB fields.
|
||||||
|
*/
|
||||||
|
async downloadDebitNoteSapResponseCsv(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const id = parseInt((req.params as { id: string }).id, 10);
|
||||||
|
if (Number.isNaN(id)) {
|
||||||
|
return ResponseHandler.error(res, 'Invalid debit note id', 400);
|
||||||
|
}
|
||||||
|
const sapResponse = await form16Service.getDebitNoteSapResponse(id);
|
||||||
|
if (!sapResponse) {
|
||||||
|
return ResponseHandler.error(res, 'The debit note is being generated. Please wait.', 409);
|
||||||
|
}
|
||||||
|
const csv = this.toSapCsv(sapResponse);
|
||||||
|
const fileName = (sapResponse.fileName || `debit-note-${id}.csv`).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||||
|
res.status(200).send(csv);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('[Form16Controller] downloadDebitNoteSapResponseCsv error:', error);
|
||||||
|
return ResponseHandler.error(res, 'Failed to fetch debit note SAP response CSV', 500, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/form16/requests/:requestId/credit-note
|
* GET /api/v1/form16/requests/:requestId/credit-note
|
||||||
* Get credit note (if any) linked to a Form 16 request. Used on Form 16 details workflow tab.
|
* Get credit note (if any) linked to a Form 16 request. Used on Form 16 details workflow tab.
|
||||||
@ -750,7 +851,8 @@ export class Form16Controller {
|
|||||||
deductorName,
|
deductorName,
|
||||||
version,
|
version,
|
||||||
ocrExtractedData,
|
ocrExtractedData,
|
||||||
}
|
},
|
||||||
|
(req as AuthenticatedRequest).user?.email
|
||||||
);
|
);
|
||||||
|
|
||||||
const { triggerForm16SubmissionResultNotification } = await import('../services/form16Notification.service');
|
const { triggerForm16SubmissionResultNotification } = await import('../services/form16Notification.service');
|
||||||
|
|||||||
@ -6,322 +6,97 @@ import {
|
|||||||
Form16CreditNote,
|
Form16CreditNote,
|
||||||
Form16DebitNote,
|
Form16DebitNote,
|
||||||
Form16SapResponse,
|
Form16SapResponse,
|
||||||
Form16DebitNoteSapResponse,
|
From16SapReadFile,
|
||||||
Form16aSubmission,
|
|
||||||
WorkflowRequest,
|
|
||||||
} from '../models';
|
} from '../models';
|
||||||
import { gcsStorageService } from '../services/gcsStorage.service';
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
type CsvRow = Record<string, string | undefined>;
|
||||||
|
|
||||||
function safeFileName(name: string): string {
|
function extractCsvFields(r: CsvRow) {
|
||||||
return (name || '').trim().replace(/[\\\/:*?"<>|]+/g, '-').slice(0, 180) || 'form16-sap-response.csv';
|
const trnsUniqNo = (r.TRNS_UNIQ_NO || r.TRNSUNIQNO || '').trim() || null;
|
||||||
|
const tdsTransId = (r.TDS_TRNS_ID || '').trim() || null;
|
||||||
|
const docNo = (r.DOC_NO || r.DOCNO || '').trim() || null;
|
||||||
|
const msgTyp = (r.MSG_TYP || r.MSGTYP || '').trim() || null;
|
||||||
|
const message = (r.MESSAGE || '').trim() || null;
|
||||||
|
return { trnsUniqNo, tdsTransId, docNo, msgTyp, message };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Columns we store in dedicated DB fields. Everything else goes into raw_row. */
|
function isUsableRow(r: CsvRow): boolean {
|
||||||
const KNOWN_CSV_COLUMNS = new Set([
|
const { tdsTransId } = extractCsvFields(r);
|
||||||
'TRNS_UNIQ_NO', 'TRNSUNIQNO', 'DMS_UNIQ_NO', 'DMSUNIQNO',
|
if (!tdsTransId) return false;
|
||||||
'TDS_TRNS_ID',
|
const upper = tdsTransId.toUpperCase();
|
||||||
'CLAIM_NUMBER',
|
if (upper === 'TDS_TRNS_ID' || upper === 'MSG_TYP' || upper === 'MESSAGE') return false;
|
||||||
'DOC_NO', 'DOCNO', 'SAP_DOC_NO', 'SAPDOC',
|
return true;
|
||||||
'MSG_TYP', 'MSGTYP', 'MSG_TYPE',
|
|
||||||
'MESSAGE', 'MSG',
|
|
||||||
'DOC_DATE', 'DOCDATE',
|
|
||||||
'TDS_AMT', 'TDSAMT',
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse all columns from one CSV data row.
|
|
||||||
* Returns { known fields } + rawRow (only the columns NOT in KNOWN_CSV_COLUMNS).
|
|
||||||
*/
|
|
||||||
function extractCsvFields(r: Record<string, string | undefined>) {
|
|
||||||
const trnsUniqNo = (r.TRNS_UNIQ_NO || r.TRNSUNIQNO || r.DMS_UNIQ_NO || r.DMSUNIQNO || '').trim() || null;
|
|
||||||
const tdsTransId = (r.TDS_TRNS_ID || '').trim() || null;
|
|
||||||
const claimNumber = (r.CLAIM_NUMBER || '').trim() || null;
|
|
||||||
const sapDocNo = (r.DOC_NO || r.DOCNO || r.SAP_DOC_NO || r.SAPDOC || '').trim() || null;
|
|
||||||
const msgTyp = (r.MSG_TYP || r.MSGTYP || r.MSG_TYPE || '').trim() || null;
|
|
||||||
const message = (r.MESSAGE || r.MSG || '').trim() || null;
|
|
||||||
const docDate = (r.DOC_DATE || r.DOCDATE || '').trim() || null;
|
|
||||||
const tdsAmt = (r.TDS_AMT || r.TDSAMT || '').trim() || null;
|
|
||||||
|
|
||||||
// Extra columns → raw_row (so nothing is ever lost)
|
|
||||||
const rawRow: Record<string, string> = {};
|
|
||||||
for (const [key, val] of Object.entries(r)) {
|
|
||||||
if (!KNOWN_CSV_COLUMNS.has(key.trim().toUpperCase()) && !KNOWN_CSV_COLUMNS.has(key.trim())) {
|
|
||||||
rawRow[key.trim()] = val || '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { trnsUniqNo, tdsTransId, claimNumber, sapDocNo, msgTyp, message, docDate, tdsAmt, rawRow };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Credit note matching ─────────────────────────────────────────────────────
|
async function saveRowsAndUpdateNotes(rows: CsvRow[]): Promise<{ totalRecords: number; totalCreditNotes: number; totalDebitNotes: number }> {
|
||||||
|
let totalRecords = 0;
|
||||||
|
let totalCreditNotes = 0;
|
||||||
|
let totalDebitNotes = 0;
|
||||||
|
|
||||||
async function findCreditNoteId(
|
for (const row of rows) {
|
||||||
trnsUniqNo: string | null,
|
if (!isUsableRow(row)) continue;
|
||||||
tdsTransId: string | null,
|
const parsed = extractCsvFields(row);
|
||||||
claimNumber: string | null,
|
if (!parsed.tdsTransId) continue;
|
||||||
fileName: string,
|
|
||||||
): Promise<{ creditNoteId: number | null; requestId: string | null }> {
|
|
||||||
const CN = Form16CreditNote as any;
|
|
||||||
let cn: any = null;
|
|
||||||
|
|
||||||
// 1. Primary: TDS_TRNS_ID in SAP response = credit note number we sent
|
await (Form16SapResponse as any).create({
|
||||||
if (tdsTransId) {
|
trnsUniqNo: parsed.trnsUniqNo,
|
||||||
cn = await CN.findOne({ where: { creditNoteNumber: tdsTransId }, attributes: ['id', 'submissionId'] });
|
tdsTransId: parsed.tdsTransId,
|
||||||
if (cn) logger.info(`[Form16 SAP Job] Credit match via TDS_TRNS_ID=${tdsTransId} → credit_note id=${cn.id}`);
|
docNo: parsed.docNo,
|
||||||
}
|
msgTyp: parsed.msgTyp,
|
||||||
|
message: parsed.message,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
totalRecords++;
|
||||||
|
|
||||||
// 2. TRNS_UNIQ_NO (format: F16-CN-{submissionId}-{creditNoteId}-{ts})
|
const idUpper = parsed.tdsTransId.toUpperCase();
|
||||||
if (!cn && trnsUniqNo) {
|
if (idUpper.startsWith('CN')) {
|
||||||
const m = trnsUniqNo.match(/^F16-CN-(\d+)-(\d+)-/);
|
totalCreditNotes++;
|
||||||
if (m) {
|
|
||||||
cn = await CN.findByPk(parseInt(m[2]), { attributes: ['id', 'submissionId'] });
|
|
||||||
if (cn) logger.info(`[Form16 SAP Job] Credit match via TRNS_UNIQ_NO id-parse=${m[2]} → credit_note id=${cn.id}`);
|
|
||||||
}
|
|
||||||
if (!cn) {
|
|
||||||
cn = await CN.findOne({ where: { trnsUniqNo }, attributes: ['id', 'submissionId'] });
|
|
||||||
if (cn) logger.info(`[Form16 SAP Job] Credit match via trns_uniq_no=${trnsUniqNo} → credit_note id=${cn.id}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Filename (without .csv) = credit note number
|
|
||||||
if (!cn) {
|
|
||||||
const baseName = fileName.replace(/\.csv$/i, '').trim();
|
|
||||||
if (baseName) {
|
|
||||||
cn = await CN.findOne({ where: { creditNoteNumber: baseName }, attributes: ['id', 'submissionId'] });
|
|
||||||
if (cn) logger.info(`[Form16 SAP Job] Credit match via filename=${baseName} → credit_note id=${cn.id}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. CLAIM_NUMBER = credit note number (seen in some SAP/WFM exports)
|
|
||||||
if (!cn && claimNumber) {
|
|
||||||
cn = await CN.findOne({ where: { creditNoteNumber: claimNumber }, attributes: ['id', 'submissionId'] });
|
|
||||||
if (cn) logger.info(`[Form16 SAP Job] Credit match via CLAIM_NUMBER=${claimNumber} → credit_note id=${cn.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cn) return { creditNoteId: null, requestId: null };
|
|
||||||
|
|
||||||
const submission = await (Form16aSubmission as any).findByPk(cn.submissionId, { attributes: ['requestId'] });
|
|
||||||
return { creditNoteId: cn.id, requestId: submission?.requestId ?? null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Debit note matching ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function findDebitNoteId(
|
|
||||||
trnsUniqNo: string | null,
|
|
||||||
tdsTransId: string | null,
|
|
||||||
claimNumber: string | null,
|
|
||||||
fileName: string,
|
|
||||||
): Promise<number | null> {
|
|
||||||
const DN = Form16DebitNote as any;
|
|
||||||
const CN = Form16CreditNote as any;
|
|
||||||
let dn: any = null;
|
|
||||||
|
|
||||||
// 1. Primary: TRNS_UNIQ_NO (format: F16-DN-{creditNoteId}-{debitNoteId}-{ts})
|
|
||||||
if (trnsUniqNo) {
|
|
||||||
const m = trnsUniqNo.match(/^F16-DN-(\d+)-(\d+)-/);
|
|
||||||
if (m) {
|
|
||||||
dn = await DN.findByPk(parseInt(m[2]), { attributes: ['id'] });
|
|
||||||
if (dn) logger.info(`[Form16 SAP Job] Debit match via TRNS_UNIQ_NO id-parse=${m[2]} → debit_note id=${dn.id}`);
|
|
||||||
}
|
|
||||||
if (!dn) {
|
|
||||||
dn = await DN.findOne({ where: { trnsUniqNo }, attributes: ['id'] });
|
|
||||||
if (dn) logger.info(`[Form16 SAP Job] Debit match via trns_uniq_no=${trnsUniqNo} → debit_note id=${dn.id}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. TDS_TRNS_ID = credit note number → find linked debit note
|
|
||||||
if (!dn && tdsTransId) {
|
|
||||||
const cn = await CN.findOne({ where: { creditNoteNumber: tdsTransId }, attributes: ['id'] });
|
|
||||||
if (cn) {
|
|
||||||
dn = await DN.findOne({
|
|
||||||
where: { creditNoteId: cn.id },
|
|
||||||
order: [['createdAt', 'DESC']],
|
|
||||||
attributes: ['id'],
|
|
||||||
});
|
|
||||||
if (dn) logger.info(`[Form16 SAP Job] Debit match via TDS_TRNS_ID=${tdsTransId} → credit_note id=${cn.id} → debit_note id=${dn.id}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. CLAIM_NUMBER = debit note number
|
|
||||||
if (!dn && claimNumber) {
|
|
||||||
dn = await DN.findOne({ where: { debitNoteNumber: claimNumber }, attributes: ['id'] });
|
|
||||||
if (dn) logger.info(`[Form16 SAP Job] Debit match via CLAIM_NUMBER=${claimNumber} → debit_note id=${dn.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Filename (without .csv) = debit note number
|
|
||||||
if (!dn) {
|
|
||||||
const baseName = fileName.replace(/\.csv$/i, '').trim();
|
|
||||||
if (baseName) {
|
|
||||||
dn = await DN.findOne({ where: { debitNoteNumber: baseName }, attributes: ['id'] });
|
|
||||||
if (dn) logger.info(`[Form16 SAP Job] Debit match via filename=${baseName} → debit_note id=${dn.id}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dn ? dn.id : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Core processor ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function processOutgoingFile(
|
|
||||||
fileName: string,
|
|
||||||
type: 'credit' | 'debit',
|
|
||||||
resolvedOutgoingDir: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const CreditModel = Form16SapResponse as any;
|
|
||||||
const DebitModel = Form16DebitNoteSapResponse as any;
|
|
||||||
|
|
||||||
// Idempotency: skip if already fully linked
|
|
||||||
const existing =
|
|
||||||
type === 'credit'
|
|
||||||
? await CreditModel.findOne({ where: { fileName }, attributes: ['id', 'creditNoteId', 'sapDocumentNumber', 'storageUrl'] })
|
|
||||||
: await DebitModel.findOne({ where: { fileName }, attributes: ['id', 'debitNoteId', 'sapDocumentNumber', 'storageUrl'] });
|
|
||||||
|
|
||||||
if (existing && (existing.creditNoteId ?? existing.debitNoteId) && (existing.storageUrl || existing.sapDocumentNumber)) {
|
|
||||||
logger.debug(`[Form16 SAP Job] Skipping already-processed ${type} file: ${fileName}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Read CSV ──
|
|
||||||
const rows = await wfmFileService.readForm16OutgoingResponseByPath(path.join(resolvedOutgoingDir, fileName));
|
|
||||||
if (!rows || rows.length === 0) {
|
|
||||||
logger.warn(`[Form16 SAP Job] ${type} file ${fileName}: empty or unreadable CSV`);
|
|
||||||
const emptyPayload = { rawRow: null, updatedAt: new Date() };
|
|
||||||
if (existing) {
|
|
||||||
type === 'credit' ? await CreditModel.update(emptyPayload, { where: { id: existing.id } })
|
|
||||||
: await DebitModel.update(emptyPayload, { where: { id: existing.id } });
|
|
||||||
} else {
|
|
||||||
type === 'credit' ? await CreditModel.create({ type, fileName, ...emptyPayload, createdAt: new Date() })
|
|
||||||
: await DebitModel.create({ fileName, ...emptyPayload, createdAt: new Date() });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Pick the best data row ──
|
|
||||||
// Skip the degenerate "|MSG_TYP|MESSAGE|" lines that some SAP exports include after the header.
|
|
||||||
type CsvRow = Record<string, string | undefined>;
|
|
||||||
const normalizedRows = rows as CsvRow[];
|
|
||||||
const pick =
|
|
||||||
normalizedRows.find((row) => {
|
|
||||||
const trns = (row.TRNS_UNIQ_NO || row.TRNSUNIQNO || row.DMS_UNIQ_NO || '').trim();
|
|
||||||
return Boolean(trns);
|
|
||||||
}) ||
|
|
||||||
normalizedRows.find((row) => {
|
|
||||||
const tdsId = (row.TDS_TRNS_ID || '').trim();
|
|
||||||
const docNo = (row.DOC_NO || row.DOCNO || '').trim();
|
|
||||||
const msgTyp = (row.MSG_TYP || '').trim();
|
|
||||||
if (!tdsId) return false;
|
|
||||||
if (!docNo && !msgTyp) return false;
|
|
||||||
if (['MSG_TYP', 'MESSAGE', 'TDS_TRNS_ID'].includes(tdsId.toUpperCase())) return false;
|
|
||||||
return true;
|
|
||||||
}) ||
|
|
||||||
normalizedRows[0];
|
|
||||||
|
|
||||||
const r = pick as CsvRow;
|
|
||||||
const { trnsUniqNo, tdsTransId, claimNumber, sapDocNo, msgTyp, message, docDate, tdsAmt, rawRow } = extractCsvFields(r);
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`[Form16 SAP Job] Processing ${type} file ${fileName}: TRNS_UNIQ_NO=${trnsUniqNo ?? '—'}, TDS_TRNS_ID=${tdsTransId ?? '—'}, CLAIM_NUMBER=${claimNumber ?? '—'}, DOC_NO=${sapDocNo ?? '—'}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Match to a note in DB ──
|
|
||||||
let creditNoteId: number | null = null;
|
|
||||||
let debitNoteId: number | null = null;
|
|
||||||
let requestId: string | null = null;
|
|
||||||
let requestNumber: string | null = null;
|
|
||||||
|
|
||||||
if (type === 'credit') {
|
|
||||||
const res = await findCreditNoteId(trnsUniqNo, tdsTransId, claimNumber, fileName);
|
|
||||||
creditNoteId = res.creditNoteId;
|
|
||||||
requestId = res.requestId;
|
|
||||||
if (creditNoteId && sapDocNo) {
|
|
||||||
await (Form16CreditNote as any).update(
|
await (Form16CreditNote as any).update(
|
||||||
{ sapDocumentNumber: sapDocNo, status: 'completed' },
|
{
|
||||||
{ where: { id: creditNoteId } }
|
sapDocumentNumber: parsed.docNo,
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
{ where: { creditNoteNumber: parsed.tdsTransId } }
|
||||||
);
|
);
|
||||||
}
|
} else if (idUpper.startsWith('DN')) {
|
||||||
if (!creditNoteId) {
|
totalDebitNotes++;
|
||||||
logger.warn(
|
|
||||||
`[Form16 SAP Job] Credit file ${fileName}: no matching credit note. TDS_TRNS_ID=${tdsTransId ?? '—'}, TRNS_UNIQ_NO=${trnsUniqNo ?? '—'}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
debitNoteId = await findDebitNoteId(trnsUniqNo, tdsTransId, claimNumber, fileName);
|
|
||||||
if (debitNoteId && sapDocNo) {
|
|
||||||
await (Form16DebitNote as any).update(
|
await (Form16DebitNote as any).update(
|
||||||
{ sapDocumentNumber: sapDocNo, status: 'completed' },
|
{
|
||||||
{ where: { id: debitNoteId } }
|
sapDocumentNumber: parsed.docNo,
|
||||||
);
|
status: 'completed',
|
||||||
// Fetch requestId from linked credit note → submission
|
},
|
||||||
const dn = await (Form16DebitNote as any).findByPk(debitNoteId, { attributes: ['creditNoteId'] });
|
{ where: { debitNoteNumber: parsed.tdsTransId } }
|
||||||
if (dn?.creditNoteId) {
|
|
||||||
const cn = await (Form16CreditNote as any).findByPk(dn.creditNoteId, { attributes: ['submissionId'] });
|
|
||||||
if (cn?.submissionId) {
|
|
||||||
const sub = await (Form16aSubmission as any).findByPk(cn.submissionId, { attributes: ['requestId'] });
|
|
||||||
requestId = sub?.requestId ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!debitNoteId) {
|
|
||||||
logger.warn(
|
|
||||||
`[Form16 SAP Job] Debit file ${fileName}: no matching debit note. TRNS_UNIQ_NO=${trnsUniqNo ?? '—'}, TDS_TRNS_ID=${tdsTransId ?? '—'}, CLAIM_NUMBER=${claimNumber ?? '—'}.`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestId) {
|
return { totalRecords, totalCreditNotes, totalDebitNotes };
|
||||||
const req = await (WorkflowRequest as any).findOne({ where: { requestId }, attributes: ['requestNumber'] });
|
}
|
||||||
requestNumber = req?.requestNumber ?? null;
|
|
||||||
|
async function processOutgoingFile(fileName: string, resolvedOutgoingDir: string): Promise<{ totalRecords: number; totalCreditNotes: number; totalDebitNotes: number } | null> {
|
||||||
|
const alreadyRead = await (From16SapReadFile as any).findOne({
|
||||||
|
where: { fileName },
|
||||||
|
attributes: ['id'],
|
||||||
|
});
|
||||||
|
if (alreadyRead) {
|
||||||
|
logger.debug(`[Form16 SAP Job] Skipping already-read file: ${fileName}`);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Upload raw CSV to storage ──
|
const rows = (await wfmFileService.readForm16OutgoingResponseByPath(path.join(resolvedOutgoingDir, fileName))) as CsvRow[];
|
||||||
const absPath = path.join(resolvedOutgoingDir, fileName);
|
const counts = await saveRowsAndUpdateNotes(rows || []);
|
||||||
let storageUrl: string | null = null;
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(absPath)) {
|
|
||||||
const buffer = fs.readFileSync(absPath);
|
|
||||||
const upload = await gcsStorageService.uploadFileWithFallback({
|
|
||||||
buffer,
|
|
||||||
originalName: safeFileName(fileName),
|
|
||||||
mimeType: 'text/csv',
|
|
||||||
requestNumber: requestNumber || trnsUniqNo || 'FORM16',
|
|
||||||
fileType: 'documents',
|
|
||||||
});
|
|
||||||
storageUrl = upload.storageUrl || null;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('[Form16 SAP Job] Failed to upload response file:', fileName, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Persist to DB ──
|
await (From16SapReadFile as any).create({
|
||||||
const commonFields = {
|
fileName,
|
||||||
trnsUniqNo,
|
totalRecords: counts.totalRecords,
|
||||||
tdsTransId,
|
totalCreditNotes: counts.totalCreditNotes,
|
||||||
claimNumber,
|
totalDebitNotes: counts.totalDebitNotes,
|
||||||
sapDocumentNumber: sapDocNo,
|
createdAt: new Date(),
|
||||||
msgTyp,
|
|
||||||
message,
|
|
||||||
docDate,
|
|
||||||
tdsAmt,
|
|
||||||
rawRow: Object.keys(rawRow).length ? rawRow : null,
|
|
||||||
storageUrl,
|
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
});
|
||||||
|
|
||||||
if (type === 'credit') {
|
return counts;
|
||||||
const payload = { type: 'credit' as const, fileName, creditNoteId, ...commonFields };
|
|
||||||
if (existing) await CreditModel.update(payload, { where: { id: existing.id } });
|
|
||||||
else await CreditModel.create({ ...payload, createdAt: new Date() });
|
|
||||||
} else {
|
|
||||||
const payload = { fileName, debitNoteId, ...commonFields };
|
|
||||||
if (existing) await DebitModel.update(payload, { where: { id: existing.id } });
|
|
||||||
else await DebitModel.create({ ...payload, createdAt: new Date() });
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`[Form16 SAP Job] Saved ${type} SAP response for file ${fileName} → ${type === 'credit' ? `credit_note_id=${creditNoteId}` : `debit_note_id=${debitNoteId}`}, storage_url=${storageUrl ? 'yes' : 'no'}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Public API (called by Pull button controller) ────────────────────────────
|
// ─── Public API (called by Pull button controller) ────────────────────────────
|
||||||
@ -337,40 +112,36 @@ export async function runForm16SapResponseIngestionOnce(): Promise<{
|
|||||||
processed: number;
|
processed: number;
|
||||||
creditProcessed: number;
|
creditProcessed: number;
|
||||||
debitProcessed: number;
|
debitProcessed: number;
|
||||||
|
filesProcessed: number;
|
||||||
}> {
|
}> {
|
||||||
let creditProcessed = 0;
|
let creditProcessed = 0;
|
||||||
let debitProcessed = 0;
|
let debitProcessed = 0;
|
||||||
|
let filesProcessed = 0;
|
||||||
|
|
||||||
const RELATIVE_CREDIT_OUT = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'FORM16_CRDT');
|
const RELATIVE_FORM16_OUT = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'FORM16');
|
||||||
const RELATIVE_DEBIT_OUT = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'FORM16_DBT');
|
const resolvedDirs = [
|
||||||
|
path.dirname(wfmFileService.getForm16OutgoingPath('__probe__.csv', 'credit')),
|
||||||
const dirs: Array<{ dir: string; type: 'credit' | 'debit'; relSubdir: string }> = [
|
path.dirname(wfmFileService.getForm16OutgoingPath('__probe__.csv', 'debit')),
|
||||||
{
|
|
||||||
dir: path.dirname(wfmFileService.getForm16OutgoingPath('__probe__.csv', 'credit')),
|
|
||||||
type: 'credit',
|
|
||||||
relSubdir: RELATIVE_CREDIT_OUT,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dir: path.dirname(wfmFileService.getForm16OutgoingPath('__probe__.csv', 'debit')),
|
|
||||||
type: 'debit',
|
|
||||||
relSubdir: RELATIVE_DEBIT_OUT,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
const dirs: Array<{ dir: string; relSubdir: string }> = [...new Set(resolvedDirs)].map((d) => ({
|
||||||
|
dir: d,
|
||||||
|
relSubdir: RELATIVE_FORM16_OUT,
|
||||||
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const base = process.env.WFM_BASE_PATH || 'C:\\WFM';
|
const base = process.env.WFM_BASE_PATH || 'C:\\WFM';
|
||||||
|
|
||||||
for (const { dir, type, relSubdir } of dirs) {
|
for (const { dir, relSubdir } of dirs) {
|
||||||
let abs = path.isAbsolute(dir) ? dir : path.join(base, dir);
|
let abs = path.isAbsolute(dir) ? dir : path.join(base, dir);
|
||||||
|
|
||||||
if (!fs.existsSync(abs)) {
|
if (!fs.existsSync(abs)) {
|
||||||
const cwdFallback = path.join(process.cwd(), relSubdir);
|
const cwdFallback = path.join(process.cwd(), relSubdir);
|
||||||
if (fs.existsSync(cwdFallback)) {
|
if (fs.existsSync(cwdFallback)) {
|
||||||
abs = cwdFallback;
|
abs = cwdFallback;
|
||||||
logger.info(`[Form16 SAP Job] ${type} OUTGOING dir resolved via cwd: ${abs}`);
|
logger.info(`[Form16 SAP Job] OUTGOING dir resolved via cwd: ${abs}`);
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[Form16 SAP Job] ${type} OUTGOING dir not found. Tried: ${abs} | ${cwdFallback}. ` +
|
`[Form16 SAP Job] OUTGOING dir not found. Tried: ${abs} | ${cwdFallback}. ` +
|
||||||
`Set WFM_BASE_PATH to the folder containing WFM-QRE.`
|
`Set WFM_BASE_PATH to the folder containing WFM-QRE.`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@ -378,17 +149,17 @@ export async function runForm16SapResponseIngestionOnce(): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
const files = fs.readdirSync(abs).filter((f) => f.toLowerCase().endsWith('.csv'));
|
const files = fs.readdirSync(abs).filter((f) => f.toLowerCase().endsWith('.csv'));
|
||||||
logger.info(
|
logger.info(`[Form16 SAP Job] OUTGOING dir: ${abs} → ${files.length} CSV file(s)${files.length ? ': ' + files.join(', ') : ''}`);
|
||||||
`[Form16 SAP Job] ${type} OUTGOING dir: ${abs} → ${files.length} CSV file(s)${files.length ? ': ' + files.join(', ') : ''}`
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
try {
|
try {
|
||||||
await processOutgoingFile(f, type, abs);
|
const counts = await processOutgoingFile(f, abs);
|
||||||
if (type === 'credit') creditProcessed++;
|
if (!counts) continue;
|
||||||
else debitProcessed++;
|
filesProcessed++;
|
||||||
|
creditProcessed += counts.totalCreditNotes;
|
||||||
|
debitProcessed += counts.totalDebitNotes;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`[Form16 SAP Job] Error processing ${type} file ${f}:`, e);
|
logger.error(`[Form16 SAP Job] Error processing file ${f}:`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -404,5 +175,6 @@ export async function runForm16SapResponseIngestionOnce(): Promise<{
|
|||||||
processed: creditProcessed + debitProcessed,
|
processed: creditProcessed + debitProcessed,
|
||||||
creditProcessed,
|
creditProcessed,
|
||||||
debitProcessed,
|
debitProcessed,
|
||||||
|
filesProcessed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,87 @@
|
|||||||
|
import type { QueryInterface } from 'sequelize';
|
||||||
|
import { DataTypes } from 'sequelize';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface: QueryInterface) => {
|
||||||
|
// 1) Create read-log table for processed SAP CSV files
|
||||||
|
await queryInterface.createTable('from16_sap_read_file', {
|
||||||
|
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||||
|
file_name: { type: DataTypes.STRING(255), allowNull: false, unique: true },
|
||||||
|
total_records: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
|
||||||
|
total_credit_notes: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
|
||||||
|
total_debit_notes: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
|
||||||
|
created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
|
||||||
|
updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// 2) Add required new fields to form16_sap_responses
|
||||||
|
await queryInterface.addColumn('form16_sap_responses', 'doc_no', {
|
||||||
|
type: DataTypes.STRING(200),
|
||||||
|
allowNull: true,
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// 3) Drop old fields from form16_sap_responses (as requested)
|
||||||
|
for (const col of [
|
||||||
|
'type',
|
||||||
|
'file_name',
|
||||||
|
'credit_note_id',
|
||||||
|
'debit_note_id',
|
||||||
|
'claim_number',
|
||||||
|
'sap_document_number',
|
||||||
|
'doc_date',
|
||||||
|
'tds_amt',
|
||||||
|
'raw_row',
|
||||||
|
'storage_url',
|
||||||
|
]) {
|
||||||
|
await queryInterface.removeColumn('form16_sap_responses', col).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Ensure required columns exist for new contract
|
||||||
|
await queryInterface.addColumn('form16_sap_responses', 'trns_uniq_no', {
|
||||||
|
type: DataTypes.STRING(200),
|
||||||
|
allowNull: true,
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
await queryInterface.addColumn('form16_sap_responses', 'tds_trns_id', {
|
||||||
|
type: DataTypes.STRING(200),
|
||||||
|
allowNull: true,
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
await queryInterface.addColumn('form16_sap_responses', 'msg_typ', {
|
||||||
|
type: DataTypes.STRING(20),
|
||||||
|
allowNull: true,
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
await queryInterface.addColumn('form16_sap_responses', 'message', {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
await queryInterface.addIndex('form16_sap_responses', ['tds_trns_id'], {
|
||||||
|
name: 'idx_form16_sap_responses_tds_trns_id',
|
||||||
|
}).catch(() => {});
|
||||||
|
await queryInterface.addIndex('form16_sap_responses', ['trns_uniq_no'], {
|
||||||
|
name: 'idx_form16_sap_responses_trns_uniq_no',
|
||||||
|
}).catch(() => {});
|
||||||
|
await queryInterface.addIndex('from16_sap_read_file', ['file_name'], {
|
||||||
|
name: 'idx_from16_sap_read_file_name',
|
||||||
|
}).catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface: QueryInterface) => {
|
||||||
|
// Recreate old columns in form16_sap_responses
|
||||||
|
await queryInterface.addColumn('form16_sap_responses', 'type', { type: DataTypes.STRING(10), allowNull: false, defaultValue: 'credit' }).catch(() => {});
|
||||||
|
await queryInterface.addColumn('form16_sap_responses', 'file_name', { type: DataTypes.STRING(255), allowNull: true }).catch(() => {});
|
||||||
|
await queryInterface.addColumn('form16_sap_responses', 'credit_note_id', { type: DataTypes.INTEGER, allowNull: true }).catch(() => {});
|
||||||
|
await queryInterface.addColumn('form16_sap_responses', 'debit_note_id', { type: DataTypes.INTEGER, allowNull: true }).catch(() => {});
|
||||||
|
await queryInterface.addColumn('form16_sap_responses', 'claim_number', { type: DataTypes.STRING(100), allowNull: true }).catch(() => {});
|
||||||
|
await queryInterface.addColumn('form16_sap_responses', 'sap_document_number', { type: DataTypes.STRING(100), allowNull: true }).catch(() => {});
|
||||||
|
await queryInterface.addColumn('form16_sap_responses', 'doc_date', { type: DataTypes.STRING(20), allowNull: true }).catch(() => {});
|
||||||
|
await queryInterface.addColumn('form16_sap_responses', 'tds_amt', { type: DataTypes.STRING(50), allowNull: true }).catch(() => {});
|
||||||
|
await queryInterface.addColumn('form16_sap_responses', 'raw_row', { type: DataTypes.JSONB, allowNull: true }).catch(() => {});
|
||||||
|
await queryInterface.addColumn('form16_sap_responses', 'storage_url', { type: DataTypes.STRING(500), allowNull: true }).catch(() => {});
|
||||||
|
|
||||||
|
await queryInterface.removeColumn('form16_sap_responses', 'doc_no').catch(() => {});
|
||||||
|
await queryInterface.dropTable('from16_sap_read_file').catch(() => {});
|
||||||
|
},
|
||||||
|
};
|
||||||
18
src/migrations/20260324110001-add-pan-number-to-26as.ts
Normal file
18
src/migrations/20260324110001-add-pan-number-to-26as.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import type { QueryInterface } from 'sequelize';
|
||||||
|
import { DataTypes } from 'sequelize';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface: QueryInterface) => {
|
||||||
|
await queryInterface.addColumn('tds_26as_entries', 'pan_number', {
|
||||||
|
type: DataTypes.STRING(20),
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'PAN from 26AS header (assessee PAN)',
|
||||||
|
});
|
||||||
|
await queryInterface.addIndex('tds_26as_entries', ['pan_number'], { name: 'idx_tds_26as_pan' });
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface: QueryInterface) => {
|
||||||
|
await queryInterface.removeIndex('tds_26as_entries', 'idx_tds_26as_pan');
|
||||||
|
await queryInterface.removeColumn('tds_26as_entries', 'pan_number');
|
||||||
|
},
|
||||||
|
};
|
||||||
46
src/migrations/20260325090001-ensure-pan-number-in-26as.ts
Normal file
46
src/migrations/20260325090001-ensure-pan-number-in-26as.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import type { QueryInterface } from 'sequelize';
|
||||||
|
import { DataTypes, QueryTypes } from 'sequelize';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface: QueryInterface) => {
|
||||||
|
// Use information_schema so this migration is safe even if a previous run
|
||||||
|
// recorded as "executed" but didn't actually alter the schema.
|
||||||
|
const sequelize = (queryInterface as any).sequelize;
|
||||||
|
|
||||||
|
const [colRow] = await sequelize.query(
|
||||||
|
`SELECT CASE WHEN COUNT(*) > 0 THEN true ELSE false END AS exists
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tds_26as_entries' AND column_name = 'pan_number'`,
|
||||||
|
{ type: QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!colRow?.exists) {
|
||||||
|
await queryInterface.addColumn('tds_26as_entries', 'pan_number', {
|
||||||
|
type: DataTypes.STRING(20),
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'PAN from 26AS header (assessee PAN)',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [idxRow] = await sequelize.query(
|
||||||
|
`SELECT CASE WHEN COUNT(*) > 0 THEN true ELSE false END AS exists
|
||||||
|
FROM pg_indexes
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
AND tablename = 'tds_26as_entries'
|
||||||
|
AND indexname = 'idx_tds_26as_pan'`,
|
||||||
|
{ type: QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!idxRow?.exists) {
|
||||||
|
await queryInterface.addIndex('tds_26as_entries', ['pan_number'], { name: 'idx_tds_26as_pan' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface: QueryInterface) => {
|
||||||
|
// Best-effort rollback. If column/index already absent, these may throw.
|
||||||
|
// We intentionally keep down strict because rollback isn't required for forward fixes.
|
||||||
|
await queryInterface.removeIndex('tds_26as_entries', 'idx_tds_26as_pan');
|
||||||
|
await queryInterface.removeColumn('tds_26as_entries', 'pan_number');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
@ -1,23 +1,13 @@
|
|||||||
import { DataTypes, Model, Optional } from 'sequelize';
|
import { DataTypes, Model, Optional } from 'sequelize';
|
||||||
import { sequelize } from '@config/database';
|
import { sequelize } from '@config/database';
|
||||||
import { Form16CreditNote } from './Form16CreditNote';
|
|
||||||
|
|
||||||
export interface Form16SapResponseAttributes {
|
export interface Form16SapResponseAttributes {
|
||||||
id: number;
|
id: number;
|
||||||
type: 'credit';
|
trnsUniqNo: string | null;
|
||||||
fileName: string;
|
tdsTransId: string | null;
|
||||||
creditNoteId?: number | null;
|
docNo: string | null;
|
||||||
// Well-known SAP CSV columns stored as individual fields
|
msgTyp: string | null;
|
||||||
trnsUniqNo?: string | null; // TRNS_UNIQ_NO – our unique ID echoed back by SAP
|
message: string | null;
|
||||||
tdsTransId?: string | null; // TDS_TRNS_ID – credit note number echoed back (primary match key)
|
|
||||||
claimNumber?: string | null; // CLAIM_NUMBER (alias / fallback)
|
|
||||||
sapDocumentNumber?: string | null;// DOC_NO – SAP-generated document number
|
|
||||||
msgTyp?: string | null; // MSG_TYP
|
|
||||||
message?: string | null; // MESSAGE
|
|
||||||
docDate?: string | null; // DOC_DATE
|
|
||||||
tdsAmt?: string | null; // TDS_AMT
|
|
||||||
rawRow?: Record<string, unknown> | null; // any extra / unknown columns from the CSV
|
|
||||||
storageUrl?: string | null;
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@ -26,17 +16,11 @@ interface Form16SapResponseCreationAttributes
|
|||||||
extends Optional<
|
extends Optional<
|
||||||
Form16SapResponseAttributes,
|
Form16SapResponseAttributes,
|
||||||
| 'id'
|
| 'id'
|
||||||
| 'creditNoteId'
|
|
||||||
| 'trnsUniqNo'
|
| 'trnsUniqNo'
|
||||||
| 'tdsTransId'
|
| 'tdsTransId'
|
||||||
| 'claimNumber'
|
| 'docNo'
|
||||||
| 'sapDocumentNumber'
|
|
||||||
| 'msgTyp'
|
| 'msgTyp'
|
||||||
| 'message'
|
| 'message'
|
||||||
| 'docDate'
|
|
||||||
| 'tdsAmt'
|
|
||||||
| 'rawRow'
|
|
||||||
| 'storageUrl'
|
|
||||||
| 'createdAt'
|
| 'createdAt'
|
||||||
| 'updatedAt'
|
| 'updatedAt'
|
||||||
> {}
|
> {}
|
||||||
@ -46,41 +30,23 @@ class Form16SapResponse
|
|||||||
implements Form16SapResponseAttributes
|
implements Form16SapResponseAttributes
|
||||||
{
|
{
|
||||||
public id!: number;
|
public id!: number;
|
||||||
public type!: 'credit';
|
public trnsUniqNo!: string | null;
|
||||||
public fileName!: string;
|
public tdsTransId!: string | null;
|
||||||
public creditNoteId?: number | null;
|
public docNo!: string | null;
|
||||||
public trnsUniqNo?: string | null;
|
public msgTyp!: string | null;
|
||||||
public tdsTransId?: string | null;
|
public message!: string | null;
|
||||||
public claimNumber?: string | null;
|
|
||||||
public sapDocumentNumber?: string | null;
|
|
||||||
public msgTyp?: string | null;
|
|
||||||
public message?: string | null;
|
|
||||||
public docDate?: string | null;
|
|
||||||
public tdsAmt?: string | null;
|
|
||||||
public rawRow?: Record<string, unknown> | null;
|
|
||||||
public storageUrl?: string | null;
|
|
||||||
public createdAt!: Date;
|
public createdAt!: Date;
|
||||||
public updatedAt!: Date;
|
public updatedAt!: Date;
|
||||||
|
|
||||||
public creditNote?: Form16CreditNote;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Form16SapResponse.init(
|
Form16SapResponse.init(
|
||||||
{
|
{
|
||||||
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||||
type: { type: DataTypes.STRING(10), allowNull: false },
|
|
||||||
fileName: { type: DataTypes.STRING(255), allowNull: false, unique: true, field: 'file_name' },
|
|
||||||
creditNoteId: { type: DataTypes.INTEGER, allowNull: true, field: 'credit_note_id' },
|
|
||||||
trnsUniqNo: { type: DataTypes.STRING(200), allowNull: true, field: 'trns_uniq_no' },
|
trnsUniqNo: { type: DataTypes.STRING(200), allowNull: true, field: 'trns_uniq_no' },
|
||||||
tdsTransId: { type: DataTypes.STRING(200), allowNull: true, field: 'tds_trns_id' },
|
tdsTransId: { type: DataTypes.STRING(200), allowNull: true, field: 'tds_trns_id' },
|
||||||
claimNumber: { type: DataTypes.STRING(200), allowNull: true, field: 'claim_number' },
|
docNo: { type: DataTypes.STRING(200), allowNull: true, field: 'doc_no' },
|
||||||
sapDocumentNumber:{ type: DataTypes.STRING(100), allowNull: true, field: 'sap_document_number' },
|
|
||||||
msgTyp: { type: DataTypes.STRING(20), allowNull: true, field: 'msg_typ' },
|
msgTyp: { type: DataTypes.STRING(20), allowNull: true, field: 'msg_typ' },
|
||||||
message: { type: DataTypes.TEXT, allowNull: true },
|
message: { type: DataTypes.TEXT, allowNull: true },
|
||||||
docDate: { type: DataTypes.STRING(20), allowNull: true, field: 'doc_date' },
|
|
||||||
tdsAmt: { type: DataTypes.STRING(50), allowNull: true, field: 'tds_amt' },
|
|
||||||
rawRow: { type: DataTypes.JSONB, allowNull: true, field: 'raw_row' },
|
|
||||||
storageUrl: { type: DataTypes.STRING(500), allowNull: true, field: 'storage_url' },
|
|
||||||
createdAt: { type: DataTypes.DATE, allowNull: false, field: 'created_at' },
|
createdAt: { type: DataTypes.DATE, allowNull: false, field: 'created_at' },
|
||||||
updatedAt: { type: DataTypes.DATE, allowNull: false, field: 'updated_at' },
|
updatedAt: { type: DataTypes.DATE, allowNull: false, field: 'updated_at' },
|
||||||
},
|
},
|
||||||
@ -94,10 +60,4 @@ Form16SapResponse.init(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
Form16SapResponse.belongsTo(Form16CreditNote, {
|
|
||||||
as: 'creditNote',
|
|
||||||
foreignKey: 'creditNoteId',
|
|
||||||
targetKey: 'id',
|
|
||||||
});
|
|
||||||
|
|
||||||
export { Form16SapResponse };
|
export { Form16SapResponse };
|
||||||
|
|||||||
50
src/models/From16SapReadFile.ts
Normal file
50
src/models/From16SapReadFile.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { DataTypes, Model, Optional } from 'sequelize';
|
||||||
|
import { sequelize } from '@config/database';
|
||||||
|
|
||||||
|
export interface From16SapReadFileAttributes {
|
||||||
|
id: number;
|
||||||
|
fileName: string;
|
||||||
|
totalRecords: number;
|
||||||
|
totalCreditNotes: number;
|
||||||
|
totalDebitNotes: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface From16SapReadFileCreationAttributes
|
||||||
|
extends Optional<From16SapReadFileAttributes, 'id' | 'createdAt' | 'updatedAt'> {}
|
||||||
|
|
||||||
|
class From16SapReadFile
|
||||||
|
extends Model<From16SapReadFileAttributes, From16SapReadFileCreationAttributes>
|
||||||
|
implements From16SapReadFileAttributes
|
||||||
|
{
|
||||||
|
public id!: number;
|
||||||
|
public fileName!: string;
|
||||||
|
public totalRecords!: number;
|
||||||
|
public totalCreditNotes!: number;
|
||||||
|
public totalDebitNotes!: number;
|
||||||
|
public createdAt!: Date;
|
||||||
|
public updatedAt!: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
From16SapReadFile.init(
|
||||||
|
{
|
||||||
|
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||||
|
fileName: { type: DataTypes.STRING(255), allowNull: false, unique: true, field: 'file_name' },
|
||||||
|
totalRecords: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, field: 'total_records' },
|
||||||
|
totalCreditNotes: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, field: 'total_credit_notes' },
|
||||||
|
totalDebitNotes: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, field: 'total_debit_notes' },
|
||||||
|
createdAt: { type: DataTypes.DATE, allowNull: false, field: 'created_at' },
|
||||||
|
updatedAt: { type: DataTypes.DATE, allowNull: false, field: 'updated_at' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
tableName: 'from16_sap_read_file',
|
||||||
|
timestamps: true,
|
||||||
|
underscored: true,
|
||||||
|
createdAt: 'created_at',
|
||||||
|
updatedAt: 'updated_at',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export { From16SapReadFile };
|
||||||
@ -3,6 +3,7 @@ import { sequelize } from '@config/database';
|
|||||||
|
|
||||||
export interface Tds26asEntryAttributes {
|
export interface Tds26asEntryAttributes {
|
||||||
id: number;
|
id: number;
|
||||||
|
panNumber?: string;
|
||||||
tanNumber: string;
|
tanNumber: string;
|
||||||
deductorName?: string;
|
deductorName?: string;
|
||||||
quarter: string;
|
quarter: string;
|
||||||
@ -26,6 +27,7 @@ interface Tds26asEntryCreationAttributes
|
|||||||
extends Optional<
|
extends Optional<
|
||||||
Tds26asEntryAttributes,
|
Tds26asEntryAttributes,
|
||||||
| 'id'
|
| 'id'
|
||||||
|
| 'panNumber'
|
||||||
| 'deductorName'
|
| 'deductorName'
|
||||||
| 'assessmentYear'
|
| 'assessmentYear'
|
||||||
| 'sectionCode'
|
| 'sectionCode'
|
||||||
@ -46,6 +48,7 @@ class Tds26asEntry
|
|||||||
implements Tds26asEntryAttributes
|
implements Tds26asEntryAttributes
|
||||||
{
|
{
|
||||||
public id!: number;
|
public id!: number;
|
||||||
|
public panNumber?: string;
|
||||||
public tanNumber!: string;
|
public tanNumber!: string;
|
||||||
public deductorName?: string;
|
public deductorName?: string;
|
||||||
public quarter!: string;
|
public quarter!: string;
|
||||||
@ -72,6 +75,11 @@ Tds26asEntry.init(
|
|||||||
autoIncrement: true,
|
autoIncrement: true,
|
||||||
primaryKey: true,
|
primaryKey: true,
|
||||||
},
|
},
|
||||||
|
panNumber: {
|
||||||
|
type: DataTypes.STRING(20),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'pan_number',
|
||||||
|
},
|
||||||
tanNumber: {
|
tanNumber: {
|
||||||
type: DataTypes.STRING(20),
|
type: DataTypes.STRING(20),
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
|
|||||||
@ -41,6 +41,7 @@ import { Form16QuarterStatus } from './Form16QuarterStatus';
|
|||||||
import { Form16LedgerEntry } from './Form16LedgerEntry';
|
import { Form16LedgerEntry } from './Form16LedgerEntry';
|
||||||
import { Form16SapResponse } from './Form16SapResponse';
|
import { Form16SapResponse } from './Form16SapResponse';
|
||||||
import { Form16DebitNoteSapResponse } from './Form16DebitNoteSapResponse';
|
import { Form16DebitNoteSapResponse } from './Form16DebitNoteSapResponse';
|
||||||
|
import { From16SapReadFile } from './From16SapReadFile';
|
||||||
|
|
||||||
// Define associations
|
// Define associations
|
||||||
const defineAssociations = () => {
|
const defineAssociations = () => {
|
||||||
@ -235,7 +236,8 @@ export {
|
|||||||
Form16QuarterStatus,
|
Form16QuarterStatus,
|
||||||
Form16LedgerEntry,
|
Form16LedgerEntry,
|
||||||
Form16SapResponse,
|
Form16SapResponse,
|
||||||
Form16DebitNoteSapResponse
|
Form16DebitNoteSapResponse,
|
||||||
|
From16SapReadFile
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export default sequelize instance
|
// Export default sequelize instance
|
||||||
|
|||||||
@ -87,11 +87,22 @@ router.get(
|
|||||||
requireForm16SubmissionAccess,
|
requireForm16SubmissionAccess,
|
||||||
asyncHandler(form16Controller.viewDebitNoteSapResponse.bind(form16Controller))
|
asyncHandler(form16Controller.viewDebitNoteSapResponse.bind(form16Controller))
|
||||||
);
|
);
|
||||||
|
router.get(
|
||||||
|
'/debit-notes/:id/sap-response/csv',
|
||||||
|
requireForm16ReOnly,
|
||||||
|
requireForm16SubmissionAccess,
|
||||||
|
asyncHandler(form16Controller.downloadDebitNoteSapResponseCsv.bind(form16Controller))
|
||||||
|
);
|
||||||
router.get(
|
router.get(
|
||||||
'/credit-notes/:id/sap-response',
|
'/credit-notes/:id/sap-response',
|
||||||
requireForm16SubmissionAccess,
|
requireForm16SubmissionAccess,
|
||||||
asyncHandler(form16Controller.viewCreditNoteSapResponse.bind(form16Controller))
|
asyncHandler(form16Controller.viewCreditNoteSapResponse.bind(form16Controller))
|
||||||
);
|
);
|
||||||
|
router.get(
|
||||||
|
'/credit-notes/:id/sap-response/csv',
|
||||||
|
requireForm16SubmissionAccess,
|
||||||
|
asyncHandler(form16Controller.downloadCreditNoteSapResponseCsv.bind(form16Controller))
|
||||||
|
);
|
||||||
router.get(
|
router.get(
|
||||||
'/credit-notes/:id',
|
'/credit-notes/:id',
|
||||||
requireForm16SubmissionAccess,
|
requireForm16SubmissionAccess,
|
||||||
|
|||||||
@ -180,8 +180,11 @@ async function runMigrations(): Promise<void> {
|
|||||||
const m63 = require('../migrations/20260317120001-add-form16-trns-uniq-no');
|
const m63 = require('../migrations/20260317120001-add-form16-trns-uniq-no');
|
||||||
const m64 = require('../migrations/20260318100001-create-form16-debit-note-sap-responses');
|
const m64 = require('../migrations/20260318100001-create-form16-debit-note-sap-responses');
|
||||||
const m65 = require('../migrations/20260318200001-add-sap-response-csv-fields');
|
const m65 = require('../migrations/20260318200001-add-sap-response-csv-fields');
|
||||||
const m66 = require('../migrations/20260325094500-add-user-session-and-hsn-sac-codes');
|
const m66 = require('../migrations/20260324090001-refactor-form16-sap-response-and-add-read-log');
|
||||||
const m67 = require('../migrations/20260325175000-update-credit-notes-and-add-items');
|
const m67 = require('../migrations/20260324110001-add-pan-number-to-26as');
|
||||||
|
const m68 = require('../migrations/20260325090001-ensure-pan-number-in-26as');
|
||||||
|
const m69 = require('../migrations/20260325094500-add-user-session-and-hsn-sac-codes');
|
||||||
|
const m70 = require('../migrations/20260325175000-update-credit-notes-and-add-items');
|
||||||
|
|
||||||
const migrations = [
|
const migrations = [
|
||||||
{ name: '2025103000-create-users', module: m0 },
|
{ name: '2025103000-create-users', module: m0 },
|
||||||
@ -254,8 +257,11 @@ async function runMigrations(): Promise<void> {
|
|||||||
{ name: '20260317120001-add-form16-trns-uniq-no', module: m63 },
|
{ name: '20260317120001-add-form16-trns-uniq-no', module: m63 },
|
||||||
{ name: '20260318100001-create-form16-debit-note-sap-responses', module: m64 },
|
{ name: '20260318100001-create-form16-debit-note-sap-responses', module: m64 },
|
||||||
{ name: '20260318200001-add-sap-response-csv-fields', module: m65 },
|
{ name: '20260318200001-add-sap-response-csv-fields', module: m65 },
|
||||||
{ name: '20260325094500-add-user-session-and-hsn-sac-codes', module: m66 },
|
{ name: '20260324090001-refactor-form16-sap-response-and-add-read-log', module: m66 },
|
||||||
{ name: '20260325175000-update-credit-notes-and-add-items', module: m67 },
|
{ name: '20260324110001-add-pan-number-to-26as', module: m67 },
|
||||||
|
{ name: '20260325090001-ensure-pan-number-in-26as', module: m68 },
|
||||||
|
{ name: '20260325094500-add-user-session-and-hsn-sac-codes', module: m69 },
|
||||||
|
{ name: '20260325175000-update-credit-notes-and-add-items', module: m70 },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Dynamically import sequelize after secrets are loaded
|
// Dynamically import sequelize after secrets are loaded
|
||||||
|
|||||||
@ -70,7 +70,11 @@ import * as m62 from '../migrations/20260317100001-create-form16-sap-responses';
|
|||||||
import * as m63 from '../migrations/20260317120001-add-form16-trns-uniq-no';
|
import * as m63 from '../migrations/20260317120001-add-form16-trns-uniq-no';
|
||||||
import * as m64 from '../migrations/20260318100001-create-form16-debit-note-sap-responses';
|
import * as m64 from '../migrations/20260318100001-create-form16-debit-note-sap-responses';
|
||||||
import * as m65 from '../migrations/20260318200001-add-sap-response-csv-fields';
|
import * as m65 from '../migrations/20260318200001-add-sap-response-csv-fields';
|
||||||
import * as m66 from '../migrations/20260325094500-add-user-session-and-hsn-sac-codes';
|
import * as m66 from '../migrations/20260324090001-refactor-form16-sap-response-and-add-read-log';
|
||||||
|
import * as m67 from '../migrations/20260324110001-add-pan-number-to-26as';
|
||||||
|
import * as m68 from '../migrations/20260325090001-ensure-pan-number-in-26as';
|
||||||
|
import * as m69 from '../migrations/20260325094500-add-user-session-and-hsn-sac-codes';
|
||||||
|
import * as m70 from '../migrations/20260325175000-update-credit-notes-and-add-items';
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
name: string;
|
name: string;
|
||||||
@ -148,7 +152,11 @@ const migrations: Migration[] = [
|
|||||||
{ name: '20260317120001-add-form16-trns-uniq-no', module: m63 },
|
{ name: '20260317120001-add-form16-trns-uniq-no', module: m63 },
|
||||||
{ name: '20260318100001-create-form16-debit-note-sap-responses', module: m64 },
|
{ name: '20260318100001-create-form16-debit-note-sap-responses', module: m64 },
|
||||||
{ name: '20260318200001-add-sap-response-csv-fields', module: m65 },
|
{ name: '20260318200001-add-sap-response-csv-fields', module: m65 },
|
||||||
{ name: '20260325094500-add-user-session-and-hsn-sac-codes', module: m66 },
|
{ name: '20260324090001-refactor-form16-sap-response-and-add-read-log', module: m66 },
|
||||||
|
{ name: '20260324110001-add-pan-number-to-26as', module: m67 },
|
||||||
|
{ name: '20260325090001-ensure-pan-number-in-26as', module: m68 },
|
||||||
|
{ name: '20260325094500-add-user-session-and-hsn-sac-codes', module: m69 },
|
||||||
|
{ name: '20260325175000-update-credit-notes-and-add-items', module: m70 },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -207,7 +215,7 @@ async function markMigrationExecuted(sequelize: any, name: string): Promise<void
|
|||||||
/**
|
/**
|
||||||
* Run all pending migrations
|
* Run all pending migrations
|
||||||
*/
|
*/
|
||||||
async function run() {
|
export async function runMigrations() {
|
||||||
try {
|
try {
|
||||||
console.log('🔐 Initializing secrets...');
|
console.log('🔐 Initializing secrets...');
|
||||||
await initializeGoogleSecretManager();
|
await initializeGoogleSecretManager();
|
||||||
@ -232,7 +240,6 @@ async function run() {
|
|||||||
|
|
||||||
if (pendingMigrations.length === 0) {
|
if (pendingMigrations.length === 0) {
|
||||||
console.log('✅ Migrations up-to-date');
|
console.log('✅ Migrations up-to-date');
|
||||||
process.exit(0);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -252,11 +259,15 @@ async function run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ Applied ${pendingMigrations.length} migration(s)`);
|
console.log(`✅ Applied ${pendingMigrations.length} migration(s)`);
|
||||||
process.exit(0);
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('❌ Migration failed:', err.message);
|
console.error('❌ Migration failed:', err.message);
|
||||||
process.exit(1);
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
run();
|
// When executed directly: behave like a script (set exit codes).
|
||||||
|
if (require.main === module) {
|
||||||
|
runMigrations()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch(() => process.exit(1));
|
||||||
|
}
|
||||||
|
|||||||
@ -62,6 +62,21 @@ const startServer = async (): Promise<void> => {
|
|||||||
// Initialize database connection explicitly after secrets are loaded
|
// Initialize database connection explicitly after secrets are loaded
|
||||||
await initializeAppDatabase();
|
await initializeAppDatabase();
|
||||||
|
|
||||||
|
// Apply pending DB migrations automatically in non-production to prevent schema drift.
|
||||||
|
// Can be disabled via RUN_MIGRATIONS_ON_STARTUP=false
|
||||||
|
const autoMigrate =
|
||||||
|
process.env.RUN_MIGRATIONS_ON_STARTUP === 'true' ||
|
||||||
|
process.env.NODE_ENV !== 'production';
|
||||||
|
if (autoMigrate) {
|
||||||
|
try {
|
||||||
|
const { runMigrations } = require('./scripts/migrate');
|
||||||
|
await runMigrations();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('❌ Auto migration on startup failed:', e);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
require('./queues/tatWorker'); // Initialize TAT worker
|
require('./queues/tatWorker'); // Initialize TAT worker
|
||||||
const { logTatConfig } = require('./config/tat.config');
|
const { logTatConfig } = require('./config/tat.config');
|
||||||
const { logSystemConfig } = require('./config/system.config');
|
const { logSystemConfig } = require('./config/system.config');
|
||||||
|
|||||||
@ -585,7 +585,7 @@ export async function seedDefaultConfigurations(): Promise<void> {
|
|||||||
gen_random_uuid(),
|
gen_random_uuid(),
|
||||||
'FORM16_ADMIN_CONFIG',
|
'FORM16_ADMIN_CONFIG',
|
||||||
'SYSTEM_SETTINGS',
|
'SYSTEM_SETTINGS',
|
||||||
'{"submissionViewerEmails":[],"twentySixAsViewerEmails":[],"reminderEnabled":true,"reminderDays":7,"notification26AsDataAdded":{"enabled":true,"template":"26AS data has been added. Please review."},"notificationForm16SuccessCreditNote":{"enabled":true,"template":"Form 16 submitted successfully. Credit note: [CreditNoteRef]."},"notificationForm16Unsuccessful":{"enabled":true,"template":"Form 16 submission was unsuccessful. Issue: [Issue]."},"alertSubmitForm16Enabled":true,"alertSubmitForm16FrequencyDays":0,"alertSubmitForm16FrequencyHours":24,"alertSubmitForm16Template":"Please submit your Form 16 at your earliest. [Name], due date: [DueDate].","reminderNotificationEnabled":true,"reminderFrequencyDays":0,"reminderFrequencyHours":12,"reminderNotificationTemplate":"Reminder: Form 16 submission is pending. [Name], [Request ID]. Please review.","debitNoteNotification":{"enabled":true,"template":"Debit note issued: [DebitNoteRef]. Please review."}}',
|
'{"submissionViewerEmails":[],"twentySixAsViewerEmails":[],"reminderEnabled":true,"reminderDays":7,"notification26AsDataAdded":{"enabled":true,"template":"26AS data has been added. Please review."},"notificationForm16SuccessCreditNote":{"enabled":true,"template":"Form 16 submitted successfully. Credit note: [CreditNoteRef]."},"notificationForm16Unsuccessful":{"enabled":true,"template":"Form 16 submission was unsuccessful. Issue: [Issue]."},"alertSubmitForm16Enabled":true,"alertSubmitForm16FrequencyDays":0,"alertSubmitForm16FrequencyHours":24,"alertSubmitForm16Template":"Dear [Name], please submit Form 16A for the pending period. Due: [DueDate].","reminderNotificationEnabled":true,"reminderFrequencyDays":0,"reminderFrequencyHours":12,"reminderNotificationTemplate":"Reminder: Dear [Name], your Form 16A submission is pending for request [Request ID]. Please complete it.","debitNoteNotification":{"enabled":true,"template":"Debit note issued: [DebitNoteRef]. Please review."}}',
|
||||||
'JSON',
|
'JSON',
|
||||||
'Form 16 Admin Config',
|
'Form 16 Admin Config',
|
||||||
'Form 16 visibility (submission data viewers, 26AS viewers), reminders and notification settings',
|
'Form 16 visibility (submission data viewers, 26AS viewers), reminders and notification settings',
|
||||||
|
|||||||
@ -19,7 +19,6 @@ import {
|
|||||||
Form16QuarterStatus,
|
Form16QuarterStatus,
|
||||||
Form16LedgerEntry,
|
Form16LedgerEntry,
|
||||||
Form16SapResponse,
|
Form16SapResponse,
|
||||||
Form16DebitNoteSapResponse,
|
|
||||||
} from '../models';
|
} from '../models';
|
||||||
import { Tds26asEntry } from '../models/Tds26asEntry';
|
import { Tds26asEntry } from '../models/Tds26asEntry';
|
||||||
import { Form1626asUploadLog } from '../models/Form1626asUploadLog';
|
import { Form1626asUploadLog } from '../models/Form1626asUploadLog';
|
||||||
@ -39,7 +38,7 @@ import logger from '../utils/logger';
|
|||||||
* Same dealer code is used for submission, credit note, and debit note generation.
|
* Same dealer code is used for submission, credit note, and debit note generation.
|
||||||
* Returns null if no dealer code found.
|
* Returns null if no dealer code found.
|
||||||
*/
|
*/
|
||||||
export async function getDealerCodeForUser(userId: string): Promise<string | null> {
|
export async function getDealerCodeForUser(userId: string, userEmail?: string | null): Promise<string | null> {
|
||||||
const [row] = await sequelize.query<{ employee_number: string | null }>(
|
const [row] = await sequelize.query<{ employee_number: string | null }>(
|
||||||
`SELECT employee_number FROM users WHERE user_id = :userId LIMIT 1`,
|
`SELECT employee_number FROM users WHERE user_id = :userId LIMIT 1`,
|
||||||
{ replacements: { userId }, type: QueryTypes.SELECT }
|
{ replacements: { userId }, type: QueryTypes.SELECT }
|
||||||
@ -49,10 +48,11 @@ export async function getDealerCodeForUser(userId: string): Promise<string | nul
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = await User.findByPk(userId, { attributes: ['userId', 'email'] });
|
const user = await User.findByPk(userId, { attributes: ['userId', 'email'] });
|
||||||
if (!user?.email) return null;
|
const emailToUse = (user?.email || userEmail || '').toString().trim();
|
||||||
|
if (!emailToUse) return null;
|
||||||
const dealer = await Dealer.findOne({
|
const dealer = await Dealer.findOne({
|
||||||
where: {
|
where: {
|
||||||
dealerPrincipalEmailId: { [Op.iLike]: user.email },
|
dealerPrincipalEmailId: { [Op.iLike]: emailToUse },
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
attributes: ['salesCode', 'dlrcode', 'dealerId'],
|
attributes: ['salesCode', 'dlrcode', 'dealerId'],
|
||||||
@ -65,6 +65,45 @@ 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';
|
||||||
|
const AMOUNT_MATCH_TOLERANCE = 1;
|
||||||
|
|
||||||
|
type Latest26asRow = {
|
||||||
|
panNumber: string | null;
|
||||||
|
amountPaid: number | null;
|
||||||
|
taxDeducted: number;
|
||||||
|
totalTdsDeposited: number | null;
|
||||||
|
transactionDate: string | null;
|
||||||
|
dateOfBooking: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeTanNumber(raw: unknown): string {
|
||||||
|
return String(raw ?? '')
|
||||||
|
.trim()
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[^A-Z0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some environments might still be on the old DB schema.
|
||||||
|
// If `tds_26as_entries.pan_number` is missing, uploads/selects will fail without this guard.
|
||||||
|
let _panNumberColumnPresent: boolean | null = null;
|
||||||
|
async function isPanNumberColumnPresent(): Promise<boolean> {
|
||||||
|
if (_panNumberColumnPresent !== null) return _panNumberColumnPresent;
|
||||||
|
try {
|
||||||
|
const [row] = await sequelize.query<{ exists: boolean }>(
|
||||||
|
`SELECT CASE WHEN COUNT(*) > 0 THEN true ELSE false END AS exists
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tds_26as_entries' AND column_name = 'pan_number'`,
|
||||||
|
{ type: QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
_panNumberColumnPresent = !!row?.exists;
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('[Form16] pan_number column presence check failed; disabling PAN persistence/enforcement.', {
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
_panNumberColumnPresent = false;
|
||||||
|
}
|
||||||
|
return _panNumberColumnPresent;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get aggregated TDS amount for (tan, fy, quarter) from the LATEST 26AS upload only (Section 194Q, Booking F/O).
|
* Get aggregated TDS amount for (tan, fy, quarter) from the LATEST 26AS upload only (Section 194Q, Booking F/O).
|
||||||
@ -77,33 +116,202 @@ export async function getLatest26asAggregatedForQuarter(
|
|||||||
financialYear: string,
|
financialYear: string,
|
||||||
quarter: string
|
quarter: string
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const normalized = (tanNumber || '').trim().replace(/\s+/g, ' ');
|
const normalizedTan = normalizeTanNumber(tanNumber);
|
||||||
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 }>(
|
||||||
`WITH latest_upload AS (
|
`WITH latest_upload AS (
|
||||||
SELECT MAX(upload_log_id) AS mid 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 UPPER(REGEXP_REPLACE(TRIM(COALESCE(tan_number, '')), '[^A-Z0-9]', '', 'g')) = :tan
|
||||||
AND financial_year = :fy AND quarter = :qtr
|
AND financial_year = :fy AND quarter = :qtr
|
||||||
AND section_code = :section
|
AND UPPER(TRIM(COALESCE(section_code, ''))) = :section
|
||||||
AND (status_oltas = 'F' OR status_oltas = 'O')
|
AND UPPER(TRIM(COALESCE(status_oltas, ''))) IN ('F', 'O')
|
||||||
AND upload_log_id IS NOT NULL
|
AND upload_log_id IS NOT NULL
|
||||||
)
|
)
|
||||||
SELECT COALESCE(SUM(e.tax_deducted), 0)::text AS sum
|
SELECT COALESCE(SUM(e.tax_deducted), 0)::text AS sum
|
||||||
FROM tds_26as_entries e
|
FROM tds_26as_entries e
|
||||||
WHERE LOWER(REPLACE(TRIM(e.tan_number), ' ', '')) = LOWER(REPLACE(TRIM(:tan), ' ', ''))
|
WHERE UPPER(REGEXP_REPLACE(TRIM(COALESCE(e.tan_number, '')), '[^A-Z0-9]', '', 'g')) = :tan
|
||||||
AND e.financial_year = :fy AND e.quarter = :qtr
|
AND e.financial_year = :fy AND e.quarter = :qtr
|
||||||
AND e.section_code = :section
|
AND UPPER(TRIM(COALESCE(e.section_code, ''))) = :section
|
||||||
AND (e.status_oltas = 'F' OR e.status_oltas = 'O')
|
AND UPPER(TRIM(COALESCE(e.status_oltas, ''))) IN ('F', 'O')
|
||||||
AND (
|
AND (
|
||||||
e.upload_log_id = (SELECT mid FROM latest_upload)
|
e.upload_log_id = (SELECT mid FROM latest_upload)
|
||||||
OR (SELECT mid FROM latest_upload) IS NULL
|
OR (SELECT mid FROM latest_upload) IS NULL
|
||||||
)`,
|
)`,
|
||||||
{ replacements: { tan: normalized, fy, qtr: q, section: SECTION_26AS_194Q }, type: QueryTypes.SELECT }
|
{ replacements: { tan: normalizedTan, fy, qtr: q, section: SECTION_26AS_194Q }, type: QueryTypes.SELECT }
|
||||||
);
|
);
|
||||||
return parseFloat(row?.sum ?? '0') || 0;
|
return parseFloat(row?.sum ?? '0') || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getLatest26asRowsForQuarter(
|
||||||
|
tanNumber: string,
|
||||||
|
financialYear: string,
|
||||||
|
quarter: string
|
||||||
|
): Promise<Latest26asRow[]> {
|
||||||
|
const normalizedTan = normalizeTanNumber(tanNumber);
|
||||||
|
const fy = normalizeFinancialYear(financialYear) || financialYear;
|
||||||
|
const q = normalizeQuarter(quarter) || quarter;
|
||||||
|
|
||||||
|
const hasPan = await isPanNumberColumnPresent();
|
||||||
|
const panSelect = hasPan ? 'e.pan_number' : 'NULL::text AS pan_number';
|
||||||
|
|
||||||
|
const rows = await sequelize.query<{
|
||||||
|
pan_number: string | null;
|
||||||
|
amount_paid: string | null;
|
||||||
|
tax_deducted: string;
|
||||||
|
total_tds_deposited: string | null;
|
||||||
|
transaction_date: string | null;
|
||||||
|
date_of_booking: string | null;
|
||||||
|
}>(
|
||||||
|
`WITH latest_upload AS (
|
||||||
|
SELECT MAX(upload_log_id) AS mid FROM tds_26as_entries
|
||||||
|
WHERE UPPER(REGEXP_REPLACE(TRIM(COALESCE(tan_number, '')), '[^A-Z0-9]', '', 'g')) = :tan
|
||||||
|
AND financial_year = :fy AND quarter = :qtr
|
||||||
|
AND UPPER(TRIM(COALESCE(section_code, ''))) = :section
|
||||||
|
AND UPPER(TRIM(COALESCE(status_oltas, ''))) IN ('F', 'O')
|
||||||
|
AND upload_log_id IS NOT NULL
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
${panSelect},
|
||||||
|
e.amount_paid,
|
||||||
|
e.tax_deducted,
|
||||||
|
e.total_tds_deposited,
|
||||||
|
e.transaction_date,
|
||||||
|
e.date_of_booking
|
||||||
|
FROM tds_26as_entries e
|
||||||
|
WHERE UPPER(REGEXP_REPLACE(TRIM(COALESCE(e.tan_number, '')), '[^A-Z0-9]', '', 'g')) = :tan
|
||||||
|
AND e.financial_year = :fy
|
||||||
|
AND e.quarter = :qtr
|
||||||
|
AND UPPER(TRIM(COALESCE(e.section_code, ''))) = :section
|
||||||
|
AND UPPER(TRIM(COALESCE(e.status_oltas, ''))) IN ('F', 'O')
|
||||||
|
AND (
|
||||||
|
e.upload_log_id = (SELECT mid FROM latest_upload)
|
||||||
|
OR (SELECT mid FROM latest_upload) IS NULL
|
||||||
|
)`,
|
||||||
|
{ replacements: { tan: normalizedTan, fy, qtr: q, section: SECTION_26AS_194Q }, type: QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
panNumber: r.pan_number ? String(r.pan_number).trim().toUpperCase() : null,
|
||||||
|
amountPaid: r.amount_paid == null ? null : parseFloat(r.amount_paid),
|
||||||
|
taxDeducted: parseFloat(r.tax_deducted || '0') || 0,
|
||||||
|
totalTdsDeposited: r.total_tds_deposited == null ? null : parseFloat(r.total_tds_deposited),
|
||||||
|
transactionDate: r.transaction_date || null,
|
||||||
|
dateOfBooking: r.date_of_booking || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get26asCoverageDebug(tanNumber: string, financialYear: string, quarter: string) {
|
||||||
|
const normalizedTan = normalizeTanNumber(tanNumber);
|
||||||
|
const fy = normalizeFinancialYear(financialYear) || financialYear;
|
||||||
|
const q = normalizeQuarter(quarter) || quarter;
|
||||||
|
|
||||||
|
// Overall counts + how many rows qualify for our matching rule:
|
||||||
|
const [counts] = await sequelize.query<{
|
||||||
|
total_rows: string;
|
||||||
|
matching_194q_f_o_rows: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*)::text AS total_rows,
|
||||||
|
SUM(
|
||||||
|
CASE
|
||||||
|
WHEN UPPER(TRIM(COALESCE(section_code, ''))) = :section
|
||||||
|
AND UPPER(TRIM(COALESCE(status_oltas, ''))) IN ('F', 'O')
|
||||||
|
THEN 1 ELSE 0
|
||||||
|
END
|
||||||
|
)::text AS matching_194q_f_o_rows
|
||||||
|
FROM tds_26as_entries e
|
||||||
|
WHERE UPPER(REGEXP_REPLACE(TRIM(COALESCE(e.tan_number, '')), '[^A-Z0-9]', '', 'g')) = :tan
|
||||||
|
AND e.financial_year = :fy
|
||||||
|
AND e.quarter = :q`,
|
||||||
|
{ replacements: { tan: normalizedTan, fy, q, section: SECTION_26AS_194Q }, type: QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Section/status breakdown to make it obvious why latestRows became empty.
|
||||||
|
const breakdown = (await sequelize.query(
|
||||||
|
`SELECT
|
||||||
|
section_code,
|
||||||
|
status_oltas,
|
||||||
|
COUNT(*)::text AS cnt
|
||||||
|
FROM tds_26as_entries e
|
||||||
|
WHERE UPPER(REGEXP_REPLACE(TRIM(COALESCE(e.tan_number, '')), '[^A-Z0-9]', '', 'g')) = :tan
|
||||||
|
AND e.financial_year = :fy
|
||||||
|
AND e.quarter = :q
|
||||||
|
GROUP BY section_code, status_oltas
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
LIMIT 8`,
|
||||||
|
{ replacements: { tan: normalizedTan, fy, q }, type: QueryTypes.SELECT }
|
||||||
|
)) as Array<{ section_code: string | null; status_oltas: string | null; cnt: string }>;
|
||||||
|
|
||||||
|
const totalRows = parseInt(String(counts?.total_rows ?? '0'), 10) || 0;
|
||||||
|
const matchingRows = parseInt(String(counts?.matching_194q_f_o_rows ?? '0'), 10) || 0;
|
||||||
|
|
||||||
|
const breakdownLines = (breakdown || [])
|
||||||
|
.map((b) => {
|
||||||
|
const sec = (b.section_code ?? '').toString().trim() || '(null)';
|
||||||
|
const st = (b.status_oltas ?? '').toString().trim() || '(null)';
|
||||||
|
const c = parseInt(String(b.cnt ?? '0'), 10) || 0;
|
||||||
|
return `${sec}/${st}:${c}`;
|
||||||
|
})
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
return { totalRows, matchingRows, breakdownLines };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDateOnly(value: unknown): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const raw = String(value).trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
// Prefer Indian-style numeric dates from OCR (DD-MM-YYYY or DD/MM/YYYY).
|
||||||
|
// Do NOT let JS Date parse these first, because it may interpret as MM-DD-YYYY.
|
||||||
|
const m = raw.match(/^(\d{1,2})[-\/](\d{1,2})[-\/](\d{2,4})$/);
|
||||||
|
if (m) {
|
||||||
|
const dd = m[1].padStart(2, '0');
|
||||||
|
const mm = m[2].padStart(2, '0');
|
||||||
|
const yyyy = m[3].length === 2 ? `20${m[3]}` : m[3];
|
||||||
|
return `${yyyy}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = new Date(raw);
|
||||||
|
if (!Number.isNaN(d.getTime())) return d.toISOString().slice(0, 10);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive Indian FY and 26AS quarter from an OCR date-only string (YYYY-MM-DD).
|
||||||
|
* Uses the same quarter boundaries as 26AS dateToFyAndQuarter:
|
||||||
|
* - Apr-Jun => Q1 (FY end = year+1)
|
||||||
|
* - Jul-Sep => Q2
|
||||||
|
* - Oct-Dec => Q3
|
||||||
|
* - Jan-Mar => Q4 (FY end = year)
|
||||||
|
*/
|
||||||
|
function deriveFyAndQuarterFromDateOnly(dateOnly: string | null): { financialYear: string; quarter: string } | null {
|
||||||
|
if (!dateOnly) return null;
|
||||||
|
const d = new Date(`${dateOnly}T00:00:00.000Z`);
|
||||||
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
|
|
||||||
|
const month = d.getUTCMonth() + 1; // 1-12
|
||||||
|
const year = d.getUTCFullYear();
|
||||||
|
|
||||||
|
let quarter: string;
|
||||||
|
if ([4, 5, 6].includes(month)) quarter = 'Q1';
|
||||||
|
else if ([7, 8, 9].includes(month)) quarter = 'Q2';
|
||||||
|
else if ([10, 11, 12].includes(month)) quarter = 'Q3';
|
||||||
|
else quarter = 'Q4'; // Jan-Mar
|
||||||
|
|
||||||
|
const fyEnd = [1, 2, 3].includes(month) ? year : year + 1;
|
||||||
|
const fyStart = fyEnd - 1;
|
||||||
|
const next = (fyEnd % 100).toString().padStart(2, '0');
|
||||||
|
return { financialYear: `${fyStart}-${next}`, quarter };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumberOrNull(value: unknown): number | null {
|
||||||
|
if (value == null || value === '') return null;
|
||||||
|
const n = typeof value === 'number' ? value : parseFloat(String(value).replace(/,/g, ''));
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Get latest 26AS quarter snapshot for (tan, fy, quarter). */
|
/** Get latest 26AS quarter snapshot for (tan, fy, quarter). */
|
||||||
export async function getLatest26asSnapshot(
|
export async function getLatest26asSnapshot(
|
||||||
tanNumber: string,
|
tanNumber: string,
|
||||||
@ -277,16 +485,19 @@ export async function listCreditNotesForDealer(userId: string, filters?: { finan
|
|||||||
let sapSet = new Set<number>();
|
let sapSet = new Set<number>();
|
||||||
if (hasTrnsUniqNoColumn && noteIds.length) {
|
if (hasTrnsUniqNoColumn && noteIds.length) {
|
||||||
try {
|
try {
|
||||||
|
const creditNotes = await Form16CreditNote.findAll({
|
||||||
|
where: { id: { [Op.in]: noteIds } },
|
||||||
|
attributes: ['id', 'creditNoteNumber'],
|
||||||
|
raw: true,
|
||||||
|
}) as any[];
|
||||||
|
const creditNumbers = creditNotes.map((n) => n.creditNoteNumber).filter(Boolean);
|
||||||
const sapRows = await (Form16SapResponse as any).findAll({
|
const sapRows = await (Form16SapResponse as any).findAll({
|
||||||
where: {
|
where: { tdsTransId: { [Op.in]: creditNumbers } },
|
||||||
type: 'credit',
|
attributes: ['tdsTransId'],
|
||||||
creditNoteId: { [Op.in]: noteIds },
|
|
||||||
[Op.or]: [{ storageUrl: { [Op.ne]: null } }, { sapDocumentNumber: { [Op.ne]: null } }],
|
|
||||||
},
|
|
||||||
attributes: ['creditNoteId'],
|
|
||||||
raw: true,
|
raw: true,
|
||||||
});
|
});
|
||||||
sapSet = new Set((sapRows as any[]).map((r) => r.creditNoteId));
|
const available = new Set((sapRows as any[]).map((r) => String(r.tdsTransId)));
|
||||||
|
sapSet = new Set(creditNotes.filter((n) => available.has(String(n.creditNoteNumber))).map((n) => Number(n.id)));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logger.warn('[Form16] SAP response lookup failed (will treat as unavailable):', e?.message || e);
|
logger.warn('[Form16] SAP response lookup failed (will treat as unavailable):', e?.message || e);
|
||||||
sapSet = new Set<number>();
|
sapSet = new Set<number>();
|
||||||
@ -530,24 +741,24 @@ export function formatForm16DebitNoteNumber(
|
|||||||
async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise<{ validationStatus: string; creditNoteNumber?: string | null; validationNotes?: string }> {
|
async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise<{ validationStatus: string; creditNoteNumber?: string | null; validationNotes?: string }> {
|
||||||
const sub = submission as any;
|
const sub = submission as any;
|
||||||
const tanNumberRaw = (sub.tanNumber || '').toString().trim();
|
const tanNumberRaw = (sub.tanNumber || '').toString().trim();
|
||||||
const tanNumber = tanNumberRaw.replace(/\s+/g, ' ');
|
const tanNumber = normalizeTanNumber(tanNumberRaw);
|
||||||
const tdsAmount = parseFloat(sub.tdsAmount) || 0;
|
const tdsAmount = parseFloat(sub.tdsAmount) || 0;
|
||||||
|
|
||||||
if (!tanNumber || tdsAmount <= 0) {
|
if (!tanNumber || tanNumber.length < 10 || tdsAmount <= 0) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[Form16] 26AS MATCH RESULT: RESUBMISSION_NEEDED – OCR incomplete. Form 16A: TAN=${tanNumber || '(missing)'}, TDS amount=${tdsAmount}, FY=${(sub.financialYear || '').toString().trim() || '(missing)'}, Quarter=${(sub.quarter || '').toString().trim() || '(missing)'}. No 26AS check performed.`
|
`[Form16] 26AS MATCH RESULT: RESUBMISSION_NEEDED – OCR incomplete. Form 16A: TAN=${tanNumber || '(missing)'}, TDS amount=${tdsAmount}, FY=${(sub.financialYear || '').toString().trim() || '(missing)'}, Quarter=${(sub.quarter || '').toString().trim() || '(missing)'}. No 26AS check performed.`
|
||||||
);
|
);
|
||||||
await submission.update({
|
await submission.update({
|
||||||
validationStatus: 'resubmission_needed',
|
validationStatus: 'resubmission_needed',
|
||||||
validationNotes: 'OCR data incomplete (TAN or TDS amount missing). Please resubmit Form 16 or contact RE for manual approval.',
|
validationNotes: 'OCR data incomplete/invalid (TAN or TDS amount missing). Please resubmit Form 16 or contact RE for manual approval.',
|
||||||
});
|
});
|
||||||
return { validationStatus: 'resubmission_needed' };
|
return { validationStatus: 'resubmission_needed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const financialYearRaw = (sub.financialYear || '').trim();
|
const financialYearRaw = (sub.financialYear || '').trim();
|
||||||
const quarterRaw = (sub.quarter || '').trim();
|
const quarterRaw = (sub.quarter || '').trim();
|
||||||
const financialYear = normalizeFinancialYear(financialYearRaw) || financialYearRaw;
|
let financialYear = normalizeFinancialYear(financialYearRaw) || financialYearRaw;
|
||||||
const quarter = normalizeQuarter(quarterRaw) || quarterRaw;
|
let quarter = normalizeQuarter(quarterRaw) || quarterRaw;
|
||||||
|
|
||||||
if (!financialYear || !quarter) {
|
if (!financialYear || !quarter) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@ -560,22 +771,162 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
|
|||||||
return { validationStatus: 'resubmission_needed' };
|
return { validationStatus: 'resubmission_needed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Official quarter total from 26AS (Section 194Q, Booking F/O only)
|
const extracted = (sub.ocrExtractedData || {}) as Record<string, unknown>;
|
||||||
const aggregated26as = await getLatest26asAggregatedForQuarter(tanNumber, financialYear, quarter);
|
const submittedPan =
|
||||||
|
(extracted.panOfDeductee as string) ||
|
||||||
|
(extracted.deducteePan as string) ||
|
||||||
|
(extracted.panNumber as string) ||
|
||||||
|
null;
|
||||||
|
const normalizedSubmittedPan = submittedPan ? String(submittedPan).trim().toUpperCase() : null;
|
||||||
|
const submittedAmountPaid = toNumberOrNull(extracted.totalAmountPaid ?? sub.totalAmount);
|
||||||
|
const submittedTaxDeducted = toNumberOrNull(extracted.totalTaxDeducted ?? sub.tdsAmount);
|
||||||
|
const submittedTdsDeposited = toNumberOrNull(extracted.totalTdsDeposited ?? sub.tdsAmount);
|
||||||
|
const submittedTransactionDate = normalizeDateOnly(extracted.transactionDate);
|
||||||
|
const submittedBookingDate = normalizeDateOnly(extracted.dateOfBooking);
|
||||||
|
|
||||||
if (aggregated26as <= 0) {
|
// Latest 26AS upload rows for the same TAN + FY + Quarter.
|
||||||
|
let latestRows = await getLatest26asRowsForQuarter(tanNumber, financialYear, quarter);
|
||||||
|
|
||||||
|
// If OCR extracted FY/Quarter incorrectly, derive FY/Quarter from OCR dates and retry.
|
||||||
|
if (latestRows.length === 0) {
|
||||||
|
const derivedFromTx = deriveFyAndQuarterFromDateOnly(submittedTransactionDate);
|
||||||
|
const derivedFromBooking = deriveFyAndQuarterFromDateOnly(submittedBookingDate);
|
||||||
|
const derived = derivedFromTx || derivedFromBooking;
|
||||||
|
if (derived && (derived.financialYear !== financialYear || derived.quarter !== quarter)) {
|
||||||
|
const altRows = await getLatest26asRowsForQuarter(tanNumber, derived.financialYear, derived.quarter);
|
||||||
|
if (altRows.length > 0) {
|
||||||
|
logger.warn(
|
||||||
|
`[Form16] FY/Quarter retry using OCR date-derived period. TAN=${tanNumber}. OCR FY=${financialYear},Q=${quarter} → derived FY=${derived.financialYear},Q=${derived.quarter}.`
|
||||||
|
);
|
||||||
|
financialYear = derived.financialYear;
|
||||||
|
quarter = derived.quarter;
|
||||||
|
latestRows = altRows;
|
||||||
|
await submission.update({ financialYear, quarter });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregated26as = latestRows.reduce((sum, r) => sum + (r.taxDeducted || 0), 0);
|
||||||
|
|
||||||
|
const hasPanColumn = await isPanNumberColumnPresent();
|
||||||
|
if (normalizedSubmittedPan && hasPanColumn && latestRows.length > 0) {
|
||||||
|
const hasPanMatch = latestRows.some((r) => r.panNumber && r.panNumber === normalizedSubmittedPan);
|
||||||
|
if (!hasPanMatch) {
|
||||||
|
logger.warn(
|
||||||
|
`[Form16] 26AS MATCH RESULT: FAILED – PAN mismatch. TAN=${tanNumber}, PAN(Form16A)=${normalizedSubmittedPan}, FY=${financialYear}, Quarter=${quarter}.`
|
||||||
|
);
|
||||||
|
await submission.update({
|
||||||
|
validationStatus: 'failed',
|
||||||
|
validationNotes: `PAN mismatch with latest 26AS for TAN no - ${tanNumber}. Form 16A PAN: ${normalizedSubmittedPan}.`,
|
||||||
|
});
|
||||||
|
return { validationStatus: 'failed', validationNotes: 'PAN mismatch with latest 26AS.' };
|
||||||
|
}
|
||||||
|
} else if (normalizedSubmittedPan && !hasPanColumn) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[Form16] 26AS MATCH RESULT: FAILED – No 26AS data. Form 16A: TAN=${tanNumber}, FY=${financialYear}, Quarter=${quarter}, TDS amount=${tdsAmount} | 26AS: no records for this TAN/FY/Quarter (Section 194Q, Booking F/O).`
|
`[Form16] PAN strict check skipped because DB column tds_26as_entries.pan_number is missing. TAN=${tanNumber}, FY=${financialYear}, Quarter=${quarter}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestRows.length === 0) {
|
||||||
|
// Provide actionable debug info so we can see why latestRows became empty
|
||||||
|
// (section_code not 194Q, status not F/O, FY/Quarter mismatch, or TAN mismatch).
|
||||||
|
let debugNotes = '';
|
||||||
|
try {
|
||||||
|
const dbg = await get26asCoverageDebug(tanNumber, financialYear, quarter);
|
||||||
|
debugNotes = ` | DEBUG 26AS coverage: total=${dbg.totalRows}, matching(194Q & F/O)=${dbg.matchingRows}. Top breakdown: ${dbg.breakdownLines || '(none)'}`;
|
||||||
|
} catch (e: any) {
|
||||||
|
debugNotes = ` | DEBUG 26AS coverage query failed: ${e?.message || String(e)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`[Form16] 26AS MATCH RESULT: FAILED – No 26AS data. Form 16A: TAN=${tanNumber}, FY=${financialYear}, Quarter=${quarter}, TDS amount=${tdsAmount} | 26AS: no records for this TAN/FY/Quarter (Section 194Q, Booking F/O).${debugNotes}`
|
||||||
);
|
);
|
||||||
await submission.update({
|
await submission.update({
|
||||||
validationStatus: 'failed',
|
validationStatus: 'failed',
|
||||||
validationNotes: `No 26AS data found for TAN no - ${tanNumber}, financial year and quarter. Please ensure 26AS has been uploaded for this period.`,
|
validationNotes: `No 26AS data found for TAN no - ${tanNumber}, financial year and quarter. Please ensure 26AS has been uploaded for this period.${debugNotes}`,
|
||||||
});
|
});
|
||||||
return { validationStatus: 'failed', validationNotes: `No 26AS record found for this TAN no - ${tanNumber}, financial year and quarter.` };
|
return {
|
||||||
|
validationStatus: 'failed',
|
||||||
|
validationNotes: `No 26AS record found for this TAN no - ${tanNumber}, financial year and quarter.${debugNotes}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const amountTolerance = 1; // allow 1 rupee rounding
|
// Validate against quarter-level aggregate from latest upload.
|
||||||
if (Math.abs(tdsAmount - aggregated26as) > amountTolerance) {
|
// 26AS has many transaction lines; we compare submitted totals against aggregated totals.
|
||||||
|
const aggregatedAmountPaid = latestRows.reduce((sum, r) => sum + (r.amountPaid || 0), 0);
|
||||||
|
const aggregatedTaxDeducted = latestRows.reduce((sum, r) => sum + (r.taxDeducted || 0), 0);
|
||||||
|
const aggregatedTdsDeposited = latestRows.reduce((sum, r) => sum + (r.totalTdsDeposited ?? r.taxDeducted ?? 0), 0);
|
||||||
|
|
||||||
|
if (
|
||||||
|
submittedAmountPaid != null &&
|
||||||
|
Math.abs(submittedAmountPaid - aggregatedAmountPaid) > AMOUNT_MATCH_TOLERANCE
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
`[Form16] 26AS MATCH RESULT: FAILED – Amount paid mismatch. TAN=${tanNumber}, PAN=${submittedPan || '(not available)'}, FY=${financialYear}, Quarter=${quarter}. Form16A amountPaid=${submittedAmountPaid}, 26AS latest aggregate amountPaid=${aggregatedAmountPaid}.`
|
||||||
|
);
|
||||||
|
await submission.update({
|
||||||
|
validationStatus: 'failed',
|
||||||
|
validationNotes:
|
||||||
|
`Amount paid mismatch with latest 26AS for TAN no - ${tanNumber}. Form 16A amount paid: ${submittedAmountPaid}. Latest 26AS aggregated amount paid for this quarter: ${aggregatedAmountPaid}.`,
|
||||||
|
});
|
||||||
|
return { validationStatus: 'failed', validationNotes: 'Amount paid mismatch with latest 26AS.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
submittedTaxDeducted != null &&
|
||||||
|
Math.abs(submittedTaxDeducted - aggregatedTaxDeducted) > AMOUNT_MATCH_TOLERANCE
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
`[Form16] 26AS MATCH RESULT: FAILED – Tax deducted mismatch. TAN=${tanNumber}, PAN=${submittedPan || '(not available)'}, FY=${financialYear}, Quarter=${quarter}. Form16A taxDeducted=${submittedTaxDeducted}, 26AS latest aggregate taxDeducted=${aggregatedTaxDeducted}.`
|
||||||
|
);
|
||||||
|
await submission.update({
|
||||||
|
validationStatus: 'failed',
|
||||||
|
validationNotes:
|
||||||
|
`Tax deducted mismatch with latest 26AS for TAN no - ${tanNumber}. Form 16A tax deducted: ${submittedTaxDeducted}. Latest 26AS aggregated tax deducted for this quarter: ${aggregatedTaxDeducted}.`,
|
||||||
|
});
|
||||||
|
return { validationStatus: 'failed', validationNotes: 'Tax deducted mismatch with latest 26AS.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
submittedTdsDeposited != null &&
|
||||||
|
Math.abs(submittedTdsDeposited - aggregatedTdsDeposited) > AMOUNT_MATCH_TOLERANCE
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
`[Form16] 26AS MATCH RESULT: FAILED – TDS deposited mismatch. TAN=${tanNumber}, PAN=${submittedPan || '(not available)'}, FY=${financialYear}, Quarter=${quarter}. Form16A tdsDeposited=${submittedTdsDeposited}, 26AS latest aggregate tdsDeposited=${aggregatedTdsDeposited}.`
|
||||||
|
);
|
||||||
|
await submission.update({
|
||||||
|
validationStatus: 'failed',
|
||||||
|
validationNotes:
|
||||||
|
`TDS deposited mismatch with latest 26AS for TAN no - ${tanNumber}. Form 16A TDS deposited: ${submittedTdsDeposited}. Latest 26AS aggregated TDS deposited for this quarter: ${aggregatedTdsDeposited}.`,
|
||||||
|
});
|
||||||
|
return { validationStatus: 'failed', validationNotes: 'TDS deposited mismatch with latest 26AS.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional date checks: if OCR extracted transaction/booking date, at least one latest-upload row should contain that date.
|
||||||
|
if (submittedTransactionDate) {
|
||||||
|
const hasTxDate = latestRows.some((r) => normalizeDateOnly(r.transactionDate) === submittedTransactionDate);
|
||||||
|
if (!hasTxDate) {
|
||||||
|
await submission.update({
|
||||||
|
validationStatus: 'failed',
|
||||||
|
validationNotes:
|
||||||
|
`Transaction date mismatch with latest 26AS for TAN no - ${tanNumber}. No latest 26AS transaction found with date ${submittedTransactionDate}.`,
|
||||||
|
});
|
||||||
|
return { validationStatus: 'failed', validationNotes: 'Transaction date mismatch with latest 26AS.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (submittedBookingDate) {
|
||||||
|
const hasBookingDate = latestRows.some((r) => normalizeDateOnly(r.dateOfBooking) === submittedBookingDate);
|
||||||
|
if (!hasBookingDate) {
|
||||||
|
await submission.update({
|
||||||
|
validationStatus: 'failed',
|
||||||
|
validationNotes:
|
||||||
|
`Booking date mismatch with latest 26AS for TAN no - ${tanNumber}. No latest 26AS record found with booking date ${submittedBookingDate}.`,
|
||||||
|
});
|
||||||
|
return { validationStatus: 'failed', validationNotes: 'Booking date mismatch with latest 26AS.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(tdsAmount - aggregated26as) > AMOUNT_MATCH_TOLERANCE) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[Form16] 26AS MATCH RESULT: FAILED – Amount mismatch. Form 16A TDS amount=${tdsAmount} | 26AS aggregated amount (quarter)=${aggregated26as} | TAN=${tanNumber}, FY=${financialYear}, Quarter=${quarter}. Difference=${Math.abs(tdsAmount - aggregated26as).toFixed(2)}.`
|
`[Form16] 26AS MATCH RESULT: FAILED – Amount mismatch. Form 16A TDS amount=${tdsAmount} | 26AS aggregated amount (quarter)=${aggregated26as} | TAN=${tanNumber}, FY=${financialYear}, Quarter=${quarter}. Difference=${Math.abs(tdsAmount - aggregated26as).toFixed(2)}.`
|
||||||
);
|
);
|
||||||
@ -594,7 +945,7 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
|
|||||||
if (qStatus && qStatus.status === 'SETTLED' && qStatus.lastCreditNoteId) {
|
if (qStatus && qStatus.status === 'SETTLED' && qStatus.lastCreditNoteId) {
|
||||||
const lastCn = await Form16CreditNote.findByPk(qStatus.lastCreditNoteId, { attributes: ['amount'] });
|
const lastCn = await Form16CreditNote.findByPk(qStatus.lastCreditNoteId, { attributes: ['amount'] });
|
||||||
const lastAmount = lastCn ? parseFloat((lastCn as any).amount as string) : 0;
|
const lastAmount = lastCn ? parseFloat((lastCn as any).amount as string) : 0;
|
||||||
if (Math.abs(lastAmount - tdsAmount) <= amountTolerance) {
|
if (Math.abs(lastAmount - tdsAmount) <= AMOUNT_MATCH_TOLERANCE) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[Form16] 26AS MATCH RESULT: DUPLICATE – Quarter already settled. Form 16A TDS amount=${tdsAmount} | Existing credit note amount=${lastAmount} | TAN=${tanNumber}, FY=${financialYear}, Quarter=${quarter}. No new credit note issued.`
|
`[Form16] 26AS MATCH RESULT: DUPLICATE – Quarter already settled. Form 16A TDS amount=${tdsAmount} | Existing credit note amount=${lastAmount} | TAN=${tanNumber}, FY=${financialYear}, Quarter=${quarter}. No new credit note issued.`
|
||||||
);
|
);
|
||||||
@ -640,7 +991,7 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
|
|||||||
validationNotes: null,
|
validationNotes: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Push Form 16 credit note incoming CSV to WFM INCOMING/WFM_MAIN/FORM16_CRDT (SAP credit note generation – exact fields only)
|
// Push Form 16 credit note incoming CSV to WFM INCOMING/WFM_MAIN/FORM16
|
||||||
try {
|
try {
|
||||||
const trnsUniqNo = `F16-CN-${submission.id}-${creditNote.id}-${Date.now()}`;
|
const trnsUniqNo = `F16-CN-${submission.id}-${creditNote.id}-${Date.now()}`;
|
||||||
await creditNote.update({ trnsUniqNo });
|
await creditNote.update({ trnsUniqNo });
|
||||||
@ -651,17 +1002,18 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
|
|||||||
TRNS_UNIQ_NO: trnsUniqNo,
|
TRNS_UNIQ_NO: trnsUniqNo,
|
||||||
TDS_TRNS_ID: cnNumber,
|
TDS_TRNS_ID: cnNumber,
|
||||||
DEALER_CODE: padDealerCode(dealerCode),
|
DEALER_CODE: padDealerCode(dealerCode),
|
||||||
TDS_TRNS_DOC_TYP: 'ZTDS',
|
TDS_TRNS_DOC_TYPE: 'ZTDS',
|
||||||
DLR_TAN_NO: tanNumber,
|
DLR_TAN_NO: tanNumber,
|
||||||
'FIN_YEAR & QUARTER': finYearAndQuarter,
|
'FIN_YEAR&QUARTER': finYearAndQuarter,
|
||||||
DOC_DATE: docDate,
|
DOC_DATE: docDate,
|
||||||
TDS_AMT: Number(tdsAmount).toFixed(2),
|
TDS_AMT: `+${Number(Math.abs(tdsAmount)).toFixed(2)}`,
|
||||||
|
TDS_CERTIFICATE_NO: certificateNumber,
|
||||||
};
|
};
|
||||||
const fileName = `${cnNumber}.csv`;
|
const fileName = `${cnNumber}.csv`;
|
||||||
await wfmFileService.generateForm16IncomingCSV([csvRow], fileName, 'credit');
|
await wfmFileService.generateForm16IncomingCSV([csvRow], fileName, 'credit');
|
||||||
logger.info(`[Form16] Credit note CSV pushed to WFM FORM16_CRDT: ${cnNumber}`);
|
logger.info(`[Form16] Credit note CSV pushed to WFM FORM16: ${cnNumber}`);
|
||||||
} catch (csvErr: any) {
|
} catch (csvErr: any) {
|
||||||
logger.error('[Form16] Failed to push credit note CSV to WFM FORM16_CRDT:', csvErr?.message || csvErr);
|
logger.error('[Form16] Failed to push credit note CSV to WFM FORM16:', csvErr?.message || csvErr);
|
||||||
// Do not fail the flow; credit note and ledger are already created
|
// Do not fail the flow; credit note and ledger are already created
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -675,17 +1027,23 @@ export async function createSubmission(
|
|||||||
userId: string,
|
userId: string,
|
||||||
fileBuffer: Buffer,
|
fileBuffer: Buffer,
|
||||||
originalName: string,
|
originalName: string,
|
||||||
body: CreateForm16SubmissionBody
|
body: CreateForm16SubmissionBody,
|
||||||
|
userEmail?: string | null
|
||||||
): Promise<CreateForm16SubmissionResult> {
|
): Promise<CreateForm16SubmissionResult> {
|
||||||
// Dealer: dealer code from users.employee_number (getDealerCodeForUser) or body.dealerCode for RE/UAT. Used for submission, credit note, and debit note.
|
// 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 resolvedDealerCode = await getDealerCodeForUser(userId, userEmail);
|
||||||
const overrideDealerCode = (body.dealerCode || '').trim() || null;
|
const overrideDealerCode = (body.dealerCode || '').trim() || null;
|
||||||
const dealerCode = resolvedDealerCode || overrideDealerCode;
|
const dealerCode = resolvedDealerCode || overrideDealerCode;
|
||||||
|
// Frontend may not provide dealerCode (dealer code may be absent in Form16/26AS),
|
||||||
|
// so we must not hard-fail. Use a deterministic fallback so note generation can continue.
|
||||||
|
const effectiveDealerCode = dealerCode || '000000';
|
||||||
if (!dealerCode) {
|
if (!dealerCode) {
|
||||||
throw new Error('dealerCode is required to submit Form 16.');
|
logger.warn(
|
||||||
|
'[Form16] dealerCode not resolved for submission; using fallback dealerCode="000000". Matching uses TAN/FY/Quarter, but note numbering/CSV dealer code will be for fallback.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const version = await getNextVersionForDealerFyQuarter(dealerCode, body.financialYear, body.quarter);
|
const version = await getNextVersionForDealerFyQuarter(effectiveDealerCode, body.financialYear, body.quarter);
|
||||||
|
|
||||||
const requestNumber = await generateRequestNumber();
|
const requestNumber = await generateRequestNumber();
|
||||||
const title = version > 1
|
const title = version > 1
|
||||||
@ -740,7 +1098,7 @@ export async function createSubmission(
|
|||||||
const safeStr = (s: string, max: number) => (s ?? '').slice(0, max);
|
const safeStr = (s: string, max: number) => (s ?? '').slice(0, max);
|
||||||
const submission = await Form16aSubmission.create({
|
const submission = await Form16aSubmission.create({
|
||||||
requestId,
|
requestId,
|
||||||
dealerCode: safeStr(dealerCode, 50),
|
dealerCode: safeStr(effectiveDealerCode, 50),
|
||||||
form16aNumber: safeStr(body.form16aNumber, 50),
|
form16aNumber: safeStr(body.form16aNumber, 50),
|
||||||
financialYear: safeStr(body.financialYear, 20),
|
financialYear: safeStr(body.financialYear, 20),
|
||||||
quarter: safeStr(body.quarter, 10),
|
quarter: safeStr(body.quarter, 10),
|
||||||
@ -886,16 +1244,19 @@ export async function listAllCreditNotesForRe(filters?: { financialYear?: string
|
|||||||
let sapSet = new Set<number>();
|
let sapSet = new Set<number>();
|
||||||
if (hasTrnsUniqNoColumn && noteIds.length) {
|
if (hasTrnsUniqNoColumn && noteIds.length) {
|
||||||
try {
|
try {
|
||||||
|
const creditNotes = await Form16CreditNote.findAll({
|
||||||
|
where: { id: { [Op.in]: noteIds } },
|
||||||
|
attributes: ['id', 'creditNoteNumber'],
|
||||||
|
raw: true,
|
||||||
|
}) as any[];
|
||||||
|
const creditNumbers = creditNotes.map((n) => n.creditNoteNumber).filter(Boolean);
|
||||||
const sapRows = await (Form16SapResponse as any).findAll({
|
const sapRows = await (Form16SapResponse as any).findAll({
|
||||||
where: {
|
where: { tdsTransId: { [Op.in]: creditNumbers } },
|
||||||
type: 'credit',
|
attributes: ['tdsTransId'],
|
||||||
creditNoteId: { [Op.in]: noteIds },
|
|
||||||
[Op.or]: [{ storageUrl: { [Op.ne]: null } }, { sapDocumentNumber: { [Op.ne]: null } }],
|
|
||||||
},
|
|
||||||
attributes: ['creditNoteId'],
|
|
||||||
raw: true,
|
raw: true,
|
||||||
});
|
});
|
||||||
sapSet = new Set((sapRows as any[]).map((r) => r.creditNoteId));
|
const available = new Set((sapRows as any[]).map((r) => String(r.tdsTransId)));
|
||||||
|
sapSet = new Set(creditNotes.filter((n) => available.has(String(n.creditNoteNumber))).map((n) => Number(n.id)));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logger.warn('[Form16] SAP response lookup failed (will treat as unavailable):', e?.message || e);
|
logger.warn('[Form16] SAP response lookup failed (will treat as unavailable):', e?.message || e);
|
||||||
sapSet = new Set<number>();
|
sapSet = new Set<number>();
|
||||||
@ -1002,15 +1363,19 @@ export async function listAllDebitNotesForRe(filters?: { financialYear?: string;
|
|||||||
let sapSet = new Set<number>();
|
let sapSet = new Set<number>();
|
||||||
if (noteIds.length) {
|
if (noteIds.length) {
|
||||||
try {
|
try {
|
||||||
const sapRows = await (Form16DebitNoteSapResponse as any).findAll({
|
const debitNotes = await Form16DebitNote.findAll({
|
||||||
where: {
|
where: { id: { [Op.in]: noteIds } },
|
||||||
debitNoteId: { [Op.in]: noteIds },
|
attributes: ['id', 'debitNoteNumber'],
|
||||||
[Op.or]: [{ storageUrl: { [Op.ne]: null } }, { sapDocumentNumber: { [Op.ne]: null } }],
|
raw: true,
|
||||||
},
|
}) as any[];
|
||||||
attributes: ['debitNoteId'],
|
const debitNumbers = debitNotes.map((n) => n.debitNoteNumber).filter(Boolean);
|
||||||
|
const sapRows = await (Form16SapResponse as any).findAll({
|
||||||
|
where: { tdsTransId: { [Op.in]: debitNumbers } },
|
||||||
|
attributes: ['tdsTransId'],
|
||||||
raw: true,
|
raw: true,
|
||||||
});
|
});
|
||||||
sapSet = new Set((sapRows as any[]).map((r) => r.debitNoteId ?? r.debit_note_id));
|
const available = new Set((sapRows as any[]).map((r) => String(r.tdsTransId)));
|
||||||
|
sapSet = new Set(debitNotes.filter((n) => available.has(String(n.debitNoteNumber))).map((n) => Number(n.id)));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logger.warn('[Form16] Debit SAP response lookup failed (will treat as unavailable):', e?.message || e);
|
logger.warn('[Form16] Debit SAP response lookup failed (will treat as unavailable):', e?.message || e);
|
||||||
sapSet = new Set<number>();
|
sapSet = new Set<number>();
|
||||||
@ -1490,20 +1855,15 @@ export async function getCreditNoteById(creditNoteId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCreditNoteSapResponseUrl(creditNoteId: number): Promise<string | null> {
|
export async function getCreditNoteSapResponseUrl(creditNoteId: number): Promise<string | null> {
|
||||||
const row = await (Form16SapResponse as any).findOne({
|
// API-backed CSV generation is used now; URL is deterministic when SAP response exists.
|
||||||
where: { type: 'credit', creditNoteId, storageUrl: { [Op.ne]: null } },
|
const row = await getCreditNoteSapResponse(creditNoteId);
|
||||||
attributes: ['storageUrl', 'createdAt'],
|
return row ? `/api/v1/form16/credit-notes/${creditNoteId}/sap-response/csv` : null;
|
||||||
order: [['createdAt', 'DESC']],
|
|
||||||
});
|
|
||||||
const url = row?.storageUrl;
|
|
||||||
return url && String(url).trim() ? String(url) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Form16SapResponseView {
|
export interface Form16SapResponseView {
|
||||||
fileName: string | null;
|
fileName: string | null;
|
||||||
trnsUniqNo: string | null;
|
trnsUniqNo: string | null;
|
||||||
tdsTransId: string | null;
|
tdsTransId: string | null;
|
||||||
claimNumber: string | null;
|
|
||||||
sapDocumentNumber: string | null;
|
sapDocumentNumber: string | null;
|
||||||
msgTyp: string | null;
|
msgTyp: string | null;
|
||||||
message: string | null;
|
message: string | null;
|
||||||
@ -1519,61 +1879,48 @@ function mapSapResponseView(row: any): Form16SapResponseView {
|
|||||||
fileName: row?.fileName ?? null,
|
fileName: row?.fileName ?? null,
|
||||||
trnsUniqNo: row?.trnsUniqNo ?? null,
|
trnsUniqNo: row?.trnsUniqNo ?? null,
|
||||||
tdsTransId: row?.tdsTransId ?? null,
|
tdsTransId: row?.tdsTransId ?? null,
|
||||||
claimNumber: row?.claimNumber ?? null,
|
sapDocumentNumber: row?.docNo ?? null,
|
||||||
sapDocumentNumber: row?.sapDocumentNumber ?? null,
|
|
||||||
msgTyp: row?.msgTyp ?? null,
|
msgTyp: row?.msgTyp ?? null,
|
||||||
message: row?.message ?? null,
|
message: row?.message ?? null,
|
||||||
docDate: row?.docDate ?? null,
|
docDate: null,
|
||||||
tdsAmt: row?.tdsAmt ?? null,
|
tdsAmt: null,
|
||||||
storageUrl: row?.storageUrl ?? null,
|
storageUrl: null,
|
||||||
createdAt: row?.createdAt ?? null,
|
createdAt: row?.createdAt ?? null,
|
||||||
updatedAt: row?.updatedAt ?? null,
|
updatedAt: row?.updatedAt ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCreditNoteSapResponse(creditNoteId: number): Promise<Form16SapResponseView | null> {
|
export async function getCreditNoteSapResponse(creditNoteId: number): Promise<Form16SapResponseView | null> {
|
||||||
|
const cn = await Form16CreditNote.findByPk(creditNoteId, { attributes: ['id', 'creditNoteNumber'] });
|
||||||
|
const creditNoteNumber = (cn as any)?.creditNoteNumber;
|
||||||
|
if (!creditNoteNumber) return null;
|
||||||
const row = await (Form16SapResponse as any).findOne({
|
const row = await (Form16SapResponse as any).findOne({
|
||||||
where: {
|
where: {
|
||||||
type: 'credit',
|
tdsTransId: creditNoteNumber,
|
||||||
creditNoteId,
|
|
||||||
[Op.or]: [
|
|
||||||
{ storageUrl: { [Op.ne]: null } },
|
|
||||||
{ sapDocumentNumber: { [Op.ne]: null } },
|
|
||||||
{ trnsUniqNo: { [Op.ne]: null } },
|
|
||||||
{ tdsTransId: { [Op.ne]: null } },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
attributes: ['fileName', 'trnsUniqNo', 'tdsTransId', 'claimNumber', 'sapDocumentNumber', 'msgTyp', 'message', 'docDate', 'tdsAmt', 'storageUrl', 'createdAt', 'updatedAt'],
|
attributes: ['trnsUniqNo', 'tdsTransId', 'docNo', 'msgTyp', 'message', 'createdAt', 'updatedAt'],
|
||||||
order: [['createdAt', 'DESC']],
|
order: [['createdAt', 'DESC']],
|
||||||
});
|
});
|
||||||
return row ? mapSapResponseView(row) : null;
|
return row ? mapSapResponseView({ ...row.toJSON(), fileName: `${creditNoteNumber}.csv` }) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDebitNoteSapResponseUrl(debitNoteId: number): Promise<string | null> {
|
export async function getDebitNoteSapResponseUrl(debitNoteId: number): Promise<string | null> {
|
||||||
const row = await (Form16DebitNoteSapResponse as any).findOne({
|
const row = await getDebitNoteSapResponse(debitNoteId);
|
||||||
where: { debitNoteId, storageUrl: { [Op.ne]: null } },
|
return row ? `/api/v1/form16/debit-notes/${debitNoteId}/sap-response/csv` : null;
|
||||||
attributes: ['storageUrl', 'createdAt'],
|
|
||||||
order: [['createdAt', 'DESC']],
|
|
||||||
});
|
|
||||||
const url = row?.storageUrl;
|
|
||||||
return url && String(url).trim() ? String(url) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDebitNoteSapResponse(debitNoteId: number): Promise<Form16SapResponseView | null> {
|
export async function getDebitNoteSapResponse(debitNoteId: number): Promise<Form16SapResponseView | null> {
|
||||||
const row = await (Form16DebitNoteSapResponse as any).findOne({
|
const dn = await Form16DebitNote.findByPk(debitNoteId, { attributes: ['id', 'debitNoteNumber'] });
|
||||||
|
const debitNoteNumber = (dn as any)?.debitNoteNumber;
|
||||||
|
if (!debitNoteNumber) return null;
|
||||||
|
const row = await (Form16SapResponse as any).findOne({
|
||||||
where: {
|
where: {
|
||||||
debitNoteId,
|
tdsTransId: debitNoteNumber,
|
||||||
[Op.or]: [
|
|
||||||
{ storageUrl: { [Op.ne]: null } },
|
|
||||||
{ sapDocumentNumber: { [Op.ne]: null } },
|
|
||||||
{ trnsUniqNo: { [Op.ne]: null } },
|
|
||||||
{ tdsTransId: { [Op.ne]: null } },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
attributes: ['fileName', 'trnsUniqNo', 'tdsTransId', 'claimNumber', 'sapDocumentNumber', 'msgTyp', 'message', 'docDate', 'tdsAmt', 'storageUrl', 'createdAt', 'updatedAt'],
|
attributes: ['trnsUniqNo', 'tdsTransId', 'docNo', 'msgTyp', 'message', 'createdAt', 'updatedAt'],
|
||||||
order: [['createdAt', 'DESC']],
|
order: [['createdAt', 'DESC']],
|
||||||
});
|
});
|
||||||
return row ? mapSapResponseView(row) : null;
|
return row ? mapSapResponseView({ ...row.toJSON(), fileName: `${debitNoteNumber}.csv` }) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1954,6 +2301,7 @@ export async function list26asEntries(filters?: List26asFilters): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function create26asEntry(data: {
|
export async function create26asEntry(data: {
|
||||||
|
panNumber?: string;
|
||||||
tanNumber: string;
|
tanNumber: string;
|
||||||
deductorName?: string;
|
deductorName?: string;
|
||||||
quarter: string;
|
quarter: string;
|
||||||
@ -1969,7 +2317,8 @@ export async function create26asEntry(data: {
|
|||||||
statusOltas?: string;
|
statusOltas?: string;
|
||||||
remarks?: string;
|
remarks?: string;
|
||||||
}) {
|
}) {
|
||||||
const entry = await Tds26asEntry.create({
|
const includePanNumber = await isPanNumberColumnPresent();
|
||||||
|
const payload: any = {
|
||||||
tanNumber: data.tanNumber,
|
tanNumber: data.tanNumber,
|
||||||
deductorName: data.deductorName,
|
deductorName: data.deductorName,
|
||||||
quarter: data.quarter,
|
quarter: data.quarter,
|
||||||
@ -1984,13 +2333,17 @@ export async function create26asEntry(data: {
|
|||||||
dateOfBooking: data.dateOfBooking,
|
dateOfBooking: data.dateOfBooking,
|
||||||
statusOltas: data.statusOltas,
|
statusOltas: data.statusOltas,
|
||||||
remarks: data.remarks,
|
remarks: data.remarks,
|
||||||
});
|
};
|
||||||
|
if (includePanNumber && data.panNumber != null) payload.panNumber = data.panNumber;
|
||||||
|
|
||||||
|
const entry = await Tds26asEntry.create(payload);
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function update26asEntry(
|
export async function update26asEntry(
|
||||||
id: number,
|
id: number,
|
||||||
data: Partial<{
|
data: Partial<{
|
||||||
|
panNumber: string;
|
||||||
tanNumber: string;
|
tanNumber: string;
|
||||||
deductorName: string;
|
deductorName: string;
|
||||||
quarter: string;
|
quarter: string;
|
||||||
@ -2009,6 +2362,10 @@ export async function update26asEntry(
|
|||||||
) {
|
) {
|
||||||
const entry = await Tds26asEntry.findByPk(id);
|
const entry = await Tds26asEntry.findByPk(id);
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
|
if (data.panNumber != null) {
|
||||||
|
const includePanNumber = await isPanNumberColumnPresent();
|
||||||
|
if (!includePanNumber) delete (data as any).panNumber;
|
||||||
|
}
|
||||||
await entry.update(data);
|
await entry.update(data);
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
@ -2070,6 +2427,7 @@ function getCurrentFinancialYear(): string {
|
|||||||
function parse26asOfficialFormat(lines: string[]): { rows: any[]; errors: string[] } {
|
function parse26asOfficialFormat(lines: string[]): { rows: any[]; errors: string[] } {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const rows: any[] = [];
|
const rows: any[] = [];
|
||||||
|
let currentPAN = '';
|
||||||
let currentTAN = '';
|
let currentTAN = '';
|
||||||
let currentDeductorName = '';
|
let currentDeductorName = '';
|
||||||
const transactionDateRe = /^\d{1,2}-\w{3}-\d{4}$/i;
|
const transactionDateRe = /^\d{1,2}-\w{3}-\d{4}$/i;
|
||||||
@ -2084,6 +2442,14 @@ function parse26asOfficialFormat(lines: string[]): { rows: any[]; errors: string
|
|||||||
const c2 = cells[2];
|
const c2 = cells[2];
|
||||||
const c3 = cells[3];
|
const c3 = cells[3];
|
||||||
|
|
||||||
|
// Header block with PAN row:
|
||||||
|
// File Creation Date ^ Permanent Account Number (PAN) ^ ...
|
||||||
|
// 25-10-2024 ^ AAACE3883A ^ ...
|
||||||
|
if (cells.length >= 2 && /^[A-Z]{5}[0-9]{4}[A-Z]$/i.test(c1 || '')) {
|
||||||
|
currentPAN = String(c1).trim().toUpperCase();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Deductor summary: first cell is numeric (Sr No), third looks like TAN (e.g. AGRA13250G), then empties then amounts
|
// Deductor summary: first cell is numeric (Sr No), third looks like TAN (e.g. AGRA13250G), then empties then amounts
|
||||||
const srNoNum = /^\d+$/.test(c0);
|
const srNoNum = /^\d+$/.test(c0);
|
||||||
const looksLikeTan = c2 && c2.length >= 8 && /^[A-Z0-9]+$/i.test(c2);
|
const looksLikeTan = c2 && c2.length >= 8 && /^[A-Z0-9]+$/i.test(c2);
|
||||||
@ -2104,6 +2470,7 @@ function parse26asOfficialFormat(lines: string[]): { rows: any[]; errors: string
|
|||||||
const totalTds = parseDecimal(cells[9]);
|
const totalTds = parseDecimal(cells[9]);
|
||||||
const { financialYear, quarter } = dateToFyAndQuarter(cells[3]);
|
const { financialYear, quarter } = dateToFyAndQuarter(cells[3]);
|
||||||
rows.push({
|
rows.push({
|
||||||
|
panNumber: currentPAN || undefined,
|
||||||
tanNumber: currentTAN,
|
tanNumber: currentTAN,
|
||||||
deductorName: currentDeductorName || undefined,
|
deductorName: currentDeductorName || undefined,
|
||||||
quarter,
|
quarter,
|
||||||
@ -2156,6 +2523,7 @@ function buildColumnMap(headerCells: string[]): Record<string, number> {
|
|||||||
const norm = (s: string) => s.toLowerCase().replace(/[\s_-]+/g, '');
|
const norm = (s: string) => s.toLowerCase().replace(/[\s_-]+/g, '');
|
||||||
headerCells.forEach((cell, idx) => {
|
headerCells.forEach((cell, idx) => {
|
||||||
const n = norm(cell);
|
const n = norm(cell);
|
||||||
|
if (n === 'pan' || n.includes('pannumber') || (n.includes('permanent') && n.includes('account'))) map['panNumber'] = idx;
|
||||||
if (n.includes('tan') && !n.includes('amount')) map['tanNumber'] = idx;
|
if (n.includes('tan') && !n.includes('amount')) map['tanNumber'] = idx;
|
||||||
else if (n.includes('deductor') && (n.includes('name') || n.length < 20)) map['deductorName'] = idx;
|
else if (n.includes('deductor') && (n.includes('name') || n.length < 20)) map['deductorName'] = idx;
|
||||||
else if (n.includes('quarter') || n === 'q') map['quarter'] = idx;
|
else if (n.includes('quarter') || n === 'q') map['quarter'] = idx;
|
||||||
@ -2221,6 +2589,7 @@ export function parse26asTxtFile(buffer: Buffer): { rows: any[]; errors: string[
|
|||||||
const financialYear = get(cells, 'financialYear') || defaultFY;
|
const financialYear = get(cells, 'financialYear') || defaultFY;
|
||||||
const taxDeductedNum = parseDecimal(get(cells, 'taxDeducted')) ?? 0;
|
const taxDeductedNum = parseDecimal(get(cells, 'taxDeducted')) ?? 0;
|
||||||
rows.push({
|
rows.push({
|
||||||
|
panNumber: get(cells, 'panNumber') || undefined,
|
||||||
tanNumber,
|
tanNumber,
|
||||||
deductorName: get(cells, 'deductorName') || undefined,
|
deductorName: get(cells, 'deductorName') || undefined,
|
||||||
quarter,
|
quarter,
|
||||||
@ -2242,19 +2611,25 @@ export function parse26asTxtFile(buffer: Buffer): { rows: any[]; errors: string[
|
|||||||
|
|
||||||
/** Allowed fields for 26AS create – exclude timestamps so Sequelize sets them. */
|
/** Allowed fields for 26AS create – exclude timestamps so Sequelize sets them. */
|
||||||
const TDS_26AS_CREATE_KEYS = [
|
const TDS_26AS_CREATE_KEYS = [
|
||||||
'tanNumber', 'deductorName', 'quarter', 'financialYear', 'sectionCode',
|
'panNumber', 'tanNumber', 'deductorName', 'quarter', 'financialYear', 'sectionCode',
|
||||||
'amountPaid', 'taxDeducted', 'totalTdsDeposited', 'natureOfPayment',
|
'amountPaid', 'taxDeducted', 'totalTdsDeposited', 'natureOfPayment',
|
||||||
'transactionDate', 'dateOfBooking', 'assessmentYear', 'statusOltas', 'remarks', 'uploadLogId',
|
'transactionDate', 'dateOfBooking', 'assessmentYear', 'statusOltas', 'remarks', 'uploadLogId',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function build26asCreatePayload(row: Record<string, unknown>, uploadLogId?: number | null): Record<string, unknown> {
|
function build26asCreatePayload(
|
||||||
|
row: Record<string, unknown>,
|
||||||
|
uploadLogId: number | null | undefined,
|
||||||
|
includePanNumber: boolean
|
||||||
|
): Record<string, unknown> {
|
||||||
const payload: Record<string, unknown> = {};
|
const payload: Record<string, unknown> = {};
|
||||||
for (const k of TDS_26AS_CREATE_KEYS) {
|
for (const k of TDS_26AS_CREATE_KEYS) {
|
||||||
if (k === 'uploadLogId') continue;
|
if (k === 'uploadLogId') continue;
|
||||||
|
if (k === 'panNumber' && !includePanNumber) continue;
|
||||||
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 != null ? String(row.tanNumber).trim() : '') || '';
|
payload.tanNumber = normalizeTanNumber(row.tanNumber);
|
||||||
|
if (includePanNumber && row.panNumber != null) payload.panNumber = String(row.panNumber).trim().toUpperCase();
|
||||||
const rawFy = (row.financialYear != null ? String(row.financialYear).trim() : '') || '';
|
const rawFy = (row.financialYear != null ? String(row.financialYear).trim() : '') || '';
|
||||||
const rawQ = (row.quarter != null ? String(row.quarter).trim() : '') || 'Q1';
|
const rawQ = (row.quarter != null ? String(row.quarter).trim() : '') || 'Q1';
|
||||||
payload.financialYear = normalizeFinancialYear(rawFy) || rawFy;
|
payload.financialYear = normalizeFinancialYear(rawFy) || rawFy;
|
||||||
@ -2277,9 +2652,11 @@ export async function upload26asFile(buffer: Buffer, uploadLogId?: number | null
|
|||||||
}
|
}
|
||||||
const insertErrors: string[] = [];
|
const insertErrors: string[] = [];
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
|
|
||||||
|
const includePanNumber = await isPanNumberColumnPresent();
|
||||||
for (let i = 0; i < rows.length; i += TDS_26AS_BATCH_SIZE) {
|
for (let i = 0; i < rows.length; i += TDS_26AS_BATCH_SIZE) {
|
||||||
const batch = rows.slice(i, i + TDS_26AS_BATCH_SIZE);
|
const batch = rows.slice(i, i + TDS_26AS_BATCH_SIZE);
|
||||||
const payloads = batch.map((r) => build26asCreatePayload(r as Record<string, unknown>, uploadLogId));
|
const payloads = batch.map((r) => build26asCreatePayload(r as Record<string, unknown>, uploadLogId, includePanNumber));
|
||||||
try {
|
try {
|
||||||
const created = await Tds26asEntry.bulkCreate(payloads as any[], { validate: true });
|
const created = await Tds26asEntry.bulkCreate(payloads as any[], { validate: true });
|
||||||
imported += created.length;
|
imported += created.length;
|
||||||
@ -2359,29 +2736,29 @@ export async function process26asUploadAggregation(uploadLogId: number): Promise
|
|||||||
await setQuarterStatusDebitIssued(tanNumber, fy, q, debit.id);
|
await setQuarterStatusDebitIssued(tanNumber, fy, q, debit.id);
|
||||||
debitsCreated++;
|
debitsCreated++;
|
||||||
|
|
||||||
// Push Form 16 debit note CSV to WFM INCOMING/WFM_MAIN/FORM16_DEBT (same column set as credit note / SAP expectation)
|
// Push Form 16 debit note CSV to WFM INCOMING/WFM_MAIN/FORM16
|
||||||
try {
|
try {
|
||||||
const trnsUniqNo = `F16-DN-${creditNote.id}-${debit.id}-${Date.now()}`;
|
const trnsUniqNo = `F16-DN-${creditNote.id}-${debit.id}-${Date.now()}`;
|
||||||
await debit.update({ trnsUniqNo });
|
await debit.update({ trnsUniqNo });
|
||||||
const docDate = now.toISOString().slice(0, 10).replace(/-/g, '');
|
const docDate = now.toISOString().slice(0, 10).replace(/-/g, '');
|
||||||
const fyCompact = form16FyCompact(cnFy) || '';
|
const fyCompact = form16FyCompact(cnFy) || '';
|
||||||
const finYearAndQuarter = fyCompact && cnQuarter ? `FY ${fyCompact}_${cnQuarter}` : '';
|
const finYearAndQuarter = fyCompact && cnQuarter ? `FY_${fyCompact}_${cnQuarter}` : '';
|
||||||
const csvRow: Record<string, string | number> = {
|
const csvRow: Record<string, string | number> = {
|
||||||
TRNS_UNIQ_NO: trnsUniqNo,
|
TRNS_UNIQ_NO: trnsUniqNo,
|
||||||
TDS_TRNS_ID: creditNoteNumber,
|
TDS_TRNS_ID: debitNum,
|
||||||
DEALER_CODE: padDealerCode(dealerCode),
|
DEALER_CODE: padDealerCode(dealerCode),
|
||||||
TDS_TRNS_DOC_TYP: 'ZTDS',
|
TDS_TRNS_DOC_TYPE: 'ZTDS',
|
||||||
'Org.Document Number': debit.id,
|
|
||||||
DLR_TAN_NO: tanNumber,
|
DLR_TAN_NO: tanNumber,
|
||||||
'FIN_YEAR & QUARTER': finYearAndQuarter,
|
'FIN_YEAR&QUARTER': finYearAndQuarter,
|
||||||
DOC_DATE: docDate,
|
DOC_DATE: docDate,
|
||||||
TDS_AMT: Number(amount).toFixed(2),
|
TDS_AMT: `-${Math.abs(Number(amount)).toFixed(2)}`,
|
||||||
|
TDS_CERTIFICATE_NO: creditNoteCertNumber,
|
||||||
};
|
};
|
||||||
const fileName = `${debitNum}.csv`;
|
const fileName = `${debitNum}.csv`;
|
||||||
await wfmFileService.generateForm16IncomingCSV([csvRow], fileName, 'debit');
|
await wfmFileService.generateForm16IncomingCSV([csvRow], fileName, 'debit');
|
||||||
logger.info(`[Form16] Debit note CSV pushed to WFM FORM16_DEBT: ${debitNum}`);
|
logger.info(`[Form16] Debit note CSV pushed to WFM FORM16: ${debitNum}`);
|
||||||
} catch (csvErr: any) {
|
} catch (csvErr: any) {
|
||||||
logger.error('[Form16] Failed to push debit note CSV to WFM FORM16_DEBT:', csvErr?.message || csvErr);
|
logger.error('[Form16] Failed to push debit note CSV to WFM FORM16:', csvErr?.message || csvErr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -164,9 +164,22 @@ function resolveReminderTemplate(configTemplate: string | undefined): string {
|
|||||||
if (!t) return fallback;
|
if (!t) return fallback;
|
||||||
// Guard against swapped template in config.
|
// Guard against swapped template in config.
|
||||||
if (/\[duedate\]/i.test(t)) return fallback;
|
if (/\[duedate\]/i.test(t)) return fallback;
|
||||||
|
// Guard against legacy awkward template currently seen in UAT screenshots.
|
||||||
|
if (/form 16 submission is pending\.\s*\[name\],\s*\[request id\]\.\s*please review\.?/i.test(t)) return fallback;
|
||||||
|
// Enforce required placeholders to keep reminder body meaningful and consistent.
|
||||||
|
if (!/\[name\]/i.test(t) || !/\[request id\]/i.test(t)) return fallback;
|
||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeRequestRef(value: string | undefined): string {
|
||||||
|
const v = (value || '').trim();
|
||||||
|
if (!v) return '—';
|
||||||
|
// Avoid sending internal UUIDs in user-facing reminder templates.
|
||||||
|
const uuidV4Like = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
if (uuidV4Like.test(v)) return 'pending Form 16 request';
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
function getQuarterInfoForDate(d: Date): { financialYear: string; quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4' } {
|
function getQuarterInfoForDate(d: Date): { financialYear: string; quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4' } {
|
||||||
const year = d.getFullYear();
|
const year = d.getFullYear();
|
||||||
const month = d.getMonth() + 1; // 1..12
|
const month = d.getMonth() + 1; // 1..12
|
||||||
@ -342,7 +355,7 @@ export async function triggerForm16Reminder(dealerUserIds: string[], placeholder
|
|||||||
const template = resolveReminderTemplate(config.reminderNotificationTemplate);
|
const template = resolveReminderTemplate(config.reminderNotificationTemplate);
|
||||||
const body = replacePlaceholders(template, {
|
const body = replacePlaceholders(template, {
|
||||||
Name: placeholders?.name ?? 'Dealer',
|
Name: placeholders?.name ?? 'Dealer',
|
||||||
'Request ID': placeholders?.requestId ?? '—',
|
'Request ID': sanitizeRequestRef(placeholders?.requestId),
|
||||||
});
|
});
|
||||||
const { notificationService } = await import('./notification.service');
|
const { notificationService } = await import('./notification.service');
|
||||||
await notificationService.sendToUsers(dealerUserIds, {
|
await notificationService.sendToUsers(dealerUserIds, {
|
||||||
|
|||||||
@ -5,17 +5,19 @@ import logger from '../utils/logger';
|
|||||||
/** Default WFM folder names (joined with path.sep for current OS). */
|
/** Default WFM folder names (joined with path.sep for current OS). */
|
||||||
const DEFAULT_CLAIMS_INCOMING = path.join('WFM-QRE', 'INCOMING', 'WFM_MAIN', 'DLR_INC_CLAIMS');
|
const DEFAULT_CLAIMS_INCOMING = path.join('WFM-QRE', 'INCOMING', 'WFM_MAIN', 'DLR_INC_CLAIMS');
|
||||||
const DEFAULT_CLAIMS_OUTGOING = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'DLR_INC_CLAIMS');
|
const DEFAULT_CLAIMS_OUTGOING = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'DLR_INC_CLAIMS');
|
||||||
const DEFAULT_FORM16_CREDIT_INCOMING = path.join('WFM-QRE', 'INCOMING', 'WFM_MAIN', 'FORM16_CRDT');
|
const DEFAULT_FORM16_INCOMING_MAIN = path.join('WFM-QRE', 'INCOMING', 'WFM_MAIN', 'FORM16');
|
||||||
const DEFAULT_FORM16_DEBIT_INCOMING = path.join('WFM-QRE', 'INCOMING', 'WFM_MAIN', 'FORM16_DBT');
|
const DEFAULT_FORM16_INCOMING_ARCHIVE = path.join('WFM-QRE', 'INCOMING', 'WFM_ARCHIVE', 'FORM16');
|
||||||
const DEFAULT_FORM16_CREDIT_OUTGOING = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'FORM16_CRDT');
|
const DEFAULT_FORM16_OUTGOING_MAIN = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'FORM16');
|
||||||
const DEFAULT_FORM16_DEBIT_OUTGOING = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'FORM16_DBT');
|
const DEFAULT_FORM16_OUTGOING_ARCHIVE = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_ARCHIVE', 'FORM16');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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:
|
* Dealer claims use DLR_INC_CLAIMS; Form 16 uses unified folders:
|
||||||
* - FORM16_CRDT (credit) and FORM16_DEBT (debit) under INCOMING/WFM_MAIN
|
* - INCOMING/WFM_MAIN/FORM16
|
||||||
* - FORM16_CRDT (credit) and FORM16_DBT (debit) under OUTGOING/WFM_SAP_MAIN
|
* - INCOMING/WFM_ARCHIVE/FORM16
|
||||||
|
* - OUTGOING/WFM_SAP_MAIN/FORM16
|
||||||
|
* - OUTGOING/WFM_SAP_ARCHIVE/FORM16
|
||||||
* Paths are cross-platform; set WFM_BASE_PATH (and optional path overrides) in .env for production.
|
* Paths are cross-platform; set WFM_BASE_PATH (and optional path overrides) in .env for production.
|
||||||
*/
|
*/
|
||||||
export class WFMFileService {
|
export class WFMFileService {
|
||||||
@ -34,13 +36,13 @@ export class WFMFileService {
|
|||||||
private form16IncomingDebitPath: string;
|
private form16IncomingDebitPath: string;
|
||||||
private incomingArchiveForm16DebitPath: string;
|
private incomingArchiveForm16DebitPath: string;
|
||||||
|
|
||||||
// --- OUTGOING PATHS (WFM_SAP_MAIN) ---
|
// --- OUTGOING PATHS (WFM_SAP_MAIN / WFM_SAP_ARCHIVE) ---
|
||||||
private outgoingGstClaimsPath: string;
|
private outgoingGstClaimsPath: string;
|
||||||
private outgoingNonGstClaimsPath: string;
|
private outgoingNonGstClaimsPath: string;
|
||||||
|
|
||||||
/** Form 16 credit responses: OUTGOING/WFM_SAP_MAIN/FORM16_CRDT */
|
/** Form 16 credit responses: OUTGOING/WFM_SAP_MAIN/FORM16 */
|
||||||
private form16OutgoingCreditPath: string;
|
private form16OutgoingCreditPath: string;
|
||||||
/** Form 16 debit responses: OUTGOING/WFM_SAP_MAIN/FORM16_DBT */
|
/** Form 16 debit responses: OUTGOING/WFM_SAP_MAIN/FORM16 */
|
||||||
private form16OutgoingDebitPath: string;
|
private form16OutgoingDebitPath: string;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -53,27 +55,39 @@ export class WFMFileService {
|
|||||||
this.incomingNonGstClaimsPath = process.env.WFM_INCOMING_NON_GST_CLAIMS_PATH || DEFAULT_CLAIMS_INCOMING + '_NON_GST';
|
this.incomingNonGstClaimsPath = process.env.WFM_INCOMING_NON_GST_CLAIMS_PATH || DEFAULT_CLAIMS_INCOMING + '_NON_GST';
|
||||||
this.incomingArchiveNonGstClaimsPath = process.env.WFM_ARCHIVE_NON_GST_CLAIMS_PATH || path.join('WFM-QRE', 'INCOMING', 'WFM_ARACHIVE', 'DLR_INC_CLAIMS_NON_GST');
|
this.incomingArchiveNonGstClaimsPath = process.env.WFM_ARCHIVE_NON_GST_CLAIMS_PATH || path.join('WFM-QRE', 'INCOMING', 'WFM_ARACHIVE', 'DLR_INC_CLAIMS_NON_GST');
|
||||||
|
|
||||||
// Backwards-compatible: support legacy WFM_FORM16_INCOMING_PATH if specific credit/debit paths are not set
|
// Form16 unified path (credit/debit both use FORM16). Keep legacy vars as fallback.
|
||||||
const legacyForm16Incoming = process.env.WFM_FORM16_INCOMING_PATH;
|
const legacyForm16Incoming = process.env.WFM_FORM16_INCOMING_PATH;
|
||||||
|
const form16IncomingMain =
|
||||||
|
process.env.WFM_FORM16_INCOMING_MAIN_PATH ||
|
||||||
|
process.env.WFM_FORM16_CREDIT_INCOMING_PATH ||
|
||||||
|
process.env.WFM_FORM16_DEBIT_INCOMING_PATH ||
|
||||||
|
legacyForm16Incoming ||
|
||||||
|
DEFAULT_FORM16_INCOMING_MAIN;
|
||||||
|
const form16IncomingArchive =
|
||||||
|
process.env.WFM_FORM16_INCOMING_ARCHIVE_PATH ||
|
||||||
|
process.env.WFM_FORM16_CREDIT_ARCHIVE_PATH ||
|
||||||
|
process.env.WFM_FORM16_DEBIT_ARCHIVE_PATH ||
|
||||||
|
DEFAULT_FORM16_INCOMING_ARCHIVE;
|
||||||
|
|
||||||
this.form16IncomingCreditPath =
|
this.form16IncomingCreditPath = form16IncomingMain;
|
||||||
process.env.WFM_FORM16_CREDIT_INCOMING_PATH || legacyForm16Incoming || DEFAULT_FORM16_CREDIT_INCOMING;
|
this.form16IncomingDebitPath = form16IncomingMain;
|
||||||
this.incomingArchiveForm16CreditPath = process.env.WFM_FORM16_CREDIT_ARCHIVE_PATH || path.join('WFM-QRE', 'INCOMING', 'WFM_ARACHIVE', 'FORM16_CRDT');
|
this.incomingArchiveForm16CreditPath = form16IncomingArchive;
|
||||||
|
this.incomingArchiveForm16DebitPath = form16IncomingArchive;
|
||||||
this.form16IncomingDebitPath =
|
|
||||||
process.env.WFM_FORM16_DEBIT_INCOMING_PATH || legacyForm16Incoming || DEFAULT_FORM16_DEBIT_INCOMING;
|
|
||||||
this.incomingArchiveForm16DebitPath = process.env.WFM_FORM16_DEBIT_ARCHIVE_PATH || path.join('WFM-QRE', 'INCOMING', 'WFM_ARACHIVE', 'FORM16_DBT');
|
|
||||||
|
|
||||||
// Initialize Outgoing Paths from .env or defaults
|
// Initialize Outgoing Paths from .env or defaults
|
||||||
this.outgoingGstClaimsPath = process.env.WFM_OUTGOING_GST_CLAIMS_PATH || DEFAULT_CLAIMS_OUTGOING + '_GST';
|
this.outgoingGstClaimsPath = process.env.WFM_OUTGOING_GST_CLAIMS_PATH || DEFAULT_CLAIMS_OUTGOING + '_GST';
|
||||||
this.outgoingNonGstClaimsPath = process.env.WFM_OUTGOING_NON_GST_CLAIMS_PATH || DEFAULT_CLAIMS_OUTGOING + '_NON_GST';
|
this.outgoingNonGstClaimsPath = process.env.WFM_OUTGOING_NON_GST_CLAIMS_PATH || DEFAULT_CLAIMS_OUTGOING + '_NON_GST';
|
||||||
|
|
||||||
// Outgoing: allow specific credit/debit overrides; fall back to legacy single path for credit
|
// Outgoing unified path (credit/debit both use FORM16). Keep legacy vars as fallback.
|
||||||
const legacyForm16Outgoing = process.env.WFM_FORM16_OUTGOING_PATH;
|
const legacyForm16Outgoing = process.env.WFM_FORM16_OUTGOING_PATH;
|
||||||
this.form16OutgoingCreditPath =
|
const form16OutgoingMain =
|
||||||
process.env.WFM_FORM16_CREDIT_OUTGOING_PATH || legacyForm16Outgoing || DEFAULT_FORM16_CREDIT_OUTGOING;
|
process.env.WFM_FORM16_OUTGOING_MAIN_PATH ||
|
||||||
this.form16OutgoingDebitPath =
|
process.env.WFM_FORM16_CREDIT_OUTGOING_PATH ||
|
||||||
process.env.WFM_FORM16_DEBIT_OUTGOING_PATH || DEFAULT_FORM16_DEBIT_OUTGOING;
|
process.env.WFM_FORM16_DEBIT_OUTGOING_PATH ||
|
||||||
|
legacyForm16Outgoing ||
|
||||||
|
DEFAULT_FORM16_OUTGOING_MAIN;
|
||||||
|
this.form16OutgoingCreditPath = form16OutgoingMain;
|
||||||
|
this.form16OutgoingDebitPath = form16OutgoingMain;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -261,13 +275,11 @@ export class WFMFileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a CSV file for Form 16 (credit/debit note) and store in the appropriate INCOMING/WFM_MAIN folder.
|
* Generate a CSV file for Form 16 (credit/debit note) and store in INCOMING/WFM_MAIN/FORM16.
|
||||||
* - Credit: FORM16_CRDT
|
|
||||||
* - Debit: FORM16_DEBT
|
|
||||||
* Format: pipe (|) as column separator, no double quotes around values (SAP/WFM requirement).
|
* Format: pipe (|) as column separator, no double quotes around values (SAP/WFM requirement).
|
||||||
* @param data Array of one or more row objects (keys become header; use UPPER_SNAKE_CASE for column names)
|
* @param data Array of one or more row objects (keys become header; use UPPER_SNAKE_CASE for column names)
|
||||||
* @param fileName File name (e.g. CN-F-16-6282-24-25-Q1.csv or DN-F-16-6282-24-25-Q1.csv)
|
* @param fileName File name (e.g. CN-F-16-6282-24-25-Q1.csv or DN-F-16-6282-24-25-Q1.csv)
|
||||||
* @param type 'credit' (default) or 'debit' – selects FORM16_CRDT vs FORM16_DEBT
|
* @param type 'credit' (default) or 'debit' – logical type only; folder is unified FORM16
|
||||||
*/
|
*/
|
||||||
async generateForm16IncomingCSV(data: any[], fileName: string, type: 'credit' | 'debit' = 'credit'): Promise<string> {
|
async generateForm16IncomingCSV(data: any[], fileName: string, type: 'credit' | 'debit' = 'credit'): Promise<string> {
|
||||||
const maxRetries = 3;
|
const maxRetries = 3;
|
||||||
@ -330,8 +342,7 @@ export class WFMFileService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the absolute path for a Form 16 outgoing (response) file.
|
* Get the absolute path for a Form 16 outgoing (response) file.
|
||||||
* - Credit: WFM/WFM-QRE/OUTGOING/WFM_SAP_MAIN/FORM16_CRDT
|
* Both credit and debit use WFM/WFM-QRE/OUTGOING/WFM_SAP_MAIN/FORM16.
|
||||||
* - Debit: WFM/WFM-QRE/OUTGOING/WFM_SAP_MAIN/FORM16_DBT
|
|
||||||
*/
|
*/
|
||||||
getForm16OutgoingPath(fileName: string, type: 'credit' | 'debit' = 'credit'): string {
|
getForm16OutgoingPath(fileName: string, type: 'credit' | 'debit' = 'credit'): string {
|
||||||
const targetPath = type === 'debit' ? this.form16OutgoingDebitPath : this.form16OutgoingCreditPath;
|
const targetPath = type === 'debit' ? this.form16OutgoingDebitPath : this.form16OutgoingCreditPath;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user