feat(duplicate-orders): enhance duplicate order detection and UI
- Introduced a new `DuplicateKind` type to differentiate between "order_number" and "line_aggregate" duplicates. - Updated the `DuplicateOrdersDialog` component to support tabbed navigation between order number and line aggregate views, improving user experience. - Enhanced the data structure for duplicate records to include `totalQuantity` and `totalSellingPrice`, facilitating more accurate duplicate detection. - Implemented logic to auto-flip to the tab with available content, ensuring operators do not encounter empty views. - Expanded test coverage for the `findDuplicateOrders` function to validate new line-aggregate matching logic and ensure accurate detection of duplicates across different scenarios. - Improved documentation for the duplicate order handling process, clarifying the criteria for identifying duplicates.
This commit is contained in:
parent
17580b5280
commit
46e0683635
3 changed files with 481 additions and 59 deletions
|
|
@ -33,10 +33,16 @@ interface DuplicateRecord {
|
|||
itemCount: number;
|
||||
itemStatusSummary: Record<string, number>;
|
||||
items: DuplicateItem[];
|
||||
totalQuantity: number | null;
|
||||
totalSellingPrice: number | null;
|
||||
}
|
||||
|
||||
type DuplicateKind = "order_number" | "line_aggregate";
|
||||
|
||||
interface DuplicateGroup {
|
||||
kind: DuplicateKind;
|
||||
orderNumber: string;
|
||||
aggregateKey?: string;
|
||||
records: DuplicateRecord[];
|
||||
}
|
||||
|
||||
|
|
@ -70,6 +76,11 @@ export function DuplicateOrdersDialog({
|
|||
}) {
|
||||
const [groups, setGroups] = useState<DuplicateGroup[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// "order_number" is the surer signal (same number = certainly
|
||||
// duplicated at intake); "line_aggregate" is a heuristic. Default
|
||||
// to the surer tab, then auto-flip if it's empty so the operator
|
||||
// doesn't see a blank list when only the heuristic tab has hits.
|
||||
const [activeKind, setActiveKind] = useState<DuplicateKind>("order_number");
|
||||
// Track which specific record ids are mid-cancel so the operator
|
||||
// sees a spinner on the exact row they're cancelling, not on the
|
||||
// whole dialog.
|
||||
|
|
@ -162,30 +173,61 @@ export function DuplicateOrdersDialog({
|
|||
}
|
||||
};
|
||||
|
||||
const numberGroups = groups.filter((g) => g.kind === "order_number");
|
||||
const aggregateGroups = groups.filter((g) => g.kind === "line_aggregate");
|
||||
const totalGroups = groups.length;
|
||||
|
||||
// Auto-flip to whichever tab has content — the operator shouldn't
|
||||
// land on an empty first tab when the heuristic tab is the only one
|
||||
// with hits. The first render after `load()` runs from the same
|
||||
// effect, so this is safe to compute inline.
|
||||
const effectiveKind: DuplicateKind =
|
||||
activeKind === "order_number" && numberGroups.length === 0 && aggregateGroups.length > 0
|
||||
? "line_aggregate"
|
||||
: activeKind;
|
||||
const effectiveVisible =
|
||||
effectiveKind === "order_number" ? numberGroups : aggregateGroups;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Copy className="h-4 w-4" />
|
||||
Duplicate order numbers
|
||||
Duplicate orders
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{loading
|
||||
? "Loading…"
|
||||
: totalGroups === 0
|
||||
? "No duplicate order numbers on the order board."
|
||||
: `${totalGroups} order number${totalGroups === 1 ? "" : "s"} appear on more than one active order. Click Cancel next to the duplicate you want to remove.`}
|
||||
? "No duplicate orders on the order board."
|
||||
: `${numberGroups.length} shared order number${numberGroups.length === 1 ? "" : "s"} · ${aggregateGroups.length} matching line-aggregate${aggregateGroups.length === 1 ? "" : "s"}.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!loading && groups.length > 0 && (
|
||||
{!loading && totalGroups > 0 && (
|
||||
<div className="flex items-center gap-1 border-b border-border/60">
|
||||
<TabButton
|
||||
label="Order number"
|
||||
count={numberGroups.length}
|
||||
active={effectiveKind === "order_number"}
|
||||
onClick={() => setActiveKind("order_number")}
|
||||
/>
|
||||
<TabButton
|
||||
label="Same line items"
|
||||
count={aggregateGroups.length}
|
||||
active={effectiveKind === "line_aggregate"}
|
||||
onClick={() => setActiveKind("line_aggregate")}
|
||||
hint="Heuristic — same item count, total qty, and total selling price. Descriptions may differ."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && effectiveVisible.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{groups.map((group) => (
|
||||
{effectiveVisible.map((group) => (
|
||||
<DuplicateGroupRow
|
||||
key={group.orderNumber}
|
||||
key={`${group.kind}-${group.orderNumber}`}
|
||||
group={group}
|
||||
canceling={canceling}
|
||||
onCancel={cancelOne}
|
||||
|
|
@ -194,6 +236,12 @@ export function DuplicateOrdersDialog({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!loading && totalGroups > 0 && effectiveVisible.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No {activeKind === "order_number" ? "shared order numbers" : "matching line-aggregates"} on the order board.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
|
|
@ -204,6 +252,38 @@ export function DuplicateOrdersDialog({
|
|||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
count,
|
||||
active,
|
||||
onClick,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={hint}
|
||||
className={`px-3 py-1.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
||||
active
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<Badge variant="secondary" className="ml-1.5 text-[10px]">
|
||||
{count}
|
||||
</Badge>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DuplicateGroupRow({
|
||||
group,
|
||||
canceling,
|
||||
|
|
@ -219,13 +299,18 @@ function DuplicateGroupRow({
|
|||
|
||||
return (
|
||||
<div className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-mono text-sm font-semibold truncate">
|
||||
{group.orderNumber}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{group.records.length} copies
|
||||
{group.records.length} {group.kind === "order_number" ? "copies" : "matching"}
|
||||
</Badge>
|
||||
{group.kind === "line_aggregate" && (
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||
heuristic
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{sorted.map((r, idx) => {
|
||||
|
|
|
|||
|
|
@ -2010,23 +2010,232 @@ describe("findDuplicateOrders", () => {
|
|||
});
|
||||
|
||||
it("returns groups sorted by size (largest first)", async () => {
|
||||
for (const [id, num] of [
|
||||
["o-s1", "SOLO"],
|
||||
["o-s2", "SOLO"],
|
||||
["o-t1", "TRIPLE"],
|
||||
["o-t2", "TRIPLE"],
|
||||
["o-t3", "TRIPLE"],
|
||||
// Use distinct quantities per order_number so the cross-number
|
||||
// line-aggregate pass doesn't accidentally fire here — this test
|
||||
// is about the order-number pass's sort order.
|
||||
for (const [id, num, qty] of [
|
||||
["o-s1", "SOLO", 1],
|
||||
["o-s2", "SOLO", 1],
|
||||
["o-t1", "TRIPLE", 2],
|
||||
["o-t2", "TRIPLE", 2],
|
||||
["o-t3", "TRIPLE", 2],
|
||||
]) {
|
||||
await pbMock.collection("orders").create({
|
||||
id, order_number: num, quote: "q",
|
||||
created: "2025-01-01T00:00:00Z",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: `oi-${id}`, order: id, description: "x", item_status: "pending",
|
||||
id: `oi-${id}`, order: id, description: "x", quantity: qty, item_status: "pending",
|
||||
});
|
||||
}
|
||||
|
||||
const groups = await findDuplicateOrders();
|
||||
expect(groups.map((g) => g.orderNumber)).toEqual(["TRIPLE", "SOLO"]);
|
||||
});
|
||||
|
||||
// ────── line-aggregate ("Same line items") pass ──────
|
||||
it("flags cross-order_number line-aggregate duplicates even when descriptions differ", async () => {
|
||||
// Two orders under different order_numbers, same line breakdown
|
||||
// (1 line, qty 5, R100). Descriptions and created-timestamps
|
||||
// differ — the kind: "line_aggregate" pass should still flag it.
|
||||
await pbMock.collection("orders").create({
|
||||
id: "o-a", order_number: "ORD-A", quote: "q",
|
||||
created: "2025-01-01T00:00:00Z",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: "oi-a", order: "o-a", description: "M16 hex bolt 50mm grade 8.8",
|
||||
quantity: 5, selling_price: 100, item_status: "pending",
|
||||
});
|
||||
await pbMock.collection("orders").create({
|
||||
id: "o-b", order_number: "ORD-B", quote: "q",
|
||||
created: "2025-01-02T00:00:00Z",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: "oi-b", order: "o-b", description: "M16 x 50 hex bolt", // different text
|
||||
quantity: 5, selling_price: 100, item_status: "pending",
|
||||
});
|
||||
|
||||
const groups = await findDuplicateOrders();
|
||||
const aggregateGroups = groups.filter((g) => g.kind === "line_aggregate");
|
||||
expect(aggregateGroups).toHaveLength(1);
|
||||
expect(aggregateGroups[0].records.map((r) => r.id).sort()).toEqual(["o-a", "o-b"]);
|
||||
expect(aggregateGroups[0].orderNumber).toMatch(/^1 line · 5 · R/);
|
||||
});
|
||||
|
||||
it("does not double-list a same-number group under line-aggregate (cross-number filter)", async () => {
|
||||
// Two records under the SAME order_number with matching content
|
||||
// — already an order-number duplicate. The line-aggregate pass
|
||||
// must NOT surface them again.
|
||||
await pbMock.collection("orders").create({
|
||||
id: "o-1", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00Z",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: "oi-1", order: "o-1", description: "x", quantity: 5, selling_price: 100,
|
||||
item_status: "pending",
|
||||
});
|
||||
await pbMock.collection("orders").create({
|
||||
id: "o-2", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00.100Z",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: "oi-2", order: "o-2", description: "x", quantity: 5, selling_price: 100,
|
||||
item_status: "invoiced",
|
||||
});
|
||||
|
||||
const groups = await findDuplicateOrders();
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].kind).toBe("order_number");
|
||||
expect(groups[0].records.map((r) => r.id)).toEqual(["o-1", "o-2"]);
|
||||
});
|
||||
|
||||
it("excludes orders with zero items from line-aggregate matching", async () => {
|
||||
// Empty orders would all match each other on (0 lines, 0 qty, R0)
|
||||
// — must not be flagged.
|
||||
await pbMock.collection("orders").create({
|
||||
id: "o-empty-1", order_number: "EMPTY-A", quote: "q",
|
||||
});
|
||||
await pbMock.collection("orders").create({
|
||||
id: "o-empty-2", order_number: "EMPTY-B", quote: "q",
|
||||
});
|
||||
|
||||
const groups = await findDuplicateOrders();
|
||||
expect(groups).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rounds sub-cent price differences so 1499.998 and 1500.002 both match 1500.00", async () => {
|
||||
await pbMock.collection("orders").create({
|
||||
id: "o-x", order_number: "X", quote: "q", created: "2025-01-01T00:00:00Z",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: "oi-x", order: "o-x", description: "x", quantity: 1,
|
||||
selling_price: 1499.998, item_status: "pending",
|
||||
});
|
||||
await pbMock.collection("orders").create({
|
||||
id: "o-y", order_number: "Y", quote: "q", created: "2025-01-02T00:00:00Z",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: "oi-y", order: "o-y", description: "x", quantity: 1,
|
||||
selling_price: 1500.002, item_status: "pending",
|
||||
});
|
||||
|
||||
const groups = await findDuplicateOrders();
|
||||
expect(groups.filter((g) => g.kind === "line_aggregate")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("matches when both sides are entirely zero-priced", async () => {
|
||||
await pbMock.collection("orders").create({
|
||||
id: "o-z1", order_number: "Z1", quote: "q", created: "2025-01-01T00:00:00Z",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: "oi-z1", order: "o-z1", description: "x", quantity: 5,
|
||||
selling_price: 0, item_status: "pending",
|
||||
});
|
||||
await pbMock.collection("orders").create({
|
||||
id: "o-z2", order_number: "Z2", quote: "q", created: "2025-01-02T00:00:00Z",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: "oi-z2", order: "o-z2", description: "x", quantity: 5,
|
||||
selling_price: 0, item_status: "pending",
|
||||
});
|
||||
|
||||
const groups = await findDuplicateOrders();
|
||||
expect(groups.filter((g) => g.kind === "line_aggregate")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not match when one side is zero-priced and the other is not", async () => {
|
||||
await pbMock.collection("orders").create({
|
||||
id: "o-p1", order_number: "P1", quote: "q", created: "2025-01-01T00:00:00Z",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: "oi-p1", order: "o-p1", description: "x", quantity: 1,
|
||||
selling_price: 0, item_status: "pending",
|
||||
});
|
||||
await pbMock.collection("orders").create({
|
||||
id: "o-p2", order_number: "P2", quote: "q", created: "2025-01-02T00:00:00Z",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: "oi-p2", order: "o-p2", description: "x", quantity: 1,
|
||||
selling_price: 50, item_status: "pending",
|
||||
});
|
||||
|
||||
const groups = await findDuplicateOrders();
|
||||
expect(groups).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not match when total quantity differs (everything else the same)", async () => {
|
||||
await pbMock.collection("orders").create({
|
||||
id: "o-q1", order_number: "Q1", quote: "q", created: "2025-01-01T00:00:00Z",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: "oi-q1", order: "o-q1", description: "x", quantity: 5,
|
||||
selling_price: 100, item_status: "pending",
|
||||
});
|
||||
await pbMock.collection("orders").create({
|
||||
id: "o-q2", order_number: "Q2", quote: "q", created: "2025-01-02T00:00:00Z",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: "oi-q2", order: "o-q2", description: "x", quantity: 6,
|
||||
selling_price: 100, item_status: "pending",
|
||||
});
|
||||
|
||||
const groups = await findDuplicateOrders();
|
||||
expect(groups).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not match when line count differs (everything else the same)", async () => {
|
||||
await pbMock.collection("orders").create({
|
||||
id: "o-c1", order_number: "C1", quote: "q", created: "2025-01-01T00:00:00Z",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: "oi-c1a", order: "o-c1", description: "x", quantity: 5,
|
||||
selling_price: 100, item_status: "pending",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: "oi-c1b", order: "o-c1", description: "y", quantity: 5,
|
||||
selling_price: 100, item_status: "pending",
|
||||
});
|
||||
await pbMock.collection("orders").create({
|
||||
id: "o-c2", order_number: "C2", quote: "q", created: "2025-01-02T00:00:00Z",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: "oi-c2", order: "o-c2", description: "x", quantity: 10,
|
||||
selling_price: 100, item_status: "pending",
|
||||
});
|
||||
|
||||
const groups = await findDuplicateOrders();
|
||||
expect(groups).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("computes totalSellingPrice as the sum of selling_price × quantity per line", async () => {
|
||||
// 2 lines: qty 2 @ R50 = R100, qty 3 @ R100 = R300 → total R400.
|
||||
// qty 5, R400. Match against another order with same aggregates.
|
||||
await pbMock.collection("orders").create({
|
||||
id: "o-m1", order_number: "M1", quote: "q", created: "2025-01-01T00:00:00Z",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: "oi-m1a", order: "o-m1", description: "a", quantity: 2, selling_price: 50,
|
||||
item_status: "pending",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: "oi-m1b", order: "o-m1", description: "b", quantity: 3, selling_price: 100,
|
||||
item_status: "pending",
|
||||
});
|
||||
await pbMock.collection("orders").create({
|
||||
id: "o-m2", order_number: "M2", quote: "q", created: "2025-01-02T00:00:00Z",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: "oi-m2a", order: "o-m2", description: "a", quantity: 2, selling_price: 50,
|
||||
item_status: "pending",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: "oi-m2b", order: "o-m2", description: "b", quantity: 3, selling_price: 100,
|
||||
item_status: "pending",
|
||||
});
|
||||
|
||||
const groups = await findDuplicateOrders();
|
||||
const aggs = groups.filter((g) => g.kind === "line_aggregate");
|
||||
expect(aggs).toHaveLength(1);
|
||||
expect(aggs[0].orderNumber).toMatch(/^2 lines · 5 · R/);
|
||||
expect(aggs[0].records[0].totalQuantity).toBe(5);
|
||||
expect(aggs[0].records[0].totalSellingPrice).toBeCloseTo(400, 2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2273,10 +2273,40 @@ export interface DuplicateOrderRecord {
|
|||
itemCount: number;
|
||||
itemStatusSummary: Record<string, number>;
|
||||
items: Array<{ description: string; quantity: number | null; item_status: string }>;
|
||||
/**
|
||||
* Sum of `quantity` across all line items on this order. Used by the
|
||||
* line-aggregate duplicate pass to flag orders where the line totals
|
||||
* (count + qty + selling-price) are identical even though descriptions
|
||||
* differ or `order_number` was re-used. Null when the order has no
|
||||
* items — such orders are excluded from line-aggregate matching.
|
||||
*/
|
||||
totalQuantity: number | null;
|
||||
/**
|
||||
* Sum of `selling_price * quantity` across all lines, rounded to 2
|
||||
* decimal places. Same use as `totalQuantity`. Null when the order
|
||||
* has no items.
|
||||
*/
|
||||
totalSellingPrice: number | null;
|
||||
}
|
||||
|
||||
export type DuplicateMatchKind = "order_number" | "line_aggregate";
|
||||
|
||||
export interface DuplicateOrderGroup {
|
||||
/**
|
||||
* What the records under this group have in common.
|
||||
* - "order_number": same `order_number` (the existing intake-race case).
|
||||
* - "line_aggregate": same `(itemCount, totalQuantity, totalSellingPrice)`
|
||||
* — flagged as a likely duplicate because the chance of two distinct
|
||||
* orders sharing an identical line breakdown is small.
|
||||
*/
|
||||
kind: DuplicateMatchKind;
|
||||
/** The shared identifier — order_number, or aggregate label. */
|
||||
orderNumber: string;
|
||||
/**
|
||||
* Human-readable aggregate label for `kind: "line_aggregate"`
|
||||
* (e.g. "5 lines · 100 · R1,500.00"). Absent for `order_number` groups.
|
||||
*/
|
||||
aggregateKey?: string;
|
||||
records: DuplicateOrderRecord[];
|
||||
}
|
||||
|
||||
|
|
@ -2339,61 +2369,159 @@ export async function findDuplicateOrders(): Promise<DuplicateOrderGroup[]> {
|
|||
|
||||
// Group by order_number. Only keep groups with ≥2 records (otherwise
|
||||
// a singleton can't be a duplicate).
|
||||
const byNum = new Map<string, typeof orders>();
|
||||
const out: DuplicateOrderGroup[] = [];
|
||||
const allRecords: DuplicateOrderRecord[] = [];
|
||||
|
||||
// Fetch items + build a record for every active order up front so
|
||||
// both the order-number pass and the line-aggregate pass can read
|
||||
// from the same flat list. Without this, records that don't share
|
||||
// an order_number (the line-aggregate-only case) would have no
|
||||
// items fetched and skip the aggregate pass.
|
||||
for (const o of orders) {
|
||||
const arr = byNum.get(o.order_number) ?? [];
|
||||
arr.push(o);
|
||||
byNum.set(o.order_number, arr);
|
||||
const items = await loadOrderItemsForRecord(pb, o.id);
|
||||
allRecords.push(buildRecord(o, items));
|
||||
}
|
||||
|
||||
const out: DuplicateOrderGroup[] = [];
|
||||
for (const [orderNumber, arr] of byNum) {
|
||||
// Order-number pass. Only groups with ≥2 records qualify as a
|
||||
// duplicate (a singleton can't be a duplicate).
|
||||
const byNum = new Map<string, DuplicateOrderRecord[]>();
|
||||
for (const r of allRecords) {
|
||||
const arr = byNum.get(r.orderNumber) ?? [];
|
||||
arr.push(r);
|
||||
byNum.set(r.orderNumber, arr);
|
||||
}
|
||||
for (const [orderNumber, records] of byNum) {
|
||||
if (records.length < 2) continue;
|
||||
sortRecordsByCreated(records);
|
||||
out.push({ kind: "order_number", orderNumber, records });
|
||||
}
|
||||
|
||||
// Line-aggregate pass. Flags orders whose line breakdown
|
||||
// (count + total qty + total selling price) matches across ≥2
|
||||
// distinct order_numbers. Descriptions may differ but the line
|
||||
// totals staying identical is rare and a strong dupe signal.
|
||||
//
|
||||
// Indexed by `"count|qty|price"` (2-dp on price; qty rounded to int).
|
||||
// Records with no items are excluded — otherwise every empty draft
|
||||
// would match every other empty draft.
|
||||
const byAggregate = new Map<string, DuplicateOrderRecord[]>();
|
||||
for (const r of allRecords) {
|
||||
if (r.itemCount === 0) continue;
|
||||
const key = `${r.itemCount}|${r.totalQuantity}|${r.totalSellingPrice}`;
|
||||
const arr = byAggregate.get(key) ?? [];
|
||||
arr.push(r);
|
||||
byAggregate.set(key, arr);
|
||||
}
|
||||
for (const [key, arr] of byAggregate) {
|
||||
if (arr.length < 2) continue;
|
||||
|
||||
const records: DuplicateOrderRecord[] = [];
|
||||
for (const o of arr) {
|
||||
const items = await pb
|
||||
.collection(getOrderItemsCollectionName())
|
||||
.getFullList<{ description: string; quantity: number | null; item_status: string }>({
|
||||
filter: `order = "${o.id}"`,
|
||||
fields: "description,quantity,item_status",
|
||||
batch: 500,
|
||||
});
|
||||
const summary: Record<string, number> = {};
|
||||
for (const it of items) {
|
||||
summary[it.item_status] = (summary[it.item_status] || 0) + 1;
|
||||
}
|
||||
records.push({
|
||||
id: o.id,
|
||||
orderNumber: o.order_number,
|
||||
created: o.created,
|
||||
customer: o.customer || null,
|
||||
company: o.company || null,
|
||||
customerName: o.expand?.customer?.customer ?? null,
|
||||
companyName: o.expand?.company?.company ?? null,
|
||||
itemCount: items.length,
|
||||
itemStatusSummary: summary,
|
||||
items: items.map((i) => ({
|
||||
description: i.description,
|
||||
quantity: i.quantity,
|
||||
item_status: i.item_status,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by created asc — Date.getTime() rather than localeCompare
|
||||
// because ISO 8601 sorts incorrectly when the fractional-second
|
||||
// part is missing (".050Z" vs "Z" — code-point bug).
|
||||
records.sort((a, b) => new Date(a.created).getTime() - new Date(b.created).getTime());
|
||||
|
||||
out.push({ orderNumber, records });
|
||||
// Skip groups where every record shares one `order_number` —
|
||||
// that's already covered by the order-number pass, and listing
|
||||
// them again here would double-flag.
|
||||
const orderNumbers = new Set(arr.map((r) => r.orderNumber));
|
||||
if (orderNumbers.size < 2) continue;
|
||||
const [countStr, qtyStr, priceStr] = key.split("|");
|
||||
const aggregateKey = `${countStr} line${countStr === "1" ? "" : "s"} · ${qtyStr} · R${formatRand(Number(priceStr))}`;
|
||||
sortRecordsByCreated(arr);
|
||||
out.push({ kind: "line_aggregate", orderNumber: aggregateKey, aggregateKey, records: arr });
|
||||
}
|
||||
|
||||
// Largest groups first — the most painful to fix manually.
|
||||
// Stable so order-number groups keep their relative order before
|
||||
// line-aggregate groups of the same size.
|
||||
out.sort((a, b) => b.records.length - a.records.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
function sortRecordsByCreated(records: DuplicateOrderRecord[]): void {
|
||||
// Sort by created asc — Date.getTime() rather than localeCompare
|
||||
// because ISO 8601 sorts incorrectly when the fractional-second
|
||||
// part is missing (".050Z" vs "Z" — code-point bug).
|
||||
records.sort(
|
||||
(a, b) => new Date(a.created).getTime() - new Date(b.created).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
/** Round to two decimal places without floating-point drift. */
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
/** Format a number as Rand — R1,500.00. Used for the aggregate label. */
|
||||
function formatRand(n: number): string {
|
||||
return n.toLocaleString("en-ZA", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
type AggregateItemShape = {
|
||||
description: string;
|
||||
quantity: number | null;
|
||||
item_status: string;
|
||||
selling_price?: number | null;
|
||||
};
|
||||
|
||||
type OrderHeaderShape = {
|
||||
id: string;
|
||||
order_number: string;
|
||||
created: string;
|
||||
customer?: string;
|
||||
company?: string;
|
||||
expand?: {
|
||||
customer?: { id: string; customer: string };
|
||||
company?: { id: string; company: string };
|
||||
};
|
||||
};
|
||||
|
||||
async function loadOrderItemsForRecord(
|
||||
pb: AdminPocketBase,
|
||||
orderId: string,
|
||||
): Promise<AggregateItemShape[]> {
|
||||
return pb
|
||||
.collection(getOrderItemsCollectionName())
|
||||
.getFullList<AggregateItemShape>({
|
||||
filter: `order = "${orderId}"`,
|
||||
fields: "description,quantity,item_status,selling_price",
|
||||
batch: 500,
|
||||
});
|
||||
}
|
||||
|
||||
function buildRecord(
|
||||
order: OrderHeaderShape,
|
||||
items: AggregateItemShape[],
|
||||
): DuplicateOrderRecord {
|
||||
const summary: Record<string, number> = {};
|
||||
let totalQty = 0;
|
||||
let totalSellingPrice = 0;
|
||||
for (const it of items) {
|
||||
summary[it.item_status] = (summary[it.item_status] || 0) + 1;
|
||||
const qty = Number(it.quantity ?? 0);
|
||||
const price = Number(it.selling_price ?? 0);
|
||||
if (Number.isFinite(qty)) totalQty += qty;
|
||||
if (Number.isFinite(price)) totalSellingPrice += price * qty;
|
||||
}
|
||||
return {
|
||||
id: order.id,
|
||||
orderNumber: order.order_number,
|
||||
created: order.created,
|
||||
customer: order.customer || null,
|
||||
company: order.company || null,
|
||||
customerName: order.expand?.customer?.customer ?? null,
|
||||
companyName: order.expand?.company?.company ?? null,
|
||||
itemCount: items.length,
|
||||
itemStatusSummary: summary,
|
||||
items: items.map((i) => ({
|
||||
description: i.description,
|
||||
quantity: i.quantity,
|
||||
item_status: i.item_status,
|
||||
})),
|
||||
// Null totals when the order has no items — those rows are excluded
|
||||
// from line-aggregate matching.
|
||||
totalQuantity: items.length === 0 ? null : Math.round(totalQty),
|
||||
totalSellingPrice: items.length === 0 ? null : round2(totalSellingPrice),
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Stats & summary helpers (Phase H — orders MCP coverage expansion)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Reference in a new issue