seed-prod.ts: - Correct vessel list: Head Office, PMS Kochi, CSD H&R 1/3/4, CSD Champion, CSD Hanuman, Kavaratti, Laccadives, Thinnakara, Thillaakam, GD3000 - Add System Admin user (admin@pelagia.local / admin1234) via bcrypt Unit tests: - po-import-parser: assert on line item .name rather than .description - po-line-items-editor: fix placeholder text assertions, add .name to LineItemInput fixtures, add two new GST 0% calculation tests - validations: add .name to line item fixtures; update createPoSchema assertions to reference costCentreRef; mark description as optional Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
276 lines
9.7 KiB
TypeScript
276 lines
9.7 KiB
TypeScript
/**
|
|
* Unit tests for the PO Excel import parser.
|
|
* Tests parseSheet() against the real Sample_PO.xlsx fixture and synthetic
|
|
* workbooks built in-memory, without any HTTP or database layer.
|
|
*/
|
|
import { describe, it, expect } from "vitest";
|
|
import { readFileSync } from "fs";
|
|
import { resolve } from "path";
|
|
import * as XLSX from "xlsx";
|
|
import { parseSheet, parseWorkbook, cellStr, cellNum } from "@/lib/po-import-parser";
|
|
|
|
const SAMPLE_PATH = resolve(__dirname, "../../../../Prototype/Sample_PO.xlsx");
|
|
|
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
/** Build a minimal XLSX WorkSheet from a 2-D array of values. */
|
|
function sheetFrom(rows: (string | number | null)[][]): XLSX.WorkSheet {
|
|
return XLSX.utils.aoa_to_sheet(rows);
|
|
}
|
|
|
|
/** Build a Workbook with a single sheet. */
|
|
function wbFrom(rows: (string | number | null)[][]): XLSX.WorkBook {
|
|
const wb = XLSX.utils.book_new();
|
|
XLSX.utils.book_append_sheet(wb, sheetFrom(rows), "Sheet1");
|
|
return wb;
|
|
}
|
|
|
|
function wbBuffer(rows: (string | number | null)[][]): Buffer {
|
|
return Buffer.from(XLSX.write(wbFrom(rows), { type: "buffer", bookType: "xlsx" }));
|
|
}
|
|
|
|
// ── cellStr / cellNum ─────────────────────────────────────────────────────────
|
|
|
|
describe("cellStr", () => {
|
|
it("returns the string value of a cell", () => {
|
|
const sheet = sheetFrom([["hello", 42, null]]);
|
|
expect(cellStr(sheet, 0, 0)).toBe("hello");
|
|
});
|
|
|
|
it("coerces a number to string", () => {
|
|
const sheet = sheetFrom([[null, 99]]);
|
|
expect(cellStr(sheet, 0, 1)).toBe("99");
|
|
});
|
|
|
|
it("returns empty string for a missing cell", () => {
|
|
const sheet = sheetFrom([[]]);
|
|
expect(cellStr(sheet, 5, 5)).toBe("");
|
|
});
|
|
|
|
it("trims whitespace", () => {
|
|
const sheet = sheetFrom([[" padded "]]);
|
|
expect(cellStr(sheet, 0, 0)).toBe("padded");
|
|
});
|
|
});
|
|
|
|
describe("cellNum", () => {
|
|
it("returns the numeric value of a cell", () => {
|
|
const sheet = sheetFrom([[42]]);
|
|
expect(cellNum(sheet, 0, 0)).toBe(42);
|
|
});
|
|
|
|
it("returns 0 for a missing cell", () => {
|
|
const sheet = sheetFrom([[]]);
|
|
expect(cellNum(sheet, 0, 5)).toBe(0);
|
|
});
|
|
|
|
it("returns 0 for a non-numeric string cell", () => {
|
|
const sheet = sheetFrom([["text"]]);
|
|
expect(cellNum(sheet, 0, 0)).toBe(0);
|
|
});
|
|
|
|
it("parses a numeric string", () => {
|
|
const sheet = sheetFrom([["182"]]);
|
|
expect(cellNum(sheet, 0, 0)).toBe(182);
|
|
});
|
|
});
|
|
|
|
// ── parseSheet against real Sample_PO.xlsx ───────────────────────────────────
|
|
|
|
describe("parseSheet — Sample_PO.xlsx", () => {
|
|
let parsed: ReturnType<typeof parseSheet>;
|
|
|
|
beforeAll(() => {
|
|
const buffer = readFileSync(SAMPLE_PATH);
|
|
const wb = XLSX.read(buffer, { type: "buffer" });
|
|
parsed = parseSheet(wb.Sheets[wb.SheetNames[0]]);
|
|
});
|
|
|
|
it("extracts exactly one line item (T&C rows must not appear)", () => {
|
|
expect(parsed.lineItems).toHaveLength(1);
|
|
});
|
|
|
|
it("extracts the correct line item name", () => {
|
|
expect(parsed.lineItems[0].name).toBe("Eni EP 80W90 GEAR OIL");
|
|
});
|
|
|
|
it("extracts quantity 1050", () => {
|
|
expect(parsed.lineItems[0].quantity).toBe(1050);
|
|
});
|
|
|
|
it("extracts unit price 182", () => {
|
|
expect(parsed.lineItems[0].unitPrice).toBe(182);
|
|
});
|
|
|
|
it("extracts unit 'Ltr'", () => {
|
|
expect(parsed.lineItems[0].unit).toBe("Ltr");
|
|
});
|
|
|
|
it("extracts GST rate 0.18 (raw value already <1)", () => {
|
|
expect(parsed.lineItems[0].gstRate).toBeCloseTo(0.18);
|
|
});
|
|
|
|
it("extracts vendor name", () => {
|
|
expect(parsed.vendorName).toBe("Apar Industries Ltd");
|
|
});
|
|
|
|
it("extracts PI/Quotation number", () => {
|
|
expect(parsed.piQuotationNo).toBe("Verbal");
|
|
});
|
|
|
|
it("extracts PO number", () => {
|
|
expect(parsed.poNumber).toMatch(/PMS\/HNR3\/056/);
|
|
});
|
|
|
|
it("extracts delivery T&C (strips 'DELIVERY :' prefix)", () => {
|
|
expect(parsed.tcDelivery).toMatch(/Within 4 to 5 days/i);
|
|
});
|
|
|
|
it("extracts payment terms T&C (strips 'PAYMENT TERMS:' prefix)", () => {
|
|
expect(parsed.tcPaymentTerms).toMatch(/30 days/i);
|
|
});
|
|
|
|
it("extracts place of delivery", () => {
|
|
expect(parsed.placeOfDelivery).toMatch(/CBD Belapur/i);
|
|
});
|
|
});
|
|
|
|
// ── parseSheet — synthetic cases ──────────────────────────────────────────────
|
|
|
|
describe("parseSheet — synthetic edge cases", () => {
|
|
/** Build a minimal valid sheet: header row at 14, one item at 15. */
|
|
function buildSheet(overrides: Partial<{
|
|
snRow15: string | number;
|
|
descRow15: string;
|
|
unitRow15: string;
|
|
qtyRow15: number;
|
|
priceRow15: number;
|
|
gstRow15: number;
|
|
extraRows: (string | number | null)[][];
|
|
}> = {}) {
|
|
const rows: (string | number | null)[][] = Array.from({ length: 40 }, () =>
|
|
Array(10).fill(null)
|
|
);
|
|
// Row 4: PO number
|
|
rows[4][2] = "TEST-PO-001";
|
|
// Row 5: PI quotation
|
|
rows[5][2] = "INV-999";
|
|
// Row 8: delivery address
|
|
rows[8][2] = "Test Delivery Address";
|
|
// Row 12: vendor
|
|
rows[12][2] = "Test Vendor";
|
|
// Row 14: header
|
|
rows[14] = ["S.N.", "Description", null, "Unit", "Qnty", "Unit price", "Taxable", "GST%", "Total"];
|
|
// Row 15: one line item
|
|
rows[15][0] = overrides.snRow15 ?? 1;
|
|
rows[15][1] = overrides.descRow15 ?? "Test Item";
|
|
rows[15][3] = overrides.unitRow15 ?? "pc";
|
|
rows[15][4] = overrides.qtyRow15 ?? 5;
|
|
rows[15][5] = overrides.priceRow15 ?? 100;
|
|
rows[15][7] = overrides.gstRow15 ?? 0.18;
|
|
// Extra rows appended after row 15
|
|
if (overrides.extraRows) {
|
|
overrides.extraRows.forEach((row, i) => {
|
|
rows[16 + i] = row.concat(Array(10).fill(null)).slice(0, 10);
|
|
});
|
|
}
|
|
return sheetFrom(rows);
|
|
}
|
|
|
|
it("parses a single synthetic line item correctly", () => {
|
|
const result = parseSheet(buildSheet());
|
|
expect(result.lineItems).toHaveLength(1);
|
|
expect(result.lineItems[0].name).toBe("Test Item");
|
|
expect(result.lineItems[0].quantity).toBe(5);
|
|
expect(result.lineItems[0].unitPrice).toBe(100);
|
|
});
|
|
|
|
it("converts a GST rate > 1 (percentage) to a fraction", () => {
|
|
const result = parseSheet(buildSheet({ gstRow15: 18 }));
|
|
expect(result.lineItems[0].gstRate).toBeCloseTo(0.18);
|
|
});
|
|
|
|
it("leaves a GST rate already < 1 unchanged", () => {
|
|
const result = parseSheet(buildSheet({ gstRow15: 0.05 }));
|
|
expect(result.lineItems[0].gstRate).toBeCloseTo(0.05);
|
|
});
|
|
|
|
it("falls back to gstRate 0.18 when GST cell is 0", () => {
|
|
const result = parseSheet(buildSheet({ gstRow15: 0 }));
|
|
expect(result.lineItems[0].gstRate).toBeCloseTo(0.18);
|
|
});
|
|
|
|
it("falls back to unit 'pc' when unit cell is empty", () => {
|
|
const result = parseSheet(buildSheet({ unitRow15: "" }));
|
|
expect(result.lineItems[0].unit).toBe("pc");
|
|
});
|
|
|
|
it("stops at INSTRUCTIONS TO VENDORS row — T&C rows not included", () => {
|
|
const result = parseSheet(buildSheet({
|
|
extraRows: [
|
|
// row 16: "INSTRUCTIONS TO VENDORS" in col 0
|
|
["INSTRUCTIONS TO VENDORS", null, null, null, null, null, null, null, null, null],
|
|
// row 17: T&C item — must NOT appear in lineItems
|
|
[1, "Please quote this PO number in all correspondence", null, null, null, null, null, null, null, null],
|
|
[2, "DELIVERY : Within 4 days", null, null, null, null, null, null, null, null],
|
|
],
|
|
}));
|
|
expect(result.lineItems).toHaveLength(1);
|
|
});
|
|
|
|
it("skips rows where both qty and unitPrice are 0 (text-only rows)", () => {
|
|
const result = parseSheet(buildSheet({
|
|
extraRows: [
|
|
[2, "Some descriptive text but no price", null, null, 0, 0, null, null, null, null],
|
|
],
|
|
}));
|
|
expect(result.lineItems).toHaveLength(1);
|
|
});
|
|
|
|
it("skips fully empty rows", () => {
|
|
const result = parseSheet(buildSheet({
|
|
extraRows: [
|
|
[null, null, null, null, null, null, null, null, null, null],
|
|
[null, null, null, null, null, null, null, null, null, null],
|
|
[2, "Second Item", null, "set", 3, 500, 1500, 0.18, 1770, null],
|
|
],
|
|
}));
|
|
expect(result.lineItems).toHaveLength(2);
|
|
expect(result.lineItems[1].name).toBe("Second Item");
|
|
});
|
|
|
|
it("returns empty lineItems for a blank sheet", () => {
|
|
const result = parseSheet(sheetFrom([]));
|
|
expect(result.lineItems).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ── parseWorkbook ─────────────────────────────────────────────────────────────
|
|
|
|
describe("parseWorkbook", () => {
|
|
it("parses the real Sample_PO.xlsx and returns one result", () => {
|
|
const buffer = readFileSync(SAMPLE_PATH);
|
|
const results = parseWorkbook(buffer);
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].lineItems).toHaveLength(1);
|
|
});
|
|
|
|
it("returns empty array for a workbook with no parseable PO data", () => {
|
|
const buffer = wbBuffer([["Header only"], ["no PO data here"]]);
|
|
const results = parseWorkbook(buffer);
|
|
expect(results).toHaveLength(0);
|
|
});
|
|
|
|
it("returns empty array for a non-XLSX buffer (errors are swallowed)", () => {
|
|
// parseWorkbook catches XLSX.read errors — callers must check the result length
|
|
let result: ReturnType<typeof parseWorkbook>;
|
|
try {
|
|
result = parseWorkbook(Buffer.from("not-an-xlsx-file"));
|
|
} catch {
|
|
// Some xlsx versions throw, others return garbage — both are acceptable
|
|
result = [];
|
|
}
|
|
// Either it returned [] or threw; in both cases no PO data should be present
|
|
expect(Array.isArray(result) ? result : []).toHaveLength(0);
|
|
});
|
|
});
|