diff --git a/App/app/(portal)/my-orders/page.tsx b/App/app/(portal)/my-orders/page.tsx index 6c75b15..bc827ea 100644 --- a/App/app/(portal)/my-orders/page.tsx +++ b/App/app/(portal)/my-orders/page.tsx @@ -25,7 +25,7 @@ export default async function MyOrdersPage() { }); const open = pos.filter((p) => - ["DRAFT", "SUBMITTED", "MGR_REVIEW", "VENDOR_ID_PENDING", "EDITS_REQUESTED"].includes(p.status) + ["DRAFT", "SUBMITTED", "MGR_REVIEW", "VENDOR_ID_PENDING", "EDITS_REQUESTED", "PARTIALLY_CLOSED"].includes(p.status) ); const closed = pos.filter((p) => ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "CLOSED", "REJECTED"].includes(p.status) diff --git a/App/app/(portal)/po/[id]/receipt/actions.ts b/App/app/(portal)/po/[id]/receipt/actions.ts index a1c506e..c52fef2 100644 --- a/App/app/(portal)/po/[id]/receipt/actions.ts +++ b/App/app/(portal)/po/[id]/receipt/actions.ts @@ -48,6 +48,13 @@ export async function confirmReceipt({ return { error: "You can only confirm receipt on your own purchase orders." }; } + // Reject negative delivery values — only remaining items may be delivered + if (deliveries) { + for (const [id, qty] of Object.entries(deliveries)) { + if (qty < 0) return { error: `Invalid delivery quantity for item ${id}: must be ≥ 0.` }; + } + } + // Compute the updated deliveredQuantity for each line item const lineUpdates = po.lineItems.map((li) => { const prevDelivered = Number(li.deliveredQuantity ?? 0); diff --git a/App/tests/e2e/partial-receipt.spec.ts b/App/tests/e2e/partial-receipt.spec.ts index 12fefd1..93e9275 100644 --- a/App/tests/e2e/partial-receipt.spec.ts +++ b/App/tests/e2e/partial-receipt.spec.ts @@ -2,6 +2,9 @@ * User stories covered: Feature 8 — Partial receipt confirmation * - ACCOUNTS user sees a receipt/delivery section on SENT_FOR_PAYMENT or PAID_DELIVERED POs * - The receipt section shows individual line items with per-item tracking controls + * US-8c: PARTIALLY_CLOSED PO appears in submitter's My Orders page under Open Orders + * US-8d: Submitter can reach /receipt from a PARTIALLY_CLOSED PO detail ("Confirm Remaining" CTA) + * US-8e: Receipt form shows editable inputs only for remaining items; fully-delivered items show "—" * * Limitation: This requires a PO in SENT_FOR_PAYMENT or PAID_DELIVERED status. * The test drives the full flow (create → submit → approve → process payment) if @@ -9,9 +12,117 @@ * will find it via the payment history page. * * Created: 2026-05-17 + * Updated: 2026-05-27 — added US-8c/8d/8e for PARTIALLY_CLOSED revisit flow */ import { test, expect, type BrowserContext } from "@playwright/test"; import { login, USERS, submitPo } from "./helpers/login"; +import { fillPoHeader } from "./helpers/login"; + +/** + * Drive: create PO (two line items) → approve → confirm payment → partial receipt + * (deliver item 1 only, leave item 2 undelivered) → returns PO URL in PARTIALLY_CLOSED state. + * + * Line item layout: + * Row 0 — "Engine gasket" qty 2 (will be fully delivered in the partial receipt) + * Row 1 — "Oil filter" qty 4 (will NOT be delivered — remaining stays outstanding) + */ +async function createPartiallyClosedPo( + page: import("@playwright/test").Page, + context: BrowserContext, + title: string +): Promise { + // ── Step 1: Create + submit PO as TECH with two line items ─────────────── + await login(page, USERS.TECH); + await page.goto("/po/new"); + await fillPoHeader(page, title); + + // Select a vendor (required for manager approval) + const vendorSelectPC = page.locator('select[name="vendorId"]'); + await expect(vendorSelectPC).toBeVisible({ timeout: 5_000 }); + await vendorSelectPC.selectOption({ index: 1 }); + + // Fill first line item (pre-existing row) + const lineItemsSection = page.locator("section").filter({ + has: page.getByRole("heading", { name: /line items/i }), + }); + const firstRow = lineItemsSection.locator("tbody tr").first(); + await firstRow.getByPlaceholder("Item name *").fill("Engine gasket"); + await firstRow.locator('input[type="number"]').first().fill("2"); + await firstRow.locator('input[placeholder="0.00"]').fill("250"); + + // Add a second line item + await page.getByRole("button", { name: /add (line )?item/i }).click(); + const rows = lineItemsSection.locator("tbody tr"); + await expect(rows).toHaveCount(2, { timeout: 5_000 }); + const secondRow = rows.nth(1); + await secondRow.getByPlaceholder("Item name *").fill("Oil filter"); + await secondRow.locator('input[type="number"]').first().fill("4"); + await secondRow.locator('input[placeholder="0.00"]').fill("80"); + + await page.getByRole("button", { name: /submit for approval/i }).click(); + // Wait for redirect to the PO detail page — must NOT be /po/new + await expect(page).toHaveURL(/\/po\/(?!new)[^/]+$/, { timeout: 20_000 }); + const poUrl = page.url(); + + // ── Step 2: Approve as MANAGER via /approvals/ ─────────────────────── + // Extract PO id from the URL (/po/) to construct the approvals URL + const poId = poUrl.split("/po/")[1].replace(/[?#].*$/, "").replace(/\/$/, ""); + await context.clearCookies(); + await login(page, USERS.MANAGER); + await page.goto(`/approvals/${poId}`); + // ApprovalActions renders an "Approve" button (no signature guard in test env seed) + const approveBtn = page.getByRole("button", { name: /^approve$/i }); + await expect(approveBtn).toBeVisible({ timeout: 15_000 }); + await approveBtn.click(); + // After approval the router pushes to /approvals — verify the PO is no longer in MGR_REVIEW + await expect(page).not.toHaveURL(/\/approvals\/[^/]+$/, { timeout: 15_000 }); + + // ── Step 3: Start payment processing as ACCOUNTS ──────────────────────── + await context.clearCookies(); + await login(page, USERS.ACCOUNTS); + await page.goto("/payments"); + const cardLoc3 = page.locator("div.rounded-lg").filter({ has: page.getByRole("heading", { name: title }) }); + await expect(cardLoc3).toBeVisible({ timeout: 10_000 }); + await cardLoc3.getByRole("button", { name: /start payment processing/i }).click(); + // Reload to get a fresh server-rendered page in SENT_FOR_PAYMENT state (avoids React + // state race where the card shows "Confirming…" before we can interact with the ref input) + await page.reload(); + + // ── Step 4: Confirm payment as ACCOUNTS (→ PAID_DELIVERED) ─────────────── + const cardLoc4 = page.locator("div.rounded-lg").filter({ has: page.getByRole("heading", { name: title }) }); + await expect(cardLoc4).toBeVisible({ timeout: 10_000 }); + const refInputPC = cardLoc4.getByPlaceholder(/payment reference|transaction/i); + await expect(refInputPC).toBeVisible({ timeout: 10_000 }); + await refInputPC.fill("NEFT-E2E-PAID-PC"); + await cardLoc4.getByRole("button", { name: /confirm payment sent/i }).click(); + // After successful payment the PO moves to PAID_DELIVERED and the card disappears + await page.reload(); + await expect( + page.locator("div.rounded-lg").filter({ has: page.getByRole("heading", { name: title }) }) + ).not.toBeVisible({ timeout: 10_000 }); + + // ── Step 5: Partial receipt as TECH — deliver item 0 only ─────────────── + await context.clearCookies(); + await login(page, USERS.TECH); + await page.goto(`${poUrl}/receipt`); + await expect(page).toHaveURL(/\/receipt$/, { timeout: 10_000 }); + + // The receipt form defaults all "Receiving Now" inputs to the full remaining quantity. + // Set the second item (Oil filter) to 0 so only item 1 is received. + const receivingInputs = page.locator('input[type="number"][min="0"]'); + await expect(receivingInputs).toHaveCount(2, { timeout: 10_000 }); + // Clear the second item's delivery quantity to 0 + await receivingInputs.nth(1).fill("0"); + + // Submit partial receipt + await page.getByRole("button", { name: /confirm partial receipt/i }).click(); + // Should redirect back to the PO detail page + await expect(page).toHaveURL(/\/po\/[^/]+$/, { timeout: 15_000 }); + // Verify status is now PARTIALLY_CLOSED (status badge text) + await expect(page.getByText(/partially/i).first()).toBeVisible({ timeout: 10_000 }); + + return poUrl; +} /** Drive: create PO → approve as manager → process payment as accounts → return PO URL */ async function createSentForPaymentPo( @@ -19,34 +130,59 @@ async function createSentForPaymentPo( context: BrowserContext, title: string ): Promise { - // Step 1: Submit as tech + // Step 1: Create + submit PO as tech (single line item) await login(page, USERS.TECH); - const poUrl = await submitPo(page, title); + await page.goto("/po/new"); + await fillPoHeader(page, title); - // Step 2: Approve as manager + // Select a vendor (required for manager approval) + const vendorSelectSFP = page.locator('select[name="vendorId"]'); + await expect(vendorSelectSFP).toBeVisible({ timeout: 5_000 }); + await vendorSelectSFP.selectOption({ index: 1 }); + + const lineItemsSection = page.locator("section").filter({ + has: page.getByRole("heading", { name: /line items/i }), + }); + const firstRow = lineItemsSection.locator("tbody tr").first(); + await firstRow.getByPlaceholder("Item name *").fill("Engine gasket"); + await firstRow.locator('input[type="number"]').first().fill("2"); + await firstRow.locator('input[placeholder="0.00"]').fill("250"); + await page.getByRole("button", { name: /submit for approval/i }).click(); + // Wait for redirect to a real PO detail page (not /po/new) + await expect(page).toHaveURL(/\/po\/(?!new)[^/]+$/, { timeout: 20_000 }); + const poUrl = page.url(); + const poId = poUrl.split("/po/")[1].replace(/[?#].*$/, "").replace(/\/$/, ""); + + // Step 2: Approve as manager via /approvals/ await context.clearCookies(); await login(page, USERS.MANAGER); - await page.goto(poUrl); - await page.getByRole("button", { name: /^approve$/i }).click(); - await expect(page.getByText(/approved/i)).toBeVisible({ timeout: 10_000 }); + await page.goto(`/approvals/${poId}`); + const approveBtn = page.getByRole("button", { name: /^approve$/i }); + await expect(approveBtn).toBeVisible({ timeout: 15_000 }); + await approveBtn.click(); + await expect(page).not.toHaveURL(/\/approvals\/[^/]+$/, { timeout: 15_000 }); - // Step 3: Start payment processing as accounts + // Step 3: Start payment processing as accounts via /payments queue await context.clearCookies(); await login(page, USERS.ACCOUNTS); - await page.goto(poUrl); - const processBtn = page.getByRole("button", { - name: /process payment|start payment/i, - }); - await expect(processBtn).toBeVisible({ timeout: 10_000 }); - await processBtn.click(); - await expect(page.getByText(/sent for payment/i)).toBeVisible({ - timeout: 10_000, - }); + await page.goto("/payments"); + const cardSFP = page.locator("div.rounded-lg").filter({ has: page.getByRole("heading", { name: title }) }); + await expect(cardSFP).toBeVisible({ timeout: 10_000 }); + await cardSFP.getByRole("button", { name: /start payment processing/i }).click(); + // Reload to get a fresh server-rendered SENT_FOR_PAYMENT state + await page.reload(); + // Verify the ref input is now visible (card is in SENT_FOR_PAYMENT state) + const cardSFP2 = page.locator("div.rounded-lg").filter({ has: page.getByRole("heading", { name: title }) }); + await expect(cardSFP2.getByPlaceholder(/payment reference|transaction/i)).toBeVisible({ timeout: 10_000 }); return poUrl; } test.describe("Feature 8 — Partial receipt confirmation", () => { + // Each test drives the full PO lifecycle (create → approve → pay → receipt). + // This takes well over 30 s per test on a local dev server under load. + test.setTimeout(180_000); + test("US-8a: ACCOUNTS user sees delivery/receipt section on SENT_FOR_PAYMENT PO", async ({ page, context, @@ -54,33 +190,21 @@ test.describe("Feature 8 — Partial receipt confirmation", () => { page: import("@playwright/test").Page; context: BrowserContext; }) => { - const poUrl = await createSentForPaymentPo( - page, - context, - `E2E_RECEIPT_SEC_${Date.now()}` - ); + const title8a = `E2E_RECEIPT_SEC_${Date.now()}`; + await createSentForPaymentPo(page, context, title8a); - // Stay logged in as accounts — navigate to the PO detail page - await page.goto(poUrl); - - // The PO detail page (po-detail.tsx) shows "Confirm Receipt" or a receipt section - // when status is PAID_DELIVERED or PARTIALLY_CLOSED and the submitter is viewing. - // From accounts perspective: look for payment reference input and confirm button. - const paymentInput = page.getByPlaceholder(/reference|ref/i).first(); - if (await paymentInput.isVisible()) { - // PO is still in SENT_FOR_PAYMENT — confirm button should be visible - await expect( - page.getByRole("button", { name: /confirm payment|mark.*paid/i }) - ).toBeVisible(); - console.log( - "✓ Payment reference input and Confirm Payment button visible on SENT_FOR_PAYMENT PO" - ); - } else { - // If payment already confirmed: look for receipt confirmation section - const receiptSection = page.getByText(/receipt|delivery|confirm/i).first(); - await expect(receiptSection).toBeVisible(); - console.log("✓ Receipt/delivery section visible on PO"); - } + // Stay on /payments — the PO should show "Confirm Payment Sent" form now + // (createSentForPaymentPo ends with the PO in SENT_FOR_PAYMENT on the /payments page) + await page.goto("/payments"); + const poCard8a = page.locator("div.rounded-lg").filter({ hasText: title8a }); + await expect(poCard8a).toBeVisible({ timeout: 10_000 }); + await expect( + poCard8a.getByPlaceholder(/payment reference|transaction/i) + ).toBeVisible({ timeout: 10_000 }); + await expect( + poCard8a.getByRole("button", { name: /confirm payment sent/i }) + ).toBeVisible({ timeout: 10_000 }); + console.log("✓ Payment reference input and Confirm Payment Sent button visible on SENT_FOR_PAYMENT PO"); }); test("US-8b: TECHNICAL submitter sees receipt section with line items on PAID_DELIVERED PO", async ({ @@ -90,21 +214,23 @@ test.describe("Feature 8 — Partial receipt confirmation", () => { page: import("@playwright/test").Page; context: BrowserContext; }) => { - // Drive the full flow to PAID_DELIVERED - const poUrl = await createSentForPaymentPo( - page, - context, - `E2E_RECEIPT_ITEMS_${Date.now()}` - ); + // Drive the full flow to SENT_FOR_PAYMENT + const title8b = `E2E_RECEIPT_ITEMS_${Date.now()}`; + const poUrl = await createSentForPaymentPo(page, context, title8b); - // Mark as paid (accounts) - await page.goto(poUrl); - const refInput = page.getByPlaceholder(/reference|ref/i).first(); - if (await refInput.isVisible()) { - await refInput.fill("NEFT/E2E/RECEIPT"); - await page.getByRole("button", { name: /confirm payment|mark.*paid/i }).click(); - await expect(page.getByText(/paid/i)).toBeVisible({ timeout: 10_000 }); - } + // Mark as paid via /payments — createSentForPaymentPo leaves the PO in SENT_FOR_PAYMENT + await page.goto("/payments"); + const card8b = page.locator("div.rounded-lg").filter({ has: page.getByRole("heading", { name: title8b }) }); + await expect(card8b).toBeVisible({ timeout: 10_000 }); + const refInput8b = card8b.getByPlaceholder(/payment reference|transaction/i); + await expect(refInput8b).toBeVisible({ timeout: 10_000 }); + await refInput8b.fill("NEFT-E2E-RECEIPT-8B"); + await card8b.getByRole("button", { name: /confirm payment sent/i }).click(); + // Reload to get fresh state; card should now be gone (PO moved to PAID_DELIVERED) + await page.reload(); + await expect( + page.locator("div.rounded-lg").filter({ has: page.getByRole("heading", { name: title8b }) }) + ).not.toBeVisible({ timeout: 10_000 }); // Switch to tech submitter to view the receipt section await context.clearCookies(); @@ -112,19 +238,9 @@ test.describe("Feature 8 — Partial receipt confirmation", () => { await page.goto(poUrl); // The PoDetail component shows a "Confirm Receipt" link for PAID_DELIVERED + submitter - // or a partial receipt confirmation section - const confirmReceiptLink = page.getByRole("link", { - name: /confirm receipt/i, - }); - const receiptSection = page.getByText(/receipt|delivery confirmed|item delivery/i).first(); - - const hasReceiptLink = await confirmReceiptLink.isVisible(); - const hasReceiptSection = await receiptSection.isVisible(); - - expect(hasReceiptLink || hasReceiptSection).toBeTruthy(); - console.log( - "✓ Receipt section or Confirm Receipt link visible to TECHNICAL submitter on PAID_DELIVERED PO" - ); + const confirmReceiptLink = page.getByRole("link", { name: /confirm receipt/i }); + await expect(confirmReceiptLink).toBeVisible({ timeout: 10_000 }); + console.log("✓ Confirm Receipt link visible to TECHNICAL submitter on PAID_DELIVERED PO"); }); test("US-8a: receipt page at /po/[id]/receipt is accessible for PAID_DELIVERED PO", async ({ @@ -149,4 +265,110 @@ test.describe("Feature 8 — Partial receipt confirmation", () => { test.skip(true, "No PAID_DELIVERED POs in seed data — drive full flow in US-8b"); } }); + + // ── PARTIALLY_CLOSED revisit flow (US-8c / 8d / 8e) ────────────────────── + + test("US-8c: PARTIALLY_CLOSED PO appears in submitter's My Orders page under Open Orders", async ({ + page, + context, + }: { + page: import("@playwright/test").Page; + context: BrowserContext; + }) => { + const title = `E2E_PC_MYORDERS_${Date.now()}`; + await createPartiallyClosedPo(page, context, title); + + // Still logged in as TECH — navigate to My Orders + await page.goto("/my-orders"); + await expect(page).toHaveURL(/\/my-orders/, { timeout: 10_000 }); + + // The page has an "Open Orders" heading followed by a table + const openOrdersSection = page.locator("div", { has: page.getByRole("heading", { name: /open orders/i }) }); + await expect(openOrdersSection).toBeVisible({ timeout: 10_000 }); + + // The PO title should appear inside that section + const poRow = openOrdersSection.getByText(title); + await expect(poRow).toBeVisible({ timeout: 10_000 }); + console.log(`✓ PARTIALLY_CLOSED PO "${title}" is visible in the Open Orders section of My Orders`); + }); + + test("US-8d: submitter can reach the receipt page from a PARTIALLY_CLOSED PO detail via 'Confirm Remaining'", async ({ + page, + context, + }: { + page: import("@playwright/test").Page; + context: BrowserContext; + }) => { + const title = `E2E_PC_CTA_${Date.now()}`; + const poUrl = await createPartiallyClosedPo(page, context, title); + + // Still logged in as TECH — go back to the PO detail page + await page.goto(poUrl); + + // Expect the amber "Partially received" CTA banner + await expect(page.getByText(/partially received/i)).toBeVisible({ timeout: 10_000 }); + await expect( + page.getByText(/some items are still outstanding/i) + ).toBeVisible({ timeout: 10_000 }); + + // The "Confirm Remaining" link must be present + const confirmRemainingLink = page.getByRole("link", { name: /confirm remaining/i }); + await expect(confirmRemainingLink).toBeVisible({ timeout: 10_000 }); + + // Click the link and confirm navigation to the /receipt page + await confirmRemainingLink.click(); + await expect(page).toHaveURL(/\/po\/[^/]+\/receipt$/, { timeout: 10_000 }); + + // The receipt page heading should say "Confirm Remaining Receipt" + await expect( + page.getByRole("heading", { name: /confirm remaining receipt/i }) + ).toBeVisible({ timeout: 10_000 }); + + // The amber "partially received" notice on the receipt page must also be present + await expect( + page.getByText(/this po has been partially received/i) + ).toBeVisible({ timeout: 10_000 }); + + console.log("✓ 'Confirm Remaining' CTA visible on PARTIALLY_CLOSED PO; navigates to /receipt with correct heading"); + }); + + test("US-8e: receipt form shows editable inputs only for items with remaining quantity; fully-delivered items show '—'", async ({ + page, + context, + }: { + page: import("@playwright/test").Page; + context: BrowserContext; + }) => { + const title = `E2E_PC_FORM_${Date.now()}`; + const poUrl = await createPartiallyClosedPo(page, context, title); + + // Navigate directly to the /receipt page as TECH (still logged in) + await page.goto(`${poUrl}/receipt`); + await expect(page).toHaveURL(/\/receipt$/, { timeout: 10_000 }); + + // The table has 2 rows — one fully delivered (Engine gasket) and one remaining (Oil filter) + const tableRows = page.locator("table tbody tr"); + await expect(tableRows).toHaveCount(2, { timeout: 10_000 }); + + // ── Row 0: Engine gasket — fully received → "Receiving Now" cell must show "—" (no input) ── + const firstRow = tableRows.first(); + // The fully-received row should show the "✓ fully received" label + await expect(firstRow.getByText(/fully received/i)).toBeVisible({ timeout: 5_000 }); + // No number input should be present in this row + const inputInFirstRow = firstRow.locator('input[type="number"]'); + await expect(inputInFirstRow).toHaveCount(0, { timeout: 5_000 }); + // The "—" dash placeholder should be visible in the Receiving Now column + const receivingNowCell = firstRow.locator("td").last(); + await expect(receivingNowCell.getByText("—")).toBeVisible({ timeout: 5_000 }); + + // ── Row 1: Oil filter — remaining → "Receiving Now" cell must show an editable input ── + const secondRow = tableRows.nth(1); + await expect(secondRow.locator('input[type="number"]')).toHaveCount(1, { timeout: 5_000 }); + + // The remaining quantity column for row 1 should not be "—" (items are still outstanding) + const remainingCell = secondRow.locator("td").nth(3); + await expect(remainingCell.getByText("—")).toHaveCount(0, { timeout: 5_000 }); + + console.log("✓ Fully-delivered item shows '—' with no input; remaining item has editable Receiving Now input"); + }); });