import { type Page } from "@playwright/test"; import { test, expect, transferOrderIdFromUrl, TRANSFER_ORDER_DETAIL_URL, type TransferStock, } from "../fixtures/transfer-order-lifecycle"; import { TransferOrderCreateFormPage } from "../pages/transfer-order-create-form"; import { TransferOrderDetailPage } from "../pages/transfer-order-detail"; import { TransferOrderEditFormPage } from "../pages/transfer-order-edit-form"; import { TransferOrderListPage } from "../pages/transfer-order-list"; /** * E2E coverage for the `transfer-order-lifecycle` business flow, driven end to * end through the live frontend by the Inventory Manager (the single actor for * the whole flow — there is no submit / approve hand-off, unlike the PO flow). * * The flow follows the native erp-kit `DRAFT → OPEN → CLOSED` state machine: * open reserves stock, ship and receive are separate explicit actions that keep * the order OPEN, and close settles it. There is no Cancel action / CANCELLED * state — close serves both settlement and abandonment. */ const ORDER_QTY = "10"; /** * Create a DRAFT TransferOrder with a single line, tagging its notes with * `tag` for human disambiguation. Lands on the detail page and returns the TO * id resolved from the detail URL. */ async function createDraftTransferOrder( page: Page, tag: string, stock: TransferStock, ): Promise { const createForm = new TransferOrderCreateFormPage(page); await createForm.goto(); await expect(createForm.sourceSiteCombobox).toBeVisible(); // Source is the warehouse the fixture stocked our dedicated item into. // Destination just has to differ (the resolver rejects a self-transfer); the // two comboboxes mutually exclude each other's selected id, so picking the // first available destination yields a different site. await createForm.selectSourceSiteByName(stock.sourceSiteName); await createForm.selectFirstDestinationSite(); // Both date fields are required by the form schema. await createForm.fillPlannedShipmentDate("2030-01-01"); await createForm.fillExpectedReceiptDate("2030-01-08"); await createForm.fillNotes(tag); // A single line: our dedicated, amply-stocked item (selected by name so we // never pick up another test's low-stock leftovers). Unit is pinned to // "each" at the resolver, so there is nothing to pick. await createForm.selectLineItemByName(0, stock.itemName); await createForm.setQuantity(0, ORDER_QTY); // The create form submits directly without a confirmation dialog. await createForm.submit(); // The submit navigates to the new TO's detail page. await page.waitForURL(TRANSFER_ORDER_DETAIL_URL); const detailPage = new TransferOrderDetailPage(page); await expect(detailPage.heading).toBeVisible(); return transferOrderIdFromUrl(page); } // Flow main path: Create -> Edit (DRAFT) -> Open (OPEN) -> Ship (OPEN) -> // Receive (OPEN) -> Close (CLOSED). Walks the whole state machine on one TO. test("inventory manager creates, edits, opens, ships, receives, and closes a transfer order", async ({ page, transferStock, }) => { const tag = `E2E-TO-${Date.now()}`; await createDraftTransferOrder(page, tag, transferStock); const detailPage = new TransferOrderDetailPage(page); await expect(detailPage.statusBadge).toHaveText(/draft/i); // Edit Transfer Order — DRAFT is still editable; raise the line quantity and // save. Edit is only offered while DRAFT. await expect(detailPage.editButton).toBeVisible(); await detailPage.editButton.click(); const editForm = new TransferOrderEditFormPage(page); await expect(editForm.heading).toBeVisible(); // The edit form resolves the order's site/item objects asynchronously and // only resets the fields once they load. Wait for that (source site // populated) before editing, or the change lands on an empty form. await expect(editForm.sourceSiteCombobox).toHaveValue(/\S/); await editForm.setQuantity(0, "12"); // Save also re-checks source-stock availability and silently bails (with a // transient "still checking… please retry" toast) until that rollup finishes // loading, so a single submit can no-op. Retry submit until it commits and // navigates back to the detail page. await expect(async () => { await editForm.submit(); await page.waitForURL(TRANSFER_ORDER_DETAIL_URL, { timeout: 3_000 }); }).toPass({ timeout: 30_000 }); await expect(detailPage.heading).toBeVisible(); // Open Transfer Order — DRAFT -> OPEN. The ActionPanel button calls the // mutation directly; wait for the status badge to reflect the new state. await expect(detailPage.openButton).toBeVisible(); await detailPage.openButton.click(); await expect(detailPage.statusBadge).toHaveText(/open/i); // Post-open the order is locked against edits and the flight actions surface. await expect(detailPage.editButton).toHaveCount(0); await expect(detailPage.shipButton).toBeVisible(); // Ship Transfer Order — moves source AVAILABLE -> IN_TRANSIT and sets // shippedQuantity; the status stays OPEN. await detailPage.shipButton.click(); await expect(detailPage.statusBadge).toHaveText(/open/i); // Once every line is fully shipped, Ship is hidden and Receive is offered. await expect(detailPage.shipButton).toHaveCount(0); await expect(detailPage.receiveButton).toBeVisible(); // Receive Transfer Order — moves IN_TRANSIT -> destination AVAILABLE and sets // receivedQuantity; the status stays OPEN. await detailPage.receiveButton.click(); await expect(detailPage.statusBadge).toHaveText(/open/i); // In-transit is now clear (shipped == received), so Receive is hidden and the // Close action becomes available. await expect(detailPage.receiveButton).toHaveCount(0); await expect(detailPage.closeButton).toBeVisible(); // Close Transfer Order — OPEN -> CLOSED. Terminal: the action panel collapses // to Back only, and no lifecycle actions remain. await detailPage.closeButton.click(); await expect(detailPage.statusBadge).toHaveText(/closed/i); await expect(detailPage.backButton).toBeVisible(); await expect(detailPage.openButton).toHaveCount(0); await expect(detailPage.shipButton).toHaveCount(0); await expect(detailPage.closeButton).toHaveCount(0); }); // Abandonment + list triage: open a DRAFT, then close it directly without ever // shipping (all shippedQuantity == 0, so in-transit is trivially clear), then // confirm the closed TO surfaces in the list, linked by its document number. test("inventory manager opens then closes an unshipped transfer order and finds it in the list", async ({ page, transferStock, }) => { const tag = `E2E-TO-ABANDON-${Date.now()}`; const id = await createDraftTransferOrder(page, tag, transferStock); const detailPage = new TransferOrderDetailPage(page); // Open — DRAFT -> OPEN. With nothing shipped, in-transit is trivially clear. await expect(detailPage.openButton).toBeVisible(); await detailPage.openButton.click(); await expect(detailPage.statusBadge).toHaveText(/open/i); // Close directly (abandonment) — OPEN -> CLOSED with no ship / receive. await expect(detailPage.closeButton).toBeVisible(); await detailPage.closeButton.click(); await expect(detailPage.statusBadge).toHaveText(/closed/i); // View Transfer Order List — the closed TO is still listed for triage. The // list loads with the Status column present and at least one row linked to a // detail page. const listPage = new TransferOrderListPage(page); await listPage.goto(); await expect(listPage.heading).toBeVisible(); await expect(listPage.getColumnHeader("Status")).toBeVisible(); await expect(listPage.table.getByRole("link").first()).toBeVisible(); // Round-trip back into the just-closed TO's detail via its id and confirm the // detail screen renders for the same order. await detailPage.goto(id); await page.waitForURL(TRANSFER_ORDER_DETAIL_URL); await expect(detailPage.heading).toBeVisible(); await expect(detailPage.statusBadge).toHaveText(/closed/i); expect(transferOrderIdFromUrl(page)).toBe(id); });