forked from Yara724/api
YARA-885
This commit is contained in:
@@ -215,6 +215,9 @@ export class ClaimResendRequest {
|
||||
/** Damage expert profile when resend was requested (`damage-expert` collection). */
|
||||
@Prop({ type: ExpertProfileSnapshotSchema })
|
||||
expertProfileSnapshot?: ExpertProfileSnapshot;
|
||||
|
||||
@Prop({ type: Types.ObjectId })
|
||||
requestedByExpertId?: Types.ObjectId;
|
||||
}
|
||||
export const ClaimResendRequestSchema =
|
||||
SchemaFactory.createForClass(ClaimResendRequest);
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
blameCaseStatusToReportBucket,
|
||||
initialBlameExpertReportBuckets,
|
||||
} from "src/helpers/expert-panel-status-report";
|
||||
import {
|
||||
blameDocInExpertPortfolio,
|
||||
expertPortfolioFileIdsFromActivityEvents,
|
||||
objectIdsFromStringSet,
|
||||
} from "src/helpers/expert-portfolio";
|
||||
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
|
||||
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||||
import {
|
||||
@@ -222,37 +227,54 @@ export class ExpertBlameService {
|
||||
* Does NOT show requests decided by other experts.
|
||||
*/
|
||||
/**
|
||||
* V2: Count blame cases for this expert’s tenant, grouped for dashboard:
|
||||
* `IN_PROGRESS` = OPEN + WAITING_FOR_SECOND_PARTY; other {@link CaseStatus} keys unchanged.
|
||||
* V2: Count blame cases in this field expert’s portfolio, grouped for dashboard.
|
||||
* Portfolio = file-activity (checked/handled), lock, decision, or expert-initiated file.
|
||||
*/
|
||||
async getStatusReportBucketsV2(actor: any): Promise<Record<string, number>> {
|
||||
requireActorClientKey(actor);
|
||||
const rows = await this.blameRequestDbService.find({}, { lean: true });
|
||||
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 },
|
||||
);
|
||||
|
||||
const buckets = initialBlameExpertReportBuckets();
|
||||
const seen = new Set<string>();
|
||||
for (const doc of rows as Record<string, unknown>[]) {
|
||||
if (!this.blameDocIncludedInExpertTenantReport(doc, actor)) continue;
|
||||
const st = String(doc.status ?? "");
|
||||
const key = blameCaseStatusToReportBucket(st);
|
||||
const id = String(doc._id ?? "");
|
||||
if (seen.has(id)) continue;
|
||||
if (!blameDocInExpertPortfolio(doc, actor, activityFileIds)) continue;
|
||||
seen.add(id);
|
||||
const key = blameCaseStatusToReportBucket(String(doc.status ?? ""));
|
||||
buckets.all++;
|
||||
buckets[key] = (buckets[key] ?? 0) + 1;
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
/** Tenant + expert-initiated visibility (same as list, without queue filters). */
|
||||
private blameDocIncludedInExpertTenantReport(
|
||||
doc: Record<string, unknown>,
|
||||
actor: { sub: string; clientKey?: string },
|
||||
): boolean {
|
||||
if (!blameCaseAccessibleToExpert(doc, actor)) {
|
||||
return false;
|
||||
}
|
||||
if (doc.expertInitiated && doc.initiatedByFieldExpertId) {
|
||||
return String(doc.initiatedByFieldExpertId) === String(actor.sub);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async findAllV2(actor: any): Promise<AllRequestDtoRsV2> {
|
||||
try {
|
||||
requireActorClientKey(actor);
|
||||
@@ -1217,15 +1239,23 @@ export class ExpertBlameService {
|
||||
);
|
||||
}
|
||||
|
||||
const resendTenantId =
|
||||
String(request.parties?.[0]?.person?.clientId ?? "") ||
|
||||
String(request.parties?.[1]?.person?.clientId ?? "");
|
||||
await this.recordBlameExpertActivity({
|
||||
expertId: String(actorId),
|
||||
requestId: String(requestId),
|
||||
eventType: ExpertFileActivityType.UNCHECKED,
|
||||
tenantId:
|
||||
String(request.parties?.[0]?.person?.clientId ?? "") ||
|
||||
String(request.parties?.[1]?.person?.clientId ?? ""),
|
||||
tenantId: resendTenantId,
|
||||
idempotencyKey: `blame:${requestId}:unchecked:resend:${actorId}`,
|
||||
});
|
||||
await this.recordBlameExpertActivity({
|
||||
expertId: String(actorId),
|
||||
requestId: String(requestId),
|
||||
eventType: ExpertFileActivityType.HANDLED,
|
||||
tenantId: resendTenantId,
|
||||
idempotencyKey: `blame:${requestId}:handled:resend:${actorId}`,
|
||||
});
|
||||
|
||||
await this.requestManagementService.applyLinkedClaimsBlameResendStarted(
|
||||
requestId,
|
||||
|
||||
@@ -40,9 +40,9 @@ export class ExpertBlameV2Controller {
|
||||
|
||||
@Get("report/status-counts")
|
||||
@ApiOperation({
|
||||
summary: "Count blame cases by grouped status bucket (tenant)",
|
||||
summary: "Count blame cases by grouped status bucket (this field expert)",
|
||||
description:
|
||||
"IN_PROGRESS groups OPEN and WAITING_FOR_SECOND_PARTY. WAITING_FOR_EXPERT, WAITING_FOR_DOCUMENT_RESEND, WAITING_FOR_SIGNATURES, and terminal statuses are counted separately. Expert-initiated files count only for the initiating expert.",
|
||||
"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.",
|
||||
})
|
||||
async getStatusReportBucketsV2(@CurrentUser() actor: any) {
|
||||
try {
|
||||
|
||||
@@ -45,6 +45,11 @@ import {
|
||||
claimCaseStatusToReportBucket,
|
||||
initialClaimExpertReportBuckets,
|
||||
} from "src/helpers/expert-panel-status-report";
|
||||
import {
|
||||
claimDocInExpertPortfolio,
|
||||
expertPortfolioFileIdsFromActivityEvents,
|
||||
objectIdsFromStringSet,
|
||||
} from "src/helpers/expert-portfolio";
|
||||
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||||
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
|
||||
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||
@@ -2570,6 +2575,7 @@ export class ExpertClaimService {
|
||||
resendDescription: desc || undefined,
|
||||
resendDocuments: uniqueDocs,
|
||||
resendCarParts: normalizedParts,
|
||||
requestedByExpertId: new Types.ObjectId(actor.sub),
|
||||
...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }),
|
||||
},
|
||||
},
|
||||
@@ -2606,6 +2612,14 @@ export class ExpertClaimService {
|
||||
});
|
||||
}
|
||||
|
||||
await this.recordClaimExpertActivity({
|
||||
expertId: String(actor.sub),
|
||||
tenantId: this.claimActivityTenantId(claim, actor),
|
||||
claimId: String(claimRequestId),
|
||||
eventType: ExpertFileActivityType.HANDLED,
|
||||
idempotencyKey: `claim:${claimRequestId}:handled:resend:${actor.sub}`,
|
||||
});
|
||||
|
||||
return {
|
||||
claimRequestId,
|
||||
status: ClaimCaseStatus.WAITING_FOR_USER_RESEND,
|
||||
@@ -2972,18 +2986,55 @@ export class ExpertClaimService {
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Count claim cases for this damage expert’s tenant, grouped for dashboard:
|
||||
* `IN_PROGRESS` = user flow before submission complete (CREATED … CAPTURING_PART_DAMAGES);
|
||||
* other {@link ClaimCaseStatus} keys unchanged.
|
||||
* V2: Count claim cases in this damage expert’s portfolio, grouped for dashboard.
|
||||
* Includes files the expert locked, replied on, or has CHECKED/HANDLED in expertFileActivities.
|
||||
* Does not include the open tenant queue (`WAITING_FOR_DAMAGE_EXPERT`) unless this expert touched the file.
|
||||
*/
|
||||
async getStatusReportBucketsV2(actor: any): Promise<Record<string, number>> {
|
||||
const clientKey = requireActorClientKey(actor);
|
||||
const rows = await this.claimCaseDbService.find({}, { lean: true });
|
||||
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 buckets = initialClaimExpertReportBuckets();
|
||||
const seen = new Set<string>();
|
||||
for (const doc of rows as Record<string, unknown>[]) {
|
||||
if (!claimCaseTouchesClient(doc, clientKey)) continue;
|
||||
const st = String(doc.status ?? "");
|
||||
const key = claimCaseStatusToReportBucket(st);
|
||||
const id = String(doc._id ?? "");
|
||||
if (seen.has(id)) continue;
|
||||
if (
|
||||
!claimDocInExpertPortfolio(
|
||||
doc,
|
||||
expertId,
|
||||
activityFileIds,
|
||||
clientKey,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
seen.add(id);
|
||||
const key = claimCaseStatusToReportBucket(String(doc.status ?? ""));
|
||||
buckets.all++;
|
||||
buckets[key] = (buckets[key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
@@ -43,9 +43,9 @@ export class ExpertClaimV2Controller {
|
||||
|
||||
@Get("report/status-counts")
|
||||
@ApiOperation({
|
||||
summary: "Count claim cases by grouped status bucket (tenant)",
|
||||
summary: "Count claim cases by grouped status bucket (this damage expert)",
|
||||
description:
|
||||
"IN_PROGRESS groups user-phase statuses before submission is complete (CREATED through CAPTURING_PART_DAMAGES). Other keys match ClaimCaseStatus. Scoped to the insurer in the JWT.",
|
||||
"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.",
|
||||
})
|
||||
async getStatusReportBucketsV2(@CurrentUser() actor: any) {
|
||||
return await this.expertClaimService.getStatusReportBucketsV2(actor);
|
||||
|
||||
73
src/helpers/expert-portfolio.spec.ts
Normal file
73
src/helpers/expert-portfolio.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Types } from "mongoose";
|
||||
import {
|
||||
ExpertFileActivityType,
|
||||
} from "src/users/entities/schema/expert-file-activity.schema";
|
||||
import { expertPortfolioFileIdsFromActivityEvents } from "./expert-portfolio";
|
||||
|
||||
describe("expertPortfolioFileIdsFromActivityEvents", () => {
|
||||
const fid = new Types.ObjectId();
|
||||
|
||||
it("includes handled files after HANDLED", () => {
|
||||
const ids = expertPortfolioFileIdsFromActivityEvents([
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.CHECKED,
|
||||
occurredAt: new Date("2026-01-01T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.HANDLED,
|
||||
occurredAt: new Date("2026-01-01T11:00:00Z"),
|
||||
},
|
||||
]);
|
||||
expect(ids.has(String(fid))).toBe(true);
|
||||
});
|
||||
|
||||
it("includes currently checked files", () => {
|
||||
const ids = expertPortfolioFileIdsFromActivityEvents([
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.CHECKED,
|
||||
occurredAt: new Date("2026-01-01T10:00:00Z"),
|
||||
},
|
||||
]);
|
||||
expect(ids.has(String(fid))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps handled files after a later UNCHECKED (e.g. resend unlock)", () => {
|
||||
const ids = expertPortfolioFileIdsFromActivityEvents([
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.CHECKED,
|
||||
occurredAt: new Date("2026-01-01T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.HANDLED,
|
||||
occurredAt: new Date("2026-01-01T10:30:00Z"),
|
||||
},
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.UNCHECKED,
|
||||
occurredAt: new Date("2026-01-01T11:00:00Z"),
|
||||
},
|
||||
]);
|
||||
expect(ids.has(String(fid))).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes files after UNCHECKED without HANDLED", () => {
|
||||
const ids = expertPortfolioFileIdsFromActivityEvents([
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.CHECKED,
|
||||
occurredAt: new Date("2026-01-01T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.UNCHECKED,
|
||||
occurredAt: new Date("2026-01-01T10:30:00Z"),
|
||||
},
|
||||
]);
|
||||
expect(ids.has(String(fid))).toBe(false);
|
||||
});
|
||||
});
|
||||
113
src/helpers/expert-portfolio.ts
Normal file
113
src/helpers/expert-portfolio.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Types } from "mongoose";
|
||||
import { ExpertFileActivityType } from "src/users/entities/schema/expert-file-activity.schema";
|
||||
import {
|
||||
blameCaseAccessibleToExpert,
|
||||
claimCaseTouchesClient,
|
||||
} from "src/helpers/tenant-scope";
|
||||
|
||||
export interface ExpertFileActivityEventRow {
|
||||
fileId: Types.ObjectId | string;
|
||||
eventType: ExpertFileActivityType;
|
||||
occurredAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct file ids where the expert is actively checking (CHECKED, not UNCHECKED)
|
||||
* or has handled the file (HANDLED), replayed in chronological order.
|
||||
*/
|
||||
export function expertPortfolioFileIdsFromActivityEvents(
|
||||
events: ExpertFileActivityEventRow[],
|
||||
): Set<string> {
|
||||
const sorted = [...events].sort(
|
||||
(a, b) =>
|
||||
a.occurredAt.getTime() - b.occurredAt.getTime() ||
|
||||
String(a.fileId).localeCompare(String(b.fileId)),
|
||||
);
|
||||
const stateByFile = new Map<string, { checked: boolean; handled: boolean }>();
|
||||
|
||||
for (const e of sorted) {
|
||||
const fid = String(e.fileId);
|
||||
const prev = stateByFile.get(fid) ?? { checked: false, handled: false };
|
||||
if (e.eventType === ExpertFileActivityType.CHECKED) {
|
||||
if (!prev.handled) prev.checked = true;
|
||||
} else if (e.eventType === ExpertFileActivityType.UNCHECKED) {
|
||||
prev.checked = false;
|
||||
} else if (e.eventType === ExpertFileActivityType.HANDLED) {
|
||||
prev.handled = true;
|
||||
prev.checked = false;
|
||||
}
|
||||
stateByFile.set(fid, prev);
|
||||
}
|
||||
|
||||
const ids = new Set<string>();
|
||||
for (const [fid, st] of stateByFile) {
|
||||
if (st.handled || st.checked) ids.add(fid);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function objectIdsFromStringSet(ids: Set<string>): Types.ObjectId[] {
|
||||
return [...ids]
|
||||
.filter((id) => Types.ObjectId.isValid(id))
|
||||
.map((id) => new Types.ObjectId(id));
|
||||
}
|
||||
|
||||
/** Damage-expert claim portfolio: assigned (lock/check) or substantive work on the file. */
|
||||
export function claimDocInExpertPortfolio(
|
||||
doc: Record<string, unknown>,
|
||||
expertId: string,
|
||||
activityFileIds: Set<string>,
|
||||
clientKey: string,
|
||||
): boolean {
|
||||
if (!claimCaseTouchesClient(doc, clientKey)) return false;
|
||||
const id = String(doc._id ?? "");
|
||||
if (activityFileIds.has(id)) return true;
|
||||
|
||||
const lockedBy = (doc.workflow as { lockedBy?: { actorId?: unknown } } | undefined)
|
||||
?.lockedBy?.actorId;
|
||||
if (lockedBy != null && String(lockedBy) === String(expertId)) return true;
|
||||
|
||||
const evaluation = doc.evaluation as Record<string, unknown> | undefined;
|
||||
for (const key of ["damageExpertReply", "damageExpertReplyFinal"] as const) {
|
||||
const reply = evaluation?.[key] as
|
||||
| { actorDetail?: { actorId?: unknown } }
|
||||
| undefined;
|
||||
const actorId = reply?.actorDetail?.actorId;
|
||||
if (actorId != null && String(actorId) === String(expertId)) return true;
|
||||
}
|
||||
const resendBy = (
|
||||
evaluation?.damageExpertResend as { requestedByExpertId?: unknown } | undefined
|
||||
)?.requestedByExpertId;
|
||||
if (resendBy != null && String(resendBy) === String(expertId)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Field-expert blame portfolio: initiated, decided, locked, or file-activity touch. */
|
||||
export function blameDocInExpertPortfolio(
|
||||
doc: Record<string, unknown>,
|
||||
actor: { sub: string; clientKey?: string },
|
||||
activityFileIds: Set<string>,
|
||||
): boolean {
|
||||
if (!blameCaseAccessibleToExpert(doc, actor)) return false;
|
||||
const expertId = String(actor.sub);
|
||||
const id = String(doc._id ?? "");
|
||||
if (activityFileIds.has(id)) return true;
|
||||
|
||||
if (doc.expertInitiated && doc.initiatedByFieldExpertId) {
|
||||
return String(doc.initiatedByFieldExpertId) === expertId;
|
||||
}
|
||||
|
||||
const expert = doc.expert as Record<string, unknown> | undefined;
|
||||
const decidedBy = (expert?.decision as { decidedByExpertId?: unknown } | undefined)
|
||||
?.decidedByExpertId;
|
||||
if (decidedBy != null && String(decidedBy) === expertId) return true;
|
||||
|
||||
const lockedBy =
|
||||
(expert?.resend as { requestedByExpertId?: unknown } | undefined)
|
||||
?.requestedByExpertId ??
|
||||
(doc.workflow as { lockedBy?: { actorId?: unknown } } | undefined)?.lockedBy
|
||||
?.actorId;
|
||||
if (lockedBy != null && String(lockedBy) === expertId) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -71,6 +71,29 @@ export class ExpertFileActivityDbService {
|
||||
.lean();
|
||||
}
|
||||
|
||||
/** Activity rows for one expert and file kind (claim or blame panel). */
|
||||
async findByExpert(
|
||||
expertId: string | Types.ObjectId,
|
||||
fileType: ExpertFileKind,
|
||||
): Promise<
|
||||
Array<{
|
||||
fileId: Types.ObjectId;
|
||||
eventType: ExpertFileActivityType;
|
||||
occurredAt: Date;
|
||||
}>
|
||||
> {
|
||||
return this.activityModel
|
||||
.find(
|
||||
{
|
||||
expertId: new Types.ObjectId(String(expertId)),
|
||||
fileType,
|
||||
},
|
||||
{ fileId: 1, eventType: 1, occurredAt: 1 },
|
||||
)
|
||||
.sort({ occurredAt: 1, _id: 1 })
|
||||
.lean();
|
||||
}
|
||||
|
||||
/** All file-activity rows for an insurer tenant (blame + claim experts). */
|
||||
async findByTenant(tenantId: string | Types.ObjectId): Promise<
|
||||
Array<{
|
||||
|
||||
Reference in New Issue
Block a user