feat(catalog): add reviewed bulk edits and complete category duplication
This commit is contained in:
1 parent
0bedc04692
commit
55a47949f1
26 files changed
+2504
-85
No files matched your search
@@ -0,0 +1,51 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { bulkOfferInputSchema, projectBulkOffer } from "./bulk-offers";
|
||||
|
||||
const row = { pageId: 4, costCredits: 3, costPoints: 10, pointsType: 5 };
|
||||
it("combines selected changes, rounds percentages, preserves explicit zero", () => {
|
||||
expect(
|
||||
projectBulkOffer(row, {
|
||||
costCredits: { mode: "set", value: 0 },
|
||||
costPoints: { mode: "percent", value: 25 },
|
||||
pageId: 9,
|
||||
}),
|
||||
).toEqual({ ...row, costCredits: 0, costPoints: 13, pageId: 9 });
|
||||
});
|
||||
it("rejects negative results and integer overflow instead of silently clamping", () => {
|
||||
expect(() =>
|
||||
projectBulkOffer(row, { costCredits: { mode: "add", value: -4 } }),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
projectBulkOffer(row, { costCredits: { mode: "set", value: 2147483648 } }),
|
||||
).toThrow();
|
||||
});
|
||||
it("leaves legacy signed fields untouched when changing a different field", () => {
|
||||
expect(
|
||||
projectBulkOffer({ ...row, costCredits: -1 }, { pageId: 5 }).costCredits,
|
||||
).toBe(-1);
|
||||
});
|
||||
it("rejects empty, duplicate, oversized selections and unknown mutations", () => {
|
||||
for (const input of [
|
||||
{ ids: [], changes: { pageId: 1 } },
|
||||
{ ids: [1, 1], changes: { pageId: 1 } },
|
||||
{
|
||||
ids: Array.from({ length: 501 }, (_, i) => i + 1),
|
||||
changes: { pageId: 1 },
|
||||
},
|
||||
{ ids: [1], changes: {} },
|
||||
{ ids: [1], changes: { itemIds: "2" } },
|
||||
])
|
||||
expect(() => bulkOfferInputSchema.parse(input)).toThrow();
|
||||
});
|
||||
it("rejects fractional set/add prices, accepts percentage discounts", () => {
|
||||
expect(() =>
|
||||
bulkOfferInputSchema.parse({
|
||||
ids: [1],
|
||||
changes: { costCredits: { mode: "set", value: 1.5 } },
|
||||
}),
|
||||
).toThrow();
|
||||
expect(
|
||||
projectBulkOffer(row, { costPoints: { mode: "percent", value: -50 } })
|
||||
.costPoints,
|
||||
).toBe(5);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { z } from "zod";
|
||||
import { CatalogInputError } from "./hierarchy";
|
||||
import { offerIdSchema, offerInteger } from "./offer-input";
|
||||
|
||||
const priceChange = z
|
||||
.object({
|
||||
mode: z.enum(["set", "add", "percent"]),
|
||||
value: z.number().finite().min(-2147483648).max(2147483647),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(v) => v.mode === "percent" || Number.isInteger(v.value),
|
||||
"Set and add values must be whole numbers",
|
||||
);
|
||||
export const bulkOfferChangesSchema = z
|
||||
.object({
|
||||
costCredits: priceChange.optional(),
|
||||
costPoints: priceChange.optional(),
|
||||
pointsType: offerInteger.nonnegative().optional(),
|
||||
pageId: offerIdSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(v) => Object.values(v).some((x) => x !== undefined),
|
||||
"Choose at least one field to change",
|
||||
);
|
||||
export const bulkOfferInputSchema = z
|
||||
.object({
|
||||
ids: z
|
||||
.array(offerIdSchema)
|
||||
.min(1)
|
||||
.max(500)
|
||||
.refine(
|
||||
(v) => new Set(v).size === v.length,
|
||||
"Selection contains duplicate offers",
|
||||
),
|
||||
changes: bulkOfferChangesSchema,
|
||||
})
|
||||
.strict();
|
||||
export type BulkOfferInput = z.infer<typeof bulkOfferInputSchema>;
|
||||
export type BulkOfferValues = {
|
||||
pageId: number;
|
||||
costCredits: number;
|
||||
costPoints: number;
|
||||
pointsType: number;
|
||||
};
|
||||
export type BulkOfferPreview = {
|
||||
fingerprint: string;
|
||||
changedCount: number;
|
||||
pages: Array<{ id: number; caption: string }>;
|
||||
rows: Array<{
|
||||
id: number;
|
||||
catalogName: string;
|
||||
before: BulkOfferValues;
|
||||
after: BulkOfferValues;
|
||||
}>;
|
||||
};
|
||||
export function projectBulkOffer(
|
||||
row: BulkOfferValues,
|
||||
changes: BulkOfferInput["changes"],
|
||||
): BulkOfferValues {
|
||||
const after = { ...row };
|
||||
for (const key of ["costCredits", "costPoints"] as const) {
|
||||
const change = changes[key];
|
||||
if (!change) continue;
|
||||
const value =
|
||||
change.mode === "set"
|
||||
? change.value
|
||||
: change.mode === "add"
|
||||
? row[key] + change.value
|
||||
: Math.round(row[key] * (1 + change.value / 100));
|
||||
if (!Number.isSafeInteger(value) || value < 0 || value > 2147483647)
|
||||
throw new CatalogInputError(
|
||||
"A resulting price is negative or exceeds the supported range",
|
||||
);
|
||||
after[key] = value;
|
||||
}
|
||||
if (changes.pointsType !== undefined) after.pointsType = changes.pointsType;
|
||||
if (changes.pageId !== undefined) after.pageId = changes.pageId;
|
||||
return after;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { duplicateSubtree, remapIncludes } from "./duplicate";
|
||||
|
||||
describe("category duplication", () => {
|
||||
const pages = [
|
||||
{ id: 1, parent_id: -1 },
|
||||
{ id: 2, parent_id: 1 },
|
||||
{ id: 3, parent_id: 2 },
|
||||
{ id: 4, parent_id: -1 },
|
||||
];
|
||||
it("collects all descendants in parent-first order", () =>
|
||||
expect(duplicateSubtree(pages, 1, 4).map((p) => p.id)).toEqual([1, 2, 3]));
|
||||
it("rejects corrupt source cycles and missing destination", () => {
|
||||
expect(() =>
|
||||
duplicateSubtree(
|
||||
[
|
||||
{ id: 1, parent_id: 2 },
|
||||
{ id: 2, parent_id: 1 },
|
||||
],
|
||||
1,
|
||||
-1,
|
||||
),
|
||||
).toThrow();
|
||||
expect(() => duplicateSubtree(pages, 1, 99)).toThrow();
|
||||
});
|
||||
it("rejects missing source ancestry", () =>
|
||||
expect(() =>
|
||||
duplicateSubtree([{ id: 1, parent_id: 99 }], 1, -1),
|
||||
).toThrow());
|
||||
it("remaps internal includes and preserves external references", () =>
|
||||
expect(remapIncludes("2;4", new Map([[2, 20]]), new Set([2, 4]))).toBe(
|
||||
"20;4",
|
||||
));
|
||||
it("rejects broken included references", () =>
|
||||
expect(() => remapIncludes("99", new Map(), new Set([2]))).toThrow());
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
assertParent,
|
||||
CatalogInputError,
|
||||
CatalogNotFound,
|
||||
collectSubtree,
|
||||
positiveId,
|
||||
} from "./hierarchy";
|
||||
export type DuplicateRow = Record<string, unknown> & {
|
||||
id: number;
|
||||
parent_id?: number;
|
||||
};
|
||||
export type DuplicateInput = {
|
||||
kind: "normal" | "bc";
|
||||
sourceId: number;
|
||||
parentId: number;
|
||||
name: string;
|
||||
};
|
||||
export type DuplicatePreview = DuplicateInput & {
|
||||
fingerprint: string;
|
||||
pages: number;
|
||||
offers: number;
|
||||
destination: string;
|
||||
};
|
||||
export function duplicateSubtree<T extends DuplicateRow>(
|
||||
pages: T[],
|
||||
sourceId: number,
|
||||
parentId: number,
|
||||
): T[] {
|
||||
positiveId(sourceId);
|
||||
const source = pages.find((p) => Number(p.id) === sourceId);
|
||||
if (!source) throw new CatalogNotFound("Catalog category not found");
|
||||
const hierarchy = pages.map((p) => ({
|
||||
id: Number(p.id),
|
||||
parentId: Number(p.parent_id),
|
||||
}));
|
||||
assertParent(hierarchy, sourceId, Number(source.parent_id));
|
||||
assertParent(hierarchy, 0, parentId);
|
||||
const ids = collectSubtree(hierarchy, sourceId);
|
||||
if (ids.length > 500)
|
||||
throw new CatalogInputError("Duplicate at most 500 categories at a time");
|
||||
return ids.map((id) => pages.find((p) => Number(p.id) === id) as T);
|
||||
}
|
||||
export function remapIncludes(
|
||||
value: unknown,
|
||||
ids: Map<number, number>,
|
||||
existing: Set<number>,
|
||||
): string {
|
||||
const text = String(value ?? "");
|
||||
if (!text.trim()) return text;
|
||||
return text
|
||||
.split(";")
|
||||
.map((part) => {
|
||||
const id = Number(part.trim());
|
||||
if (!/^\d+$/.test(part.trim()) || !existing.has(id))
|
||||
throw new CatalogInputError("Included category reference not found");
|
||||
return String(ids.get(id) ?? id);
|
||||
})
|
||||
.join(";");
|
||||
}
|
||||
Reference in new issue
Block a user