forked from Yara724/api
Merge pull request 'YARA-850, YARA-885' (#74) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#74
This commit is contained in:
@@ -215,6 +215,9 @@ export class ClaimResendRequest {
|
|||||||
/** Damage expert profile when resend was requested (`damage-expert` collection). */
|
/** Damage expert profile when resend was requested (`damage-expert` collection). */
|
||||||
@Prop({ type: ExpertProfileSnapshotSchema })
|
@Prop({ type: ExpertProfileSnapshotSchema })
|
||||||
expertProfileSnapshot?: ExpertProfileSnapshot;
|
expertProfileSnapshot?: ExpertProfileSnapshot;
|
||||||
|
|
||||||
|
@Prop({ type: Types.ObjectId })
|
||||||
|
requestedByExpertId?: Types.ObjectId;
|
||||||
}
|
}
|
||||||
export const ClaimResendRequestSchema =
|
export const ClaimResendRequestSchema =
|
||||||
SchemaFactory.createForClass(ClaimResendRequest);
|
SchemaFactory.createForClass(ClaimResendRequest);
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import {
|
|||||||
blameCaseStatusToReportBucket,
|
blameCaseStatusToReportBucket,
|
||||||
initialBlameExpertReportBuckets,
|
initialBlameExpertReportBuckets,
|
||||||
} from "src/helpers/expert-panel-status-report";
|
} 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 { 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 { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||||||
import {
|
import {
|
||||||
@@ -222,37 +227,54 @@ export class ExpertBlameService {
|
|||||||
* Does NOT show requests decided by other experts.
|
* Does NOT show requests decided by other experts.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* V2: Count blame cases for this expert’s tenant, grouped for dashboard:
|
* V2: Count blame cases in this field expert’s portfolio, grouped for dashboard.
|
||||||
* `IN_PROGRESS` = OPEN + WAITING_FOR_SECOND_PARTY; other {@link CaseStatus} keys unchanged.
|
* Portfolio = file-activity (checked/handled), lock, decision, or expert-initiated file.
|
||||||
*/
|
*/
|
||||||
async getStatusReportBucketsV2(actor: any): Promise<Record<string, number>> {
|
async getStatusReportBucketsV2(actor: any): Promise<Record<string, number>> {
|
||||||
requireActorClientKey(actor);
|
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 buckets = initialBlameExpertReportBuckets();
|
||||||
|
const seen = new Set<string>();
|
||||||
for (const doc of rows as Record<string, unknown>[]) {
|
for (const doc of rows as Record<string, unknown>[]) {
|
||||||
if (!this.blameDocIncludedInExpertTenantReport(doc, actor)) continue;
|
const id = String(doc._id ?? "");
|
||||||
const st = String(doc.status ?? "");
|
if (seen.has(id)) continue;
|
||||||
const key = blameCaseStatusToReportBucket(st);
|
if (!blameDocInExpertPortfolio(doc, actor, activityFileIds)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
const key = blameCaseStatusToReportBucket(String(doc.status ?? ""));
|
||||||
buckets.all++;
|
buckets.all++;
|
||||||
buckets[key] = (buckets[key] ?? 0) + 1;
|
buckets[key] = (buckets[key] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
return buckets;
|
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> {
|
async findAllV2(actor: any): Promise<AllRequestDtoRsV2> {
|
||||||
try {
|
try {
|
||||||
requireActorClientKey(actor);
|
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({
|
await this.recordBlameExpertActivity({
|
||||||
expertId: String(actorId),
|
expertId: String(actorId),
|
||||||
requestId: String(requestId),
|
requestId: String(requestId),
|
||||||
eventType: ExpertFileActivityType.UNCHECKED,
|
eventType: ExpertFileActivityType.UNCHECKED,
|
||||||
tenantId:
|
tenantId: resendTenantId,
|
||||||
String(request.parties?.[0]?.person?.clientId ?? "") ||
|
|
||||||
String(request.parties?.[1]?.person?.clientId ?? ""),
|
|
||||||
idempotencyKey: `blame:${requestId}:unchecked:resend:${actorId}`,
|
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(
|
await this.requestManagementService.applyLinkedClaimsBlameResendStarted(
|
||||||
requestId,
|
requestId,
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ export class ExpertBlameV2Controller {
|
|||||||
|
|
||||||
@Get("report/status-counts")
|
@Get("report/status-counts")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Count blame cases by grouped status bucket (tenant)",
|
summary: "Count blame cases by grouped status bucket (this field expert)",
|
||||||
description:
|
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) {
|
async getStatusReportBucketsV2(@CurrentUser() actor: any) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ export class ClaimDetailV2ResponseDto {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
index: { type: 'number' },
|
index: { type: 'number' },
|
||||||
|
partId: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Send this in PUT reply/submit parts[] — stable id (`id:{catalogId}` or `{side}|{name}`)',
|
||||||
|
},
|
||||||
id: { type: 'number', nullable: true },
|
id: { type: 'number', nullable: true },
|
||||||
name: { type: 'string' },
|
name: { type: 'string' },
|
||||||
side: { type: 'string' },
|
side: { type: 'string' },
|
||||||
@@ -104,6 +109,7 @@ export class ClaimDetailV2ResponseDto {
|
|||||||
})
|
})
|
||||||
damagedParts?: Array<{
|
damagedParts?: Array<{
|
||||||
index: number;
|
index: number;
|
||||||
|
partId: string;
|
||||||
id?: number | null;
|
id?: number | null;
|
||||||
name: string;
|
name: string;
|
||||||
side: string;
|
side: string;
|
||||||
|
|||||||
@@ -7,59 +7,12 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsInt,
|
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
|
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
|
||||||
import { DamagedPartItem } from 'src/claim-request-management/dto/capture-requirements-v2.dto';
|
import { DamagedPartItem } from 'src/claim-request-management/dto/capture-requirements-v2.dto';
|
||||||
import { DaghiOption } from 'src/Types&Enums/claim-request-management/daghi-option.enum';
|
import { DaghiOption } from 'src/Types&Enums/claim-request-management/daghi-option.enum';
|
||||||
|
|
||||||
/**
|
|
||||||
* Damage line part reference — same unified shape as `damage.selectedParts` / catalog rows.
|
|
||||||
* Send either `{ name, side, label_fa?, id?, catalogKey? }` or legacy `{ part, side }`
|
|
||||||
* (e.g. part `backFender`, side `left`); the API persists canonical `{ id, name, side, label_fa, catalogKey? }`.
|
|
||||||
*/
|
|
||||||
export class ExpertReplyCarPartDamageV2Dto {
|
|
||||||
@ApiPropertyOptional({ description: 'Catalog id when known' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsInt()
|
|
||||||
id?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Side-agnostic catalog segment, e.g. backfender',
|
|
||||||
example: 'backfender',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
name?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'left' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
side?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description:
|
|
||||||
'Farsi label; optional when server can resolve from catalog + vehicle.carType',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
label_fa?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'left_backfender' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
catalogKey?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Legacy expert UI: camelCase segment with `side`, e.g. backFender',
|
|
||||||
example: 'backFender',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
part?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Same shape as V1 {@link PartsList} `daghi` (damage expert reply). */
|
/** Same shape as V1 {@link PartsList} `daghi` (damage expert reply). */
|
||||||
export class DaghiDetailsV2Dto {
|
export class DaghiDetailsV2Dto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
@@ -86,16 +39,15 @@ export class DaghiDetailsV2Dto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class PartPricingV2Dto {
|
export class PartPricingV2Dto {
|
||||||
@ApiProperty({ example: 'part-001' })
|
@ApiProperty({
|
||||||
|
example: '12',
|
||||||
|
description:
|
||||||
|
'Stable part id from GET claim detail `damagedParts[].partId` or `selectedParts` identity (`id:{catalogId}` or `{side}|{name}`). Required.',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
partId: string;
|
partId: string;
|
||||||
|
|
||||||
@ApiProperty({ type: ExpertReplyCarPartDamageV2Dto })
|
|
||||||
@ValidateNested()
|
|
||||||
@Type(() => ExpertReplyCarPartDamageV2Dto)
|
|
||||||
carPartDamage: ExpertReplyCarPartDamageV2Dto;
|
|
||||||
|
|
||||||
@ApiProperty({ example: 'Minor' })
|
@ApiProperty({ example: 'Minor' })
|
||||||
@IsString()
|
@IsString()
|
||||||
typeOfDamage: string;
|
typeOfDamage: string;
|
||||||
@@ -135,7 +87,7 @@ export class SubmitExpertReplyV2Dto {
|
|||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: [PartPricingV2Dto],
|
type: [PartPricingV2Dto],
|
||||||
description:
|
description:
|
||||||
"Each part: manual pricing and `daghi`; set `factorNeeded` only where a repair factor upload is required from the owner.",
|
"One line per damaged part (`partId` from claim detail `damagedParts[]`), plus pricing, `daghi`, and `factorNeeded`.",
|
||||||
})
|
})
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ import {
|
|||||||
claimCaseStatusToReportBucket,
|
claimCaseStatusToReportBucket,
|
||||||
initialClaimExpertReportBuckets,
|
initialClaimExpertReportBuckets,
|
||||||
} from "src/helpers/expert-panel-status-report";
|
} 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 { 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 { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
|
||||||
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||||
@@ -84,6 +89,7 @@ import {
|
|||||||
normalizeDamageSelectedParts,
|
normalizeDamageSelectedParts,
|
||||||
partIdentityKey,
|
partIdentityKey,
|
||||||
partIdentityKeyFromCarPartDamage,
|
partIdentityKeyFromCarPartDamage,
|
||||||
|
resolveSelectedPartByPartId,
|
||||||
} from "src/helpers/outer-damage-parts";
|
} from "src/helpers/outer-damage-parts";
|
||||||
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||||
import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
|
import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
|
||||||
@@ -2569,6 +2575,7 @@ export class ExpertClaimService {
|
|||||||
resendDescription: desc || undefined,
|
resendDescription: desc || undefined,
|
||||||
resendDocuments: uniqueDocs,
|
resendDocuments: uniqueDocs,
|
||||||
resendCarParts: normalizedParts,
|
resendCarParts: normalizedParts,
|
||||||
|
requestedByExpertId: new Types.ObjectId(actor.sub),
|
||||||
...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }),
|
...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2605,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 {
|
return {
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
status: ClaimCaseStatus.WAITING_FOR_USER_RESEND,
|
status: ClaimCaseStatus.WAITING_FOR_USER_RESEND,
|
||||||
@@ -2698,13 +2713,29 @@ export class ExpertClaimService {
|
|||||||
const processedParts = daghiNormalized.map((p) => {
|
const processedParts = daghiNormalized.map((p) => {
|
||||||
let carPartDamage: Record<string, unknown>;
|
let carPartDamage: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
|
const selected = resolveSelectedPartByPartId(
|
||||||
|
p.partId,
|
||||||
|
ownerSelectedParts,
|
||||||
|
);
|
||||||
|
if (!selected) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Unknown partId "${p.partId}". Use partId from claim detail damagedParts (after editing parts on the claim).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
carPartDamage = normalizeCarPartDamageForExpertReply(
|
carPartDamage = normalizeCarPartDamageForExpertReply(
|
||||||
p.carPartDamage as unknown,
|
{
|
||||||
|
id: selected.id,
|
||||||
|
name: selected.name,
|
||||||
|
side: selected.side,
|
||||||
|
label_fa: selected.label_fa,
|
||||||
|
...(selected.catalogKey ? { catalogKey: selected.catalogKey } : {}),
|
||||||
|
},
|
||||||
carTypeSubmit,
|
carTypeSubmit,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof BadRequestException) throw err;
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Invalid carPartDamage for part ${p.partId}: ${
|
`Invalid part ${p.partId}: ${
|
||||||
err instanceof Error ? err.message : String(err)
|
err instanceof Error ? err.message : String(err)
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
@@ -2713,7 +2744,7 @@ export class ExpertClaimService {
|
|||||||
const identityKey = partIdentityKeyFromCarPartDamage(carPartDamage);
|
const identityKey = partIdentityKeyFromCarPartDamage(carPartDamage);
|
||||||
if (!identityKey) {
|
if (!identityKey) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Invalid carPartDamage for part ${p.partId}: cannot derive a stable identity (need a catalog id or both name and side).`,
|
`Invalid part ${p.partId}: cannot derive a stable identity (need a catalog id or both name and side).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2955,18 +2986,55 @@ export class ExpertClaimService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2: Count claim cases for this damage expert’s tenant, grouped for dashboard:
|
* V2: Count claim cases in this damage expert’s portfolio, grouped for dashboard.
|
||||||
* `IN_PROGRESS` = user flow before submission complete (CREATED … CAPTURING_PART_DAMAGES);
|
* Includes files the expert locked, replied on, or has CHECKED/HANDLED in expertFileActivities.
|
||||||
* other {@link ClaimCaseStatus} keys unchanged.
|
* 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>> {
|
async getStatusReportBucketsV2(actor: any): Promise<Record<string, number>> {
|
||||||
const clientKey = requireActorClientKey(actor);
|
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 buckets = initialClaimExpertReportBuckets();
|
||||||
|
const seen = new Set<string>();
|
||||||
for (const doc of rows as Record<string, unknown>[]) {
|
for (const doc of rows as Record<string, unknown>[]) {
|
||||||
if (!claimCaseTouchesClient(doc, clientKey)) continue;
|
const id = String(doc._id ?? "");
|
||||||
const st = String(doc.status ?? "");
|
if (seen.has(id)) continue;
|
||||||
const key = claimCaseStatusToReportBucket(st);
|
if (
|
||||||
|
!claimDocInExpertPortfolio(
|
||||||
|
doc,
|
||||||
|
expertId,
|
||||||
|
activityFileIds,
|
||||||
|
clientKey,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(id);
|
||||||
|
const key = claimCaseStatusToReportBucket(String(doc.status ?? ""));
|
||||||
buckets.all++;
|
buckets.all++;
|
||||||
buckets[key] = (buckets[key] ?? 0) + 1;
|
buckets[key] = (buckets[key] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
@@ -3537,6 +3605,7 @@ export class ExpertClaimService {
|
|||||||
) as { url?: string; path?: string } | undefined;
|
) as { url?: string; path?: string } | undefined;
|
||||||
return {
|
return {
|
||||||
index,
|
index,
|
||||||
|
partId: partIdentityKey(sp),
|
||||||
id: sp.id,
|
id: sp.id,
|
||||||
name: sp.name,
|
name: sp.name,
|
||||||
side: sp.side,
|
side: sp.side,
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ export class ExpertClaimV2Controller {
|
|||||||
|
|
||||||
@Get("report/status-counts")
|
@Get("report/status-counts")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Count claim cases by grouped status bucket (tenant)",
|
summary: "Count claim cases by grouped status bucket (this damage expert)",
|
||||||
description:
|
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) {
|
async getStatusReportBucketsV2(@CurrentUser() actor: any) {
|
||||||
return await this.expertClaimService.getStatusReportBucketsV2(actor);
|
return await this.expertClaimService.getStatusReportBucketsV2(actor);
|
||||||
@@ -132,7 +132,7 @@ export class ExpertClaimV2Controller {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Submit expert damage assessment reply",
|
summary: "Submit expert damage assessment reply",
|
||||||
description:
|
description:
|
||||||
"**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each part needs `daghi` (V1 rules) and may set `factorNeeded` (repair factor file required from owner) or priced lines only. **Cap:** sum of line `totalPayment` values ≤ 53,000,000 (same limit as factor-validation totals across priced + factor lines). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" +
|
"**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each `parts[]` line needs `partId` (from GET claim detail `damagedParts[].partId`), plus pricing, `daghi`, and optional `factorNeeded`. **Cap:** sum of line `totalPayment` values ≤ 53,000,000 (same limit as factor-validation totals across priced + factor lines). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" +
|
||||||
"**Frontend routing by `ClaimCaseStatus` (`status`):**\n" +
|
"**Frontend routing by `ClaimCaseStatus` (`status`):**\n" +
|
||||||
"- **All parts `factorNeeded`:** `OWNER_REPAIR_FACTOR_UPLOAD_PENDING`, `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`, `workflow.nextStep=EXPERT_COST_EVALUATION` → owner uploads all factors; then `status` becomes **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `currentStep=EXPERT_COST_EVALUATION` for expert **validate-factors**.\n" +
|
"- **All parts `factorNeeded`:** `OWNER_REPAIR_FACTOR_UPLOAD_PENDING`, `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`, `workflow.nextStep=EXPERT_COST_EVALUATION` → owner uploads all factors; then `status` becomes **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `currentStep=EXPERT_COST_EVALUATION` for expert **validate-factors**.\n" +
|
||||||
"- **Mixed (some priced, some factorNeeded):** `INSURER_REVIEW_MIXED_FACTORS_PENDING`, `claimStatus=NEEDS_REVISION`, `currentStep=INSURER_REVIEW`, `nextStep=OWNER_UPLOAD_FACTOR_DOCUMENTS` → owner must call **owner-insurer-approval/sign** first (priced-line acceptance); `currentStep` then moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` (same case `status` until factors are done).\n" +
|
"- **Mixed (some priced, some factorNeeded):** `INSURER_REVIEW_MIXED_FACTORS_PENDING`, `claimStatus=NEEDS_REVISION`, `currentStep=INSURER_REVIEW`, `nextStep=OWNER_UPLOAD_FACTOR_DOCUMENTS` → owner must call **owner-insurer-approval/sign** first (priced-line acceptance); `currentStep` then moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` (same case `status` until factors are done).\n" +
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
37
src/helpers/outer-damage-parts-resolve.spec.ts
Normal file
37
src/helpers/outer-damage-parts-resolve.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
partIdentityKey,
|
||||||
|
resolveSelectedPartByPartId,
|
||||||
|
type DamageSelectedPartV2,
|
||||||
|
} from "./outer-damage-parts";
|
||||||
|
|
||||||
|
describe("resolveSelectedPartByPartId", () => {
|
||||||
|
const selected: DamageSelectedPartV2[] = [
|
||||||
|
{
|
||||||
|
id: 12,
|
||||||
|
name: "backfender",
|
||||||
|
side: "left",
|
||||||
|
label_fa: "گلگیر",
|
||||||
|
catalogKey: "left_backfender",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: null,
|
||||||
|
name: "hood",
|
||||||
|
side: "front",
|
||||||
|
label_fa: "کاپوت",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("resolves by partIdentityKey", () => {
|
||||||
|
expect(
|
||||||
|
resolveSelectedPartByPartId(partIdentityKey(selected[0]), selected),
|
||||||
|
).toBe(selected[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves by id:N catalog prefix", () => {
|
||||||
|
expect(resolveSelectedPartByPartId("id:12", selected)).toBe(selected[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves by index:N", () => {
|
||||||
|
expect(resolveSelectedPartByPartId("index:1", selected)).toBe(selected[1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -298,6 +298,48 @@ export function partIdentityKey(p: DamageSelectedPartV2): string {
|
|||||||
return `${p.side}|${p.name}`;
|
return `${p.side}|${p.name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a client `partId` from claim detail / expert reply to a selected part row.
|
||||||
|
* Accepts `partIdentityKey` values (`id:12`, `left|backfender`), `id:12`, catalog id
|
||||||
|
* when unique, or `index:N` for the Nth entry in `selectedParts`.
|
||||||
|
*/
|
||||||
|
export function resolveSelectedPartByPartId(
|
||||||
|
partId: string,
|
||||||
|
selected: DamageSelectedPartV2[],
|
||||||
|
): DamageSelectedPartV2 | undefined {
|
||||||
|
const raw = String(partId ?? "").trim();
|
||||||
|
if (!raw || !selected.length) return undefined;
|
||||||
|
|
||||||
|
for (const p of selected) {
|
||||||
|
if (partIdentityKey(p) === raw) return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idColon = raw.match(/^id:(\d+)$/i);
|
||||||
|
if (idColon) {
|
||||||
|
const id = Number(idColon[1]);
|
||||||
|
return selected.find((p) => p.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexColon = raw.match(/^index:(\d+)$/i);
|
||||||
|
if (indexColon) {
|
||||||
|
const i = Number(indexColon[1]);
|
||||||
|
if (Number.isInteger(i) && i >= 0 && i < selected.length) {
|
||||||
|
return selected[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d+$/.test(raw)) {
|
||||||
|
const n = Number(raw);
|
||||||
|
const byCatalogId = selected.filter((p) => p.id === n);
|
||||||
|
if (byCatalogId.length === 1) return byCatalogId[0];
|
||||||
|
if (Number.isInteger(n) && n >= 0 && n < selected.length) {
|
||||||
|
return selected[n];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same canonical identity as `partIdentityKey`, but derived directly from a
|
* Same canonical identity as `partIdentityKey`, but derived directly from a
|
||||||
* unified `carPartDamage` snapshot (the shape we persist on
|
* unified `carPartDamage` snapshot (the shape we persist on
|
||||||
|
|||||||
@@ -71,6 +71,29 @@ export class ExpertFileActivityDbService {
|
|||||||
.lean();
|
.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). */
|
/** All file-activity rows for an insurer tenant (blame + claim experts). */
|
||||||
async findByTenant(tenantId: string | Types.ObjectId): Promise<
|
async findByTenant(tenantId: string | Types.ObjectId): Promise<
|
||||||
Array<{
|
Array<{
|
||||||
|
|||||||
Reference in New Issue
Block a user