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:
Hardik 2026-05-27 03:32:26 +05:30
parent 1c5727850a
commit 91f369da9e
3 changed files with 299 additions and 70 deletions

View file

@ -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)

View file

@ -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);

View file

@ -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");
});
}); });