Compare commits

...

14 Commits

Author SHA1 Message Date
519d91102f merge upstream 2026-06-01 16:02:06 +03:30
a47c6f1c96 Merge pull request 'Fixed inquiry of birthdate' (#102) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#102
2026-06-01 14:11:57 +03:30
SepehrYahyaee
5fab0a00b6 Fixed inquiry of birthdate 2026-06-01 14:11:23 +03:30
e650abdf40 Merge pull request 'main' (#101) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#101
2026-06-01 13:21:59 +03:30
SepehrYahyaee
6261af8a29 Fixed lock 2026-06-01 13:21:32 +03:30
SepehrYahyaee
fde6464739 YARA-951 2026-06-01 13:06:09 +03:30
a07b5c3c1e Merge pull request 'Fixed sheba' (#100) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#100
2026-06-01 12:47:22 +03:30
SepehrYahyaee
06af79fa47 Fixed sheba 2026-06-01 12:46:19 +03:30
1a8181a872 Merge pull request 'YARA-972' (#99) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#99
2026-06-01 12:07:32 +03:30
SepehrYahyaee
4a189ba4ef YARA-972 2026-06-01 11:49:25 +03:30
859244940c Merge pull request 'Centralized damaged parts alongside all their required info' (#98) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#98
2026-06-01 11:38:55 +03:30
SepehrYahyaee
cec349b7c2 Centralized damaged parts alongside all their required info 2026-06-01 11:38:30 +03:30
8d8f76eadd Merge pull request 'Reactivated inquiry of birth date' (#97) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#97
2026-06-01 09:39:40 +03:30
SepehrYahyaee
b9e7373225 Reactivated inquiry of birth date 2026-06-01 09:38:56 +03:30
11 changed files with 1115 additions and 797 deletions

View File

@@ -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.nationalCodeOfInsurer": nationalCode,
"money.sheba": "",
"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,

View File

@@ -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) {
throw new ForbiddenException(
const decision = (doc.expert as any)?.decision; "You do not have permission to view this request. It has been handled by another expert.",
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.",
); );
} }
// 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

View 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,
},
};
});
}

View 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;
};
}

View File

@@ -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.",
}; };
} }

View File

@@ -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;

View File

@@ -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;
} }

View File

@@ -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}`,

View File

@@ -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").`,

View File

@@ -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: [