FIX USER VALIDATION
This commit is contained in:
parent
dfe2c1423a
commit
387d1881f7
File diff suppressed because one or more lines are too long
@ -84,49 +84,49 @@
|
|||||||
"value": "1",
|
"value": "1",
|
||||||
"type": "default",
|
"type": "default",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"description": "GET documents/recent — page (1-based)."
|
"description": "`GET .../documents/recent` — **page** (integer, **1-based**). Increment to fetch the next page; reset to `1` when you change `recentSearch`, `recentStatus`, or `recentType`."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "recentLimit",
|
"key": "recentLimit",
|
||||||
"value": "30",
|
"value": "15",
|
||||||
"type": "default",
|
"type": "default",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"description": "GET documents/recent — page size (max sensible for UI parity)."
|
"description": "`GET .../documents/recent` — **limit** (page size, number of **document rows** per page). The SPA dashboard offers 10 / 15 / 30 / 50. Larger pages reduce the chance a multi-file CPC batch is split across pages."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "recentSearch",
|
"key": "recentSearch",
|
||||||
"value": "",
|
"value": "",
|
||||||
"type": "default",
|
"type": "default",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"description": "Optional: filter by booking/claim/type text and id (when API supports searchIncludeId)."
|
"description": "Optional **`search`** query: case-insensitive substring on **`booking_id`**, **`claim_id`**, **`document_type`**, and document **`id`** (UUID). Examples: `CPC-114`, `POSTMAN`, part of a UUID. Leave **empty** to list without text filter (matches Dashboard debounced booking search)."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "recentStatus",
|
"key": "recentStatus",
|
||||||
"value": "",
|
"value": "",
|
||||||
"type": "default",
|
"type": "default",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"description": "Leave empty for no filter. Set SUCCESSFUL or UNSUCCESSFUL to match History page filters (backend maps to validation_status sets)."
|
"description": "Optional **`status`** filter. **Empty** or omit in URL = all statuses.\n\n| Value | Server behaviour |\n|-------|------------------|\n| *(empty)* | No status filter — “All submissions”. |\n| `SUCCESSFUL` | `MATCH`, `SUCCESSFUL`, `APPROVED`. |\n| `UNSUCCESSFUL` | `MISMATCH`, `REJECTED`, `UNSUCCESSFUL`, `NEED_MANUAL` — use for **“Rejected / mismatch”** tab parity. |\n| `ALL` | Explicit no-op filter. |\n| Any other string | Treated as exact **`validation_status`** value. |\n\nImplementation: `appendCpcDocumentFilters` in `re-workflow-be/src/services/cpc-cdc/utils.ts`."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "recentType",
|
"key": "recentType",
|
||||||
"value": "",
|
"value": "",
|
||||||
"type": "default",
|
"type": "default",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"description": "Leave empty for no filter. Else: AADHAAR | CPC_AUTH | CSD_PO | RETAIL_INVOICE | AUTHORITY_LETTER (see appendCpcDocumentFilters)."
|
"description": "Optional **`type`** (document family). **Empty** = all types.\n\nSupported tokens include **`AADHAAR`**, **`CPC_AUTH`**, **`CSD_PO`**, **`RETAIL_INVOICE`**, **`ALL`** — server maps to `document_type` `ILIKE` patterns (see same `appendCpcDocumentFilters`)."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "recentSortBy",
|
"key": "recentSortBy",
|
||||||
"value": "createdAt",
|
"value": "createdAt",
|
||||||
"type": "default",
|
"type": "default",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"description": "Sort field: id | bookingId | createdAt | documentType | validationStatus | claimId | matchPercentage."
|
"description": "`sortBy` query — must be one of: **`id`**, **`bookingId`**, **`createdAt`**, **`documentType`**, **`validationStatus`**, **`claimId`**, **`matchPercentage`**. Invalid values fall back to **`createdAt`** in the controller."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "recentOrder",
|
"key": "recentOrder",
|
||||||
"value": "DESC",
|
"value": "desc",
|
||||||
"type": "default",
|
"type": "default",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"description": "ASC or DESC."
|
"description": "`order` query — **`asc`** or **`desc`** (case-insensitive). **`desc`** = newest first (dashboard default)."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "masterReportSearch",
|
"key": "masterReportSearch",
|
||||||
@ -214,6 +214,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"_postman_variable_scope": "environment",
|
"_postman_variable_scope": "environment",
|
||||||
"_postman_exported_at": "2026-04-15T12:00:00.000Z",
|
"_postman_exported_at": "2026-04-20T12:00:00.000Z",
|
||||||
"_postman_exported_using": "RE Workflow CPC-CSD bundle"
|
"_postman_exported_using": "RE Workflow CPC-CSD bundle"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,15 @@ import { CpcHistoryService } from '@services/cpc-cdc/CpcHistoryService';
|
|||||||
import { CpcRuleExtractService } from '@services/cpc-cdc/CpcRuleExtractService';
|
import { CpcRuleExtractService } from '@services/cpc-cdc/CpcRuleExtractService';
|
||||||
import { cpcGcsService } from '@services/cpc-cdc/CpcGcsService';
|
import { cpcGcsService } from '@services/cpc-cdc/CpcGcsService';
|
||||||
import { extractPdfTextFromBuffer } from '@services/cpc-cdc/extractPdfText';
|
import { extractPdfTextFromBuffer } from '@services/cpc-cdc/extractPdfText';
|
||||||
import { appendCpcDocumentFilters, cpcWhereFromAndParts } from '@services/cpc-cdc/utils';
|
import {
|
||||||
|
appendCpcDocumentFilters,
|
||||||
|
canonicalizeMoneyFieldKeysInRecord,
|
||||||
|
canonicalizeRuleFieldKey,
|
||||||
|
cpcWhereFromAndParts,
|
||||||
|
isMoneyFieldKey,
|
||||||
|
sanitizeMoneyValuesInRecord,
|
||||||
|
sanitizePersonNameFieldsInRecord
|
||||||
|
} from '@services/cpc-cdc/utils';
|
||||||
import { gcsStorageService } from '@services/gcsStorage.service';
|
import { gcsStorageService } from '@services/gcsStorage.service';
|
||||||
|
|
||||||
import logger from '@utils/logger';
|
import logger from '@utils/logger';
|
||||||
@ -211,6 +219,27 @@ export class CpcCdcController {
|
|||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
queue = queue.map((entry) => {
|
||||||
|
const rawMsd = (entry.msd_payload || {}) as Record<string, unknown>;
|
||||||
|
const msd_payload = sanitizeMoneyValuesInRecord(canonicalizeMoneyFieldKeysInRecord(rawMsd));
|
||||||
|
const rawKeys = (entry as { expected_field_keys?: unknown }).expected_field_keys;
|
||||||
|
const out: Record<string, unknown> = { ...entry, msd_payload };
|
||||||
|
if (Array.isArray(rawKeys)) {
|
||||||
|
out.expected_field_keys = [
|
||||||
|
...new Set(
|
||||||
|
(rawKeys as unknown[])
|
||||||
|
.map((k) => {
|
||||||
|
const s = String(k ?? '').trim();
|
||||||
|
if (!s) return '';
|
||||||
|
return isMoneyFieldKey(s) ? canonicalizeRuleFieldKey(s) : s;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
const results: any[] = [];
|
const results: any[] = [];
|
||||||
const ipAddress = req.ip || req.headers['x-forwarded-for'] || req.socket.remoteAddress;
|
const ipAddress = req.ip || req.headers['x-forwarded-for'] || req.socket.remoteAddress;
|
||||||
// Production: real Vertex/Gemini only unless CPC_ALLOW_DEGRADED_SAVE_WITHOUT_AI=true.
|
// Production: real Vertex/Gemini only unless CPC_ALLOW_DEGRADED_SAVE_WITHOUT_AI=true.
|
||||||
@ -436,6 +465,19 @@ export class CpcCdcController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Object.assign(
|
||||||
|
extracted,
|
||||||
|
sanitizePersonNameFieldsInRecord({ ...extracted } as Record<string, unknown>)
|
||||||
|
);
|
||||||
|
Object.assign(
|
||||||
|
extracted,
|
||||||
|
canonicalizeMoneyFieldKeysInRecord({ ...extracted } as Record<string, unknown>)
|
||||||
|
);
|
||||||
|
Object.assign(
|
||||||
|
extracted,
|
||||||
|
sanitizeMoneyValuesInRecord({ ...extracted } as Record<string, unknown>)
|
||||||
|
);
|
||||||
|
|
||||||
// 3. Validation
|
// 3. Validation
|
||||||
const v = CpcValidationService.validateSrs(
|
const v = CpcValidationService.validateSrs(
|
||||||
expectedPayload,
|
expectedPayload,
|
||||||
@ -586,16 +628,23 @@ export class CpcCdcController {
|
|||||||
*/
|
*/
|
||||||
async getRecentDocuments(req: Request, res: Response) {
|
async getRecentDocuments(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
const { search, status, type, limit = 50, page, sortBy, order } = req.query;
|
const { search, status, type, limit, page, sortBy, order } = req.query;
|
||||||
const take = parseInt(limit as string);
|
const qFirst = (v: unknown): string => {
|
||||||
const pageNum = parseInt(page as string || '1');
|
if (v == null) return '';
|
||||||
|
if (Array.isArray(v)) return v[0] != null ? String(v[0]) : '';
|
||||||
|
return String(v);
|
||||||
|
};
|
||||||
|
const takeRaw = parseInt(qFirst(limit) || '50', 10);
|
||||||
|
const take = Number.isFinite(takeRaw) && takeRaw > 0 ? Math.min(200, takeRaw) : 50;
|
||||||
|
const pageRaw = parseInt(qFirst(page) || '1', 10);
|
||||||
|
const pageNum = Number.isFinite(pageRaw) && pageRaw > 0 ? pageRaw : 1;
|
||||||
const skip = (pageNum - 1) * take;
|
const skip = (pageNum - 1) * take;
|
||||||
|
|
||||||
const andParts: Record<string, unknown>[] = [];
|
const andParts: Record<string, unknown>[] = [];
|
||||||
appendCpcDocumentFilters(andParts, {
|
appendCpcDocumentFilters(andParts, {
|
||||||
type: type as string,
|
type: qFirst(type) || (type as string),
|
||||||
status: status as string,
|
status: qFirst(status) || (status as string),
|
||||||
search: search as string,
|
search: qFirst(search) || (search as string),
|
||||||
searchIncludeId: true
|
searchIncludeId: true
|
||||||
});
|
});
|
||||||
const where = cpcWhereFromAndParts(andParts);
|
const where = cpcWhereFromAndParts(andParts);
|
||||||
@ -620,13 +669,15 @@ export class CpcCdcController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const pages = count === 0 ? 1 : Math.ceil(count / take);
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
items: enriched,
|
items: enriched,
|
||||||
meta: {
|
meta: {
|
||||||
total: count,
|
total: count,
|
||||||
page: pageNum,
|
page: pageNum,
|
||||||
limit: take,
|
limit: take,
|
||||||
pages: Math.ceil(count / take)
|
pages
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -831,56 +882,15 @@ export class CpcCdcController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manually override validation status
|
* Manual validation override (edit / approve / reject) — disabled; status comes from pipeline only.
|
||||||
*/
|
*/
|
||||||
async updateDocumentStatus(req: Request, res: Response) {
|
async updateDocumentStatus(_req: Request, res: Response) {
|
||||||
try {
|
return res.status(403).json({
|
||||||
const { id } = req.params;
|
error_code: 'MANUAL_DOCUMENT_ACTIONS_DISABLED',
|
||||||
const { status, remarks, correctedFields } = req.body;
|
error_message:
|
||||||
|
'Manual document status updates and corrected-field edits are not available for CPC/CSD documents.',
|
||||||
const document = await CpcDocument.findByPk(id);
|
retryable: false
|
||||||
if (!document) {
|
});
|
||||||
return res.status(404).json({
|
|
||||||
error_code: 'DOCUMENT_NOT_FOUND',
|
|
||||||
error_message: 'Document not found',
|
|
||||||
retryable: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousStatus = document.validationStatus;
|
|
||||||
|
|
||||||
await document.update({
|
|
||||||
validationStatus: status,
|
|
||||||
extractedFields: correctedFields || document.extractedFields,
|
|
||||||
mismatchReasons: remarks ? [{ field: 'MANUAL_REVIEW', expected: '-', actual: remarks }] : document.mismatchReasons
|
|
||||||
});
|
|
||||||
|
|
||||||
const statusRequestId = String(req.headers['x-request-id'] || randomUUID());
|
|
||||||
const statusClientId = String(req.headers['x-client-id'] || (req as any).user?.email || 'unknown');
|
|
||||||
|
|
||||||
await CpcAuditLog.create({
|
|
||||||
documentId: id,
|
|
||||||
action: 'STATUS_UPDATED',
|
|
||||||
performedBy: statusClientId,
|
|
||||||
previousState: { status: previousStatus },
|
|
||||||
newState: {
|
|
||||||
status,
|
|
||||||
request_id: statusRequestId,
|
|
||||||
client_id: statusClientId,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
},
|
|
||||||
remarks: remarks || `Status manual update to ${status}`
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json(document);
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error("[CpcController] updateDocumentStatus Error:", error);
|
|
||||||
return res.status(500).json({
|
|
||||||
error_code: 'INTERNAL_SERVER_ERROR',
|
|
||||||
error_message: 'Failed to update status',
|
|
||||||
retryable: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { calculateMatch } from './utils';
|
import { calculateMatch, normalizePersonNameExtract } from './utils';
|
||||||
|
|
||||||
export type RuleExtractHints = {
|
export type RuleExtractHints = {
|
||||||
/** MSD fields typed in UI — used to find the same text inside the PDF (no "Name:" label needed). */
|
/** MSD fields typed in UI — used to find the same text inside the PDF (no "Name:" label needed). */
|
||||||
@ -303,6 +303,10 @@ export class CpcRuleExtractService {
|
|||||||
if (isCsdPo && displayName) {
|
if (isCsdPo && displayName) {
|
||||||
displayName = CpcRuleExtractService.refineCsdPoCustomerName(t, displayName) ?? displayName;
|
displayName = CpcRuleExtractService.refineCsdPoCustomerName(t, displayName) ?? displayName;
|
||||||
}
|
}
|
||||||
|
if (displayName) {
|
||||||
|
const n = normalizePersonNameExtract(displayName);
|
||||||
|
if (n) displayName = n;
|
||||||
|
}
|
||||||
|
|
||||||
// PAN (Indian format) + MSD hint (PDF may lack strict word boundaries)
|
// PAN (Indian format) + MSD hint (PDF may lack strict word boundaries)
|
||||||
let panFromRegex = t.match(/\b([A-Z]{5}[0-9]{4}[A-Z])\b/i);
|
let panFromRegex = t.match(/\b([A-Z]{5}[0-9]{4}[A-Z])\b/i);
|
||||||
|
|||||||
@ -1,7 +1,14 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { VertexAI } from '@google-cloud/vertexai';
|
import { VertexAI } from '@google-cloud/vertexai';
|
||||||
import { calculateMatch, digitsOnly, normalizeMoney } from './utils';
|
import {
|
||||||
|
calculateMatch,
|
||||||
|
canonicalizeRuleFieldKey,
|
||||||
|
digitsOnly,
|
||||||
|
isPersonalHolderNameField,
|
||||||
|
normalizeMoney,
|
||||||
|
normalizePersonNameExtract
|
||||||
|
} from './utils';
|
||||||
import { getCriteriaLabel } from './CpcHistoryService';
|
import { getCriteriaLabel } from './CpcHistoryService';
|
||||||
import logger from '@utils/logger';
|
import logger from '@utils/logger';
|
||||||
|
|
||||||
@ -177,7 +184,7 @@ function buildMsdStyleMessage(fieldKey: string, status: string, docType?: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pickRuleForKey(rules: Record<string, unknown>, key: string): string {
|
function pickRuleForKey(rules: Record<string, unknown>, key: string): string {
|
||||||
const k = key.toLowerCase();
|
const k = canonicalizeRuleFieldKey(key).toLowerCase();
|
||||||
const candidates = Object.keys(rules)
|
const candidates = Object.keys(rules)
|
||||||
.filter((rk) => rk !== 'default')
|
.filter((rk) => rk !== 'default')
|
||||||
.sort((a, b) => b.length - a.length);
|
.sort((a, b) => b.length - a.length);
|
||||||
@ -348,10 +355,19 @@ export class CpcValidationService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expected = rawExpected;
|
let expected = rawExpected;
|
||||||
const found = findNormalizedValue(extractedFields, key);
|
let found = findNormalizedValue(extractedFields, key);
|
||||||
const confidence = fieldConfidence[key] || 0;
|
const confidence = fieldConfidence[key] || 0;
|
||||||
|
|
||||||
|
if (isPersonalHolderNameField(key)) {
|
||||||
|
const en = normalizePersonNameExtract(String(expected ?? ''));
|
||||||
|
if (en) expected = en as typeof rawExpected;
|
||||||
|
if (found !== undefined && found !== null) {
|
||||||
|
const fn = normalizePersonNameExtract(String(found));
|
||||||
|
if (fn) found = fn as typeof found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ruleKey = pickRuleForKey(rules as Record<string, unknown>, key);
|
const ruleKey = pickRuleForKey(rules as Record<string, unknown>, key);
|
||||||
const rule = rules[ruleKey] || rules.default || DOCUMENT_RULES.GENERIC.default;
|
const rule = rules[ruleKey] || rules.default || DOCUMENT_RULES.GENERIC.default;
|
||||||
|
|
||||||
@ -743,6 +759,8 @@ ${scriptPrefBlock}
|
|||||||
BILINGUAL_FORMS: Indian CPC/CSD forms often print the same label in English and Hindi. For each key in MSD_SCRIPT_PREFERENCE (if present), the MSD value shows which language the user entered — prefer_script is Devanagari (Hindi script) vs Latin (English). When both languages appear for that field on the image/PDF, copy the value whose script matches prefer_script. When only one script is visible, extract that visible value. Never return the other language if both are printed and MSD is clearly single-script. Numeric-only fields (amounts, IDs): use digits as printed; script rule applies mainly to name and free-text fields.
|
BILINGUAL_FORMS: Indian CPC/CSD forms often print the same label in English and Hindi. For each key in MSD_SCRIPT_PREFERENCE (if present), the MSD value shows which language the user entered — prefer_script is Devanagari (Hindi script) vs Latin (English). When both languages appear for that field on the image/PDF, copy the value whose script matches prefer_script. When only one script is visible, extract that visible value. Never return the other language if both are printed and MSD is clearly single-script. Numeric-only fields (amounts, IDs): use digits as printed; script rule applies mainly to name and free-text fields.
|
||||||
|
|
||||||
For Aadhaar: customer_name (holder name), aadhar_number (12 digits, no spaces preferred), optional dob (DDMMYYYY), gender, address. You may also populate legacy keys name and aadhaar_number if visible.
|
For Aadhaar: customer_name (holder name), aadhar_number (12 digits, no spaces preferred), optional dob (DDMMYYYY), gender, address. You may also populate legacy keys name and aadhaar_number if visible.
|
||||||
|
NAME_LINE_VS_MSD: When the printed name includes a relation suffix (S/O, D/O, W/O, C/O, Son of, …) after the holder's name, if REFERENCE_VALUES show the same person's name without that suffix, return only that shorter holder name for customer_name / name / authorized_person_name (do not append the S/O clause).
|
||||||
|
HOLDER_NAME_NO_TITLES: For customer_name, name, and authorized_person_name only — return the person's given name tokens as printed (Latin or Devanagari per script rules). Do NOT include salutations or ranks (Mr, Mrs, Ms, Dr, Prof, Sir, Shri, Smt, Kumari, Lt, Captain, Major, Colonel, General, Admiral, Wing Commander, Group Captain, etc.). Do NOT include relation lines (S/O, D/O, W/O, C/O, Son of, …) or father's name after the holder name; only the holder's own name span.
|
||||||
CRITICAL: For 'address', extract ONLY the physical location details.
|
CRITICAL: For 'address', extract ONLY the physical location details.
|
||||||
${isCsdPo
|
${isCsdPo
|
||||||
? `For CSD Purchase Order: extract po_number (PO reference — exact text), po_amount (digits only, rupees), vendor_name (supplier/dealer company from letterhead or From/Supplier block), customer_name (the human buyer / beneficiary — NOT the dealer company name), invoice_date, signature_and_stamp as yes/no (official stamp or authorized signatory visible). Legacy keys order_or_authorisation_number, invoice_value, govt_signatory_and_stamp_present may be filled with the same values if present.
|
? `For CSD Purchase Order: extract po_number (PO reference — exact text), po_amount (digits only, rupees), vendor_name (supplier/dealer company from letterhead or From/Supplier block), customer_name (the human buyer / beneficiary — NOT the dealer company name), invoice_date, signature_and_stamp as yes/no (official stamp or authorized signatory visible). Legacy keys order_or_authorisation_number, invoice_value, govt_signatory_and_stamp_present may be filled with the same values if present.
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import stringSimilarity from 'string-similarity';
|
import stringSimilarity from 'string-similarity';
|
||||||
import { Op } from 'sequelize';
|
import { Op, cast, col, where as sqlWhere } from 'sequelize';
|
||||||
|
|
||||||
/** Shared list/report filters for CPC documents (parity with legacy CPC-CSD). */
|
/** Shared list/report filters for CPC documents (parity with legacy CPC-CSD). */
|
||||||
export function appendCpcDocumentFilters(
|
export function appendCpcDocumentFilters(
|
||||||
@ -68,14 +68,17 @@ export function appendCpcDocumentFilters(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (search) {
|
const q = String(search ?? '').trim();
|
||||||
const orClause: Record<string, unknown>[] = [
|
if (q) {
|
||||||
{ bookingId: { [Op.iLike]: `%${search}%` } },
|
const pattern = `%${q}%`;
|
||||||
{ claimId: { [Op.iLike]: `%${search}%` } },
|
const orClause: object[] = [
|
||||||
{ documentType: { [Op.iLike]: `%${search}%` } }
|
{ bookingId: { [Op.iLike]: pattern } },
|
||||||
|
{ claimId: { [Op.iLike]: pattern } },
|
||||||
|
{ documentType: { [Op.iLike]: pattern } }
|
||||||
];
|
];
|
||||||
if (searchIncludeId) {
|
if (searchIncludeId) {
|
||||||
orClause.unshift({ id: { [Op.iLike]: `%${search}%` } });
|
// Postgres: `uuid ILIKE '…'` is invalid — cast so id substring search works and does not break the whole OR.
|
||||||
|
orClause.unshift(sqlWhere(cast(col('id'), 'TEXT'), { [Op.iLike]: pattern }));
|
||||||
}
|
}
|
||||||
andParts.push({ [Op.or]: orClause });
|
andParts.push({ [Op.or]: orClause });
|
||||||
}
|
}
|
||||||
@ -97,6 +100,170 @@ export function normalizeMoney(str: string | null | undefined): string {
|
|||||||
return String(Math.round(num));
|
return String(Math.round(num));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Compact key for rule lookup / money detection (spaces, hyphens, underscores removed). */
|
||||||
|
export function compactFieldKey(rawKey: string): string {
|
||||||
|
return String(rawKey || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\s_-]+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for MSD/extraction keys that represent rupee amounts (commas / Indian grouping should be ignored).
|
||||||
|
*/
|
||||||
|
export function isMoneyFieldKey(rawKey: string): boolean {
|
||||||
|
const k = compactFieldKey(rawKey);
|
||||||
|
if (!k) return false;
|
||||||
|
if (k.includes('amount')) return true;
|
||||||
|
if (k.includes('invoicevalue')) return true;
|
||||||
|
if (k.includes('totalvalue')) return true;
|
||||||
|
if (k.includes('taxamount')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lowercase + spaces/hyphens → underscores for all keys; compact camelCase aliases **only for money keys**
|
||||||
|
* (e.g. poAmount / Po Amount → po_amount). Non-money keys are unchanged except whitespace normalization.
|
||||||
|
*/
|
||||||
|
export function canonicalizeRuleFieldKey(rawKey: string): string {
|
||||||
|
const k = String(rawKey || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\s-]+/g, '_');
|
||||||
|
if (!isMoneyFieldKey(k) && !isMoneyFieldKey(rawKey)) {
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
const compact = k.replace(/_/g, '');
|
||||||
|
const amountAliases: Record<string, string> = {
|
||||||
|
poamount: 'po_amount',
|
||||||
|
letteramount: 'letter_amount',
|
||||||
|
invoicevalue: 'invoice_value',
|
||||||
|
taxamount: 'tax_amount',
|
||||||
|
totalamount: 'total_amount'
|
||||||
|
};
|
||||||
|
if (amountAliases[compact]) return amountAliases[compact];
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rename payload keys so money fields use canonical snake_case (e.g. poAmount → po_amount). Non-money keys untouched. */
|
||||||
|
export function canonicalizeMoneyFieldKeysInRecord(obj: Record<string, unknown> | null | undefined): Record<string, unknown> {
|
||||||
|
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return (obj || {}) as Record<string, unknown>;
|
||||||
|
const out = { ...obj };
|
||||||
|
for (const key of [...Object.keys(out)]) {
|
||||||
|
if (!isMoneyFieldKey(key)) continue;
|
||||||
|
const nk = canonicalizeRuleFieldKey(key);
|
||||||
|
if (nk === key) continue;
|
||||||
|
const v = out[key];
|
||||||
|
delete out[key];
|
||||||
|
if (out[nk] === undefined) out[nk] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize money-type values to plain digit strings (no commas) for MSD / extracted payloads. */
|
||||||
|
export function sanitizeMoneyValuesInRecord(obj: Record<string, unknown> | null | undefined): Record<string, unknown> {
|
||||||
|
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return (obj || {}) as Record<string, unknown>;
|
||||||
|
const out: Record<string, unknown> = { ...obj };
|
||||||
|
for (const key of Object.keys(out)) {
|
||||||
|
if (!isMoneyFieldKey(key)) continue;
|
||||||
|
const v = out[key];
|
||||||
|
if (v === null || v === undefined) continue;
|
||||||
|
const s = String(v).trim();
|
||||||
|
if (!s) continue;
|
||||||
|
const nm = normalizeMoney(s);
|
||||||
|
if (nm !== '') out[key] = nm;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip trailing relation / father-name suffix (S/O, W/O, …) so "Arjun Mehar S/O Radheshyam Mehar" → "Arjun Mehar".
|
||||||
|
*/
|
||||||
|
export function trimPatronymicSuffixFromName(s: string | null | undefined): string {
|
||||||
|
let t = cleanText(s);
|
||||||
|
if (!t) return '';
|
||||||
|
const re = /\b(?:s\/o|w\/o|d\/o|c\/o|son\s+of|daughter\s+of|wife\s+of|husband\s+of|care\s+of)\b/i;
|
||||||
|
const parts = t.split(re);
|
||||||
|
t = (parts[0] ?? t).trim();
|
||||||
|
t = t.split(/[,;]/)[0]?.trim() ?? t;
|
||||||
|
return cleanText(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multi-word military / rank prefixes at the start of a name line (longest first). */
|
||||||
|
const MULTI_TITLE_PREFIX_RES: RegExp[] = [
|
||||||
|
/^air\s+vice\s+marshal\s+/i,
|
||||||
|
/^air\s+commodore\s+/i,
|
||||||
|
/^vice\s+admiral\s+/i,
|
||||||
|
/^rear\s+admiral\s+/i,
|
||||||
|
/^group\s+captain\s+/i,
|
||||||
|
/^wing\s+commander\s+/i,
|
||||||
|
/^sqn\s+ldr\.?\s+/i,
|
||||||
|
/^flying\s+officer\s+/i,
|
||||||
|
/^fg\s+offr\.?\s+/i
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Single-token salutations / ranks at the start (repeat until none). */
|
||||||
|
const SINGLE_TITLE_PREFIX_RE =
|
||||||
|
/^(?:mr|mrs|ms|miss|dr\.?|doctor|prof\.?|sir|madam|shri|smt\.?|smti\.?|kumari|kum\.?|lt\.?|lieut\.?|lieutenant|leftenant|capt\.?|captain|maj\.?|major|col\.?|colonel|brig\.?|brigadier|gen\.?|general|cmdr|commander|cmde|commodore|adm\.?|admiral|hon\.?|honorable|honourable|retd\.?|svc)\s+/i;
|
||||||
|
|
||||||
|
function stripLeadingSalutationsAndTitles(s: string): string {
|
||||||
|
let t = cleanText(s);
|
||||||
|
for (let guard = 0; guard < 24; guard++) {
|
||||||
|
let removed = false;
|
||||||
|
for (const re of MULTI_TITLE_PREFIX_RES) {
|
||||||
|
if (re.test(t)) {
|
||||||
|
t = t.replace(re, '').trim();
|
||||||
|
removed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (removed) continue;
|
||||||
|
if (SINGLE_TITLE_PREFIX_RE.test(t)) {
|
||||||
|
t = t.replace(SINGLE_TITLE_PREFIX_RE, '').trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holder-style person name for extraction / compare: no leading Dr./military rank tokens, no S/O-style suffixes.
|
||||||
|
*/
|
||||||
|
export function normalizePersonNameExtract(s: string | null | undefined): string {
|
||||||
|
if (s == null || !String(s).trim()) return '';
|
||||||
|
let t = stripLeadingSalutationsAndTitles(String(s));
|
||||||
|
t = trimPatronymicSuffixFromName(t);
|
||||||
|
return cleanText(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip salutations / relation clutter from holder name fields on an extracted / payload object. */
|
||||||
|
export function sanitizePersonNameFieldsInRecord(obj: Record<string, unknown> | null | undefined): Record<string, unknown> {
|
||||||
|
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return (obj || {}) as Record<string, unknown>;
|
||||||
|
const out = { ...obj };
|
||||||
|
for (const key of Object.keys(out)) {
|
||||||
|
if (!isPersonalHolderNameField(key)) continue;
|
||||||
|
const v = out[key];
|
||||||
|
if (v === null || v === undefined) continue;
|
||||||
|
const n = normalizePersonNameExtract(String(v));
|
||||||
|
if (n) out[key] = n;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer / holder person name fields (not supplier, grantor, or company). */
|
||||||
|
export function isPersonalHolderNameField(rawKey: string): boolean {
|
||||||
|
const k = compactFieldKey(rawKey);
|
||||||
|
if (!k) return false;
|
||||||
|
if (/(vendor|grantor|supplier|dealer|company|business)/.test(k)) return false;
|
||||||
|
return (
|
||||||
|
k === 'name' ||
|
||||||
|
k === 'customername' ||
|
||||||
|
k === 'authorizedpersonname' ||
|
||||||
|
k === 'accountholdername'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function cleanText(str: string | null | undefined): string {
|
export function cleanText(str: string | null | undefined): string {
|
||||||
return String(str || "").trim().replace(/\s+/g, " ");
|
return String(str || "").trim().replace(/\s+/g, " ");
|
||||||
}
|
}
|
||||||
@ -172,6 +339,45 @@ export function calculateMatch(expected: string, found: string, key: string = ""
|
|||||||
expStr = cleanAddress(expStr).toLowerCase();
|
expStr = cleanAddress(expStr).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2a. Personal name (MSD): document may print "Arjun Mehar S/O Radheshyam Mehar" while MSD is "Arjun Mehar".
|
||||||
|
// Strip S/O-style suffixes from the document side, then pass if the full MSD phrase appears as a whole phrase.
|
||||||
|
if (isPersonalHolderNameField(lowerKey)) {
|
||||||
|
const expTrim = trimPatronymicSuffixFromName(expStr).toLowerCase().replace(/\s+/g, ' ').trim();
|
||||||
|
const fndTrim = trimPatronymicSuffixFromName(fndStr).toLowerCase().replace(/\s+/g, ' ').trim();
|
||||||
|
if (expTrim.length >= 2 && fndTrim.length >= 2) {
|
||||||
|
const phraseOk = (hay: string, needle: string) => {
|
||||||
|
if (hay === needle) return true;
|
||||||
|
if (hay.startsWith(needle)) {
|
||||||
|
if (hay.length === needle.length) return true;
|
||||||
|
const next = hay.charAt(needle.length);
|
||||||
|
return /\s|[,;/]/.test(next);
|
||||||
|
}
|
||||||
|
const esc = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
return new RegExp(`(^|\\s)${esc}(\\s|$)`).test(hay);
|
||||||
|
};
|
||||||
|
if (expTrim.length >= 3 && phraseOk(fndTrim, expTrim)) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
expStr = expTrim;
|
||||||
|
fndStr = fndTrim;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b. Money: ignore commas, ₹, spaces — compare numeric rupees (aligns browser vs API + Gemini "1,93,533")
|
||||||
|
if (isMoneyFieldKey(lowerKey)) {
|
||||||
|
const expM = normalizeMoney(expStr);
|
||||||
|
const fndM = normalizeMoney(fndStr);
|
||||||
|
if (expM && fndM && expM === fndM) return 100;
|
||||||
|
const a = expM ? Number(expM) : NaN;
|
||||||
|
const b = fndM ? Number(fndM) : NaN;
|
||||||
|
if (!Number.isNaN(a) && !Number.isNaN(b)) {
|
||||||
|
if (Math.abs(a - b) <= 5) return 100;
|
||||||
|
const maxv = Math.max(Math.abs(a), Math.abs(b), 1);
|
||||||
|
const pct = Math.round(100 - Math.min(100, (Math.abs(a - b) / maxv) * 100));
|
||||||
|
return Math.max(0, pct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Exact match
|
// 3. Exact match
|
||||||
if (expStr === fndStr) return 100;
|
if (expStr === fndStr) return 100;
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user