fix(my-orders): surface PARTIALLY_CLOSED POs in Open Orders
PARTIALLY_CLOSED was missing from the open-filter so affected POs disappeared from the submitter''s My Orders view entirely, making it impossible to confirm remaining deliveries. Also hardens confirmReceipt() against negative delivery quantities and extends partial-receipt.spec.ts with US-8c/8d/8e covering the full PARTIALLY_CLOSED revisit flow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1c5727850a
commit
91f369da9e
3 changed files with 299 additions and 70 deletions
|
|
@ -25,7 +25,7 @@ export default async function MyOrdersPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const open = pos.filter((p) =>
|
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) =>
|
const closed = pos.filter((p) =>
|
||||||
["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "CLOSED", "REJECTED"].includes(p.status)
|
["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "CLOSED", "REJECTED"].includes(p.status)
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,13 @@ export async function confirmReceipt({
|
||||||
return { error: "You can only confirm receipt on your own purchase orders." };
|
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
|
// Compute the updated deliveredQuantity for each line item
|
||||||
const lineUpdates = po.lineItems.map((li) => {
|
const lineUpdates = po.lineItems.map((li) => {
|
||||||
const prevDelivered = Number(li.deliveredQuantity ?? 0);
|
const prevDelivered = Number(li.deliveredQuantity ?? 0);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
* User stories covered: Feature 8 — Partial receipt confirmation
|
* User stories covered: Feature 8 — Partial receipt confirmation
|
||||||
* - ACCOUNTS user sees a receipt/delivery section on SENT_FOR_PAYMENT or PAID_DELIVERED POs
|
* - 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
|
* - 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.
|
* 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
|
* The test drives the full flow (create → submit → approve → process payment) if
|
||||||
|
|
@ -9,9 +12,117 @@
|
||||||
* will find it via the payment history page.
|
* will find it via the payment history page.
|
||||||
*
|
*
|
||||||
* Created: 2026-05-17
|
* 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 { test, expect, type BrowserContext } from "@playwright/test";
|
||||||
import { login, USERS, submitPo } from "./helpers/login";
|
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<string> {
|
||||||
|
// ── 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/<id> ───────────────────────
|
||||||
|
// Extract PO id from the URL (/po/<id>) 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 */
|
/** Drive: create PO → approve as manager → process payment as accounts → return PO URL */
|
||||||
async function createSentForPaymentPo(
|
async function createSentForPaymentPo(
|
||||||
|
|
@ -19,34 +130,59 @@ async function createSentForPaymentPo(
|
||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
title: string
|
title: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// Step 1: Submit as tech
|
// Step 1: Create + submit PO as tech (single line item)
|
||||||
await login(page, USERS.TECH);
|
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/<id>
|
||||||
await context.clearCookies();
|
await context.clearCookies();
|
||||||
await login(page, USERS.MANAGER);
|
await login(page, USERS.MANAGER);
|
||||||
await page.goto(poUrl);
|
await page.goto(`/approvals/${poId}`);
|
||||||
await page.getByRole("button", { name: /^approve$/i }).click();
|
const approveBtn = page.getByRole("button", { name: /^approve$/i });
|
||||||
await expect(page.getByText(/approved/i)).toBeVisible({ timeout: 10_000 });
|
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 context.clearCookies();
|
||||||
await login(page, USERS.ACCOUNTS);
|
await login(page, USERS.ACCOUNTS);
|
||||||
await page.goto(poUrl);
|
await page.goto("/payments");
|
||||||
const processBtn = page.getByRole("button", {
|
const cardSFP = page.locator("div.rounded-lg").filter({ has: page.getByRole("heading", { name: title }) });
|
||||||
name: /process payment|start payment/i,
|
await expect(cardSFP).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
await cardSFP.getByRole("button", { name: /start payment processing/i }).click();
|
||||||
await expect(processBtn).toBeVisible({ timeout: 10_000 });
|
// Reload to get a fresh server-rendered SENT_FOR_PAYMENT state
|
||||||
await processBtn.click();
|
await page.reload();
|
||||||
await expect(page.getByText(/sent for payment/i)).toBeVisible({
|
// Verify the ref input is now visible (card is in SENT_FOR_PAYMENT state)
|
||||||
timeout: 10_000,
|
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;
|
return poUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Feature 8 — Partial receipt confirmation", () => {
|
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 ({
|
test("US-8a: ACCOUNTS user sees delivery/receipt section on SENT_FOR_PAYMENT PO", async ({
|
||||||
page,
|
page,
|
||||||
context,
|
context,
|
||||||
|
|
@ -54,33 +190,21 @@ test.describe("Feature 8 — Partial receipt confirmation", () => {
|
||||||
page: import("@playwright/test").Page;
|
page: import("@playwright/test").Page;
|
||||||
context: BrowserContext;
|
context: BrowserContext;
|
||||||
}) => {
|
}) => {
|
||||||
const poUrl = await createSentForPaymentPo(
|
const title8a = `E2E_RECEIPT_SEC_${Date.now()}`;
|
||||||
page,
|
await createSentForPaymentPo(page, context, title8a);
|
||||||
context,
|
|
||||||
`E2E_RECEIPT_SEC_${Date.now()}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Stay logged in as accounts — navigate to the PO detail page
|
// Stay on /payments — the PO should show "Confirm Payment Sent" form now
|
||||||
await page.goto(poUrl);
|
// (createSentForPaymentPo ends with the PO in SENT_FOR_PAYMENT on the /payments page)
|
||||||
|
await page.goto("/payments");
|
||||||
// The PO detail page (po-detail.tsx) shows "Confirm Receipt" or a receipt section
|
const poCard8a = page.locator("div.rounded-lg").filter({ hasText: title8a });
|
||||||
// when status is PAID_DELIVERED or PARTIALLY_CLOSED and the submitter is viewing.
|
await expect(poCard8a).toBeVisible({ timeout: 10_000 });
|
||||||
// From accounts perspective: look for payment reference input and confirm button.
|
await expect(
|
||||||
const paymentInput = page.getByPlaceholder(/reference|ref/i).first();
|
poCard8a.getByPlaceholder(/payment reference|transaction/i)
|
||||||
if (await paymentInput.isVisible()) {
|
).toBeVisible({ timeout: 10_000 });
|
||||||
// PO is still in SENT_FOR_PAYMENT — confirm button should be visible
|
await expect(
|
||||||
await expect(
|
poCard8a.getByRole("button", { name: /confirm payment sent/i })
|
||||||
page.getByRole("button", { name: /confirm payment|mark.*paid/i })
|
).toBeVisible({ timeout: 10_000 });
|
||||||
).toBeVisible();
|
console.log("✓ Payment reference input and Confirm Payment Sent button visible on SENT_FOR_PAYMENT PO");
|
||||||
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");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("US-8b: TECHNICAL submitter sees receipt section with line items on PAID_DELIVERED PO", async ({
|
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;
|
page: import("@playwright/test").Page;
|
||||||
context: BrowserContext;
|
context: BrowserContext;
|
||||||
}) => {
|
}) => {
|
||||||
// Drive the full flow to PAID_DELIVERED
|
// Drive the full flow to SENT_FOR_PAYMENT
|
||||||
const poUrl = await createSentForPaymentPo(
|
const title8b = `E2E_RECEIPT_ITEMS_${Date.now()}`;
|
||||||
page,
|
const poUrl = await createSentForPaymentPo(page, context, title8b);
|
||||||
context,
|
|
||||||
`E2E_RECEIPT_ITEMS_${Date.now()}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mark as paid (accounts)
|
// Mark as paid via /payments — createSentForPaymentPo leaves the PO in SENT_FOR_PAYMENT
|
||||||
await page.goto(poUrl);
|
await page.goto("/payments");
|
||||||
const refInput = page.getByPlaceholder(/reference|ref/i).first();
|
const card8b = page.locator("div.rounded-lg").filter({ has: page.getByRole("heading", { name: title8b }) });
|
||||||
if (await refInput.isVisible()) {
|
await expect(card8b).toBeVisible({ timeout: 10_000 });
|
||||||
await refInput.fill("NEFT/E2E/RECEIPT");
|
const refInput8b = card8b.getByPlaceholder(/payment reference|transaction/i);
|
||||||
await page.getByRole("button", { name: /confirm payment|mark.*paid/i }).click();
|
await expect(refInput8b).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(page.getByText(/paid/i)).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
|
// Switch to tech submitter to view the receipt section
|
||||||
await context.clearCookies();
|
await context.clearCookies();
|
||||||
|
|
@ -112,19 +238,9 @@ test.describe("Feature 8 — Partial receipt confirmation", () => {
|
||||||
await page.goto(poUrl);
|
await page.goto(poUrl);
|
||||||
|
|
||||||
// The PoDetail component shows a "Confirm Receipt" link for PAID_DELIVERED + submitter
|
// 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 confirmReceiptLink = page.getByRole("link", {
|
await expect(confirmReceiptLink).toBeVisible({ timeout: 10_000 });
|
||||||
name: /confirm receipt/i,
|
console.log("✓ Confirm Receipt link visible to TECHNICAL submitter on PAID_DELIVERED PO");
|
||||||
});
|
|
||||||
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"
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("US-8a: receipt page at /po/[id]/receipt is accessible for PAID_DELIVERED PO", async ({
|
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");
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue