Compare commits
No commits in common. "41b8b57efea401ea6975b95c15eea51b79dec267" and "0f99fe68d5380a7ef4819472874d607ec6683d30" have entirely different histories.
41b8b57efe
...
0f99fe68d5
@ -1 +1 @@
|
|||||||
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};
|
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};
|
||||||
File diff suppressed because one or more lines are too long
64
build/assets/index-BgKXDGEk.js
Normal file
64
build/assets/index-BgKXDGEk.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-D2NzWWdB.css
Normal file
1
build/assets/index-D2NzWWdB.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-CxsBWvVP.js
Normal file
2
build/assets/ui-vendor-CxsBWvVP.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-BACqZT24.js"></script>
|
<script type="module" crossorigin src="/assets/index-BgKXDGEk.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-BVfwAPj-.js">
|
<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/radix-vendor-CYvDqP9X.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-CxsBWvVP.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-BATWUvr6.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-C9eBMrZm.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D2NzWWdB.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -1,137 +0,0 @@
|
|||||||
# 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,8 +14,6 @@ import { extractForm16ADetails } from '../services/form16Ocr.service';
|
|||||||
import { canViewForm16Submission, canView26As } from '../services/form16Permission.service';
|
import { canViewForm16Submission, canView26As } from '../services/form16Permission.service';
|
||||||
import { ResponseHandler } from '../utils/responseHandler';
|
import { ResponseHandler } from '../utils/responseHandler';
|
||||||
import logger from '../utils/logger';
|
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.
|
* Form 16 controller: credit notes, OCR extract, and create submission for dealers.
|
||||||
@ -82,28 +80,6 @@ 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
|
* GET /api/v1/form16/dealer/submissions
|
||||||
* Dealer only. List Form 16 submissions for the authenticated dealer (pending/failed for Pending Submissions page).
|
* Dealer only. List Form 16 submissions for the authenticated dealer (pending/failed for Pending Submissions page).
|
||||||
@ -350,65 +326,6 @@ 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 /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.
|
||||||
@ -764,62 +681,6 @@ export class Form16Controller {
|
|||||||
return ResponseHandler.error(res, 'Failed to send test notification', 500, errorMessage);
|
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();
|
export const form16Controller = new Form16Controller();
|
||||||
|
|||||||
@ -1,24 +0,0 @@
|
|||||||
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,17 +1,13 @@
|
|||||||
import { getForm16Config } from '../services/form16Config.service';
|
import { getForm16Config } from '../services/form16Config.service';
|
||||||
import { runForm16AlertSubmitJob, runForm16ReminderJob, runForm16Remind26AsUploadJob } from '../services/form16Notification.service';
|
import { runForm16AlertSubmitJob, runForm16ReminderJob } from '../services/form16Notification.service';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
const TZ = process.env.TZ || 'Asia/Kolkata';
|
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. */
|
/** Last date (YYYY-MM-DD) we ran the alert job in the configured timezone. */
|
||||||
let lastAlertRunDate: string | null = null;
|
let lastAlertRunDate: string | null = null;
|
||||||
/** Last date (YYYY-MM-DD) we ran the reminder job in the configured timezone. */
|
/** Last date (YYYY-MM-DD) we ran the reminder job in the configured timezone. */
|
||||||
let lastReminderRunDate: string | null = null;
|
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).
|
* Get current time in configured TZ as HH:mm (24h, zero-padded).
|
||||||
@ -55,12 +51,6 @@ async function form16NotificationTick(): Promise<void> {
|
|||||||
logger.info(`[Form16 Job] Running reminder job (scheduled at ${reminderTime})`);
|
logger.info(`[Form16 Job] Running reminder job (scheduled at ${reminderTime})`);
|
||||||
await runForm16ReminderJob();
|
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) {
|
} catch (e) {
|
||||||
logger.error('[Form16 Job] Tick error:', e);
|
logger.error('[Form16 Job] Tick error:', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,240 +0,0 @@
|
|||||||
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)');
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
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,7 +7,6 @@ export interface Form16CreditNoteAttributes {
|
|||||||
id: number;
|
id: number;
|
||||||
submissionId: number;
|
submissionId: number;
|
||||||
creditNoteNumber: string;
|
creditNoteNumber: string;
|
||||||
trnsUniqNo?: string;
|
|
||||||
sapDocumentNumber?: string;
|
sapDocumentNumber?: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
issueDate: Date;
|
issueDate: Date;
|
||||||
@ -33,7 +32,6 @@ class Form16CreditNote
|
|||||||
public id!: number;
|
public id!: number;
|
||||||
public submissionId!: number;
|
public submissionId!: number;
|
||||||
public creditNoteNumber!: string;
|
public creditNoteNumber!: string;
|
||||||
public trnsUniqNo?: string;
|
|
||||||
public sapDocumentNumber?: string;
|
public sapDocumentNumber?: string;
|
||||||
public amount!: number;
|
public amount!: number;
|
||||||
public issueDate!: Date;
|
public issueDate!: Date;
|
||||||
@ -68,11 +66,6 @@ Form16CreditNote.init(
|
|||||||
unique: true,
|
unique: true,
|
||||||
field: 'credit_note_number',
|
field: 'credit_note_number',
|
||||||
},
|
},
|
||||||
trnsUniqNo: {
|
|
||||||
type: DataTypes.STRING(120),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'trns_uniq_no',
|
|
||||||
},
|
|
||||||
sapDocumentNumber: {
|
sapDocumentNumber: {
|
||||||
type: DataTypes.STRING(50),
|
type: DataTypes.STRING(50),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
|
|||||||
@ -7,7 +7,6 @@ export interface Form16DebitNoteAttributes {
|
|||||||
id: number;
|
id: number;
|
||||||
creditNoteId: number;
|
creditNoteId: number;
|
||||||
debitNoteNumber: string;
|
debitNoteNumber: string;
|
||||||
trnsUniqNo?: string;
|
|
||||||
sapDocumentNumber?: string;
|
sapDocumentNumber?: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
issueDate: Date;
|
issueDate: Date;
|
||||||
@ -31,7 +30,6 @@ class Form16DebitNote
|
|||||||
public id!: number;
|
public id!: number;
|
||||||
public creditNoteId!: number;
|
public creditNoteId!: number;
|
||||||
public debitNoteNumber!: string;
|
public debitNoteNumber!: string;
|
||||||
public trnsUniqNo?: string;
|
|
||||||
public sapDocumentNumber?: string;
|
public sapDocumentNumber?: string;
|
||||||
public amount!: number;
|
public amount!: number;
|
||||||
public issueDate!: Date;
|
public issueDate!: Date;
|
||||||
@ -66,11 +64,6 @@ Form16DebitNote.init(
|
|||||||
unique: true,
|
unique: true,
|
||||||
field: 'debit_note_number',
|
field: 'debit_note_number',
|
||||||
},
|
},
|
||||||
trnsUniqNo: {
|
|
||||||
type: DataTypes.STRING(120),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'trns_uniq_no',
|
|
||||||
},
|
|
||||||
sapDocumentNumber: {
|
sapDocumentNumber: {
|
||||||
type: DataTypes.STRING(50),
|
type: DataTypes.STRING(50),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
|
|||||||
@ -1,83 +0,0 @@
|
|||||||
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 };
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
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,8 +38,6 @@ import { Form16NonSubmittedNotification } from './Form16NonSubmittedNotification
|
|||||||
import { Form1626asQuarterSnapshot } from './Form1626asQuarterSnapshot';
|
import { Form1626asQuarterSnapshot } from './Form1626asQuarterSnapshot';
|
||||||
import { Form16QuarterStatus } from './Form16QuarterStatus';
|
import { Form16QuarterStatus } from './Form16QuarterStatus';
|
||||||
import { Form16LedgerEntry } from './Form16LedgerEntry';
|
import { Form16LedgerEntry } from './Form16LedgerEntry';
|
||||||
import { Form16SapResponse } from './Form16SapResponse';
|
|
||||||
import { Form16DebitNoteSapResponse } from './Form16DebitNoteSapResponse';
|
|
||||||
|
|
||||||
// Define associations
|
// Define associations
|
||||||
const defineAssociations = () => {
|
const defineAssociations = () => {
|
||||||
@ -224,9 +222,7 @@ export {
|
|||||||
Form16NonSubmittedNotification,
|
Form16NonSubmittedNotification,
|
||||||
Form1626asQuarterSnapshot,
|
Form1626asQuarterSnapshot,
|
||||||
Form16QuarterStatus,
|
Form16QuarterStatus,
|
||||||
Form16LedgerEntry,
|
Form16LedgerEntry
|
||||||
Form16SapResponse,
|
|
||||||
Form16DebitNoteSapResponse
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export default sequelize instance
|
// Export default sequelize instance
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import fs from 'fs';
|
|||||||
import { authenticateToken } from '../middlewares/auth.middleware';
|
import { authenticateToken } from '../middlewares/auth.middleware';
|
||||||
import { requireForm16SubmissionAccess, requireForm1626AsAccess, requireForm16ReOnly } from '../middlewares/form16Permission.middleware';
|
import { requireForm16SubmissionAccess, requireForm1626AsAccess, requireForm16ReOnly } from '../middlewares/form16Permission.middleware';
|
||||||
import { form16Controller } from '../controllers/form16.controller';
|
import { form16Controller } from '../controllers/form16.controller';
|
||||||
import { form16SapController } from '../controllers/form16Sap.controller';
|
|
||||||
import { asyncHandler } from '../middlewares/errorHandler.middleware';
|
import { asyncHandler } from '../middlewares/errorHandler.middleware';
|
||||||
import { UPLOAD_DIR } from '../config/storage';
|
import { UPLOAD_DIR } from '../config/storage';
|
||||||
|
|
||||||
@ -74,29 +73,11 @@ router.get(
|
|||||||
requireForm16SubmissionAccess,
|
requireForm16SubmissionAccess,
|
||||||
asyncHandler(form16Controller.listCreditNotes.bind(form16Controller))
|
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(
|
router.get(
|
||||||
'/credit-notes/:id',
|
'/credit-notes/:id',
|
||||||
requireForm16SubmissionAccess,
|
requireForm16SubmissionAccess,
|
||||||
asyncHandler(form16Controller.getCreditNoteById.bind(form16Controller))
|
asyncHandler(form16Controller.getCreditNoteById.bind(form16Controller))
|
||||||
);
|
);
|
||||||
router.get(
|
|
||||||
'/credit-notes/:id/download',
|
|
||||||
requireForm16SubmissionAccess,
|
|
||||||
asyncHandler(form16Controller.downloadCreditNote.bind(form16Controller))
|
|
||||||
);
|
|
||||||
router.get(
|
router.get(
|
||||||
'/requests/:requestId/credit-note',
|
'/requests/:requestId/credit-note',
|
||||||
requireForm16SubmissionAccess,
|
requireForm16SubmissionAccess,
|
||||||
@ -116,20 +97,6 @@ router.post(
|
|||||||
asyncHandler(form16Controller.setForm16ResubmissionNeeded.bind(form16Controller))
|
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.
|
// Form 16 SAP simulation (credit note / debit note). Replace with real SAP when integrating.
|
||||||
router.post(
|
router.post(
|
||||||
'/sap-simulate/credit-note',
|
'/sap-simulate/credit-note',
|
||||||
|
|||||||
@ -176,9 +176,6 @@ async function runMigrations(): Promise<void> {
|
|||||||
const m59 = require('../migrations/20260309-add-wfm-push-fields');
|
const m59 = require('../migrations/20260309-add-wfm-push-fields');
|
||||||
const m60 = require('../migrations/20260316-update-holiday-type-enum');
|
const m60 = require('../migrations/20260316-update-holiday-type-enum');
|
||||||
const m61 = require('../migrations/20260317-refactor-activity-types-columns');
|
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 = [
|
const migrations = [
|
||||||
{ name: '2025103000-create-users', module: m0 },
|
{ name: '2025103000-create-users', module: m0 },
|
||||||
@ -247,9 +244,6 @@ async function runMigrations(): Promise<void> {
|
|||||||
{ name: '20260309-add-wfm-push-fields', module: m59 },
|
{ name: '20260309-add-wfm-push-fields', module: m59 },
|
||||||
{ name: '20260316-update-holiday-type-enum', module: m60 },
|
{ name: '20260316-update-holiday-type-enum', module: m60 },
|
||||||
{ name: '20260317-refactor-activity-types-columns', module: m61 },
|
{ 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
|
// Dynamically import sequelize after secrets are loaded
|
||||||
|
|||||||
@ -66,9 +66,6 @@ import * as m58 from '../migrations/20260303100001-drop-form16a-number-unique';
|
|||||||
import * as m59 from '../migrations/20260309-add-wfm-push-fields';
|
import * as m59 from '../migrations/20260309-add-wfm-push-fields';
|
||||||
import * as m60 from '../migrations/20260316-update-holiday-type-enum';
|
import * as m60 from '../migrations/20260316-update-holiday-type-enum';
|
||||||
import * as m61 from '../migrations/20260317-refactor-activity-types-columns';
|
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 {
|
interface Migration {
|
||||||
name: string;
|
name: string;
|
||||||
@ -142,9 +139,6 @@ const migrations: Migration[] = [
|
|||||||
{ name: '20260309-add-wfm-push-fields', module: m59 },
|
{ name: '20260309-add-wfm-push-fields', module: m59 },
|
||||||
{ name: '20260316-update-holiday-type-enum', module: m60 },
|
{ name: '20260316-update-holiday-type-enum', module: m60 },
|
||||||
{ name: '20260317-refactor-activity-types-columns', module: m61 },
|
{ 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,8 +117,6 @@ const startServer = async (): Promise<void> => {
|
|||||||
startPauseResumeJob();
|
startPauseResumeJob();
|
||||||
const { startForm16NotificationJobs } = require('./jobs/form16NotificationJob');
|
const { startForm16NotificationJobs } = require('./jobs/form16NotificationJob');
|
||||||
startForm16NotificationJobs();
|
startForm16NotificationJobs();
|
||||||
const { startForm16SapResponseJob } = require('./jobs/form16SapResponseJob');
|
|
||||||
startForm16SapResponseJob();
|
|
||||||
const { startForm16ArchiveJob } = require('./services/form16Archive.service');
|
const { startForm16ArchiveJob } = require('./services/form16Archive.service');
|
||||||
startForm16ArchiveJob();
|
startForm16ArchiveJob();
|
||||||
|
|
||||||
|
|||||||
@ -18,8 +18,6 @@ import {
|
|||||||
Form1626asQuarterSnapshot,
|
Form1626asQuarterSnapshot,
|
||||||
Form16QuarterStatus,
|
Form16QuarterStatus,
|
||||||
Form16LedgerEntry,
|
Form16LedgerEntry,
|
||||||
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';
|
||||||
@ -239,15 +237,6 @@ export async function listCreditNotesForDealer(userId: string, filters?: { finan
|
|||||||
return { rows: [], total: 0 };
|
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 };
|
const whereSubmission: any = { dealerCode };
|
||||||
if (filters?.financialYear) whereSubmission.financialYear = filters.financialYear;
|
if (filters?.financialYear) whereSubmission.financialYear = filters.financialYear;
|
||||||
if (filters?.quarter) whereSubmission.quarter = filters.quarter;
|
if (filters?.quarter) whereSubmission.quarter = filters.quarter;
|
||||||
@ -273,22 +262,6 @@ export async function listCreditNotesForDealer(userId: string, filters?: { finan
|
|||||||
order: [['issueDate', 'DESC'], ['createdAt', 'DESC']],
|
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({
|
const dealer = await Dealer.findOne({
|
||||||
where: { [Op.or]: [{ salesCode: dealerCode }, { dlrcode: dealerCode }], isActive: true },
|
where: { [Op.or]: [{ salesCode: dealerCode }, { dlrcode: dealerCode }], isActive: true },
|
||||||
attributes: ['dealership', 'dealerPrincipalName'],
|
attributes: ['dealership', 'dealerPrincipalName'],
|
||||||
@ -300,7 +273,6 @@ export async function listCreditNotesForDealer(userId: string, filters?: { finan
|
|||||||
id: r.id,
|
id: r.id,
|
||||||
creditNoteNumber: r.creditNoteNumber,
|
creditNoteNumber: r.creditNoteNumber,
|
||||||
sapDocumentNumber: r.sapDocumentNumber,
|
sapDocumentNumber: r.sapDocumentNumber,
|
||||||
sapResponseAvailable: sapSet.has(r.id),
|
|
||||||
amount: r.amount,
|
amount: r.amount,
|
||||||
issueDate: r.issueDate,
|
issueDate: r.issueDate,
|
||||||
financialYear: r.financialYear,
|
financialYear: r.financialYear,
|
||||||
@ -639,7 +611,6 @@ 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)
|
// Push Form 16 credit note incoming CSV to WFM INCOMING/WFM_MAIN/FORM16_CRDT (SAP credit note generation – exact fields only)
|
||||||
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 });
|
|
||||||
const docDate = now.toISOString().slice(0, 10).replace(/-/g, '');
|
const docDate = now.toISOString().slice(0, 10).replace(/-/g, '');
|
||||||
const fyCompact = form16FyCompact(financialYear) || '';
|
const fyCompact = form16FyCompact(financialYear) || '';
|
||||||
const finYearAndQuarter = fyCompact && quarter ? `FY_${fyCompact}_${quarter}` : '';
|
const finYearAndQuarter = fyCompact && quarter ? `FY_${fyCompact}_${quarter}` : '';
|
||||||
@ -871,29 +842,6 @@ export async function listAllCreditNotesForRe(filters?: { financialYear?: string
|
|||||||
order: [['issueDate', 'DESC'], ['createdAt', 'DESC']],
|
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 dealerCodes = [...new Set(rows.map((r) => (r as any).submission?.dealerCode).filter(Boolean))] as string[];
|
||||||
const dealers = dealerCodes.length
|
const dealers = dealerCodes.length
|
||||||
? await Dealer.findAll({
|
? await Dealer.findAll({
|
||||||
@ -922,7 +870,6 @@ export async function listAllCreditNotesForRe(filters?: { financialYear?: string
|
|||||||
id: r.id,
|
id: r.id,
|
||||||
creditNoteNumber: r.creditNoteNumber,
|
creditNoteNumber: r.creditNoteNumber,
|
||||||
sapDocumentNumber: r.sapDocumentNumber,
|
sapDocumentNumber: r.sapDocumentNumber,
|
||||||
sapResponseAvailable: sapSet.has(r.id),
|
|
||||||
amount: r.amount,
|
amount: r.amount,
|
||||||
issueDate: r.issueDate,
|
issueDate: r.issueDate,
|
||||||
financialYear: r.financialYear,
|
financialYear: r.financialYear,
|
||||||
@ -962,99 +909,6 @@ export async function listCreditNotesDealerOrRe(userId: string, filters?: { fina
|
|||||||
return listAllCreditNotesForRe(filters);
|
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).
|
* List Form 16 submissions for the authenticated dealer (for Pending Submissions page).
|
||||||
* Optional filter: status = pending | failed | pending,failed (default: pending,failed).
|
* Optional filter: status = pending | failed | pending,failed (default: pending,failed).
|
||||||
@ -1478,46 +1332,6 @@ 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) ----------
|
// ---------- Non-submitted dealers (RE only) ----------
|
||||||
const QUARTERS = ['Q1', 'Q2', 'Q3', 'Q4'] as const;
|
const QUARTERS = ['Q1', 'Q2', 'Q3', 'Q4'] as const;
|
||||||
|
|
||||||
@ -1725,24 +1539,6 @@ export async function getDealerUserIdsFromNonSubmittedDealers(financialYear?: st
|
|||||||
return users.map((u) => (u as any).userId);
|
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).
|
* 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.
|
* Returns one entry per (userId, requestId) so the reminder can include the request ID. Used by the reminder scheduled job.
|
||||||
@ -2265,7 +2061,6 @@ 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)
|
// Push Form 16 debit note CSV to WFM INCOMING/WFM_MAIN/FORM16_DEBT (same column set as credit note / SAP expectation)
|
||||||
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 });
|
|
||||||
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}` : '';
|
||||||
|
|||||||
@ -25,13 +25,6 @@ export interface Form16Notification26AsConfig {
|
|||||||
|
|
||||||
export interface Form16Config {
|
export interface Form16Config {
|
||||||
notification26AsDataAdded: Form16Notification26AsConfig;
|
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 };
|
notificationForm16SuccessCreditNote: { enabled: boolean; template: string };
|
||||||
notificationForm16Unsuccessful: { enabled: boolean; template: string };
|
notificationForm16Unsuccessful: { enabled: boolean; template: string };
|
||||||
reminderNotificationEnabled: boolean;
|
reminderNotificationEnabled: boolean;
|
||||||
@ -42,10 +35,6 @@ export interface Form16Config {
|
|||||||
alertSubmitForm16Template: string;
|
alertSubmitForm16Template: string;
|
||||||
/** When to run the alert job daily (HH:mm, 24h, server timezone). Empty = no scheduled run. */
|
/** When to run the alert job daily (HH:mm, 24h, server timezone). Empty = no scheduled run. */
|
||||||
alertSubmitForm16RunAtTime: string;
|
alertSubmitForm16RunAtTime: string;
|
||||||
/** Dealer reminder: start after quarter ends + N days. */
|
|
||||||
alertSubmitForm16AfterQuarterEndDays: number;
|
|
||||||
/** Dealer reminder: repeat every N days after start. */
|
|
||||||
alertSubmitForm16EveryDays: number;
|
|
||||||
twentySixAsViewerEmails: string[];
|
twentySixAsViewerEmails: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,10 +46,6 @@ const default26As = (): Form16Notification26AsConfig => ({
|
|||||||
|
|
||||||
const defaults: Form16Config = {
|
const defaults: Form16Config = {
|
||||||
notification26AsDataAdded: default26As(),
|
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].' },
|
notificationForm16SuccessCreditNote: { enabled: true, template: 'Form 16 submitted successfully. Credit note: [CreditNoteRef].' },
|
||||||
notificationForm16Unsuccessful: { enabled: true, template: 'Form 16 submission was unsuccessful. Issue: [Issue].' },
|
notificationForm16Unsuccessful: { enabled: true, template: 'Form 16 submission was unsuccessful. Issue: [Issue].' },
|
||||||
reminderNotificationEnabled: true,
|
reminderNotificationEnabled: true,
|
||||||
@ -69,8 +54,6 @@ const defaults: Form16Config = {
|
|||||||
alertSubmitForm16Enabled: true,
|
alertSubmitForm16Enabled: true,
|
||||||
alertSubmitForm16Template: 'Please submit your Form 16 at your earliest. [Name], due date: [DueDate].',
|
alertSubmitForm16Template: 'Please submit your Form 16 at your earliest. [Name], due date: [DueDate].',
|
||||||
alertSubmitForm16RunAtTime: '09:00',
|
alertSubmitForm16RunAtTime: '09:00',
|
||||||
alertSubmitForm16AfterQuarterEndDays: 0,
|
|
||||||
alertSubmitForm16EveryDays: 7,
|
|
||||||
twentySixAsViewerEmails: [],
|
twentySixAsViewerEmails: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -103,16 +86,6 @@ export async function getForm16Config(): Promise<Form16Config> {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
notification26AsDataAdded: merge26As(),
|
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:
|
notificationForm16SuccessCreditNote:
|
||||||
parsed.notificationForm16SuccessCreditNote && typeof (parsed.notificationForm16SuccessCreditNote as any).template === 'string'
|
parsed.notificationForm16SuccessCreditNote && typeof (parsed.notificationForm16SuccessCreditNote as any).template === 'string'
|
||||||
? { enabled: (parsed.notificationForm16SuccessCreditNote as any).enabled !== false, template: (parsed.notificationForm16SuccessCreditNote as any).template }
|
? { enabled: (parsed.notificationForm16SuccessCreditNote as any).enabled !== false, template: (parsed.notificationForm16SuccessCreditNote as any).template }
|
||||||
@ -127,14 +100,6 @@ export async function getForm16Config(): Promise<Form16Config> {
|
|||||||
alertSubmitForm16Enabled: typeof parsed.alertSubmitForm16Enabled === 'boolean' ? parsed.alertSubmitForm16Enabled : defaults.alertSubmitForm16Enabled,
|
alertSubmitForm16Enabled: typeof parsed.alertSubmitForm16Enabled === 'boolean' ? parsed.alertSubmitForm16Enabled : defaults.alertSubmitForm16Enabled,
|
||||||
alertSubmitForm16Template: typeof parsed.alertSubmitForm16Template === 'string' ? parsed.alertSubmitForm16Template : defaults.alertSubmitForm16Template,
|
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) : '',
|
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)
|
twentySixAsViewerEmails: Array.isArray(parsed.twentySixAsViewerEmails)
|
||||||
? (parsed.twentySixAsViewerEmails as string[]).map((e) => String(e).trim().toLowerCase()).filter(Boolean)
|
? (parsed.twentySixAsViewerEmails as string[]).map((e) => String(e).trim().toLowerCase()).filter(Boolean)
|
||||||
: defaults.twentySixAsViewerEmails,
|
: defaults.twentySixAsViewerEmails,
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import { Dealer } from '@models/Dealer';
|
|||||||
import { Form16CreditNote } from '@models/Form16CreditNote';
|
import { Form16CreditNote } from '@models/Form16CreditNote';
|
||||||
import { Form16aSubmission } from '@models/Form16aSubmission';
|
import { Form16aSubmission } from '@models/Form16aSubmission';
|
||||||
import { WorkflowRequest } from '@models/WorkflowRequest';
|
import { WorkflowRequest } from '@models/WorkflowRequest';
|
||||||
import { Form1626asQuarterSnapshot } from '@models/Form1626asQuarterSnapshot';
|
|
||||||
import { getForm16Config } from './form16Config.service';
|
import { getForm16Config } from './form16Config.service';
|
||||||
import logger from '@utils/logger';
|
import logger from '@utils/logger';
|
||||||
|
|
||||||
@ -35,40 +34,24 @@ function userWhereEmailIn(emails: string[]) {
|
|||||||
return { [Op.or]: emails.map((e) => ({ email: { [Op.iLike]: e } })) };
|
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[]> {
|
export async function getReUserIdsFor26As(): Promise<string[]> {
|
||||||
const config = await getForm16Config();
|
const config = await getForm16Config();
|
||||||
const viewerEmails = config.twentySixAsViewerEmails || [];
|
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) {
|
if (viewerEmails.length > 0) {
|
||||||
const users = await User.findAll({
|
const users = await User.findAll({
|
||||||
where: userWhereEmailIn(viewerEmails),
|
where: userWhereEmailIn(viewerEmails),
|
||||||
attributes: ['userId'],
|
attributes: ['userId'],
|
||||||
raw: true,
|
raw: true,
|
||||||
});
|
});
|
||||||
const ids = users.map((u) => (u as any).userId).filter(Boolean);
|
return users.map((u) => (u as any).userId);
|
||||||
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 allUsers = await User.findAll({ attributes: ['userId'], raw: true });
|
||||||
const allIds = allUsers.map((u) => (u as any).userId).filter(Boolean);
|
const allIds = allUsers.map((u) => (u as any).userId);
|
||||||
return [...new Set([...adminIds, ...allIds])].filter((id) => !dealerSet.has(id));
|
if (dealerIds.length === 0) return allIds;
|
||||||
|
const dealerSet = new Set(dealerIds);
|
||||||
|
return allIds.filter((id) => !dealerSet.has(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -106,36 +89,6 @@ 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. */
|
/** Replace [CreditNoteRef] / [Issue] in template. */
|
||||||
function replacePlaceholders(template: string, replacements: Record<string, string>): string {
|
function replacePlaceholders(template: string, replacements: Record<string, string>): string {
|
||||||
let out = template;
|
let out = template;
|
||||||
@ -145,79 +98,6 @@ function replacePlaceholders(template: string, replacements: Record<string, stri
|
|||||||
return out;
|
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.
|
* Notify the dealer (initiator) after Form 16 submission result: success (credit note) or unsuccessful.
|
||||||
*/
|
*/
|
||||||
@ -343,24 +223,17 @@ export async function runForm16AlertSubmitJob(): Promise<void> {
|
|||||||
logger.info('[Form16Notification] Alert submit disabled in config, skipping job');
|
logger.info('[Form16Notification] Alert submit disabled in config, skipping job');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { financialYear, quarter } = getMostRecentEndedQuarter(new Date());
|
const { getDealerUserIdsFromNonSubmittedDealers } = await import('./form16.service');
|
||||||
const nowUtc = new Date();
|
const dealerUserIds = await getDealerUserIdsFromNonSubmittedDealers();
|
||||||
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) {
|
if (dealerUserIds.length === 0) {
|
||||||
logger.info('[Form16Notification] No non-submitted dealers for alert, skipping');
|
logger.info('[Form16Notification] No non-submitted dealers for alert, skipping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const dueDate = `${financialYear} ${quarter}`;
|
const y = new Date().getFullYear();
|
||||||
|
const fy = `${y}-${(y + 1).toString().slice(-2)}`;
|
||||||
|
const dueDate = `FY ${fy} (as per policy)`;
|
||||||
await triggerForm16AlertSubmit(dealerUserIds, { name: 'Dealer', dueDate });
|
await triggerForm16AlertSubmit(dealerUserIds, { name: 'Dealer', dueDate });
|
||||||
logger.info(`[Form16Notification] Alert submit job completed: notified ${dealerUserIds.length} dealer(s)`, { financialYear, quarter });
|
logger.info(`[Form16Notification] Alert submit job completed: notified ${dealerUserIds.length} dealer(s)`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('[Form16Notification] runForm16AlertSubmitJob failed:', e);
|
logger.error('[Form16Notification] runForm16AlertSubmitJob failed:', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -363,8 +363,6 @@ class NotificationService {
|
|||||||
to: user.email,
|
to: user.email,
|
||||||
subject: payload.title || 'Form 16 Notification',
|
subject: payload.title || 'Form 16 Notification',
|
||||||
html,
|
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})`);
|
logger.info(`[Email] Form 16 email sent to ${user.email} (type: ${payload.type})`);
|
||||||
} catch (err) {
|
} 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.
|
* 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.
|
* Expected columns (both credit and debit):
|
||||||
|
* - DMS_UNIQ_NO
|
||||||
|
* - CLAIM_NUMBER
|
||||||
|
* - DOC_NO
|
||||||
|
* - MSG_TYP
|
||||||
|
* - MESSAGE
|
||||||
*/
|
*/
|
||||||
async readForm16OutgoingResponse(fileName: string, type: 'credit' | 'debit' = 'credit'): Promise<any[]> {
|
async readForm16OutgoingResponse(fileName: string, type: 'credit' | 'debit' = 'credit'): Promise<any[]> {
|
||||||
const filePath = this.getForm16OutgoingPath(fileName, type);
|
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 {
|
try {
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
||||||
const lines = fileContent.split(/\r?\n/).filter(line => line.trim() !== '');
|
const lines = fileContent.split('\n').filter(line => line.trim() !== '');
|
||||||
if (lines.length <= 1) return [];
|
if (lines.length <= 1) return []; // Only headers or empty
|
||||||
const headers = lines[0].split('|').map(h => h.trim());
|
|
||||||
|
const headers = lines[0].split('|');
|
||||||
const data = lines.slice(1).map(line => {
|
const data = lines.slice(1).map(line => {
|
||||||
const values = line.split('|');
|
const values = line.split('|');
|
||||||
const row: any = {};
|
const row: any = {};
|
||||||
headers.forEach((header, index) => {
|
headers.forEach((header, index) => {
|
||||||
row[header] = values[index]?.trim() || '';
|
row[header.trim()] = values[index]?.trim() || '';
|
||||||
});
|
});
|
||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[WFMFileService] Error reading Form 16 response CSV:', filePath, error);
|
logger.error(`[WFMFileService] Error reading Form 16 response CSV (${type}): ${fileName}`, error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user