Add unit tests and components for invoicing period flag handling and self-wash view rendering

This commit is contained in:
Jeppe Bundgaard
2026-05-11 21:40:51 +02:00
parent 81c4ba0800
commit a261f2f3bd
62 changed files with 10158 additions and 698 deletions
+243
View File
@@ -178,6 +178,233 @@ function normalizePositiveIntegerValue(value) {
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function normalizeReferenceKey(value) {
return String(value ?? "")
.trim()
.toLowerCase();
}
function normalizeReferencePlate(value) {
return String(value ?? "")
.trim()
.toUpperCase()
.replace(/\s+/g, "");
}
function referenceMatchScore(reference, search) {
const referenceKey = normalizeReferenceKey(reference);
const searchKey = normalizeReferenceKey(search);
if (!searchKey) {
return 0;
}
if (referenceKey === searchKey) {
return 1000;
}
if (referenceKey.startsWith(searchKey)) {
return 600;
}
if (referenceKey.includes(searchKey)) {
return 300;
}
return 0;
}
function referenceSourceScore(source) {
if (source === "booking") {
return 30;
}
if (source === "order") {
return 20;
}
if (source === "vehicle") {
return 10;
}
return 0;
}
function referenceContextBoost(row, customerId, plates) {
let score = 0;
if (customerId && Number(row.customer_id || 0) === Number(customerId)) {
score += 80;
}
if (referenceMatchesAnyPlate(row, plates)) {
score += 90;
}
return score;
}
function referenceMatchesAnyPlate(row, plates) {
const rowPlates = [row.reg_1, row.reg_2, row.reg_3].map(normalizeReferencePlate).filter(Boolean);
return plates.some((plate) => rowPlates.includes(plate));
}
function referenceSection(row, customerId, plates) {
if (referenceMatchesAnyPlate(row, plates)) {
return "this_vehicle";
}
if (customerId && Number(row.customer_id || 0) === Number(customerId)) {
return "other_customer_vehicle";
}
return "other";
}
function referenceSectionScore(section) {
if (section === "this_vehicle") {
return 40;
}
if (section === "other_customer_vehicle") {
return 20;
}
return 0;
}
function buildReferenceSuggestions(
posFixture,
{ search = "", departmentId = null, customerId = null, plates = [], limit = 10 }
) {
const normalizedSearch = String(search || "").trim();
const normalizedDepartmentId = Number(departmentId || 0);
const normalizedCustomerId = Number(customerId || 0);
const normalizedPlates = plates.map(normalizeReferencePlate).filter(Boolean);
const rows = [];
for (const booking of posFixture.orderBookings || []) {
const reference = String(booking.reference ?? booking.reference_number ?? "").trim();
if (
!reference ||
booking.deleted_at ||
Number(booking.department ?? booking.department_id ?? 0) !== normalizedDepartmentId
) {
continue;
}
if (normalizedSearch && !normalizeReferenceKey(reference).includes(normalizeReferenceKey(normalizedSearch))) {
continue;
}
rows.push({
source: "booking",
origin_id: Number(booking.id || 0),
reference,
source_created_at: booking.datetime || booking.created_at || "",
used_at: booking.datetime || booking.created_at || "",
customer_id: Number(booking.customer_number || booking.customer_id || 0),
reg_1: booking.reg_1 || booking.regNr || "",
reg_2: booking.reg_2 || booking.regNrTrailer || "",
reg_3: booking.reg_3 || "",
});
}
for (const order of Object.values(posFixture.ordersById || {})) {
const reference = String(order.reference || "").trim();
if (!reference || order.deleted_at || Number(order.department_id || 0) !== normalizedDepartmentId) {
continue;
}
if (normalizedSearch && !normalizeReferenceKey(reference).includes(normalizeReferenceKey(normalizedSearch))) {
continue;
}
rows.push({
source: "order",
origin_id: Number(order.id || 0),
reference,
source_created_at: order.created_at || "",
used_at: order.created_at || "",
customer_id: Number(order.customer_id || 0),
reg_1: order.reg_1 || "",
reg_2: order.reg_2 || "",
reg_3: order.reg_3 || "",
});
}
for (const vehicle of posFixture.vehicles || []) {
const reference = String(vehicle.reference || "").trim();
const vehiclePlate = normalizeReferencePlate(vehicle.reg);
const matchesContext =
(normalizedCustomerId && Number(vehicle.customer_id || 0) === normalizedCustomerId) ||
(vehiclePlate && normalizedPlates.includes(vehiclePlate));
if (!reference || vehicle.deleted_at || !matchesContext) {
continue;
}
if (normalizedSearch && !normalizeReferenceKey(reference).includes(normalizeReferenceKey(normalizedSearch))) {
continue;
}
rows.push({
source: "vehicle",
origin_id: Number(vehicle.id || 0),
reference,
source_created_at: vehicle.created_at || "",
used_at: vehicle.created_at || "",
customer_id: Number(vehicle.customer_id || 0),
reg_1: vehicle.reg || "",
reg_2: "",
reg_3: "",
});
}
const groups = new Map();
for (const row of rows) {
const key = normalizeReferenceKey(row.reference);
const existing = groups.get(key) || {
reference: row.reference,
rows: [],
usage_count: 0,
last_used_at: "",
context_boost: 0,
section: "other",
};
const rowSection = referenceSection(row, normalizedCustomerId, normalizedPlates);
existing.rows.push(row);
existing.usage_count += 1;
existing.last_used_at =
String(row.used_at || "") > String(existing.last_used_at || "") ? row.used_at || "" : existing.last_used_at;
existing.context_boost = Math.max(
existing.context_boost,
referenceContextBoost(row, normalizedCustomerId, normalizedPlates)
);
existing.section =
referenceSectionScore(rowSection) > referenceSectionScore(existing.section) ? rowSection : existing.section;
groups.set(key, existing);
}
return Array.from(groups.values())
.map((group) => {
const bestRow = [...group.rows].sort((left, right) => {
return (
referenceSourceScore(right.source) - referenceSourceScore(left.source) ||
String(right.source_created_at || "").localeCompare(String(left.source_created_at || "")) ||
Number(right.origin_id || 0) - Number(left.origin_id || 0)
);
})[0];
const usageCount = Number(group.usage_count || 1);
return {
source: bestRow.source,
section: group.section,
reference: group.reference,
source_created_at: bestRow.source_created_at || "",
last_used_at: group.last_used_at || bestRow.used_at || "",
usage_count: usageCount,
origin_id: Number(bestRow.origin_id || 0),
score:
referenceMatchScore(group.reference, normalizedSearch) +
Number(group.context_boost || 0) +
referenceSectionScore(group.section) +
referenceSourceScore(bestRow.source) +
Math.min(usageCount, 20) * 5,
};
})
.sort((left, right) => {
return (
Number(right.score || 0) - Number(left.score || 0) ||
Number(right.usage_count || 0) - Number(left.usage_count || 0) ||
referenceSectionScore(right.section) - referenceSectionScore(left.section) ||
String(right.last_used_at || "").localeCompare(String(left.last_used_at || "")) ||
String(left.reference || "").localeCompare(String(right.reference || ""))
);
})
.slice(0, Math.max(1, Math.min(Number(limit || 10), 25)));
}
function resolveCustomerNumberFromAttributeTarget(posFixture, target = {}) {
const customerNumber = normalizePositiveIntegerValue(target.customer_number ?? target.customerNumber);
if (customerNumber) {
@@ -3149,6 +3376,22 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
return true;
}
if (pathname.endsWith("/orders/reference-suggestions") && method === "GET") {
const suggestions = buildReferenceSuggestions(posFixture, {
search: parsedUrl.searchParams.get("search") || "",
departmentId: parsedUrl.searchParams.get("department_id") || null,
customerId: parsedUrl.searchParams.get("customer_id") || null,
plates: [
parsedUrl.searchParams.get("reg_1") || "",
parsedUrl.searchParams.get("reg_2") || "",
parsedUrl.searchParams.get("reg_3") || "",
],
limit: parsedUrl.searchParams.get("limit") || 10,
});
await route.fulfill(json({ success: true, data: suggestions }));
return true;
}
if (pathname.endsWith("/orders") && method === "GET") {
const filters = String(parsedUrl.searchParams.get("filters") || "");
const orders = filterPosOrders(posFixture, filters)