Erweitere VoTiAn NG um Auftrags-, Stamm- und Verwaltungsfunktionen

Ergänzt Auftragserfassung und -bearbeitung, Unternehmerbelege, Stornos, Lagerkorrekturen, Stammdaten, Abrechnung, CRM, Formulare, Berichte und Preisverwaltung auf Basis der aktuellen PHP-Anwendung.

Enthält Integrations- und Oberflächentests sowie dokumentierte Abgleiche und Prüfstände.
This commit is contained in:
Sven committed 2026-10-13 20:31:31 +02:00
1 parent 42c87282ee
commit 363bc27c26
190 files changed
+14815 -1857

No files matched your search

+27 -18
View File
@@ -4,6 +4,7 @@ import { test, expect, type Page } from "@playwright/test";
// into an application database, filesystem store, or production response.
const capabilities = {
readOrders: true,
readStatistics: true,
readCustomers: true,
readCouriers: true,
readInvoices: false,
@@ -178,11 +179,21 @@ async function mockApi(
csrfVersion += 1;
return route.fulfill({ status: 204 });
}
if (path === "/statistics/query" && request.method() === "POST") {
if (!authenticated) return respond(401, { message: "Anmeldung erforderlich." });
const query = request.postDataJSON();
const totals = { orders: 1234, revenue: 68000, courierRevenue: 47000, missingCourierValues: 0, serviceRevenue: 1500, fuelMarkup: 2300, courierService: 500, kilometers: 26000, co2eKg: 3300, kilometersOrders: 1200, co2eKgOrders: 1100 };
return respond(200, overrides[path] ?? {
periods: [query.interval, ...query.comparisons].map((interval: {from:string;to:string}) => ({interval,totals,rows:[{...totals,period:interval.from.slice(0,7),...Object.fromEntries(query.splits.map((_:string,i:number)=>[`split${i}`,"Testauswertung"]))}]})),
splits: query.splits, dateBasis: query.dateBasis, period: query.period, completePeriod: true,
});
}
throw new Error(`Unexpected mutation: ${request.method()} ${path}`);
}
if (!authenticated && path !== "/system")
return respond(401, { message: "Anmeldung erforderlich." });
let body: unknown = {
"/statistics/options": {headquarters:[{id:1,label:"Test-Niederlassung"}],currentHeadquarters:1,groups:[],excludeFreeCancellations:false,relatedCustomer:false},
"/me": currentUser,
"/system": system,
"/customers": customers,
@@ -486,7 +497,7 @@ for (const customerAccess of ["empty", "denied", "loaded"] as const) {
await expect(dialog.getByText("KUNDE / ZAHLER", { exact: true })).toBeVisible();
await expect(dialog.getByRole("heading", { name: "Testzahler Westwind", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Details schließen", exact: true }).click();
for (const module of ["Disposition", "Touren", "Auswertung"]) {
for (const module of ["Disposition", "Touren"]) {
await page.getByRole("button", { name: module, exact: true }).click();
await expect(page.locator("main").getByText("Testzahler Westwind", { exact: true })).toBeVisible();
await expect(page.locator("main").getByText("Testkunde Nordlicht", { exact: true })).toHaveCount(0);
@@ -496,7 +507,7 @@ for (const customerAccess of ["empty", "denied", "loaded"] as const) {
});
}
test("orders and reports show safe customer fallbacks for missing identities and names", async ({ page }) => {
test("orders show safe customer fallbacks for missing identities and names", async ({ page }) => {
const variants = [
{ customerId: null, customerName: null, label: "Kunde nicht zugeordnet" },
{ customerId: 0, customerName: "", label: "Kunde nicht zugeordnet" },
@@ -517,27 +528,25 @@ test("orders and reports show safe customer fallbacks for missing identities and
await expect(page.getByRole("dialog").getByRole("heading", { name: label, exact: true })).toBeVisible();
await page.getByRole("button", { name: "Details schließen", exact: true }).click();
}
await page.getByRole("button", { name: "Auswertung", exact: true }).click();
await expect(page.locator("main").getByText("Kunde nicht zugeordnet", { exact: true })).toHaveCount(3);
await expect(page.locator("main").getByText("Kunde #44", { exact: true })).toBeVisible();
await expect(page.locator("main").getByText("Testkunde Nordlicht", { exact: true })).toBeVisible();
await expect(page.locator("main")).not.toContainText(/Kunde #(null|undefined|0)/);
});
test("customer reports aggregate by customer ID and use a name from any matching order", async ({ page }) => {
await mockApi(page, {
"/customers": [],
"/orders": [
{ ...orders[0], customerName: null },
{ ...orders[0], id: 880002, number: "880002", customerName: "Testzahler Westwind" },
],
});
test("statistics use server totals beyond the order list and submit comparison filters", async ({ page }) => {
await mockApi(page, { "/orders": [orders[0]] });
await page.goto("/");
await page.getByRole("button", { name: "Auswertung", exact: true }).click();
const row = page.getByRole("row").filter({ hasText: "Testzahler Westwind" });
await expect(row).toHaveCount(1);
await expect(row.getByRole("cell").nth(1)).toHaveText("2");
await expect(row.getByRole("cell").nth(2)).toHaveText("85,00 €");
await expect(page.getByText("Geladener Ausschnitt:")).toHaveCount(0);
await page.getByRole("button", { name: "Vergleichszeitraum", exact: true }).click();
await page.getByLabel("Kunde", {exact:true}).check();
const pending=page.waitForRequest("**/api/statistics/query");
await page.getByRole("button", { name: "Statistik berechnen", exact: true }).click();
const query=(await pending).postDataJSON();
expect(query.comparisons).toHaveLength(1);
expect(query.splits).toEqual(["HEADQUARTERS","CUSTOMER"]);
expect(query.headquarters).toEqual([1]);
const comparison=page.getByRole("heading",{name:"Zeiträume im Vergleich"}).locator("..");
await expect(comparison.getByRole("cell",{name:"1234",exact:true})).toHaveCount(2);
await expect(page.getByRole("button",{name:"Statistik als CSV"})).toBeVisible();
});
test("order history loads on demand and shows newest events with safe multiline details", async ({
+235
View File
@@ -0,0 +1,235 @@
import { test, expect, type Page } from "@playwright/test";
// Every API response is an isolated browser fixture. This suite cannot write to a database.
const caps = { readOrders: true, readCustomers: false, readCouriers: false, readInvoices: false, createOrders: true,
writeOrders: false, writeCustomers: false, writeCouriers: false, writeInvoices: false, completeStops: false };
const center = { id: 41, customerId: 31, customerNumber: "TEST", customerName: "Testkunde", name: "Hauptkostenstelle", defaultPayerId: null, fixedDiscount: 10, taxId: 4 };
const options = { vehicleTypes: [{ id: 2, label: "PKW" }], taxes: [{ id: 4, label: "N", rate: 19 }],
rhythms: [{ id: 1, label: "Werktags" }, { id: 4, label: "Wöchentlich" }, { id: 9, label: "Feiertags" }], editCourierPrice: true, overrideCourierLimit: true };
const quote = { net: 110, tax: 20.9, gross: 130.9, courierNet: 77, markup: 10, courierMarkup: 10, discount: 0, taxRate: 19,
batch: false, fingerprint: "fixture-quote", courierLimit: 90, courierLimitExceeded: false,
components: [{ sequence: 0, name: "Fixpreis", price: 110, discount: 0, net: 110 }] };
async function fixture(page: Page, failFirstSave = false) {
const saved: Record<string, any>[] = [], quoted: Record<string, any>[] = [];
await page.route("**/api/**", async route => {
const req = route.request(), url = new URL(req.url()), path = url.pathname.slice(4);
const send = (body: unknown, status = 200) => route.fulfill({ status, contentType: "application/json", headers: { "X-Total-Count": "0", "X-Result-Limit": "500" }, body: JSON.stringify(body) });
if (path === "/csrf") return send({ token: "entry-csrf", headerName: "X-CSRF-TOKEN", parameterName: "_csrf" });
if (req.method() === "POST") {
expect(req.headers()["x-csrf-token"]).toBe("entry-csrf");
const input = req.postDataJSON();
if (path === "/orders/quote") { quoted.push(input); return send(quote); }
if (path === "/orders") {
saved.push(input);
if (failFirstSave && saved.length === 1) return route.abort("failed");
return send({ id: 101, number: "101", legacyStatus: input.mode === "RETROSPECTIVE" ? 2 : 9, replayed: saved.length > 1,
pickupAt: input.pickupAt || "2026-10-12T10:00:00" }, saved.length > 1 ? 200 : 201);
}
throw new Error(`Unexpected write: ${path}`);
}
if (path === "/me") return send({ username: "entry-fixture", roles: ["VIEWER"], capabilities: caps });
if (path === "/system") return send({ connected: true, database: "legacy-mysql", message: "Testfixture", capabilities: caps, schemaChanges: false, maxResults: 500, legacyUrl: null });
if (path === "/orders") return send([]);
if (path === "/orders/entry/upload") return send({ url: "" });
if (path === "/orders/entry/courier-filters" || path === "/orders/entry/addresses" || path === "/orders/entry/billing-postal-codes" || path === "/orders/entry/saved-orders") return send([]);
if (path.endsWith("/defaults")) return send({ invoiceEmail: "", vatId: "DE-TEST", referenceRequired: false, calculationRequired: false });
if (path.endsWith("/services")) return send([]);
if (path === "/orders/entry/headquarters") return send({ currentId: 7, items: [{ id: 7, label: "Testniederlassung" }] });
if (path === "/orders/entry/features") return send({ mapUrl: "", bookStock: false });
if (path === "/orders/entry/options") return send(options);
if (path === "/orders/entry/cost-centers") return send([center]);
if (path === "/orders/entry/cost-centers/41/addresses") return send([{ id: 1, costCenterId: 41, name: "Testlager", street: "Lagerstraße", houseNumber: "3", postalCode: "20095", city: "Hamburg", country: "DE", contact: "", phone: "", note: "" }]);
if (path === "/orders/entry/cost-centers/41/photo-workflows") return send({ configured: true, sets: [{number:1,name:"Standard",defaultSet:true,workflows:[{number:0,name:"Keine Fotos",defaultWorkflow:false,min:0,max:0,stationScope:"ALL",positions:[]},{number:2,name:"Abliefernachweis",defaultWorkflow:true,min:1,max:5,stationScope:"ALL",positions:[]}]}] });
if (path === "/orders/entry/vehicles") return send([{ id: 52, courierId: 61, sid: "TEST701", name: "Testkurier", vehicleTypeId: 2, plate: "HH-TEST" }]);
return send({ message: `Unexpected fixture endpoint ${path}` }, 404);
});
return { saved, quoted };
}
async function openAndFill(page: Page) {
await page.goto("/");
await page.getByRole("button", { name: "Neuer Auftrag", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Neuen Auftrag erfassen" });
await dialog.getByRole("combobox", { name: "Rechnungszahler / Kostenstelle auswählen" }).selectOption("41");
await dialog.getByLabel("Besteller *", { exact: true }).fill("Testbesteller");
for (let index = 1; index <= 2; index++) {
const stop = dialog.getByRole("region", { name: `Station ${index}`, exact: true });
await stop.getByLabel("Firma / Name *", { exact: true }).fill(`Teststation ${index}`);
await stop.getByLabel("Straße *", { exact: true }).fill("Teststraße");
await stop.getByLabel("Hausnummer *", { exact: true }).fill(String(index));
await stop.getByLabel("PLZ *", { exact: true }).fill(index === 1 ? "20095" : "20097");
await stop.getByLabel("Ort *", { exact: true }).fill("Hamburg");
}
await dialog.getByLabel("Fahrzeugtyp *", { exact: true }).selectOption("2");
await dialog.getByLabel("Preisermittlung", { exact: true }).selectOption("FIXED");
await dialog.getByLabel("Festpreis netto, vor Zuschlag (€) *", { exact: true }).fill("100");
return dialog;
}
test("native entry uses separate lookup rights, previews and saves an immediate order", async ({ page }) => {
const data = await fixture(page), dialog = await openAndFill(page);
await expect(dialog.getByRole("button", { name: "Auftrag speichern", exact: true })).toBeDisabled();
await dialog.getByRole("button", { name: "Preis berechnen", exact: true }).click();
await expect(dialog.getByLabel("Preisvorschau")).toContainText("130,90");
await page.screenshot({ path: "work/test-results/auftragserfassung-preisvorschau.png", fullPage: true });
await dialog.getByRole("button", { name: "Auftrag speichern", exact: true }).click();
await expect(dialog).toBeHidden();
await expect(page.getByText("Auftrag 101 wurde gespeichert.")).toBeVisible();
expect(data.saved).toHaveLength(1);
expect(data.saved[0]).toMatchObject({ mode: "IMMEDIATE", pickupAt: null, payerCostCenterId: 41, quoteFingerprint: "fixture-quote", vehicleTypeId: 2, payment: "INVOICE" });
expect(data.saved[0].stops.map((s: any) => s.type)).toEqual(["PICKUP", "DELIVERY"]);
expect(data.saved[0]).not.toHaveProperty("hq_id");
});
test("cash recurring order retains weekday mask, collection station and vehicle SID selection", async ({ page }) => {
const data = await fixture(page), dialog = await openAndFill(page);
await dialog.getByLabel("Auftragsart", { exact: true }).selectOption("RECURRING");
await dialog.getByLabel("Abholtermin / Serienbeginn *", { exact: true }).fill("2026-12-01T09:00");
await dialog.getByLabel("Serienende (optional)", { exact: true }).fill("2027-01-31");
await dialog.getByLabel("Zahlungsart", { exact: true }).selectOption("CASH");
await dialog.getByLabel("Kassieren an Station *", { exact: true }).selectOption("2");
await dialog.getByRole("combobox", { name: "Fahrzeug direkt zuweisen (optional) auswählen" }).selectOption("52");
await dialog.getByRole("button", { name: "Preis berechnen", exact: true }).click();
await expect(dialog.getByLabel("Preisvorschau")).toContainText("Zu kassieren");
await dialog.getByRole("button", { name: "Auftrag speichern", exact: true }).click();
await expect(dialog).toBeHidden();
expect(data.saved[0]).toMatchObject({ mode: "RECURRING", pickupAt: "2026-12-01T09:00:00", payment: "CASH", cashStop: 2, courierVehicleId: 52,
recurrence: { rhythm: 1, weekdays: "1111100", endDate: "2027-01-31" } });
});
test("retrospective and combined stops are represented explicitly", async ({ page }) => {
const data = await fixture(page), dialog = await openAndFill(page);
await dialog.getByLabel("Auftragsart", { exact: true }).selectOption("RETROSPECTIVE");
await dialog.getByLabel("Ausführungszeitpunkt *", { exact: true }).fill("2026-10-10T12:30");
await dialog.getByRole("combobox", { name: "Ausführendes Fahrzeug * auswählen" }).selectOption("52");
await dialog.getByLabel("Art der Station 2", { exact: true }).selectOption("BOTH");
await dialog.getByRole("button", { name: "Preis berechnen", exact: true }).click();
await dialog.getByRole("button", { name: "Auftrag speichern", exact: true }).click();
await expect(dialog).toBeHidden();
expect(data.saved[0]).toMatchObject({ mode: "RETROSPECTIVE", courierVehicleId: 52 });
expect(data.saved[0].stops[1].type).toBe("BOTH");
});
test("reservation keeps its future pickup date and manual dispatch", async ({ page }) => {
const data = await fixture(page), dialog = await openAndFill(page);
await dialog.getByLabel("Auftragsart", { exact: true }).selectOption("RESERVATION");
await dialog.getByLabel("Abholtermin / Serienbeginn *", { exact: true }).fill("2026-12-03T10:00");
await dialog.getByLabel("Manuell disponieren", { exact: true }).check();
await dialog.getByRole("button", { name: "Preis berechnen", exact: true }).click();
await dialog.getByRole("button", { name: "Auftrag speichern", exact: true }).click();
await expect(dialog).toBeHidden();
expect(data.saved[0]).toMatchObject({ mode: "RESERVATION", pickupAt: "2026-12-03T10:00:00", manualDispatch: true, recurrence: null });
await expect(page.getByLabel("Abholung von", { exact: true })).toHaveValue("2026-12-03");
});
test("editing a quoted price invalidates the preview before saving", async ({ page }) => {
const data = await fixture(page), dialog = await openAndFill(page);
await dialog.getByRole("button", { name: "Preis berechnen", exact: true }).click();
await expect(dialog.getByRole("button", { name: "Auftrag speichern", exact: true })).toBeEnabled();
await dialog.getByLabel("Festpreis netto, vor Zuschlag (€) *", { exact: true }).fill("120");
await expect(dialog.getByRole("button", { name: "Auftrag speichern", exact: true })).toBeDisabled();
expect(data.saved).toHaveLength(0);
});
test("a lost save response retries the exact request without another order", async ({ page }) => {
const data = await fixture(page, true), dialog = await openAndFill(page);
await dialog.getByRole("button", { name: "Preis berechnen", exact: true }).click();
await dialog.getByRole("button", { name: "Auftrag speichern", exact: true }).click();
await expect(dialog.getByRole("alert")).toContainText("Speicherbestätigung fehlt");
await expect(dialog.getByLabel("Auftragsart", { exact: true })).toBeDisabled();
await dialog.getByRole("button", { name: "Speicherstatus prüfen", exact: true }).click();
await expect(dialog).toBeHidden();
expect(data.saved).toHaveLength(2);
expect(data.saved[1]).toEqual(data.saved[0]);
});
test("station addresses are searched and copied without changing the payer", async ({ page }) => {
const data = await fixture(page), dialog = await openAndFill(page);
const station = dialog.getByRole("region", { name: "Station 1", exact: true });
await station.getByRole("button", { name: "Adresse aus Kostenstelle übernehmen" }).click();
await station.getByRole("combobox", { name: "Stationskostenstelle auswählen" }).selectOption("41");
await station.getByLabel("Gespeicherte Adresse", { exact: true }).selectOption("1");
await expect(station.getByLabel("Straße *", { exact: true })).toHaveValue("Lagerstraße");
await dialog.getByRole("button", { name: "Preis berechnen", exact: true }).click();
await expect(dialog.getByLabel("Preisvorschau")).toBeVisible();
expect(data.quoted[0].payerCostCenterId).toBe(41);
expect(data.quoted[0].stops[0]).toMatchObject({ costCenterId: 41, street: "Lagerstraße", houseNumber: "3" });
});
test("photo workflow zero stays with its station when stations are reordered", async ({ page }) => {
const data = await fixture(page), dialog = await openAndFill(page);
const first = dialog.getByRole("region", { name: "Station 1", exact: true });
await first.getByLabel("Fotoworkflow", { exact: true }).selectOption("0");
await dialog.getByRole("button", { name: "Station hinzufügen", exact: true }).click();
const third = dialog.getByRole("region", { name: "Station 3", exact: true });
await third.getByLabel("Firma / Name *", { exact: true }).fill("Dritte Station");
await third.getByLabel("Straße *", { exact: true }).fill("Testweg");
await third.getByLabel("Hausnummer *", { exact: true }).fill("3");
await third.getByLabel("PLZ *", { exact: true }).fill("20098");
await third.getByLabel("Ort *", { exact: true }).fill("Hamburg");
await third.getByLabel("Fotoworkflow", { exact: true }).selectOption("2");
await third.getByRole("button", { name: "Station nach vorne", exact: true }).click();
await dialog.getByRole("button", { name: "Preis berechnen", exact: true }).click();
await expect(dialog.getByLabel("Preisvorschau")).toBeVisible();
expect(data.quoted[0].stops.map((stop: any) => [stop.name, stop.photoWorkflow])).toEqual([["Teststation 1", 0], ["Dritte Station", 2], ["Teststation 2", null]]);
});
test("foreign zonal quote shows round trips and supports explicit ASAP", async ({ page }) => {
const data = await fixture(page);
await page.route("**/api/orders/quote", async route => {
data.quoted.push(route.request().postDataJSON());
await route.fulfill({ json: { ...quote, net: 715, tax: 135.85, gross: 850.85, courierNet: 500.5,
components: [{ sequence: 0, name: "Fixpreis", price: 715, discount: 0, net: 715 }], route: { distanceKm: 650, calculationKm: 700, durationSeconds: 24000,
dispatchHeadquarters: "Niederlassung B", pricingHeadquartersId: 8, longhaulEligible: true, longhaul: false,
comparedHeadquarters: [{ id: 8, name: "Niederlassung B", roundTripKm: 780 }, { id: 7, name: "Niederlassung A", roundTripKm: 810 }] },
photos: [{ station: 1, number: 2, name: "Abliefernachweis" }],
calculation: [{ name: "Außenzone", quantity: 650, rate: 1, amount: 650 }] } });
});
const dialog = await openAndFill(page);
await dialog.getByRole("region", { name: "Station 1", exact: true }).getByLabel("Land (ISO-Code) *", { exact: true }).fill("FR");
await dialog.getByLabel("Preisermittlung", { exact: true }).selectOption("ZONE");
await dialog.getByLabel("Fernverkehr", { exact: true }).selectOption("ASAP");
await dialog.getByRole("button", { name: "Preis berechnen", exact: true }).click();
await expect(dialog.getByLabel("Routenvorschau")).toContainText("650 km");
await expect(dialog.getByLabel("Routenvorschau")).toContainText("auf Wunsch als ASAP erfasst");
await dialog.getByText("Niederlassungsvergleich für Auslandsabholung", { exact: true }).click();
await expect(dialog.getByLabel("Routenvorschau")).toContainText("Niederlassung B: 780 km");
await dialog.getByText("Streckenpreis vor Zuschlägen und Rabatt", { exact: true }).click();
await expect(dialog.getByLabel("Preisvorschau")).toContainText("Außenzone");
expect(data.quoted[0]).toMatchObject({ pricing: { mode: "ZONE", fixedPrice: null }, longhaul: "ASAP" });
await page.screenshot({ path: "work/test-results/auftrag-sonderprozesse.png", fullPage: true });
await dialog.getByLabel("Fernverkehr", { exact: true }).selectOption("AUTO");
await expect(dialog.getByRole("button", { name: "Auftrag speichern", exact: true })).toBeDisabled();
});
test("photo service outage blocks quoting and offers a retry", async ({ page }) => {
const data = await fixture(page);
let calls = 0;
await page.route("**/api/orders/entry/cost-centers/41/photo-workflows", async route => {
if (++calls === 1) return route.fulfill({ status: 503, json: { message: "Fotodienst nicht erreichbar" } });
await route.fallback();
});
const dialog = await openAndFill(page);
await expect(dialog.getByText("Fotodienst nicht erreichbar", { exact: false })).toBeVisible();
await expect(dialog.getByRole("button", { name: "Preis berechnen", exact: true })).toBeDisabled();
expect(data.quoted).toHaveLength(0);
await dialog.getByRole("button", { name: "Fotoworkflows erneut laden", exact: true }).click();
await expect(dialog.getByRole("button", { name: "Preis berechnen", exact: true })).toBeEnabled();
await expect(dialog.getByLabel("Fotoworkflow", { exact: true }).first()).toBeVisible();
});
test("pending photo delivery is shown after the order was committed", async ({ page }) => {
await fixture(page);const dialog = await openAndFill(page);
await dialog.getByLabel("Preisermittlung", { exact: true }).selectOption("KILOMETER");
await dialog.getByRole("button", { name: "Preis berechnen", exact: true }).click();
await expect(dialog.getByLabel("Preisvorschau")).toBeVisible();
await page.route("**/api/orders", async route => {
if (route.request().method() !== "POST") return route.fallback();
expect(route.request().postDataJSON().pricing.mode).toBe("KILOMETER");
await route.fulfill({ status: 201, json: { id: 101, number: "101", legacyStatus: 9, replayed: false, pickupAt: "2026-10-12T10:00:00", photoSyncPending: true } });
});
await dialog.getByRole("button", { name: "Auftrag speichern", exact: true }).click();
await expect(dialog).toBeHidden();
await expect(page.getByText("Auftrag 101 gespeichert. Die Fotoübertragung steht aus. Der Auftrag bleibt zur Prüfung in der Disposition.")).toBeVisible();
});