Merge origin/laxman_dev into laxman_dev.
Accept pulled backend updates and keep existing resolved functionality. Made-with: Cursor
This commit is contained in:
commit
3c1c743df6
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
||||
import{a as s}from"./index-CwFNZe2z.js";import"./radix-vendor-CYvDqP9X.js";import"./charts-vendor-BVfwAPj-.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-BrA5VgBk.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-BATWUvr6.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};
|
||||
import{a as s}from"./index-B4PRp9Lp.js";import"./radix-vendor-CLtqm-Ae.js";import"./charts-vendor-CmYZJIYl.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-DgwXkk2Y.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-HW_ujxKo.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};
|
||||
64
build/assets/index-B4PRp9Lp.js
Normal file
64
build/assets/index-B4PRp9Lp.js
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/index-BNFD-0wA.css
Normal file
1
build/assets/index-BNFD-0wA.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
build/assets/ui-vendor-DgwXkk2Y.js
Normal file
2
build/assets/ui-vendor-DgwXkk2Y.js
Normal file
File diff suppressed because one or more lines are too long
@ -13,15 +13,15 @@
|
||||
<!-- Preload essential fonts and icons -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<script type="module" crossorigin src="/assets/index-CwFNZe2z.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-BVfwAPj-.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CYvDqP9X.js">
|
||||
<script type="module" crossorigin src="/assets/index-B4PRp9Lp.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-CmYZJIYl.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CLtqm-Ae.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-BrA5VgBk.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-DgwXkk2Y.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/router-vendor-BATWUvr6.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C9eBMrZm.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/router-vendor-HW_ujxKo.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BNFD-0wA.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@ -138,4 +138,4 @@ SAP_DISABLE_SSL_VERIFY=false
|
||||
# WFM Archive configuration examples (if overrides are needed)
|
||||
# WFM_ARCHIVE_GST_CLAIMS_PATH=WFM-QRE\INCOMING\WFM_ARACHIVE\DLR_INC_CLAIMS_GST
|
||||
# WFM_FORM16_CREDIT_ARCHIVE_PATH=WFM-QRE\INCOMING\WFM_ARACHIVE\FORM16_CRDT
|
||||
|
||||
#CREDIT_NOTE_SYNC_INTERVAL_MINUTES=1
|
||||
|
||||
@ -23,7 +23,8 @@ export class AuthController {
|
||||
// Validate request body
|
||||
const validatedData = validateSSOCallback(req.body);
|
||||
|
||||
const result = await this.authService.handleSSOCallback(validatedData as any);
|
||||
const userAgent = req.headers['user-agent'] || getRequestMetadata(req).userAgent;
|
||||
const result = await this.authService.handleSSOCallback(validatedData as any, userAgent);
|
||||
|
||||
// Log login activity
|
||||
const requestMeta = getRequestMetadata(req);
|
||||
@ -180,7 +181,8 @@ export class AuthController {
|
||||
const { code, redirectUri } = validateTokenExchange(req.body);
|
||||
logger.info('Tanflow token exchange validation passed', { redirectUri });
|
||||
|
||||
const result = await this.authService.exchangeTanflowCodeForTokens(code, redirectUri);
|
||||
const userAgent = req.headers['user-agent'] || getRequestMetadata(req).userAgent;
|
||||
const result = await this.authService.exchangeTanflowCodeForTokens(code, redirectUri, userAgent);
|
||||
|
||||
// Log login activity
|
||||
const requestMeta = getRequestMetadata(req);
|
||||
@ -395,6 +397,13 @@ export class AuthController {
|
||||
// Clear all cookies using multiple methods
|
||||
clearCookiesCompletely();
|
||||
|
||||
if (userId !== 'unknown') {
|
||||
const user = await this.authService.getUserProfile(userId);
|
||||
if (user) {
|
||||
await this.authService.updateUserProfile(userId, { sessionToken: null, lastLoginDevice: null });
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('User logout successful - cookies cleared', {
|
||||
userId: req.user?.userId || 'unknown',
|
||||
email: req.user?.email || 'unknown',
|
||||
@ -452,7 +461,8 @@ export class AuthController {
|
||||
|
||||
const { username, password } = validatePasswordLogin(req.body);
|
||||
|
||||
const result = await this.authService.authenticateWithPassword(username, password);
|
||||
const userAgent = req.headers['user-agent'] || getRequestMetadata(req).userAgent;
|
||||
const result = await this.authService.authenticateWithPassword(username, password, userAgent);
|
||||
|
||||
// Log login activity
|
||||
const requestMeta = getRequestMetadata(req);
|
||||
@ -535,7 +545,8 @@ export class AuthController {
|
||||
const { code, redirectUri } = validateTokenExchange(req.body);
|
||||
logger.info('Token exchange validation passed', { redirectUri });
|
||||
|
||||
const result = await this.authService.exchangeCodeForTokens(code, redirectUri);
|
||||
const userAgent = req.headers['user-agent'] || getRequestMetadata(req).userAgent;
|
||||
const result = await this.authService.exchangeCodeForTokens(code, redirectUri, userAgent);
|
||||
|
||||
// Log login activity
|
||||
const requestMeta = getRequestMetadata(req);
|
||||
|
||||
@ -15,10 +15,12 @@ import crypto from 'crypto';
|
||||
import { DealerClaimDetails } from '../models/DealerClaimDetails';
|
||||
import { ClaimInvoice } from '../models/ClaimInvoice';
|
||||
import { ClaimInvoiceItem } from '../models/ClaimInvoiceItem';
|
||||
import { ClaimCreditNote } from '../models/ClaimCreditNote';
|
||||
import { ClaimCreditNoteItem } from '../models/ClaimCreditNoteItem';
|
||||
import { ActivityType } from '../models/ActivityType';
|
||||
import { Participant } from '../models/Participant';
|
||||
import { sanitizeObject, sanitizePermissive } from '../utils/sanitizer';
|
||||
import { padDealerCode } from '../utils/helpers';
|
||||
import { buildWfmClaimCsvRow, padDealerCode, WFM_CLAIM_CSV_HEADERS } from '../utils/helpers';
|
||||
import { costBreakupSchema, closedExpensesSchema, updateEInvoiceSchema, updateIOSchema } from '../validators/dealerClaim.validator';
|
||||
|
||||
export class DealerClaimController {
|
||||
@ -1114,7 +1116,8 @@ export class DealerClaimController {
|
||||
const isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
|
||||
|
||||
// Construct CSV with pipe separator
|
||||
const headers = [
|
||||
const headers = isNonGst
|
||||
? [
|
||||
'TRNS_UNIQ_NO',
|
||||
'CLAIM_NUMBER',
|
||||
'INV_NUMBER',
|
||||
@ -1124,42 +1127,38 @@ export class DealerClaimController {
|
||||
'CLAIM_TYPE',
|
||||
'CLAIM_DATE',
|
||||
'CLAIM_AMT'
|
||||
];
|
||||
|
||||
if (!isNonGst) {
|
||||
headers.push('GST_AMT', 'GST_PERCENTAGE');
|
||||
}
|
||||
]
|
||||
: [...WFM_CLAIM_CSV_HEADERS];
|
||||
|
||||
const rows = items.map(item => {
|
||||
// For Non-GST, we hide HSN (often stored in transactionCode)
|
||||
const trnsUniqNo = item.transactionCode || '';
|
||||
const claimNumber = requestNumber;
|
||||
const invNumber = invoice?.invoiceNumber || '';
|
||||
const dealerCode = padDealerCode(claimDetails?.dealerCode || '');
|
||||
const ioNumber = internalOrder?.ioNumber || '';
|
||||
const claimDocTyp = sapRefNo;
|
||||
const claimType = claimDetails?.activityType || '';
|
||||
const claimDate = invoice?.createdAt ? new Date(invoice.createdAt).toISOString().split('T')[0] : '';
|
||||
const claimAmt = item.assAmt;
|
||||
|
||||
const rowItems = [
|
||||
trnsUniqNo,
|
||||
claimNumber,
|
||||
invNumber,
|
||||
dealerCode,
|
||||
ioNumber,
|
||||
claimDocTyp,
|
||||
claimType,
|
||||
if (isNonGst) {
|
||||
const d = new Date(invoice?.invoiceDate || invoice?.createdAt || new Date());
|
||||
const claimDate = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
|
||||
return [
|
||||
item.transactionCode || '',
|
||||
requestNumber,
|
||||
invoice?.invoiceNumber || '',
|
||||
padDealerCode(claimDetails?.dealerCode || ''),
|
||||
internalOrder?.ioNumber || '',
|
||||
sapRefNo,
|
||||
claimDetails?.activityType || '',
|
||||
claimDate,
|
||||
claimAmt
|
||||
];
|
||||
|
||||
if (!isNonGst) {
|
||||
const totalTax = Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0) + Number(item.utgstAmt || 0);
|
||||
rowItems.push(totalTax.toFixed(2), item.gstRt || 0);
|
||||
item.assAmt
|
||||
].join('|');
|
||||
}
|
||||
|
||||
return rowItems.join('|');
|
||||
const row = buildWfmClaimCsvRow({
|
||||
item: item as any,
|
||||
requestNumber,
|
||||
invoiceNumber: invoice?.invoiceNumber || '',
|
||||
invoiceDate: (invoice?.invoiceDate as Date) || (invoice?.createdAt as Date) || new Date(),
|
||||
dealerCode: claimDetails?.dealerCode || '',
|
||||
ioNumber: internalOrder?.ioNumber || '',
|
||||
claimDocTyp: sapRefNo,
|
||||
claimType: claimDetails?.activityType || '',
|
||||
});
|
||||
|
||||
return headers.map((key) => String((row as any)[key] ?? '')).join('|');
|
||||
});
|
||||
|
||||
const csvContent = [headers.join('|'), ...rows].join('\n');
|
||||
@ -1232,9 +1231,67 @@ export class DealerClaimController {
|
||||
}
|
||||
|
||||
const { wfmFileService } = await import('../services/wfmFile.service');
|
||||
const creditNoteData = await wfmFileService.getCreditNoteDetails(claimDetails.dealerCode, requestNumber, isNonGst);
|
||||
const existingCreditNote = await ClaimCreditNote.findOne({
|
||||
where: { requestId },
|
||||
include: [{
|
||||
model: ClaimCreditNoteItem,
|
||||
as: 'items',
|
||||
attributes: ['transactionNo'],
|
||||
order: [['slNo', 'ASC']]
|
||||
}]
|
||||
}) as any;
|
||||
|
||||
return ResponseHandler.success(res, creditNoteData, 'Credit note data fetched successfully');
|
||||
if (existingCreditNote?.sapDocumentNumber || existingCreditNote?.creditNoteNumber) {
|
||||
let displayTxn = existingCreditNote.transactionNo || '';
|
||||
const items = existingCreditNote.items || [];
|
||||
if (items.length > 1) {
|
||||
const first = items[0].transactionNo;
|
||||
const last = items[items.length - 1].transactionNo;
|
||||
if (first && last && first !== last) {
|
||||
displayTxn = `${first} - ${last}`;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = [{
|
||||
TRNS_UNIQ_NO: displayTxn,
|
||||
CLAIM_NUMBER: requestNumber,
|
||||
DOC_NO: existingCreditNote.sapDocumentNumber || existingCreditNote.creditNoteNumber || '',
|
||||
MSG_TYP: existingCreditNote.status || '',
|
||||
MESSAGE: existingCreditNote.errorMessage || ''
|
||||
}];
|
||||
return ResponseHandler.success(res, payload, 'Credit note data fetched successfully');
|
||||
}
|
||||
|
||||
const { filePath, data: creditNoteData } = await wfmFileService.getCreditNoteDetailsWithPath(
|
||||
claimDetails.dealerCode,
|
||||
requestNumber,
|
||||
isNonGst
|
||||
);
|
||||
|
||||
if (!creditNoteData.length) {
|
||||
return ResponseHandler.success(res, [], 'Credit note data fetched successfully');
|
||||
}
|
||||
|
||||
// Process the file using the sync service (handles grouping and transactions)
|
||||
const { creditNoteSyncService } = await import('../services/creditNoteSync.service');
|
||||
await creditNoteSyncService.processFile(filePath);
|
||||
|
||||
// Return unified row with range if multiple rows exist for this claim
|
||||
const claimRows = creditNoteData.filter(row => row.CLAIM_NUMBER === requestNumber);
|
||||
if (claimRows.length === 0) {
|
||||
return ResponseHandler.success(res, [], 'Credit note data fetched successfully');
|
||||
}
|
||||
|
||||
const claimRow = { ...claimRows[0] };
|
||||
if (claimRows.length > 1) {
|
||||
const first = claimRows[0].TRNS_UNIQ_NO;
|
||||
const last = claimRows[claimRows.length - 1].TRNS_UNIQ_NO;
|
||||
if (first && last && first !== last) {
|
||||
claimRow.TRNS_UNIQ_NO = `${first} - ${last}`;
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseHandler.success(res, [claimRow], 'Credit note data fetched successfully');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[DealerClaimController] Error fetching credit note WFM data:', error);
|
||||
|
||||
133
src/controllers/hsnSacCode.controller.ts
Normal file
133
src/controllers/hsnSacCode.controller.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { hsnSacCodeService } from '../services/hsnSacCode.service';
|
||||
import { ResponseHandler } from '../utils/responseHandler';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
export class HsnSacCodeController {
|
||||
/**
|
||||
* Get HSN/SAC codes with pagination and search
|
||||
*/
|
||||
async getAllCodes(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const onlyActive = req.query.active === 'true';
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = parseInt(req.query.limit as string) || 10;
|
||||
const search = req.query.search as string;
|
||||
|
||||
const result = await hsnSacCodeService.getAllCodes(onlyActive, page, limit, search);
|
||||
|
||||
ResponseHandler.success(
|
||||
res,
|
||||
result.codes,
|
||||
'HSN/SAC codes fetched successfully',
|
||||
200,
|
||||
result.pagination
|
||||
);
|
||||
} catch (error: any) {
|
||||
logger.error('Error in getAllCodes controller:', error);
|
||||
ResponseHandler.error(res, 'Failed to fetch HSN/SAC codes', 500, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get code by ID
|
||||
*/
|
||||
async getCodeById(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const code = await hsnSacCodeService.getCodeById(id);
|
||||
if (!code) {
|
||||
return ResponseHandler.error(res, 'HSN/SAC code not found', 404);
|
||||
}
|
||||
ResponseHandler.success(res, code, 'HSN/SAC code fetched successfully');
|
||||
} catch (error: any) {
|
||||
logger.error('Error in getCodeById controller:', error);
|
||||
ResponseHandler.error(res, 'Failed to fetch HSN/SAC code', 500, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new code
|
||||
*/
|
||||
async createCode(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { code, type, gstRate, description, isActive } = req.body;
|
||||
|
||||
if (!code || !type) {
|
||||
return ResponseHandler.error(res, 'Code and type are required', 400);
|
||||
}
|
||||
|
||||
const newCode = await hsnSacCodeService.createCode({
|
||||
code,
|
||||
type,
|
||||
gstRate,
|
||||
description,
|
||||
isActive: isActive !== undefined ? isActive : true
|
||||
});
|
||||
|
||||
ResponseHandler.success(res, newCode, 'HSN/SAC code created successfully', 201);
|
||||
} catch (error: any) {
|
||||
logger.error('Error in createCode controller:', error);
|
||||
ResponseHandler.error(res, 'Failed to create HSN/SAC code', 500, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update code
|
||||
*/
|
||||
async updateCode(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updatedCode = await hsnSacCodeService.updateCode(id, req.body);
|
||||
|
||||
if (!updatedCode) {
|
||||
return ResponseHandler.error(res, 'HSN/SAC code not found', 404);
|
||||
}
|
||||
|
||||
ResponseHandler.success(res, updatedCode, 'HSN/SAC code updated successfully');
|
||||
} catch (error: any) {
|
||||
logger.error('Error in updateCode controller:', error);
|
||||
ResponseHandler.error(res, 'Failed to update HSN/SAC code', 500, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete code
|
||||
*/
|
||||
async deleteCode(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const success = await hsnSacCodeService.deleteCode(id);
|
||||
|
||||
if (!success) {
|
||||
return ResponseHandler.error(res, 'HSN/SAC code not found', 404);
|
||||
}
|
||||
|
||||
ResponseHandler.success(res, null, 'HSN/SAC code deleted successfully');
|
||||
} catch (error: any) {
|
||||
logger.error('Error in deleteCode controller:', error);
|
||||
ResponseHandler.error(res, 'Failed to delete HSN/SAC code', 500, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle active status
|
||||
*/
|
||||
async toggleActive(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updatedCode = await hsnSacCodeService.toggleActive(id);
|
||||
|
||||
if (!updatedCode) {
|
||||
return ResponseHandler.error(res, 'HSN/SAC code not found', 404);
|
||||
}
|
||||
|
||||
ResponseHandler.success(res, updatedCode, 'HSN/SAC code status toggled successfully');
|
||||
} catch (error: any) {
|
||||
logger.error('Error in toggleActive controller:', error);
|
||||
ResponseHandler.error(res, 'Failed to toggle HSN/SAC code status', 500, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const hsnSacCodeController = new HsnSacCodeController();
|
||||
25
src/jobs/creditNoteSyncJob.ts
Normal file
25
src/jobs/creditNoteSyncJob.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { creditNoteSyncService } from '../services/creditNoteSync.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Main sync function to process all outgoing files
|
||||
* Delegates to creditNoteSyncService
|
||||
*/
|
||||
export async function syncCreditNotes(): Promise<void> {
|
||||
await creditNoteSyncService.syncCreditNotes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the background sync job
|
||||
*/
|
||||
export function startCreditNoteSyncJob(): void {
|
||||
const intervalMinutes = Number(process.env.CREDIT_NOTE_SYNC_INTERVAL_MINUTES) || 5;
|
||||
logger.info(`[CreditNoteSyncJob] Background job initialized (Interval: ${intervalMinutes}m)`);
|
||||
|
||||
// Run once immediately on startup
|
||||
syncCreditNotes().catch(err => logger.error('[CreditNoteSyncJob] Initial sync failed:', err));
|
||||
|
||||
setInterval(() => {
|
||||
syncCreditNotes().catch(err => logger.error('[CreditNoteSyncJob] Periodic sync failed:', err));
|
||||
}, intervalMinutes * 60 * 1000);
|
||||
}
|
||||
@ -12,6 +12,7 @@ interface JwtPayload {
|
||||
employeeId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
sessionToken: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
@ -70,6 +71,15 @@ export const authenticateToken = async (
|
||||
return;
|
||||
}
|
||||
|
||||
if (!decoded.sessionToken || decoded.sessionToken !== user.sessionToken) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
errorCode: 'SESSION_SUPERSEDED',
|
||||
message: `You have been logged out because an active session was detected from ${user.lastLoginDevice || 'another device'}.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Attach user info to request object
|
||||
req.user = {
|
||||
userId: user.userId,
|
||||
@ -117,6 +127,7 @@ export const optionalAuth = async (
|
||||
const user = await User.findByPk(decoded.userId);
|
||||
|
||||
if (user && user.isActive) {
|
||||
if (!decoded.sessionToken || decoded.sessionToken === user.sessionToken) {
|
||||
req.user = {
|
||||
userId: user.userId,
|
||||
email: user.email,
|
||||
@ -125,6 +136,7 @@ export const optionalAuth = async (
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
import { QueryInterface, DataTypes } from 'sequelize';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface: QueryInterface) => {
|
||||
// 1. Add sessionToken to users table
|
||||
await queryInterface.addColumn('users', 'sessionToken', {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
comment: 'Unique token for active session to restrict concurrent logins'
|
||||
});
|
||||
|
||||
// 2. Add lastLoginDevice to users table
|
||||
await queryInterface.addColumn('users', 'lastLoginDevice', {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
comment: 'Browser/Device string from User-Agent of the active session'
|
||||
});
|
||||
|
||||
// 3. Create hsn_sac_codes table
|
||||
await queryInterface.createTable('hsn_sac_codes', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
code: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false,
|
||||
comment: 'The HSN or SAC code value'
|
||||
},
|
||||
type: {
|
||||
type: DataTypes.ENUM('HSN', 'SAC'),
|
||||
allowNull: false,
|
||||
comment: 'Type of code: either HSN or SAC'
|
||||
},
|
||||
gstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'gst_rate',
|
||||
comment: 'Associated GST rate percentage'
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'Description of the code'
|
||||
},
|
||||
isActive: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true,
|
||||
allowNull: false,
|
||||
field: 'is_active'
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'created_at'
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'updated_at'
|
||||
}
|
||||
});
|
||||
|
||||
// Add indexes to hsn_sac_codes
|
||||
await queryInterface.addIndex('hsn_sac_codes', ['code']);
|
||||
await queryInterface.addIndex('hsn_sac_codes', ['type']);
|
||||
await queryInterface.addIndex('hsn_sac_codes', ['is_active']);
|
||||
},
|
||||
|
||||
down: async (queryInterface: QueryInterface) => {
|
||||
// Drop hsn_sac_codes table
|
||||
await queryInterface.dropTable('hsn_sac_codes');
|
||||
|
||||
// Remove columns from users table
|
||||
await queryInterface.removeColumn('users', 'lastLoginDevice');
|
||||
await queryInterface.removeColumn('users', 'sessionToken');
|
||||
|
||||
// Also drop the ENUM type created for hsn_sac_codes type
|
||||
await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_hsn_sac_codes_type";');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,166 @@
|
||||
import { QueryInterface, DataTypes } from 'sequelize';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface: QueryInterface) => {
|
||||
// 1. Update claim_credit_notes table with idempotency checks
|
||||
const tableDefinition = await queryInterface.describeTable('claim_credit_notes');
|
||||
|
||||
if (!tableDefinition.transaction_no) {
|
||||
await queryInterface.addColumn('claim_credit_notes', 'transaction_no', {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
comment: 'Overall PWC transaction unique number'
|
||||
});
|
||||
}
|
||||
|
||||
if (!tableDefinition.tds_amount) {
|
||||
await queryInterface.addColumn('claim_credit_notes', 'tds_amount', {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
defaultValue: 0,
|
||||
comment: 'TDS amount for the credit note'
|
||||
});
|
||||
}
|
||||
|
||||
if (!tableDefinition.credit_amount) {
|
||||
await queryInterface.addColumn('claim_credit_notes', 'credit_amount', {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
defaultValue: 0,
|
||||
comment: 'Final credit amount after TDS'
|
||||
});
|
||||
}
|
||||
|
||||
// Drop redundant columns if they exist
|
||||
const columnsToDrop = [
|
||||
'gst_rate', 'gst_amt', 'cgst_rate', 'cgst_amt',
|
||||
'sgst_rate', 'sgst_amt', 'igst_rate', 'igst_amt',
|
||||
'utgst_rate', 'utgst_amt', 'cess_rate', 'cess_amt',
|
||||
'total_amt'
|
||||
];
|
||||
|
||||
for (const column of columnsToDrop) {
|
||||
if (tableDefinition[column]) {
|
||||
await queryInterface.removeColumn('claim_credit_notes', column);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Create claim_credit_note_items table (Refined & Unified)
|
||||
const allTables = await queryInterface.showAllTables();
|
||||
const tableExists = allTables.some(t => {
|
||||
const name = typeof t === 'string' ? t : (t as any).tableName;
|
||||
return name.toLowerCase() === 'claim_credit_note_items';
|
||||
});
|
||||
|
||||
if (!tableExists) {
|
||||
await queryInterface.createTable('claim_credit_note_items', {
|
||||
item_id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
credit_note_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'claim_credit_notes',
|
||||
key: 'credit_note_id',
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
sl_no: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
transaction_no: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
comment: 'Per-item TRNS_UNIQ_NO'
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
hsn_cd: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true,
|
||||
},
|
||||
amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
claim_amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
defaultValue: 0,
|
||||
},
|
||||
tds_amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
defaultValue: 0,
|
||||
},
|
||||
credit_amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
defaultValue: 0,
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
}
|
||||
});
|
||||
|
||||
// Add index for performance
|
||||
await queryInterface.addIndex('claim_credit_note_items', ['credit_note_id']);
|
||||
await queryInterface.addIndex('claim_credit_note_items', ['transaction_no']);
|
||||
}
|
||||
},
|
||||
|
||||
down: async (queryInterface: QueryInterface) => {
|
||||
// Drop the items table first
|
||||
await queryInterface.dropTable('claim_credit_note_items');
|
||||
|
||||
// Re-add dropped columns to claim_credit_notes (if they were removed)
|
||||
const tableDefinition = await queryInterface.describeTable('claim_credit_notes');
|
||||
const columnsToReAdd = [
|
||||
{ name: 'gst_rate', type: DataTypes.DECIMAL(5, 2) },
|
||||
{ name: 'gst_amt', type: DataTypes.DECIMAL(15, 2) },
|
||||
{ name: 'cgst_rate', type: DataTypes.DECIMAL(5, 2) },
|
||||
{ name: 'cgst_amt', type: DataTypes.DECIMAL(15, 2) },
|
||||
{ name: 'sgst_rate', type: DataTypes.DECIMAL(5, 2) },
|
||||
{ name: 'sgst_amt', type: DataTypes.DECIMAL(15, 2) },
|
||||
{ name: 'igst_rate', type: DataTypes.DECIMAL(5, 2) },
|
||||
{ name: 'igst_amt', type: DataTypes.DECIMAL(15, 2) },
|
||||
{ name: 'utgst_rate', type: DataTypes.DECIMAL(5, 2) },
|
||||
{ name: 'utgst_amt', type: DataTypes.DECIMAL(15, 2) },
|
||||
{ name: 'cess_rate', type: DataTypes.DECIMAL(5, 2) },
|
||||
{ name: 'cess_amt', type: DataTypes.DECIMAL(15, 2) },
|
||||
{ name: 'total_amt', type: DataTypes.DECIMAL(15, 2) }
|
||||
];
|
||||
|
||||
for (const col of columnsToReAdd) {
|
||||
if (!tableDefinition[col.name]) {
|
||||
await queryInterface.addColumn('claim_credit_notes', col.name, {
|
||||
type: col.type,
|
||||
allowNull: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove added columns
|
||||
const addedCols = ['credit_amount', 'tds_amount', 'transaction_no'];
|
||||
for (const col of addedCols) {
|
||||
if (tableDefinition[col]) {
|
||||
await queryInterface.removeColumn('claim_credit_notes', col);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -10,19 +10,9 @@ interface ClaimCreditNoteAttributes {
|
||||
creditNoteNumber?: string;
|
||||
creditNoteDate?: Date;
|
||||
creditNoteAmount: number;
|
||||
gstRate?: number;
|
||||
gstAmt?: number;
|
||||
cgstRate?: number;
|
||||
cgstAmt?: number;
|
||||
sgstRate?: number;
|
||||
sgstAmt?: number;
|
||||
igstRate?: number;
|
||||
igstAmt?: number;
|
||||
utgstRate?: number;
|
||||
utgstAmt?: number;
|
||||
cessRate?: number;
|
||||
cessAmt?: number;
|
||||
totalAmt?: number;
|
||||
transactionNo?: string;
|
||||
tdsAmount?: number;
|
||||
creditAmount?: number;
|
||||
sapDocumentNumber?: string;
|
||||
creditNoteFilePath?: string;
|
||||
status?: string;
|
||||
@ -35,7 +25,7 @@ interface ClaimCreditNoteAttributes {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface ClaimCreditNoteCreationAttributes extends Optional<ClaimCreditNoteAttributes, 'creditNoteId' | 'invoiceId' | 'creditNoteNumber' | 'creditNoteDate' | 'gstRate' | 'gstAmt' | 'cgstRate' | 'cgstAmt' | 'sgstRate' | 'sgstAmt' | 'igstRate' | 'igstAmt' | 'utgstRate' | 'utgstAmt' | 'cessRate' | 'cessAmt' | 'totalAmt' | 'sapDocumentNumber' | 'creditNoteFilePath' | 'status' | 'errorMessage' | 'confirmedBy' | 'confirmedAt' | 'reason' | 'description' | 'createdAt' | 'updatedAt'> { }
|
||||
interface ClaimCreditNoteCreationAttributes extends Optional<ClaimCreditNoteAttributes, 'creditNoteId' | 'invoiceId' | 'creditNoteNumber' | 'creditNoteDate' | 'transactionNo' | 'tdsAmount' | 'creditAmount' | 'sapDocumentNumber' | 'creditNoteFilePath' | 'status' | 'errorMessage' | 'confirmedBy' | 'confirmedAt' | 'reason' | 'description' | 'createdAt' | 'updatedAt'> { }
|
||||
|
||||
class ClaimCreditNote extends Model<ClaimCreditNoteAttributes, ClaimCreditNoteCreationAttributes> implements ClaimCreditNoteAttributes {
|
||||
public creditNoteId!: string;
|
||||
@ -44,19 +34,9 @@ class ClaimCreditNote extends Model<ClaimCreditNoteAttributes, ClaimCreditNoteCr
|
||||
public creditNoteNumber?: string;
|
||||
public creditNoteDate?: Date;
|
||||
public creditNoteAmount!: number;
|
||||
public gstRate?: number;
|
||||
public gstAmt?: number;
|
||||
public cgstRate?: number;
|
||||
public cgstAmt?: number;
|
||||
public sgstRate?: number;
|
||||
public sgstAmt?: number;
|
||||
public igstRate?: number;
|
||||
public igstAmt?: number;
|
||||
public utgstRate?: number;
|
||||
public utgstAmt?: number;
|
||||
public cessRate?: number;
|
||||
public cessAmt?: number;
|
||||
public totalAmt?: number;
|
||||
public transactionNo?: string;
|
||||
public tdsAmount?: number;
|
||||
public creditAmount?: number;
|
||||
public sapDocumentNumber?: string;
|
||||
public creditNoteFilePath?: string;
|
||||
public status?: string;
|
||||
@ -115,70 +95,22 @@ ClaimCreditNote.init(
|
||||
allowNull: false,
|
||||
field: 'credit_amount'
|
||||
},
|
||||
gstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
transactionNo: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
field: 'gst_rate'
|
||||
field: 'transaction_no',
|
||||
},
|
||||
gstAmt: {
|
||||
tdsAmount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'gst_amt'
|
||||
defaultValue: 0,
|
||||
field: 'tds_amount',
|
||||
},
|
||||
cgstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'cgst_rate'
|
||||
},
|
||||
cgstAmt: {
|
||||
creditAmount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'cgst_amt'
|
||||
},
|
||||
sgstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'sgst_rate'
|
||||
},
|
||||
sgstAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'sgst_amt'
|
||||
},
|
||||
igstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'igst_rate'
|
||||
},
|
||||
igstAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'igst_amt'
|
||||
},
|
||||
utgstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'utgst_rate'
|
||||
},
|
||||
utgstAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'utgst_amt'
|
||||
},
|
||||
cessRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'cess_rate'
|
||||
},
|
||||
cessAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'cess_amt'
|
||||
},
|
||||
totalAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'total_amt'
|
||||
defaultValue: 0,
|
||||
field: 'credit_amount',
|
||||
},
|
||||
sapDocumentNumber: {
|
||||
type: DataTypes.STRING(100),
|
||||
|
||||
83
src/models/ClaimCreditNoteItem.ts
Normal file
83
src/models/ClaimCreditNoteItem.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { Model, DataTypes } from 'sequelize';
|
||||
import { sequelize } from '@config/database';
|
||||
|
||||
export class ClaimCreditNoteItem extends Model {
|
||||
public itemId!: string;
|
||||
public creditNoteId!: string;
|
||||
public slNo!: number;
|
||||
public transactionNo!: string | null;
|
||||
public description!: string | null;
|
||||
public hsnCd!: string | null;
|
||||
public amount!: number;
|
||||
public claimAmount!: number | null;
|
||||
public tdsAmount!: number | null;
|
||||
public creditAmount!: number | null;
|
||||
public readonly createdAt!: Date;
|
||||
public readonly updatedAt!: Date;
|
||||
}
|
||||
|
||||
ClaimCreditNoteItem.init(
|
||||
{
|
||||
itemId: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
field: 'item_id',
|
||||
},
|
||||
creditNoteId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
field: 'credit_note_id',
|
||||
},
|
||||
slNo: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'sl_no',
|
||||
},
|
||||
transactionNo: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
field: 'transaction_no',
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'description',
|
||||
},
|
||||
hsnCd: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true,
|
||||
field: 'hsn_cd',
|
||||
},
|
||||
amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'amount',
|
||||
},
|
||||
claimAmount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
defaultValue: 0,
|
||||
field: 'claim_amount',
|
||||
},
|
||||
tdsAmount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
defaultValue: 0,
|
||||
field: 'tds_amount',
|
||||
},
|
||||
creditAmount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
defaultValue: 0,
|
||||
field: 'credit_amount',
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'claim_credit_note_items',
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
}
|
||||
);
|
||||
95
src/models/HsnSacCode.ts
Normal file
95
src/models/HsnSacCode.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '../config/database';
|
||||
|
||||
export type CodeType = 'HSN' | 'SAC';
|
||||
|
||||
export interface HsnSacCodeAttributes {
|
||||
id: string;
|
||||
code: string;
|
||||
type: CodeType;
|
||||
gstRate?: number | null;
|
||||
description?: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface HsnSacCodeCreationAttributes extends Optional<HsnSacCodeAttributes, 'id' | 'gstRate' | 'description' | 'isActive' | 'createdAt' | 'updatedAt'> { }
|
||||
|
||||
class HsnSacCode extends Model<HsnSacCodeAttributes, HsnSacCodeCreationAttributes> implements HsnSacCodeAttributes {
|
||||
public id!: string;
|
||||
public code!: string;
|
||||
public type!: CodeType;
|
||||
public gstRate?: number | null;
|
||||
public description?: string | null;
|
||||
public isActive!: boolean;
|
||||
public readonly createdAt!: Date;
|
||||
public readonly updatedAt!: Date;
|
||||
}
|
||||
|
||||
HsnSacCode.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
code: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false,
|
||||
comment: 'The HSN or SAC code value'
|
||||
},
|
||||
type: {
|
||||
type: DataTypes.ENUM('HSN', 'SAC'),
|
||||
allowNull: false,
|
||||
comment: 'Type of code: either HSN or SAC'
|
||||
},
|
||||
gstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'gst_rate',
|
||||
comment: 'Associated GST rate percentage'
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'Description of the code'
|
||||
},
|
||||
isActive: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true,
|
||||
allowNull: false,
|
||||
field: 'is_active'
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'created_at'
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'updated_at'
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'HsnSacCode',
|
||||
tableName: 'hsn_sac_codes',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['code']
|
||||
},
|
||||
{
|
||||
fields: ['type']
|
||||
},
|
||||
{
|
||||
fields: ['is_active']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
export { HsnSacCode };
|
||||
@ -51,11 +51,13 @@ interface UserAttributes {
|
||||
isActive: boolean;
|
||||
role: UserRole; // RBAC: USER, MANAGEMENT, ADMIN
|
||||
lastLogin?: Date;
|
||||
sessionToken?: string | null;
|
||||
lastLoginDevice?: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface UserCreationAttributes extends Optional<UserAttributes, 'userId' | 'employeeId' | 'department' | 'designation' | 'phone' | 'manager' | 'secondEmail' | 'jobTitle' | 'employeeNumber' | 'postalAddress' | 'mobilePhone' | 'adGroups' | 'emailNotificationsEnabled' | 'pushNotificationsEnabled' | 'inAppNotificationsEnabled' | 'role' | 'lastLogin' | 'createdAt' | 'updatedAt'> { }
|
||||
interface UserCreationAttributes extends Optional<UserAttributes, 'userId' | 'employeeId' | 'department' | 'designation' | 'phone' | 'manager' | 'secondEmail' | 'jobTitle' | 'employeeNumber' | 'postalAddress' | 'mobilePhone' | 'adGroups' | 'emailNotificationsEnabled' | 'pushNotificationsEnabled' | 'inAppNotificationsEnabled' | 'role' | 'lastLogin' | 'sessionToken' | 'lastLoginDevice' | 'createdAt' | 'updatedAt'> { }
|
||||
|
||||
class User extends Model<UserAttributes, UserCreationAttributes> implements UserAttributes {
|
||||
public userId!: string;
|
||||
@ -95,6 +97,8 @@ class User extends Model<UserAttributes, UserCreationAttributes> implements User
|
||||
public isActive!: boolean;
|
||||
public role!: UserRole; // RBAC: USER, MANAGEMENT, ADMIN
|
||||
public lastLogin?: Date;
|
||||
public sessionToken?: string | null;
|
||||
public lastLoginDevice?: string | null;
|
||||
public createdAt!: Date;
|
||||
public updatedAt!: Date;
|
||||
|
||||
@ -278,6 +282,18 @@ User.init(
|
||||
allowNull: true,
|
||||
field: 'last_login'
|
||||
},
|
||||
sessionToken: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
field: 'sessionToken',
|
||||
comment: 'Unique token for active session to restrict concurrent logins'
|
||||
},
|
||||
lastLoginDevice: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'lastLoginDevice',
|
||||
comment: 'Browser/Device string from User-Agent of the active session'
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
|
||||
@ -29,6 +29,7 @@ import { WorkflowTemplate } from './WorkflowTemplate';
|
||||
import { ClaimInvoice } from './ClaimInvoice';
|
||||
import { ClaimInvoiceItem } from './ClaimInvoiceItem';
|
||||
import { ClaimCreditNote } from './ClaimCreditNote';
|
||||
import { ClaimCreditNoteItem } from './ClaimCreditNoteItem';
|
||||
import { Form16aSubmission } from './Form16aSubmission';
|
||||
import { Form16CreditNote } from './Form16CreditNote';
|
||||
import { Form16DebitNote } from './Form16DebitNote';
|
||||
@ -179,6 +180,13 @@ const defineAssociations = () => {
|
||||
sourceKey: 'id'
|
||||
});
|
||||
|
||||
// ClaimCreditNote associations
|
||||
ClaimCreditNote.hasMany(ClaimCreditNoteItem, {
|
||||
as: 'items',
|
||||
foreignKey: 'creditNoteId',
|
||||
sourceKey: 'creditNoteId'
|
||||
});
|
||||
|
||||
// Note: belongsTo associations are defined in individual model files to avoid duplicate alias conflicts
|
||||
// Only hasMany associations from WorkflowRequest are defined here since they're one-way
|
||||
};
|
||||
@ -217,6 +225,7 @@ export {
|
||||
ClaimInvoice,
|
||||
ClaimInvoiceItem,
|
||||
ClaimCreditNote,
|
||||
ClaimCreditNoteItem,
|
||||
Form16aSubmission,
|
||||
Form16CreditNote,
|
||||
Form16DebitNote,
|
||||
|
||||
56
src/routes/hsnSacCode.routes.ts
Normal file
56
src/routes/hsnSacCode.routes.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { Router } from 'express';
|
||||
import { hsnSacCodeController } from '../controllers/hsnSacCode.controller';
|
||||
import { authenticateToken } from '../middlewares/auth.middleware';
|
||||
import { requireAdmin } from '../middlewares/authorization.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All HSN/SAC routes require authentication
|
||||
router.use(authenticateToken);
|
||||
|
||||
/**
|
||||
* @route GET /api/v1/hsn-sac
|
||||
* @desc Get all HSN/SAC codes
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/', (req, res) => hsnSacCodeController.getAllCodes(req, res));
|
||||
|
||||
/**
|
||||
* @route GET /api/v1/hsn-sac/:id
|
||||
* @desc Get code by ID
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/:id', (req, res) => hsnSacCodeController.getCodeById(req, res));
|
||||
|
||||
// Admin only routes for modification
|
||||
router.use(requireAdmin);
|
||||
|
||||
/**
|
||||
* @route POST /api/v1/hsn-sac
|
||||
* @desc Create new HSN/SAC code
|
||||
* @access Private/Admin
|
||||
*/
|
||||
router.post('/', (req, res) => hsnSacCodeController.createCode(req, res));
|
||||
|
||||
/**
|
||||
* @route PATCH /api/v1/hsn-sac/:id
|
||||
* @desc Update HSN/SAC code
|
||||
* @access Private/Admin
|
||||
*/
|
||||
router.patch('/:id', (req, res) => hsnSacCodeController.updateCode(req, res));
|
||||
|
||||
/**
|
||||
* @route DELETE /api/v1/hsn-sac/:id
|
||||
* @desc Delete HSN/SAC code
|
||||
* @access Private/Admin
|
||||
*/
|
||||
router.delete('/:id', (req, res) => hsnSacCodeController.deleteCode(req, res));
|
||||
|
||||
/**
|
||||
* @route PATCH /api/v1/hsn-sac/:id/toggle-active
|
||||
* @desc Toggle active status
|
||||
* @access Private/Admin
|
||||
*/
|
||||
router.patch('/:id/toggle-active', (req, res) => hsnSacCodeController.toggleActive(req, res));
|
||||
|
||||
export default router;
|
||||
@ -34,6 +34,7 @@ import apiTokenRoutes from './apiToken.routes';
|
||||
import antivirusRoutes from './antivirus.routes';
|
||||
import dealerExternalRoutes from './dealerExternal.routes';
|
||||
import form16Routes from './form16.routes';
|
||||
import hsnSacCodeRoutes from './hsnSacCode.routes';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@ -99,6 +100,7 @@ router.use('/dealers', generalApiLimiter, dealerRoutes); // 200 r
|
||||
router.use('/dealers-external', generalApiLimiter, dealerExternalRoutes); // 200 req/15min
|
||||
router.use('/form16', uploadLimiter, form16Routes); // 50 req/15min (file uploads: extract, submissions, 26as)
|
||||
router.use('/api-tokens', authLimiter, apiTokenRoutes); // 20 req/15min (sensitive — same as auth)
|
||||
router.use('/hsn-sac', generalApiLimiter, hsnSacCodeRoutes); // 200 req/15min
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@ -180,6 +180,11 @@ async function runMigrations(): Promise<void> {
|
||||
const m63 = require('../migrations/20260317120001-add-form16-trns-uniq-no');
|
||||
const m64 = require('../migrations/20260318100001-create-form16-debit-note-sap-responses');
|
||||
const m65 = require('../migrations/20260318200001-add-sap-response-csv-fields');
|
||||
const m66 = require('../migrations/20260324090001-refactor-form16-sap-response-and-add-read-log');
|
||||
const m67 = require('../migrations/20260324110001-add-pan-number-to-26as');
|
||||
const m68 = require('../migrations/20260325090001-ensure-pan-number-in-26as');
|
||||
const m69 = require('../migrations/20260325094500-add-user-session-and-hsn-sac-codes');
|
||||
const m70 = require('../migrations/20260325175000-update-credit-notes-and-add-items');
|
||||
|
||||
const migrations = [
|
||||
{ name: '2025103000-create-users', module: m0 },
|
||||
@ -252,6 +257,11 @@ async function runMigrations(): Promise<void> {
|
||||
{ name: '20260317120001-add-form16-trns-uniq-no', module: m63 },
|
||||
{ name: '20260318100001-create-form16-debit-note-sap-responses', module: m64 },
|
||||
{ name: '20260318200001-add-sap-response-csv-fields', module: m65 },
|
||||
{ name: '20260324090001-refactor-form16-sap-response-and-add-read-log', module: m66 },
|
||||
{ name: '20260324110001-add-pan-number-to-26as', module: m67 },
|
||||
{ name: '20260325090001-ensure-pan-number-in-26as', module: m68 },
|
||||
{ name: '20260325094500-add-user-session-and-hsn-sac-codes', module: m69 },
|
||||
{ name: '20260325175000-update-credit-notes-and-add-items', module: m70 },
|
||||
];
|
||||
|
||||
// Dynamically import sequelize after secrets are loaded
|
||||
|
||||
@ -73,6 +73,8 @@ import * as m65 from '../migrations/20260318200001-add-sap-response-csv-fields';
|
||||
import * as m66 from '../migrations/20260324090001-refactor-form16-sap-response-and-add-read-log';
|
||||
import * as m67 from '../migrations/20260324110001-add-pan-number-to-26as';
|
||||
import * as m68 from '../migrations/20260325090001-ensure-pan-number-in-26as';
|
||||
import * as m69 from '../migrations/20260325094500-add-user-session-and-hsn-sac-codes';
|
||||
import * as m70 from '../migrations/20260325175000-update-credit-notes-and-add-items';
|
||||
|
||||
interface Migration {
|
||||
name: string;
|
||||
@ -153,7 +155,8 @@ const migrations: Migration[] = [
|
||||
{ name: '20260324090001-refactor-form16-sap-response-and-add-read-log', module: m66 },
|
||||
{ name: '20260324110001-add-pan-number-to-26as', module: m67 },
|
||||
{ name: '20260325090001-ensure-pan-number-in-26as', module: m68 },
|
||||
|
||||
{ name: '20260325094500-add-user-session-and-hsn-sac-codes', module: m69 },
|
||||
{ name: '20260325175000-update-credit-notes-and-add-items', module: m70 },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -134,6 +134,8 @@ const startServer = async (): Promise<void> => {
|
||||
startForm16NotificationJobs();
|
||||
const { startForm16ArchiveJob } = require('./services/form16Archive.service');
|
||||
startForm16ArchiveJob();
|
||||
const { startCreditNoteSyncJob } = require('./jobs/creditNoteSyncJob');
|
||||
startCreditNoteSyncJob();
|
||||
|
||||
// Initialize queue metrics collection for Prometheus
|
||||
initializeQueueMetrics();
|
||||
|
||||
@ -9,19 +9,19 @@ import { ActivityType } from '@models/ActivityType';
|
||||
*/
|
||||
const DEFAULT_ACTIVITY_TYPES = [
|
||||
{ title: 'Riders Mania Claims', itemCode: '1', taxationType: 'Non GST', sapRefNo: 'ZRDM', creditPostingOn: 'Spares' },
|
||||
{ title: 'Marketing Cost – Bike to Vendor', itemCode: '2', taxationType: 'Non GST', sapRefNo: 'ZMBV', creditPostingOn: 'Vehicle' },
|
||||
{ title: 'Marketing Cost – Bike to Vendor', itemCode: '2', taxationType: 'Non GST', sapRefNo: 'ZMBV', creditPostingOn: 'Vehicles' },
|
||||
{ title: 'Media Bike Service', itemCode: '3', taxationType: 'GST', sapRefNo: 'ZMBS', creditPostingOn: 'Spares' },
|
||||
{ title: 'ARAI Motorcycle Liquidation', itemCode: '4', taxationType: 'GST', sapRefNo: 'ZAML', creditPostingOn: 'Vehicle' },
|
||||
{ title: 'ARAI Certification – STA Approval CNR', itemCode: '5', taxationType: 'Non GST', sapRefNo: 'ZACS', creditPostingOn: 'Vehicle' },
|
||||
{ title: 'ARAI Motorcycle Liquidation', itemCode: '4', taxationType: 'GST', sapRefNo: 'ZAML', creditPostingOn: 'Vehicles' },
|
||||
{ title: 'ARAI Certification – STA Approval CNR', itemCode: '5', taxationType: 'Non GST', sapRefNo: 'ZACS', creditPostingOn: 'Vehicles' },
|
||||
{ title: 'Procurement of Spares/Apparel/GMA for Events', itemCode: '6', taxationType: 'GST', sapRefNo: 'ZPPE', creditPostingOn: 'Spares' },
|
||||
{ title: 'Fuel for Media Bike Used for Event', itemCode: '7', taxationType: 'Non GST', sapRefNo: 'ZFMB', creditPostingOn: 'Vehicle' },
|
||||
{ title: 'Motorcycle Buyback and Goodwill Support', itemCode: '8', taxationType: 'Non GST', sapRefNo: 'ZMBG', creditPostingOn: 'Vehicle' },
|
||||
{ title: 'Liquidation of Used Motorcycle', itemCode: '9', taxationType: 'GST', sapRefNo: 'ZLUM', creditPostingOn: 'Vehicle' },
|
||||
{ title: 'Motorcycle Registration CNR (Owned or Gifted by RE)', itemCode: '10', taxationType: 'GST', sapRefNo: 'ZMRC', creditPostingOn: 'Vehicle' },
|
||||
{ title: 'Legal Claims Reimbursement', itemCode: '11', taxationType: 'Non GST', sapRefNo: 'ZLCR', creditPostingOn: 'Vehicle' },
|
||||
{ title: 'Fuel for Media Bike Used for Event', itemCode: '7', taxationType: 'Non GST', sapRefNo: 'ZFMB', creditPostingOn: 'Vehicles' },
|
||||
{ title: 'Motorcycle Buyback and Goodwill Support', itemCode: '8', taxationType: 'Non GST', sapRefNo: 'ZMBG', creditPostingOn: 'Vehicles' },
|
||||
{ title: 'Liquidation of Used Motorcycle', itemCode: '9', taxationType: 'GST', sapRefNo: 'ZLUM', creditPostingOn: 'Vehicles' },
|
||||
{ title: 'Motorcycle Registration CNR (Owned or Gifted by RE)', itemCode: '10', taxationType: 'GST', sapRefNo: 'ZMRC', creditPostingOn: 'Vehicles' },
|
||||
{ title: 'Legal Claims Reimbursement', itemCode: '11', taxationType: 'Non GST', sapRefNo: 'ZLCR', creditPostingOn: 'Vehicles' },
|
||||
{ title: 'Service Camp Claims', itemCode: '12', taxationType: 'Non GST', sapRefNo: 'ZSCC', creditPostingOn: 'Spares' },
|
||||
{ title: 'Corporate Claims – Institutional Sales PD', itemCode: '13', taxationType: 'Non GST', sapRefNo: 'ZCCN', creditPostingOn: 'Vehicle' },
|
||||
{ title: 'Corporate Claims – Institutional Sales PD', itemCode: '14', taxationType: 'GST', sapRefNo: 'ZCCG', creditPostingOn: 'Vehicle' }
|
||||
{ title: 'Corporate Claims – Institutional Sales PD', itemCode: '13', taxationType: 'Non GST', sapRefNo: 'ZCCN', creditPostingOn: 'Vehicles' },
|
||||
{ title: 'Corporate Claims – Institutional Sales PD', itemCode: '14', taxationType: 'GST', sapRefNo: 'ZCCG', creditPostingOn: 'Vehicles' }
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -6,6 +6,27 @@ import { LoginResponse } from '../types/auth.types';
|
||||
import logger, { logAuthEvent } from '../utils/logger';
|
||||
import axios from 'axios';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { emitToUser } from '../realtime/socket';
|
||||
|
||||
function parseDeviceFromUserAgent(ua?: string): string {
|
||||
if (!ua) return 'Unknown Device';
|
||||
let browser = 'Unknown Browser';
|
||||
if (ua.includes('Firefox/')) browser = 'Firefox';
|
||||
else if (ua.includes('Chrome/') && !ua.includes('Edg/')) browser = 'Chrome';
|
||||
else if (ua.includes('Safari/') && !ua.includes('Chrome/')) browser = 'Safari';
|
||||
else if (ua.includes('Edg/')) browser = 'Edge';
|
||||
|
||||
let os = 'Unknown OS';
|
||||
if (ua.includes('Windows')) os = 'Windows';
|
||||
else if (ua.includes('Mac OS')) os = 'macOS';
|
||||
else if (ua.includes('Linux')) os = 'Linux';
|
||||
else if (ua.includes('Android')) os = 'Android';
|
||||
else if (ua.includes('iOS') || ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
|
||||
|
||||
return `${browser} on ${os}`;
|
||||
}
|
||||
|
||||
|
||||
export class AuthService {
|
||||
/**
|
||||
@ -240,7 +261,7 @@ export class AuthService {
|
||||
* Handle SSO callback from frontend
|
||||
* Creates new user or updates existing user based on employeeId
|
||||
*/
|
||||
async handleSSOCallback(userData: SSOUserData): Promise<LoginResponse> {
|
||||
async handleSSOCallback(userData: SSOUserData, userAgent?: string): Promise<LoginResponse> {
|
||||
try {
|
||||
// Validate required fields - email and oktaSub are required
|
||||
if (!userData.email || !userData.oktaSub) {
|
||||
@ -272,11 +293,16 @@ export class AuthService {
|
||||
displayName = userData.email.split('@')[0] || 'User';
|
||||
}
|
||||
|
||||
const sessionToken = uuidv4();
|
||||
const lastLoginDevice = parseDeviceFromUserAgent(userAgent);
|
||||
|
||||
// Prepare update/create data - always include required fields
|
||||
const userUpdateData: any = {
|
||||
email: userData.email,
|
||||
oktaSub: userData.oktaSub,
|
||||
lastLogin: new Date(),
|
||||
sessionToken,
|
||||
lastLoginDevice,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
@ -315,6 +341,14 @@ export class AuthService {
|
||||
action: 'user_updated',
|
||||
updatedFields: Object.keys(userUpdateData),
|
||||
});
|
||||
|
||||
// Notify previous session via WebSocket to logout immediately
|
||||
// This provides real-time "Last-In-Wins" enforcement
|
||||
emitToUser(user.userId, 'SESSION_SUPERSEDED', {
|
||||
reason: 'CONCURRENT_LOGIN',
|
||||
device: lastLoginDevice,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
// Create new user with required fields (email and oktaSub)
|
||||
user = await User.create({
|
||||
@ -335,7 +369,9 @@ export class AuthService {
|
||||
employeeNumber: userData.employeeNumber || userData.dealerCode || null,
|
||||
isActive: true,
|
||||
role: 'USER',
|
||||
lastLogin: new Date()
|
||||
lastLogin: new Date(),
|
||||
sessionToken,
|
||||
lastLoginDevice
|
||||
});
|
||||
|
||||
logAuthEvent('sso_callback', user.userId, {
|
||||
@ -390,7 +426,8 @@ export class AuthService {
|
||||
userId: user.userId,
|
||||
employeeId: user.employeeId,
|
||||
email: user.email,
|
||||
role: user.role // Keep uppercase: USER, MANAGEMENT, ADMIN
|
||||
role: user.role, // Keep uppercase: USER, MANAGEMENT, ADMIN
|
||||
sessionToken: user.sessionToken
|
||||
};
|
||||
|
||||
const options: SignOptions = {
|
||||
@ -410,7 +447,8 @@ export class AuthService {
|
||||
|
||||
const payload = {
|
||||
userId: user.userId,
|
||||
type: 'refresh'
|
||||
type: 'refresh',
|
||||
sessionToken: user.sessionToken
|
||||
};
|
||||
|
||||
const options: SignOptions = {
|
||||
@ -447,6 +485,10 @@ export class AuthService {
|
||||
throw new Error('User not found or inactive');
|
||||
}
|
||||
|
||||
if (decoded.sessionToken !== user.sessionToken) {
|
||||
throw new Error('Session expired due to login from another device');
|
||||
}
|
||||
|
||||
return this.generateAccessToken(user);
|
||||
} catch (error) {
|
||||
logAuthEvent('auth_failure', undefined, {
|
||||
@ -497,7 +539,7 @@ export class AuthService {
|
||||
* 4. Create/update user in our database if needed
|
||||
* 5. Return our JWT tokens
|
||||
*/
|
||||
async authenticateWithPassword(username: string, password: string): Promise<LoginResponse> {
|
||||
async authenticateWithPassword(username: string, password: string, userAgent?: string): Promise<LoginResponse> {
|
||||
// Demo admin: admin@example.com / Admin@123 (works with or without .env; for dev/demo only)
|
||||
const DEMO_ADMIN_EMAIL = 'admin@example.com';
|
||||
const DEFAULT_DEMO_ADMIN_HASH = '$2a$10$H4ikTC.HDZPM0iFxjBy2C./WlkbGbidipIiZlXIJx6QpcBazdf12K'; // bcrypt of "Admin@123"
|
||||
@ -509,6 +551,9 @@ export class AuthService {
|
||||
const passwordMatch = await bcrypt.compare(password, hash);
|
||||
if (!passwordMatch) return null;
|
||||
let user = await User.findOne({ where: { email: adminEmail } });
|
||||
const sessionToken = uuidv4();
|
||||
const lastLoginDevice = parseDeviceFromUserAgent(userAgent);
|
||||
|
||||
if (!user) {
|
||||
user = await User.create({
|
||||
email: adminEmail,
|
||||
@ -521,10 +566,13 @@ export class AuthService {
|
||||
emailNotificationsEnabled: true,
|
||||
pushNotificationsEnabled: true,
|
||||
inAppNotificationsEnabled: true,
|
||||
sessionToken,
|
||||
lastLoginDevice,
|
||||
lastLogin: new Date()
|
||||
});
|
||||
logger.info('Demo admin user created on first login', { email: adminEmail });
|
||||
} else {
|
||||
await user.update({ lastLogin: new Date() });
|
||||
await user.update({ lastLogin: new Date(), sessionToken, lastLoginDevice });
|
||||
}
|
||||
logger.info('Demo admin login successful', { email: adminEmail });
|
||||
const accessToken = this.generateAccessToken(user);
|
||||
@ -563,7 +611,7 @@ export class AuthService {
|
||||
displayName: 'Test Reflow Dealer',
|
||||
firstName: 'Test',
|
||||
lastName: 'Reflow',
|
||||
});
|
||||
}, userAgent);
|
||||
};
|
||||
|
||||
// Fallback bcrypt hash for "Test@123" when .env hash is corrupted (dev only)
|
||||
@ -608,7 +656,9 @@ export class AuthService {
|
||||
logger.warn('Local dealer login by email: user not found', { email });
|
||||
return null;
|
||||
}
|
||||
await user.update({ lastLogin: new Date() });
|
||||
const sessionToken = uuidv4();
|
||||
const lastLoginDevice = parseDeviceFromUserAgent(userAgent);
|
||||
await user.update({ lastLogin: new Date(), sessionToken, lastLoginDevice });
|
||||
logger.info('Local dealer login by email successful', { email });
|
||||
const accessToken = this.generateAccessToken(user);
|
||||
const refreshToken = this.generateRefreshToken(user);
|
||||
@ -735,7 +785,7 @@ export class AuthService {
|
||||
});
|
||||
|
||||
// Step 4: Create/update user in our database
|
||||
const result = await this.handleSSOCallback(userData);
|
||||
const result = await this.handleSSOCallback(userData, userAgent);
|
||||
|
||||
logger.info('User authenticated successfully via password flow', {
|
||||
userId: result.user.userId,
|
||||
@ -791,7 +841,7 @@ export class AuthService {
|
||||
* This is the FRONTEND callback URL (e.g., http://localhost:3000/login/callback),
|
||||
* NOT the backend URL. Okta verifies this matches to prevent redirect URI attacks.
|
||||
*/
|
||||
async exchangeCodeForTokens(code: string, redirectUri: string): Promise<LoginResponse> {
|
||||
async exchangeCodeForTokens(code: string, redirectUri: string, userAgent?: string): Promise<LoginResponse> {
|
||||
try {
|
||||
// Validate configuration
|
||||
if (!ssoConfig.oktaClientId || ssoConfig.oktaClientId.trim() === '') {
|
||||
@ -926,7 +976,7 @@ export class AuthService {
|
||||
});
|
||||
|
||||
// Handle SSO callback to create/update user and generate our tokens
|
||||
const result = await this.handleSSOCallback(userData);
|
||||
const result = await this.handleSSOCallback(userData, userAgent);
|
||||
|
||||
// Return our JWT tokens along with Okta tokens (store Okta refresh token for future use)
|
||||
return {
|
||||
@ -970,7 +1020,7 @@ export class AuthService {
|
||||
* Exchange Tanflow authorization code for tokens
|
||||
* Similar to Okta flow but uses Tanflow IAM endpoints
|
||||
*/
|
||||
async exchangeTanflowCodeForTokens(code: string, redirectUri: string): Promise<LoginResponse> {
|
||||
async exchangeTanflowCodeForTokens(code: string, redirectUri: string, userAgent?: string): Promise<LoginResponse> {
|
||||
try {
|
||||
// Validate configuration
|
||||
if (!ssoConfig.tanflowClientId || ssoConfig.tanflowClientId.trim() === '') {
|
||||
@ -1138,7 +1188,7 @@ export class AuthService {
|
||||
});
|
||||
|
||||
// Handle SSO callback to create/update user and generate our tokens
|
||||
const result = await this.handleSSOCallback(userData);
|
||||
const result = await this.handleSSOCallback(userData, userAgent);
|
||||
|
||||
// Return our JWT tokens along with Tanflow tokens
|
||||
return {
|
||||
|
||||
161
src/services/creditNoteSync.service.ts
Normal file
161
src/services/creditNoteSync.service.ts
Normal file
@ -0,0 +1,161 @@
|
||||
import fs from 'fs';
|
||||
import { wfmFileService } from './wfmFile.service';
|
||||
import { WorkflowRequest } from '../models/WorkflowRequest';
|
||||
import { ClaimCreditNote } from '../models/ClaimCreditNote';
|
||||
import { ClaimCreditNoteItem } from '../models/ClaimCreditNoteItem';
|
||||
import { sequelize } from '@config/database';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
export class CreditNoteSyncService {
|
||||
/**
|
||||
* Main sync function to process all outgoing files
|
||||
*/
|
||||
async syncCreditNotes(): Promise<void> {
|
||||
try {
|
||||
const gstFiles = wfmFileService.listOutgoingFiles(false);
|
||||
const nonGstFiles = wfmFileService.listOutgoingFiles(true);
|
||||
|
||||
const allFiles = [
|
||||
...gstFiles.map(f => ({ path: f, isNonGst: false })),
|
||||
...nonGstFiles.map(f => ({ path: f, isNonGst: true }))
|
||||
];
|
||||
|
||||
if (allFiles.length === 0) return;
|
||||
|
||||
logger.info(`[CreditNoteSyncService] Found ${allFiles.length} files to process`);
|
||||
|
||||
for (const fileInfo of allFiles) {
|
||||
await this.processFile(fileInfo.path);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[CreditNoteSyncService] Error during sync:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single CSV file
|
||||
*/
|
||||
async processFile(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
|
||||
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = fileContent.split(/\r?\n/).filter(l => l.trim() !== '');
|
||||
if (lines.length <= 1) {
|
||||
// Empty or only headers - delete it
|
||||
fs.unlinkSync(filePath);
|
||||
logger.info(`[CreditNoteSyncService] Deleted empty/header-only file: ${filePath}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const headers = lines[0].split('|').map(h => h.trim().toUpperCase());
|
||||
const rows = lines.slice(1).map(line => {
|
||||
const values = line.split('|');
|
||||
const row: any = {};
|
||||
headers.forEach((h, i) => { row[h] = values[i]?.trim() || ''; });
|
||||
return row;
|
||||
});
|
||||
|
||||
// Group rows by CLAIM_NUMBER
|
||||
const groups: Record<string, any[]> = {};
|
||||
rows.forEach(row => {
|
||||
const claimNum = row.CLAIM_NUMBER;
|
||||
if (!claimNum) return;
|
||||
if (!groups[claimNum]) groups[claimNum] = [];
|
||||
groups[claimNum].push(row);
|
||||
});
|
||||
|
||||
// Process each group
|
||||
let allProcessed = true;
|
||||
for (const [claimNumber, rows] of Object.entries(groups)) {
|
||||
const success = await this.processClaimGroup(claimNumber, rows, filePath);
|
||||
if (!success) {
|
||||
allProcessed = false;
|
||||
logger.warn(`[CreditNoteSyncService] Failed to process claim group ${claimNumber} in file ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (allProcessed && rows.length > 0) {
|
||||
fs.unlinkSync(filePath);
|
||||
logger.info(`[CreditNoteSyncService] Successfully processed and deleted file: ${filePath}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.error(`[CreditNoteSyncService] Error processing file ${filePath}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async processClaimGroup(claimNumber: string, rows: any[], filePath: string): Promise<boolean> {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
// 1. Find the request by requestNumber (which is the CLAIM_NUMBER in CSV)
|
||||
const request = await WorkflowRequest.findOne({ where: { requestNumber: claimNumber }, transaction: t });
|
||||
if (!request) {
|
||||
logger.warn(`[CreditNoteSyncService] WorkflowRequest not found for claim number: ${claimNumber}`);
|
||||
await t.rollback();
|
||||
// We return true here because we might still want to delete the file if other claims are processed
|
||||
// or if this is a filtered/old claim we don't care about.
|
||||
return true;
|
||||
}
|
||||
|
||||
const requestId = request.requestId;
|
||||
|
||||
// 2. Calculate totals
|
||||
let totalAmount = 0;
|
||||
let totalTds = 0;
|
||||
let totalCredit = 0;
|
||||
rows.forEach(row => {
|
||||
totalAmount += Number(row.CREDITED_TOTAL_AMT || row.CLAIM_AMT || row.CREDIT_AMT || 0);
|
||||
totalTds += Number(row.TDS_AMT || 0);
|
||||
totalCredit += Number(row.CREDITED_TOTAL_AMT || row.CREDIT_AMT || row.FINAL_AMT || 0);
|
||||
});
|
||||
|
||||
const firstRow = rows[0];
|
||||
|
||||
// 3. Upsert Header
|
||||
const [cnHeader] = await ClaimCreditNote.upsert({
|
||||
requestId,
|
||||
creditNoteNumber: firstRow.DOC_NO || firstRow.CREDIT_NOTE_NUMBER || undefined,
|
||||
sapDocumentNumber: firstRow.DOC_NO || firstRow.CREDIT_NOTE_NUMBER || undefined,
|
||||
status: firstRow.MSG_TYP || 'CONFIRMED',
|
||||
errorMessage: firstRow.MESSAGE || undefined,
|
||||
creditNoteFilePath: filePath,
|
||||
creditNoteAmount: totalAmount,
|
||||
transactionNo: firstRow.TRNS_UNIQ_NO || undefined,
|
||||
tdsAmount: totalTds,
|
||||
creditAmount: totalCredit,
|
||||
confirmedAt: new Date()
|
||||
}, { transaction: t, returning: true });
|
||||
|
||||
// 4. Update Line Items
|
||||
// Clear existing items
|
||||
await ClaimCreditNoteItem.destroy({ where: { creditNoteId: cnHeader.creditNoteId }, transaction: t });
|
||||
|
||||
// Bulk create new items
|
||||
const itemsToCreate = rows.map((row, index) => ({
|
||||
creditNoteId: cnHeader.creditNoteId,
|
||||
slNo: index + 1,
|
||||
transactionNo: row.TRNS_UNIQ_NO,
|
||||
description: row.DESCRIPTION || row.MESSAGE || '',
|
||||
hsnCd: row.HSN_CODE || row.SAC_CODE || '',
|
||||
amount: Number(row.CREDITED_TOTAL_AMT || row.FINAL_AMT || row.CREDIT_AMT || 0),
|
||||
claimAmount: Number(row.CREDITED_TOTAL_AMT || row.CLAIM_AMT || 0),
|
||||
tdsAmount: Number(row.TDS_AMT || 0),
|
||||
creditAmount: Number(row.CREDITED_TOTAL_AMT || row.FINAL_AMT || row.CREDIT_AMT || 0)
|
||||
}));
|
||||
|
||||
await ClaimCreditNoteItem.bulkCreate(itemsToCreate, { transaction: t });
|
||||
|
||||
await t.commit();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (t) await t.rollback();
|
||||
logger.error(`[CreditNoteSyncService] Error processing claim ${claimNumber}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const creditNoteSyncService = new CreditNoteSyncService();
|
||||
@ -33,6 +33,7 @@ export interface DealerInfo {
|
||||
gstin?: string | null;
|
||||
pincode?: string | null;
|
||||
itemGroup?: string | null;
|
||||
parentCode?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -112,6 +113,7 @@ export async function getAllDealers(searchTerm?: string, limit: number = 10): Pr
|
||||
gstin: dealer.gst || null,
|
||||
pincode: dealer.showroomPincode || null,
|
||||
itemGroup: null, // Local dealer table doesn't have item group yet
|
||||
parentCode: null,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
@ -138,9 +140,13 @@ export async function getDealerByCode(dealerCode: string): Promise<DealerInfo |
|
||||
}
|
||||
|
||||
// 2. Try to find in User table directly (Validation logic)
|
||||
// One dealer can have multiple codes but they share one parentCode.
|
||||
// If externalData has a parentcode, we check if there's a user record for that parentcode.
|
||||
const userLookupCode = externalData?.parentcode || dealerCode;
|
||||
|
||||
const user = await User.findOne({
|
||||
where: {
|
||||
employeeNumber: dealerCode,
|
||||
employeeNumber: userLookupCode,
|
||||
jobTitle: 'Dealer',
|
||||
isActive: true
|
||||
},
|
||||
@ -149,11 +155,12 @@ export async function getDealerByCode(dealerCode: string): Promise<DealerInfo |
|
||||
|
||||
if (user) {
|
||||
logger.info(`[DealerService] Dealer found in User table: ${dealerCode}`);
|
||||
logger.info(`[DealerService] Dealer mapping found via code ${userLookupCode}: ${dealerCode}`);
|
||||
return {
|
||||
dealerId: user.userId,
|
||||
userId: user.userId,
|
||||
email: user.email,
|
||||
dealerCode: user.employeeNumber || dealerCode,
|
||||
dealerCode: dealerCode, // Return the originally requested code
|
||||
dealerName: externalData?.['dealer name'] || user.displayName || '',
|
||||
displayName: user.displayName || '',
|
||||
phone: externalData?.['dealer phone'] || (user as any).mobilePhone || user.phone || undefined,
|
||||
@ -165,6 +172,7 @@ export async function getDealerByCode(dealerCode: string): Promise<DealerInfo |
|
||||
state: externalData?.['re state code'] || null,
|
||||
pincode: externalData?.pincode || null,
|
||||
itemGroup: externalData?.['item group'] || null,
|
||||
parentCode: externalData?.parentcode || null,
|
||||
};
|
||||
}
|
||||
|
||||
@ -193,6 +201,7 @@ export async function getDealerByCode(dealerCode: string): Promise<DealerInfo |
|
||||
state: externalData['re state code'],
|
||||
pincode: externalData.pincode,
|
||||
itemGroup: externalData['item group'] || null,
|
||||
parentCode: externalData.parentcode || null,
|
||||
};
|
||||
}
|
||||
logger.warn(`[DealerService] Dealer not found in any source: ${dealerCode}`);
|
||||
@ -222,6 +231,7 @@ export async function getDealerByCode(dealerCode: string): Promise<DealerInfo |
|
||||
gstin: externalData?.gstin || dealer.gst || null,
|
||||
pincode: externalData?.pincode || dealer.showroomPincode || null,
|
||||
itemGroup: externalData?.['item group'] || null,
|
||||
parentCode: externalData?.parentcode || null,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[DealerService] Error fetching dealer by code:', error);
|
||||
@ -284,6 +294,7 @@ export async function getDealerByEmail(email: string): Promise<DealerInfo | null
|
||||
gstin: dealer.gst || null,
|
||||
pincode: dealer.showroomPincode || null,
|
||||
itemGroup: null,
|
||||
parentCode: null,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[DealerService] Error fetching dealer by email:', error);
|
||||
|
||||
@ -10,6 +10,7 @@ import { InternalOrder, IOStatus } from '../models/InternalOrder';
|
||||
import { ClaimBudgetTracking, BudgetStatus } from '../models/ClaimBudgetTracking';
|
||||
import { ClaimInvoice } from '../models/ClaimInvoice';
|
||||
import { ClaimCreditNote } from '../models/ClaimCreditNote';
|
||||
import { ClaimCreditNoteItem } from '../models/ClaimCreditNoteItem';
|
||||
import { ClaimInvoiceItem } from '../models/ClaimInvoiceItem';
|
||||
import { DealerCompletionExpense } from '../models/DealerCompletionExpense';
|
||||
import { ApprovalLevel } from '../models/ApprovalLevel';
|
||||
@ -21,7 +22,7 @@ import { Document } from '../models/Document';
|
||||
import { Dealer } from '../models/Dealer';
|
||||
import { WorkflowService } from './workflow.service';
|
||||
import { DealerClaimApprovalService } from './dealerClaimApproval.service';
|
||||
import { generateRequestNumber, padDealerCode } from '../utils/helpers';
|
||||
import { buildWfmClaimCsvRow, generateRequestNumber, padDealerCode } from '../utils/helpers';
|
||||
import { Priority, WorkflowStatus, ApprovalStatus, ParticipantType } from '../types/common.types';
|
||||
import { sapIntegrationService } from './sapIntegration.service';
|
||||
import { pwcIntegrationService } from './pwcIntegration.service';
|
||||
@ -125,8 +126,13 @@ export class DealerClaimService {
|
||||
// 2. Map and validate dealer user
|
||||
const dealerCode = claimData.dealerCode;
|
||||
|
||||
logger.info(`[DealerClaimService] Validating dealer for code: ${claimData.dealerCode}`);
|
||||
const dealerUser = await validateDealerUser(claimData.dealerCode);
|
||||
logger.info(`[DealerClaimService] Resolving dealer mapping for code: ${dealerCode}`);
|
||||
// Fetch dealer info (this handles external lookup and resolves parentCode)
|
||||
const dealerInfo = await findDealerLocally(dealerCode);
|
||||
const userLookupCode = dealerInfo?.parentCode || dealerCode;
|
||||
|
||||
logger.info(`[DealerClaimService] Validating dealer user for lookup code: ${userLookupCode}`);
|
||||
const dealerUser = await validateDealerUser(userLookupCode);
|
||||
|
||||
// Validate Dealer Item Group against Activity Credit Posting
|
||||
const activityType = await ActivityType.findOne({ where: { title: claimData.activityType } });
|
||||
@ -1249,9 +1255,10 @@ export class DealerClaimService {
|
||||
where: { requestId }
|
||||
});
|
||||
|
||||
// Fetch Credit Note details
|
||||
// Fetch Credit Note details with items
|
||||
const claimCreditNote = await ClaimCreditNote.findOne({
|
||||
where: { requestId }
|
||||
where: { requestId },
|
||||
include: [{ model: ClaimCreditNoteItem, as: 'items' }]
|
||||
});
|
||||
|
||||
// Fetch Completion Expenses (individual expense items)
|
||||
@ -3625,16 +3632,9 @@ export class DealerClaimService {
|
||||
isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
|
||||
}
|
||||
|
||||
const formatDate = (date: any) => {
|
||||
const d = new Date(date);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${year}${month}${day}`;
|
||||
};
|
||||
|
||||
const csvData = invoiceItems.map((item: any) => {
|
||||
const row: any = {
|
||||
if (isNonGst) {
|
||||
return {
|
||||
TRNS_UNIQ_NO: item.transactionCode || '',
|
||||
CLAIM_NUMBER: requestNumber,
|
||||
INV_NUMBER: invoice.invoiceNumber || '',
|
||||
@ -3642,17 +3642,24 @@ export class DealerClaimService {
|
||||
IO_NUMBER: internalOrder?.ioNumber || '',
|
||||
CLAIM_DOC_TYP: sapRefNo,
|
||||
CLAIM_TYPE: claimDetails.activityType,
|
||||
CLAIM_DATE: formatDate(invoice.invoiceDate || new Date()),
|
||||
CLAIM_DATE: (() => {
|
||||
const d = new Date(invoice.invoiceDate || new Date());
|
||||
return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
|
||||
})(),
|
||||
CLAIM_AMT: item.assAmt
|
||||
};
|
||||
|
||||
if (!isNonGst) {
|
||||
const totalTax = Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0) + Number(item.utgstAmt || 0);
|
||||
row.GST_AMT = totalTax.toFixed(2);
|
||||
row.GST_PERCENTAGE = item.gstRt;
|
||||
}
|
||||
|
||||
return row;
|
||||
return buildWfmClaimCsvRow({
|
||||
item,
|
||||
requestNumber,
|
||||
invoiceNumber: invoice.invoiceNumber || '',
|
||||
invoiceDate: (invoice.invoiceDate as Date) || new Date(),
|
||||
dealerCode: claimDetails.dealerCode,
|
||||
ioNumber: internalOrder?.ioNumber || '',
|
||||
claimDocTyp: sapRefNo,
|
||||
claimType: claimDetails.activityType || '',
|
||||
});
|
||||
});
|
||||
|
||||
await wfmFileService.generateIncomingClaimCSV(csvData, `CN_${padDealerCode(claimDetails.dealerCode)}_${requestNumber}.csv`, isNonGst);
|
||||
|
||||
@ -16,6 +16,7 @@ export interface ExternalDealerResponse {
|
||||
pincode: string | null;
|
||||
'dealer email': string | null;
|
||||
'dealer phone': string | null;
|
||||
parentcode?: string | null;
|
||||
}
|
||||
|
||||
export class DealerExternalService {
|
||||
@ -76,7 +77,8 @@ export class DealerExternalService {
|
||||
'store address': data['store address'] || null,
|
||||
pincode: data.pincode || null,
|
||||
'dealer email': data['dealer email'] || null,
|
||||
'dealer phone': data['dealer phone'] || null
|
||||
'dealer phone': data['dealer phone'] || null,
|
||||
parentcode: data.parentcode || null
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
|
||||
158
src/services/hsnSacCode.service.ts
Normal file
158
src/services/hsnSacCode.service.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import { Op } from 'sequelize';
|
||||
import { HsnSacCode, HsnSacCodeAttributes, HsnSacCodeCreationAttributes } from '../models/HsnSacCode';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
export interface PaginatedHsnSacCodes {
|
||||
codes: HsnSacCode[];
|
||||
pagination: {
|
||||
totalRecords: number;
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class HsnSacCodeService {
|
||||
/**
|
||||
* Get HSN/SAC codes with pagination and search
|
||||
*/
|
||||
async getAllCodes(
|
||||
onlyActive: boolean = false,
|
||||
page: number = 1,
|
||||
limit: number = 10,
|
||||
search?: string
|
||||
): Promise<PaginatedHsnSacCodes> {
|
||||
try {
|
||||
const where: any = {};
|
||||
if (onlyActive) {
|
||||
where.isActive = true;
|
||||
}
|
||||
|
||||
if (search && search.trim()) {
|
||||
const searchTerm = `%${search.trim()}%`;
|
||||
where[Op.or] = [
|
||||
{ code: { [Op.like]: searchTerm } },
|
||||
{ description: { [Op.like]: searchTerm } }
|
||||
];
|
||||
}
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { count, rows } = await HsnSacCode.findAndCountAll({
|
||||
where,
|
||||
order: [['type', 'ASC'], ['code', 'ASC']],
|
||||
limit,
|
||||
offset
|
||||
});
|
||||
|
||||
return {
|
||||
codes: rows,
|
||||
pagination: {
|
||||
totalRecords: count,
|
||||
totalPages: Math.ceil(count / limit),
|
||||
currentPage: page,
|
||||
limit
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error fetching HSN/SAC codes:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single code by ID
|
||||
*/
|
||||
async getCodeById(id: string): Promise<HsnSacCode | null> {
|
||||
try {
|
||||
return await HsnSacCode.findByPk(id);
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching HSN/SAC code with id ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new HSN/SAC code with duplicate check
|
||||
*/
|
||||
async createCode(data: HsnSacCodeCreationAttributes): Promise<HsnSacCode> {
|
||||
try {
|
||||
// Check for duplicates
|
||||
const existing = await HsnSacCode.findOne({
|
||||
where: {
|
||||
code: data.code,
|
||||
type: data.type
|
||||
}
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error(`${data.type} code "${data.code}" already exists`);
|
||||
}
|
||||
|
||||
return await HsnSacCode.create(data);
|
||||
} catch (error) {
|
||||
logger.error('Error creating HSN/SAC code:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing HSN/SAC code with duplicate check
|
||||
*/
|
||||
async updateCode(id: string, data: Partial<HsnSacCodeAttributes>): Promise<HsnSacCode | null> {
|
||||
try {
|
||||
const code = await HsnSacCode.findByPk(id);
|
||||
if (!code) return null;
|
||||
|
||||
// If code or type is being updated, check for duplicates
|
||||
if (data.code || data.type) {
|
||||
const existing = await HsnSacCode.findOne({
|
||||
where: {
|
||||
code: data.code || code.code,
|
||||
type: data.type || code.type,
|
||||
id: { [Op.ne]: id }
|
||||
}
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error(`${data.type || code.type} code "${data.code || code.code}" already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
return await code.update(data);
|
||||
} catch (error) {
|
||||
logger.error(`Error updating HSN/SAC code with id ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an HSN/SAC code
|
||||
*/
|
||||
async deleteCode(id: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await HsnSacCode.destroy({ where: { id } });
|
||||
return result > 0;
|
||||
} catch (error) {
|
||||
logger.error(`Error deleting HSN/SAC code with id ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle active status
|
||||
*/
|
||||
async toggleActive(id: string): Promise<HsnSacCode | null> {
|
||||
try {
|
||||
const code = await HsnSacCode.findByPk(id);
|
||||
if (!code) return null;
|
||||
|
||||
return await code.update({ isActive: !code.isActive });
|
||||
} catch (error) {
|
||||
logger.error(`Error toggling active status for HSN/SAC code with id ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const hsnSacCodeService = new HsnSacCodeService();
|
||||
@ -168,21 +168,79 @@ export class WFMFileService {
|
||||
return path.join(this.basePath, targetPath, fileName);
|
||||
}
|
||||
|
||||
private getOutgoingClaimsDir(isNonGst: boolean = false): string {
|
||||
const targetPath = isNonGst ? this.outgoingNonGstClaimsPath : this.outgoingGstClaimsPath;
|
||||
return path.join(this.basePath, targetPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all outgoing CSV files in the folder.
|
||||
*/
|
||||
listOutgoingFiles(isNonGst: boolean = false): string[] {
|
||||
const outgoingDir = this.getOutgoingClaimsDir(isNonGst);
|
||||
if (!fs.existsSync(outgoingDir)) return [];
|
||||
|
||||
return fs.readdirSync(outgoingDir)
|
||||
.filter(file => file.toLowerCase().endsWith('.csv'))
|
||||
.map(file => path.join(outgoingDir, file));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build outgoing credit note file path for a dealer + request.
|
||||
*/
|
||||
getCreditNoteOutgoingFilePath(dealerCode: string, requestNumber: string, isNonGst: boolean = false): { fileName: string; filePath: string } {
|
||||
const dealer = String(dealerCode || '').trim();
|
||||
const paddedDealer = dealer.padStart(6, '0');
|
||||
const exactCandidates = [
|
||||
`CN_${paddedDealer}_${requestNumber}.csv`,
|
||||
`CN_${dealer}_${requestNumber}.csv`,
|
||||
];
|
||||
|
||||
for (const candidate of exactCandidates) {
|
||||
const candidatePath = this.getOutgoingPath(candidate, isNonGst);
|
||||
if (fs.existsSync(candidatePath)) {
|
||||
return { fileName: candidate, filePath: candidatePath };
|
||||
}
|
||||
}
|
||||
|
||||
// Last fallback: pick any CN_*_<requestNumber>.csv from target outgoing folder.
|
||||
const outgoingDir = this.getOutgoingClaimsDir(isNonGst);
|
||||
if (fs.existsSync(outgoingDir)) {
|
||||
const escapedReq = requestNumber.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const reqFileRegex = new RegExp(`^CN_.*_${escapedReq}\\.csv$`, 'i');
|
||||
const matched = fs.readdirSync(outgoingDir).find((name) => reqFileRegex.test(name));
|
||||
if (matched) {
|
||||
return { fileName: matched, filePath: path.join(outgoingDir, matched) };
|
||||
}
|
||||
}
|
||||
|
||||
// Keep deterministic default for callers when no file exists yet.
|
||||
const fileName = exactCandidates[0];
|
||||
return { fileName, filePath: this.getOutgoingPath(fileName, isNonGst) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get credit note details from outgoing CSV
|
||||
*/
|
||||
async getCreditNoteDetails(dealerCode: string, requestNumber: string, isNonGst: boolean = false): Promise<any[]> {
|
||||
const fileName = `CN_${String(dealerCode).padStart(6, '0')}_${requestNumber}.csv`;
|
||||
const filePath = this.getOutgoingPath(fileName, isNonGst);
|
||||
const { data } = await this.getCreditNoteDetailsWithPath(dealerCode, requestNumber, isNonGst);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get credit note details and resolved file path from outgoing CSV.
|
||||
*/
|
||||
async getCreditNoteDetailsWithPath(dealerCode: string, requestNumber: string, isNonGst: boolean = false): Promise<{ fileName: string; filePath: string; data: any[] }> {
|
||||
const { fileName, filePath } = this.getCreditNoteOutgoingFilePath(dealerCode, requestNumber, isNonGst);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return [];
|
||||
return { fileName, filePath, data: [] };
|
||||
}
|
||||
|
||||
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = fileContent.split('\n').filter(line => line.trim() !== '');
|
||||
if (lines.length <= 1) return []; // Only headers or empty
|
||||
if (lines.length <= 1) return { fileName, filePath, data: [] }; // Only headers or empty
|
||||
|
||||
const headers = lines[0].split('|');
|
||||
const data = lines.slice(1).map(line => {
|
||||
@ -194,10 +252,25 @@ export class WFMFileService {
|
||||
return row;
|
||||
});
|
||||
|
||||
return data;
|
||||
return { fileName, filePath, data };
|
||||
} catch (error) {
|
||||
logger.error(`[WFMFileService] Error reading credit note CSV: ${fileName}`, error);
|
||||
return [];
|
||||
return { fileName, filePath, data: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an outgoing credit note file once it has been persisted.
|
||||
*/
|
||||
deleteCreditNoteOutgoingFileByPath(filePath: string): void {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
logger.info(`[WFMFileService] Deleted processed credit note CSV: ${filePath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[WFMFileService] Error deleting processed credit note CSV:', filePath, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -39,6 +39,12 @@ export interface ApiResponse<T = any> {
|
||||
message: string;
|
||||
data?: T;
|
||||
error?: string;
|
||||
pagination?: {
|
||||
totalRecords: number;
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
limit: number;
|
||||
};
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
|
||||
@ -115,3 +115,93 @@ export const padDealerCode = (dealerCode: string | number): string => {
|
||||
if (dealerCode === null || dealerCode === undefined) return '';
|
||||
return String(dealerCode).padStart(6, '0');
|
||||
};
|
||||
|
||||
/**
|
||||
* WFM incoming claim CSV headers in required fixed order.
|
||||
*/
|
||||
export const WFM_CLAIM_CSV_HEADERS = [
|
||||
'TRNS_UNIQ_NO',
|
||||
'CLAIM_NUMBER',
|
||||
'INV_NUMBER',
|
||||
'INV_DATE',
|
||||
'DEALER_CODE',
|
||||
'IO_NUMBER',
|
||||
'CLAIM_DOC_TYP',
|
||||
'CLAIM_TYPE',
|
||||
'CLAIM_DATE',
|
||||
'HSN_CODE',
|
||||
'SAC_CODE',
|
||||
'PART_AMT',
|
||||
'LABOUR_AMT',
|
||||
'GST_AMT',
|
||||
'GST_PERCENTAGE',
|
||||
] as const;
|
||||
|
||||
type WfmInvoiceItemLike = {
|
||||
transactionCode?: string;
|
||||
hsnCd?: string;
|
||||
assAmt?: number | string;
|
||||
gstRt?: number | string;
|
||||
igstAmt?: number | string;
|
||||
cgstAmt?: number | string;
|
||||
sgstAmt?: number | string;
|
||||
utgstAmt?: number | string;
|
||||
isServc?: string;
|
||||
};
|
||||
|
||||
type BuildWfmRowInput = {
|
||||
item: WfmInvoiceItemLike;
|
||||
requestNumber: string;
|
||||
invoiceNumber: string;
|
||||
invoiceDate: Date;
|
||||
dealerCode: string;
|
||||
ioNumber: string;
|
||||
claimDocTyp: string;
|
||||
claimType: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build one WFM incoming claim CSV row.
|
||||
* Business rule:
|
||||
* - Service line -> SAC_CODE + LABOUR_AMT, keep HSN_CODE + PART_AMT empty
|
||||
* - Material line -> HSN_CODE + PART_AMT, keep SAC_CODE + LABOUR_AMT empty
|
||||
*/
|
||||
export const buildWfmClaimCsvRow = ({
|
||||
item,
|
||||
requestNumber,
|
||||
invoiceNumber,
|
||||
invoiceDate,
|
||||
dealerCode,
|
||||
ioNumber,
|
||||
claimDocTyp,
|
||||
claimType,
|
||||
}: BuildWfmRowInput): Record<(typeof WFM_CLAIM_CSV_HEADERS)[number], string | number> => {
|
||||
const d = new Date(invoiceDate);
|
||||
const invDate = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
|
||||
const normalizedIsService = String(item.isServc || '').toUpperCase() === 'Y';
|
||||
const hsnOrSac = item.hsnCd || '';
|
||||
const assesableAmount = Number(item.assAmt || 0);
|
||||
const totalTax =
|
||||
Number(item.igstAmt || 0) +
|
||||
Number(item.cgstAmt || 0) +
|
||||
Number(item.sgstAmt || 0) +
|
||||
Number(item.utgstAmt || 0);
|
||||
|
||||
return {
|
||||
TRNS_UNIQ_NO: item.transactionCode || '',
|
||||
CLAIM_NUMBER: requestNumber || '',
|
||||
INV_NUMBER: invoiceNumber || '',
|
||||
INV_DATE: invDate,
|
||||
DEALER_CODE: padDealerCode(dealerCode || ''),
|
||||
IO_NUMBER: ioNumber || '',
|
||||
CLAIM_DOC_TYP: claimDocTyp || '',
|
||||
CLAIM_TYPE: claimType || '',
|
||||
CLAIM_DATE: invDate,
|
||||
HSN_CODE: normalizedIsService ? '' : hsnOrSac,
|
||||
SAC_CODE: normalizedIsService ? hsnOrSac : '',
|
||||
PART_AMT: normalizedIsService ? '' : assesableAmount.toFixed(2),
|
||||
LABOUR_AMT: normalizedIsService ? assesableAmount.toFixed(2) : '',
|
||||
GST_AMT: totalTax.toFixed(2),
|
||||
GST_PERCENTAGE: item.gstRt ?? '',
|
||||
};
|
||||
};
|
||||
|
||||
@ -7,16 +7,22 @@ export class ResponseHandler {
|
||||
res: Response,
|
||||
data: T,
|
||||
message: string = 'Success',
|
||||
statusCode: number = 200
|
||||
statusCode: number = 200,
|
||||
pagination?: {
|
||||
totalRecords: number;
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
limit: number;
|
||||
}
|
||||
): void {
|
||||
const response: ApiResponse<T> = {
|
||||
success: true,
|
||||
message,
|
||||
data,
|
||||
pagination,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
|
||||
res.status(statusCode).json(response);
|
||||
}
|
||||
|
||||
|
||||
@ -58,8 +58,8 @@ export const createActivityTypeSchema = z.object({
|
||||
errorMap: () => ({ message: 'Taxation type must be GST or Non GST' }),
|
||||
}),
|
||||
sapRefNo: z.string().min(1, 'SAP ref number (Claim Document Type) is required').max(50, 'SAP ref number too long'),
|
||||
creditPostingOn: z.enum(['Spares', 'Vehicle', 'GMA', 'Apparel'], {
|
||||
errorMap: () => ({ message: 'Credit posting on must be Spares, Vehicle, GMA or Apparel' }),
|
||||
creditPostingOn: z.enum(['Spares', 'Vehicle', 'Vehicles', 'GMA', 'Apparel'], {
|
||||
errorMap: () => ({ message: 'Credit posting on must be Spares, Vehicle, Vehicles, GMA or Apparel' }),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user