pelagia-portal/App/lib/po-import-parser.ts
Hardik e308d86e93 feat: Companies — multi-company PO support with admin CRUD and export integration
Schema:
- New Company model (name, gstNumber, address, telephone, mobile, email, invoiceAddress, isActive)
- PurchaseOrder.companyId FK (optional, SET NULL on company delete)
- Migration: 20260530000003_add_company

Admin:
- /admin/companies page with full CRUD (create, edit, deactivate, delete)
- Companies table shows name, GST, contact details, status
- Companies link added to Admin section of sidebar (Briefcase icon)

PO forms (new / edit / import / manager-edit):
- Company dropdown appears at the top of Order Information when companies exist
- Pre-populated with first active company; selection persisted to DB via companyId

Import form:
- parseSheet() now extracts companyName from Excel row 1 (col A)
- Import preview auto-matches detected company name against known companies
- Shows detected name as a hint; user can override before saving

Export (PDF + XLSX):
- Company constants (CO_NAME, CO_ADDR, CO_TEL, INV_ADDR, INV_GST) are now
  derived from the linked Company record when present, falling back to the
  original Pelagia Marine hardcoded defaults when no company is set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 19:31:34 +05:30

124 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as XLSX from "xlsx";
export type ParsedImportLine = {
name: string;
unit: string;
quantity: number;
unitPrice: number;
gstRate: number;
};
export type ParsedImport = {
companyName: string;
poNumber: string;
piQuotationNo: string;
placeOfDelivery: string;
tcDelivery: string;
tcDispatch: string;
tcInspection: string;
tcTransitInsurance: string;
tcPaymentTerms: string;
tcOthers: string;
vendorName: string;
vendorAddress: string;
vendorContact: string;
lineItems: ParsedImportLine[];
};
export function cellStr(sheet: XLSX.WorkSheet, row: number, col: number): string {
const addr = XLSX.utils.encode_cell({ r: row, c: col });
const cell = sheet[addr];
if (!cell) return "";
return String(cell.v ?? "").trim();
}
export function cellNum(sheet: XLSX.WorkSheet, row: number, col: number): number {
const addr = XLSX.utils.encode_cell({ r: row, c: col });
const cell = sheet[addr];
if (!cell) return 0;
const v = parseFloat(String(cell.v));
return isNaN(v) ? 0 : v;
}
export function parseSheet(sheet: XLSX.WorkSheet): ParsedImport {
// Row 1 (index 0) = company name, spanning the full header (col 0)
const companyName = cellStr(sheet, 0, 0);
const poNumber = cellStr(sheet, 4, 2);
const piQuotationNo = cellStr(sheet, 5, 2);
const placeOfDelivery = cellStr(sheet, 8, 2);
const vendorName = cellStr(sheet, 12, 2);
const vendorAddress = cellStr(sheet, 12, 3);
const vendorContact = cellStr(sheet, 13, 2);
// T&C from instruction rows 2833 (col 1)
const tcDelivery = cellStr(sheet, 28, 1).replace(/^DELIVERY\s*:\s*/i, "").trim();
const tcDispatch = cellStr(sheet, 29, 1).replace(/^DISPATCH INSTRUCTIONS:\s*/i, "").trim();
const tcInspection = cellStr(sheet, 30, 1).replace(/^INSPECTION\s*:\s*/i, "").trim();
const tcTransitInsurance = cellStr(sheet, 31, 1).replace(/^TRANSIT INSURANCE:\s*/i, "").trim();
const tcPaymentTerms = cellStr(sheet, 32, 1).replace(/^PAYMENT TERMS:\s*/i, "").trim();
const tcOthers = cellStr(sheet, 33, 1).trim();
const lineItems: ParsedImportLine[] = [];
for (let r = 15; r <= 100; r++) {
const sn = cellStr(sheet, r, 0);
const desc = cellStr(sheet, r, 1);
// "INSTRUCTIONS TO VENDORS" in col 0 signals the T&C section — stop here
if (sn.toUpperCase().includes("INSTRUCTION")) break;
if (!desc && !sn) continue;
if (!desc) continue;
if (desc.toLowerCase().includes("total") || desc.toLowerCase().includes("grand")) break;
const unitRaw = cellStr(sheet, r, 3);
const qty = cellNum(sheet, r, 4);
const unitPrice = cellNum(sheet, r, 5);
// Skip rows with no quantity and no unit price (T&C text rows, etc.)
if (qty === 0 && unitPrice === 0) continue;
const gstRaw = cellNum(sheet, r, 7);
const gstRate = gstRaw > 1 ? gstRaw / 100 : gstRaw;
lineItems.push({
name: desc,
unit: unitRaw || "pc",
quantity: qty || 1,
unitPrice,
gstRate: gstRate || 0.18,
});
}
return {
companyName,
poNumber,
piQuotationNo,
placeOfDelivery,
tcDelivery,
tcDispatch,
tcInspection,
tcTransitInsurance,
tcPaymentTerms,
tcOthers,
vendorName,
vendorAddress,
vendorContact,
lineItems,
};
}
export function parseWorkbook(buffer: Buffer): ParsedImport[] {
const workbook = XLSX.read(buffer, { type: "buffer" });
const results: ParsedImport[] = [];
for (const sheetName of workbook.SheetNames) {
const sheet = workbook.Sheets[sheetName];
try {
const parsed = parseSheet(sheet);
if (parsed.lineItems.length > 0) results.push(parsed);
} catch {
// skip unparseable sheets
}
}
return results;
}