1
0
forked from Yara724/api

Compare commits

...

3 Commits

6 changed files with 580 additions and 108 deletions

View File

@@ -3,7 +3,8 @@
/* /*
* One-time script to replace mocked blameCases party vehicle inquiry data. * One-time script to replace mocked blameCases party vehicle inquiry data.
* THIRD_PARTY cases use Tejarat block inquiry; CAR_BODY cases use both * THIRD_PARTY cases use Tejarat block inquiry; CAR_BODY cases use both
* Tejarat third-party block inquiry and car-body inquiry. * Tejarat third-party block inquiry and car-body inquiry. All cases also
* refresh available party personal inquiries into inquiries.person.
* *
* Defaults to DRY_RUN=true. Set DRY_RUN=false to write changes. * Defaults to DRY_RUN=true. Set DRY_RUN=false to write changes.
*/ */
@@ -61,10 +62,15 @@ const config = {
carBodyUrl: carBodyUrl:
process.env.TEJARAT_CAR_BODY_URL || process.env.TEJARAT_CAR_BODY_URL ||
"http://82.99.202.245:3027/block-inquiry-tejarat/badane", "http://82.99.202.245:3027/block-inquiry-tejarat/badane",
personUrl:
process.env.TEJARAT_PERSON_URL ||
"http://82.99.202.245:3027/personal-inquiry/tejarat-no",
thirdPartyToken: thirdPartyToken:
process.env.TEJARAT_THIRD_PARTY_TOKEN || process.env.TEJARAT_TOKEN || "", process.env.TEJARAT_THIRD_PARTY_TOKEN || process.env.TEJARAT_TOKEN || "",
carBodyToken: carBodyToken:
process.env.TEJARAT_CAR_BODY_TOKEN || process.env.TEJARAT_TOKEN || "", process.env.TEJARAT_CAR_BODY_TOKEN || process.env.TEJARAT_TOKEN || "",
personToken:
process.env.TEJARAT_PERSON_TOKEN || process.env.TEJARAT_TOKEN || "",
rateLimitPerMinute: Number(process.env.RATE_LIMIT_PER_MINUTE || 5), rateLimitPerMinute: Number(process.env.RATE_LIMIT_PER_MINUTE || 5),
retryEnabled: String(process.env.RETRY_ENABLED ?? "true").toLowerCase() !== "false", retryEnabled: String(process.env.RETRY_ENABLED ?? "true").toLowerCase() !== "false",
retryCount: Number(process.env.RETRY_COUNT || 3), retryCount: Number(process.env.RETRY_COUNT || 3),
@@ -124,6 +130,7 @@ async function main() {
requestNo: 1, requestNo: 1,
type: 1, type: 1,
parties: 1, parties: 1,
inquiries: 1,
}, },
}) })
.sort({ createdAt: 1, _id: 1 }); .sort({ createdAt: 1, _id: 1 });
@@ -136,6 +143,9 @@ async function main() {
partiesInquired: 0, partiesInquired: 0,
partiesSkipped: 0, partiesSkipped: 0,
partiesFailed: 0, partiesFailed: 0,
personInquired: 0,
personSkipped: 0,
personFailed: 0,
}; };
for await (const doc of cursor) { for await (const doc of cursor) {
@@ -145,7 +155,9 @@ async function main() {
console.log(`currently inquiry for doc with ${label} and requestId ${requestId}`); console.log(`currently inquiry for doc with ${label} and requestId ${requestId}`);
const parties = Array.isArray(doc.parties) ? clone(doc.parties) : []; const parties = Array.isArray(doc.parties) ? clone(doc.parties) : [];
const inquiries = doc.inquiries && typeof doc.inquiries === "object" ? clone(doc.inquiries) : {};
let docChanged = false; let docChanged = false;
let inquiriesChanged = false;
for (let index = 0; index < parties.length; index += 1) { for (let index = 0; index < parties.length; index += 1) {
const party = parties[index]; const party = parties[index];
@@ -155,61 +167,101 @@ async function main() {
if (!input.ok) { if (!input.ok) {
summary.partiesSkipped += 1; summary.partiesSkipped += 1;
console.warn(`[skip] ${partyLabel}: ${input.reason}`); console.warn(`[skip] ${partyLabel}: ${input.reason}`);
continue; } else {
} let nextParty = party;
let partyChanged = false;
let nextParty = party; for (const request of input.requests) {
let partyChanged = false;
for (const request of input.requests) {
console.log(
`[request] ${partyLabel} type=${request.type} body=${JSON.stringify(request.body)}`,
);
try {
const inquiryResponse = await inquiryWithRetry(request.type, request.body);
const successful = isInquirySuccessful(request.type, inquiryResponse);
console.log( console.log(
`[response] ${partyLabel} type=${request.type} successful=${successful} body=${JSON.stringify(inquiryResponse)}`, `[request] ${partyLabel} type=${request.type} body=${JSON.stringify(request.body)}`,
); );
if (!successful) { try {
summary.partiesFailed += 1; const inquiryResponse = await inquiryWithRetry(request.type, request.body);
continue; const successful = isInquirySuccessful(request.type, inquiryResponse);
} console.log(
`[response] ${partyLabel} type=${request.type} successful=${successful} body=${JSON.stringify(inquiryResponse)}`,
);
nextParty = applyInquiryToParty(request.type, nextParty, inquiryResponse, input.plate); if (!successful) {
summary.partiesInquired += 1; summary.partiesFailed += 1;
partyChanged = true; continue;
} catch (error) { }
summary.partiesFailed += 1;
console.error(`[error] ${partyLabel} type=${request.type}: ${error.message}`); nextParty = applyInquiryToParty(request.type, nextParty, inquiryResponse, input.plate);
summary.partiesInquired += 1;
partyChanged = true;
} catch (error) {
summary.partiesFailed += 1;
console.error(`[error] ${partyLabel} type=${request.type}: ${error.message}`);
}
}
if (partyChanged) {
parties[index] = nextParty;
docChanged = true;
} }
} }
if (partyChanged) { const personInput = buildPersonInquiryInput(party);
parties[index] = nextParty; if (!personInput.ok) {
docChanged = true; summary.personSkipped += 1;
console.warn(`[skip] ${partyLabel} person: ${personInput.reason}`);
continue;
}
console.log(`[request] ${partyLabel} type=PERSON body=${JSON.stringify(personInput.body)}`);
try {
const personResponse = await inquiryWithRetry("PERSON", personInput.body);
const successful = isInquirySuccessful("PERSON", personResponse);
console.log(
`[response] ${partyLabel} type=PERSON successful=${successful} body=${JSON.stringify(personResponse)}`,
);
if (!successful) {
summary.personFailed += 1;
applyPersonInquiryToCaseInquiries(inquiries, party, index, false, {}, personResponse);
inquiriesChanged = true;
continue;
}
applyPersonInquiryToCaseInquiries(
inquiries,
party,
index,
true,
normalizePersonResponse(personResponse),
);
summary.personInquired += 1;
inquiriesChanged = true;
} catch (error) {
summary.personFailed += 1;
applyPersonInquiryToCaseInquiries(inquiries, party, index, false, {}, error);
inquiriesChanged = true;
console.error(`[error] ${partyLabel} type=PERSON: ${error.message}`);
} }
} }
if (!docChanged) { if (!docChanged && !inquiriesChanged) {
console.log(`[doc] ${label} no changes`); console.log(`[doc] ${label} no changes`);
continue; continue;
} }
if (config.dryRun) { if (config.dryRun) {
console.log(`[dry-run] ${label} would update parties array`); console.log(`[dry-run] ${label} would update parties/inquiries`);
continue; continue;
} }
const updateSet = {
updatedAt: new Date(),
};
if (docChanged) updateSet.parties = parties;
if (inquiriesChanged) updateSet.inquiries = inquiries;
const result = await collection.updateOne( const result = await collection.updateOne(
{ _id: doc._id }, { _id: doc._id },
{ {
$set: { $set: updateSet,
parties,
updatedAt: new Date(),
},
}, },
); );
summary.docsChanged += result.modifiedCount; summary.docsChanged += result.modifiedCount;
@@ -285,6 +337,40 @@ function buildCarBodyRequest(plate, serialLetter, nationalCode) {
}; };
} }
function buildPersonInquiryInput(party) {
if (!party || typeof party !== "object") return { ok: false, reason: "party is empty" };
const person = party.person && typeof party.person === "object" ? party.person : null;
if (!person) return { ok: false, reason: "party.person is missing" };
const nationalCode =
cleanString(person.nationalCodeOfInsurer) ||
cleanString(person.nationalCodeOfDriver);
const birthDate = firstPresent(
person.insurerBirthday,
person.driverBirthday,
person.birthday,
);
const gregorianBirthdate = jalaliToGregorianDate(birthDate);
if (!nationalCode) {
return { ok: false, reason: "nationalCodeOfInsurer/nationalCodeOfDriver missing" };
}
if (!gregorianBirthdate) {
return {
ok: false,
reason: `invalid insurerBirthday/driverBirthday=${cleanString(birthDate)}`,
};
}
return {
ok: true,
body: {
nationalCode,
birthdate: gregorianBirthdate,
},
};
}
function parsePlateId(plateId) { function parsePlateId(plateId) {
const value = cleanString(plateId); const value = cleanString(plateId);
if (!value) return null; if (!value) return null;
@@ -352,8 +438,10 @@ function extractPlate(party) {
} }
async function inquiryWithRetry(type, body) { async function inquiryWithRetry(type, body) {
const url = type === "CAR_BODY" ? config.carBodyUrl : config.thirdPartyUrl; const endpoint = getInquiryEndpoint(type);
const token = type === "CAR_BODY" ? config.carBodyToken : config.thirdPartyToken; const url = endpoint.url;
const token = endpoint.token;
const accept = endpoint.accept;
let lastError; let lastError;
const maxAttempts = config.retryEnabled ? config.retryCount : 1; const maxAttempts = config.retryEnabled ? config.retryCount : 1;
@@ -362,7 +450,7 @@ async function inquiryWithRetry(type, body) {
await waitForRateLimit(); await waitForRateLimit();
try { try {
console.log("[http] " + type + " attempt=" + attempt + "/" + maxAttempts); console.log("[http] " + type + " attempt=" + attempt + "/" + maxAttempts);
return await postJson(url, token, body); return await postJson(url, token, body, accept);
} catch (error) { } catch (error) {
lastError = error; lastError = error;
console.error(`[retry] ${type} attempt=${attempt} failed: ${error.message}`); console.error(`[retry] ${type} attempt=${attempt} failed: ${error.message}`);
@@ -375,11 +463,11 @@ async function inquiryWithRetry(type, body) {
throw lastError; throw lastError;
} }
async function postJson(url, token, body) { async function postJson(url, token, body, accept = "application/json") {
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
headers: { headers: {
accept: "application/json", accept,
authorization: `Bearer ${token}`, authorization: `Bearer ${token}`,
"content-type": "application/json", "content-type": "application/json",
}, },
@@ -395,7 +483,10 @@ async function postJson(url, token, body) {
} }
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${JSON.stringify(data)}`); const error = new Error(`HTTP ${response.status}: ${JSON.stringify(data)}`);
error.status = response.status;
error.data = data;
throw error;
} }
return data; return data;
@@ -403,9 +494,20 @@ async function postJson(url, token, body) {
function isInquirySuccessful(type, response) { function isInquirySuccessful(type, response) {
if (type === "CAR_BODY") return response && response.isSuccess === true && response.data; if (type === "CAR_BODY") return response && response.isSuccess === true && response.data;
if (type === "PERSON") return response && response.status === 200 && response.data;
return response && response.resultStatus === true; return response && response.resultStatus === true;
} }
function getInquiryEndpoint(type) {
if (type === "CAR_BODY") {
return { url: config.carBodyUrl, token: config.carBodyToken, accept: "application/json" };
}
if (type === "PERSON") {
return { url: config.personUrl, token: config.personToken, accept: "*/*" };
}
return { url: config.thirdPartyUrl, token: config.thirdPartyToken, accept: "application/json" };
}
function applyInquiryToParty(type, party, response, originalPlate) { function applyInquiryToParty(type, party, response, originalPlate) {
if (type === "CAR_BODY") return applyCarBodyInquiryToParty(party, response, originalPlate); if (type === "CAR_BODY") return applyCarBodyInquiryToParty(party, response, originalPlate);
return applyThirdPartyInquiryToParty(party, response, originalPlate); return applyThirdPartyInquiryToParty(party, response, originalPlate);
@@ -486,6 +588,51 @@ function applyCarBodyInquiryToParty(party, response, originalPlate) {
return next; return next;
} }
function applyPersonInquiryToCaseInquiries(inquiries, party, index, has, data, error) {
if (!inquiries.person || typeof inquiries.person !== "object") {
inquiries.person = {};
}
const existingData =
inquiries.person.data && typeof inquiries.person.data === "object" && !Array.isArray(inquiries.person.data)
? inquiries.person.data
: {};
const existingError =
inquiries.person.error && typeof inquiries.person.error === "object" && !Array.isArray(inquiries.person.error)
? inquiries.person.error
: {};
const roleKey = party && party.role ? party.role : `party_${index}`;
const nextData = { ...existingData };
const nextError = { ...existingError };
if (has) {
nextData[roleKey] = data || {};
delete nextError[roleKey];
} else {
nextError[roleKey] = normalizeInquiryError(error);
}
inquiries.person = {
has: Object.keys(nextData).length > 0,
data: nextData,
...(Object.keys(nextError).length > 0 ? { error: nextError } : {}),
updatedAt: new Date(),
};
}
function normalizePersonResponse(response) {
return response && response.data ? response.data : response;
}
function normalizeInquiryError(error) {
if (!error) return undefined;
return {
message: error.message || String(error),
status: error.status,
data: error.data,
};
}
function buildPlateId(plate) { function buildPlateId(plate) {
if (!plate) return ""; if (!plate) return "";
const serialLetter = NUMBER_TO_LETTER[String(plate.Plk2)] || cleanString(plate.Plk2); const serialLetter = NUMBER_TO_LETTER[String(plate.Plk2)] || cleanString(plate.Plk2);
@@ -555,6 +702,111 @@ function normalizeCarBodyResponse(response, originalPlate) {
}; };
} }
function jalaliToGregorianDate(input) {
if (input === null || input === undefined) return null;
const raw = normalizeDigits(typeof input === "number" ? String(input) : String(input).trim());
if (!raw) return null;
let year = 0;
let month = 0;
let day = 0;
const separated = raw.match(/^(\d{4})[\-/](\d{1,2})[\-/](\d{1,2})$/);
if (separated) {
year = parseInt(separated[1], 10);
month = parseInt(separated[2], 10);
day = parseInt(separated[3], 10);
} else {
const digits = raw.replace(/\D/g, "");
if (digits.length !== 8) return null;
year = parseInt(digits.slice(0, 4), 10);
month = parseInt(digits.slice(4, 6), 10);
day = parseInt(digits.slice(6, 8), 10);
}
if (!year || !month || !day) return null;
if (year >= 1900) {
const mm = String(month).padStart(2, "0");
const dd = String(day).padStart(2, "0");
const result = `${year}-${mm}-${dd}`;
return isNaN(new Date(result).getTime()) ? null : result;
}
return jalaliPartsToGregorian(year, month, day);
}
function jalaliPartsToGregorian(jYear, jMonth, jDay) {
const jy = jYear - 979;
const jm = jMonth - 1;
const jd = jDay - 1;
let jDayNo =
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];
for (let i = 0; i < jm; i += 1) {
jDayNo += jalaliMonthDays[i];
}
jDayNo += jd;
let gDayNo = jDayNo + 79;
let gy = 1600 + 400 * Math.floor(gDayNo / 146097);
gDayNo %= 146097;
let leap = true;
if (gDayNo >= 36525) {
gDayNo -= 1;
gy += 100 * Math.floor(gDayNo / 36524);
gDayNo %= 36524;
if (gDayNo >= 365) gDayNo += 1;
else leap = false;
}
gy += 4 * Math.floor(gDayNo / 1461);
gDayNo %= 1461;
if (gDayNo >= 366) {
leap = false;
gDayNo -= 1;
gy += Math.floor(gDayNo / 365);
gDayNo %= 365;
}
const gregorianMonthDays = [
31,
leap ? 29 : 28,
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let gm = 0;
for (let i = 0; i < 12; i += 1) {
if (gDayNo < gregorianMonthDays[i]) {
gm = i + 1;
break;
}
gDayNo -= gregorianMonthDays[i];
}
const gd = gDayNo + 1;
const mm = String(gm).padStart(2, "0");
const dd = String(gd).padStart(2, "0");
const result = `${gy}-${mm}-${dd}`;
return isNaN(new Date(result).getTime()) ? null : result;
}
async function waitForRateLimit() { async function waitForRateLimit() {
const minDelayMs = Math.ceil(60000 / config.rateLimitPerMinute); const minDelayMs = Math.ceil(60000 / config.rateLimitPerMinute);
const elapsed = Date.now() - lastRequestAt; const elapsed = Date.now() - lastRequestAt;
@@ -580,6 +832,9 @@ function validateConfig() {
if (!config.carBodyToken) { if (!config.carBodyToken) {
throw new Error("TEJARAT_CAR_BODY_TOKEN or TEJARAT_TOKEN is required"); throw new Error("TEJARAT_CAR_BODY_TOKEN or TEJARAT_TOKEN is required");
} }
if (!config.personToken) {
throw new Error("TEJARAT_PERSON_TOKEN or TEJARAT_TOKEN is required");
}
} }
function loadEnvFiles(filePath) { function loadEnvFiles(filePath) {

View File

@@ -3973,6 +3973,7 @@ export class ClaimRequestManagementService {
blameRequestNo: blameRequest.requestNo, blameRequestNo: blameRequest.requestNo,
status: ClaimCaseStatus.SELECTING_OUTER_PARTS, status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
claimStatus: ClaimStatus.PENDING, claimStatus: ClaimStatus.PENDING,
inquiries: (blameRequest as any).inquiries ?? {},
workflow: { workflow: {
currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS, currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS, nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
@@ -4108,6 +4109,7 @@ export class ClaimRequestManagementService {
blameRequestNo: blameRequest.requestNo, blameRequestNo: blameRequest.requestNo,
status: ClaimCaseStatus.SELECTING_OUTER_PARTS, status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
claimStatus: ClaimStatus.PENDING, claimStatus: ClaimStatus.PENDING,
inquiries: (blameRequest as any).inquiries ?? {},
workflow: { workflow: {
currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS, currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS, nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
@@ -4258,6 +4260,7 @@ export class ClaimRequestManagementService {
blameRequestNo: blameRequest.requestNo, blameRequestNo: blameRequest.requestNo,
status: ClaimCaseStatus.SELECTING_OUTER_PARTS, status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
claimStatus: ClaimStatus.PENDING, claimStatus: ClaimStatus.PENDING,
inquiries: (blameRequest as any).inquiries ?? {},
workflow: { workflow: {
currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS, currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS, nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,

View File

@@ -3,7 +3,10 @@ import { HydratedDocument, Schema as MongooseSchema, Types } from "mongoose";
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 { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum"; import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { HistoryEvent, HistoryEventSchema } from "src/request-management/entities/schema/historyEvent.type"; import {
HistoryEvent,
HistoryEventSchema,
} from "src/request-management/entities/schema/historyEvent.type";
import { import {
ClaimDamageSelection, ClaimDamageSelection,
ClaimDamageSelectionSchema, ClaimDamageSelectionSchema,
@@ -25,8 +28,15 @@ import {
ClaimCaseSnapshot, ClaimCaseSnapshot,
ClaimCaseSnapshotSchema, ClaimCaseSnapshotSchema,
} from "./claim-case.snapshot.schema"; } from "./claim-case.snapshot.schema";
import { ClaimWorkflow, ClaimWorkflowSchema } from "./claim-case.workflow.schema"; import {
ClaimWorkflow,
ClaimWorkflowSchema,
} from "./claim-case.workflow.schema";
import { UserClaimRating } from "./claim-request-management.schema"; import { UserClaimRating } from "./claim-request-management.schema";
import {
CaseInquiries,
CaseInquiriesSchema,
} from "src/common/schema/case-inquiries.schema";
@Schema({ _id: false }) @Schema({ _id: false })
export class RequiredDocumentRef { export class RequiredDocumentRef {
@@ -45,7 +55,8 @@ export class RequiredDocumentRef {
@Prop({ type: Date }) @Prop({ type: Date })
uploadedAt?: Date; uploadedAt?: Date;
} }
export const RequiredDocumentRefSchema = SchemaFactory.createForClass(RequiredDocumentRef); export const RequiredDocumentRefSchema =
SchemaFactory.createForClass(RequiredDocumentRef);
@Schema({ @Schema({
collection: "claimCases", collection: "claimCases",
@@ -67,7 +78,12 @@ export class ClaimCase {
/** /**
* Overall case status (user flow + expert flow progression) * Overall case status (user flow + expert flow progression)
*/ */
@Prop({ required: true, type: String, enum: ClaimCaseStatus, default: ClaimCaseStatus.CREATED }) @Prop({
required: true,
type: String,
enum: ClaimCaseStatus,
default: ClaimCaseStatus.CREATED,
})
status: ClaimCaseStatus; status: ClaimCaseStatus;
/** /**
@@ -119,6 +135,9 @@ export class ClaimCase {
@Prop({ type: ClaimEvaluationSchema, default: () => ({}) }) @Prop({ type: ClaimEvaluationSchema, default: () => ({}) })
evaluation?: ClaimEvaluation; evaluation?: ClaimEvaluation;
@Prop({ type: CaseInquiriesSchema, default: () => ({}) })
inquiries?: CaseInquiries;
/** /**
* Optional “read-optimized” copy of fields from blame/request side. * Optional “read-optimized” copy of fields from blame/request side.
* Source of truth remains `blameRequestId`. * Source of truth remains `blameRequestId`.
@@ -160,4 +179,3 @@ export class ClaimCase {
export type ClaimCaseDocument = HydratedDocument<ClaimCase>; export type ClaimCaseDocument = HydratedDocument<ClaimCase>;
export const ClaimCaseSchema = SchemaFactory.createForClass(ClaimCase); export const ClaimCaseSchema = SchemaFactory.createForClass(ClaimCase);
ClaimCaseSchema.index({ status: 1, "workflow.locked": 1 }); ClaimCaseSchema.index({ status: 1, "workflow.locked": 1 });

View File

@@ -0,0 +1,34 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Schema as MongooseSchema } from "mongoose";
@Schema({ _id: false })
export class InquiryStatus {
@Prop({ type: Boolean, default: false })
has?: boolean;
@Prop({ type: MongooseSchema.Types.Mixed, default: () => ({}) })
data?: any;
@Prop({ type: MongooseSchema.Types.Mixed })
error?: any;
@Prop({ type: Date })
updatedAt?: Date;
}
export const InquiryStatusSchema = SchemaFactory.createForClass(InquiryStatus);
@Schema({ _id: false, strict: false })
export class CaseInquiries {
@Prop({ type: InquiryStatusSchema })
thirdParty?: InquiryStatus;
@Prop({ type: InquiryStatusSchema })
carBody?: InquiryStatus;
@Prop({ type: InquiryStatusSchema })
person?: InquiryStatus;
@Prop({ type: InquiryStatusSchema })
drivingLicence?: InquiryStatus;
}
export const CaseInquiriesSchema = SchemaFactory.createForClass(CaseInquiries);

View File

@@ -8,6 +8,10 @@ import { HistoryEvent, HistoryEventSchema } from "./historyEvent.type";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum"; import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum"; import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
import { CreationMethod, FilledBy } from "./request-management.schema"; import { CreationMethod, FilledBy } from "./request-management.schema";
import {
CaseInquiries,
CaseInquiriesSchema,
} from "src/common/schema/case-inquiries.schema";
/** CAR_BODY only: mocked insurance inquiry result (legacy top-level; prefer parties[].insurance.carBodyInsurance) */ /** CAR_BODY only: mocked insurance inquiry result (legacy top-level; prefer parties[].insurance.carBodyInsurance) */
@Schema({ _id: false }) @Schema({ _id: false })
@@ -18,84 +22,93 @@ export class CarBodyInsuranceDetail {
@Prop() insurerCompany?: string; @Prop() insurerCompany?: string;
@Prop({ type: [String] }) coverages?: string[]; @Prop({ type: [String] }) coverages?: string[];
} }
export const CarBodyInsuranceDetailSchema = SchemaFactory.createForClass(CarBodyInsuranceDetail); export const CarBodyInsuranceDetailSchema = SchemaFactory.createForClass(
CarBodyInsuranceDetail,
);
@Schema({ _id: false }) @Schema({ _id: false })
export class BlameRequestSnapshot { export class BlameRequestSnapshot {
@Prop({ type: [PartySchema], default: [] }) @Prop({ type: [PartySchema], default: [] })
parties?: Party[]; parties?: Party[];
} }
export const BlameRequestSnapshotSchema = export const BlameRequestSnapshotSchema =
SchemaFactory.createForClass(BlameRequestSnapshot); SchemaFactory.createForClass(BlameRequestSnapshot);
@Schema({ collection: "blameCases", timestamps: true, id: true })
@Schema({ collection: "blameCases", timestamps: true , id:true })
export class BlameRequest { export class BlameRequest {
@Prop({ required: true, unique: true, index: true, trim: true }) @Prop({ required: true, unique: true, index: true, trim: true })
requestNo: string; requestNo: string;
/** /**
* Human-friendly shared id across blame+claim (e.g. A14235). * Human-friendly shared id across blame+claim (e.g. A14235).
* Generated once at creation time and reused by claim. * Generated once at creation time and reused by claim.
*/ */
@Prop({ required: true, unique: true, index: true, trim: true }) @Prop({ required: true, unique: true, index: true, trim: true })
publicId: string; publicId: string;
@Prop({ required: true, type: String, enum: BlameRequestType }) @Prop({ required: true, type: String, enum: BlameRequestType })
type: BlameRequestType; type: BlameRequestType;
@Prop({ required: true, type: String, enum: CaseStatus, default: CaseStatus.OPEN }) @Prop({
status: CaseStatus; required: true,
type: String,
enum: CaseStatus,
default: CaseStatus.OPEN,
})
status: CaseStatus;
@Prop({ type: String, enum: BlameStatus, default: BlameStatus.UNKNOWN }) @Prop({ type: String, enum: BlameStatus, default: BlameStatus.UNKNOWN })
blameStatus: BlameStatus; blameStatus: BlameStatus;
@Prop({ type: WorkflowSchema }) @Prop({ type: WorkflowSchema })
workflow?: Workflow; workflow?: Workflow;
@Prop({ type: [PartySchema], default: [] }) @Prop({ type: [PartySchema], default: [] })
parties: Party[]; parties: Party[];
@Prop({ type: ExpertSectionSchema }) @Prop({ type: CaseInquiriesSchema, default: () => ({}) })
expert?: ExpertSection; inquiries?: CaseInquiries;
@Prop({ type: [HistoryEventSchema], default: [] }) @Prop({ type: ExpertSectionSchema })
history: HistoryEvent[]; expert?: ExpertSection;
/** CAR_BODY only: first party car-body insurance (mocked inquiry) legacy; prefer parties[0].insurance.carBodyInsurance */ @Prop({ type: [HistoryEventSchema], default: [] })
@Prop({ type: CarBodyInsuranceDetailSchema }) history: HistoryEvent[];
carBodyInsuranceDetail?: CarBodyInsuranceDetail;
/** /** CAR_BODY only: first party car-body insurance (mocked inquiry) legacy; prefer parties[0].insurance.carBodyInsurance */
* Optional snapshot structure (kept for future use / read-optimized copies). @Prop({ type: CarBodyInsuranceDetailSchema })
* Source of truth remains the top-level fields of this document. carBodyInsuranceDetail?: CarBodyInsuranceDetail;
*/
@Prop({ type: BlameRequestSnapshotSchema, required: false })
snapshot?: BlameRequestSnapshot;
/** True when this blame was created by a field expert (independent expert); only that expert can see/review it. */ /**
@Prop({ default: false }) * Optional snapshot structure (kept for future use / read-optimized copies).
expertInitiated?: boolean; * Source of truth remains the top-level fields of this document.
*/
@Prop({ type: BlameRequestSnapshotSchema, required: false })
snapshot?: BlameRequestSnapshot;
/** Field expert who created this file (for expert-initiated flows). */ /** True when this blame was created by a field expert (independent expert); only that expert can see/review it. */
@Prop({ type: Types.ObjectId }) @Prop({ default: false })
initiatedByFieldExpertId?: Types.ObjectId; expertInitiated?: boolean;
/** True when this blame was created by a registrar. */ /** Field expert who created this file (for expert-initiated flows). */
@Prop({ default: false }) @Prop({ type: Types.ObjectId })
registrarInitiated?: boolean; initiatedByFieldExpertId?: Types.ObjectId;
/** Registrar who created this file (for registrar-initiated flows). */ /** True when this blame was created by a registrar. */
@Prop({ type: Types.ObjectId }) @Prop({ default: false })
initiatedByRegistrarId?: Types.ObjectId; registrarInitiated?: boolean;
/** LINK = expert sends link to user(s); IN_PERSON = expert fills form on-site. */ /** Registrar who created this file (for registrar-initiated flows). */
@Prop({ type: String, enum: CreationMethod }) @Prop({ type: Types.ObjectId })
creationMethod?: CreationMethod; initiatedByRegistrarId?: Types.ObjectId;
/** Who fills the blame data: CUSTOMER (LINK) or EXPERT (IN_PERSON). */ /** LINK = expert sends link to user(s); IN_PERSON = expert fills form on-site. */
@Prop({ type: String, enum: FilledBy }) @Prop({ type: String, enum: CreationMethod })
filledBy?: FilledBy; creationMethod?: CreationMethod;
/** Who fills the blame data: CUSTOMER (LINK) or EXPERT (IN_PERSON). */
@Prop({ type: String, enum: FilledBy })
filledBy?: FilledBy;
} }
export type BlameRequestDocument = HydratedDocument<BlameRequest>; export type BlameRequestDocument = HydratedDocument<BlameRequest>;

View File

@@ -123,6 +123,64 @@ export class RequestManagementService {
: -1; : -1;
} }
private normalizeInquiryError(err: any): any {
if (!err) return undefined;
return {
message: err?.message || String(err),
status: err?.response?.status,
data: err?.response?.data,
};
}
private recordCaseInquiryStatus(
req: any,
key: "thirdParty" | "carBody" | "person" | "drivingLicence" | string,
has: boolean,
data: any = {},
error?: any,
): void {
const existingInquiries =
req.inquiries?.toObject?.() ??
(req.inquiries && typeof req.inquiries === "object" ? req.inquiries : {});
req.inquiries = {
...existingInquiries,
[key]: {
has,
data: data ?? {},
...(error ? { error: this.normalizeInquiryError(error) } : {}),
updatedAt: new Date(),
},
};
}
private recordPartyCaseInquiryStatus(
req: any,
key: "thirdParty" | "carBody" | "person" | "drivingLicence" | string,
role: PartyRole,
has: boolean,
data: any = {},
error?: any,
): void {
const existingData =
req?.inquiries?.[key]?.data?.toObject?.() ?? req?.inquiries?.[key]?.data;
const dataByRole =
existingData &&
typeof existingData === "object" &&
!Array.isArray(existingData)
? existingData
: {};
this.recordCaseInquiryStatus(
req,
key,
has,
{
...dataByRole,
[role]: data ?? {},
},
error,
);
}
private assertPartyOwner(party: any, user: any, errMsg: string) { private assertPartyOwner(party: any, user: any, errMsg: string) {
const partyUserId = party?.person?.userId const partyUserId = party?.person?.userId
? String(party.person.userId) ? String(party.person.userId)
@@ -967,6 +1025,11 @@ export class RequestManagementService {
this.logger.log( this.logger.log(
`[TEJARAT] block inquiry mapped for request=${req._id}: ${JSON.stringify(inquiryMapped)}`, `[TEJARAT] block inquiry mapped for request=${req._id}: ${JSON.stringify(inquiryMapped)}`,
); );
this.recordPartyCaseInquiryStatus(req, "thirdParty", role, true, {
source: "TEJARAT_BLOCK_INQUIRY",
raw: inquiryRaw,
mapped: inquiryMapped,
});
} catch (err: any) { } catch (err: any) {
this.logger.error( this.logger.error(
`[TEJARAT] block inquiry failed for request=${req._id}: ${err?.message || err}`, `[TEJARAT] block inquiry failed for request=${req._id}: ${err?.message || err}`,
@@ -978,6 +1041,15 @@ export class RequestManagementService {
}, data=${JSON.stringify(err.response.data)}`, }, data=${JSON.stringify(err.response.data)}`,
); );
} }
this.recordPartyCaseInquiryStatus(
req,
"thirdParty",
role,
false,
{},
err,
);
await (req as any).save();
throw new HttpException("Inquiry failed", HttpStatus.BAD_REQUEST); throw new HttpException("Inquiry failed", HttpStatus.BAD_REQUEST);
} }
@@ -987,6 +1059,12 @@ export class RequestManagementService {
inquiryMapped.Error, inquiryMapped.Error,
)}`, )}`,
); );
this.recordPartyCaseInquiryStatus(req, "thirdParty", role, false, {
source: "TEJARAT_BLOCK_INQUIRY",
raw: inquiryRaw,
mapped: inquiryMapped,
});
await (req as any).save();
throw new HttpException( throw new HttpException(
inquiryMapped.Error.Message || "Inquiry returned error", inquiryMapped.Error.Message || "Inquiry returned error",
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
@@ -1020,6 +1098,13 @@ export class RequestManagementService {
personalNationalCode, personalNationalCode,
personalBirthDate, personalBirthDate,
); );
this.recordPartyCaseInquiryStatus(
req,
"person",
role,
true,
personalInquiry,
);
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,
@@ -1029,6 +1114,8 @@ export class RequestManagementService {
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}`,
); );
this.recordPartyCaseInquiryStatus(req, "person", role, false, {}, err);
await (req as any).save();
throw new HttpException( throw new HttpException(
"Personal identity inquiry failed", "Personal identity inquiry failed",
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
@@ -1120,10 +1207,28 @@ export class RequestManagementService {
// CAR BODY EXTERNAL API INQUIRY // CAR BODY EXTERNAL API INQUIRY
if (req.type === BlameRequestType.CAR_BODY && role === PartyRole.FIRST) { if (req.type === BlameRequestType.CAR_BODY && role === PartyRole.FIRST) {
const carBodyInfo = await this.sandHubService.getTejaratCarBodyInquiry({ let carBodyInfo: any;
nationalCodeOfInsurer: body.nationalCodeOfInsurer, try {
plate: body.plate, carBodyInfo = await this.sandHubService.getTejaratCarBodyInquiry({
}); nationalCodeOfInsurer: body.nationalCodeOfInsurer,
plate: body.plate,
});
this.recordPartyCaseInquiryStatus(req, "carBody", role, true, {
source: "TEJARAT_CAR_BODY_INQUIRY",
raw: carBodyInfo.raw,
mapped: carBodyInfo.mapped,
});
} catch (err: any) {
this.logger.error(
`[TEJARAT] car body inquiry failed for request=${req._id}: ${err?.message || err}`,
);
this.recordPartyCaseInquiryStatus(req, "carBody", role, false, {}, err);
await (req as any).save();
throw new HttpException(
"Car body inquiry failed",
HttpStatus.BAD_REQUEST,
);
}
// Raw + mapped stored under vehicle (same pattern as third-party inquiry) // Raw + mapped stored under vehicle (same pattern as third-party inquiry)
party.vehicle.inquiry = { party.vehicle.inquiry = {
@@ -4829,8 +4934,24 @@ export class RequestManagementService {
nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer,
}); });
sandHubReport = sandHubResponse["_doc"] || sandHubResponse; sandHubReport = sandHubResponse["_doc"] || sandHubResponse;
this.recordPartyCaseInquiryStatus(
req,
"thirdParty",
PartyRole.FIRST,
true,
sandHubReport,
);
} catch (e) { } catch (e) {
this.logger.error("SandHub error in expertCompleteCarBodyFormV2:", e); this.logger.error("SandHub error in expertCompleteCarBodyFormV2:", e);
this.recordPartyCaseInquiryStatus(
req,
"thirdParty",
PartyRole.FIRST,
false,
{},
e,
);
await (req as any).save();
throw new InternalServerErrorException( throw new InternalServerErrorException(
"Failed to process plate information.", "Failed to process plate information.",
); );
@@ -4864,6 +4985,13 @@ export class RequestManagementService {
firstPartyPlate.plate, firstPartyPlate.plate,
firstPartyPlate.nationalCodeOfInsurer, firstPartyPlate.nationalCodeOfInsurer,
); );
this.recordPartyCaseInquiryStatus(
req,
"carBody",
PartyRole.FIRST,
true,
carBodyInfo,
);
const firstParty: any = { const firstParty: any = {
role: PartyRole.FIRST, role: PartyRole.FIRST,
@@ -5010,11 +5138,32 @@ export class RequestManagementService {
desc: string, desc: string,
role: PartyRole, role: PartyRole,
) => { ) => {
const sandHubResponse = await this.sandHubService.getSandHubResponse({ let sandHubResponse: any;
plate: plateDto.plate, try {
nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer, sandHubResponse = await this.sandHubService.getSandHubResponse({
}); plate: plateDto.plate,
nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer,
});
} catch (err: any) {
this.recordPartyCaseInquiryStatus(
req,
"thirdParty",
role,
false,
{},
err,
);
await (req as any).save();
throw err;
}
const sandHubReport = (sandHubResponse["_doc"] || sandHubResponse) as any; const sandHubReport = (sandHubResponse["_doc"] || sandHubResponse) as any;
this.recordPartyCaseInquiryStatus(
req,
"thirdParty",
role,
true,
sandHubReport,
);
const clientName = const clientName =
sandHubReport?.CompanyName || sandHubReport?.LastCompanyName; sandHubReport?.CompanyName || sandHubReport?.LastCompanyName;
const companyCode = sandHubReport?.CompanyCode; const companyCode = sandHubReport?.CompanyCode;