Files
edr-platform/apps/edr-freight-web/backoffice/src/components/warehouses/full-container-excel.ts
Hagernesh 8ef50f9aff feat(warehouses): allow deleting a warehouse
Soft-delete route guarded by warehouses:delete, refused with 409 while
yards remain — zones and inventory hang off a yard, so cascading would
orphan stock. Backoffice list gets a delete action in both views,
omitted when the user lacks the permission.
2026-08-28 14:40:18 +00:00

207 lines
6.6 KiB
TypeScript

import * as XLSX from "xlsx";
// Excel import for loaded containers already sitting in a yard but never
// entered in the system. One row per container. All-or-nothing — any bad row
// rejects the file with row-numbered errors, so a half-registered yard cannot
// happen.
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
export interface ParsedFullContainerRow {
containerNumber: string;
containerSize: string;
companyName: string;
/** ISO instant; null when the cell was empty or unreadable. */
arrivedAt: string | null;
facility: string;
yard: string;
zone: string;
sealNumber: string;
weight: string;
notes: string;
}
export interface FullContainerExcelResult {
rows: ParsedFullContainerRow[];
errors: string[];
}
type ColumnKey =
| "containerNumber"
| "containerSize"
| "companyName"
| "arrivedAt"
| "facility"
| "yard"
| "zone"
| "sealNumber"
| "weight"
| "notes";
/** Match a header cell to a known column, tolerant of casing/spacing/punctuation. */
function headerKey(raw: string): ColumnKey | null {
const h = raw.toLowerCase().replace(/[^a-z]/g, "");
if (!h) return null;
if (h.includes("seal")) return "sealNumber";
if (h.includes("size") || h.includes("type")) return "containerSize";
if (h.includes("company") || h.includes("owner") || h.includes("consignee")) return "companyName";
if (h.includes("arriv") || h.includes("date")) return "arrivedAt";
if (h.includes("facility") || h.includes("warehouse") || h.includes("terminal")) return "facility";
if (h.includes("yard")) return "yard";
if (h.includes("zone")) return "zone";
if (h.includes("weight") || h.includes("vgm")) return "weight";
if (h.includes("note") || h.includes("remark")) return "notes";
if (h.includes("container") || h.includes("number")) return "containerNumber";
return null;
}
/**
* Excel dates arrive either as a serial number (raw cells) or as text.
* Returns an ISO instant, or null when the cell is empty/unparseable.
*/
function normalizeDate(raw: string): string | null {
const v = raw.trim();
if (!v) return null;
if (/^\d{1,6}(\.\d+)?$/.test(v)) {
const serial = Number(v);
if (serial > 20000 && serial < 80000) {
return new Date(Math.round((serial - 25569) * 86400000)).toISOString();
}
}
const parsed = new Date(v);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
}
/** Parse an uploaded workbook into one row per loaded container. */
export async function parseFullContainerExcel(file: File): Promise<FullContainerExcelResult> {
let sheet: XLSX.WorkSheet | undefined;
try {
const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
sheet = workbook.Sheets[workbook.SheetNames[0]];
} catch {
return { rows: [], errors: ["Could not read the file — is it a valid Excel file?"] };
}
if (!sheet) return { rows: [], errors: ["The file has no sheets."] };
const grid = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1, raw: false, defval: "" });
let headerRowIdx = -1;
let columns: Array<ColumnKey | null> = [];
for (let i = 0; i < grid.length; i++) {
const mapped = (grid[i] ?? []).map((c) => headerKey(String(c ?? "")));
if (mapped.includes("containerNumber")) {
headerRowIdx = i;
columns = mapped;
break;
}
}
if (headerRowIdx < 0) {
return {
rows: [],
errors: [
'Could not find a "Container Number" column — download the template to see the expected format.',
],
};
}
const rows: ParsedFullContainerRow[] = [];
const errors: string[] = [];
const numberCounts = new Map<string, number>();
const startOfTomorrow = new Date();
startOfTomorrow.setHours(24, 0, 0, 0);
for (let i = headerRowIdx + 1; i < grid.length; i++) {
const cells = grid[i] ?? [];
if (cells.every((c) => String(c ?? "").trim() === "")) continue;
const rowNo = i + 1; // 1-based, as shown in Excel
const cell = (key: ColumnKey) => {
const idx = columns.indexOf(key);
return idx >= 0 ? String(cells[idx] ?? "").trim() : "";
};
const containerNumber = cell("containerNumber").toUpperCase().replace(/\s/g, "");
if (!ISO_CONTAINER_NUMBER_REGEX.test(containerNumber)) {
errors.push(
`Row ${rowNo}: "${cell("containerNumber") || "—"}" is not a valid ISO container number (e.g. TEMU1234567).`,
);
} else {
numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
}
const arrivedRaw = cell("arrivedAt");
const arrivedAt = normalizeDate(arrivedRaw);
if (arrivedRaw && !arrivedAt) {
errors.push(`Row ${rowNo}: arrival date "${arrivedRaw}" is not a date.`);
}
// The whole point of a backlog is that it arrived in the past.
if (arrivedAt && new Date(arrivedAt).getTime() >= startOfTomorrow.getTime()) {
errors.push(`Row ${rowNo}: arrival date "${arrivedRaw}" is in the future.`);
}
const weightRaw = cell("weight");
if (weightRaw && (Number.isNaN(Number(weightRaw)) || Number(weightRaw) < 0)) {
errors.push(`Row ${rowNo}: weight "${weightRaw}" must be a number of 0 or more.`);
}
rows.push({
containerNumber,
containerSize: cell("containerSize"),
companyName: cell("companyName"),
arrivedAt,
facility: cell("facility"),
yard: cell("yard"),
zone: cell("zone"),
sealNumber: cell("sealNumber"),
weight: weightRaw,
notes: cell("notes"),
});
}
numberCounts.forEach((count, num) => {
if (count > 1) {
errors.push(`Container number ${num} appears ${count} times — numbers must be unique.`);
}
});
if (rows.length === 0 && errors.length === 0) {
errors.push("The sheet has no container rows below the header.");
}
return errors.length > 0 ? { rows: [], errors } : { rows, errors: [] };
}
/** Download the import template with one filled sample row. */
export function downloadFullContainerTemplate() {
const headers = [
"Container Number",
"Container Size",
"Company",
"Arrival Date",
"Facility",
"Yard",
"Zone",
"Seal Number",
"Weight (Tons)",
"Notes",
];
const sample = [
"TEMU1234567",
"40",
"Acme Import PLC",
"2026-03-14",
"Gelan Multipurpose port",
"Yard A",
"Zone 1",
"SL482910",
24.5,
"Backlog — registered from yard tally sheet",
];
const sheet = XLSX.utils.aoa_to_sheet([headers, sample]);
sheet["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 18) }));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sheet, "Full Containers");
XLSX.writeFile(workbook, "full-container-backlog-template.xlsx");
}