forked from Yara724/api
Merge pull request 'Fix v3 mirror flow' (#147) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#147
This commit is contained in:
@@ -309,6 +309,44 @@ export class ClaimRequestManagementService {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** V3 initial document step — excludes capture-phase vehicle evidence. */
|
||||
private v3PreCaptureDocumentKeys(isCarBody: boolean): string[] {
|
||||
return this.requiredDocumentKeysV2(isCarBody).filter(
|
||||
(k) => !isCapturePhaseDamagedPartyDocKey(k),
|
||||
);
|
||||
}
|
||||
|
||||
private allV3PreCaptureDocumentsComplete(
|
||||
claimCase: any,
|
||||
isCarBody: boolean,
|
||||
assumeUploadedKey?: string,
|
||||
): boolean {
|
||||
for (const k of this.v3PreCaptureDocumentKeys(isCarBody)) {
|
||||
const ok =
|
||||
k === assumeUploadedKey
|
||||
? true
|
||||
: this.isRequiredDocumentUploadedOnClaim(claimCase, k);
|
||||
if (!ok) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private countRemainingV3PreCaptureDocuments(
|
||||
claimCase: any,
|
||||
isCarBody: boolean,
|
||||
assumeUploadedKey?: string,
|
||||
): number {
|
||||
let n = 0;
|
||||
for (const k of this.v3PreCaptureDocumentKeys(isCarBody)) {
|
||||
const ok =
|
||||
k === assumeUploadedKey
|
||||
? true
|
||||
: this.isRequiredDocumentUploadedOnClaim(claimCase, k);
|
||||
if (!ok) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
private countRemainingV2OwnerDocuments(
|
||||
claimCase: any,
|
||||
isCarBody: boolean,
|
||||
@@ -5985,6 +6023,7 @@ export class ClaimRequestManagementService {
|
||||
file: Express.Multer.File,
|
||||
currentUserId: string,
|
||||
actor?: { sub: string; role?: string },
|
||||
options?: { v3InPersonFlow?: boolean },
|
||||
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
||||
try {
|
||||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||||
@@ -6042,6 +6081,21 @@ export class ClaimRequestManagementService {
|
||||
`Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} (capture-phase documents only), or ${ClaimWorkflowStep.USER_EXPERT_RESEND}, but current step is ${claimCase.workflow?.currentStep}`,
|
||||
);
|
||||
}
|
||||
if (options?.v3InPersonFlow) {
|
||||
if (body.documentKey === ClaimRequiredDocumentType.CAR_GREEN_CARD) {
|
||||
throw new BadRequestException(
|
||||
"Car green card must be uploaded during other-parts selection, not in the documents step.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS &&
|
||||
isCapturePhaseDamagedPartyDocKey(body.documentKey)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Chassis, engine, and metal plate photos must be uploaded during capture (after parts and angles), not in the initial documents step (${CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.join(", ")}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isResendUpload) {
|
||||
@@ -6160,8 +6214,9 @@ export class ClaimRequestManagementService {
|
||||
$set["status"] = ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS;
|
||||
$set["workflow.currentStep"] =
|
||||
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS;
|
||||
$set["workflow.nextStep"] =
|
||||
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE;
|
||||
$set["workflow.nextStep"] = options?.v3InPersonFlow
|
||||
? ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
|
||||
: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE;
|
||||
|
||||
completedStepsEntries.push(
|
||||
ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||||
@@ -6183,18 +6238,61 @@ export class ClaimRequestManagementService {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
allDocumentsUploaded = this.allV2OwnerDocumentsComplete(
|
||||
const v3InitialDocsComplete =
|
||||
!!options?.v3InPersonFlow &&
|
||||
step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS &&
|
||||
this.allV3PreCaptureDocumentsComplete(
|
||||
claimCase,
|
||||
isCarBodyUpload,
|
||||
body.documentKey,
|
||||
);
|
||||
remaining = this.countRemainingV2OwnerDocuments(
|
||||
|
||||
allDocumentsUploaded = options?.v3InPersonFlow
|
||||
? v3InitialDocsComplete
|
||||
: this.allV2OwnerDocumentsComplete(
|
||||
claimCase,
|
||||
isCarBodyUpload,
|
||||
body.documentKey,
|
||||
);
|
||||
remaining = options?.v3InPersonFlow
|
||||
? this.countRemainingV3PreCaptureDocuments(
|
||||
claimCase,
|
||||
isCarBodyUpload,
|
||||
body.documentKey,
|
||||
)
|
||||
: this.countRemainingV2OwnerDocuments(
|
||||
claimCase,
|
||||
isCarBodyUpload,
|
||||
body.documentKey,
|
||||
);
|
||||
|
||||
if (allDocumentsUploaded) {
|
||||
if (options?.v3InPersonFlow) {
|
||||
$set["status"] = ClaimCaseStatus.SELECTING_OUTER_PARTS;
|
||||
$set["workflow.currentStep"] =
|
||||
ClaimWorkflowStep.SELECT_OUTER_PARTS;
|
||||
$set["workflow.nextStep"] =
|
||||
ClaimWorkflowStep.SELECT_OTHER_PARTS;
|
||||
|
||||
completedStepsEntries.push(
|
||||
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||||
);
|
||||
historyEntries.push({
|
||||
type: "STEP_COMPLETED",
|
||||
actor: {
|
||||
actorId: new Types.ObjectId(currentUserId),
|
||||
actorName: claimCase.owner?.fullName || "User",
|
||||
actorType: "user",
|
||||
},
|
||||
timestamp: new Date(),
|
||||
metadata: {
|
||||
stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||||
description:
|
||||
"Initial required documents uploaded. Proceed to outer part selection.",
|
||||
v3InPersonFlow: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
$set["status"] = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
|
||||
$set["claimStatus"] = ClaimStatus.PENDING;
|
||||
$set["workflow.currentStep"] =
|
||||
@@ -6237,6 +6335,7 @@ export class ClaimRequestManagementService {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the final MongoDB update — all operators explicit and clean
|
||||
const updatePayload: Record<string, unknown> = { $set };
|
||||
@@ -6295,7 +6394,9 @@ export class ClaimRequestManagementService {
|
||||
}).sequencePhase,
|
||||
)
|
||||
: allDocumentsUploaded
|
||||
? "All documents uploaded successfully. Your claim is now ready for damage expert review."
|
||||
? options?.v3InPersonFlow
|
||||
? "Initial required documents uploaded. Proceed to outer part selection."
|
||||
: "All documents uploaded successfully. Your claim is now ready for damage expert review."
|
||||
: `Document uploaded successfully. ${remaining} documents remaining.`;
|
||||
|
||||
return {
|
||||
@@ -6308,7 +6409,9 @@ export class ClaimRequestManagementService {
|
||||
? ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
|
||||
: ClaimWorkflowStep.CAPTURE_PART_DAMAGES
|
||||
: allDocumentsUploaded
|
||||
? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE
|
||||
? options?.v3InPersonFlow
|
||||
? ClaimWorkflowStep.SELECT_OUTER_PARTS
|
||||
: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE
|
||||
: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||||
message,
|
||||
};
|
||||
@@ -7852,6 +7955,11 @@ export class ClaimRequestManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
private v3PartsFlowNotStarted(claimCase: any): boolean {
|
||||
const parts = claimCase.damage?.selectedParts;
|
||||
return !Array.isArray(parts) || parts.length === 0;
|
||||
}
|
||||
|
||||
private async advanceV3ClaimToOuterPartsIfReady(
|
||||
claimCase: any,
|
||||
blame: any,
|
||||
@@ -7859,9 +7967,18 @@ export class ClaimRequestManagementService {
|
||||
if (claimCase.workflow?.currentStep === ClaimWorkflowStep.SELECT_OUTER_PARTS) {
|
||||
return;
|
||||
}
|
||||
if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS) {
|
||||
|
||||
const step = claimCase.workflow?.currentStep;
|
||||
const partsNotStarted = this.v3PartsFlowNotStarted(claimCase);
|
||||
const prematurelyCompleted =
|
||||
step === ClaimWorkflowStep.USER_SUBMISSION_COMPLETE && partsNotStarted;
|
||||
const canAdvance =
|
||||
step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS ||
|
||||
prematurelyCompleted;
|
||||
|
||||
if (!canAdvance) {
|
||||
throw new BadRequestException(
|
||||
`Complete required documents before selecting outer parts. Current step: ${claimCase.workflow?.currentStep}`,
|
||||
`Complete required documents before selecting outer parts. Current step: ${step}`,
|
||||
);
|
||||
}
|
||||
if (!(blame.expert?.decision as any)?.fields?.accidentWay) {
|
||||
@@ -7869,9 +7986,11 @@ export class ClaimRequestManagementService {
|
||||
"Submit accident fields before selecting outer parts.",
|
||||
);
|
||||
}
|
||||
|
||||
await this.claimCaseDbService.findByIdAndUpdate(String(claimCase._id), {
|
||||
$set: {
|
||||
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||||
claimStatus: ClaimStatus.PENDING,
|
||||
"workflow.currentStep": ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||||
"workflow.nextStep": ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||||
},
|
||||
@@ -7906,22 +8025,13 @@ export class ClaimRequestManagementService {
|
||||
const blame = await this.assertV3InPersonClaim(claimCase);
|
||||
this.assertV3ClaimDocumentsPhase(blame);
|
||||
|
||||
const step = claimCase.workflow?.currentStep;
|
||||
if (step !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS) {
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||
$set: {
|
||||
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||
"workflow.currentStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.uploadRequiredDocumentV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
file,
|
||||
currentUserId,
|
||||
actor,
|
||||
{ v3InPersonFlow: true },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
MaxLength,
|
||||
Min,
|
||||
} from "class-validator";
|
||||
import { UNIFIED_FILE_STATUS_KEYS } from "src/helpers/unified-file-status";
|
||||
|
||||
/** Allowed sort keys for V2 list endpoints (claims / blame). */
|
||||
export const LIST_SORT_BY_V2 = [
|
||||
@@ -75,4 +76,13 @@ export class ListQueryV2Dto {
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: UNIFIED_FILE_STATUS_KEYS,
|
||||
description:
|
||||
"Filter by calculated unified file status (blame + claim combined).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn([...UNIFIED_FILE_STATUS_KEYS])
|
||||
unifiedStatus?: (typeof UNIFIED_FILE_STATUS_KEYS)[number];
|
||||
}
|
||||
|
||||
36
src/common/dto/unified-file-status-report.dto.ts
Normal file
36
src/common/dto/unified-file-status-report.dto.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import {
|
||||
UNIFIED_FILE_STATUS_KEYS,
|
||||
UnifiedFileStatusDefinition,
|
||||
UnifiedFileStatusKey,
|
||||
} from "src/helpers/unified-file-status";
|
||||
|
||||
export class UnifiedFileStatusDefinitionDto implements UnifiedFileStatusDefinition {
|
||||
@ApiProperty({ enum: UNIFIED_FILE_STATUS_KEYS })
|
||||
key: UnifiedFileStatusKey;
|
||||
|
||||
@ApiProperty()
|
||||
labelEn: string;
|
||||
|
||||
@ApiProperty()
|
||||
labelFa: string;
|
||||
|
||||
@ApiProperty()
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class UnifiedFileStatusReportDto {
|
||||
@ApiProperty({ type: [UnifiedFileStatusDefinitionDto] })
|
||||
statuses: UnifiedFileStatusDefinitionDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: "Counts per unified status key plus `all`",
|
||||
example: {
|
||||
all: 42,
|
||||
IN_PROGRESS: 10,
|
||||
WAITING_FOR_DAMAGE_EXPERT: 5,
|
||||
COMPLETED: 12,
|
||||
},
|
||||
})
|
||||
counts: Record<string, number>;
|
||||
}
|
||||
@@ -117,6 +117,8 @@ export class AllRequestDtoV2 {
|
||||
lockTime: string | null;
|
||||
type: string;
|
||||
blameStatus: string;
|
||||
/** Calculated blame + linked claim lifecycle status */
|
||||
unifiedFileStatus?: string;
|
||||
partiesInitialForms: { firstParty: string; secondParty: string };
|
||||
partiesVehicles: { firstPartyVehicle: string; secondPartyVehicle: string };
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ExpertBlameV2Controller } from "./expert-blame.v2.controller";
|
||||
import { ExpertBlameService } from "./expert-blame.service";
|
||||
import { RequestManagementModule } from "src/request-management/request-management.module";
|
||||
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
||||
import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -15,6 +16,7 @@ import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.
|
||||
RequestManagementModule,
|
||||
PlatesModule,
|
||||
SmsOrchestrationModule,
|
||||
ClaimRequestManagementModule,
|
||||
],
|
||||
controllers: [ExpertBlameController, ExpertBlameV2Controller],
|
||||
providers: [ExpertBlameService],
|
||||
|
||||
@@ -23,7 +23,13 @@ import {
|
||||
expertPortfolioFileIdsFromActivityEvents,
|
||||
objectIdsFromStringSet,
|
||||
} from "src/helpers/expert-portfolio";
|
||||
import {
|
||||
countUnifiedFileStatuses,
|
||||
getUnifiedFileStatusCatalog,
|
||||
resolveUnifiedFileStatus,
|
||||
} from "src/helpers/unified-file-status";
|
||||
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
||||
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||
import {
|
||||
ExpertFileAssignResultDto,
|
||||
@@ -115,8 +121,109 @@ export class ExpertBlameService {
|
||||
private readonly requestManagementService: RequestManagementService,
|
||||
private readonly smsOrchestrationService: SmsOrchestrationService,
|
||||
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
|
||||
private readonly claimCaseDbService: ClaimCaseDbService,
|
||||
) {}
|
||||
|
||||
private async loadClaimsByPublicIds(
|
||||
publicIds: string[],
|
||||
): Promise<Map<string, { status?: string }>> {
|
||||
const unique = [...new Set(publicIds.filter(Boolean))];
|
||||
if (unique.length === 0) return new Map();
|
||||
const claims = (await this.claimCaseDbService.find(
|
||||
{ publicId: { $in: unique } },
|
||||
{ lean: true, select: "publicId status" },
|
||||
)) as Array<{ publicId?: string; status?: string }>;
|
||||
return new Map(
|
||||
claims.map((c) => [String(c.publicId), { status: c.status }]),
|
||||
);
|
||||
}
|
||||
|
||||
private filterBlameDocsByUnifiedStatus(
|
||||
docs: Record<string, unknown>[],
|
||||
claimByPublicId: Map<string, { status?: string }>,
|
||||
unifiedStatus?: string,
|
||||
): Record<string, unknown>[] {
|
||||
if (!unifiedStatus) return docs;
|
||||
return docs.filter((doc) => {
|
||||
const publicId = String((doc as { publicId?: string }).publicId ?? "");
|
||||
const claim = claimByPublicId.get(publicId);
|
||||
return (
|
||||
resolveUnifiedFileStatus({
|
||||
blameStatus: String((doc as { status?: string }).status ?? ""),
|
||||
claimStatus: claim?.status,
|
||||
}) === unifiedStatus
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async getUnifiedFileStatusReportV2(actor: any) {
|
||||
const rows = await this.collectBlamePortfolioDocs(actor);
|
||||
const claimByPublicId = await this.loadClaimsByPublicIds(
|
||||
rows.map((d) => String((d as { publicId?: string }).publicId ?? "")),
|
||||
);
|
||||
return {
|
||||
statuses: getUnifiedFileStatusCatalog(),
|
||||
counts: countUnifiedFileStatuses(
|
||||
rows.map((doc) => {
|
||||
const publicId = String((doc as { publicId?: string }).publicId ?? "");
|
||||
const claim = claimByPublicId.get(publicId);
|
||||
return {
|
||||
blameStatus: String((doc as { status?: string }).status ?? ""),
|
||||
claimStatus: claim?.status,
|
||||
};
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private async collectBlamePortfolioDocs(
|
||||
actor: any,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
if (actor.role === RoleEnum.FIELD_EXPERT) {
|
||||
return (await this.blameRequestDbService.find(
|
||||
{
|
||||
expertInitiated: true,
|
||||
initiatedByFieldExpertId: new Types.ObjectId(actor.sub),
|
||||
},
|
||||
{ lean: true },
|
||||
)) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
requireActorClientKey(actor);
|
||||
const expertId = String(actor.sub);
|
||||
const expertOid = new Types.ObjectId(expertId);
|
||||
const activityEvents = await this.expertFileActivityDbService.findByExpert(
|
||||
expertId,
|
||||
ExpertFileKind.BLAME,
|
||||
);
|
||||
const activityFileIds =
|
||||
expertPortfolioFileIdsFromActivityEvents(activityEvents);
|
||||
const orClauses: Record<string, unknown>[] = [
|
||||
{ initiatedByFieldExpertId: expertOid },
|
||||
{ "expert.decision.decidedByExpertId": expertOid },
|
||||
{ "expert.resend.requestedByExpertId": expertOid },
|
||||
{ "workflow.lockedBy.actorId": expertOid },
|
||||
];
|
||||
const activityOids = objectIdsFromStringSet(activityFileIds);
|
||||
if (activityOids.length > 0) {
|
||||
orClauses.push({ _id: { $in: activityOids } });
|
||||
}
|
||||
const rows = (await this.blameRequestDbService.find(
|
||||
{ blameStatus: BlameStatus.DISAGREEMENT, $or: orClauses },
|
||||
{ lean: true },
|
||||
)) as Record<string, unknown>[];
|
||||
const seen = new Set<string>();
|
||||
const out: Record<string, unknown>[] = [];
|
||||
for (const doc of rows) {
|
||||
const id = String(doc._id ?? "");
|
||||
if (seen.has(id)) continue;
|
||||
if (!blameDocInExpertPortfolio(doc, actor, activityFileIds)) continue;
|
||||
seen.add(id);
|
||||
out.push(doc);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Load immutable profile fields from `expert` for the acting field expert. */
|
||||
private async snapshotFieldExpert(actorId: string) {
|
||||
const expertDoc = await this.expertDbService.findOne({
|
||||
@@ -386,44 +493,8 @@ export class ExpertBlameService {
|
||||
}
|
||||
}
|
||||
|
||||
const paged = applyListQueryV2(
|
||||
visibleCases,
|
||||
{
|
||||
publicId: (doc) =>
|
||||
String((doc as { publicId?: string }).publicId ?? ""),
|
||||
createdAt: (doc) => (doc as { createdAt?: Date }).createdAt,
|
||||
requestNo: (doc) =>
|
||||
String(
|
||||
(doc as { requestNo?: string }).requestNo ??
|
||||
(doc as { publicId?: string }).publicId ??
|
||||
"",
|
||||
),
|
||||
status: (doc) => String((doc as { status?: string }).status ?? ""),
|
||||
searchExtras: (doc) => {
|
||||
const d = doc as {
|
||||
_id?: unknown;
|
||||
blameStatus?: string;
|
||||
type?: string;
|
||||
};
|
||||
return [String(d._id ?? ""), d.blameStatus, d.type].filter(
|
||||
Boolean,
|
||||
) as string[];
|
||||
},
|
||||
},
|
||||
query,
|
||||
);
|
||||
|
||||
const items: AllRequestDtoV2[] = paged.list.map((doc) =>
|
||||
this.mapBlameRequestToListItemV2(doc),
|
||||
);
|
||||
|
||||
return new AllRequestDtoRsV2({
|
||||
data: items,
|
||||
total: paged.total,
|
||||
page: paged.page,
|
||||
limit: paged.limit,
|
||||
totalPages: paged.totalPages,
|
||||
});
|
||||
const pagedResult = await this.paginateBlameListV2(visibleCases, query);
|
||||
return pagedResult;
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(
|
||||
@@ -480,8 +551,27 @@ export class ExpertBlameService {
|
||||
}
|
||||
}
|
||||
|
||||
const paged = applyListQueryV2(
|
||||
const pagedResult = await this.paginateBlameListV2(visibleCases, query);
|
||||
return pagedResult;
|
||||
}
|
||||
|
||||
private async paginateBlameListV2(
|
||||
visibleCases: Record<string, unknown>[],
|
||||
query: ListQueryV2Dto,
|
||||
): Promise<AllRequestDtoRsV2> {
|
||||
const claimByPublicId = await this.loadClaimsByPublicIds(
|
||||
visibleCases.map((d) =>
|
||||
String((d as { publicId?: string }).publicId ?? ""),
|
||||
),
|
||||
);
|
||||
const filtered = this.filterBlameDocsByUnifiedStatus(
|
||||
visibleCases,
|
||||
claimByPublicId,
|
||||
query.unifiedStatus,
|
||||
);
|
||||
|
||||
const paged = applyListQueryV2(
|
||||
filtered,
|
||||
{
|
||||
publicId: (doc) =>
|
||||
String((doc as { publicId?: string }).publicId ?? ""),
|
||||
@@ -492,23 +582,42 @@ export class ExpertBlameService {
|
||||
(doc as { publicId?: string }).publicId ??
|
||||
"",
|
||||
),
|
||||
status: (doc) => String((doc as { status?: string }).status ?? ""),
|
||||
status: (doc) => {
|
||||
const publicId = String((doc as { publicId?: string }).publicId ?? "");
|
||||
const claim = claimByPublicId.get(publicId);
|
||||
return resolveUnifiedFileStatus({
|
||||
blameStatus: String((doc as { status?: string }).status ?? ""),
|
||||
claimStatus: claim?.status,
|
||||
});
|
||||
},
|
||||
searchExtras: (doc) => {
|
||||
const d = doc as {
|
||||
_id?: unknown;
|
||||
blameStatus?: string;
|
||||
type?: string;
|
||||
publicId?: string;
|
||||
status?: string;
|
||||
};
|
||||
return [String(d._id ?? ""), d.blameStatus, d.type].filter(
|
||||
Boolean,
|
||||
) as string[];
|
||||
const publicId = String(d.publicId ?? "");
|
||||
const claim = claimByPublicId.get(publicId);
|
||||
const unified = resolveUnifiedFileStatus({
|
||||
blameStatus: String(d.status ?? ""),
|
||||
claimStatus: claim?.status,
|
||||
});
|
||||
return [
|
||||
String(d._id ?? ""),
|
||||
d.blameStatus,
|
||||
d.type,
|
||||
unified,
|
||||
claim?.status,
|
||||
].filter(Boolean) as string[];
|
||||
},
|
||||
},
|
||||
query,
|
||||
);
|
||||
|
||||
const items: AllRequestDtoV2[] = paged.list.map((doc) =>
|
||||
this.mapBlameRequestToListItemV2(doc),
|
||||
this.mapBlameRequestToListItemV2(doc, claimByPublicId),
|
||||
);
|
||||
|
||||
return new AllRequestDtoRsV2({
|
||||
@@ -522,6 +631,7 @@ export class ExpertBlameService {
|
||||
|
||||
private mapBlameRequestToListItemV2(
|
||||
doc: Record<string, unknown>,
|
||||
claimByPublicId?: Map<string, { status?: string }>,
|
||||
): AllRequestDtoV2 {
|
||||
const createdAt = doc.createdAt
|
||||
? new Date(doc.createdAt as string)
|
||||
@@ -552,10 +662,17 @@ export class ExpertBlameService {
|
||||
|
||||
const firstParty = parties.find((p) => p.role === "FIRST");
|
||||
const secondParty = parties.find((p) => p.role === "SECOND");
|
||||
const publicId = String(doc.publicId ?? "");
|
||||
const linkedClaim = claimByPublicId?.get(publicId);
|
||||
const unifiedFileStatus = resolveUnifiedFileStatus({
|
||||
blameStatus: String(doc.status ?? ""),
|
||||
claimStatus: linkedClaim?.status,
|
||||
});
|
||||
|
||||
return {
|
||||
requestId: String(doc._id),
|
||||
status: String(doc.status ?? ""),
|
||||
unifiedFileStatus,
|
||||
userComment: null,
|
||||
requestCode: String(doc.publicId ?? ""),
|
||||
date,
|
||||
|
||||
@@ -44,7 +44,7 @@ export class ExpertBlameV2Controller {
|
||||
description:
|
||||
"Damage experts (`expert`): tenant-scoped **DISAGREEMENT** queue (available, locked, or decided by you). " +
|
||||
"Field experts (`field_expert`): all expert-initiated blame files they created (LINK + IN_PERSON). Use expert-claim for claims after blame completion. " +
|
||||
"Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`.",
|
||||
"Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus` (calculated blame+claim status).",
|
||||
})
|
||||
async findAll(
|
||||
@CurrentUser() actor: any,
|
||||
@@ -60,11 +60,31 @@ export class ExpertBlameV2Controller {
|
||||
}
|
||||
}
|
||||
|
||||
@Get("report/unified-file-statuses")
|
||||
@ApiOperation({
|
||||
summary: "Unified file status catalog + counts (blame + linked claim)",
|
||||
description:
|
||||
"Calculatable statuses for this expert's blame portfolio. Filter lists with `unifiedStatus` query param.",
|
||||
})
|
||||
async getUnifiedFileStatusReportV2(@CurrentUser() actor: any) {
|
||||
try {
|
||||
return await this.expertBlameService.getUnifiedFileStatusReportV2(actor);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to load unified file status report",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get("report/status-counts")
|
||||
@ApiOperation({
|
||||
summary: "Count blame cases by grouped status bucket (this field expert)",
|
||||
deprecated: true,
|
||||
description:
|
||||
"Counts only files in the acting expert’s portfolio: expertFileActivities (checked or handled), workflow lock, expert decision, or expert-initiated blame. IN_PROGRESS groups OPEN and WAITING_FOR_SECOND_PARTY. Other keys match CaseStatus. Does not include the open WAITING_FOR_EXPERT queue unless this expert has touched the file.",
|
||||
"Legacy blame-only buckets. Prefer GET report/unified-file-statuses for blame+claim calculatable statuses.",
|
||||
})
|
||||
async getStatusReportBucketsV2(@CurrentUser() actor: any) {
|
||||
try {
|
||||
|
||||
@@ -15,6 +15,12 @@ export class ClaimListItemV2Dto {
|
||||
})
|
||||
status: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Calculated unified blame+claim lifecycle status",
|
||||
example: "WAITING_FOR_DAMAGE_EXPERT",
|
||||
})
|
||||
unifiedFileStatus?: string;
|
||||
|
||||
@ApiProperty({ description: 'Workflow step', example: 'USER_SUBMISSION_COMPLETE' })
|
||||
currentStep: string;
|
||||
|
||||
|
||||
@@ -142,6 +142,11 @@ import {
|
||||
import { getClaimV2TotalPaymentCapToman } from "src/constants/repair-amount-limits";
|
||||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
||||
import {
|
||||
countUnifiedFileStatuses,
|
||||
getUnifiedFileStatusCatalog,
|
||||
resolveUnifiedFileStatus,
|
||||
} from "src/helpers/unified-file-status";
|
||||
import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher";
|
||||
import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys";
|
||||
|
||||
@@ -3411,6 +3416,144 @@ export class ExpertClaimService {
|
||||
return buckets;
|
||||
}
|
||||
|
||||
async getUnifiedFileStatusReportV2(actor: any) {
|
||||
const { claims, blameById } =
|
||||
await this.collectClaimPortfolioWithBlame(actor);
|
||||
return {
|
||||
statuses: getUnifiedFileStatusCatalog(),
|
||||
counts: countUnifiedFileStatuses(
|
||||
claims.map((c) => {
|
||||
const blame = c.blameRequestId
|
||||
? blameById.get(String(c.blameRequestId))
|
||||
: undefined;
|
||||
return {
|
||||
blameStatus: blame?.status,
|
||||
claimStatus: c.status,
|
||||
};
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private async collectClaimPortfolioWithBlame(actor: any): Promise<{
|
||||
claims: any[];
|
||||
blameById: Map<string, any>;
|
||||
}> {
|
||||
let claims: any[] = [];
|
||||
if (actor.role === RoleEnum.FIELD_EXPERT) {
|
||||
const expertOid = new Types.ObjectId(actor.sub);
|
||||
const expertBlameIds = await this.blameRequestDbService
|
||||
.find(
|
||||
{ expertInitiated: true, initiatedByFieldExpertId: expertOid },
|
||||
{ select: "_id", lean: true },
|
||||
)
|
||||
.then((docs) => docs.map((d) => (d as { _id: unknown })._id));
|
||||
const claimOr: Record<string, unknown>[] = [
|
||||
{ initiatedByFieldExpertId: expertOid },
|
||||
];
|
||||
if (expertBlameIds.length > 0) {
|
||||
claimOr.push({ blameRequestId: { $in: expertBlameIds } });
|
||||
}
|
||||
claims = (await this.claimCaseDbService.find({ $or: claimOr })) as any[];
|
||||
} else {
|
||||
const clientKey = requireActorClientKey(actor);
|
||||
const expertId = String(actor.sub);
|
||||
const expertOid = new Types.ObjectId(expertId);
|
||||
const activityEvents = await this.expertFileActivityDbService.findByExpert(
|
||||
expertId,
|
||||
ExpertFileKind.CLAIM,
|
||||
);
|
||||
const activityFileIds =
|
||||
expertPortfolioFileIdsFromActivityEvents(activityEvents);
|
||||
const orClauses: Record<string, unknown>[] = [
|
||||
{ "workflow.lockedBy.actorId": expertOid },
|
||||
{ "evaluation.damageExpertReply.actorDetail.actorId": expertOid },
|
||||
{ "evaluation.damageExpertReplyFinal.actorDetail.actorId": expertOid },
|
||||
{ "evaluation.damageExpertResend.requestedByExpertId": expertOid },
|
||||
];
|
||||
const activityOids = objectIdsFromStringSet(activityFileIds);
|
||||
if (activityOids.length > 0) {
|
||||
orClauses.push({ _id: { $in: activityOids } });
|
||||
}
|
||||
const rows =
|
||||
orClauses.length === 0
|
||||
? []
|
||||
: await this.claimCaseDbService.find({ $or: orClauses }, { lean: true });
|
||||
const seen = new Set<string>();
|
||||
for (const doc of rows as Record<string, unknown>[]) {
|
||||
const id = String(doc._id ?? "");
|
||||
if (seen.has(id)) continue;
|
||||
if (
|
||||
!claimDocInExpertPortfolio(doc, expertId, activityFileIds, clientKey)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
seen.add(id);
|
||||
claims.push(doc);
|
||||
}
|
||||
}
|
||||
|
||||
const blameIds = [
|
||||
...new Set(
|
||||
claims
|
||||
.map((c) => c.blameRequestId?.toString())
|
||||
.filter((id): id is string => !!id),
|
||||
),
|
||||
];
|
||||
const blames =
|
||||
blameIds.length > 0
|
||||
? ((await this.blameRequestDbService.find(
|
||||
{
|
||||
_id: { $in: blameIds.map((id) => new Types.ObjectId(id)) },
|
||||
},
|
||||
{ lean: true, select: "status publicId" },
|
||||
)) as any[])
|
||||
: [];
|
||||
const blameById = new Map<string, any>(
|
||||
blames.map((b) => [String(b._id), b]),
|
||||
);
|
||||
return { claims, blameById };
|
||||
}
|
||||
|
||||
private paginateClaimListV2(
|
||||
list: ClaimListItemV2Dto[],
|
||||
query: ListQueryV2Dto,
|
||||
): GetClaimListV2ResponseDto {
|
||||
let filtered = list;
|
||||
if (query.unifiedStatus) {
|
||||
filtered = list.filter(
|
||||
(item) => item.unifiedFileStatus === query.unifiedStatus,
|
||||
);
|
||||
}
|
||||
const paged = applyListQueryV2(
|
||||
filtered,
|
||||
{
|
||||
publicId: (r) => r.publicId,
|
||||
createdAt: (r) => r.createdAt,
|
||||
requestNo: (r) => r.publicId,
|
||||
status: (r) => r.unifiedFileStatus ?? r.status,
|
||||
searchExtras: (r) =>
|
||||
[
|
||||
r.claimRequestId,
|
||||
r.currentStep,
|
||||
r.vehicle?.carName,
|
||||
r.vehicle?.carModel,
|
||||
r.blameRequestType,
|
||||
r.unifiedFileStatus,
|
||||
r.status,
|
||||
].filter(Boolean) as string[],
|
||||
},
|
||||
query,
|
||||
);
|
||||
return {
|
||||
list: paged.list,
|
||||
total: paged.total,
|
||||
page: paged.page,
|
||||
limit: paged.limit,
|
||||
totalPages: paged.totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Get claim list for damage expert
|
||||
*
|
||||
@@ -3581,6 +3724,10 @@ export class ExpertClaimService {
|
||||
claimRequestId: c._id.toString(),
|
||||
publicId: c.publicId,
|
||||
status: statusForList,
|
||||
unifiedFileStatus: resolveUnifiedFileStatus({
|
||||
blameStatus: blame?.status,
|
||||
claimStatus: c.status,
|
||||
}),
|
||||
currentStep: c.workflow?.currentStep || "",
|
||||
locked: lockActive,
|
||||
lockedBy:
|
||||
@@ -3601,32 +3748,7 @@ export class ExpertClaimService {
|
||||
};
|
||||
}) as ClaimListItemV2Dto[];
|
||||
|
||||
const paged = applyListQueryV2(
|
||||
list,
|
||||
{
|
||||
publicId: (r) => r.publicId,
|
||||
createdAt: (r) => r.createdAt,
|
||||
requestNo: (r) => r.publicId,
|
||||
status: (r) => r.status,
|
||||
searchExtras: (r) =>
|
||||
[
|
||||
r.claimRequestId,
|
||||
r.currentStep,
|
||||
r.vehicle?.carName,
|
||||
r.vehicle?.carModel,
|
||||
r.blameRequestType,
|
||||
].filter(Boolean) as string[],
|
||||
},
|
||||
query,
|
||||
);
|
||||
|
||||
return {
|
||||
list: paged.list,
|
||||
total: paged.total,
|
||||
page: paged.page,
|
||||
limit: paged.limit,
|
||||
totalPages: paged.totalPages,
|
||||
};
|
||||
return this.paginateClaimListV2(list, query);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3687,6 +3809,10 @@ export class ExpertClaimService {
|
||||
claimRequestId: c._id.toString(),
|
||||
publicId: c.publicId,
|
||||
status: c.status,
|
||||
unifiedFileStatus: resolveUnifiedFileStatus({
|
||||
blameStatus: blame?.status,
|
||||
claimStatus: c.status,
|
||||
}),
|
||||
currentStep: c.workflow?.currentStep || "",
|
||||
locked: lockActive,
|
||||
lockedBy:
|
||||
@@ -3707,31 +3833,7 @@ export class ExpertClaimService {
|
||||
};
|
||||
}) as ClaimListItemV2Dto[];
|
||||
|
||||
const paged = applyListQueryV2(
|
||||
list,
|
||||
{
|
||||
publicId: (r) => r.publicId,
|
||||
createdAt: (r) => r.createdAt,
|
||||
requestNo: (r) => r.publicId,
|
||||
status: (r) => r.status,
|
||||
searchExtras: (r) =>
|
||||
[
|
||||
r.claimRequestId,
|
||||
r.currentStep,
|
||||
r.vehicle?.carName,
|
||||
r.vehicle?.carModel,
|
||||
].filter(Boolean) as string[],
|
||||
},
|
||||
query,
|
||||
);
|
||||
|
||||
return {
|
||||
list: paged.list,
|
||||
total: paged.total,
|
||||
page: paged.page,
|
||||
limit: paged.limit,
|
||||
totalPages: paged.totalPages,
|
||||
};
|
||||
return this.paginateClaimListV2(list, query);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -65,11 +65,22 @@ export class ExpertClaimV2Controller {
|
||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||
) {}
|
||||
|
||||
@Get("report/unified-file-statuses")
|
||||
@ApiOperation({
|
||||
summary: "Unified file status catalog + counts (claim + linked blame)",
|
||||
description:
|
||||
"Calculatable statuses for this expert's claim portfolio. Filter lists with `unifiedStatus` query param.",
|
||||
})
|
||||
async getUnifiedFileStatusReportV2(@CurrentUser() actor: any) {
|
||||
return await this.expertClaimService.getUnifiedFileStatusReportV2(actor);
|
||||
}
|
||||
|
||||
@Get("report/status-counts")
|
||||
@ApiOperation({
|
||||
summary: "Count claim cases by grouped status bucket (this damage expert)",
|
||||
deprecated: true,
|
||||
description:
|
||||
"Counts only files in the acting expert’s portfolio: expertFileActivities (checked or handled), current workflow lock, or a prior damageExpertReply / damageExpertReplyFinal by this expert. IN_PROGRESS groups user-phase statuses before submission is complete (CREATED through CAPTURING_PART_DAMAGES). Other keys match ClaimCaseStatus. Does not include the open tenant queue unless this expert has touched the file.",
|
||||
"Legacy claim-only buckets. Prefer GET report/unified-file-statuses for blame+claim calculatable statuses.",
|
||||
})
|
||||
async getStatusReportBucketsV2(@CurrentUser() actor: any) {
|
||||
return await this.expertClaimService.getStatusReportBucketsV2(actor);
|
||||
@@ -116,7 +127,7 @@ export class ExpertClaimV2Controller {
|
||||
summary: "List available claim requests for damage expert",
|
||||
description:
|
||||
"Damage experts: tenant queue (`WAITING_FOR_DAMAGE_EXPERT`), locked/in-progress, factor validation. " +
|
||||
"Field experts: all claims linked to blame files they initiated (`initiatedByFieldExpertId` or matching `blameRequestId`). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`.",
|
||||
"Field experts: all claims linked to blame files they initiated (`initiatedByFieldExpertId` or matching `blameRequestId`). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`, `unifiedStatus`.",
|
||||
})
|
||||
@ApiResponse({ status: 200, type: GetClaimListV2ResponseDto })
|
||||
async getClaimListV2(
|
||||
|
||||
@@ -26,6 +26,7 @@ import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { FileRating } from "src/request-management/entities/schema/request-management.schema";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { ExpertInsurerService } from "./expert-insurer.service";
|
||||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||
import { CreateBranchDto } from "src/client/dto/create-branch.dto";
|
||||
import {
|
||||
CreateBlameExpertByInsurerDto,
|
||||
@@ -162,9 +163,46 @@ export class ExpertInsurerController {
|
||||
}
|
||||
|
||||
@Get("files")
|
||||
async getAllFiles(@CurrentUser() insurer) {
|
||||
@ApiOperation({
|
||||
summary: "List insurer files (blame + claim merged by publicId)",
|
||||
description:
|
||||
"Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus`.",
|
||||
})
|
||||
async getAllFiles(
|
||||
@CurrentUser() insurer,
|
||||
@Query() query: ListQueryV2Dto,
|
||||
) {
|
||||
return await this.expertInsurerService.retrieveAllFilesOfClient(
|
||||
insurer.clientKey,
|
||||
query,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("report/unified-file-statuses")
|
||||
@ApiOperation({
|
||||
summary: "Unified file status catalog + counts (blame + claim)",
|
||||
description:
|
||||
"Returns calculatable unified statuses and per-status counts for this insurer's files. Use `unifiedStatus` on GET files to filter.",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "from",
|
||||
required: false,
|
||||
description: "Optional start datetime (ISO string)",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "to",
|
||||
required: false,
|
||||
description: "Optional end datetime (ISO string)",
|
||||
})
|
||||
async getUnifiedFileStatusReport(
|
||||
@CurrentUser() actor,
|
||||
@Query("from") from?: string,
|
||||
@Query("to") to?: string,
|
||||
) {
|
||||
return await this.expertInsurerService.getInsurerUnifiedFileStatusReport(
|
||||
actor,
|
||||
from,
|
||||
to,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -265,6 +303,11 @@ export class ExpertInsurerController {
|
||||
}
|
||||
|
||||
@Get("report/status-counts")
|
||||
@ApiOperation({
|
||||
summary: "Legacy status counts (mapped from unified statuses)",
|
||||
deprecated: true,
|
||||
description: "Prefer GET report/unified-file-statuses for full calculatable blame+claim statuses.",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "from",
|
||||
required: false,
|
||||
|
||||
@@ -39,6 +39,13 @@ import { toJalaliDateAndTime } from "src/helpers/date-jalali";
|
||||
import { enrichBlamePartiesForAgreementView } from "src/helpers/blame-party-agreement-decision";
|
||||
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
||||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
||||
import {
|
||||
countUnifiedFileStatuses,
|
||||
getUnifiedFileStatusCatalog,
|
||||
resolveUnifiedFileStatus,
|
||||
} from "src/helpers/unified-file-status";
|
||||
import {
|
||||
getClaimCarAngleCaptureBlob,
|
||||
getDamagedPartCaptureBlob,
|
||||
@@ -1145,13 +1152,10 @@ export class ExpertInsurerService {
|
||||
};
|
||||
}
|
||||
|
||||
async retrieveAllFilesOfClient(insurerId: string) {
|
||||
const id = this.getClientId(insurerId);
|
||||
const idStr = String(id);
|
||||
|
||||
private async buildMergedInsurerFiles(clientObjectId: Types.ObjectId) {
|
||||
const [blameFiles, claimFiles] = await Promise.all([
|
||||
this.getClientBlameFiles(id),
|
||||
this.getClientClaimFiles(id),
|
||||
this.getClientBlameFiles(clientObjectId),
|
||||
this.getClientClaimFiles(clientObjectId),
|
||||
]);
|
||||
|
||||
const byPublicId = new Map<
|
||||
@@ -1192,7 +1196,6 @@ export class ExpertInsurerService {
|
||||
}
|
||||
>();
|
||||
|
||||
// Index blame files first
|
||||
for (const b of blameFiles) {
|
||||
const key = String((b as any).publicId || (b as any)._id || "");
|
||||
if (!key) continue;
|
||||
@@ -1216,8 +1219,6 @@ export class ExpertInsurerService {
|
||||
byPublicId.set(key, prev);
|
||||
}
|
||||
|
||||
// For claim files whose blame wasn't in blameFiles (different insurer's blame),
|
||||
// fetch the linked blame directly so we can get vehicle + parties data
|
||||
const claimOnlyBlameIds = claimFiles
|
||||
.filter((c) => {
|
||||
const key = String((c as any).publicId || (c as any)._id || "");
|
||||
@@ -1243,13 +1244,11 @@ export class ExpertInsurerService {
|
||||
linkedBlames.map((b) => [String(b.publicId), b]),
|
||||
);
|
||||
|
||||
// Index claim files
|
||||
for (const c of claimFiles) {
|
||||
const key = String((c as any).publicId || (c as any)._id || "");
|
||||
if (!key) continue;
|
||||
const prev = byPublicId.get(key) ?? { publicId: key };
|
||||
|
||||
// If no blame entry yet, use the linked blame for parties/vehicle/fileType
|
||||
if (!prev.blame) {
|
||||
const linkedBlame = linkedBlameByPublicId.get(key);
|
||||
if (linkedBlame) {
|
||||
@@ -1293,26 +1292,85 @@ export class ExpertInsurerService {
|
||||
byPublicId.set(key, prev);
|
||||
}
|
||||
|
||||
const files = Array.from(byPublicId.values())
|
||||
.map((f) => ({
|
||||
return Array.from(byPublicId.values()).map((f) => ({
|
||||
publicId: f.publicId,
|
||||
fileType: f.fileType,
|
||||
hasClaim: !!f.claim,
|
||||
unifiedFileStatus: resolveUnifiedFileStatus({
|
||||
blameStatus: f.blame?.status,
|
||||
claimStatus: f.claim?.status,
|
||||
}),
|
||||
parties: f.parties,
|
||||
blame: f.blame,
|
||||
claim: f.claim,
|
||||
createdAt: f.createdAt,
|
||||
updatedAt: f.updatedAt,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const at = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
||||
const bt = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
||||
return bt - at;
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
async retrieveAllFilesOfClient(
|
||||
insurerId: string,
|
||||
query: ListQueryV2Dto = {},
|
||||
) {
|
||||
const id = this.getClientId(insurerId);
|
||||
let files = await this.buildMergedInsurerFiles(id);
|
||||
|
||||
if (query.unifiedStatus) {
|
||||
files = files.filter(
|
||||
(f) => f.unifiedFileStatus === query.unifiedStatus,
|
||||
);
|
||||
}
|
||||
|
||||
const paged = applyListQueryV2(
|
||||
files,
|
||||
{
|
||||
publicId: (f) => f.publicId,
|
||||
createdAt: (f) => f.updatedAt ?? f.createdAt,
|
||||
requestNo: (f) => f.publicId,
|
||||
status: (f) => f.unifiedFileStatus,
|
||||
searchExtras: (f) =>
|
||||
[
|
||||
f.fileType,
|
||||
f.blame?.requestNo,
|
||||
f.claim?.requestNo,
|
||||
f.blame?.status,
|
||||
f.claim?.status,
|
||||
f.unifiedFileStatus,
|
||||
].filter(Boolean) as string[],
|
||||
},
|
||||
query,
|
||||
);
|
||||
|
||||
return {
|
||||
total: files.length,
|
||||
files,
|
||||
total: paged.total,
|
||||
page: paged.page,
|
||||
limit: paged.limit,
|
||||
totalPages: paged.totalPages,
|
||||
files: paged.list,
|
||||
};
|
||||
}
|
||||
|
||||
async getInsurerUnifiedFileStatusReport(
|
||||
actor: any,
|
||||
from?: string,
|
||||
to?: string,
|
||||
) {
|
||||
const clientObjectId = this.getClientId(actor);
|
||||
const { fromDate, toDate } = this.parseDateRange(from, to);
|
||||
|
||||
const files = await this.buildMergedInsurerFiles(clientObjectId);
|
||||
const inRange = files.filter((f) =>
|
||||
this.isInDateRange(f.createdAt, fromDate, toDate),
|
||||
);
|
||||
|
||||
return {
|
||||
statuses: getUnifiedFileStatusCatalog(),
|
||||
counts: countUnifiedFileStatuses(
|
||||
inRange.map((f) => ({
|
||||
blameStatus: f.blame?.status,
|
||||
claimStatus: f.claim?.status,
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1888,72 +1946,17 @@ export class ExpertInsurerService {
|
||||
under_review: number;
|
||||
waiting_for_documents_resend: number;
|
||||
}> {
|
||||
const clientObjectId = this.getClientId(actor);
|
||||
const { fromDate, toDate } = this.parseDateRange(from, to);
|
||||
|
||||
const [blameFilesRaw, claimFilesRaw] = await Promise.all([
|
||||
this.getClientBlameFiles(clientObjectId),
|
||||
this.getClientClaimFiles(clientObjectId),
|
||||
]);
|
||||
|
||||
const blameFiles = blameFilesRaw.filter((f) =>
|
||||
this.isInDateRange((f as any)?.createdAt, fromDate, toDate),
|
||||
);
|
||||
const claimFiles = claimFilesRaw.filter((f) =>
|
||||
this.isInDateRange((f as any)?.createdAt, fromDate, toDate),
|
||||
);
|
||||
|
||||
const fileMap = new Map<string, { blame?: any; claim?: any }>();
|
||||
|
||||
for (const b of blameFiles) {
|
||||
const key = String((b as any).publicId || (b as any)._id || "");
|
||||
if (!key) continue;
|
||||
const prev = fileMap.get(key) ?? {};
|
||||
prev.blame = b;
|
||||
fileMap.set(key, prev);
|
||||
}
|
||||
for (const c of claimFiles) {
|
||||
const key = String((c as any).publicId || (c as any)._id || "");
|
||||
if (!key) continue;
|
||||
const prev = fileMap.get(key) ?? {};
|
||||
prev.claim = c;
|
||||
fileMap.set(key, prev);
|
||||
}
|
||||
|
||||
let completed = 0;
|
||||
let underReview = 0;
|
||||
let waitingForDocumentsResend = 0;
|
||||
|
||||
for (const entry of fileMap.values()) {
|
||||
const blameStatus = entry.blame?.status;
|
||||
const claimStatus = entry.claim?.status;
|
||||
|
||||
if (claimStatus === ClaimCaseStatus.COMPLETED) {
|
||||
completed += 1;
|
||||
}
|
||||
|
||||
const blameUnderReview = blameStatus === CaseStatus.WAITING_FOR_EXPERT;
|
||||
const claimUnderReview =
|
||||
claimStatus === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT ||
|
||||
claimStatus === ClaimCaseStatus.EXPERT_REVIEWING;
|
||||
if (blameUnderReview || claimUnderReview) {
|
||||
underReview += 1;
|
||||
}
|
||||
|
||||
const blameResend =
|
||||
blameStatus === CaseStatus.WAITING_FOR_DOCUMENT_RESEND;
|
||||
const claimResend =
|
||||
claimStatus === ClaimCaseStatus.WAITING_FOR_USER_RESEND;
|
||||
if (blameResend || claimResend) {
|
||||
waitingForDocumentsResend += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const report = await this.getInsurerUnifiedFileStatusReport(actor, from, to);
|
||||
const counts = report.counts;
|
||||
return {
|
||||
all: fileMap.size,
|
||||
completed,
|
||||
under_review: underReview,
|
||||
waiting_for_documents_resend: waitingForDocumentsResend,
|
||||
all: counts.all ?? 0,
|
||||
completed: counts.COMPLETED ?? 0,
|
||||
under_review:
|
||||
(counts.WAITING_FOR_BLAME_EXPERT ?? 0) +
|
||||
(counts.WAITING_FOR_DAMAGE_EXPERT ?? 0) +
|
||||
(counts.EXPERT_REVIEWING ?? 0) +
|
||||
(counts.EXPERT_VALIDATING_FACTORS ?? 0),
|
||||
waiting_for_documents_resend: counts.WAITING_FOR_DOCUMENT_RESEND ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
46
src/helpers/unified-file-status.spec.ts
Normal file
46
src/helpers/unified-file-status.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
countUnifiedFileStatuses,
|
||||
resolveUnifiedFileStatus,
|
||||
} from "./unified-file-status";
|
||||
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
||||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||
|
||||
describe("resolveUnifiedFileStatus", () => {
|
||||
it("prefers claim completed over blame in progress", () => {
|
||||
expect(
|
||||
resolveUnifiedFileStatus({
|
||||
blameStatus: CaseStatus.WAITING_FOR_EXPERT,
|
||||
claimStatus: ClaimCaseStatus.COMPLETED,
|
||||
}),
|
||||
).toBe("COMPLETED");
|
||||
});
|
||||
|
||||
it("maps blame expert queue", () => {
|
||||
expect(
|
||||
resolveUnifiedFileStatus({
|
||||
blameStatus: CaseStatus.WAITING_FOR_EXPERT,
|
||||
}),
|
||||
).toBe("WAITING_FOR_BLAME_EXPERT");
|
||||
});
|
||||
|
||||
it("maps claim damage expert queue", () => {
|
||||
expect(
|
||||
resolveUnifiedFileStatus({
|
||||
blameStatus: CaseStatus.COMPLETED,
|
||||
claimStatus: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
|
||||
}),
|
||||
).toBe("WAITING_FOR_DAMAGE_EXPERT");
|
||||
});
|
||||
|
||||
it("counts merged entries", () => {
|
||||
const counts = countUnifiedFileStatuses([
|
||||
{ blameStatus: CaseStatus.OPEN },
|
||||
{
|
||||
blameStatus: CaseStatus.COMPLETED,
|
||||
claimStatus: ClaimCaseStatus.CAPTURING_PART_DAMAGES,
|
||||
},
|
||||
]);
|
||||
expect(counts.all).toBe(2);
|
||||
expect(counts.IN_PROGRESS).toBe(2);
|
||||
});
|
||||
});
|
||||
221
src/helpers/unified-file-status.ts
Normal file
221
src/helpers/unified-file-status.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
||||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||
|
||||
/** Calculated lifecycle status for a publicId (blame + claim combined). */
|
||||
export const UNIFIED_FILE_STATUS_KEYS = [
|
||||
"IN_PROGRESS",
|
||||
"WAITING_FOR_SECOND_PARTY",
|
||||
"WAITING_FOR_BLAME_EXPERT",
|
||||
"WAITING_FOR_SIGNATURES",
|
||||
"WAITING_FOR_DOCUMENT_RESEND",
|
||||
"WAITING_FOR_DAMAGE_EXPERT",
|
||||
"EXPERT_REVIEWING",
|
||||
"EXPERT_VALIDATING_FACTORS",
|
||||
"INSURER_REVIEW",
|
||||
"COMPLETED",
|
||||
"CANCELLED",
|
||||
"REJECTED",
|
||||
"STOPPED",
|
||||
] as const;
|
||||
|
||||
export type UnifiedFileStatusKey = (typeof UNIFIED_FILE_STATUS_KEYS)[number];
|
||||
|
||||
export type UnifiedFileStatusInput = {
|
||||
blameStatus?: string | null;
|
||||
claimStatus?: string | null;
|
||||
};
|
||||
|
||||
const CLAIM_USER_PHASE = new Set<string>([
|
||||
ClaimCaseStatus.CREATED,
|
||||
ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||||
ClaimCaseStatus.SELECTING_OTHER_PARTS,
|
||||
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||
ClaimCaseStatus.CAPTURING_PART_DAMAGES,
|
||||
]);
|
||||
|
||||
const INSURER_REVIEW_CLAIM_STATUSES = new Set<string>([
|
||||
ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
|
||||
ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN,
|
||||
ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING,
|
||||
ClaimCaseStatus.OWNER_REPAIR_FACTOR_UPLOAD_PENDING,
|
||||
]);
|
||||
|
||||
export type UnifiedFileStatusDefinition = {
|
||||
key: UnifiedFileStatusKey;
|
||||
labelEn: string;
|
||||
labelFa: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const UNIFIED_FILE_STATUS_CATALOG: UnifiedFileStatusDefinition[] = [
|
||||
{
|
||||
key: "IN_PROGRESS",
|
||||
labelEn: "In progress (user)",
|
||||
labelFa: "در حال تکمیل توسط کاربر",
|
||||
description:
|
||||
"Parties are still filling blame steps and/or the damaged party is completing the claim (parts, documents, capture).",
|
||||
},
|
||||
{
|
||||
key: "WAITING_FOR_SECOND_PARTY",
|
||||
labelEn: "Waiting for second party",
|
||||
labelFa: "در انتظار طرف دوم",
|
||||
description: "Blame file is waiting for the second party to join or complete steps.",
|
||||
},
|
||||
{
|
||||
key: "WAITING_FOR_BLAME_EXPERT",
|
||||
labelEn: "Waiting for blame expert",
|
||||
labelFa: "در انتظار کارشناس مقصر",
|
||||
description: "Blame case is in the expert disagreement / guilt-decision queue.",
|
||||
},
|
||||
{
|
||||
key: "WAITING_FOR_SIGNATURES",
|
||||
labelEn: "Waiting for signatures",
|
||||
labelFa: "در انتظار امضا",
|
||||
description: "Blame file is waiting for party signatures on the expert decision.",
|
||||
},
|
||||
{
|
||||
key: "WAITING_FOR_DOCUMENT_RESEND",
|
||||
labelEn: "Waiting for document resend",
|
||||
labelFa: "در انتظار ارسال مجدد مدارک",
|
||||
description:
|
||||
"Blame or claim is waiting for the user to resubmit documents or photos requested by an expert.",
|
||||
},
|
||||
{
|
||||
key: "WAITING_FOR_DAMAGE_EXPERT",
|
||||
labelEn: "Waiting for damage expert",
|
||||
labelFa: "در انتظار کارشناس خسارت",
|
||||
description: "Claim is in the damage-expert review queue (not yet locked).",
|
||||
},
|
||||
{
|
||||
key: "EXPERT_REVIEWING",
|
||||
labelEn: "Damage expert reviewing",
|
||||
labelFa: "در حال بررسی کارشناس خسارت",
|
||||
description: "A damage expert has locked the claim and is assessing damage.",
|
||||
},
|
||||
{
|
||||
key: "EXPERT_VALIDATING_FACTORS",
|
||||
labelEn: "Validating repair factors",
|
||||
labelFa: "اعتبارسنجی فاکتور تعمیر",
|
||||
description: "Damage expert is validating uploaded repair-factor documents.",
|
||||
},
|
||||
{
|
||||
key: "INSURER_REVIEW",
|
||||
labelEn: "Insurer / owner review",
|
||||
labelFa: "بررسی بیمهگر / مالک",
|
||||
description:
|
||||
"Expert pricing is done; owner signature, factor upload, or insurer approval is pending.",
|
||||
},
|
||||
{
|
||||
key: "COMPLETED",
|
||||
labelEn: "Completed",
|
||||
labelFa: "تکمیل شده",
|
||||
description: "Claim (and blame when present) is finished successfully.",
|
||||
},
|
||||
{
|
||||
key: "CANCELLED",
|
||||
labelEn: "Cancelled",
|
||||
labelFa: "لغو شده",
|
||||
description: "File was cancelled or auto-closed.",
|
||||
},
|
||||
{
|
||||
key: "REJECTED",
|
||||
labelEn: "Rejected",
|
||||
labelFa: "رد شده",
|
||||
description: "Claim was rejected.",
|
||||
},
|
||||
{
|
||||
key: "STOPPED",
|
||||
labelEn: "Stopped",
|
||||
labelFa: "متوقف شده",
|
||||
description: "Blame file was stopped.",
|
||||
},
|
||||
];
|
||||
|
||||
export function initialUnifiedFileStatusCounts(): Record<
|
||||
UnifiedFileStatusKey | "all",
|
||||
number
|
||||
> {
|
||||
const out: Record<string, number> = { all: 0 };
|
||||
for (const key of UNIFIED_FILE_STATUS_KEYS) {
|
||||
out[key] = 0;
|
||||
}
|
||||
return out as Record<UnifiedFileStatusKey | "all", number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single calculatable status for a shared publicId from raw blame + claim statuses.
|
||||
* Claim pipeline takes precedence once a claim exists and has left the user-only phase.
|
||||
*/
|
||||
export function resolveUnifiedFileStatus(
|
||||
input: UnifiedFileStatusInput,
|
||||
): UnifiedFileStatusKey {
|
||||
const blame = input.blameStatus?.trim() || undefined;
|
||||
const claim = input.claimStatus?.trim() || undefined;
|
||||
|
||||
if (claim === ClaimCaseStatus.COMPLETED) return "COMPLETED";
|
||||
if (claim === ClaimCaseStatus.REJECTED) return "REJECTED";
|
||||
if (
|
||||
claim === ClaimCaseStatus.CANCELLED ||
|
||||
blame === CaseStatus.CANCELLED ||
|
||||
blame === CaseStatus.AUTO_CLOSED
|
||||
) {
|
||||
return "CANCELLED";
|
||||
}
|
||||
if (blame === CaseStatus.STOPPED) return "STOPPED";
|
||||
|
||||
if (
|
||||
blame === CaseStatus.WAITING_FOR_DOCUMENT_RESEND ||
|
||||
claim === ClaimCaseStatus.WAITING_FOR_USER_RESEND
|
||||
) {
|
||||
return "WAITING_FOR_DOCUMENT_RESEND";
|
||||
}
|
||||
|
||||
if (claim === ClaimCaseStatus.EXPERT_REVIEWING) return "EXPERT_REVIEWING";
|
||||
if (claim === ClaimCaseStatus.EXPERT_VALIDATING_REPAIR_FACTORS) {
|
||||
return "EXPERT_VALIDATING_FACTORS";
|
||||
}
|
||||
if (claim && INSURER_REVIEW_CLAIM_STATUSES.has(claim)) {
|
||||
return "INSURER_REVIEW";
|
||||
}
|
||||
if (claim === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) {
|
||||
return "WAITING_FOR_DAMAGE_EXPERT";
|
||||
}
|
||||
|
||||
if (blame === CaseStatus.WAITING_FOR_EXPERT) return "WAITING_FOR_BLAME_EXPERT";
|
||||
if (blame === CaseStatus.WAITING_FOR_SIGNATURES) {
|
||||
return "WAITING_FOR_SIGNATURES";
|
||||
}
|
||||
if (blame === CaseStatus.WAITING_FOR_SECOND_PARTY) {
|
||||
return "WAITING_FOR_SECOND_PARTY";
|
||||
}
|
||||
|
||||
if (blame === CaseStatus.COMPLETED && !claim) return "COMPLETED";
|
||||
|
||||
if (
|
||||
blame === CaseStatus.OPEN ||
|
||||
(claim && CLAIM_USER_PHASE.has(claim)) ||
|
||||
(blame === CaseStatus.COMPLETED && claim && CLAIM_USER_PHASE.has(claim))
|
||||
) {
|
||||
return "IN_PROGRESS";
|
||||
}
|
||||
|
||||
if (claim) return "IN_PROGRESS";
|
||||
if (blame) return "IN_PROGRESS";
|
||||
return "IN_PROGRESS";
|
||||
}
|
||||
|
||||
export function countUnifiedFileStatuses(
|
||||
entries: UnifiedFileStatusInput[],
|
||||
): Record<UnifiedFileStatusKey | "all", number> {
|
||||
const buckets = initialUnifiedFileStatusCounts();
|
||||
for (const entry of entries) {
|
||||
const key = resolveUnifiedFileStatus(entry);
|
||||
buckets.all++;
|
||||
buckets[key]++;
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
export function getUnifiedFileStatusCatalog(): UnifiedFileStatusDefinition[] {
|
||||
return UNIFIED_FILE_STATUS_CATALOG;
|
||||
}
|
||||
@@ -8284,7 +8284,7 @@ export class RequestManagementService {
|
||||
inquiries: (req as any).inquiries ?? {},
|
||||
workflow: {
|
||||
currentStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||||
nextStep: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||||
nextStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||||
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
|
||||
locked: false,
|
||||
},
|
||||
@@ -8881,6 +8881,33 @@ export class RequestManagementService {
|
||||
}
|
||||
await (req as any).save();
|
||||
|
||||
await this.claimCaseDbService.findByIdAndUpdate(String(claim._id), {
|
||||
$set: {
|
||||
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
|
||||
claimStatus: ClaimStatus.PENDING,
|
||||
"workflow.currentStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||||
"workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
||||
},
|
||||
$push: {
|
||||
"workflow.completedSteps": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||||
history: {
|
||||
type: "STEP_COMPLETED",
|
||||
actor: {
|
||||
actorId: new Types.ObjectId(expert.sub),
|
||||
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
|
||||
actorType: "field_expert",
|
||||
},
|
||||
timestamp: new Date(),
|
||||
metadata: {
|
||||
stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||||
description:
|
||||
"V3 field workflow complete. Claim ready for damage expert review.",
|
||||
v3InPersonFlow: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
requestId: String(req._id),
|
||||
publicId: req.publicId,
|
||||
|
||||
Reference in New Issue
Block a user