Compare commits
2 Commits
0f99fe68d5
...
41b8b57efe
| Author | SHA1 | Date | |
|---|---|---|---|
| 41b8b57efe | |||
|
|
62ca4f985a |
@ -1 +1 @@
|
||||
import{a as s}from"./index-BgKXDGEk.js";import"./radix-vendor-CYvDqP9X.js";import"./charts-vendor-BVfwAPj-.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-CxsBWvVP.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-BATWUvr6.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};
|
||||
import{a as s}from"./index-BACqZT24.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};
|
||||
64
build/assets/index-BACqZT24.js
Normal file
64
build/assets/index-BACqZT24.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
1
build/assets/index-C9eBMrZm.css
Normal file
1
build/assets/index-C9eBMrZm.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
2
build/assets/ui-vendor-BrA5VgBk.js
Normal file
2
build/assets/ui-vendor-BrA5VgBk.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
@ -13,15 +13,15 @@
|
||||
<!-- Preload essential fonts and icons -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<script type="module" crossorigin src="/assets/index-BgKXDGEk.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BACqZT24.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-BVfwAPj-.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CYvDqP9X.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-CxsBWvVP.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-BrA5VgBk.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/router-vendor-BATWUvr6.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D2NzWWdB.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C9eBMrZm.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
137
docs/FORM16_CREDIT_DEBIT_PROCESS.md
Normal file
137
docs/FORM16_CREDIT_DEBIT_PROCESS.md
Normal file
@ -0,0 +1,137 @@
|
||||
# Form 16 – Full Process: Credit & Debit Notes (Incoming & Outgoing)
|
||||
|
||||
This document describes the end-to-end flow for Form 16 (Form 16A TDS Credit): 26AS reconciliation, credit/debit note creation, WFM/SAP incoming and outgoing file handling, and how users view SAP responses.
|
||||
|
||||
---
|
||||
|
||||
## 1. High-Level Flow
|
||||
|
||||
- **26AS**: TDS entries are uploaded (RE) and aggregated by TAN + Financial Year + Quarter (Section 194Q, Booking F/O only).
|
||||
- **Form 16A submission**: Dealer submits Form 16A (PDF). OCR extracts TAN, FY, Quarter, TDS amount, certificate number, etc.
|
||||
- **Credit note**: When a submission is validated, the system matches it against the latest 26AS aggregate for that TAN/FY/Quarter. On match, a **credit note** is created, ledger updated, quarter marked **SETTLED**, and a CSV is pushed to WFM **INCOMING** for SAP (credit note generation).
|
||||
- **Debit note**: When a new 26AS upload changes the quarter total and that quarter was already **SETTLED**, the system creates a **debit note** (reversing the earlier credit), updates ledger, sets quarter to **DEBIT_ISSUED_PENDING_FORM16**, and pushes a CSV to WFM **INCOMING** for SAP (debit note generation).
|
||||
- **SAP responses**: SAP processes the INCOMING files and drops response CSVs in WFM **OUTGOING**. The backend ingests these (scheduler every 5 min or on-demand Pull), stores them in DB, and users can **View** (and for credit, **Download**) the SAP response.
|
||||
|
||||
---
|
||||
|
||||
## 2. Paths (WFM Folder Structure)
|
||||
|
||||
All paths are relative to **WFM_BASE_PATH** (default `C:\WFM`). Can be overridden via `.env` (e.g. `WFM_BASE_PATH=D:\Form-16 Main`). The job also tries `<process.cwd()>\WFM-QRE\...` if the default path does not exist.
|
||||
|
||||
| Direction | Type | Default path (under WFM_BASE_PATH) |
|
||||
|------------|--------|-------------------------------------|
|
||||
| **INCOMING** | Credit | `WFM-QRE\INCOMING\WFM_MAIN\FORM16_CRDT` |
|
||||
| **INCOMING** | Debit | `WFM-QRE\INCOMING\WFM_MAIN\FORM16_DEBT` |
|
||||
| **OUTGOING** | Credit | `WFM-QRE\OUTGOING\WFM_SAP_MAIN\FORM16_CRDT` |
|
||||
| **OUTGOING** | Debit | `WFM-QRE\OUTGOING\WFM_SAP_MAIN\FORM16_DBT` |
|
||||
|
||||
- **INCOMING** = files we **push** to WFM (for SAP to pick up and process).
|
||||
- **OUTGOING** = files **SAP drops** (responses); we read and store them.
|
||||
|
||||
---
|
||||
|
||||
## 3. Credit Note Flow
|
||||
|
||||
### 3.1 When is a credit note created?
|
||||
|
||||
- On **Form 16A submission validation** (after OCR and 26AS check).
|
||||
- `run26asMatchAndCreditNote(submission)` is called (e.g. from submission validation flow).
|
||||
- Conditions: TAN + FY + Quarter match latest 26AS aggregate (Section 194Q, F/O), amount within tolerance, quarter not already settled with same amount.
|
||||
- On success: create `Form16CreditNote`, ledger entry (CREDIT), set quarter status **SETTLED**, then push **INCOMING** CSV.
|
||||
|
||||
### 3.2 Credit note – INCOMING (we push to WFM/SAP)
|
||||
|
||||
- **Path**: `WFM-QRE\INCOMING\WFM_MAIN\FORM16_CRDT`
|
||||
- **When**: Immediately after credit note is created.
|
||||
- **File name**: `{creditNoteNumber}.csv` (e.g. `CN00628226Q20001.csv`).
|
||||
- **Content** (pipe `|` separated):
|
||||
`TRNS_UNIQ_NO` (e.g. `F16-CN-{submissionId}-{creditNoteId}-{timestamp}`),
|
||||
`TDS_TRNS_ID` (= credit note number),
|
||||
`DEALER_CODE`, `TDS_TRNS_DOC_TYP`, `DLR_TAN_NO`, `FIN_YEAR & QUARTER`, `DOC_DATE`, `TDS_AMT`.
|
||||
- **TDS_TRNS_ID** = credit note number (format: `CN` + 6-digit dealer code + 2-digit FY + quarter + 4-digit sequence, e.g. `CN00628226Q20001`).
|
||||
- A copy is also written to the Form 16 credit archive path (INCOMING archive).
|
||||
|
||||
### 3.3 Credit note – OUTGOING (SAP response)
|
||||
|
||||
- **Path**: `WFM-QRE\OUTGOING\WFM_SAP_MAIN\FORM16_CRDT`
|
||||
- **Who writes**: SAP (response CSVs placed here by SAP/WFM).
|
||||
- **Who reads**: Backend **Form 16 SAP response job** (scheduler every 5 min + on **Pull** button).
|
||||
- **What we do**: Read each CSV, parse first “real” data row, match to credit note by `TRNS_UNIQ_NO` or `creditNoteNumber` (TDS_TRNS_ID in response), upload file to storage, insert/update row in **`form16_sap_responses`** with `type = 'credit'`, `credit_note_id`, `storage_url`, etc.
|
||||
- **User**: Credit notes list shows **View** when a response exists; **View** opens popup with SAP fields and **Download CSV**; **Pull** triggers ingestion and list refresh.
|
||||
|
||||
---
|
||||
|
||||
## 4. Debit Note Flow
|
||||
|
||||
### 4.1 When is a debit note created?
|
||||
|
||||
- On **26AS upload** that changes the quarter aggregate for a quarter that is already **SETTLED** (had a credit note).
|
||||
- `process26asUploadAggregation(uploadLogId)` is called after 26AS file upload (controller calls it when records are imported).
|
||||
- For each (TAN, FY, Quarter) where new 26AS total ≠ previous snapshot and status is SETTLED: create `Form16DebitNote` (linked to the last credit note for that quarter), ledger entry (DEBIT), set quarter status **DEBIT_ISSUED_PENDING_FORM16**, then push **INCOMING** CSV.
|
||||
|
||||
### 4.2 Debit note – INCOMING (we push to WFM/SAP)
|
||||
|
||||
- **Path**: `WFM-QRE\INCOMING\WFM_MAIN\FORM16_DEBT`
|
||||
- **When**: Immediately after debit note is created in `process26asUploadAggregation`.
|
||||
- **File name**: `{debitNoteNumber}.csv` (e.g. `DN00628226Q20001.csv`).
|
||||
- **Content** (pipe `|` separated):
|
||||
`TRNS_UNIQ_NO` (e.g. `F16-DN-{creditNoteId}-{debitId}-{timestamp}`),
|
||||
**`TDS_TRNS_ID`** = **credit note number** (not debit note number),
|
||||
`DEALER_CODE`, `TDS_TRNS_DOC_TYP`, `Org.Document Number` (= debit id), `DLR_TAN_NO`, `FIN_YEAR & QUARTER`, `DOC_DATE`, `TDS_AMT`.
|
||||
- **TDS_TRNS_ID** in debit incoming = credit note number (same format as credit, e.g. `CN00628226Q20001`). Debit note number = same string with `CN` replaced by `DN` (e.g. `DN00628226Q20001`).
|
||||
- A copy is also written to the Form 16 debit archive path.
|
||||
|
||||
### 4.3 Debit note – OUTGOING (SAP response)
|
||||
|
||||
- **Path**: `WFM-QRE\OUTGOING\WFM_SAP_MAIN\FORM16_DBT`
|
||||
- **Who writes**: SAP (response CSVs placed here).
|
||||
- **Who reads**: Same **Form 16 SAP response job** (every 5 min + **Pull** on Debit Notes page).
|
||||
- **What we do**: Read each CSV, parse, match to debit note by (in order):
|
||||
(1) `TRNS_UNIQ_NO` → `form_16_debit_notes.trns_uniq_no`,
|
||||
(2) `CLAIM_NUMBER` → `form_16_debit_notes.debit_note_number`,
|
||||
(3) **filename (without .csv)** → `form_16_debit_notes.debit_note_number`.
|
||||
Upload file to storage, insert/update row in **`form16_debit_note_sap_responses`** (separate table from credit) with `debit_note_id`, `storage_url`, etc.
|
||||
- **User**: Debit notes list shows **View** when a response exists; **View** opens popup (no download); **Pull** triggers ingestion and list refresh.
|
||||
|
||||
---
|
||||
|
||||
## 5. Database Tables for SAP Responses
|
||||
|
||||
| Table | Purpose |
|
||||
|-----------------------------------|--------|
|
||||
| **form16_sap_responses** | Credit note SAP responses only. Columns: `type` ('credit'), `file_name`, `credit_note_id`, `claim_number`, `sap_document_number`, `msg_typ`, `message`, `raw_row`, `storage_url`, timestamps. |
|
||||
| **form16_debit_note_sap_responses**| Debit note SAP responses only. Columns: `file_name`, `debit_note_id`, `claim_number`, `sap_document_number`, `msg_typ`, `message`, `raw_row`, `storage_url`, timestamps. No `type` or `credit_note_id`. |
|
||||
|
||||
Credit and debit SAP responses are **not** mixed; each has its own table.
|
||||
|
||||
---
|
||||
|
||||
## 6. Scheduler and Pull
|
||||
|
||||
- **Scheduler**: `startForm16SapResponseJob()` runs **every 5 minutes** (cron `*/5 * * * *`). It calls `runForm16SapResponseIngestionOnce()`, which:
|
||||
- Scans **OUTGOING** credit dir (`FORM16_CRDT`) and **OUTGOING** debit dir (`FORM16_DBT`) for `.csv` files.
|
||||
- For each file: parse, match to credit or debit note, upload to storage, write to `form16_sap_responses` (credit) or `form16_debit_note_sap_responses` (debit).
|
||||
- **Pull button** (Credit Notes page and Debit Notes page): `POST /api/v1/form16/sap/pull` triggers the **same** `runForm16SapResponseIngestionOnce()`, then the frontend refetches the list. So Pull = one-off run of the same ingestion logic; no separate “pull-only” path.
|
||||
- **View** appears when the corresponding table has a row for that note with a non-null `storage_url` (and for list, we check by `credit_note_id` / `debit_note_id`).
|
||||
|
||||
---
|
||||
|
||||
## 7. End-to-End Summary
|
||||
|
||||
| Step | Credit note | Debit note |
|
||||
|------|-------------|------------|
|
||||
| **Trigger** | Form 16A submission validated, 26AS match | 26AS upload changes total for a SETTLED quarter |
|
||||
| **INCOMING (we push)** | CSV to `INCOMING\WFM_MAIN\FORM16_CRDT` | CSV to `INCOMING\WFM_MAIN\FORM16_DEBT` |
|
||||
| **TDS_TRNS_ID in CSV** | Credit note number | Credit note number |
|
||||
| **File name** | `{creditNoteNumber}.csv` | `{debitNoteNumber}.csv` |
|
||||
| **OUTGOING (SAP writes)** | SAP drops response in `OUTGOING\WFM_SAP_MAIN\FORM16_CRDT` | SAP drops response in `OUTGOING\WFM_SAP_MAIN\FORM16_DBT` |
|
||||
| **We read & store** | Job reads CSV, matches, stores in `form16_sap_responses` | Job reads CSV, matches, stores in `form16_debit_note_sap_responses` |
|
||||
| **User action** | View / Download CSV (Pull to refresh) | View only (Pull to refresh) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Env / Config (relevant)
|
||||
|
||||
- **WFM_BASE_PATH**: Base folder that contains `WFM-QRE` (e.g. `C:\WFM` or `D:\Form-16 Main`). If not set and default path missing, job tries `process.cwd()\WFM-QRE\...`.
|
||||
- **WFM_FORM16_CREDIT_INCOMING_PATH**, **WFM_FORM16_DEBIT_INCOMING_PATH**: Override INCOMING paths.
|
||||
- **WFM_FORM16_CREDIT_OUTGOING_PATH**, **WFM_FORM16_DEBIT_OUTGOING_PATH**: Override OUTGOING paths.
|
||||
@ -14,6 +14,8 @@ import { extractForm16ADetails } from '../services/form16Ocr.service';
|
||||
import { canViewForm16Submission, canView26As } from '../services/form16Permission.service';
|
||||
import { ResponseHandler } from '../utils/responseHandler';
|
||||
import logger from '../utils/logger';
|
||||
import { WorkflowRequest } from '@models/WorkflowRequest';
|
||||
import { Form16aSubmission } from '@models/Form16aSubmission';
|
||||
|
||||
/**
|
||||
* Form 16 controller: credit notes, OCR extract, and create submission for dealers.
|
||||
@ -80,6 +82,28 @@ export class Form16Controller {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/form16/debit-notes
|
||||
* RE only. List all debit notes (all dealers).
|
||||
*/
|
||||
async listDebitNotes(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const financialYear = req.query.financialYear as string | undefined;
|
||||
const quarter = req.query.quarter as string | undefined;
|
||||
const result = await form16Service.listAllDebitNotesForRe({ financialYear, quarter });
|
||||
const payload: { debitNotes: typeof result.rows; total: number; summary?: typeof result.summary } = {
|
||||
debitNotes: result.rows,
|
||||
total: result.total,
|
||||
summary: (result as any).summary,
|
||||
};
|
||||
return ResponseHandler.success(res, payload, 'Debit notes fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] listDebitNotes error:', error);
|
||||
return ResponseHandler.error(res, 'Failed to fetch debit notes', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/form16/dealer/submissions
|
||||
* Dealer only. List Form 16 submissions for the authenticated dealer (pending/failed for Pending Submissions page).
|
||||
@ -326,6 +350,65 @@ export class Form16Controller {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/form16/credit-notes/:id/download
|
||||
* Returns a storage URL for the SAP response CSV if available.
|
||||
* If not yet available, returns 409 so UI can show "being generated, wait".
|
||||
*/
|
||||
async downloadCreditNote(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);
|
||||
}
|
||||
let url: string | null = null;
|
||||
try {
|
||||
url = await form16Service.getCreditNoteSapResponseUrlForUser(id, userId);
|
||||
} catch (e: any) {
|
||||
const msg = String(e?.message || '');
|
||||
if (msg.toLowerCase().includes('not found')) {
|
||||
return ResponseHandler.error(res, 'Credit note not found', 404);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (!url) {
|
||||
return ResponseHandler.error(res, 'The credit note is being generated. Please wait.', 409);
|
||||
}
|
||||
return ResponseHandler.success(res, { url }, 'OK');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] downloadCreditNote error:', error);
|
||||
return ResponseHandler.error(res, 'Failed to fetch credit note download link', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/form16/debit-notes/:id/sap-response
|
||||
* RE only. Returns a storage URL for the SAP response CSV if available.
|
||||
* If not yet available, returns 409 so UI can show "being generated, wait".
|
||||
*/
|
||||
async viewDebitNoteSapResponse(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 url = await form16Service.getDebitNoteSapResponseUrl(id);
|
||||
if (!url) {
|
||||
return ResponseHandler.error(res, 'The debit note is being generated. Please wait.', 409);
|
||||
}
|
||||
return ResponseHandler.success(res, { url }, 'OK');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] viewDebitNoteSapResponse error:', error);
|
||||
return ResponseHandler.error(res, 'Failed to fetch debit note SAP response', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@ -681,6 +764,62 @@ export class Form16Controller {
|
||||
return ResponseHandler.error(res, 'Failed to send test notification', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/form16/requests/:requestId/contact-admin
|
||||
* Dealer UX: when submission fails because 26AS is missing for the quarter, dealer can notify RE admins.
|
||||
*/
|
||||
async contactAdmin(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const user = (req as AuthenticatedRequest).user;
|
||||
const userId = user?.userId;
|
||||
if (!userId) {
|
||||
return ResponseHandler.unauthorized(res, 'Authentication required');
|
||||
}
|
||||
const requestId = String(req.params.requestId || '').trim();
|
||||
if (!requestId) return ResponseHandler.error(res, 'requestId is required', 400);
|
||||
|
||||
const reqRow = await WorkflowRequest.findByPk(requestId, {
|
||||
attributes: ['requestId', 'requestNumber', 'templateType', 'initiatorId'],
|
||||
raw: true,
|
||||
});
|
||||
if (!reqRow || (reqRow as any).templateType !== 'FORM_16') {
|
||||
return ResponseHandler.error(res, 'Form 16 request not found', 404);
|
||||
}
|
||||
if ((reqRow as any).initiatorId !== userId) {
|
||||
return ResponseHandler.error(res, 'Forbidden', 403);
|
||||
}
|
||||
|
||||
const submission = await Form16aSubmission.findOne({
|
||||
where: { requestId },
|
||||
order: [['submittedDate', 'DESC']],
|
||||
attributes: ['id', 'validationStatus', 'validationNotes'],
|
||||
});
|
||||
if (!submission) return ResponseHandler.error(res, 'Submission not found', 404);
|
||||
|
||||
const s: any = submission as any;
|
||||
const notes = String(s.validationNotes || '').toLowerCase();
|
||||
const isMissing26As =
|
||||
notes.includes('no 26as') ||
|
||||
notes.includes('no 26as record') ||
|
||||
notes.includes('ensure 26as has been uploaded');
|
||||
if (!isMissing26As) {
|
||||
return ResponseHandler.error(res, 'Contact admin is available only when 26AS is missing for the quarter.', 400);
|
||||
}
|
||||
|
||||
const { triggerForm16MismatchContactAdminNotification } = await import('../services/form16Notification.service');
|
||||
await triggerForm16MismatchContactAdminNotification({
|
||||
requestId,
|
||||
requestNumber: (reqRow as any).requestNumber,
|
||||
dealerUserId: userId,
|
||||
});
|
||||
|
||||
return ResponseHandler.success(res, { ok: true }, 'Admin notified');
|
||||
} catch (error: any) {
|
||||
logger.error('[Form16Controller] contactAdmin error:', error);
|
||||
return ResponseHandler.error(res, 'Failed to notify admin', 500, error?.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const form16Controller = new Form16Controller();
|
||||
|
||||
24
src/controllers/form16Sap.controller.ts
Normal file
24
src/controllers/form16Sap.controller.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ResponseHandler } from '../utils/responseHandler';
|
||||
import logger from '../utils/logger';
|
||||
import { runForm16SapResponseIngestionOnce } from '../jobs/form16SapResponseJob';
|
||||
|
||||
export class Form16SapController {
|
||||
/**
|
||||
* POST /api/v1/form16/sap/pull
|
||||
* Trigger an immediate scan of the SAP OUTGOING directories for new Form 16 response CSVs.
|
||||
* Safe to call multiple times; ingestion is idempotent by file name.
|
||||
*/
|
||||
async pull(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const result = await runForm16SapResponseIngestionOnce();
|
||||
return ResponseHandler.success(res, result, 'Pulled SAP responses');
|
||||
} catch (e: any) {
|
||||
logger.error('[Form16SapController] pull error:', e);
|
||||
return ResponseHandler.error(res, 'Failed to pull SAP responses', 500, e?.message || 'Unknown error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const form16SapController = new Form16SapController();
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
import { getForm16Config } from '../services/form16Config.service';
|
||||
import { runForm16AlertSubmitJob, runForm16ReminderJob } from '../services/form16Notification.service';
|
||||
import { runForm16AlertSubmitJob, runForm16ReminderJob, runForm16Remind26AsUploadJob } from '../services/form16Notification.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const TZ = process.env.TZ || 'Asia/Kolkata';
|
||||
// 26AS reminder is quarter-based; we evaluate once daily at this fixed time.
|
||||
const RE_26AS_REMINDER_CHECK_TIME = '08:30';
|
||||
|
||||
/** Last date (YYYY-MM-DD) we ran the alert job in the configured timezone. */
|
||||
let lastAlertRunDate: string | null = null;
|
||||
/** Last date (YYYY-MM-DD) we ran the reminder job in the configured timezone. */
|
||||
let lastReminderRunDate: string | null = null;
|
||||
/** Last date (YYYY-MM-DD) we ran the 26AS upload reminder job in the configured timezone. */
|
||||
let last26AsReminderRunDate: string | null = null;
|
||||
|
||||
/**
|
||||
* Get current time in configured TZ as HH:mm (24h, zero-padded).
|
||||
@ -51,6 +55,12 @@ async function form16NotificationTick(): Promise<void> {
|
||||
logger.info(`[Form16 Job] Running reminder job (scheduled at ${reminderTime})`);
|
||||
await runForm16ReminderJob();
|
||||
}
|
||||
|
||||
if (config.reminder26AsUploadEnabled && RE_26AS_REMINDER_CHECK_TIME === nowTime && last26AsReminderRunDate !== today) {
|
||||
last26AsReminderRunDate = today;
|
||||
logger.info(`[Form16 Job] Running 26AS upload reminder job (daily check at ${RE_26AS_REMINDER_CHECK_TIME})`);
|
||||
await runForm16Remind26AsUploadJob();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('[Form16 Job] Tick error:', e);
|
||||
}
|
||||
|
||||
240
src/jobs/form16SapResponseJob.ts
Normal file
240
src/jobs/form16SapResponseJob.ts
Normal file
@ -0,0 +1,240 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import logger from '../utils/logger';
|
||||
import { wfmFileService } from '../services/wfmFile.service';
|
||||
import { Form16CreditNote, Form16DebitNote, Form16SapResponse, Form16DebitNoteSapResponse, Form16aSubmission, WorkflowRequest } from '../models';
|
||||
import { gcsStorageService } from '../services/gcsStorage.service';
|
||||
|
||||
type ResponseRow = Record<string, string | undefined>;
|
||||
|
||||
function safeFileName(name: string): string {
|
||||
return (name || '').trim().replace(/[\\\/:*?"<>|]+/g, '-').slice(0, 180) || 'form16-sap-response.csv';
|
||||
}
|
||||
|
||||
async function processOutgoingFile(fileName: string, type: 'credit' | 'debit', resolvedOutgoingDir?: string): Promise<void> {
|
||||
// Idempotency by file name. Credit uses form16_sap_responses; debit uses form16_debit_note_sap_responses.
|
||||
const CreditModel = Form16SapResponse as any;
|
||||
const DebitModel = Form16DebitNoteSapResponse as any;
|
||||
const existingCredit = type === 'credit' ? await CreditModel.findOne({ where: { fileName }, attributes: ['id', 'creditNoteId', 'sapDocumentNumber', 'storageUrl'] }) : null;
|
||||
const existingDebit = type === 'debit' ? await DebitModel.findOne({ where: { fileName }, attributes: ['id', 'debitNoteId', 'sapDocumentNumber', 'storageUrl'] }) : null;
|
||||
const existing = existingCredit || existingDebit;
|
||||
if (existing && (existing.creditNoteId ?? existing.debitNoteId) && (existing.storageUrl || existing.sapDocumentNumber)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = resolvedOutgoingDir
|
||||
? await wfmFileService.readForm16OutgoingResponseByPath(path.join(resolvedOutgoingDir, fileName))
|
||||
: await wfmFileService.readForm16OutgoingResponse(fileName, type);
|
||||
if (!rows || rows.length === 0) {
|
||||
// Still record as processed so we don't retry empty/invalid files forever
|
||||
if (existing) {
|
||||
if (type === 'credit') await CreditModel.update({ rawRow: null, updatedAt: new Date() }, { where: { id: existing.id } });
|
||||
else await DebitModel.update({ rawRow: null, updatedAt: new Date() }, { where: { id: existing.id } });
|
||||
} else {
|
||||
if (type === 'credit') await CreditModel.create({ type, fileName, rawRow: null, createdAt: new Date(), updatedAt: new Date() });
|
||||
else await DebitModel.create({ fileName, rawRow: null, createdAt: new Date(), updatedAt: new Date() });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Choose the first "real" row. Some SAP/WFM exports include an extra line like "|MSG_TYP|MESSAGE|"
|
||||
// after the header; in that case rows[0] will not have TRNS_UNIQ_NO and mapping will fail.
|
||||
const normalizedRows = rows as ResponseRow[];
|
||||
const pick =
|
||||
// Prefer proper transaction id rows
|
||||
normalizedRows.find((row) => {
|
||||
const id = (row.TRNS_UNIQ_NO || row.TRNSUNIQNO || row.DMS_UNIQ_NO || row.DMSUNIQNO || '')?.toString().trim();
|
||||
return Boolean(id);
|
||||
}) ||
|
||||
// Fallback: require BOTH a claim ref and a doc/status field to avoid picking the "|MSG_TYP|MESSAGE|" line
|
||||
normalizedRows.find((row) => {
|
||||
const claimRef = (row.TDS_TRNS_ID || row.CLAIM_NUMBER || '')?.toString().trim();
|
||||
const docNo = (row.DOC_NO || row.DOCNO || row.SAP_DOC_NO || row.SAPDOC || '')?.toString().trim();
|
||||
const msgTyp = (row.MSG_TYP || row.MSGTYP || row.MSG_TYPE || '')?.toString().trim();
|
||||
if (!claimRef) return false;
|
||||
if (!docNo && !msgTyp) return false;
|
||||
// guard against the "MSG_TYP" literal being inside claimRef column
|
||||
if (claimRef.toUpperCase() === 'MSG_TYP' || claimRef.toUpperCase() === 'MESSAGE') return false;
|
||||
return true;
|
||||
});
|
||||
const r = (pick || (rows[0] as ResponseRow)) as ResponseRow;
|
||||
const trnsUniqNo = (r.TRNS_UNIQ_NO || r.TRNSUNIQNO || r.DMS_UNIQ_NO || r.DMS_UNIQ_NO || '')?.toString().trim() || null;
|
||||
// SAP claim number: credit uses TDS_TRNS_ID; debit uses CLAIM_NUMBER
|
||||
const claimNumber = (
|
||||
(type === 'credit' ? r.TDS_TRNS_ID : r.CLAIM_NUMBER) ||
|
||||
r.CLAIM_NUMBER ||
|
||||
r.TDS_TRNS_ID ||
|
||||
''
|
||||
)?.toString().trim() || null;
|
||||
const sapDocNo = (r.DOC_NO || r.DOCNO || r.SAP_DOC_NO || r.SAPDOC || '')?.toString().trim() || null;
|
||||
const msgTyp = (r.MSG_TYP || r.MSGTYP || r.MSG_TYPE || '')?.toString().trim() || null;
|
||||
const message = (r.MESSAGE || r.MSG || '')?.toString().trim() || null;
|
||||
|
||||
let creditNoteId: number | null = null;
|
||||
let debitNoteId: number | null = null;
|
||||
let requestId: string | null = null;
|
||||
let requestNumber: string | null = null;
|
||||
|
||||
if (type === 'credit' && trnsUniqNo) {
|
||||
const cn = await (Form16CreditNote as any).findOne({ where: { trnsUniqNo }, attributes: ['id', 'submissionId'] });
|
||||
if (cn) {
|
||||
creditNoteId = cn.id;
|
||||
if (sapDocNo) await (Form16CreditNote as any).update({ sapDocumentNumber: sapDocNo, status: 'completed' }, { where: { id: cn.id } });
|
||||
const submission = await (Form16aSubmission as any).findByPk(cn.submissionId, { attributes: ['requestId'] });
|
||||
requestId = submission?.requestId ?? null;
|
||||
}
|
||||
}
|
||||
// Backward compatibility: old credit notes may not have trnsUniqNo stored. Match by creditNoteNumber (SAP sends it as TDS_TRNS_ID).
|
||||
if (type === 'credit' && !creditNoteId && claimNumber) {
|
||||
const cn = await (Form16CreditNote as any).findOne({ where: { creditNoteNumber: claimNumber }, attributes: ['id', 'submissionId'] });
|
||||
if (cn) {
|
||||
creditNoteId = cn.id;
|
||||
if (sapDocNo) await (Form16CreditNote as any).update({ sapDocumentNumber: sapDocNo, status: 'completed' }, { where: { id: cn.id } });
|
||||
const submission = await (Form16aSubmission as any).findByPk(cn.submissionId, { attributes: ['requestId'] });
|
||||
requestId = submission?.requestId ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'debit' && trnsUniqNo) {
|
||||
const dn = await (Form16DebitNote as any).findOne({ where: { trnsUniqNo }, attributes: ['id'] });
|
||||
if (dn) {
|
||||
debitNoteId = dn.id;
|
||||
if (sapDocNo) await (Form16DebitNote as any).update({ sapDocumentNumber: sapDocNo, status: 'completed' }, { where: { id: dn.id } });
|
||||
}
|
||||
}
|
||||
if (type === 'debit' && !debitNoteId && claimNumber) {
|
||||
const dn = await (Form16DebitNote as any).findOne({ where: { debitNoteNumber: claimNumber }, attributes: ['id'] });
|
||||
if (dn) {
|
||||
debitNoteId = dn.id;
|
||||
if (sapDocNo) await (Form16DebitNote as any).update({ sapDocumentNumber: sapDocNo, status: 'completed' }, { where: { id: dn.id } });
|
||||
}
|
||||
}
|
||||
// Fallback: match by filename (without .csv) to debit_note_number when SAP uses different TRNS_UNIQ_NO/CLAIM_NUMBER
|
||||
if (type === 'debit' && !debitNoteId) {
|
||||
const baseName = fileName.replace(/\.csv$/i, '').trim();
|
||||
if (baseName) {
|
||||
const dn = await (Form16DebitNote as any).findOne({ where: { debitNoteNumber: baseName }, attributes: ['id'] });
|
||||
if (dn) {
|
||||
debitNoteId = dn.id;
|
||||
if (sapDocNo) await (Form16DebitNote as any).update({ sapDocumentNumber: sapDocNo, status: 'completed' }, { where: { id: dn.id } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (requestId) {
|
||||
const req = await (WorkflowRequest as any).findOne({ where: { requestId }, attributes: ['requestNumber'] });
|
||||
requestNumber = req?.requestNumber ?? null;
|
||||
}
|
||||
|
||||
// Read the raw file bytes and upload to storage so it can be downloaded later
|
||||
const absPath = resolvedOutgoingDir ? path.join(resolvedOutgoingDir, fileName) : wfmFileService.getForm16OutgoingPath(fileName, type);
|
||||
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 outgoing response file:', fileName, e);
|
||||
}
|
||||
|
||||
if (type === 'credit') {
|
||||
const payload = {
|
||||
type: 'credit' as const,
|
||||
fileName,
|
||||
creditNoteId,
|
||||
claimNumber,
|
||||
sapDocumentNumber: sapDocNo,
|
||||
msgTyp,
|
||||
message,
|
||||
rawRow: r as any,
|
||||
storageUrl,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (existing) await CreditModel.update(payload, { where: { id: existing.id } });
|
||||
else await CreditModel.create({ ...payload, createdAt: new Date() });
|
||||
} else {
|
||||
if (debitNoteId == null && (trnsUniqNo || claimNumber)) {
|
||||
logger.warn(`[Form16 SAP Job] Debit file ${fileName}: no matching debit note in DB. TRNS_UNIQ_NO=${trnsUniqNo ?? '—'}, CLAIM_NUMBER=${claimNumber ?? '—'}. Ensure a debit note exists with matching trns_uniq_no or debit_note_number.`);
|
||||
}
|
||||
const payload = {
|
||||
fileName,
|
||||
debitNoteId,
|
||||
claimNumber,
|
||||
sapDocumentNumber: sapDocNo,
|
||||
msgTyp,
|
||||
message,
|
||||
rawRow: r as any,
|
||||
storageUrl,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (existing) await DebitModel.update(payload, { where: { id: existing.id } });
|
||||
else await DebitModel.create({ ...payload, createdAt: new Date() });
|
||||
}
|
||||
}
|
||||
|
||||
export async function runForm16SapResponseIngestionOnce(): Promise<{
|
||||
processed: number;
|
||||
creditProcessed: number;
|
||||
debitProcessed: number;
|
||||
}> {
|
||||
let creditProcessed = 0;
|
||||
let debitProcessed = 0;
|
||||
try {
|
||||
const base = process.env.WFM_BASE_PATH || 'C:\\WFM';
|
||||
const creditDir = path.dirname(wfmFileService.getForm16OutgoingPath('__probe__.csv', 'credit'));
|
||||
const debitDir = path.dirname(wfmFileService.getForm16OutgoingPath('__probe__.csv', 'debit'));
|
||||
const dirs: Array<{ dir: string; type: 'credit' | 'debit' }> = [
|
||||
{ dir: creditDir, type: 'credit' },
|
||||
{ dir: debitDir, type: 'debit' },
|
||||
];
|
||||
|
||||
const RELATIVE_DEBIT_OUT = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'FORM16_DBT');
|
||||
const RELATIVE_CREDIT_OUT = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'FORM16_CRDT');
|
||||
for (const { dir, type } of dirs) {
|
||||
let abs = path.isAbsolute(dir) ? dir : path.join(base, dir);
|
||||
if (!fs.existsSync(abs)) {
|
||||
const relSubdir = type === 'debit' ? RELATIVE_DEBIT_OUT : RELATIVE_CREDIT_OUT;
|
||||
const cwdFallback = path.join(process.cwd(), relSubdir);
|
||||
if (fs.existsSync(cwdFallback)) {
|
||||
abs = cwdFallback;
|
||||
logger.info(`[Form16 SAP Job] Using ${type} outgoing dir from current working directory: ${abs}`);
|
||||
} else {
|
||||
logger.warn(`[Form16 SAP Job] ${type} outgoing dir does not exist, skipping. Tried: ${abs} and ${cwdFallback}. Set WFM_BASE_PATH to the folder that contains WFM-QRE, or place WFM-QRE under project root.`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const files = fs.readdirSync(abs).filter((f) => f.toLowerCase().endsWith('.csv'));
|
||||
logger.info(`[Form16 SAP Job] ${type} outgoing dir: ${abs}, found ${files.length} CSV file(s): ${files.length ? files.join(', ') : '(none)'}`);
|
||||
for (const f of files) {
|
||||
await processOutgoingFile(f, type, abs);
|
||||
if (type === 'credit') creditProcessed++;
|
||||
else debitProcessed++;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('[Form16 SAP Job] Tick error:', e);
|
||||
}
|
||||
return {
|
||||
processed: creditProcessed + debitProcessed,
|
||||
creditProcessed,
|
||||
debitProcessed,
|
||||
};
|
||||
}
|
||||
|
||||
/** Start scheduler that ingests SAP response files every 5 minutes. */
|
||||
export function startForm16SapResponseJob(): void {
|
||||
const cron = require('node-cron');
|
||||
cron.schedule('*/5 * * * *', () => {
|
||||
runForm16SapResponseIngestionOnce();
|
||||
});
|
||||
logger.info('[Form16 SAP Job] Scheduled SAP response ingestion (every 5 minutes)');
|
||||
}
|
||||
|
||||
85
src/migrations/20260317100001-create-form16-sap-responses.ts
Normal file
85
src/migrations/20260317100001-create-form16-sap-responses.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import type { QueryInterface } from 'sequelize';
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
/**
|
||||
* Stores SAP/WFM outgoing response files for Form 16 credit/debit notes.
|
||||
* Used for:
|
||||
* - Showing "credit note is being generated, wait" until SAP response is received
|
||||
* - Allowing users to download the SAP response file later
|
||||
*/
|
||||
module.exports = {
|
||||
up: async (queryInterface: QueryInterface) => {
|
||||
await queryInterface.createTable('form16_sap_responses', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
},
|
||||
type: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: false, // 'credit' | 'debit'
|
||||
},
|
||||
file_name: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
},
|
||||
credit_note_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'form_16_credit_notes', key: 'id' },
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
debit_note_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'form_16_debit_notes', key: 'id' },
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
claim_number: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
},
|
||||
sap_document_number: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
},
|
||||
msg_typ: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true,
|
||||
},
|
||||
message: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
raw_row: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
},
|
||||
storage_url: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: true,
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('form16_sap_responses', ['type']);
|
||||
await queryInterface.addIndex('form16_sap_responses', ['credit_note_id']);
|
||||
await queryInterface.addIndex('form16_sap_responses', ['debit_note_id']);
|
||||
await queryInterface.addIndex('form16_sap_responses', ['claim_number']);
|
||||
},
|
||||
|
||||
down: async (queryInterface: QueryInterface) => {
|
||||
await queryInterface.dropTable('form16_sap_responses');
|
||||
},
|
||||
};
|
||||
|
||||
26
src/migrations/20260317120001-add-form16-trns-uniq-no.ts
Normal file
26
src/migrations/20260317120001-add-form16-trns-uniq-no.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { QueryInterface } from 'sequelize';
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface: QueryInterface) => {
|
||||
await queryInterface.addColumn('form_16_credit_notes', 'trns_uniq_no', {
|
||||
type: DataTypes.STRING(120),
|
||||
allowNull: true,
|
||||
});
|
||||
await queryInterface.addColumn('form_16_debit_notes', 'trns_uniq_no', {
|
||||
type: DataTypes.STRING(120),
|
||||
allowNull: true,
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('form_16_credit_notes', ['trns_uniq_no'], { name: 'idx_form16_credit_notes_trns_uniq_no' });
|
||||
await queryInterface.addIndex('form_16_debit_notes', ['trns_uniq_no'], { name: 'idx_form16_debit_notes_trns_uniq_no' });
|
||||
},
|
||||
|
||||
down: async (queryInterface: QueryInterface) => {
|
||||
await queryInterface.removeIndex('form_16_debit_notes', 'idx_form16_debit_notes_trns_uniq_no');
|
||||
await queryInterface.removeIndex('form_16_credit_notes', 'idx_form16_credit_notes_trns_uniq_no');
|
||||
await queryInterface.removeColumn('form_16_debit_notes', 'trns_uniq_no');
|
||||
await queryInterface.removeColumn('form_16_credit_notes', 'trns_uniq_no');
|
||||
},
|
||||
};
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
import type { QueryInterface } from 'sequelize';
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
/**
|
||||
* Separate table for Form 16 debit note SAP responses (OUTGOING FORM16_DBT).
|
||||
* Credit note SAP responses remain in form16_sap_responses only.
|
||||
*/
|
||||
module.exports = {
|
||||
up: async (queryInterface: QueryInterface) => {
|
||||
await queryInterface.createTable('form16_debit_note_sap_responses', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
},
|
||||
file_name: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
},
|
||||
debit_note_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'form_16_debit_notes', key: 'id' },
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
claim_number: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
},
|
||||
sap_document_number: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
},
|
||||
msg_typ: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true,
|
||||
},
|
||||
message: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
raw_row: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
},
|
||||
storage_url: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: true,
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('form16_debit_note_sap_responses', ['debit_note_id']);
|
||||
await queryInterface.addIndex('form16_debit_note_sap_responses', ['claim_number']);
|
||||
},
|
||||
|
||||
down: async (queryInterface: QueryInterface) => {
|
||||
await queryInterface.dropTable('form16_debit_note_sap_responses');
|
||||
},
|
||||
};
|
||||
@ -7,6 +7,7 @@ export interface Form16CreditNoteAttributes {
|
||||
id: number;
|
||||
submissionId: number;
|
||||
creditNoteNumber: string;
|
||||
trnsUniqNo?: string;
|
||||
sapDocumentNumber?: string;
|
||||
amount: number;
|
||||
issueDate: Date;
|
||||
@ -32,6 +33,7 @@ class Form16CreditNote
|
||||
public id!: number;
|
||||
public submissionId!: number;
|
||||
public creditNoteNumber!: string;
|
||||
public trnsUniqNo?: string;
|
||||
public sapDocumentNumber?: string;
|
||||
public amount!: number;
|
||||
public issueDate!: Date;
|
||||
@ -66,6 +68,11 @@ Form16CreditNote.init(
|
||||
unique: true,
|
||||
field: 'credit_note_number',
|
||||
},
|
||||
trnsUniqNo: {
|
||||
type: DataTypes.STRING(120),
|
||||
allowNull: true,
|
||||
field: 'trns_uniq_no',
|
||||
},
|
||||
sapDocumentNumber: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true,
|
||||
|
||||
@ -7,6 +7,7 @@ export interface Form16DebitNoteAttributes {
|
||||
id: number;
|
||||
creditNoteId: number;
|
||||
debitNoteNumber: string;
|
||||
trnsUniqNo?: string;
|
||||
sapDocumentNumber?: string;
|
||||
amount: number;
|
||||
issueDate: Date;
|
||||
@ -30,6 +31,7 @@ class Form16DebitNote
|
||||
public id!: number;
|
||||
public creditNoteId!: number;
|
||||
public debitNoteNumber!: string;
|
||||
public trnsUniqNo?: string;
|
||||
public sapDocumentNumber?: string;
|
||||
public amount!: number;
|
||||
public issueDate!: Date;
|
||||
@ -64,6 +66,11 @@ Form16DebitNote.init(
|
||||
unique: true,
|
||||
field: 'debit_note_number',
|
||||
},
|
||||
trnsUniqNo: {
|
||||
type: DataTypes.STRING(120),
|
||||
allowNull: true,
|
||||
field: 'trns_uniq_no',
|
||||
},
|
||||
sapDocumentNumber: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true,
|
||||
|
||||
83
src/models/Form16DebitNoteSapResponse.ts
Normal file
83
src/models/Form16DebitNoteSapResponse.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '@config/database';
|
||||
import { Form16DebitNote } from './Form16DebitNote';
|
||||
|
||||
export interface Form16DebitNoteSapResponseAttributes {
|
||||
id: number;
|
||||
fileName: string;
|
||||
debitNoteId?: number | null;
|
||||
claimNumber?: string | null;
|
||||
sapDocumentNumber?: string | null;
|
||||
msgTyp?: string | null;
|
||||
message?: string | null;
|
||||
rawRow?: Record<string, unknown> | null;
|
||||
storageUrl?: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface Form16DebitNoteSapResponseCreationAttributes
|
||||
extends Optional<
|
||||
Form16DebitNoteSapResponseAttributes,
|
||||
| 'id'
|
||||
| 'debitNoteId'
|
||||
| 'claimNumber'
|
||||
| 'sapDocumentNumber'
|
||||
| 'msgTyp'
|
||||
| 'message'
|
||||
| 'rawRow'
|
||||
| 'storageUrl'
|
||||
| 'createdAt'
|
||||
| 'updatedAt'
|
||||
> {}
|
||||
|
||||
class Form16DebitNoteSapResponse
|
||||
extends Model<Form16DebitNoteSapResponseAttributes, Form16DebitNoteSapResponseCreationAttributes>
|
||||
implements Form16DebitNoteSapResponseAttributes
|
||||
{
|
||||
public id!: number;
|
||||
public fileName!: string;
|
||||
public debitNoteId?: number | null;
|
||||
public claimNumber?: string | null;
|
||||
public sapDocumentNumber?: string | null;
|
||||
public msgTyp?: string | null;
|
||||
public message?: string | null;
|
||||
public rawRow?: Record<string, unknown> | null;
|
||||
public storageUrl?: string | null;
|
||||
public createdAt!: Date;
|
||||
public updatedAt!: Date;
|
||||
|
||||
public debitNote?: Form16DebitNote;
|
||||
}
|
||||
|
||||
Form16DebitNoteSapResponse.init(
|
||||
{
|
||||
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||
fileName: { type: DataTypes.STRING(255), allowNull: false, unique: true, field: 'file_name' },
|
||||
debitNoteId: { type: DataTypes.INTEGER, allowNull: true, field: 'debit_note_id' },
|
||||
claimNumber: { type: DataTypes.STRING(100), allowNull: true, field: 'claim_number' },
|
||||
sapDocumentNumber: { type: DataTypes.STRING(100), allowNull: true, field: 'sap_document_number' },
|
||||
msgTyp: { type: DataTypes.STRING(20), allowNull: true, field: 'msg_typ' },
|
||||
message: { type: DataTypes.TEXT, allowNull: true },
|
||||
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' },
|
||||
updatedAt: { type: DataTypes.DATE, allowNull: false, field: 'updated_at' },
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'form16_debit_note_sap_responses',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
}
|
||||
);
|
||||
|
||||
Form16DebitNoteSapResponse.belongsTo(Form16DebitNote, {
|
||||
as: 'debitNote',
|
||||
foreignKey: 'debitNoteId',
|
||||
targetKey: 'id',
|
||||
});
|
||||
|
||||
export { Form16DebitNoteSapResponse };
|
||||
99
src/models/Form16SapResponse.ts
Normal file
99
src/models/Form16SapResponse.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '@config/database';
|
||||
import { Form16CreditNote } from './Form16CreditNote';
|
||||
import { Form16DebitNote } from './Form16DebitNote';
|
||||
|
||||
export interface Form16SapResponseAttributes {
|
||||
id: number;
|
||||
type: 'credit' | 'debit';
|
||||
fileName: string;
|
||||
creditNoteId?: number | null;
|
||||
debitNoteId?: number | null;
|
||||
claimNumber?: string | null;
|
||||
sapDocumentNumber?: string | null;
|
||||
msgTyp?: string | null;
|
||||
message?: string | null;
|
||||
rawRow?: Record<string, unknown> | null;
|
||||
storageUrl?: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface Form16SapResponseCreationAttributes
|
||||
extends Optional<
|
||||
Form16SapResponseAttributes,
|
||||
| 'id'
|
||||
| 'creditNoteId'
|
||||
| 'debitNoteId'
|
||||
| 'claimNumber'
|
||||
| 'sapDocumentNumber'
|
||||
| 'msgTyp'
|
||||
| 'message'
|
||||
| 'rawRow'
|
||||
| 'storageUrl'
|
||||
| 'createdAt'
|
||||
| 'updatedAt'
|
||||
> {}
|
||||
|
||||
class Form16SapResponse
|
||||
extends Model<Form16SapResponseAttributes, Form16SapResponseCreationAttributes>
|
||||
implements Form16SapResponseAttributes
|
||||
{
|
||||
public id!: number;
|
||||
public type!: 'credit' | 'debit';
|
||||
public fileName!: string;
|
||||
public creditNoteId?: number | null;
|
||||
public debitNoteId?: number | null;
|
||||
public claimNumber?: string | null;
|
||||
public sapDocumentNumber?: string | null;
|
||||
public msgTyp?: string | null;
|
||||
public message?: string | null;
|
||||
public rawRow?: Record<string, unknown> | null;
|
||||
public storageUrl?: string | null;
|
||||
public createdAt!: Date;
|
||||
public updatedAt!: Date;
|
||||
|
||||
public creditNote?: Form16CreditNote;
|
||||
public debitNote?: Form16DebitNote;
|
||||
}
|
||||
|
||||
Form16SapResponse.init(
|
||||
{
|
||||
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' },
|
||||
debitNoteId: { type: DataTypes.INTEGER, allowNull: true, field: 'debit_note_id' },
|
||||
claimNumber: { type: DataTypes.STRING(100), allowNull: true, field: 'claim_number' },
|
||||
sapDocumentNumber: { type: DataTypes.STRING(100), allowNull: true, field: 'sap_document_number' },
|
||||
msgTyp: { type: DataTypes.STRING(20), allowNull: true, field: 'msg_typ' },
|
||||
message: { type: DataTypes.TEXT, allowNull: true },
|
||||
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' },
|
||||
updatedAt: { type: DataTypes.DATE, allowNull: false, field: 'updated_at' },
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'form16_sap_responses',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
}
|
||||
);
|
||||
|
||||
Form16SapResponse.belongsTo(Form16CreditNote, {
|
||||
as: 'creditNote',
|
||||
foreignKey: 'creditNoteId',
|
||||
targetKey: 'id',
|
||||
});
|
||||
|
||||
Form16SapResponse.belongsTo(Form16DebitNote, {
|
||||
as: 'debitNote',
|
||||
foreignKey: 'debitNoteId',
|
||||
targetKey: 'id',
|
||||
});
|
||||
|
||||
export { Form16SapResponse };
|
||||
|
||||
@ -38,6 +38,8 @@ import { Form16NonSubmittedNotification } from './Form16NonSubmittedNotification
|
||||
import { Form1626asQuarterSnapshot } from './Form1626asQuarterSnapshot';
|
||||
import { Form16QuarterStatus } from './Form16QuarterStatus';
|
||||
import { Form16LedgerEntry } from './Form16LedgerEntry';
|
||||
import { Form16SapResponse } from './Form16SapResponse';
|
||||
import { Form16DebitNoteSapResponse } from './Form16DebitNoteSapResponse';
|
||||
|
||||
// Define associations
|
||||
const defineAssociations = () => {
|
||||
@ -222,7 +224,9 @@ export {
|
||||
Form16NonSubmittedNotification,
|
||||
Form1626asQuarterSnapshot,
|
||||
Form16QuarterStatus,
|
||||
Form16LedgerEntry
|
||||
Form16LedgerEntry,
|
||||
Form16SapResponse,
|
||||
Form16DebitNoteSapResponse
|
||||
};
|
||||
|
||||
// Export default sequelize instance
|
||||
|
||||
@ -5,6 +5,7 @@ import fs from 'fs';
|
||||
import { authenticateToken } from '../middlewares/auth.middleware';
|
||||
import { requireForm16SubmissionAccess, requireForm1626AsAccess, requireForm16ReOnly } from '../middlewares/form16Permission.middleware';
|
||||
import { form16Controller } from '../controllers/form16.controller';
|
||||
import { form16SapController } from '../controllers/form16Sap.controller';
|
||||
import { asyncHandler } from '../middlewares/errorHandler.middleware';
|
||||
import { UPLOAD_DIR } from '../config/storage';
|
||||
|
||||
@ -73,11 +74,29 @@ router.get(
|
||||
requireForm16SubmissionAccess,
|
||||
asyncHandler(form16Controller.listCreditNotes.bind(form16Controller))
|
||||
);
|
||||
// RE only: list debit notes
|
||||
router.get(
|
||||
'/debit-notes',
|
||||
requireForm16ReOnly,
|
||||
requireForm16SubmissionAccess,
|
||||
asyncHandler(form16Controller.listDebitNotes.bind(form16Controller))
|
||||
);
|
||||
router.get(
|
||||
'/debit-notes/:id/sap-response',
|
||||
requireForm16ReOnly,
|
||||
requireForm16SubmissionAccess,
|
||||
asyncHandler(form16Controller.viewDebitNoteSapResponse.bind(form16Controller))
|
||||
);
|
||||
router.get(
|
||||
'/credit-notes/:id',
|
||||
requireForm16SubmissionAccess,
|
||||
asyncHandler(form16Controller.getCreditNoteById.bind(form16Controller))
|
||||
);
|
||||
router.get(
|
||||
'/credit-notes/:id/download',
|
||||
requireForm16SubmissionAccess,
|
||||
asyncHandler(form16Controller.downloadCreditNote.bind(form16Controller))
|
||||
);
|
||||
router.get(
|
||||
'/requests/:requestId/credit-note',
|
||||
requireForm16SubmissionAccess,
|
||||
@ -97,6 +116,20 @@ router.post(
|
||||
asyncHandler(form16Controller.setForm16ResubmissionNeeded.bind(form16Controller))
|
||||
);
|
||||
|
||||
// Dealer: contact admin when 26AS missing for quarter
|
||||
router.post(
|
||||
'/requests/:requestId/contact-admin',
|
||||
requireForm16SubmissionAccess,
|
||||
asyncHandler(form16Controller.contactAdmin.bind(form16Controller))
|
||||
);
|
||||
|
||||
// Pull SAP outgoing responses now (credit/debit). Used by Pull button in UI.
|
||||
router.post(
|
||||
'/sap/pull',
|
||||
requireForm16SubmissionAccess,
|
||||
asyncHandler(form16SapController.pull.bind(form16SapController))
|
||||
);
|
||||
|
||||
// Form 16 SAP simulation (credit note / debit note). Replace with real SAP when integrating.
|
||||
router.post(
|
||||
'/sap-simulate/credit-note',
|
||||
|
||||
@ -176,6 +176,9 @@ async function runMigrations(): Promise<void> {
|
||||
const m59 = require('../migrations/20260309-add-wfm-push-fields');
|
||||
const m60 = require('../migrations/20260316-update-holiday-type-enum');
|
||||
const m61 = require('../migrations/20260317-refactor-activity-types-columns');
|
||||
const m62 = require('../migrations/20260317100001-create-form16-sap-responses');
|
||||
const m63 = require('../migrations/20260317120001-add-form16-trns-uniq-no');
|
||||
const m64 = require('../migrations/20260318100001-create-form16-debit-note-sap-responses');
|
||||
|
||||
const migrations = [
|
||||
{ name: '2025103000-create-users', module: m0 },
|
||||
@ -244,6 +247,9 @@ async function runMigrations(): Promise<void> {
|
||||
{ name: '20260309-add-wfm-push-fields', module: m59 },
|
||||
{ name: '20260316-update-holiday-type-enum', module: m60 },
|
||||
{ name: '20260317-refactor-activity-types-columns', module: m61 },
|
||||
{ name: '20260317100001-create-form16-sap-responses', module: m62 },
|
||||
{ name: '20260317120001-add-form16-trns-uniq-no', module: m63 },
|
||||
{ name: '20260318100001-create-form16-debit-note-sap-responses', module: m64 },
|
||||
];
|
||||
|
||||
// Dynamically import sequelize after secrets are loaded
|
||||
|
||||
@ -66,6 +66,9 @@ import * as m58 from '../migrations/20260303100001-drop-form16a-number-unique';
|
||||
import * as m59 from '../migrations/20260309-add-wfm-push-fields';
|
||||
import * as m60 from '../migrations/20260316-update-holiday-type-enum';
|
||||
import * as m61 from '../migrations/20260317-refactor-activity-types-columns';
|
||||
import * as m62 from '../migrations/20260317100001-create-form16-sap-responses';
|
||||
import * as m63 from '../migrations/20260317120001-add-form16-trns-uniq-no';
|
||||
import * as m64 from '../migrations/20260318100001-create-form16-debit-note-sap-responses';
|
||||
|
||||
interface Migration {
|
||||
name: string;
|
||||
@ -139,6 +142,9 @@ const migrations: Migration[] = [
|
||||
{ name: '20260309-add-wfm-push-fields', module: m59 },
|
||||
{ name: '20260316-update-holiday-type-enum', module: m60 },
|
||||
{ name: '20260317-refactor-activity-types-columns', module: m61 },
|
||||
{ name: '20260317100001-create-form16-sap-responses', module: m62 },
|
||||
{ name: '20260317120001-add-form16-trns-uniq-no', module: m63 },
|
||||
{ name: '20260318100001-create-form16-debit-note-sap-responses', module: m64 },
|
||||
|
||||
];
|
||||
|
||||
|
||||
@ -117,6 +117,8 @@ const startServer = async (): Promise<void> => {
|
||||
startPauseResumeJob();
|
||||
const { startForm16NotificationJobs } = require('./jobs/form16NotificationJob');
|
||||
startForm16NotificationJobs();
|
||||
const { startForm16SapResponseJob } = require('./jobs/form16SapResponseJob');
|
||||
startForm16SapResponseJob();
|
||||
const { startForm16ArchiveJob } = require('./services/form16Archive.service');
|
||||
startForm16ArchiveJob();
|
||||
|
||||
|
||||
@ -18,6 +18,8 @@ import {
|
||||
Form1626asQuarterSnapshot,
|
||||
Form16QuarterStatus,
|
||||
Form16LedgerEntry,
|
||||
Form16SapResponse,
|
||||
Form16DebitNoteSapResponse,
|
||||
} from '../models';
|
||||
import { Tds26asEntry } from '../models/Tds26asEntry';
|
||||
import { Form1626asUploadLog } from '../models/Form1626asUploadLog';
|
||||
@ -237,6 +239,15 @@ export async function listCreditNotesForDealer(userId: string, filters?: { finan
|
||||
return { rows: [], total: 0 };
|
||||
}
|
||||
|
||||
// If DB migrations for TRNS uniq no are not applied yet, do not break listing.
|
||||
// We'll return sapResponseAvailable=false in that case.
|
||||
let hasTrnsUniqNoColumn = true;
|
||||
try {
|
||||
await sequelize.query(`SELECT trns_uniq_no FROM form_16_credit_notes LIMIT 1`, { type: QueryTypes.SELECT });
|
||||
} catch {
|
||||
hasTrnsUniqNoColumn = false;
|
||||
}
|
||||
|
||||
const whereSubmission: any = { dealerCode };
|
||||
if (filters?.financialYear) whereSubmission.financialYear = filters.financialYear;
|
||||
if (filters?.quarter) whereSubmission.quarter = filters.quarter;
|
||||
@ -262,6 +273,22 @@ export async function listCreditNotesForDealer(userId: string, filters?: { finan
|
||||
order: [['issueDate', 'DESC'], ['createdAt', 'DESC']],
|
||||
});
|
||||
|
||||
const noteIds = rows.map((r) => r.id);
|
||||
let sapSet = new Set<number>();
|
||||
if (hasTrnsUniqNoColumn && noteIds.length) {
|
||||
try {
|
||||
const sapRows = await (Form16SapResponse as any).findAll({
|
||||
where: { type: 'credit', creditNoteId: { [Op.in]: noteIds }, storageUrl: { [Op.ne]: null } },
|
||||
attributes: ['creditNoteId'],
|
||||
raw: true,
|
||||
});
|
||||
sapSet = new Set((sapRows as any[]).map((r) => r.creditNoteId));
|
||||
} catch (e: any) {
|
||||
logger.warn('[Form16] SAP response lookup failed (will treat as unavailable):', e?.message || e);
|
||||
sapSet = new Set<number>();
|
||||
}
|
||||
}
|
||||
|
||||
const dealer = await Dealer.findOne({
|
||||
where: { [Op.or]: [{ salesCode: dealerCode }, { dlrcode: dealerCode }], isActive: true },
|
||||
attributes: ['dealership', 'dealerPrincipalName'],
|
||||
@ -273,6 +300,7 @@ export async function listCreditNotesForDealer(userId: string, filters?: { finan
|
||||
id: r.id,
|
||||
creditNoteNumber: r.creditNoteNumber,
|
||||
sapDocumentNumber: r.sapDocumentNumber,
|
||||
sapResponseAvailable: sapSet.has(r.id),
|
||||
amount: r.amount,
|
||||
issueDate: r.issueDate,
|
||||
financialYear: r.financialYear,
|
||||
@ -611,6 +639,7 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
|
||||
// Push Form 16 credit note incoming CSV to WFM INCOMING/WFM_MAIN/FORM16_CRDT (SAP credit note generation – exact fields only)
|
||||
try {
|
||||
const trnsUniqNo = `F16-CN-${submission.id}-${creditNote.id}-${Date.now()}`;
|
||||
await creditNote.update({ trnsUniqNo });
|
||||
const docDate = now.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const fyCompact = form16FyCompact(financialYear) || '';
|
||||
const finYearAndQuarter = fyCompact && quarter ? `FY_${fyCompact}_${quarter}` : '';
|
||||
@ -842,6 +871,29 @@ export async function listAllCreditNotesForRe(filters?: { financialYear?: string
|
||||
order: [['issueDate', 'DESC'], ['createdAt', 'DESC']],
|
||||
});
|
||||
|
||||
let hasTrnsUniqNoColumn = true;
|
||||
try {
|
||||
await sequelize.query(`SELECT trns_uniq_no FROM form_16_credit_notes LIMIT 1`, { type: QueryTypes.SELECT });
|
||||
} catch {
|
||||
hasTrnsUniqNoColumn = false;
|
||||
}
|
||||
|
||||
const noteIds = rows.map((r) => r.id);
|
||||
let sapSet = new Set<number>();
|
||||
if (hasTrnsUniqNoColumn && noteIds.length) {
|
||||
try {
|
||||
const sapRows = await (Form16SapResponse as any).findAll({
|
||||
where: { type: 'credit', creditNoteId: { [Op.in]: noteIds }, storageUrl: { [Op.ne]: null } },
|
||||
attributes: ['creditNoteId'],
|
||||
raw: true,
|
||||
});
|
||||
sapSet = new Set((sapRows as any[]).map((r) => r.creditNoteId));
|
||||
} catch (e: any) {
|
||||
logger.warn('[Form16] SAP response lookup failed (will treat as unavailable):', e?.message || e);
|
||||
sapSet = new Set<number>();
|
||||
}
|
||||
}
|
||||
|
||||
const dealerCodes = [...new Set(rows.map((r) => (r as any).submission?.dealerCode).filter(Boolean))] as string[];
|
||||
const dealers = dealerCodes.length
|
||||
? await Dealer.findAll({
|
||||
@ -870,6 +922,7 @@ export async function listAllCreditNotesForRe(filters?: { financialYear?: string
|
||||
id: r.id,
|
||||
creditNoteNumber: r.creditNoteNumber,
|
||||
sapDocumentNumber: r.sapDocumentNumber,
|
||||
sapResponseAvailable: sapSet.has(r.id),
|
||||
amount: r.amount,
|
||||
issueDate: r.issueDate,
|
||||
financialYear: r.financialYear,
|
||||
@ -909,6 +962,99 @@ export async function listCreditNotesDealerOrRe(userId: string, filters?: { fina
|
||||
return listAllCreditNotesForRe(filters);
|
||||
}
|
||||
|
||||
// ---------- RE-only: list all debit notes (all dealers) ----------
|
||||
export async function listAllDebitNotesForRe(filters?: { financialYear?: string; quarter?: string }) {
|
||||
const whereDebit: any = {};
|
||||
if (filters?.financialYear) whereDebit.financialYear = filters.financialYear;
|
||||
if (filters?.quarter) whereDebit.quarter = filters.quarter;
|
||||
|
||||
const { rows, count } = await Form16DebitNote.findAndCountAll({
|
||||
include: [
|
||||
{
|
||||
model: Form16CreditNote,
|
||||
as: 'creditNote',
|
||||
attributes: ['id', 'creditNoteNumber', 'financialYear', 'quarter', 'submissionId'],
|
||||
required: true,
|
||||
where: Object.keys(whereDebit).length ? whereDebit : undefined,
|
||||
include: [
|
||||
{
|
||||
model: Form16aSubmission,
|
||||
as: 'submission',
|
||||
attributes: ['dealerCode', 'form16aNumber'],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
order: [['issueDate', 'DESC'], ['createdAt', 'DESC']],
|
||||
});
|
||||
|
||||
// Mark which debit notes have ingested SAP response CSV available.
|
||||
const noteIds = rows.map((r: any) => r.id);
|
||||
let sapSet = new Set<number>();
|
||||
if (noteIds.length) {
|
||||
try {
|
||||
const sapRows = await (Form16DebitNoteSapResponse as any).findAll({
|
||||
where: { debitNoteId: { [Op.in]: noteIds }, storageUrl: { [Op.ne]: null } },
|
||||
attributes: ['debitNoteId'],
|
||||
raw: true,
|
||||
});
|
||||
sapSet = new Set((sapRows as any[]).map((r) => r.debitNoteId ?? r.debit_note_id));
|
||||
} catch (e: any) {
|
||||
logger.warn('[Form16] Debit SAP response lookup failed (will treat as unavailable):', e?.message || e);
|
||||
sapSet = new Set<number>();
|
||||
}
|
||||
}
|
||||
|
||||
const dealerCodes = [...new Set(rows.map((r: any) => r.creditNote?.submission?.dealerCode).filter(Boolean))] as string[];
|
||||
const dealers = dealerCodes.length
|
||||
? await Dealer.findAll({
|
||||
where: {
|
||||
isActive: true,
|
||||
[Op.or]: [
|
||||
...dealerCodes.map((c) => ({ salesCode: c })),
|
||||
...dealerCodes.map((c) => ({ dlrcode: c })),
|
||||
],
|
||||
},
|
||||
attributes: ['salesCode', 'dlrcode', 'dealership', 'dealerPrincipalName'],
|
||||
})
|
||||
: [];
|
||||
const codeToName = new Map<string, string>();
|
||||
for (const d of dealers) {
|
||||
const code = (d as any).salesCode || (d as any).dlrcode;
|
||||
if (code) codeToName.set(code, (d as any).dealership || (d as any).dealerPrincipalName || code);
|
||||
}
|
||||
|
||||
const totalAmount = rows.reduce((sum: number, r: any) => sum + (Number(r.amount) || 0), 0);
|
||||
|
||||
return {
|
||||
rows: rows.map((r: any) => {
|
||||
const dc = r.creditNote?.submission?.dealerCode ?? null;
|
||||
return {
|
||||
id: r.id,
|
||||
debitNoteNumber: r.debitNoteNumber,
|
||||
sapDocumentNumber: r.sapDocumentNumber ?? null,
|
||||
sapResponseAvailable: sapSet.has(r.id),
|
||||
amount: r.amount ?? null,
|
||||
issueDate: r.issueDate ?? null,
|
||||
status: r.status ?? null,
|
||||
financialYear: r.creditNote?.financialYear ?? null,
|
||||
quarter: r.creditNote?.quarter ?? null,
|
||||
creditNoteNumber: r.creditNote?.creditNoteNumber ?? null,
|
||||
dealerCode: dc,
|
||||
dealerName: (dc && codeToName.get(dc)) || dc || '—',
|
||||
form16aNumber: r.creditNote?.submission?.form16aNumber ?? null,
|
||||
};
|
||||
}),
|
||||
total: count,
|
||||
summary: {
|
||||
totalDebitNotes: count,
|
||||
totalAmount,
|
||||
impactedDealersCount: dealerCodes.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List Form 16 submissions for the authenticated dealer (for Pending Submissions page).
|
||||
* Optional filter: status = pending | failed | pending,failed (default: pending,failed).
|
||||
@ -1332,6 +1478,46 @@ export async function getCreditNoteById(creditNoteId: number) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCreditNoteSapResponseUrl(creditNoteId: number): Promise<string | null> {
|
||||
const row = await (Form16SapResponse as any).findOne({
|
||||
where: { type: 'credit', creditNoteId, storageUrl: { [Op.ne]: null } },
|
||||
attributes: ['storageUrl', 'createdAt'],
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
const url = row?.storageUrl;
|
||||
return url && String(url).trim() ? String(url) : null;
|
||||
}
|
||||
|
||||
export async function getDebitNoteSapResponseUrl(debitNoteId: number): Promise<string | null> {
|
||||
const row = await (Form16DebitNoteSapResponse as any).findOne({
|
||||
where: { debitNoteId, storageUrl: { [Op.ne]: null } },
|
||||
attributes: ['storageUrl', 'createdAt'],
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
const url = row?.storageUrl;
|
||||
return url && String(url).trim() ? String(url) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dealer-safe download: dealers can only download their own credit note.
|
||||
* RE/Admin (non-dealer users) can download any credit note.
|
||||
*/
|
||||
export async function getCreditNoteSapResponseUrlForUser(creditNoteId: number, userId: string): Promise<string | null> {
|
||||
const dealerCode = await getDealerCodeForUser(userId);
|
||||
if (dealerCode) {
|
||||
const note = await Form16CreditNote.findByPk(creditNoteId, {
|
||||
attributes: ['id', 'submissionId'],
|
||||
include: [{ model: Form16aSubmission, as: 'submission', attributes: ['dealerCode'] }],
|
||||
});
|
||||
const noteDealerCode = (note as any)?.submission?.dealerCode;
|
||||
if (!note || !noteDealerCode || String(noteDealerCode).trim() !== String(dealerCode).trim()) {
|
||||
// Hide existence for unauthorized dealer
|
||||
throw new Error('Credit note not found');
|
||||
}
|
||||
}
|
||||
return getCreditNoteSapResponseUrl(creditNoteId);
|
||||
}
|
||||
|
||||
// ---------- Non-submitted dealers (RE only) ----------
|
||||
const QUARTERS = ['Q1', 'Q2', 'Q3', 'Q4'] as const;
|
||||
|
||||
@ -1539,6 +1725,24 @@ export async function getDealerUserIdsFromNonSubmittedDealers(financialYear?: st
|
||||
return users.map((u) => (u as any).userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dealer user IDs missing a specific FY+quarter submission.
|
||||
* Used by quarter-based submit reminders.
|
||||
*/
|
||||
export async function getDealerUserIdsMissingQuarter(financialYear: string, quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4'): Promise<string[]> {
|
||||
const result = await listNonSubmittedDealers(financialYear);
|
||||
const key = `${quarter} ${financialYear}`;
|
||||
const emails = [...new Set(result.dealers.filter((d) => (d.missingQuarters || []).includes(key)).map((d) => d.email).filter(Boolean))]
|
||||
.map((e) => e.trim().toLowerCase());
|
||||
if (emails.length === 0) return [];
|
||||
const users = await User.findAll({
|
||||
where: { [Op.or]: emails.map((e) => ({ email: { [Op.iLike]: e } })) },
|
||||
attributes: ['userId'],
|
||||
raw: true,
|
||||
});
|
||||
return users.map((u) => (u as any).userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dealers (initiator user IDs) who have at least one pending Form 16 submission (no credit note yet, request open).
|
||||
* Returns one entry per (userId, requestId) so the reminder can include the request ID. Used by the reminder scheduled job.
|
||||
@ -2061,6 +2265,7 @@ export async function process26asUploadAggregation(uploadLogId: number): Promise
|
||||
// Push Form 16 debit note CSV to WFM INCOMING/WFM_MAIN/FORM16_DEBT (same column set as credit note / SAP expectation)
|
||||
try {
|
||||
const trnsUniqNo = `F16-DN-${creditNote.id}-${debit.id}-${Date.now()}`;
|
||||
await debit.update({ trnsUniqNo });
|
||||
const docDate = now.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const fyCompact = form16FyCompact(cnFy) || '';
|
||||
const finYearAndQuarter = fyCompact && cnQuarter ? `FY ${fyCompact}_${cnQuarter}` : '';
|
||||
|
||||
@ -25,6 +25,13 @@ export interface Form16Notification26AsConfig {
|
||||
|
||||
export interface Form16Config {
|
||||
notification26AsDataAdded: Form16Notification26AsConfig;
|
||||
/** RE reminder: prompt 26AS viewers to upload 26AS if missing for the most recently ended quarter. */
|
||||
reminder26AsUploadEnabled: boolean;
|
||||
reminder26AsUploadTemplate: string;
|
||||
/** Start reminders after quarter ends + N days. */
|
||||
reminder26AsUploadAfterQuarterEndDays: number;
|
||||
/** Repeat reminders every N days after start. */
|
||||
reminder26AsUploadEveryDays: number;
|
||||
notificationForm16SuccessCreditNote: { enabled: boolean; template: string };
|
||||
notificationForm16Unsuccessful: { enabled: boolean; template: string };
|
||||
reminderNotificationEnabled: boolean;
|
||||
@ -35,6 +42,10 @@ export interface Form16Config {
|
||||
alertSubmitForm16Template: string;
|
||||
/** When to run the alert job daily (HH:mm, 24h, server timezone). Empty = no scheduled run. */
|
||||
alertSubmitForm16RunAtTime: string;
|
||||
/** Dealer reminder: start after quarter ends + N days. */
|
||||
alertSubmitForm16AfterQuarterEndDays: number;
|
||||
/** Dealer reminder: repeat every N days after start. */
|
||||
alertSubmitForm16EveryDays: number;
|
||||
twentySixAsViewerEmails: string[];
|
||||
}
|
||||
|
||||
@ -46,6 +57,10 @@ const default26As = (): Form16Notification26AsConfig => ({
|
||||
|
||||
const defaults: Form16Config = {
|
||||
notification26AsDataAdded: default26As(),
|
||||
reminder26AsUploadEnabled: true,
|
||||
reminder26AsUploadTemplate: 'Reminder: 26AS data for [FinancialYear] [Quarter] is not uploaded yet. Please upload it in 26AS Management.',
|
||||
reminder26AsUploadAfterQuarterEndDays: 0,
|
||||
reminder26AsUploadEveryDays: 7,
|
||||
notificationForm16SuccessCreditNote: { enabled: true, template: 'Form 16 submitted successfully. Credit note: [CreditNoteRef].' },
|
||||
notificationForm16Unsuccessful: { enabled: true, template: 'Form 16 submission was unsuccessful. Issue: [Issue].' },
|
||||
reminderNotificationEnabled: true,
|
||||
@ -54,6 +69,8 @@ const defaults: Form16Config = {
|
||||
alertSubmitForm16Enabled: true,
|
||||
alertSubmitForm16Template: 'Please submit your Form 16 at your earliest. [Name], due date: [DueDate].',
|
||||
alertSubmitForm16RunAtTime: '09:00',
|
||||
alertSubmitForm16AfterQuarterEndDays: 0,
|
||||
alertSubmitForm16EveryDays: 7,
|
||||
twentySixAsViewerEmails: [],
|
||||
};
|
||||
|
||||
@ -86,6 +103,16 @@ export async function getForm16Config(): Promise<Form16Config> {
|
||||
|
||||
return {
|
||||
notification26AsDataAdded: merge26As(),
|
||||
reminder26AsUploadEnabled: typeof parsed.reminder26AsUploadEnabled === 'boolean' ? parsed.reminder26AsUploadEnabled : defaults.reminder26AsUploadEnabled,
|
||||
reminder26AsUploadTemplate: typeof parsed.reminder26AsUploadTemplate === 'string' ? parsed.reminder26AsUploadTemplate : defaults.reminder26AsUploadTemplate,
|
||||
reminder26AsUploadAfterQuarterEndDays:
|
||||
typeof parsed.reminder26AsUploadAfterQuarterEndDays === 'number'
|
||||
? Math.max(0, Math.min(365, parsed.reminder26AsUploadAfterQuarterEndDays))
|
||||
: defaults.reminder26AsUploadAfterQuarterEndDays,
|
||||
reminder26AsUploadEveryDays:
|
||||
typeof parsed.reminder26AsUploadEveryDays === 'number'
|
||||
? Math.max(1, Math.min(365, parsed.reminder26AsUploadEveryDays))
|
||||
: defaults.reminder26AsUploadEveryDays,
|
||||
notificationForm16SuccessCreditNote:
|
||||
parsed.notificationForm16SuccessCreditNote && typeof (parsed.notificationForm16SuccessCreditNote as any).template === 'string'
|
||||
? { enabled: (parsed.notificationForm16SuccessCreditNote as any).enabled !== false, template: (parsed.notificationForm16SuccessCreditNote as any).template }
|
||||
@ -100,6 +127,14 @@ export async function getForm16Config(): Promise<Form16Config> {
|
||||
alertSubmitForm16Enabled: typeof parsed.alertSubmitForm16Enabled === 'boolean' ? parsed.alertSubmitForm16Enabled : defaults.alertSubmitForm16Enabled,
|
||||
alertSubmitForm16Template: typeof parsed.alertSubmitForm16Template === 'string' ? parsed.alertSubmitForm16Template : defaults.alertSubmitForm16Template,
|
||||
alertSubmitForm16RunAtTime: typeof parsed.alertSubmitForm16RunAtTime === 'string' && parsed.alertSubmitForm16RunAtTime.trim() ? (/^\d{1,2}:\d{2}$/.test(parsed.alertSubmitForm16RunAtTime.trim()) ? normalizeRunAtTime(parsed.alertSubmitForm16RunAtTime.trim()) : defaults.alertSubmitForm16RunAtTime) : '',
|
||||
alertSubmitForm16AfterQuarterEndDays:
|
||||
typeof parsed.alertSubmitForm16AfterQuarterEndDays === 'number'
|
||||
? Math.max(0, Math.min(365, parsed.alertSubmitForm16AfterQuarterEndDays))
|
||||
: defaults.alertSubmitForm16AfterQuarterEndDays,
|
||||
alertSubmitForm16EveryDays:
|
||||
typeof parsed.alertSubmitForm16EveryDays === 'number'
|
||||
? Math.max(1, Math.min(365, parsed.alertSubmitForm16EveryDays))
|
||||
: defaults.alertSubmitForm16EveryDays,
|
||||
twentySixAsViewerEmails: Array.isArray(parsed.twentySixAsViewerEmails)
|
||||
? (parsed.twentySixAsViewerEmails as string[]).map((e) => String(e).trim().toLowerCase()).filter(Boolean)
|
||||
: defaults.twentySixAsViewerEmails,
|
||||
|
||||
@ -9,6 +9,7 @@ import { Dealer } from '@models/Dealer';
|
||||
import { Form16CreditNote } from '@models/Form16CreditNote';
|
||||
import { Form16aSubmission } from '@models/Form16aSubmission';
|
||||
import { WorkflowRequest } from '@models/WorkflowRequest';
|
||||
import { Form1626asQuarterSnapshot } from '@models/Form1626asQuarterSnapshot';
|
||||
import { getForm16Config } from './form16Config.service';
|
||||
import logger from '@utils/logger';
|
||||
|
||||
@ -34,24 +35,40 @@ function userWhereEmailIn(emails: string[]) {
|
||||
return { [Op.or]: emails.map((e) => ({ email: { [Op.iLike]: e } })) };
|
||||
}
|
||||
|
||||
/** Get user IDs who should receive 26AS-added notification (RE side): config list or all non-dealer users. */
|
||||
/**
|
||||
* Get user IDs who have 26AS management access (same intent as requireForm1626AsAccess).
|
||||
* - ADMIN: always included
|
||||
* - If twentySixAsViewerEmails is configured (non-empty): include those users (+ ADMIN)
|
||||
* - If empty: allow all RE users by default, but NEVER include dealers
|
||||
*/
|
||||
export async function getReUserIdsFor26As(): Promise<string[]> {
|
||||
const config = await getForm16Config();
|
||||
const viewerEmails = config.twentySixAsViewerEmails || [];
|
||||
|
||||
const dealerUserIds = await getDealerUserIds();
|
||||
const dealerSet = new Set(dealerUserIds);
|
||||
|
||||
const admins = await User.findAll({
|
||||
where: { role: { [Op.iLike]: 'ADMIN' } } as any,
|
||||
attributes: ['userId'],
|
||||
raw: true,
|
||||
});
|
||||
const adminIds = admins.map((u) => (u as any).userId).filter(Boolean);
|
||||
|
||||
if (viewerEmails.length > 0) {
|
||||
const users = await User.findAll({
|
||||
where: userWhereEmailIn(viewerEmails),
|
||||
attributes: ['userId'],
|
||||
raw: true,
|
||||
});
|
||||
return users.map((u) => (u as any).userId);
|
||||
const ids = users.map((u) => (u as any).userId).filter(Boolean);
|
||||
return [...new Set([...adminIds, ...ids])].filter((id) => !dealerSet.has(id));
|
||||
}
|
||||
const dealerIds = await getDealerUserIds();
|
||||
|
||||
// Empty list = allow all RE users with 26AS access by default, but exclude dealers
|
||||
const allUsers = await User.findAll({ attributes: ['userId'], raw: true });
|
||||
const allIds = allUsers.map((u) => (u as any).userId);
|
||||
if (dealerIds.length === 0) return allIds;
|
||||
const dealerSet = new Set(dealerIds);
|
||||
return allIds.filter((id) => !dealerSet.has(id));
|
||||
const allIds = allUsers.map((u) => (u as any).userId).filter(Boolean);
|
||||
return [...new Set([...adminIds, ...allIds])].filter((id) => !dealerSet.has(id));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -89,6 +106,36 @@ export async function trigger26AsDataAddedNotification(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dealer-triggered "contact admin" notification for 26AS mismatch / missing data.
|
||||
* Sent only to RE users who have 26AS access (26AS viewers).
|
||||
*/
|
||||
export async function triggerForm16MismatchContactAdminNotification(params: {
|
||||
requestId: string;
|
||||
requestNumber?: string;
|
||||
dealerUserId: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const { notificationService } = await import('./notification.service');
|
||||
const reUserIds = await getReUserIdsFor26As();
|
||||
if (reUserIds.length === 0) return;
|
||||
|
||||
const title = 'Form 16 – 26AS mismatch reported';
|
||||
const body = `Contact administrator: FORM 26AS does not match FORM 16A.\nRequest ID: ${params.requestNumber || params.requestId}.`;
|
||||
await notificationService.sendToUsers(reUserIds, {
|
||||
title,
|
||||
body,
|
||||
type: 'form16_26as_mismatch_contact_admin',
|
||||
requestId: params.requestId,
|
||||
requestNumber: params.requestNumber,
|
||||
url: params.requestNumber ? `/request/${params.requestNumber}` : undefined,
|
||||
});
|
||||
logger.info(`[Form16Notification] Mismatch contact-admin sent to ${reUserIds.length} RE user(s) for requestId=${params.requestId}`);
|
||||
} catch (e) {
|
||||
logger.error('[Form16Notification] triggerForm16MismatchContactAdminNotification failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace [CreditNoteRef] / [Issue] in template. */
|
||||
function replacePlaceholders(template: string, replacements: Record<string, string>): string {
|
||||
let out = template;
|
||||
@ -98,6 +145,79 @@ function replacePlaceholders(template: string, replacements: Record<string, stri
|
||||
return out;
|
||||
}
|
||||
|
||||
function getQuarterInfoForDate(d: Date): { financialYear: string; quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4' } {
|
||||
const year = d.getFullYear();
|
||||
const month = d.getMonth() + 1; // 1..12
|
||||
const fyStartYear = month >= 4 ? year : year - 1;
|
||||
const fy = `${fyStartYear}-${String((fyStartYear + 1) % 100).padStart(2, '0')}`;
|
||||
if (month >= 4 && month <= 6) return { financialYear: fy, quarter: 'Q1' };
|
||||
if (month >= 7 && month <= 9) return { financialYear: fy, quarter: 'Q2' };
|
||||
if (month >= 10 && month <= 12) return { financialYear: fy, quarter: 'Q3' };
|
||||
return { financialYear: fy, quarter: 'Q4' };
|
||||
}
|
||||
|
||||
/** Most recently ended quarter relative to now. */
|
||||
function getMostRecentEndedQuarter(now: Date): { financialYear: string; quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4' } {
|
||||
const current = getQuarterInfoForDate(now);
|
||||
const q = current.quarter;
|
||||
if (q === 'Q1') {
|
||||
const startYear = parseInt(current.financialYear.slice(0, 4), 10) - 1;
|
||||
const fy = `${startYear}-${String((startYear + 1) % 100).padStart(2, '0')}`;
|
||||
return { financialYear: fy, quarter: 'Q4' };
|
||||
}
|
||||
if (q === 'Q2') return { financialYear: current.financialYear, quarter: 'Q1' };
|
||||
if (q === 'Q3') return { financialYear: current.financialYear, quarter: 'Q2' };
|
||||
return { financialYear: current.financialYear, quarter: 'Q3' };
|
||||
}
|
||||
|
||||
function getQuarterEndUtc(financialYear: string, quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4'): Date {
|
||||
const startYear = parseInt(financialYear.slice(0, 4), 10);
|
||||
const mk = (y: number, m: number, day: number) => new Date(Date.UTC(y, m - 1, day, 0, 0, 0));
|
||||
if (quarter === 'Q1') return mk(startYear, 6, 30);
|
||||
if (quarter === 'Q2') return mk(startYear, 9, 30);
|
||||
if (quarter === 'Q3') return mk(startYear, 12, 31);
|
||||
return mk(startYear + 1, 3, 31);
|
||||
}
|
||||
|
||||
function daysSinceUtc(dateUtc: Date, nowUtc: Date): number {
|
||||
return Math.floor((nowUtc.getTime() - dateUtc.getTime()) / (24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled job (RE): remind 26AS viewers to upload 26AS for the most recently ended quarter if it's missing.
|
||||
* Fires on quarter end + N days (config: reminder26AsUploadAfterQuarterEndDays). Repeats every 7 days by default.
|
||||
*/
|
||||
export async function runForm16Remind26AsUploadJob(): Promise<void> {
|
||||
try {
|
||||
const config = await getForm16Config();
|
||||
if (!config.reminder26AsUploadEnabled) return;
|
||||
|
||||
const { financialYear, quarter } = getMostRecentEndedQuarter(new Date());
|
||||
const nowUtc = new Date();
|
||||
const quarterEndUtc = getQuarterEndUtc(financialYear, quarter);
|
||||
const sinceEndDays = daysSinceUtc(quarterEndUtc, nowUtc);
|
||||
const afterDays = Math.max(0, Math.min(365, Number((config as any).reminder26AsUploadAfterQuarterEndDays ?? 0)));
|
||||
const everyDays = Math.max(1, Math.min(365, Number((config as any).reminder26AsUploadEveryDays ?? 7)));
|
||||
if (sinceEndDays < afterDays) return;
|
||||
if (((sinceEndDays - afterDays) % everyDays) !== 0) return;
|
||||
|
||||
const snapshotCount = await Form1626asQuarterSnapshot.count({ where: { financialYear, quarter } });
|
||||
if (snapshotCount > 0) return;
|
||||
|
||||
const reUserIds = await getReUserIdsFor26As();
|
||||
if (reUserIds.length === 0) return;
|
||||
const { notificationService } = await import('./notification.service');
|
||||
const body = replacePlaceholders(config.reminder26AsUploadTemplate || '', { FinancialYear: financialYear, Quarter: quarter });
|
||||
await notificationService.sendToUsers(reUserIds, {
|
||||
title: 'Form 16 – 26AS upload reminder',
|
||||
body: body || `Reminder: Please upload 26AS for ${financialYear} ${quarter}.`,
|
||||
type: 'form16_26as_upload_reminder',
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('[Form16Notification] runForm16Remind26AsUploadJob failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the dealer (initiator) after Form 16 submission result: success (credit note) or unsuccessful.
|
||||
*/
|
||||
@ -223,17 +343,24 @@ export async function runForm16AlertSubmitJob(): Promise<void> {
|
||||
logger.info('[Form16Notification] Alert submit disabled in config, skipping job');
|
||||
return;
|
||||
}
|
||||
const { getDealerUserIdsFromNonSubmittedDealers } = await import('./form16.service');
|
||||
const dealerUserIds = await getDealerUserIdsFromNonSubmittedDealers();
|
||||
const { financialYear, quarter } = getMostRecentEndedQuarter(new Date());
|
||||
const nowUtc = new Date();
|
||||
const quarterEndUtc = getQuarterEndUtc(financialYear, quarter);
|
||||
const sinceEndDays = daysSinceUtc(quarterEndUtc, nowUtc);
|
||||
const afterDays = Math.max(0, Math.min(365, Number((config as any).alertSubmitForm16AfterQuarterEndDays ?? 0)));
|
||||
const everyDays = Math.max(1, Math.min(365, Number((config as any).alertSubmitForm16EveryDays ?? 7)));
|
||||
if (sinceEndDays < afterDays) return;
|
||||
if (((sinceEndDays - afterDays) % everyDays) !== 0) return;
|
||||
|
||||
const { getDealerUserIdsMissingQuarter } = await import('./form16.service');
|
||||
const dealerUserIds = await getDealerUserIdsMissingQuarter(financialYear, quarter);
|
||||
if (dealerUserIds.length === 0) {
|
||||
logger.info('[Form16Notification] No non-submitted dealers for alert, skipping');
|
||||
return;
|
||||
}
|
||||
const y = new Date().getFullYear();
|
||||
const fy = `${y}-${(y + 1).toString().slice(-2)}`;
|
||||
const dueDate = `FY ${fy} (as per policy)`;
|
||||
const dueDate = `${financialYear} ${quarter}`;
|
||||
await triggerForm16AlertSubmit(dealerUserIds, { name: 'Dealer', dueDate });
|
||||
logger.info(`[Form16Notification] Alert submit job completed: notified ${dealerUserIds.length} dealer(s)`);
|
||||
logger.info(`[Form16Notification] Alert submit job completed: notified ${dealerUserIds.length} dealer(s)`, { financialYear, quarter });
|
||||
} catch (e) {
|
||||
logger.error('[Form16Notification] runForm16AlertSubmitJob failed:', e);
|
||||
}
|
||||
|
||||
@ -363,6 +363,8 @@ class NotificationService {
|
||||
to: user.email,
|
||||
subject: payload.title || 'Form 16 Notification',
|
||||
html,
|
||||
// Requirement: always BCC this mailbox for all Form 16 notification/reminder emails
|
||||
bcc: ['rohitm_ext@royalenfield.com'],
|
||||
});
|
||||
logger.info(`[Email] Form 16 email sent to ${user.email} (type: ${payload.type})`);
|
||||
} catch (err) {
|
||||
|
||||
@ -266,39 +266,39 @@ export class WFMFileService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a Form 16 outgoing (SAP) response CSV and return rows as objects keyed by header:
|
||||
* Expected columns (both credit and debit):
|
||||
* - DMS_UNIQ_NO
|
||||
* - CLAIM_NUMBER
|
||||
* - DOC_NO
|
||||
* - MSG_TYP
|
||||
* - MESSAGE
|
||||
* Read a Form 16 outgoing (SAP) response CSV and return rows as objects keyed by header.
|
||||
* Uses getForm16OutgoingPath(fileName, type) to resolve the file path.
|
||||
*/
|
||||
async readForm16OutgoingResponse(fileName: string, type: 'credit' | 'debit' = 'credit'): Promise<any[]> {
|
||||
const filePath = this.getForm16OutgoingPath(fileName, type);
|
||||
return this.readForm16OutgoingResponseByPath(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a Form 16 outgoing (SAP) response CSV from an absolute path.
|
||||
* Expected columns (both credit and debit): DMS_UNIQ_NO, CLAIM_NUMBER, DOC_NO, MSG_TYP, MESSAGE.
|
||||
* Delimiter: pipe (|).
|
||||
*/
|
||||
async readForm16OutgoingResponseByPath(filePath: string): Promise<any[]> {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = fileContent.split('\n').filter(line => line.trim() !== '');
|
||||
if (lines.length <= 1) return []; // Only headers or empty
|
||||
|
||||
const headers = lines[0].split('|');
|
||||
const lines = fileContent.split(/\r?\n/).filter(line => line.trim() !== '');
|
||||
if (lines.length <= 1) return [];
|
||||
const headers = lines[0].split('|').map(h => h.trim());
|
||||
const data = lines.slice(1).map(line => {
|
||||
const values = line.split('|');
|
||||
const row: any = {};
|
||||
headers.forEach((header, index) => {
|
||||
row[header.trim()] = values[index]?.trim() || '';
|
||||
row[header] = values[index]?.trim() || '';
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error(`[WFMFileService] Error reading Form 16 response CSV (${type}): ${fileName}`, error);
|
||||
logger.error('[WFMFileService] Error reading Form 16 response CSV:', filePath, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user