mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 04:33:40 +00:00
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.
207 lines
6.6 KiB
TypeScript
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");
|
|
}
|