forked from Yara724/api
merge upstream
This commit is contained in:
@@ -3985,7 +3985,10 @@ export class ClaimRequestManagementService {
|
|||||||
...(currentUserParty.person?.clientId
|
...(currentUserParty.person?.clientId
|
||||||
? {
|
? {
|
||||||
clientId: new Types.ObjectId(
|
clientId: new Types.ObjectId(
|
||||||
currentUserParty.person.clientId as any,
|
String(blameRequest?.expert?.decision?.guiltyPartyId) ===
|
||||||
|
String(blameRequest?.parties[0]?.person?.userId)
|
||||||
|
? blameRequest?.parties[0]?.person?.clientId
|
||||||
|
: blameRequest?.parties[1]?.person?.clientId,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
@@ -4651,11 +4654,8 @@ export class ClaimRequestManagementService {
|
|||||||
|
|
||||||
const updatePayload: any = {
|
const updatePayload: any = {
|
||||||
"damage.otherParts": otherParts.length > 0 ? otherParts : undefined,
|
"damage.otherParts": otherParts.length > 0 ? otherParts : undefined,
|
||||||
|
"money.sheba": shebaNumber,
|
||||||
...(step !== ClaimWorkflowStep.USER_EXPERT_RESEND && {
|
|
||||||
"money.sheba": "",
|
|
||||||
"money.nationalCodeOfInsurer": nationalCode,
|
"money.nationalCodeOfInsurer": nationalCode,
|
||||||
}),
|
|
||||||
|
|
||||||
status: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
status: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||||||
"workflow.currentStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
"workflow.currentStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||||||
|
|||||||
@@ -303,66 +303,58 @@ export class ExpertBlameService {
|
|||||||
requireActorClientKey(actor);
|
requireActorClientKey(actor);
|
||||||
const expertId = actor.sub;
|
const expertId = actor.sub;
|
||||||
|
|
||||||
// Fetch all DISAGREEMENT cases
|
|
||||||
const allCases = await this.blameRequestDbService.find(
|
const allCases = await this.blameRequestDbService.find(
|
||||||
{
|
{ blameStatus: BlameStatus.DISAGREEMENT },
|
||||||
blameStatus: BlameStatus.DISAGREEMENT,
|
|
||||||
},
|
|
||||||
{ lean: true },
|
{ lean: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter to show only:
|
|
||||||
// 1. Same insurance tenant (party clientId or expert-initiated by this actor)
|
|
||||||
// 2. Fresh requests (WAITING_FOR_EXPERT and no decision)
|
|
||||||
// 3. Requests decided by current expert
|
|
||||||
// 4. Expert-initiated: only the initiating field expert sees them
|
|
||||||
const visibleCases = (allCases as Record<string, unknown>[]).filter(
|
const visibleCases = (allCases as Record<string, unknown>[]).filter(
|
||||||
(doc) => {
|
(doc) => {
|
||||||
if (!blameCaseAccessibleToExpert(doc, actor)) {
|
// Always filter by tenant first
|
||||||
return false;
|
if (!blameCaseAccessibleToExpert(doc, actor)) return false;
|
||||||
}
|
|
||||||
|
|
||||||
const expertInitiated = doc.expertInitiated === true;
|
|
||||||
const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
|
|
||||||
|
|
||||||
if (expertInitiated && initiatedByFieldExpertId) {
|
|
||||||
if (String(initiatedByFieldExpertId) !== expertId) {
|
|
||||||
return false; // Only the initiating field expert can see this file
|
|
||||||
}
|
|
||||||
return true; // Initiating expert can see their expert-initiated file
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = doc.status as string;
|
const status = doc.status as string;
|
||||||
const decision = doc.expert as any;
|
const decision = (doc.expert as any)?.decision;
|
||||||
const decidedByExpertId = decision?.decision?.decidedByExpertId;
|
const decidedByExpertId = decision?.decidedByExpertId
|
||||||
const hasDecision = !!decision?.decision;
|
? String(decision.decidedByExpertId)
|
||||||
|
: null;
|
||||||
|
const lockedById = String(
|
||||||
|
(doc.workflow as any)?.lockedBy?.actorId ?? "",
|
||||||
|
);
|
||||||
|
const lockEnforced =
|
||||||
|
(doc.workflow as any)?.locked &&
|
||||||
|
this.isBlameV2WorkflowLockCurrentlyEnforced(doc as any);
|
||||||
|
|
||||||
// Fresh request (no decision yet)
|
// Expert-initiated files: only the initiating expert sees them
|
||||||
if (status === CaseStatus.WAITING_FOR_EXPERT && !hasDecision) {
|
if (doc.expertInitiated === true && doc.initiatedByFieldExpertId) {
|
||||||
return true;
|
return String(doc.initiatedByFieldExpertId) === expertId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request decided by current expert
|
// Bucket 1: Available — waiting, no decision, no active lock by someone else
|
||||||
if (decidedByExpertId && String(decidedByExpertId) === expertId) {
|
const isAvailable =
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Locked by current expert but no decision yet
|
|
||||||
const lockedBy =
|
|
||||||
decision?.resend?.requestedByExpertId ||
|
|
||||||
(doc.workflow as any)?.lockedBy?.actorId;
|
|
||||||
if (
|
|
||||||
status === CaseStatus.WAITING_FOR_EXPERT &&
|
status === CaseStatus.WAITING_FOR_EXPERT &&
|
||||||
lockedBy &&
|
!decidedByExpertId &&
|
||||||
String(lockedBy) === expertId
|
(!lockEnforced || lockedById === expertId);
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
// Bucket 2: Mine — decided by me
|
||||||
|
const isDecidedByMe =
|
||||||
|
!!decidedByExpertId && decidedByExpertId === expertId;
|
||||||
|
|
||||||
|
// Bucket 3: Mine — currently locked/assigned to me (in progress)
|
||||||
|
const isLockedByMe = lockEnforced && lockedById === expertId;
|
||||||
|
|
||||||
|
// Bucket 4: Mine — persistently assigned to me for review
|
||||||
|
const assignedForReviewById = String(
|
||||||
|
(doc.workflow as any)?.assignedForReviewBy?.actorId ?? "",
|
||||||
|
);
|
||||||
|
const isAssignedToMe =
|
||||||
|
!!assignedForReviewById && assignedForReviewById === expertId;
|
||||||
|
|
||||||
|
return isAvailable || isDecidedByMe || isLockedByMe || isAssignedToMe;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Reconcile stale locks in-memory (same as before)
|
||||||
const staleIds = new Set<string>();
|
const staleIds = new Set<string>();
|
||||||
for (const doc of visibleCases) {
|
for (const doc of visibleCases) {
|
||||||
const w = doc.workflow as Record<string, unknown> | undefined;
|
const w = doc.workflow as Record<string, unknown> | undefined;
|
||||||
@@ -564,6 +556,7 @@ export class ExpertBlameService {
|
|||||||
|
|
||||||
const assigneeToPersist = blameWorkflowAssigneeToPersistOnLockExpiry(
|
const assigneeToPersist = blameWorkflowAssigneeToPersistOnLockExpiry(
|
||||||
request.workflow,
|
request.workflow,
|
||||||
|
request,
|
||||||
);
|
);
|
||||||
const expireSet: Record<string, unknown> = { "workflow.locked": false };
|
const expireSet: Record<string, unknown> = { "workflow.locked": false };
|
||||||
if (assigneeToPersist) {
|
if (assigneeToPersist) {
|
||||||
@@ -854,75 +847,79 @@ export class ExpertBlameService {
|
|||||||
actor: any,
|
actor: any,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
// requireActorClientKey(actor);
|
|
||||||
const actorId = actor.sub;
|
const actorId = actor.sub;
|
||||||
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||||
|
|
||||||
const doc =
|
const doc =
|
||||||
await this.blameRequestDbService.findByIdWithoutHistory(requestId);
|
await this.blameRequestDbService.findByIdWithoutHistory(requestId);
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
throw new NotFoundException("Request not found");
|
throw new NotFoundException("Request not found");
|
||||||
}
|
}
|
||||||
// assertBlameCaseForExpertTenant(doc, actor);
|
|
||||||
const type = doc.type as string;
|
// Tenant check
|
||||||
if (type === BlameRequestType.CAR_BODY) {
|
assertBlameCaseForExpertTenant(doc, actor);
|
||||||
|
|
||||||
|
if (doc.type === BlameRequestType.CAR_BODY) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"CAR_BODY type requests are automatically handled and do not require expert review.",
|
"CAR_BODY type requests are automatically handled and do not require expert review.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Access control
|
// Expert-initiated: only the initiating expert
|
||||||
const expertInitiated = doc.expertInitiated === true;
|
|
||||||
const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
|
|
||||||
if (
|
if (
|
||||||
expertInitiated &&
|
doc.expertInitiated === true &&
|
||||||
initiatedByFieldExpertId &&
|
doc.initiatedByFieldExpertId &&
|
||||||
String(initiatedByFieldExpertId) !== actorId
|
String(doc.initiatedByFieldExpertId) !== actorId
|
||||||
) {
|
) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"Only the field expert who created this file can view and review it.",
|
"Only the field expert who created this file can view and review it.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isBlameV2WorkflowLockCurrentlyEnforced(doc)) {
|
const decision = (doc.expert as any)?.decision;
|
||||||
const w = doc.workflow as
|
const decidedByExpertId = decision?.decidedByExpertId
|
||||||
| { lockedBy?: { actorId?: unknown } }
|
? String(decision.decidedByExpertId)
|
||||||
| undefined;
|
: null;
|
||||||
const lockerId = String(w?.lockedBy?.actorId ?? "");
|
const lockedById = String((doc.workflow as any)?.lockedBy?.actorId ?? "");
|
||||||
if (lockerId && lockerId !== actorId) {
|
const lockEnforced =
|
||||||
|
(doc.workflow as any)?.locked &&
|
||||||
|
this.isBlameV2WorkflowLockCurrentlyEnforced(doc);
|
||||||
|
const assignedForReviewById = String(
|
||||||
|
(doc.workflow as any)?.assignedForReviewBy?.actorId ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Access gates — must satisfy at least one bucket
|
||||||
|
const isAvailable =
|
||||||
|
doc.status === CaseStatus.WAITING_FOR_EXPERT && !decidedByExpertId;
|
||||||
|
const isDecidedByMe = decidedByExpertId === actorId;
|
||||||
|
const isLockedByMe = lockEnforced && lockedById === actorId;
|
||||||
|
const isAssignedToMe =
|
||||||
|
!!assignedForReviewById && assignedForReviewById === actorId;
|
||||||
|
|
||||||
|
if (!isAvailable && !isDecidedByMe && !isLockedByMe && !isAssignedToMe) {
|
||||||
|
// Give a specific reason if possible
|
||||||
|
if (lockEnforced && lockedById && lockedById !== actorId) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"This request is locked by another expert.",
|
"This request is locked by another expert.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
if (decidedByExpertId && decidedByExpertId !== actorId) {
|
||||||
|
|
||||||
const decision = (doc.expert as any)?.decision;
|
|
||||||
const decidedByExpertId = decision?.decidedByExpertId;
|
|
||||||
if (decidedByExpertId && String(decidedByExpertId) !== actorId) {
|
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"You do not have permission to view this request. It has been handled by another expert.",
|
"You do not have permission to view this request. It has been handled by another expert.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"You do not have permission to view this request.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build evidence URLs
|
||||||
const parties = Array.isArray(doc.parties) ? doc.parties : [];
|
const parties = Array.isArray(doc.parties) ? doc.parties : [];
|
||||||
|
for (const party of parties as Array<{
|
||||||
const typedParties = parties as Array<{
|
evidence?: Record<string, unknown>;
|
||||||
vehicle?: {
|
}>) {
|
||||||
inquiry?: {
|
|
||||||
mapped?: any;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
evidence?: {
|
|
||||||
videoId?: string | number;
|
|
||||||
voices?: (string | number)[];
|
|
||||||
videoUrl?: string;
|
|
||||||
voiceUrls?: string[];
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
|
|
||||||
// Evidence (videos + voices)
|
|
||||||
for (const party of typedParties) {
|
|
||||||
if (!party.evidence) continue;
|
if (!party.evidence) continue;
|
||||||
const evidence = party.evidence as Record<string, unknown>;
|
const evidence = party.evidence;
|
||||||
|
|
||||||
if (evidence.videoId) {
|
if (evidence.videoId) {
|
||||||
const videoDoc = await this.blameVideoDbService.findById(
|
const videoDoc = await this.blameVideoDbService.findById(
|
||||||
@@ -931,7 +928,7 @@ export class ExpertBlameService {
|
|||||||
if (videoDoc?.path) evidence.videoUrl = buildFileLink(videoDoc.path);
|
if (videoDoc?.path) evidence.videoUrl = buildFileLink(videoDoc.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evidence.voices && Array.isArray(evidence.voices)) {
|
if (Array.isArray(evidence.voices)) {
|
||||||
const voiceUrls: string[] = [];
|
const voiceUrls: string[] = [];
|
||||||
for (const voiceId of evidence.voices) {
|
for (const voiceId of evidence.voices) {
|
||||||
const voiceDoc = await this.blameVoiceDbService.findById(
|
const voiceDoc = await this.blameVoiceDbService.findById(
|
||||||
@@ -950,31 +947,31 @@ export class ExpertBlameService {
|
|||||||
const updatedAt = doc.updatedAt
|
const updatedAt = doc.updatedAt
|
||||||
? new Date(doc.updatedAt as string | number)
|
? new Date(doc.updatedAt as string | number)
|
||||||
: new Date();
|
: new Date();
|
||||||
|
|
||||||
const [createdDate, createdTime] = toJalaliDateAndTime(createdAt);
|
const [createdDate, createdTime] = toJalaliDateAndTime(createdAt);
|
||||||
const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt);
|
const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt);
|
||||||
|
|
||||||
doc.createdAtFormatted = `${createdDate} ${createdTime}`;
|
doc.createdAtFormatted = `${createdDate} ${createdTime}`;
|
||||||
doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`;
|
doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`;
|
||||||
|
|
||||||
// Omit heavy SandHub inquiry blob from expert response (keep other vehicle fields)
|
// Strip heavy SandHub inquiry blob
|
||||||
for (const party of typedParties) {
|
for (const party of parties as Array<{
|
||||||
const veh = party?.vehicle as Record<string, unknown> | undefined;
|
vehicle?: Record<string, unknown>;
|
||||||
if (veh && Object.prototype.hasOwnProperty.call(veh, "inquiry")) {
|
}>) {
|
||||||
delete veh.inquiry;
|
if (
|
||||||
|
party.vehicle &&
|
||||||
|
Object.prototype.hasOwnProperty.call(party.vehicle, "inquiry")
|
||||||
|
) {
|
||||||
|
delete party.vehicle.inquiry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return doc;
|
return doc;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
|
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
"findOneV2 failed",
|
"findOneV2 failed",
|
||||||
requestId,
|
requestId,
|
||||||
error instanceof Error ? error.stack : String(error),
|
error instanceof Error ? error.stack : String(error),
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
|
|||||||
138
src/expert-claim/dto/claim-damaged-part.enricher.ts
Normal file
138
src/expert-claim/dto/claim-damaged-part.enricher.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
// claim-damaged-part.enricher.ts
|
||||||
|
|
||||||
|
import {
|
||||||
|
DamagedPartCaptureStatus,
|
||||||
|
EnrichedDamagedPart,
|
||||||
|
} from "./claim-damaged-part.types";
|
||||||
|
|
||||||
|
export function buildEnrichedDamagedParts(params: {
|
||||||
|
selectedParts: any[];
|
||||||
|
damagedPartsData: any;
|
||||||
|
evaluationBlock: any;
|
||||||
|
expertAddedParts?: any[];
|
||||||
|
getDamagedPartCaptureBlob: (data: any, key: string, parts: any[]) => any;
|
||||||
|
hasDamagedPartCapture: (data: any, key: string, parts: any[]) => boolean;
|
||||||
|
catalogLikeKeyFromPart: (part: any) => string;
|
||||||
|
buildFileLink: (path: string) => string;
|
||||||
|
}): EnrichedDamagedPart[] {
|
||||||
|
const {
|
||||||
|
selectedParts,
|
||||||
|
damagedPartsData,
|
||||||
|
evaluationBlock,
|
||||||
|
expertAddedParts = [],
|
||||||
|
getDamagedPartCaptureBlob,
|
||||||
|
hasDamagedPartCapture,
|
||||||
|
catalogLikeKeyFromPart,
|
||||||
|
buildFileLink,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const priceDropLines: any[] = evaluationBlock?.priceDrop?.partLines ?? [];
|
||||||
|
const objectionParts: any[] =
|
||||||
|
evaluationBlock?.objection?.objectionParts ?? [];
|
||||||
|
const newObjectionParts: any[] = evaluationBlock?.objection?.newParts ?? [];
|
||||||
|
|
||||||
|
// Resend block — lives at evaluation.damageExpertResend
|
||||||
|
const damageExpertResend = evaluationBlock?.damageExpertResend;
|
||||||
|
const resendCarParts: any[] = damageExpertResend?.resendCarParts ?? [];
|
||||||
|
const resendFulfilledAt: Date | undefined = damageExpertResend?.fulfilledAt;
|
||||||
|
|
||||||
|
function isPartInResend(sp: any): boolean {
|
||||||
|
return resendCarParts.some(
|
||||||
|
(r: any) =>
|
||||||
|
r.id === sp.id || r.name === sp.name || r.catalogKey === sp.catalogKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOrigin(sp: any): EnrichedDamagedPart["origin"] {
|
||||||
|
if (
|
||||||
|
newObjectionParts.some((p: any) => p.id === sp.id || p.name === sp.name)
|
||||||
|
) {
|
||||||
|
return "added_by_objection";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
expertAddedParts.some((p: any) => p.id === sp.id || p.name === sp.name)
|
||||||
|
) {
|
||||||
|
return "added_by_expert";
|
||||||
|
}
|
||||||
|
return "user_selected";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCaptureStatus(
|
||||||
|
sp: any,
|
||||||
|
captured: boolean,
|
||||||
|
): DamagedPartCaptureStatus {
|
||||||
|
const inResend = isPartInResend(sp);
|
||||||
|
if (!captured) {
|
||||||
|
return inResend ? "resend_requested" : "pending";
|
||||||
|
}
|
||||||
|
// captured=true + was in a resend request that is now fulfilled → "resent"
|
||||||
|
return inResend && resendFulfilledAt ? "resent" : "uploaded";
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedParts.map((sp, index) => {
|
||||||
|
const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp);
|
||||||
|
|
||||||
|
const cap = getDamagedPartCaptureBlob(
|
||||||
|
damagedPartsData,
|
||||||
|
ck,
|
||||||
|
selectedParts,
|
||||||
|
) as { url?: string; path?: string } | undefined;
|
||||||
|
const captured = hasDamagedPartCapture(damagedPartsData, ck, selectedParts);
|
||||||
|
const url = cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined);
|
||||||
|
|
||||||
|
const matchingPriceDrop = priceDropLines.find(
|
||||||
|
(line: any) =>
|
||||||
|
line.partId === sp.id ||
|
||||||
|
line.priceDropPartKey?.toLowerCase() === sp.name?.toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isObjected = objectionParts.some(
|
||||||
|
(p: any) => p.id === sp.id || p.name === sp.name,
|
||||||
|
);
|
||||||
|
const isNewlyAdded = newObjectionParts.some(
|
||||||
|
(p: any) => p.id === sp.id || p.name === sp.name,
|
||||||
|
);
|
||||||
|
const isNewByExpert = expertAddedParts.some(
|
||||||
|
(p: any) => p.id === sp.id || p.name === sp.name,
|
||||||
|
);
|
||||||
|
const inResend = isPartInResend(sp);
|
||||||
|
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
partId: sp.id ?? null,
|
||||||
|
id: sp.id ?? null,
|
||||||
|
name: sp.name,
|
||||||
|
side: sp.side,
|
||||||
|
label_fa: sp.label_fa,
|
||||||
|
...(sp.catalogKey ? { catalogKey: sp.catalogKey } : {}),
|
||||||
|
|
||||||
|
origin: resolveOrigin(sp),
|
||||||
|
captureStatus: resolveCaptureStatus(sp, captured),
|
||||||
|
captured,
|
||||||
|
url,
|
||||||
|
|
||||||
|
objection: {
|
||||||
|
isObjected,
|
||||||
|
isNewlyAdded,
|
||||||
|
...(isNewlyAdded ? { addedBy: isNewByExpert ? "expert" : "user" } : {}),
|
||||||
|
},
|
||||||
|
|
||||||
|
resend: {
|
||||||
|
wasRequested: inResend,
|
||||||
|
isFulfilled: inResend && !!resendFulfilledAt,
|
||||||
|
...(inResend && damageExpertResend?.requestedAt
|
||||||
|
? { requestedAt: damageExpertResend.requestedAt }
|
||||||
|
: {}),
|
||||||
|
...(inResend && resendFulfilledAt
|
||||||
|
? { fulfilledAt: resendFulfilledAt }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
|
||||||
|
pricing: {
|
||||||
|
hasPriceDrop: !!matchingPriceDrop,
|
||||||
|
severity: matchingPriceDrop?.severity ?? null,
|
||||||
|
coefficient: matchingPriceDrop?.coefficient ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
40
src/expert-claim/dto/claim-damaged-part.types.ts
Normal file
40
src/expert-claim/dto/claim-damaged-part.types.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// claim-damaged-part.types.ts — update the type
|
||||||
|
export type DamagedPartCaptureStatus =
|
||||||
|
| "pending" // never uploaded
|
||||||
|
| "uploaded" // uploaded, no issues
|
||||||
|
| "resend_requested" // expert asked to redo this part's photo
|
||||||
|
| "resent"; // owner re-uploaded after resend request
|
||||||
|
|
||||||
|
export interface EnrichedDamagedPart {
|
||||||
|
index: number;
|
||||||
|
partId: number | null;
|
||||||
|
id: number | null;
|
||||||
|
name: string;
|
||||||
|
side: string;
|
||||||
|
label_fa: string;
|
||||||
|
catalogKey?: string;
|
||||||
|
|
||||||
|
origin: "user_selected" | "added_by_objection" | "added_by_expert";
|
||||||
|
captureStatus: DamagedPartCaptureStatus;
|
||||||
|
captured: boolean;
|
||||||
|
url?: string;
|
||||||
|
|
||||||
|
objection: {
|
||||||
|
isObjected: boolean;
|
||||||
|
isNewlyAdded: boolean;
|
||||||
|
addedBy?: "user" | "expert";
|
||||||
|
};
|
||||||
|
|
||||||
|
resend: {
|
||||||
|
wasRequested: boolean; // expert included this part in a resend request
|
||||||
|
isFulfilled: boolean; // owner completed the resend
|
||||||
|
requestedAt?: Date;
|
||||||
|
fulfilledAt?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
pricing: {
|
||||||
|
hasPriceDrop: boolean;
|
||||||
|
severity: string | null;
|
||||||
|
coefficient: number | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -132,6 +132,7 @@ import {
|
|||||||
import { CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN } from "src/constants/repair-amount-limits";
|
import { CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN } from "src/constants/repair-amount-limits";
|
||||||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||||
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
||||||
|
import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher";
|
||||||
|
|
||||||
const CLAIM_V2_TOTAL_PAYMENT_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN;
|
const CLAIM_V2_TOTAL_PAYMENT_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN;
|
||||||
|
|
||||||
@@ -3293,22 +3294,26 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
const claims = await this.claimCaseDbService.find({
|
const claims = await this.claimCaseDbService.find({
|
||||||
$or: [
|
$or: [
|
||||||
// Available claims: waiting for expert, not locked
|
// Available: waiting, not locked
|
||||||
{
|
{
|
||||||
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
|
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
|
||||||
"workflow.locked": { $ne: true },
|
"workflow.locked": { $ne: true },
|
||||||
|
// No persistent assignee (or expired without decision)
|
||||||
|
$or: [
|
||||||
|
{ "workflow.assignedForReviewBy": { $exists: false } },
|
||||||
|
{ "workflow.assignedForReviewBy": null },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
// Expert reviewing but lock cleared (e.g. TTL expiry) — still list until status is reconciled
|
// This expert's locked/in-progress claims
|
||||||
{
|
|
||||||
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
|
||||||
"workflow.locked": { $ne: true },
|
|
||||||
},
|
|
||||||
// This expert's own locked/in-progress claims
|
|
||||||
{
|
{
|
||||||
"workflow.locked": true,
|
"workflow.locked": true,
|
||||||
"workflow.lockedBy.actorId": new Types.ObjectId(actorId),
|
"workflow.lockedBy.actorId": new Types.ObjectId(actorId),
|
||||||
},
|
},
|
||||||
// User uploaded all factors; expert must approve/reject (unlocked queue)
|
// This expert's persistently assigned claims (decided or resend/objection follow-up)
|
||||||
|
{
|
||||||
|
"workflow.assignedForReviewBy.actorId": new Types.ObjectId(actorId),
|
||||||
|
},
|
||||||
|
// Factor validation queue (open to all tenant experts — no lock needed)
|
||||||
{
|
{
|
||||||
$or: [
|
$or: [
|
||||||
{
|
{
|
||||||
@@ -3323,6 +3328,16 @@ export class ExpertClaimService {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// WAITING_FOR_USER_RESEND assigned to this expert
|
||||||
|
{
|
||||||
|
status: ClaimCaseStatus.WAITING_FOR_USER_RESEND,
|
||||||
|
"workflow.assignedForReviewBy.actorId": new Types.ObjectId(actorId),
|
||||||
|
},
|
||||||
|
// EXPERT_REVIEWING with stale lock (reconciliation will fix status, show in meantime)
|
||||||
|
{
|
||||||
|
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
||||||
|
"workflow.locked": { $ne: true },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3330,10 +3345,12 @@ export class ExpertClaimService {
|
|||||||
claimCaseTouchesClient(c, clientKey),
|
claimCaseTouchesClient(c, clientKey),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Reconcile stale locks — if no decision was made, also clear assignedForReviewBy
|
||||||
const staleLockToReconcile = filtered.filter(
|
const staleLockToReconcile = filtered.filter(
|
||||||
(c) =>
|
(c) =>
|
||||||
c.workflow?.locked && !this.isClaimV2WorkflowLockCurrentlyEnforced(c),
|
c.workflow?.locked && !this.isClaimV2WorkflowLockCurrentlyEnforced(c),
|
||||||
);
|
);
|
||||||
|
|
||||||
const touchedIds = (
|
const touchedIds = (
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
staleLockToReconcile.map(async (c) => {
|
staleLockToReconcile.map(async (c) => {
|
||||||
@@ -3356,9 +3373,39 @@ export class ExpertClaimService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Post-filter: remove files that belong to another expert after reconciliation
|
||||||
|
const finalFiltered = filtered.filter((c) => {
|
||||||
|
const assignedId = String(c.workflow?.assignedForReviewBy?.actorId ?? "");
|
||||||
|
const lockEnforced =
|
||||||
|
c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c);
|
||||||
|
const lockedById = String(c.workflow?.lockedBy?.actorId ?? "");
|
||||||
|
const isFactorValidation = claimIsAwaitingExpertFactorValidationV2(c);
|
||||||
|
|
||||||
|
// Factor validation is open to all tenant experts
|
||||||
|
if (isFactorValidation) return true;
|
||||||
|
|
||||||
|
// Available: no assignee, not locked by someone else
|
||||||
|
const isAvailable =
|
||||||
|
c.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT &&
|
||||||
|
!assignedId &&
|
||||||
|
(!lockEnforced || lockedById === actorId);
|
||||||
|
|
||||||
|
// Mine: assigned to me (covers decided, resend, objection follow-up)
|
||||||
|
const isAssignedToMe = !!assignedId && assignedId === actorId;
|
||||||
|
|
||||||
|
// Mine: currently locked by me
|
||||||
|
const isLockedByMe = lockEnforced && lockedById === actorId;
|
||||||
|
|
||||||
|
// Mine: resend pending and I'm the assigned expert
|
||||||
|
const isResendMine =
|
||||||
|
c.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND && isAssignedToMe;
|
||||||
|
|
||||||
|
return isAvailable || isAssignedToMe || isLockedByMe || isResendMine;
|
||||||
|
});
|
||||||
|
|
||||||
const blameIds = [
|
const blameIds = [
|
||||||
...new Set(
|
...new Set(
|
||||||
filtered
|
finalFiltered
|
||||||
.map((c) => c.blameRequestId?.toString())
|
.map((c) => c.blameRequestId?.toString())
|
||||||
.filter((id): id is string => !!id),
|
.filter((id): id is string => !!id),
|
||||||
),
|
),
|
||||||
@@ -3374,7 +3421,7 @@ export class ExpertClaimService {
|
|||||||
blames.map((b) => [String(b._id), b]),
|
blames.map((b) => [String(b._id), b]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const list = filtered.map((c) => {
|
const list = finalFiltered.map((c) => {
|
||||||
const awaitingFactorValidation =
|
const awaitingFactorValidation =
|
||||||
claimIsAwaitingExpertFactorValidationV2(c);
|
claimIsAwaitingExpertFactorValidationV2(c);
|
||||||
const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById);
|
const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById);
|
||||||
@@ -3389,6 +3436,7 @@ export class ExpertClaimService {
|
|||||||
c.status === ClaimCaseStatus.EXPERT_REVIEWING
|
c.status === ClaimCaseStatus.EXPERT_REVIEWING
|
||||||
? ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT
|
? ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT
|
||||||
: c.status;
|
: c.status;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
claimRequestId: c._id.toString(),
|
claimRequestId: c._id.toString(),
|
||||||
publicId: c.publicId,
|
publicId: c.publicId,
|
||||||
@@ -3405,11 +3453,7 @@ export class ExpertClaimService {
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
vehicle: v
|
vehicle: v
|
||||||
? {
|
? { carName: v.carName, carModel: v.carModel, carType: v.carType }
|
||||||
carName: v.carName,
|
|
||||||
carModel: v.carModel,
|
|
||||||
carType: v.carType,
|
|
||||||
}
|
|
||||||
: undefined,
|
: undefined,
|
||||||
...fileCtx,
|
...fileCtx,
|
||||||
createdAt: c.createdAt,
|
createdAt: c.createdAt,
|
||||||
@@ -3726,6 +3770,7 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
const assigneeToPersist = claimWorkflowAssigneeToPersistOnLockExpiry(
|
const assigneeToPersist = claimWorkflowAssigneeToPersistOnLockExpiry(
|
||||||
claim.workflow,
|
claim.workflow,
|
||||||
|
claim,
|
||||||
);
|
);
|
||||||
const expireLockSet: Record<string, unknown> = { "workflow.locked": false };
|
const expireLockSet: Record<string, unknown> = { "workflow.locked": false };
|
||||||
if (assigneeToPersist) {
|
if (assigneeToPersist) {
|
||||||
@@ -3794,21 +3839,53 @@ export class ExpertClaimService {
|
|||||||
claim.status === ClaimCaseStatus.EXPERT_REVIEWING;
|
claim.status === ClaimCaseStatus.EXPERT_REVIEWING;
|
||||||
const isFactorValidationPending =
|
const isFactorValidationPending =
|
||||||
claimIsAwaitingExpertFactorValidationV2(claim);
|
claimIsAwaitingExpertFactorValidationV2(claim);
|
||||||
|
const isResendPending =
|
||||||
|
claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND;
|
||||||
|
|
||||||
if (!isDamageExpertPhase && !isFactorValidationPending) {
|
if (
|
||||||
|
!isDamageExpertPhase &&
|
||||||
|
!isFactorValidationPending &&
|
||||||
|
!isResendPending
|
||||||
|
) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
`This claim is not available for expert review. Current status: ${claim.status}`,
|
`This claim is not available for expert review. Current status: ${claim.status}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const lockBlocksOthers = this.isClaimV2WorkflowLockCurrentlyEnforced(claim);
|
// Ownership access control — same 4-bucket logic as list
|
||||||
if (lockBlocksOthers) {
|
const assignedForReviewById = String(
|
||||||
const lockerId = claim.workflow?.lockedBy?.actorId?.toString();
|
(claim.workflow as any)?.assignedForReviewBy?.actorId ?? "",
|
||||||
if (lockerId && lockerId !== actorId) {
|
);
|
||||||
|
const lockEnforced = this.isClaimV2WorkflowLockCurrentlyEnforced(claim);
|
||||||
|
const lockedById = String(claim.workflow?.lockedBy?.actorId ?? "");
|
||||||
|
|
||||||
|
const isAvailable =
|
||||||
|
isDamageExpertPhase && !assignedForReviewById && !lockEnforced;
|
||||||
|
const isAssignedToMe =
|
||||||
|
!!assignedForReviewById && assignedForReviewById === actorId;
|
||||||
|
const isLockedByMe = lockEnforced && lockedById === actorId;
|
||||||
|
// Factor validation is open to all tenant experts (no individual ownership)
|
||||||
|
const isFactorValidationOpen = isFactorValidationPending;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isAvailable &&
|
||||||
|
!isAssignedToMe &&
|
||||||
|
!isLockedByMe &&
|
||||||
|
!isFactorValidationOpen
|
||||||
|
) {
|
||||||
|
if (lockEnforced && lockedById && lockedById !== actorId) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
`This claim is locked by another expert: ${claim.workflow.lockedBy?.actorName}`,
|
`This claim is locked by another expert: ${claim.workflow?.lockedBy?.actorName}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (assignedForReviewById && assignedForReviewById !== actorId) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"This claim has been assigned to another expert.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"You do not have permission to view this claim.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasCapture = (data: any, key: string) =>
|
const hasCapture = (data: any, key: string) =>
|
||||||
@@ -3867,56 +3944,19 @@ export class ExpertClaimService {
|
|||||||
const objectionParts = evaluationBlock?.objection?.objectionParts || [];
|
const objectionParts = evaluationBlock?.objection?.objectionParts || [];
|
||||||
const newObjectionParts = evaluationBlock?.objection?.newParts || [];
|
const newObjectionParts = evaluationBlock?.objection?.newParts || [];
|
||||||
|
|
||||||
const damagedParts = selectedNormExpert.map((sp, index) => {
|
// Inside getClaimDetailV2, replace the damagedParts block:
|
||||||
const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp);
|
|
||||||
const cap = getDamagedPartCaptureBlob(
|
|
||||||
damagedPartsDataExpert,
|
|
||||||
ck,
|
|
||||||
selectedNormExpert,
|
|
||||||
) as { url?: string; path?: string } | undefined;
|
|
||||||
|
|
||||||
// 1. Cross-reference Price Drop items via partId or name match
|
const damagedParts = buildEnrichedDamagedParts({
|
||||||
const matchingPriceDrop = priceDropLines.find(
|
selectedParts: selectedNormExpert,
|
||||||
(line: any) =>
|
damagedPartsData: damagedPartsDataExpert,
|
||||||
line.partId === sp.id ||
|
evaluationBlock: (claim as any).evaluation, // damageExpertResend lives here already
|
||||||
line.priceDropPartKey?.toLowerCase() === sp.name?.toLowerCase(),
|
expertAddedParts: (claim.damage as any)?.expertAddedParts ?? [],
|
||||||
);
|
getDamagedPartCaptureBlob,
|
||||||
|
hasDamagedPartCapture,
|
||||||
// 2. Evaluate if this specific part is flag-linked within an active user objection
|
catalogLikeKeyFromPart,
|
||||||
const isObjected = objectionParts.some(
|
buildFileLink,
|
||||||
(objPart: any) => objPart.id === sp.id || objPart.name === sp.name,
|
|
||||||
);
|
|
||||||
const isNewAddedPart = newObjectionParts.some(
|
|
||||||
(newPart: any) => newPart.id === sp.id || newPart.name === sp.name,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
index,
|
|
||||||
partId: catalogPartIdFromSelectedPart(sp),
|
|
||||||
id: sp.id,
|
|
||||||
name: sp.name,
|
|
||||||
side: sp.side,
|
|
||||||
label_fa: sp.label_fa,
|
|
||||||
captured: hasDamagedPartCapture(
|
|
||||||
damagedPartsDataExpert,
|
|
||||||
ck,
|
|
||||||
selectedNormExpert,
|
|
||||||
),
|
|
||||||
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
|
|
||||||
|
|
||||||
// --- Added Consolidated Production Enrichments ---
|
|
||||||
severity: matchingPriceDrop ? matchingPriceDrop.severity : null,
|
|
||||||
coefficient: matchingPriceDrop ? matchingPriceDrop.coefficient : null,
|
|
||||||
objectionStatus: {
|
|
||||||
hasObjection: isObjected,
|
|
||||||
isNewlyAddedByObjection: isNewAddedPart,
|
|
||||||
// You can map extra parameters here if the front-end requires reason texts
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Combine both branches of the conflict, preserving necessary logic from both ---
|
|
||||||
|
|
||||||
// 1. Vehicle payload logic from "upstream"
|
// 1. Vehicle payload logic from "upstream"
|
||||||
let vehiclePayload = claim.vehicle as any;
|
let vehiclePayload = claim.vehicle as any;
|
||||||
const hasClaimVehicle =
|
const hasClaimVehicle =
|
||||||
@@ -4377,9 +4417,29 @@ export class ExpertClaimService {
|
|||||||
? [...(claim as any).damage.selectedParts]
|
? [...(claim as any).damage.selectedParts]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
// Diff to find parts the expert is adding that weren't in the original selection
|
||||||
|
const previousPartIds = new Set(
|
||||||
|
previousNorm.map((p) => p.id ?? `${p.name}::${p.side}`),
|
||||||
|
);
|
||||||
|
const expertAddedNow = nextNorm.filter(
|
||||||
|
(p) => !previousPartIds.has(p.id ?? `${p.name}::${p.side}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Merge with parts the expert added in any previous edits on this claim (idempotent)
|
||||||
|
const existingExpertAdded: any[] =
|
||||||
|
(claim.damage as any)?.expertAddedParts ?? [];
|
||||||
|
const existingExpertAddedIds = new Set(
|
||||||
|
existingExpertAdded.map((p: any) => p.id ?? `${p.name}::${p.side}`),
|
||||||
|
);
|
||||||
|
const expertAddedToAppend = expertAddedNow.filter(
|
||||||
|
(p) => !existingExpertAddedIds.has(p.id ?? `${p.name}::${p.side}`),
|
||||||
|
);
|
||||||
|
const mergedExpertAdded = [...existingExpertAdded, ...expertAddedToAppend];
|
||||||
|
|
||||||
const $set: Record<string, unknown> = {
|
const $set: Record<string, unknown> = {
|
||||||
"damage.selectedParts": nextNorm,
|
"damage.selectedParts": nextNorm,
|
||||||
"media.damagedParts": nextMedia,
|
"media.damagedParts": nextMedia,
|
||||||
|
"damage.expertAddedParts": mergedExpertAdded,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||||
@@ -4396,6 +4456,7 @@ export class ExpertClaimService {
|
|||||||
metadata: {
|
metadata: {
|
||||||
previousSelectedParts: previous,
|
previousSelectedParts: previous,
|
||||||
selectedParts: nextNorm,
|
selectedParts: nextNorm,
|
||||||
|
expertAddedParts: mergedExpertAdded,
|
||||||
...(damagedPartsEditSnapshot && {
|
...(damagedPartsEditSnapshot && {
|
||||||
expertProfileSnapshot: damagedPartsEditSnapshot,
|
expertProfileSnapshot: damagedPartsEditSnapshot,
|
||||||
}),
|
}),
|
||||||
@@ -4408,6 +4469,7 @@ export class ExpertClaimService {
|
|||||||
claimRequestId: claim._id.toString(),
|
claimRequestId: claim._id.toString(),
|
||||||
selectedParts: nextNorm,
|
selectedParts: nextNorm,
|
||||||
previousSelectedParts: previous,
|
previousSelectedParts: previous,
|
||||||
|
expertAddedParts: mergedExpertAdded,
|
||||||
message: "Damaged parts updated successfully.",
|
message: "Damaged parts updated successfully.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,14 +96,13 @@ function jalaliPartsToGregorian(
|
|||||||
jMonth: number,
|
jMonth: number,
|
||||||
jDay: number,
|
jDay: number,
|
||||||
): string | null {
|
): string | null {
|
||||||
// Jalali to Julian Day Number, then to Gregorian
|
|
||||||
// Algorithm: https://www.fourmilab.ch/documents/calendar/
|
|
||||||
const jy = jYear - 979;
|
const jy = jYear - 979;
|
||||||
const jm = jMonth - 1;
|
const jm = jMonth - 1;
|
||||||
const jd = jDay - 1;
|
const jd = jDay - 1;
|
||||||
|
|
||||||
|
// Jalali day number — correct leap cycle is every 33 years with 8 leaps
|
||||||
let jDayNo =
|
let jDayNo =
|
||||||
365 * jy + Math.floor(jy / 4) - Math.floor(jy / 100) + Math.floor(jy / 400);
|
365 * jy + Math.floor(jy / 33) * 8 + Math.floor(((jy % 33) + 3) / 4);
|
||||||
|
|
||||||
const jalaliMonthDays = [31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29];
|
const jalaliMonthDays = [31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29];
|
||||||
for (let i = 0; i < jm; i++) {
|
for (let i = 0; i < jm; i++) {
|
||||||
@@ -111,29 +110,29 @@ function jalaliPartsToGregorian(
|
|||||||
}
|
}
|
||||||
jDayNo += jd;
|
jDayNo += jd;
|
||||||
|
|
||||||
// Convert to Gregorian day number (offset from 1970-01-01 in Julian days)
|
// Offset to Gregorian day number anchored at 1600
|
||||||
const gDayNo = jDayNo + 79;
|
let gDayNo = jDayNo + 79;
|
||||||
|
|
||||||
let gy = 1979 + 400 * Math.floor(gDayNo / 146097);
|
let gy = 1600 + 400 * Math.floor(gDayNo / 146097);
|
||||||
let days = gDayNo % 146097;
|
gDayNo %= 146097;
|
||||||
|
|
||||||
let leap = true;
|
let leap = true;
|
||||||
if (days >= 36525) {
|
if (gDayNo >= 36525) {
|
||||||
days--;
|
gDayNo--;
|
||||||
gy += 100 * Math.floor(days / 36524);
|
gy += 100 * Math.floor(gDayNo / 36524);
|
||||||
days %= 36524;
|
gDayNo %= 36524;
|
||||||
if (days >= 365) days++;
|
if (gDayNo >= 365) gDayNo++;
|
||||||
else leap = false;
|
else leap = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
gy += 4 * Math.floor(days / 1461);
|
gy += 4 * Math.floor(gDayNo / 1461);
|
||||||
days %= 1461;
|
gDayNo %= 1461;
|
||||||
|
|
||||||
if (days >= 366) {
|
if (gDayNo >= 366) {
|
||||||
leap = false;
|
leap = false;
|
||||||
days--;
|
gDayNo--;
|
||||||
gy += Math.floor(days / 365);
|
gy += Math.floor(gDayNo / 365);
|
||||||
days %= 365;
|
gDayNo %= 365;
|
||||||
}
|
}
|
||||||
|
|
||||||
const gregorianMonthDays = [
|
const gregorianMonthDays = [
|
||||||
@@ -153,19 +152,18 @@ function jalaliPartsToGregorian(
|
|||||||
|
|
||||||
let gm = 0;
|
let gm = 0;
|
||||||
for (let i = 0; i < 12; i++) {
|
for (let i = 0; i < 12; i++) {
|
||||||
if (days < gregorianMonthDays[i]) {
|
if (gDayNo < gregorianMonthDays[i]) {
|
||||||
gm = i + 1;
|
gm = i + 1;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
days -= gregorianMonthDays[i];
|
gDayNo -= gregorianMonthDays[i];
|
||||||
}
|
}
|
||||||
const gd = days + 1;
|
const gd = gDayNo + 1;
|
||||||
|
|
||||||
const mm = String(gm).padStart(2, "0");
|
const mm = String(gm).padStart(2, "0");
|
||||||
const dd = String(gd).padStart(2, "0");
|
const dd = String(gd).padStart(2, "0");
|
||||||
const result = `${gy}-${mm}-${dd}`;
|
const result = `${gy}-${mm}-${dd}`;
|
||||||
|
|
||||||
// Sanity check
|
|
||||||
const check = new Date(result);
|
const check = new Date(result);
|
||||||
if (isNaN(check.getTime())) return null;
|
if (isNaN(check.getTime())) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,8 @@ export function resolvePersistentReviewAssigneeId(
|
|||||||
if (fromField != null && String(fromField).length > 0) {
|
if (fromField != null && String(fromField).length > 0) {
|
||||||
return String(fromField);
|
return String(fromField);
|
||||||
}
|
}
|
||||||
const fromHistory = history?.find(
|
const fromHistory = history?.find((h) => h.type === assignedHistoryType)
|
||||||
(h) => h.type === assignedHistoryType,
|
?.actor?.actorId;
|
||||||
)?.actor?.actorId;
|
|
||||||
if (fromHistory != null && String(fromHistory).length > 0) {
|
if (fromHistory != null && String(fromHistory).length > 0) {
|
||||||
return String(fromHistory);
|
return String(fromHistory);
|
||||||
}
|
}
|
||||||
@@ -43,8 +42,7 @@ export function buildExpertAssignConflictForOtherReviewer(
|
|||||||
message: string;
|
message: string;
|
||||||
lockedBy: { actorId: string; actorName?: string; lockedAt?: string };
|
lockedBy: { actorId: string; actorName?: string; lockedAt?: string };
|
||||||
} {
|
} {
|
||||||
const assignee =
|
const assignee = workflow?.assignedForReviewBy ?? workflow?.lockedBy;
|
||||||
workflow?.assignedForReviewBy ?? workflow?.lockedBy;
|
|
||||||
const lockedAt = workflow?.lockedAt;
|
const lockedAt = workflow?.lockedAt;
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -53,25 +51,28 @@ export function buildExpertAssignConflictForOtherReviewer(
|
|||||||
lockedBy: {
|
lockedBy: {
|
||||||
actorId: assigneeId,
|
actorId: assigneeId,
|
||||||
actorName: assignee?.actorName,
|
actorName: assignee?.actorName,
|
||||||
lockedAt: lockedAt
|
lockedAt: lockedAt ? new Date(lockedAt as Date).toISOString() : undefined,
|
||||||
? new Date(lockedAt as Date).toISOString()
|
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Copy lock holder into `assignedForReviewBy` when persisting a TTL unlock. */
|
|
||||||
export function blameWorkflowAssigneeToPersistOnLockExpiry(
|
export function blameWorkflowAssigneeToPersistOnLockExpiry(
|
||||||
workflow: WorkflowAssigneeRef | undefined,
|
workflow: any,
|
||||||
): WorkflowAssigneeRef["assignedForReviewBy"] | undefined {
|
request: any,
|
||||||
if (workflow?.assignedForReviewBy) {
|
) {
|
||||||
return undefined;
|
const hasDecision = !!request?.expert?.decision;
|
||||||
}
|
return hasDecision ? (workflow?.assignedForReviewBy ?? null) : null;
|
||||||
return workflow?.lockedBy;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function claimWorkflowAssigneeToPersistOnLockExpiry(
|
export function claimWorkflowAssigneeToPersistOnLockExpiry(
|
||||||
workflow: WorkflowAssigneeRef | undefined,
|
workflow: any,
|
||||||
): WorkflowAssigneeRef["assignedForReviewBy"] | undefined {
|
claim: any,
|
||||||
return blameWorkflowAssigneeToPersistOnLockExpiry(workflow);
|
): typeof workflow.assignedForReviewBy | null {
|
||||||
|
// If a decision or resend was already submitted, preserve ownership
|
||||||
|
const hasDecision =
|
||||||
|
!!claim?.evaluation?.damageExpertReply ||
|
||||||
|
!!claim?.evaluation?.damageExpertReplyFinal ||
|
||||||
|
!!claim?.evaluation?.damageExpertResend;
|
||||||
|
|
||||||
|
return hasDecision ? (workflow?.assignedForReviewBy ?? null) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1016,15 +1016,15 @@ export class RequestManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// const personalInquiry = await this.sandHubService.getPersonalInquiry(
|
const personalInquiry = await this.sandHubService.getPersonalInquiry(
|
||||||
// personalNationalCode,
|
personalNationalCode,
|
||||||
// personalBirthDate,
|
personalBirthDate,
|
||||||
// );
|
);
|
||||||
// this.logger.log(
|
this.logger.log(
|
||||||
// `[SANDHUB] personal inquiry success request=${req._id} nationalCode=${personalNationalCode}: ${JSON.stringify(
|
`[SANDHUB] personal inquiry success request=${req._id} nationalCode=${personalNationalCode}: ${JSON.stringify(
|
||||||
// personalInquiry,
|
personalInquiry,
|
||||||
// )}`,
|
)}`,
|
||||||
// );
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`[SANDHUB] personal inquiry failed request=${req._id} nationalCode=${personalNationalCode}: ${err?.message || err}`,
|
`[SANDHUB] personal inquiry failed request=${req._id} nationalCode=${personalNationalCode}: ${err?.message || err}`,
|
||||||
|
|||||||
@@ -564,6 +564,7 @@ export class SandHubService {
|
|||||||
const requestUrl = `${process.env.SANHUB_BASE_URL}/personal-inquiry/tejarat-no`;
|
const requestUrl = `${process.env.SANHUB_BASE_URL}/personal-inquiry/tejarat-no`;
|
||||||
|
|
||||||
const gregorianBirthdate = jalaliToGregorianDate(birthDate);
|
const gregorianBirthdate = jalaliToGregorianDate(birthDate);
|
||||||
|
console.log(gregorianBirthdate);
|
||||||
if (!gregorianBirthdate) {
|
if (!gregorianBirthdate) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Invalid birth date for personal inquiry: ${birthDate}. Expected a Jalali date (e.g. 13770624 or "1377-06-24").`,
|
`Invalid birth date for personal inquiry: ${birthDate}. Expected a Jalali date (e.g. 13770624 or "1377-06-24").`,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { MongooseModule } from '@nestjs/mongoose';
|
import { MongooseModule } from "@nestjs/mongoose";
|
||||||
import { WorkflowStepManagementService } from './workflow-step-management.service';
|
import { WorkflowStepManagementService } from "./workflow-step-management.service";
|
||||||
import { WorkflowStepManagementController } from './workflow-step-management.controller';
|
import { WorkflowStepManagementController } from "./workflow-step-management.controller";
|
||||||
import { WorkflowStepDbService } from './entities/db-service/workflow-step.db.service';
|
import { WorkflowStepDbService } from "./entities/db-service/workflow-step.db.service";
|
||||||
import {
|
import {
|
||||||
WorkflowStepsDB,
|
WorkflowStepsDB,
|
||||||
WorkflowStepSchema,
|
WorkflowStepSchema,
|
||||||
} from './entities/schema/workflow-step.schema';
|
} from "./entities/schema/workflow-step.schema";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user