diff --git a/src/components/sponsors/reports/ByItemView.js b/src/components/sponsors/reports/ByItemView.js index 910901f3d..5815cedff 100644 --- a/src/components/sponsors/reports/ByItemView.js +++ b/src/components/sponsors/reports/ByItemView.js @@ -38,9 +38,15 @@ import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; import T from "i18n-react/dist/i18n-react"; import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/money"; -import StatusPill from "./StatusPill"; import ChipList from "../../mui/chip-list"; -import { Destination, PER_PAGE_OPTIONS } from "./LinesManifestView"; +import { + Destination, + LineStatusPill, + PER_PAGE_OPTIONS, + lineStatus, + liveQuantity, + liveAmountCents +} from "./LinesManifestView"; import { formatCheckoutTime } from "./OrdersTable"; import { DEFAULT_CURRENT_PAGE, @@ -85,16 +91,11 @@ const accumulateRow = (itemMap, row) => { item.label = row.description.trim(); } item.lines += 1; - // Canceled lines are shown struck-through in the drill-down (as contributors - // below) but excluded from ALL "purchased" aggregates: qty, money, orders, - // and statusMix. Counting them would let a canceled-only line report an item - // as purchased (Qty 0 next to Orders 1 / Paid 1). A mixed order keeps its - // count because the live line for the same item still adds the order id. if (!row.is_canceled) { - item.qty += row.quantity ?? 0; - // Null-safe money: all-null stays null (renders "—"); mixed sums non-nulls. - if (row.line_total != null) { - item.totalCents = (item.totalCents ?? 0) + row.line_total; + item.qty += liveQuantity(row); + const liveCents = liveAmountCents(row); + if (liveCents != null) { + item.totalCents = (item.totalCents ?? 0) + liveCents; } const purchaseId = row.purchase?.id ?? null; if (purchaseId != null) { @@ -116,11 +117,15 @@ const accumulateRow = (itemMap, row) => { sponsorBooth: row.sponsor_booth ?? null, checkoutAt: row.purchase?.checkout_at ?? null, rateName: row.rate_name ?? "", - // the line's own state: a soft-canceled line leaves its parent order Paid - status: row.is_canceled ? "Canceled" : row.purchase?.status ?? "", - qty: row.quantity ?? 0, - lineTotalCents: row.line_total ?? null, + status: lineStatus(row), + // Struck-through rows show what was ordered, not 0. + qty: row.is_canceled ? row.quantity ?? 0 : liveQuantity(row), + orderedQty: row.quantity ?? 0, + lineTotalCents: row.is_canceled + ? row.line_total ?? null + : liveAmountCents(row), isCanceled: Boolean(row.is_canceled), + isPartiallyCanceled: Boolean(row.is_partially_canceled), // line-grain freshness (decision 1): the contributor row IS a line syncedAt: row.synced_at ?? null, sourceUpdatedAt: row.source_updated_at ?? null @@ -463,12 +468,13 @@ const ItemTable = ({ {c.rateName} - + + + + {c.isPartiallyCanceled + ? `${c.qty} / ${c.orderedQty}` + : c.qty} - {c.qty} {c.lineTotalCents == null ? "—" diff --git a/src/components/sponsors/reports/LinesManifestView.js b/src/components/sponsors/reports/LinesManifestView.js index a4d061a6e..475b97127 100644 --- a/src/components/sponsors/reports/LinesManifestView.js +++ b/src/components/sponsors/reports/LinesManifestView.js @@ -49,6 +49,20 @@ export const PER_PAGE_OPTIONS = [ MAX_PER_PAGE ]; +// Clamped: legacy lines can have quantity 0, canceled_quantity 1. +// ByItemView imports from here, never the reverse (cycle). +export const liveQuantity = (row) => { + if (row?.is_canceled) return 0; + return Math.max(0, (row?.quantity ?? 0) - (row?.canceled_quantity ?? 0)); +}; + +// Never prorate line_total — partial events charge floor unit price. +export const liveAmountCents = (row) => { + if (row?.line_total == null) return null; + if (row.is_canceled) return 0; + return Math.max(0, row.line_total - (row.canceled_amount ?? 0)); +}; + // Destination = the line's add-on (e.g. "Meeting Room T"); when absent, the // logistics convention is the sponsor's booth — the API supplies it as // `sponsor_booth` (the sponsor's Booth-type add-on name(s)). The muted "Booth" @@ -113,6 +127,38 @@ const HEADERS = [ { key: "col_source_updated" } ]; +// Both cancellation states leave the parent order Paid, so purchase.status lies. +export const lineStatus = (row) => { + if (row?.is_canceled) return "Canceled"; + if (row?.is_partially_canceled) return "partially_canceled"; + return row?.purchase?.status ?? ""; +}; + +const LABEL_KEY_BY_LINE_STATUS = { + Canceled: "sponsor_reports_page.status_canceled", + partially_canceled: "sponsor_reports_page.status_partially_canceled" +}; + +// Live over charged, matching the Qty cell. Finance reads this column and this is +// the only per-line money surface, so netting alone would drop the charged figure. +const lineTotalLabel = (row) => + row.is_partially_canceled + ? `${currencyAmountFromCents( + liveAmountCents(row) + )} / ${currencyAmountFromCents(row.line_total)}` + : currencyAmountFromCents(row.line_total); + +// Not redundant with the strikethrough, which no CSV export carries. +export const LineStatusPill = ({ status }) => { + const labelKey = LABEL_KEY_BY_LINE_STATUS[status]; + return ( + + ); +}; + const LinesManifestView = ({ rows = [], total = 0, @@ -183,31 +229,17 @@ const LinesManifestView = ({ {formatCheckoutTime(line.purchase?.checkout_at)} {line.notes} - {line.quantity} + + {line.is_partially_canceled + ? `${liveQuantity(line)} / ${line.quantity}` + : line.quantity} + {line.rate_name} - {/* The LINE's state, not the parent order's. A soft-canceled - line leaves its order Paid, so rendering purchase.status - printed "Paid" on a dead row — and the strikethrough that - was the only other signal does not survive CSV export. */} - {line.is_canceled ? ( - - ) : ( - - )} + - {line.line_total == null - ? "—" - : currencyAmountFromCents(line.line_total)} + {line.line_total == null ? "—" : lineTotalLabel(line)} {formatCheckoutTime(line.synced_at)} diff --git a/src/components/sponsors/reports/StatusPill.js b/src/components/sponsors/reports/StatusPill.js index 9353de00d..d676b5419 100644 --- a/src/components/sponsors/reports/StatusPill.js +++ b/src/components/sponsors/reports/StatusPill.js @@ -10,7 +10,8 @@ const TONE_BY_STATUS = { in_progress: "info", not_applicable: "default", canceled: "default", - cancelled: "default" + cancelled: "default", + partially_canceled: "warning" }; export const statusTone = (status) => diff --git a/src/components/sponsors/reports/__tests__/ByItemView.test.js b/src/components/sponsors/reports/__tests__/ByItemView.test.js index 3a1dc2ffe..5be09df09 100644 --- a/src/components/sponsors/reports/__tests__/ByItemView.test.js +++ b/src/components/sponsors/reports/__tests__/ByItemView.test.js @@ -32,6 +32,9 @@ const line = (over = {}) => ({ notes: "dock B", is_canceled: false, canceled_at: null, + canceled_quantity: 0, + canceled_amount: 0, + is_partially_canceled: false, ...over }); @@ -162,8 +165,10 @@ describe("groupLinesBySponsorItem", () => { rateName: "Early", status: "Canceled", qty: 2, + orderedQty: 2, lineTotalCents: 100000, isCanceled: true, + isPartiallyCanceled: false, syncedAt: null, sourceUpdatedAt: null }); @@ -253,6 +258,73 @@ describe("groupLinesBySponsorItem", () => { }); }); +describe("partial cancellation", () => { + const partial = (over = {}) => + line({ + quantity: 5, + canceled_quantity: 2, + canceled_amount: 40000, + is_canceled: false, + is_partially_canceled: true, + canceled_at: null, + ...over + }); + + it("keeps a fully cancelled line out of both aggregates", () => { + const [group] = groupLinesBySponsorItem([ + partial({ + quantity: 5, + canceled_quantity: 5, + line_total: 100000, + is_canceled: true, + is_partially_canceled: false + }) + ]); + expect(group.items[0].qty).toBe(0); + expect(group.items[0].totalCents).toBeNull(); + }); + + it("counts only live units and flags the contributor rather than Paid", () => { + const [group] = groupLinesBySponsorItem([partial()]); + expect(group.items[0].qty).toBe(3); + const [contributor] = group.items[0].contributors; + expect(contributor.isPartiallyCanceled).toBe(true); + expect(contributor.isCanceled).toBe(false); + expect(contributor.status).toBe("partially_canceled"); + expect(contributor.qty).toBe(3); + expect(contributor.orderedQty).toBe(5); + }); + + it("still counts the parent order as an order for the item", () => { + const [group] = groupLinesBySponsorItem([partial()]); + expect(group.items[0].orders).toBe(1); + }); + + it("nets the cancelled money off the item total and its contributor alike", () => { + const [group] = groupLinesBySponsorItem([ + partial({ line_total: 100000, canceled_amount: 40000 }) + ]); + expect(group.items[0].totalCents).toBe(60000); + expect(group.items[0].contributors[0].lineTotalCents).toBe(60000); + }); + + it("keeps an all-null-money item at null so it still renders as unknown", () => { + const [group] = groupLinesBySponsorItem([ + partial({ line_total: null, canceled_amount: 0 }) + ]); + expect(group.items[0].totalCents).toBeNull(); + }); + + it("keeps a fully cancelled contributor at its ordered units and charged money", () => { + const [group] = groupLinesBySponsorItem([ + line({ quantity: 5, line_total: 100000, is_canceled: true }) + ]); + const [contributor] = group.items[0].contributors; + expect(contributor.qty).toBe(5); + expect(contributor.lineTotalCents).toBe(100000); + }); +}); + describe("groupLinesByItem", () => { it("merges an item_code ACROSS sponsors into one row and names each sponsor in the drill-down", () => { const rows = [ @@ -356,8 +428,10 @@ const item = (over = {}) => ({ rateName: "Early", status: "Paid", qty: 3, + orderedQty: 3, lineTotalCents: 150000, - isCanceled: false + isCanceled: false, + isPartiallyCanceled: false }, { sponsorName: "Nvidia", @@ -366,10 +440,12 @@ const item = (over = {}) => ({ addOnName: null, checkoutAt: null, rateName: "Standard", - status: "Pending Payment", + status: "Canceled", qty: 2, + orderedQty: 2, lineTotalCents: 100000, - isCanceled: true + isCanceled: true, + isPartiallyCanceled: false } ], ...over @@ -463,7 +539,9 @@ describe("ByItemView", () => { ] }); fireEvent.click(screen.getByText("AV1")); // expand the item - expect(screen.getByText("Canceled")).toBeInTheDocument(); + expect( + screen.getByText("sponsor_reports_page.status_canceled") + ).toBeInTheDocument(); const syncedText = moment.unix(synced).utc().format("YYYY-MM-DD h:mm A"); const sourceUpdatedText = moment .unix(sourceUpdated) @@ -475,6 +553,43 @@ describe("ByItemView", () => { expect(cells[cells.length - 1]).toHaveTextContent(sourceUpdatedText); }); + it("renders a partially cancelled contributor's split quantity and pill (regression: catches the isPartiallyCanceled render branch being dropped)", () => { + renderView({ + groups: [ + group({ + items: [ + item({ + contributors: [ + { + sponsorName: "FNTECH", + number: "OCP-1", + formCode: "AV", + addOnName: null, + checkoutAt: null, + rateName: "Early", + status: "partially_canceled", + qty: 3, + orderedQty: 5, + lineTotalCents: 60000, + isCanceled: false, + isPartiallyCanceled: true, + syncedAt: null, + sourceUpdatedAt: null + } + ] + }) + ] + }) + ] + }); + fireEvent.click(screen.getByText("AV1")); + expect( + screen.getByText("sponsor_reports_page.status_partially_canceled") + ).toBeInTheDocument(); + const row = screen.getByText("OCP-1").closest("tr"); + expect(within(row).getByText("3 / 5")).toBeInTheDocument(); + }); + it("expand button toggles the drill-down and reflects aria-expanded", () => { renderView(); const toggle = screen.getByRole("button", { diff --git a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js index 4eb0e2569..eb755993d 100644 --- a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js +++ b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js @@ -2,7 +2,10 @@ import "@testing-library/jest-dom"; import React from "react"; import moment from "moment-timezone"; import { render, screen, within } from "@testing-library/react"; -import LinesManifestView from "../LinesManifestView"; +import LinesManifestView, { + liveQuantity, + liveAmountCents +} from "../LinesManifestView"; jest.mock("i18n-react/dist/i18n-react", () => ({ translate: (k, opts) => @@ -29,6 +32,9 @@ const line = (over = {}) => ({ notes: "dock B", is_canceled: false, canceled_at: null, + canceled_quantity: 0, + canceled_amount: 0, + is_partially_canceled: false, ...over }); @@ -214,3 +220,118 @@ describe("lines_count copy", () => { expect(en.sponsor_reports_page.lines_count).toBe("{count} live lines"); }); }); + +describe("liveQuantity", () => { + it("nets the cancelled portion off a partially cancelled line", () => { + expect( + liveQuantity( + line({ + quantity: 5, + canceled_quantity: 2, + is_partially_canceled: true + }) + ) + ).toBe(3); + }); + + it("clamps at 0 when a legacy line cancels more than it ordered", () => { + // legacy shape: purchases-api defaults quantity to 1, the sync to 0 + expect(liveQuantity(line({ quantity: 0, canceled_quantity: 1 }))).toBe(0); + }); + + it("treats missing fields as zero rather than NaN", () => { + expect(liveQuantity({})).toBe(0); + expect(liveQuantity(undefined)).toBe(0); + }); +}); + +describe("liveAmountCents", () => { + it("subtracts the frozen cancelled amount on a partially cancelled line", () => { + // the source's own sum, not a proration of line_total + expect( + liveAmountCents( + line({ + line_total: 100000, + canceled_amount: 40000, + quantity: 5, + canceled_quantity: 2, + is_partially_canceled: true + }) + ) + ).toBe(60000); + }); + + it("clamps at 0 when the cancelled amount exceeds the line total", () => { + expect( + liveAmountCents(line({ line_total: 1000, canceled_amount: 5000 })) + ).toBe(0); + }); +}); + +describe("partially cancelled lines", () => { + const partial = () => + line({ + quantity: 5, + canceled_quantity: 2, + canceled_amount: 40000, + is_canceled: false, + is_partially_canceled: true, + canceled_at: null + }); + + it("shows live units over ordered units in the quantity cell", () => { + renderView({ rows: [partial()], total: 1 }); + expect(screen.getByText("3 / 5")).toBeInTheDocument(); + }); + + it("shows live over charged money, so the row does not price cancelled units", () => { + renderView({ rows: [partial()], total: 1 }); + const row = screen.getByText("AV1").closest("tr"); + expect(within(row).getByText("$600.00 / $1000.00")).toBeInTheDocument(); + }); + + it("renders the partially canceled pill, not the order status", () => { + renderView({ rows: [partial()], total: 1 }); + expect( + screen.getByText("sponsor_reports_page.status_partially_canceled") + ).toBeInTheDocument(); + expect(screen.queryByText("Paid")).not.toBeInTheDocument(); + }); + + it("does not strike through a partially cancelled row", () => { + renderView({ rows: [partial()], total: 1 }); + expect(screen.getByText("AV1").closest("tr")).not.toHaveAttribute( + "data-canceled" + ); + }); + + it("still counts a partially cancelled line as one live line", () => { + renderView({ rows: [partial()], total: 1 }); + expect( + screen.getByText("sponsor_reports_page.lines_count:1") + ).toBeInTheDocument(); + }); + + it("leaves a fully cancelled line struck through with the canceled pill", () => { + const full = line({ + quantity: 5, + canceled_quantity: 5, + is_canceled: true, + is_partially_canceled: false + }); + renderView({ rows: [full], total: 1 }); + const row = screen.getByText("AV1").closest("tr"); + expect(row).toHaveAttribute("data-canceled", "true"); + expect( + within(row).getByText("sponsor_reports_page.status_canceled") + ).toBeInTheDocument(); + expect(within(row).getByText("5")).toBeInTheDocument(); + }); + + it("leaves an active line's quantity as a bare number", () => { + renderView({ rows: [line({ quantity: 2 })], total: 1 }); + const row = screen.getByText("AV1").closest("tr"); + expect(within(row).getByText("2")).toBeInTheDocument(); + expect(within(row).queryByText("2 / 2")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/sponsors/reports/__tests__/StatusPill.test.js b/src/components/sponsors/reports/__tests__/StatusPill.test.js index 25634455b..d68499b7b 100644 --- a/src/components/sponsors/reports/__tests__/StatusPill.test.js +++ b/src/components/sponsors/reports/__tests__/StatusPill.test.js @@ -18,4 +18,11 @@ describe("StatusPill", () => { renderWithRedux(); expect(screen.getByText("pending")).toBeInTheDocument(); }); + + it("colors a partially canceled pill as a warning, not muted", () => { + renderWithRedux(); + expect(screen.getByText("Partial").closest(".MuiChip-root")).toHaveClass( + "MuiChip-colorWarning" + ); + }); }); diff --git a/src/i18n/en.json b/src/i18n/en.json index 9f787b323..ee09ba95a 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -4377,6 +4377,7 @@ "status_in_progress": "In Progress", "status_pending": "Pending", "status_canceled": "Canceled", + "status_partially_canceled": "Partially Canceled", "filter_asset_status": "Status", "group_by": "Group by", "report_filters": "Report Filters",