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.
236 lines
17 KiB
TypeScript
236 lines
17 KiB
TypeScript
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();
|
|
});
|