- Express + EJS server-rendered app - Supabase PostgreSQL database - Auth: username/password with bcrypt - Dashboard with business stats - Load CRUD with filters - WhatsApp message parser - Payment tracking - Shipper & vehicle management - Reports (monthly, top shippers, routes) - Government-app aesthetic (tricolor theme) - Dark mode support - Docker + Coolify deployment ready - Seed data from existing business ledger (88 loads, 41 shippers, 70 vehicles)
190 lines
7 KiB
JavaScript
190 lines
7 KiB
JavaScript
// WhatsApp message parser for FreightDesk
|
|
// Parses natural language freight messages into structured data
|
|
|
|
const { CITIES } = require('../config/constants');
|
|
|
|
// Known shipper names (from existing data)
|
|
const KNOWN_SHIPPERS = [
|
|
'Kahn Transport', 'Agarwal Packers and Movers', 'Agarwal', 'Sahara Packers',
|
|
'Ambika Packers', 'Century Polymers', 'DRS', 'Superstar', 'Superstar Packers',
|
|
'Chips', 'Chipps', 'Indian CBE', 'Indian CBE Shipper', 'KTC', 'ATC', 'TCI',
|
|
'Filatex', 'Dryfish John', 'Sun Packers', 'Thangavel', 'Nafees Alappuzha',
|
|
'Shivaprasad', 'Jinu Coin', 'Balmer Thuni', 'Pasupathy', 'Sulphi Baddest',
|
|
'KRS', 'Hirosh', 'Hirosh Roadways', 'Aero Rubber', 'Silverstar',
|
|
'DRS Agarwal', 'Gem', 'E20 Packers and Movers', 'CRT Transport',
|
|
'Mohamed Anas', 'Nair', 'Badadosth',
|
|
];
|
|
|
|
// Status keywords mapping
|
|
const STATUS_KEYWORDS = {
|
|
'pending lead': ['pending lead', 'lead', 'enquiry', 'enquiry'],
|
|
'assigned vehicle': ['assigned vehicle', 'vehicle assigned'],
|
|
'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 due': ['commission due', 'comm due'],
|
|
'reconciled': ['reconciled'],
|
|
'completed': ['completed', 'done'],
|
|
'handled directly by shipper': ['directly by shipper', 'handled directly'],
|
|
'available vehicle': ['available', 'vehicle available'],
|
|
'partial': ['partial'],
|
|
};
|
|
|
|
function parseWhatsAppMessage(text) {
|
|
const result = {
|
|
shipper: null,
|
|
vehicle: null,
|
|
from_city: null,
|
|
to_city: null,
|
|
via: null,
|
|
status: null,
|
|
freight_charged: null,
|
|
advance_received: null,
|
|
paid_to_driver: null,
|
|
commission: null,
|
|
driver_freight: null,
|
|
pending_from_shipper: null,
|
|
pending_to_driver: null,
|
|
notes: text,
|
|
confidence: 'low',
|
|
parsed_fields: [],
|
|
};
|
|
|
|
const lower = text.toLowerCase();
|
|
|
|
// 1. Parse shipper
|
|
for (const shipper of KNOWN_SHIPPERS) {
|
|
if (lower.includes(shipper.toLowerCase())) {
|
|
result.shipper = shipper;
|
|
result.parsed_fields.push('shipper');
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 2. Parse vehicle number (Indian format: XX00XX0000)
|
|
const vehicleMatch = text.match(/\b([A-Z]{2}\s*\d{1,2}\s*[A-Z]{1,3}\s*\d{4})\b/i);
|
|
if (vehicleMatch) {
|
|
result.vehicle = vehicleMatch[1].replace(/\s/g, '').toUpperCase();
|
|
result.parsed_fields.push('vehicle');
|
|
}
|
|
|
|
// 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
|
|
const viaMatch = text.match(/via\s+([A-Za-z\s,]+?)(?:\s*(?:to|→|-|loaded|freight|₹|\d{4,}))/i);
|
|
if (viaMatch) {
|
|
result.via = viaMatch[1].trim();
|
|
result.parsed_fields.push('via');
|
|
}
|
|
|
|
// 5. Parse status
|
|
for (const [status, keywords] of Object.entries(STATUS_KEYWORDS)) {
|
|
for (const kw of keywords) {
|
|
if (lower.includes(kw)) {
|
|
result.status = status;
|
|
result.parsed_fields.push('status');
|
|
break;
|
|
}
|
|
}
|
|
if (result.status) break;
|
|
}
|
|
|
|
// 6. Parse amounts
|
|
// Freight: look for "freight", "charged", "total" followed by number
|
|
const freightMatch = text.match(/(?:freight|charged|total|amount|bill)\s*[:\-]?\s*₹?\s*(\d[\d,]*)/i);
|
|
if (freightMatch) {
|
|
result.freight_charged = parseInt(freightMatch[1].replace(/,/g, ''));
|
|
result.parsed_fields.push('freight_charged');
|
|
} else {
|
|
// Try standalone large numbers (4-6 digits) that could be freight
|
|
const amountMatches = text.match(/₹?\s*(\d{4,6})\b/g);
|
|
if (amountMatches) {
|
|
const amounts = amountMatches.map(m => parseInt(m.replace(/[₹,\s]/g, '')));
|
|
if (amounts.length > 0) {
|
|
result.freight_charged = Math.max(...amounts);
|
|
result.parsed_fields.push('freight_charged');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Advance received
|
|
const advanceMatch = text.match(/(?:advance|received|paid by shipper)\s*[:\-]?\s*₹?\s*(\d[\d,]*)/i);
|
|
if (advanceMatch) {
|
|
result.advance_received = parseInt(advanceMatch[1].replace(/,/g, ''));
|
|
result.parsed_fields.push('advance_received');
|
|
}
|
|
|
|
// Paid to driver
|
|
const driverPaidMatch = text.match(/(?:paid to driver|driver advance|driver paid|to driver)\s*[:\-]?\s*₹?\s*(\d[\d,]*)/i);
|
|
if (driverPaidMatch) {
|
|
result.paid_to_driver = parseInt(driverPaidMatch[1].replace(/,/g, ''));
|
|
result.parsed_fields.push('paid_to_driver');
|
|
}
|
|
|
|
// Commission
|
|
const commissionMatch = text.match(/(?:commission|comm)\s*[:\-]?\s*₹?\s*(\d[\d,]*)/i);
|
|
if (commissionMatch) {
|
|
result.commission = parseInt(commissionMatch[1].replace(/,/g, ''));
|
|
result.parsed_fields.push('commission');
|
|
}
|
|
|
|
// Driver freight
|
|
const driverFreightMatch = text.match(/(?:driver freight|driver rate|driver amount)\s*[:\-]?\s*₹?\s*(\d[\d,]*)/i);
|
|
if (driverFreightMatch) {
|
|
result.driver_freight = parseInt(driverFreightMatch[1].replace(/,/g, ''));
|
|
result.parsed_fields.push('driver_freight');
|
|
}
|
|
|
|
// Auto-calculate commission if not parsed
|
|
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);
|
|
if (result.pending_from_shipper > 0) result.parsed_fields.push('pending_from_shipper (auto)');
|
|
}
|
|
|
|
// Auto-calculate pending to driver
|
|
if (!result.pending_to_driver && result.driver_freight) {
|
|
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)');
|
|
}
|
|
|
|
// Confidence based on how many fields were parsed
|
|
const fieldCount = result.parsed_fields.length;
|
|
if (fieldCount >= 6) result.confidence = 'high';
|
|
else if (fieldCount >= 3) result.confidence = 'medium';
|
|
|
|
return result;
|
|
}
|
|
|
|
module.exports = { parseWhatsAppMessage, KNOWN_SHIPPERS };
|