dealer claim multi iteration implementaion started
This commit is contained in:
parent
f89514eb2b
commit
e3bda6df15
@ -250,6 +250,7 @@ export class DealerClaimController {
|
|||||||
numberOfParticipants,
|
numberOfParticipants,
|
||||||
closedExpenses,
|
closedExpenses,
|
||||||
totalClosedExpenses,
|
totalClosedExpenses,
|
||||||
|
completionDescription,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Parse closedExpenses if it's a JSON string
|
// Parse closedExpenses if it's a JSON string
|
||||||
@ -540,6 +541,7 @@ export class DealerClaimController {
|
|||||||
totalClosedExpenses: totalClosedExpenses ? parseFloat(totalClosedExpenses) : 0,
|
totalClosedExpenses: totalClosedExpenses ? parseFloat(totalClosedExpenses) : 0,
|
||||||
invoicesReceipts: invoicesReceipts.length > 0 ? invoicesReceipts : undefined,
|
invoicesReceipts: invoicesReceipts.length > 0 ? invoicesReceipts : undefined,
|
||||||
attendanceSheet: attendanceSheet || undefined,
|
attendanceSheet: attendanceSheet || undefined,
|
||||||
|
completionDescription: completionDescription || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return ResponseHandler.success(res, { message: 'Completion documents submitted successfully' }, 'Completion submitted');
|
return ResponseHandler.success(res, { message: 'Completion documents submitted successfully' }, 'Completion submitted');
|
||||||
|
|||||||
@ -13,9 +13,11 @@ import path from 'path';
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { getRequestMetadata } from '@utils/requestUtils';
|
import { getRequestMetadata } from '@utils/requestUtils';
|
||||||
import { enrichApprovalLevels, enrichSpectators, validateInitiator } from '@services/userEnrichment.service';
|
import { enrichApprovalLevels, enrichSpectators, validateInitiator } from '@services/userEnrichment.service';
|
||||||
|
import { DealerClaimService } from '@services/dealerClaim.service';
|
||||||
import logger from '@utils/logger';
|
import logger from '@utils/logger';
|
||||||
|
|
||||||
const workflowService = new WorkflowService();
|
const workflowService = new WorkflowService();
|
||||||
|
const dealerClaimService = new DealerClaimService();
|
||||||
|
|
||||||
export class WorkflowController {
|
export class WorkflowController {
|
||||||
async createWorkflow(req: AuthenticatedRequest, res: Response): Promise<void> {
|
async createWorkflow(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
@ -886,4 +888,54 @@ export class WorkflowController {
|
|||||||
ResponseHandler.error(res, 'Failed to submit workflow', 400, errorMessage);
|
ResponseHandler.error(res, 'Failed to submit workflow', 400, errorMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleInitiatorAction(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { action, ...data } = req.body;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
ResponseHandler.unauthorized(res, 'User ID missing from request');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await dealerClaimService.handleInitiatorAction(id, userId, action as any, data);
|
||||||
|
ResponseHandler.success(res, null, `Action ${action} performed successfully`);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('[WorkflowController] handleInitiatorAction failed', {
|
||||||
|
error: errorMessage,
|
||||||
|
requestId: req.params.id,
|
||||||
|
userId: req.user?.userId,
|
||||||
|
action: req.body.action
|
||||||
|
});
|
||||||
|
ResponseHandler.error(res, 'Failed to perform initiator action', 400, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHistory(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Resolve requestId UUID from identifier (could be requestNumber or UUID)
|
||||||
|
const workflowService = new WorkflowService();
|
||||||
|
const wf = await (workflowService as any).findWorkflowByIdentifier(id);
|
||||||
|
if (!wf) {
|
||||||
|
ResponseHandler.notFound(res, 'Workflow not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestId = wf.getDataValue('requestId');
|
||||||
|
|
||||||
|
const history = await dealerClaimService.getHistory(requestId);
|
||||||
|
ResponseHandler.success(res, history, 'Revision history fetched successfully');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('[WorkflowController] getHistory failed', {
|
||||||
|
error: errorMessage,
|
||||||
|
requestId: req.params.id
|
||||||
|
});
|
||||||
|
ResponseHandler.error(res, 'Failed to fetch revision history', 400, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
136
src/migrations/20260113-redesign-dealer-claim-history.ts
Normal file
136
src/migrations/20260113-redesign-dealer-claim-history.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import { QueryInterface, DataTypes } from 'sequelize';
|
||||||
|
|
||||||
|
export const up = async (queryInterface: QueryInterface) => {
|
||||||
|
// 1. Drop the old dealer_claim_history table if it exists
|
||||||
|
const tables = await queryInterface.showAllTables();
|
||||||
|
if (tables.includes('dealer_claim_history')) {
|
||||||
|
await queryInterface.dropTable('dealer_claim_history');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create or update the enum type for snapshot_type
|
||||||
|
// Check if enum exists, if not create it, if yes update it
|
||||||
|
try {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'enum_dealer_claim_history_snapshot_type') THEN
|
||||||
|
CREATE TYPE enum_dealer_claim_history_snapshot_type AS ENUM ('PROPOSAL', 'COMPLETION', 'INTERNAL_ORDER', 'WORKFLOW', 'APPROVE');
|
||||||
|
ELSE
|
||||||
|
-- Check if APPROVE exists in the enum
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_enum
|
||||||
|
WHERE enumlabel = 'APPROVE'
|
||||||
|
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'enum_dealer_claim_history_snapshot_type')
|
||||||
|
) THEN
|
||||||
|
ALTER TYPE enum_dealer_claim_history_snapshot_type ADD VALUE 'APPROVE';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
} catch (error) {
|
||||||
|
// If enum creation fails, try to continue (might already exist)
|
||||||
|
console.warn('Enum creation/update warning:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create new simplified level-based dealer_claim_history table
|
||||||
|
await queryInterface.createTable('dealer_claim_history', {
|
||||||
|
history_id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
request_id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'workflow_requests',
|
||||||
|
key: 'request_id'
|
||||||
|
},
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
},
|
||||||
|
approval_level_id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true, // Nullable for workflow-level snapshots
|
||||||
|
references: {
|
||||||
|
model: 'approval_levels',
|
||||||
|
key: 'level_id'
|
||||||
|
},
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
onDelete: 'SET NULL'
|
||||||
|
},
|
||||||
|
level_number: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true, // Nullable for workflow-level snapshots
|
||||||
|
comment: 'Level number for easier querying (e.g., 1=Dealer, 3=Dept Lead, 4/5=Completion)'
|
||||||
|
},
|
||||||
|
level_name: {
|
||||||
|
type: DataTypes.STRING(255),
|
||||||
|
allowNull: true, // Nullable for workflow-level snapshots
|
||||||
|
comment: 'Level name for consistent matching (e.g., "Dealer Proposal Submission", "Department Lead Approval")'
|
||||||
|
},
|
||||||
|
version: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
comment: 'Version number for this specific level (starts at 1 per level)'
|
||||||
|
},
|
||||||
|
snapshot_type: {
|
||||||
|
type: DataTypes.ENUM('PROPOSAL', 'COMPLETION', 'INTERNAL_ORDER', 'WORKFLOW', 'APPROVE'),
|
||||||
|
allowNull: false,
|
||||||
|
comment: 'Type of snapshot: PROPOSAL (Step 1), COMPLETION (Step 4/5), INTERNAL_ORDER (Step 3), WORKFLOW (general), APPROVE (approver actions with comments)'
|
||||||
|
},
|
||||||
|
snapshot_data: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: false,
|
||||||
|
comment: 'JSON object containing all snapshot data specific to this level and type. Structure varies by snapshot_type.'
|
||||||
|
},
|
||||||
|
change_reason: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'Reason for this version change (e.g., "Revision Requested: ...")'
|
||||||
|
},
|
||||||
|
changed_by: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'user_id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add indexes for efficient querying
|
||||||
|
await queryInterface.addIndex('dealer_claim_history', ['request_id', 'level_number', 'version'], {
|
||||||
|
name: 'idx_history_request_level_version'
|
||||||
|
});
|
||||||
|
await queryInterface.addIndex('dealer_claim_history', ['approval_level_id', 'version'], {
|
||||||
|
name: 'idx_history_level_version'
|
||||||
|
});
|
||||||
|
await queryInterface.addIndex('dealer_claim_history', ['request_id', 'snapshot_type'], {
|
||||||
|
name: 'idx_history_request_type'
|
||||||
|
});
|
||||||
|
await queryInterface.addIndex('dealer_claim_history', ['snapshot_type', 'level_number'], {
|
||||||
|
name: 'idx_history_type_level'
|
||||||
|
});
|
||||||
|
await queryInterface.addIndex('dealer_claim_history', ['request_id', 'level_name'], {
|
||||||
|
name: 'idx_history_request_level_name'
|
||||||
|
});
|
||||||
|
await queryInterface.addIndex('dealer_claim_history', ['level_name', 'snapshot_type'], {
|
||||||
|
name: 'idx_history_level_name_type'
|
||||||
|
});
|
||||||
|
// Index for JSONB queries on snapshot_data
|
||||||
|
await queryInterface.addIndex('dealer_claim_history', ['snapshot_type'], {
|
||||||
|
name: 'idx_history_snapshot_type',
|
||||||
|
using: 'BTREE'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const down = async (queryInterface: QueryInterface) => {
|
||||||
|
// Drop the new table
|
||||||
|
await queryInterface.dropTable('dealer_claim_history');
|
||||||
|
};
|
||||||
190
src/models/DealerClaimHistory.ts
Normal file
190
src/models/DealerClaimHistory.ts
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
import { DataTypes, Model, Optional } from 'sequelize';
|
||||||
|
import { sequelize } from '@config/database';
|
||||||
|
import { WorkflowRequest } from './WorkflowRequest';
|
||||||
|
import { ApprovalLevel } from './ApprovalLevel';
|
||||||
|
import { User } from './User';
|
||||||
|
|
||||||
|
export enum SnapshotType {
|
||||||
|
PROPOSAL = 'PROPOSAL',
|
||||||
|
COMPLETION = 'COMPLETION',
|
||||||
|
INTERNAL_ORDER = 'INTERNAL_ORDER',
|
||||||
|
WORKFLOW = 'WORKFLOW',
|
||||||
|
APPROVE = 'APPROVE'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type definitions for snapshot data structures
|
||||||
|
export interface ProposalSnapshotData {
|
||||||
|
documentUrl?: string;
|
||||||
|
totalBudget?: number;
|
||||||
|
comments?: string;
|
||||||
|
expectedCompletionDate?: string;
|
||||||
|
costItems?: Array<{
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
order: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompletionSnapshotData {
|
||||||
|
documentUrl?: string;
|
||||||
|
totalExpenses?: number;
|
||||||
|
comments?: string;
|
||||||
|
expenses?: Array<{
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOSnapshotData {
|
||||||
|
ioNumber?: string;
|
||||||
|
blockedAmount?: number;
|
||||||
|
availableBalance?: number;
|
||||||
|
remainingBalance?: number;
|
||||||
|
sapDocumentNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowSnapshotData {
|
||||||
|
status?: string;
|
||||||
|
currentLevel?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalSnapshotData {
|
||||||
|
action: 'APPROVE' | 'REJECT';
|
||||||
|
comments?: string;
|
||||||
|
rejectionReason?: string;
|
||||||
|
approverName?: string;
|
||||||
|
approverEmail?: string;
|
||||||
|
levelName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DealerClaimHistoryAttributes {
|
||||||
|
historyId: string;
|
||||||
|
requestId: string;
|
||||||
|
approvalLevelId?: string;
|
||||||
|
levelNumber?: number;
|
||||||
|
levelName?: string;
|
||||||
|
version: number;
|
||||||
|
snapshotType: SnapshotType;
|
||||||
|
snapshotData: ProposalSnapshotData | CompletionSnapshotData | IOSnapshotData | WorkflowSnapshotData | ApprovalSnapshotData | any;
|
||||||
|
changeReason?: string;
|
||||||
|
changedBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DealerClaimHistoryCreationAttributes extends Optional<DealerClaimHistoryAttributes, 'historyId' | 'approvalLevelId' | 'levelNumber' | 'levelName' | 'changeReason' | 'createdAt'> { }
|
||||||
|
|
||||||
|
class DealerClaimHistory extends Model<DealerClaimHistoryAttributes, DealerClaimHistoryCreationAttributes> implements DealerClaimHistoryAttributes {
|
||||||
|
public historyId!: string;
|
||||||
|
public requestId!: string;
|
||||||
|
public approvalLevelId?: string;
|
||||||
|
public levelNumber?: number;
|
||||||
|
public version!: number;
|
||||||
|
public snapshotType!: SnapshotType;
|
||||||
|
public snapshotData!: ProposalSnapshotData | CompletionSnapshotData | IOSnapshotData | WorkflowSnapshotData | any;
|
||||||
|
public changeReason?: string;
|
||||||
|
public changedBy!: string;
|
||||||
|
public createdAt!: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
DealerClaimHistory.init(
|
||||||
|
{
|
||||||
|
historyId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
field: 'history_id'
|
||||||
|
},
|
||||||
|
requestId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'request_id',
|
||||||
|
references: {
|
||||||
|
model: 'workflow_requests',
|
||||||
|
key: 'request_id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
approvalLevelId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'approval_level_id',
|
||||||
|
references: {
|
||||||
|
model: 'approval_levels',
|
||||||
|
key: 'level_id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
levelNumber: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'level_number'
|
||||||
|
},
|
||||||
|
levelName: {
|
||||||
|
type: DataTypes.STRING(255),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'level_name'
|
||||||
|
},
|
||||||
|
version: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
snapshotType: {
|
||||||
|
type: DataTypes.ENUM('PROPOSAL', 'COMPLETION', 'INTERNAL_ORDER', 'WORKFLOW', 'APPROVE'),
|
||||||
|
allowNull: false,
|
||||||
|
field: 'snapshot_type'
|
||||||
|
},
|
||||||
|
snapshotData: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'snapshot_data'
|
||||||
|
},
|
||||||
|
changeReason: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'change_reason'
|
||||||
|
},
|
||||||
|
changedBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'changed_by',
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'user_id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW,
|
||||||
|
field: 'created_at'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: 'DealerClaimHistory',
|
||||||
|
tableName: 'dealer_claim_history',
|
||||||
|
timestamps: false,
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['request_id', 'level_number', 'version'],
|
||||||
|
name: 'idx_history_request_level_version'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['approval_level_id', 'version'],
|
||||||
|
name: 'idx_history_level_version'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['request_id', 'snapshot_type'],
|
||||||
|
name: 'idx_history_request_type'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['snapshot_type', 'level_number'],
|
||||||
|
name: 'idx_history_type_level'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
DealerClaimHistory.belongsTo(WorkflowRequest, { foreignKey: 'requestId' });
|
||||||
|
DealerClaimHistory.belongsTo(ApprovalLevel, { foreignKey: 'approvalLevelId' });
|
||||||
|
DealerClaimHistory.belongsTo(User, { as: 'changer', foreignKey: 'changedBy' });
|
||||||
|
|
||||||
|
export { DealerClaimHistory };
|
||||||
@ -29,11 +29,12 @@ interface WorkflowRequestAttributes {
|
|||||||
pauseReason?: string;
|
pauseReason?: string;
|
||||||
pauseResumeDate?: Date;
|
pauseResumeDate?: Date;
|
||||||
pauseTatSnapshot?: any;
|
pauseTatSnapshot?: any;
|
||||||
|
version: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkflowRequestCreationAttributes extends Optional<WorkflowRequestAttributes, 'requestId' | 'submissionDate' | 'closureDate' | 'conclusionRemark' | 'aiGeneratedConclusion' | 'isPaused' | 'pausedAt' | 'pausedBy' | 'pauseReason' | 'pauseResumeDate' | 'pauseTatSnapshot' | 'createdAt' | 'updatedAt'> {}
|
interface WorkflowRequestCreationAttributes extends Optional<WorkflowRequestAttributes, 'requestId' | 'submissionDate' | 'closureDate' | 'conclusionRemark' | 'aiGeneratedConclusion' | 'isPaused' | 'pausedAt' | 'pausedBy' | 'pauseReason' | 'pauseResumeDate' | 'pauseTatSnapshot' | 'version' | 'createdAt' | 'updatedAt'> { }
|
||||||
|
|
||||||
class WorkflowRequest extends Model<WorkflowRequestAttributes, WorkflowRequestCreationAttributes> implements WorkflowRequestAttributes {
|
class WorkflowRequest extends Model<WorkflowRequestAttributes, WorkflowRequestCreationAttributes> implements WorkflowRequestAttributes {
|
||||||
public requestId!: string;
|
public requestId!: string;
|
||||||
@ -61,6 +62,7 @@ class WorkflowRequest extends Model<WorkflowRequestAttributes, WorkflowRequestCr
|
|||||||
public pauseReason?: string;
|
public pauseReason?: string;
|
||||||
public pauseResumeDate?: Date;
|
public pauseResumeDate?: Date;
|
||||||
public pauseTatSnapshot?: any;
|
public pauseTatSnapshot?: any;
|
||||||
|
public version!: number;
|
||||||
public createdAt!: Date;
|
public createdAt!: Date;
|
||||||
public updatedAt!: Date;
|
public updatedAt!: Date;
|
||||||
|
|
||||||
@ -211,6 +213,11 @@ WorkflowRequest.init(
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
field: 'pause_tat_snapshot'
|
field: 'pause_tat_snapshot'
|
||||||
},
|
},
|
||||||
|
version: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 1,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
createdAt: {
|
createdAt: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import { InternalOrder } from './InternalOrder';
|
|||||||
import { ClaimBudgetTracking } from './ClaimBudgetTracking';
|
import { ClaimBudgetTracking } from './ClaimBudgetTracking';
|
||||||
import { Dealer } from './Dealer';
|
import { Dealer } from './Dealer';
|
||||||
import { ActivityType } from './ActivityType';
|
import { ActivityType } from './ActivityType';
|
||||||
|
import { DealerClaimHistory } from './DealerClaimHistory';
|
||||||
|
|
||||||
// Define associations
|
// Define associations
|
||||||
const defineAssociations = () => {
|
const defineAssociations = () => {
|
||||||
@ -137,6 +138,13 @@ const defineAssociations = () => {
|
|||||||
sourceKey: 'requestId'
|
sourceKey: 'requestId'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// DealerClaimHistory associations
|
||||||
|
WorkflowRequest.hasMany(DealerClaimHistory, {
|
||||||
|
as: 'history',
|
||||||
|
foreignKey: 'requestId',
|
||||||
|
sourceKey: 'requestId'
|
||||||
|
});
|
||||||
|
|
||||||
// Note: belongsTo associations are defined in individual model files to avoid duplicate alias conflicts
|
// 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
|
// Only hasMany associations from WorkflowRequest are defined here since they're one-way
|
||||||
};
|
};
|
||||||
@ -170,7 +178,8 @@ export {
|
|||||||
InternalOrder,
|
InternalOrder,
|
||||||
ClaimBudgetTracking,
|
ClaimBudgetTracking,
|
||||||
Dealer,
|
Dealer,
|
||||||
ActivityType
|
ActivityType,
|
||||||
|
DealerClaimHistory
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export default sequelize instance
|
// Export default sequelize instance
|
||||||
|
|||||||
@ -874,4 +874,19 @@ router.get('/:id/pause',
|
|||||||
asyncHandler(pauseController.getPauseDetails.bind(pauseController))
|
asyncHandler(pauseController.getPauseDetails.bind(pauseController))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Initiator actions for rejected/returned requests
|
||||||
|
router.post('/:id/initiator-action',
|
||||||
|
authenticateToken,
|
||||||
|
requireParticipantTypes(['INITIATOR']),
|
||||||
|
validateParams(workflowParamsSchema),
|
||||||
|
asyncHandler(workflowController.handleInitiatorAction.bind(workflowController))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get revision history
|
||||||
|
router.get('/:id/history',
|
||||||
|
authenticateToken,
|
||||||
|
validateParams(workflowParamsSchema),
|
||||||
|
asyncHandler(workflowController.getHistory.bind(workflowController))
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@ -135,6 +135,7 @@ async function runMigrations(): Promise<void> {
|
|||||||
const m40 = require('../migrations/20251218-fix-claim-invoice-credit-note-columns');
|
const m40 = require('../migrations/20251218-fix-claim-invoice-credit-note-columns');
|
||||||
const m41 = require('../migrations/20250120-create-dealers-table');
|
const m41 = require('../migrations/20250120-create-dealers-table');
|
||||||
const m42 = require('../migrations/20250125-create-activity-types');
|
const m42 = require('../migrations/20250125-create-activity-types');
|
||||||
|
const m43 = require('../migrations/20260113-redesign-dealer-claim-history');
|
||||||
|
|
||||||
const migrations = [
|
const migrations = [
|
||||||
{ name: '2025103000-create-users', module: m0 },
|
{ name: '2025103000-create-users', module: m0 },
|
||||||
@ -182,6 +183,7 @@ async function runMigrations(): Promise<void> {
|
|||||||
{ name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 },
|
{ name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 },
|
||||||
{ name: '20250120-create-dealers-table', module: m41 },
|
{ name: '20250120-create-dealers-table', module: m41 },
|
||||||
{ name: '20250125-create-activity-types', module: m42 },
|
{ name: '20250125-create-activity-types', module: m42 },
|
||||||
|
{ name: '20260113-redesign-dealer-claim-history', module: m43 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const queryInterface = sequelize.getQueryInterface();
|
const queryInterface = sequelize.getQueryInterface();
|
||||||
|
|||||||
@ -45,6 +45,7 @@ import * as m39 from '../migrations/20251214-create-dealer-completion-expenses';
|
|||||||
import * as m40 from '../migrations/20251218-fix-claim-invoice-credit-note-columns';
|
import * as m40 from '../migrations/20251218-fix-claim-invoice-credit-note-columns';
|
||||||
import * as m41 from '../migrations/20250120-create-dealers-table';
|
import * as m41 from '../migrations/20250120-create-dealers-table';
|
||||||
import * as m42 from '../migrations/20250125-create-activity-types';
|
import * as m42 from '../migrations/20250125-create-activity-types';
|
||||||
|
import * as m43 from '../migrations/20260113-redesign-dealer-claim-history';
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
name: string;
|
name: string;
|
||||||
@ -104,6 +105,7 @@ const migrations: Migration[] = [
|
|||||||
{ name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 },
|
{ name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 },
|
||||||
{ name: '20250120-create-dealers-table', module: m41 },
|
{ name: '20250120-create-dealers-table', module: m41 },
|
||||||
{ name: '20250125-create-activity-types', module: m42 },
|
{ name: '20250125-create-activity-types', module: m42 },
|
||||||
|
{ name: '20260113-redesign-dealer-claim-history', module: m43 },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -26,6 +26,10 @@ import { DealerClaimService } from './dealerClaim.service';
|
|||||||
import { emitToRequestRoom } from '../realtime/socket';
|
import { emitToRequestRoom } from '../realtime/socket';
|
||||||
|
|
||||||
export class DealerClaimApprovalService {
|
export class DealerClaimApprovalService {
|
||||||
|
// Use lazy initialization to avoid circular dependency
|
||||||
|
private getDealerClaimService(): DealerClaimService {
|
||||||
|
return new DealerClaimService();
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Approve a level in a dealer claim workflow
|
* Approve a level in a dealer claim workflow
|
||||||
* Handles dealer claim-specific logic including dynamic approvers and activity creation
|
* Handles dealer claim-specific logic including dynamic approvers and activity creation
|
||||||
@ -102,6 +106,34 @@ export class DealerClaimApprovalService {
|
|||||||
return await this.handleRejection(level, action, userId, requestMetadata, elapsedHours, tatPercentage, now);
|
return await this.handleRejection(level, action, userId, requestMetadata, elapsedHours, tatPercentage, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save approval history BEFORE updating level
|
||||||
|
await this.getDealerClaimService().saveApprovalHistory(
|
||||||
|
level.requestId,
|
||||||
|
level.levelId,
|
||||||
|
level.levelNumber,
|
||||||
|
'APPROVE',
|
||||||
|
action.comments || '',
|
||||||
|
undefined,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Capture workflow snapshot for approval action (before moving to next level)
|
||||||
|
// This captures the approval action itself, including initiator evaluation
|
||||||
|
const levelName = (level.levelName || '').toLowerCase();
|
||||||
|
const isInitiatorEvaluation = levelName.includes('requestor') || levelName.includes('evaluation');
|
||||||
|
const approvalMessage = isInitiatorEvaluation
|
||||||
|
? `Initiator evaluated and approved (level ${level.levelNumber})`
|
||||||
|
: `Approved level ${level.levelNumber}`;
|
||||||
|
|
||||||
|
await this.getDealerClaimService().saveWorkflowHistory(
|
||||||
|
level.requestId,
|
||||||
|
approvalMessage,
|
||||||
|
userId,
|
||||||
|
level.levelId,
|
||||||
|
level.levelNumber,
|
||||||
|
level.levelName || undefined
|
||||||
|
);
|
||||||
|
|
||||||
// Update level status and elapsed time for approval
|
// Update level status and elapsed time for approval
|
||||||
await level.update({
|
await level.update({
|
||||||
status: ApprovalStatus.APPROVED,
|
status: ApprovalStatus.APPROVED,
|
||||||
@ -242,6 +274,17 @@ export class DealerClaimApprovalService {
|
|||||||
{ currentLevel: nextLevelNumber },
|
{ currentLevel: nextLevelNumber },
|
||||||
{ where: { requestId: level.requestId } }
|
{ where: { requestId: level.requestId } }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Capture workflow snapshot when moving to next level
|
||||||
|
// Include both the approved level and the next level in the message
|
||||||
|
await this.getDealerClaimService().saveWorkflowHistory(
|
||||||
|
level.requestId,
|
||||||
|
`Level ${level.levelNumber} approved, moved to next level (${nextLevelNumber})`,
|
||||||
|
userId,
|
||||||
|
nextLevel?.levelId || undefined, // Store next level's ID since we're moving to it
|
||||||
|
nextLevelNumber || undefined,
|
||||||
|
(nextLevel as any)?.levelName || undefined
|
||||||
|
);
|
||||||
logger.info(`[DealerClaimApproval] Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`);
|
logger.info(`[DealerClaimApproval] Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -497,7 +540,7 @@ export class DealerClaimApprovalService {
|
|||||||
try {
|
try {
|
||||||
logger.info(`[DealerClaimApproval] Sending assignment notification to next approver: ${nextApproverName} (${nextApproverId}) at level ${nextLevelNumber} for request ${(wf as any).requestNumber}`);
|
logger.info(`[DealerClaimApproval] Sending assignment notification to next approver: ${nextApproverName} (${nextApproverId}) at level ${nextLevelNumber} for request ${(wf as any).requestNumber}`);
|
||||||
|
|
||||||
await notificationService.sendToUsers([ nextApproverId ], {
|
await notificationService.sendToUsers([nextApproverId], {
|
||||||
title: `Action required: ${(wf as any).requestNumber}`,
|
title: `Action required: ${(wf as any).requestNumber}`,
|
||||||
body: `${(wf as any).title}`,
|
body: `${(wf as any).title}`,
|
||||||
requestNumber: (wf as any).requestNumber,
|
requestNumber: (wf as any).requestNumber,
|
||||||
@ -549,7 +592,7 @@ export class DealerClaimApprovalService {
|
|||||||
{ where: { requestId: level.requestId } }
|
{ where: { requestId: level.requestId } }
|
||||||
);
|
);
|
||||||
if (wf) {
|
if (wf) {
|
||||||
await notificationService.sendToUsers([ (wf as any).initiatorId ], {
|
await notificationService.sendToUsers([(wf as any).initiatorId], {
|
||||||
title: `Approved: ${(wf as any).requestNumber}`,
|
title: `Approved: ${(wf as any).requestNumber}`,
|
||||||
body: `${(wf as any).title}`,
|
body: `${(wf as any).title}`,
|
||||||
requestNumber: (wf as any).requestNumber,
|
requestNumber: (wf as any).requestNumber,
|
||||||
@ -596,6 +639,125 @@ export class DealerClaimApprovalService {
|
|||||||
const wf = await WorkflowRequest.findByPk(level.requestId);
|
const wf = await WorkflowRequest.findByPk(level.requestId);
|
||||||
if (!wf) return null;
|
if (!wf) return null;
|
||||||
|
|
||||||
|
// Check if this is the Department Lead approval step (Step 3)
|
||||||
|
// Robust check: check level name for variations and level number as fallback
|
||||||
|
const levelName = (level.levelName || '').toLowerCase();
|
||||||
|
const isDeptLeadResult =
|
||||||
|
levelName.includes('department lead') ||
|
||||||
|
levelName.includes('dept lead');
|
||||||
|
|
||||||
|
if (isDeptLeadResult) {
|
||||||
|
logger.info(`[DealerClaimApproval] Department Lead rejected request ${level.requestId}. Circling back to initiator.`);
|
||||||
|
|
||||||
|
// Save approval history (rejection) BEFORE updating level
|
||||||
|
await this.getDealerClaimService().saveApprovalHistory(
|
||||||
|
level.requestId,
|
||||||
|
level.levelId,
|
||||||
|
level.levelNumber,
|
||||||
|
'REJECT',
|
||||||
|
action.comments || '',
|
||||||
|
action.rejectionReason || undefined,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update level status to REJECTED (but signifies a return at this level)
|
||||||
|
await level.update({
|
||||||
|
status: ApprovalStatus.REJECTED,
|
||||||
|
actionDate: rejectionNow,
|
||||||
|
levelEndTime: rejectionNow,
|
||||||
|
elapsedHours: elapsedHours || 0,
|
||||||
|
tatPercentageUsed: tatPercentage || 0,
|
||||||
|
comments: action.comments || action.rejectionReason || undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create or activate initiator action level
|
||||||
|
const initiatorLevel = await this.getDealerClaimService().createOrActivateInitiatorLevel(
|
||||||
|
level.requestId,
|
||||||
|
(wf as any).initiatorId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update workflow status to REJECTED but DO NOT set closureDate
|
||||||
|
// Set currentLevel to initiator level if created
|
||||||
|
const newCurrentLevel = initiatorLevel ? initiatorLevel.levelNumber : wf.currentLevel;
|
||||||
|
await WorkflowRequest.update(
|
||||||
|
{
|
||||||
|
status: WorkflowStatus.REJECTED,
|
||||||
|
currentLevel: newCurrentLevel
|
||||||
|
},
|
||||||
|
{ where: { requestId: level.requestId } }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Capture workflow snapshot when moving back to initiator
|
||||||
|
// Include the rejected level information in the message
|
||||||
|
await this.getDealerClaimService().saveWorkflowHistory(
|
||||||
|
level.requestId,
|
||||||
|
`Department Lead rejected (level ${level.levelNumber}) and moved back to initiator (level ${newCurrentLevel})`,
|
||||||
|
userId,
|
||||||
|
level.levelId, // Store the rejected level's ID
|
||||||
|
level.levelNumber, // Store the rejected level's number
|
||||||
|
level.levelName || undefined // Store the rejected level's name
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
activityService.log({
|
||||||
|
requestId: level.requestId,
|
||||||
|
type: 'rejection',
|
||||||
|
user: { userId: level.approverId, name: level.approverName },
|
||||||
|
timestamp: rejectionNow.toISOString(),
|
||||||
|
action: 'Returned to Initiator',
|
||||||
|
details: `Request returned to initiator by ${level.approverName || level.approverEmail}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`,
|
||||||
|
ipAddress: requestMetadata?.ipAddress || undefined,
|
||||||
|
userAgent: requestMetadata?.userAgent || undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify ONLY the initiator
|
||||||
|
await notificationService.sendToUsers([(wf as any).initiatorId], {
|
||||||
|
title: `Action Required: Request Returned - ${(wf as any).requestNumber}`,
|
||||||
|
body: `Your request "${(wf as any).title}" has been returned to you by the Department Lead for revision/discussion. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`,
|
||||||
|
requestNumber: (wf as any).requestNumber,
|
||||||
|
requestId: level.requestId,
|
||||||
|
url: `/request/${(wf as any).requestNumber}`,
|
||||||
|
type: 'rejection',
|
||||||
|
priority: 'HIGH',
|
||||||
|
actionRequired: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit real-time update
|
||||||
|
emitToRequestRoom(level.requestId, 'request:updated', {
|
||||||
|
requestId: level.requestId,
|
||||||
|
requestNumber: (wf as any)?.requestNumber,
|
||||||
|
action: 'RETURN',
|
||||||
|
levelNumber: level.levelNumber,
|
||||||
|
timestamp: rejectionNow.toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default terminal rejection logic for other steps
|
||||||
|
logger.info(`[DealerClaimApproval] Standard rejection for request ${level.requestId} by level ${level.levelNumber}`);
|
||||||
|
|
||||||
|
// Save approval history (rejection) BEFORE updating level
|
||||||
|
await this.getDealerClaimService().saveApprovalHistory(
|
||||||
|
level.requestId,
|
||||||
|
level.levelId,
|
||||||
|
level.levelNumber,
|
||||||
|
'REJECT',
|
||||||
|
action.comments || '',
|
||||||
|
action.rejectionReason || undefined,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Capture workflow snapshot for terminal rejection action
|
||||||
|
await this.getDealerClaimService().saveWorkflowHistory(
|
||||||
|
level.requestId,
|
||||||
|
`Level ${level.levelNumber} rejected (terminal rejection)`,
|
||||||
|
userId,
|
||||||
|
level.levelId,
|
||||||
|
level.levelNumber,
|
||||||
|
level.levelName || undefined
|
||||||
|
);
|
||||||
|
|
||||||
// Update level status
|
// Update level status
|
||||||
await level.update({
|
await level.update({
|
||||||
status: ApprovalStatus.REJECTED,
|
status: ApprovalStatus.REJECTED,
|
||||||
|
|||||||
@ -21,16 +21,24 @@ interface UploadResult {
|
|||||||
|
|
||||||
class GCSStorageService {
|
class GCSStorageService {
|
||||||
private storage: Storage | null = null;
|
private storage: Storage | null = null;
|
||||||
private bucketName: string;
|
private bucketName: string = '';
|
||||||
private projectId: string;
|
private projectId: string = '';
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
// Check if Google Secret Manager should be used
|
||||||
|
const useGoogleSecretManager = process.env.USE_GOOGLE_SECRET_MANAGER === 'true';
|
||||||
|
|
||||||
|
if (!useGoogleSecretManager) {
|
||||||
|
logger.info('[GCS] USE_GOOGLE_SECRET_MANAGER is not enabled. Will use local storage fallback.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.projectId = process.env.GCP_PROJECT_ID || '';
|
this.projectId = process.env.GCP_PROJECT_ID || '';
|
||||||
this.bucketName = process.env.GCP_BUCKET_NAME || '';
|
this.bucketName = process.env.GCP_BUCKET_NAME || '';
|
||||||
const keyFilePath = process.env.GCP_KEY_FILE || '';
|
const keyFilePath = process.env.GCP_KEY_FILE || '';
|
||||||
|
|
||||||
if (!this.projectId || !this.bucketName || !keyFilePath) {
|
if (!this.projectId || !this.bucketName || !keyFilePath) {
|
||||||
logger.warn('[GCS] GCP configuration missing. File uploads will fail.');
|
logger.warn('[GCS] GCP configuration missing. File uploads will use local storage fallback.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,7 +49,7 @@ class GCSStorageService {
|
|||||||
: path.resolve(process.cwd(), keyFilePath);
|
: path.resolve(process.cwd(), keyFilePath);
|
||||||
|
|
||||||
if (!fs.existsSync(resolvedKeyPath)) {
|
if (!fs.existsSync(resolvedKeyPath)) {
|
||||||
logger.error(`[GCS] Key file not found at: ${resolvedKeyPath}`);
|
logger.error(`[GCS] Key file not found at: ${resolvedKeyPath}. Will use local storage fallback.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,7 +63,7 @@ class GCSStorageService {
|
|||||||
bucketName: this.bucketName,
|
bucketName: this.bucketName,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[GCS] Failed to initialize:', error);
|
logger.error('[GCS] Failed to initialize. Will use local storage fallback:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -341,6 +349,12 @@ class GCSStorageService {
|
|||||||
* Check if GCS is properly configured
|
* Check if GCS is properly configured
|
||||||
*/
|
*/
|
||||||
isConfigured(): boolean {
|
isConfigured(): boolean {
|
||||||
|
// Check if Google Secret Manager is enabled
|
||||||
|
const useGoogleSecretManager = process.env.USE_GOOGLE_SECRET_MANAGER === 'true';
|
||||||
|
if (!useGoogleSecretManager) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return this.storage !== null && this.bucketName !== '' && this.projectId !== '';
|
return this.storage !== null && this.bucketName !== '' && this.projectId !== '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user