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[] = [], quoted: Record[] = []; 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(); });