[OWL] WhatsApp parser v2 + mobile responsiveness + parser API
WhatsApp Parser v2: - Pre-processing: normalize whitespace, expand abbreviations (frt→freight, adv→advance, etc.) - Number format normalization: 1.5L→150000, 50K→50000, 2.5lakhs→250000 - Context-aware amount classification (freight vs advance vs commission vs driver pay) - Multiple route patterns: 'X to Y', 'X → Y', 'From X to Y', 'X - Y' - Vehicle number normalization with flexible spacing - Date extraction (DD/MM/YYYY, DD-MM-YY, 15 Jan 2026) - Material type and weight extraction - Auto-calculate commission (5% default or freight - driver rate) - Auto-calculate pending amounts - Confidence scoring (high/medium/low) - POST /api/parse-whatsapp endpoint - GET /api/parser/test with sample messages Mobile Responsiveness: - Hamburger menu button on mobile - Slide-in sidebar overlay - Responsive stats grid (4→2→1 columns) - Stacked filters and form rows on small screens - Full-width action buttons - Smaller login container on mobile - Horizontal scroll for tables - Pagination stacking on small screens
This commit is contained in:
parent
07c025e698
commit
04657b9f29
5 changed files with 449 additions and 103 deletions
|
|
@ -493,12 +493,67 @@ body {
|
||||||
.grid-2 { grid-template-columns: 1fr; }
|
.grid-2 { grid-template-columns: 1fr; }
|
||||||
.sidebar { display: none; }
|
.sidebar { display: none; }
|
||||||
.stats-grid { grid-template-columns: 1fr 1fr; }
|
.stats-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
.mobile-menu-btn { display: flex; }
|
||||||
|
.main-content { padding: 12px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.stats-grid { grid-template-columns: 1fr; }
|
.stats-grid { grid-template-columns: 1fr; }
|
||||||
.form-row { flex-direction: column; }
|
.form-row { flex-direction: column; }
|
||||||
.filter-bar { flex-direction: column; }
|
.filter-bar { flex-direction: column; }
|
||||||
|
.filter-bar .form-group { width: 100%; }
|
||||||
|
.page-header { flex-direction: column; gap: 12px; align-items: flex-start; }
|
||||||
|
.page-actions { width: 100%; display: flex; gap: 8px; }
|
||||||
|
.page-actions .btn { flex: 1; text-align: center; }
|
||||||
|
.card-header { flex-direction: column; gap: 8px; }
|
||||||
|
.table-responsive { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||||
|
.topbar { padding: 0 12px; }
|
||||||
|
.brand-hi { font-size: 13px; }
|
||||||
|
.brand-en { font-size: 9px; }
|
||||||
|
.login-container { margin: 16px; padding: 24px 20px; }
|
||||||
|
.detail-grid { grid-template-columns: 1fr; }
|
||||||
|
.pagination { flex-direction: column; gap: 8px; text-align: center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile menu toggle button */
|
||||||
|
.mobile-menu-btn {
|
||||||
|
display: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--white);
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile sidebar overlay */
|
||||||
|
.sidebar-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
z-index: 998;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay.active { display: block; }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.sidebar.mobile-open {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
top: 64px;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 999;
|
||||||
|
width: 260px;
|
||||||
|
box-shadow: 4px 0 16px rgba(0,0,0,0.3);
|
||||||
|
animation: slideIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { transform: translateX(-100%); }
|
||||||
|
to { transform: translateX(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
|
|
@ -564,6 +619,46 @@ body {
|
||||||
border: 1px solid rgba(19,136,8,0.2);
|
border: 1px solid rgba(19,136,8,0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
WHATSAPP PARSER
|
||||||
|
============================================================ */
|
||||||
|
.parse-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 140px 1fr;
|
||||||
|
gap: 6px 12px;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parse-field {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parse-key {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parse-val {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.parse-result {
|
||||||
|
background: rgba(0,0,128,0.04);
|
||||||
|
border: 1px solid rgba(0,0,128,0.15);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parse-result h4 {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
EMPTY STATE
|
EMPTY STATE
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,17 @@ document.querySelectorAll('form[onsubmit]').forEach(function(form) {
|
||||||
// WhatsApp parser (inline function for form page)
|
// WhatsApp parser (inline function for form page)
|
||||||
// parseWhatsApp() and applyParsed() are defined inline in the form view
|
// parseWhatsApp() and applyParsed() are defined inline in the form view
|
||||||
|
|
||||||
|
// Mobile menu toggle
|
||||||
|
function toggleMobileMenu() {
|
||||||
|
const sidebar = document.querySelector('.sidebar');
|
||||||
|
const overlay = document.getElementById('sidebarOverlay');
|
||||||
|
if (sidebar && overlay) {
|
||||||
|
sidebar.classList.toggle('mobile-open');
|
||||||
|
overlay.classList.toggle('active');
|
||||||
|
document.body.style.overflow = sidebar.classList.contains('mobile-open') ? 'hidden' : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Format number as INR
|
// Format number as INR
|
||||||
function formatINR(num) {
|
function formatINR(num) {
|
||||||
if (num === null || num === undefined || isNaN(num)) return '—';
|
if (num === null || num === undefined || isNaN(num)) return '—';
|
||||||
|
|
|
||||||
|
|
@ -212,4 +212,38 @@ router.get('/stats', asyncHandler(async (req, res) => {
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// WHATSAPP PARSER API
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// POST /api/parse-whatsapp — parse a WhatsApp message
|
||||||
|
router.post('/parse-whatsapp', asyncHandler(async (req, res) => {
|
||||||
|
const { message } = req.body;
|
||||||
|
if (!message || typeof message !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Message is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { parseWhatsAppMessage } = require('../services/parser');
|
||||||
|
const result = parseWhatsAppMessage(message);
|
||||||
|
res.json(result);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// GET /api/parser/test — test parser with sample messages (dev only)
|
||||||
|
router.get('/parser/test', requireRole('admin'), asyncHandler(async (req, res) => {
|
||||||
|
const { parseWhatsAppMessage } = require('../services/parser');
|
||||||
|
const samples = [
|
||||||
|
'Kahn Transport KL01AB1234 Bangalore to Chennai freight 50000 advance 20000 loaded',
|
||||||
|
'Vehicle: KL 05 XY 6789 From Mumbai to Kochi via Goa. Freight: ₹75,000/- Driver rate: ₹60,000. Commission: ₹15,000. Delivered.',
|
||||||
|
'Agarwal Packers MH12CD5678 Delhi to Trivandrum. ₹1.5L freight. ₹50K advance received. In transit.',
|
||||||
|
'TN09EF9012 Coimbatore to Hyderabad goods: electronics wt: 500kg ₹45000/= assigned vehicle',
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = samples.map(msg => ({
|
||||||
|
input: msg,
|
||||||
|
parsed: parseWhatsAppMessage(msg),
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(results);
|
||||||
|
}));
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
// WhatsApp message parser for FreightDesk
|
// WhatsApp message parser for FreightDesk v2
|
||||||
// Parses natural language freight messages into structured data
|
// Parses natural language freight messages into structured data
|
||||||
|
// Handles common Kerala/India freight message formats
|
||||||
|
|
||||||
const { CITIES } = require('../config/constants');
|
const { CITIES } = require('../config/constants');
|
||||||
|
|
||||||
// Known shipper names (from existing data)
|
// Known shipper names (from existing data + common Kerala names)
|
||||||
const KNOWN_SHIPPERS = [
|
const KNOWN_SHIPPERS = [
|
||||||
'Kahn Transport', 'Agarwal Packers and Movers', 'Agarwal', 'Sahara Packers',
|
'Kahn Transport', 'Agarwal Packers and Movers', 'Agarwal', 'Sahara Packers',
|
||||||
'Ambika Packers', 'Century Polymers', 'DRS', 'Superstar', 'Superstar Packers',
|
'Ambika Packers', 'Century Polymers', 'DRS', 'Superstar', 'Superstar Packers',
|
||||||
|
|
@ -15,27 +16,218 @@ const KNOWN_SHIPPERS = [
|
||||||
'Mohamed Anas', 'Nair', 'Badadosth',
|
'Mohamed Anas', 'Nair', 'Badadosth',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Status keywords mapping
|
// Status keywords mapping (ordered by specificity — most specific first)
|
||||||
const STATUS_KEYWORDS = {
|
const STATUS_KEYWORDS = {
|
||||||
'pending lead': ['pending lead', 'lead', 'enquiry', 'enquiry'],
|
'settled': ['settled', 'fully settled', 'payment received in full'],
|
||||||
'assigned vehicle': ['assigned vehicle', 'vehicle assigned'],
|
'commission received': ['commission received', 'comm received', 'commission got'],
|
||||||
'assigned': ['assigned', 'allotted'],
|
|
||||||
'loaded / in transit': ['loaded', 'in transit', 'on the way', 'dispatched', 'started'],
|
|
||||||
'delivered / pending collection': ['delivered', 'delivery done'],
|
|
||||||
'pending collection': ['pending collection', 'collection pending', 'to collect'],
|
|
||||||
'partially pending': ['partially pending', 'partial pending'],
|
|
||||||
'fully pending from shipper': ['fully pending', 'no payment'],
|
|
||||||
'settled': ['settled', 'complete', 'completed', 'closed'],
|
|
||||||
'commission received': ['commission received', 'comm received'],
|
|
||||||
'commission adjusted': ['commission adjusted', 'comm adjusted'],
|
'commission adjusted': ['commission adjusted', 'comm adjusted'],
|
||||||
'commission due': ['commission due', 'comm due'],
|
'reconciled': ['reconciled', 'recon done'],
|
||||||
'reconciled': ['reconciled'],
|
'completed': ['completed', 'fully completed'],
|
||||||
'completed': ['completed', 'done'],
|
'delivered / pending collection': ['delivered', 'delivery done', 'reached', 'reached destination', 'delivered successfully'],
|
||||||
'handled directly by shipper': ['directly by shipper', 'handled directly'],
|
'pending collection': ['pending collection', 'collection pending', 'to collect', 'amount pending'],
|
||||||
'available vehicle': ['available', 'vehicle available'],
|
'partially pending': ['partially pending', 'partial payment received'],
|
||||||
|
'fully pending from shipper': ['fully pending', 'no payment received', 'nothing received'],
|
||||||
|
'loaded / in transit': ['loaded', 'in transit', 'on the way', 'dispatched', 'started', 'left', 'moving', 'on route'],
|
||||||
|
'assigned vehicle': ['assigned vehicle', 'vehicle assigned', 'truck assigned'],
|
||||||
|
'assigned': ['assigned', 'allotted', 'booking confirmed'],
|
||||||
|
'pending lead': ['pending lead', 'lead', 'enquiry', 'just enquiry'],
|
||||||
|
'commission due': ['commission due', 'comm due', 'commission pending'],
|
||||||
|
'cancelled': ['cancelled', 'canceled', 'booking cancelled'],
|
||||||
|
'available vehicle': ['available', 'vehicle available', 'truck available'],
|
||||||
'partial': ['partial'],
|
'partial': ['partial'],
|
||||||
|
'handled directly by shipper': ['directly by shipper', 'handled directly', 'direct handling'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Common abbreviations in Kerala freight messages
|
||||||
|
const ABBREVIATIONS = {
|
||||||
|
'frt': 'freight',
|
||||||
|
'adv': 'advance',
|
||||||
|
'recd': 'received',
|
||||||
|
'pd': 'paid',
|
||||||
|
'coll': 'collection',
|
||||||
|
'del': 'delivered',
|
||||||
|
'trpt': 'transport',
|
||||||
|
'shpr': 'shipper',
|
||||||
|
'vhcl': 'vehicle',
|
||||||
|
'drv': 'driver',
|
||||||
|
'cmn': 'commission',
|
||||||
|
'amt': 'amount',
|
||||||
|
'qty': 'quantity',
|
||||||
|
'wt': 'weight',
|
||||||
|
'pcs': 'pieces',
|
||||||
|
'pkt': 'packet',
|
||||||
|
'ctn': 'carton',
|
||||||
|
'bdl': 'bundle',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-process message: normalize whitespace, expand abbreviations
|
||||||
|
*/
|
||||||
|
function preprocessMessage(text) {
|
||||||
|
let processed = text.trim();
|
||||||
|
|
||||||
|
// Normalize whitespace (WhatsApp often has irregular spacing)
|
||||||
|
processed = processed.replace(/\r\n/g, '\n').replace(/\s+/g, ' ');
|
||||||
|
|
||||||
|
// Expand common abbreviations
|
||||||
|
for (const [abbr, full] of Object.entries(ABBREVIATIONS)) {
|
||||||
|
const regex = new RegExp(`\\b${abbr}\\b`, 'gi');
|
||||||
|
processed = processed.replace(regex, full);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize common number formats
|
||||||
|
// "1.5L" → "150000", "2.5lakhs" → "250000"
|
||||||
|
processed = processed.replace(/(\d+\.?\d*)\s*L\b/gi, (m, n) => String(Math.round(parseFloat(n) * 100000)));
|
||||||
|
processed = processed.replace(/(\d+\.?\d*)\s*(?:lakhs?|lacs?)\b/gi, (m, n) => String(Math.round(parseFloat(n) * 100000)));
|
||||||
|
// "50K" → "50000"
|
||||||
|
processed = processed.replace(/(\d+\.?\d*)\s*K\b/gi, (m, n) => String(Math.round(parseFloat(n) * 1000)));
|
||||||
|
|
||||||
|
// Normalize vehicle number spacing: "KL 01 AB 1234" → "KL01AB1234"
|
||||||
|
processed = processed.replace(/\b([A-Z]{2})\s*(\d{1,2})\s*([A-Z]{1,3})\s*(\d{4})\b/gi, '$1$2$3$4');
|
||||||
|
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all currency amounts from text with context
|
||||||
|
*/
|
||||||
|
function extractAmounts(text) {
|
||||||
|
const amounts = [];
|
||||||
|
|
||||||
|
// Pattern: ₹X,XXX or Rs. X,XXX or X,XXX/-
|
||||||
|
const patterns = [
|
||||||
|
/₹\s*([\d,]+(?:\.\d{1,2})?)/g,
|
||||||
|
/Rs\.?\s*([\d,]+(?:\.\d{1,2})?)/gi,
|
||||||
|
/INR\s*([\d,]+(?:\.\d{1,2})?)/gi,
|
||||||
|
/([\d,]+(?:\.\d{1,2})?)\s*\/-(?!\d)/g,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(text)) !== null) {
|
||||||
|
const value = parseInt(match[1].replace(/,/g, ''));
|
||||||
|
if (value > 0) {
|
||||||
|
// Get surrounding context (20 chars before and after)
|
||||||
|
const start = Math.max(0, match.index - 20);
|
||||||
|
const end = Math.min(text.length, match.index + match[0].length + 20);
|
||||||
|
const context = text.substring(start, end).toLowerCase();
|
||||||
|
amounts.push({ value, context, raw: match[0] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return amounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine which amount is which based on context
|
||||||
|
*/
|
||||||
|
function classifyAmounts(amounts) {
|
||||||
|
const classified = {
|
||||||
|
freight_charged: null,
|
||||||
|
advance_received: null,
|
||||||
|
paid_to_driver: null,
|
||||||
|
commission: null,
|
||||||
|
driver_freight: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const contextMap = [
|
||||||
|
{ field: 'freight_charged', keywords: ['freight', 'charged', 'total', 'amount', 'bill', 'rate', 'frt'] },
|
||||||
|
{ field: 'advance_received', keywords: ['advance', 'received', 'paid by shipper', 'adv', 'recd'] },
|
||||||
|
{ field: 'paid_to_driver', keywords: ['paid to driver', 'driver advance', 'driver paid', 'to driver', 'drv paid'] },
|
||||||
|
{ field: 'commission', keywords: ['commission', 'comm', 'cmn', 'my commission'] },
|
||||||
|
{ field: 'driver_freight', keywords: ['driver freight', 'driver rate', 'driver amount', 'to driver', 'drv rate'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const amount of amounts) {
|
||||||
|
let bestMatch = null;
|
||||||
|
let bestScore = 0;
|
||||||
|
|
||||||
|
for (const mapping of contextMap) {
|
||||||
|
for (const keyword of mapping.keywords) {
|
||||||
|
if (amount.context.includes(keyword)) {
|
||||||
|
const score = keyword.length; // longer keyword = more specific match
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestMatch = mapping.field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestMatch && !classified[bestMatch]) {
|
||||||
|
classified[bestMatch] = amount.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have amounts but no classification, use heuristics
|
||||||
|
const unclassified = amounts.filter(a => {
|
||||||
|
return !Object.values(classified).includes(a.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (classified.freight_charged === null && amounts.length > 0) {
|
||||||
|
// Largest amount is usually freight
|
||||||
|
const sorted = [...amounts].sort((a, b) => b.value - a.value);
|
||||||
|
classified.freight_charged = sorted[0].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return classified;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse route with multiple patterns
|
||||||
|
*/
|
||||||
|
function parseRoute(text, lower) {
|
||||||
|
const cities = CITIES || [];
|
||||||
|
let from_city = null, to_city = null, via = null;
|
||||||
|
|
||||||
|
// Build city pattern (escape special regex chars)
|
||||||
|
const cityPattern = cities.map(c => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
|
||||||
|
|
||||||
|
// Pattern 1: "From X to Y" / "X to Y" / "X → Y" / "X - Y"
|
||||||
|
const routePatterns = [
|
||||||
|
new RegExp(`(?:from\\s+)?(${cityPattern})\\s*(?:to|→|->|–|—|-)\\s*(${cityPattern})`, 'i'),
|
||||||
|
new RegExp(`(${cityPattern})\\s*(?:to|→|->|–|—|-)\\s*(${cityPattern})`, 'i'),
|
||||||
|
new RegExp(`(${cityPattern})\\s+to\\s+(${cityPattern})`, 'i'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of routePatterns) {
|
||||||
|
const match = text.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
from_city = match[1];
|
||||||
|
to_city = match[2];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 2: "via X" for intermediate stops
|
||||||
|
const viaMatch = text.match(/via\s+([A-Za-z\s]+?)(?:\s+(?:to|→|-|loaded|freight|₹|\d{4,}|$))/i);
|
||||||
|
if (viaMatch) {
|
||||||
|
via = viaMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 3: If no route found, try to find any known cities
|
||||||
|
if (!from_city || !to_city) {
|
||||||
|
const found = [];
|
||||||
|
for (const city of cities) {
|
||||||
|
if (lower.includes(city.toLowerCase())) {
|
||||||
|
found.push(city);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found.length >= 2 && !from_city && !to_city) {
|
||||||
|
from_city = found[0];
|
||||||
|
to_city = found[1];
|
||||||
|
} else if (found.length === 1 && !to_city) {
|
||||||
|
to_city = found[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { from_city, to_city, via };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main parser function
|
||||||
|
*/
|
||||||
function parseWhatsAppMessage(text) {
|
function parseWhatsAppMessage(text) {
|
||||||
const result = {
|
const result = {
|
||||||
shipper: null,
|
shipper: null,
|
||||||
|
|
@ -51,14 +243,19 @@ function parseWhatsAppMessage(text) {
|
||||||
driver_freight: null,
|
driver_freight: null,
|
||||||
pending_from_shipper: null,
|
pending_from_shipper: null,
|
||||||
pending_to_driver: null,
|
pending_to_driver: null,
|
||||||
|
date: null,
|
||||||
|
material: null,
|
||||||
|
weight: null,
|
||||||
notes: text,
|
notes: text,
|
||||||
confidence: 'low',
|
confidence: 'low',
|
||||||
parsed_fields: [],
|
parsed_fields: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const lower = text.toLowerCase();
|
// Pre-process message
|
||||||
|
const processed = preprocessMessage(text);
|
||||||
|
const lower = processed.toLowerCase();
|
||||||
|
|
||||||
// 1. Parse shipper
|
// 1. Parse shipper (check known shippers first, then try to extract from context)
|
||||||
for (const shipper of KNOWN_SHIPPERS) {
|
for (const shipper of KNOWN_SHIPPERS) {
|
||||||
if (lower.includes(shipper.toLowerCase())) {
|
if (lower.includes(shipper.toLowerCase())) {
|
||||||
result.shipper = shipper;
|
result.shipper = shipper;
|
||||||
|
|
@ -67,43 +264,45 @@ function parseWhatsAppMessage(text) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Parse vehicle number (Indian format: XX00XX0000)
|
// If no known shipper, try to extract from patterns like "Shp: X" or "From: X (shipper)"
|
||||||
const vehicleMatch = text.match(/\b([A-Z]{2}\s*\d{1,2}\s*[A-Z]{1,3}\s*\d{4})\b/i);
|
if (!result.shipper) {
|
||||||
if (vehicleMatch) {
|
const shipperPatterns = [
|
||||||
result.vehicle = vehicleMatch[1].replace(/\s/g, '').toUpperCase();
|
/(?:shp|shipper|from\s+shp|client)\s*[:\\-]\\s*([A-Za-z\s]+?)(?:\\s*(?:to|→|-|vehicle|loaded|freight|₹|\d{4,}|$))/i,
|
||||||
|
/(?:booking\\s+from|received\\s+from)\\s+([A-Za-z\s]+?)(?:\\s*(?:to|→|-|vehicle|loaded|freight|₹|\d{4,}|$))/i,
|
||||||
|
];
|
||||||
|
for (const pattern of shipperPatterns) {
|
||||||
|
const match = processed.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
result.shipper = match[1].trim();
|
||||||
|
result.parsed_fields.push('shipper');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Parse vehicle number (Indian format with flexible spacing)
|
||||||
|
const vehiclePatterns = [
|
||||||
|
/\b([A-Z]{2}\s*\d{1,2}\s*[A-Z]{1,3}\s*\d{4})\b/i, // Standard: KL01AB1234
|
||||||
|
/\b([A-Z]{2}\s*\d{2}\s*[A-Z]{2}\s*\d{4})\b/i, // KL 01 AB 1234
|
||||||
|
/\b(vehicle|truck|vhcl)\s*[:#]?\s*([A-Z]{2}\d{1,2}[A-Z]{1,3}\d{4})\b/i, // "Vehicle: KL01AB1234"
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < vehiclePatterns.length; i++) {
|
||||||
|
const match = processed.match(vehiclePatterns[i]);
|
||||||
|
if (match) {
|
||||||
|
result.vehicle = (match[2] || match[1]).replace(/\s/g, '').toUpperCase();
|
||||||
result.parsed_fields.push('vehicle');
|
result.parsed_fields.push('vehicle');
|
||||||
}
|
break;
|
||||||
|
|
||||||
// 3. Parse cities (from → to pattern)
|
|
||||||
const cityPattern = CITIES.map(c => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
|
|
||||||
const routeMatch = text.match(new RegExp(`(${cityPattern})\\s*(?:to|→|-|via)\\s*(${cityPattern})`, 'i'));
|
|
||||||
if (routeMatch) {
|
|
||||||
result.from_city = routeMatch[1];
|
|
||||||
result.to_city = routeMatch[2];
|
|
||||||
result.parsed_fields.push('from_city', 'to_city');
|
|
||||||
} else {
|
|
||||||
// Try to find any known city
|
|
||||||
for (const city of CITIES) {
|
|
||||||
if (lower.includes(city.toLowerCase())) {
|
|
||||||
if (!result.to_city) {
|
|
||||||
result.to_city = city;
|
|
||||||
result.parsed_fields.push('to_city');
|
|
||||||
} else if (!result.from_city) {
|
|
||||||
result.from_city = city;
|
|
||||||
result.parsed_fields.push('from_city');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Parse via
|
// 3. Parse route
|
||||||
const viaMatch = text.match(/via\s+([A-Za-z\s,]+?)(?:\s*(?:to|→|-|loaded|freight|₹|\d{4,}))/i);
|
const route = parseRoute(processed, lower);
|
||||||
if (viaMatch) {
|
if (route.from_city) { result.from_city = route.from_city; result.parsed_fields.push('from_city'); }
|
||||||
result.via = viaMatch[1].trim();
|
if (route.to_city) { result.to_city = route.to_city; result.parsed_fields.push('to_city'); }
|
||||||
result.parsed_fields.push('via');
|
if (route.via) { result.via = route.via; result.parsed_fields.push('via'); }
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Parse status
|
// 4. Parse status (most specific first)
|
||||||
for (const [status, keywords] of Object.entries(STATUS_KEYWORDS)) {
|
for (const [status, keywords] of Object.entries(STATUS_KEYWORDS)) {
|
||||||
for (const kw of keywords) {
|
for (const kw of keywords) {
|
||||||
if (lower.includes(kw)) {
|
if (lower.includes(kw)) {
|
||||||
|
|
@ -115,76 +314,79 @@ function parseWhatsAppMessage(text) {
|
||||||
if (result.status) break;
|
if (result.status) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Parse amounts
|
// 5. Parse amounts with context-aware classification
|
||||||
// Freight: look for "freight", "charged", "total" followed by number
|
const amounts = extractAmounts(processed);
|
||||||
const freightMatch = text.match(/(?:freight|charged|total|amount|bill)\s*[:\-]?\s*₹?\s*(\d[\d,]*)/i);
|
const classified = classifyAmounts(amounts);
|
||||||
if (freightMatch) {
|
|
||||||
result.freight_charged = parseInt(freightMatch[1].replace(/,/g, ''));
|
if (classified.freight_charged) { result.freight_charged = classified.freight_charged; result.parsed_fields.push('freight_charged'); }
|
||||||
result.parsed_fields.push('freight_charged');
|
if (classified.advance_received) { result.advance_received = classified.advance_received; result.parsed_fields.push('advance_received'); }
|
||||||
} else {
|
if (classified.paid_to_driver) { result.paid_to_driver = classified.paid_to_driver; result.parsed_fields.push('paid_to_driver'); }
|
||||||
// Try standalone large numbers (4-6 digits) that could be freight
|
if (classified.commission) { result.commission = classified.commission; result.parsed_fields.push('commission'); }
|
||||||
const amountMatches = text.match(/₹?\s*(\d{4,6})\b/g);
|
if (classified.driver_freight) { result.driver_freight = classified.driver_freight; result.parsed_fields.push('driver_freight'); }
|
||||||
if (amountMatches) {
|
|
||||||
const amounts = amountMatches.map(m => parseInt(m.replace(/[₹,\s]/g, '')));
|
// 6. Parse date (common formats in WhatsApp)
|
||||||
if (amounts.length > 0) {
|
const datePatterns = [
|
||||||
result.freight_charged = Math.max(...amounts);
|
/(\d{1,2})[\/\-.](\d{1,2})[\/\-.](\d{2,4})/, // DD/MM/YYYY or DD-MM-YY
|
||||||
result.parsed_fields.push('freight_charged');
|
/(\d{1,2})\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w*\s+(\d{2,4})/i, // 15 Jan 2026
|
||||||
}
|
];
|
||||||
|
for (const pattern of datePatterns) {
|
||||||
|
const match = processed.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
result.date = match[0];
|
||||||
|
result.parsed_fields.push('date');
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Advance received
|
// 7. Parse material type
|
||||||
const advanceMatch = text.match(/(?:advance|received|paid by shipper)\s*[:\-]?\s*₹?\s*(\d[\d,]*)/i);
|
const materialPatterns = [
|
||||||
if (advanceMatch) {
|
/(?:material|goods|load|items?)\s*[:\\-]?\s*([A-Za-z\s]+?)(?:\\s*(?:wt|weight|qty|quantity|₹|\d{4,}|$))/i,
|
||||||
result.advance_received = parseInt(advanceMatch[1].replace(/,/g, ''));
|
/(furniture|electronics|machinery|food|grains|cement|steel|tiles|cement bags|sugar|rice|cotton|textile|plastic|chemical|hardware|auto parts|automobile)/i,
|
||||||
result.parsed_fields.push('advance_received');
|
];
|
||||||
|
for (const pattern of materialPatterns) {
|
||||||
|
const match = processed.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
result.material = match[1].trim();
|
||||||
|
result.parsed_fields.push('material');
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paid to driver
|
// 8. Parse weight
|
||||||
const driverPaidMatch = text.match(/(?:paid to driver|driver advance|driver paid|to driver)\s*[:\-]?\s*₹?\s*(\d[\d,]*)/i);
|
const weightMatch = processed.match(/(?:wt|weight|w)\s*[:\\-]?\s*([\d.]+)\s*(?:kg|tons?|tonnes?|quintals?|qtl|MT|mt)/i);
|
||||||
if (driverPaidMatch) {
|
if (weightMatch) {
|
||||||
result.paid_to_driver = parseInt(driverPaidMatch[1].replace(/,/g, ''));
|
result.weight = weightMatch[0].trim();
|
||||||
result.parsed_fields.push('paid_to_driver');
|
result.parsed_fields.push('weight');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commission
|
// 9. Auto-calculate derived fields
|
||||||
const commissionMatch = text.match(/(?:commission|comm)\s*[:\-]?\s*₹?\s*(\d[\d,]*)/i);
|
if (!result.commission && result.freight_charged && result.driver_freight) {
|
||||||
if (commissionMatch) {
|
result.commission = result.freight_charged - result.driver_freight;
|
||||||
result.commission = parseInt(commissionMatch[1].replace(/,/g, ''));
|
result.parsed_fields.push('commission (auto: freight - driver)');
|
||||||
result.parsed_fields.push('commission');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Driver freight
|
if (!result.commission && result.freight_charged && !result.driver_freight) {
|
||||||
const driverFreightMatch = text.match(/(?:driver freight|driver rate|driver amount)\s*[:\-]?\s*₹?\s*(\d[\d,]*)/i);
|
// Default 5% commission if only freight is known
|
||||||
if (driverFreightMatch) {
|
result.commission = Math.round(result.freight_charged * 0.05);
|
||||||
result.driver_freight = parseInt(driverFreightMatch[1].replace(/,/g, ''));
|
result.parsed_fields.push('commission (auto: 5%)');
|
||||||
result.parsed_fields.push('driver_freight');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-calculate commission if not parsed
|
if (result.freight_charged && !result.pending_from_shipper) {
|
||||||
if (!result.commission && result.freight_charged && result.paid_to_driver) {
|
|
||||||
result.commission = result.freight_charged - result.paid_to_driver;
|
|
||||||
result.parsed_fields.push('commission (auto)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-calculate pending from shipper
|
|
||||||
if (!result.pending_from_shipper && result.freight_charged) {
|
|
||||||
result.pending_from_shipper = result.freight_charged - (result.advance_received || 0);
|
result.pending_from_shipper = result.freight_charged - (result.advance_received || 0);
|
||||||
if (result.pending_from_shipper > 0) result.parsed_fields.push('pending_from_shipper (auto)');
|
if (result.pending_from_shipper > 0) result.parsed_fields.push('pending_from_shipper (auto)');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-calculate pending to driver
|
if (result.driver_freight && !result.pending_to_driver) {
|
||||||
if (!result.pending_to_driver && result.driver_freight) {
|
|
||||||
result.pending_to_driver = result.driver_freight - (result.paid_to_driver || 0);
|
result.pending_to_driver = result.driver_freight - (result.paid_to_driver || 0);
|
||||||
if (result.pending_to_driver > 0) result.parsed_fields.push('pending_to_driver (auto)');
|
if (result.pending_to_driver > 0) result.parsed_fields.push('pending_to_driver (auto)');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confidence based on how many fields were parsed
|
// 10. Confidence score
|
||||||
const fieldCount = result.parsed_fields.length;
|
const fieldCount = result.parsed_fields.length;
|
||||||
if (fieldCount >= 6) result.confidence = 'high';
|
if (fieldCount >= 7) result.confidence = 'high';
|
||||||
else if (fieldCount >= 3) result.confidence = 'medium';
|
else if (fieldCount >= 4) result.confidence = 'medium';
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { parseWhatsAppMessage, KNOWN_SHIPPERS };
|
module.exports = { parseWhatsAppMessage, KNOWN_SHIPPERS, preprocessMessage, extractAmounts };
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,16 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
|
<button class="mobile-menu-btn" onclick="toggleMobileMenu()" title="Menu">☰</button>
|
||||||
<button onclick="toggleTheme()" class="btn-icon" title="Toggle theme">☀</button>
|
<button onclick="toggleTheme()" class="btn-icon" title="Toggle theme">☀</button>
|
||||||
<span class="user-name">👤 <%= user.username %></span>
|
<span class="user-name">👤 <%= user.username %></span>
|
||||||
<a href="/logout" class="btn btn-sm btn-outline">Logout</a>
|
<a href="/logout" class="btn btn-sm btn-outline">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- Mobile sidebar overlay -->
|
||||||
|
<div class="sidebar-overlay" id="sidebarOverlay" onclick="toggleMobileMenu()"></div>
|
||||||
|
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue