修复(#122): 补齐促销价档位提交
PR Check / Lint, Check, Test and Build (push) Has been cancelled
Build and Release / Build (push) Has been cancelled

This commit is contained in:
2026-05-29 02:32:01 -07:00
parent a7d89acc7f
commit d66b664198
14 changed files with 369 additions and 34 deletions
@@ -22,10 +22,13 @@
"inactiveMonthsPlaceholder": "Months without purchase",
"name": "Name",
"namePlaceholder": "Rule name",
"noDiscounts": "This subscribe has no discount quantities and cannot be configured.",
"promoPriceLessThanOriginalPrice": "Promo price must be lower than original price",
"promoPriceLessThanUnitPrice": "Promo price must be lower than unit price",
"selectEndTime": "Select time",
"selectRule": "Select rule",
"selectStartTime": "Select time",
"selectQuantity": "Select discount quantity",
"selectSubscribe": "Select subscribe",
"type": "Rule Type",
"windowHours": "Window Hours",
@@ -38,10 +41,12 @@
"name": "Name",
"no": "No",
"orderNo": "Order No.",
"originalPriceHint": "Original price",
"params": "Params",
"prices": "Prices",
"priority": "Priority",
"promoPrice": "Promo Price",
"quantity": "Quantity",
"rule": "Rule",
"ruleDetail": "Rule Detail",
"rules": "Rules",
@@ -22,10 +22,13 @@
"inactiveMonthsPlaceholder": "未购买的月数",
"name": "名称",
"namePlaceholder": "规则名称",
"noDiscounts": "该订阅没有 discount 档位,不能配置促销价。",
"promoPriceLessThanOriginalPrice": "促销价必须低于原价",
"promoPriceLessThanUnitPrice": "促销价必须低于原价",
"selectEndTime": "选择时间",
"selectRule": "选择规则",
"selectStartTime": "选择时间",
"selectQuantity": "选择 discount 档位",
"selectSubscribe": "选择订阅",
"type": "规则类型",
"windowHours": "新用户窗口小时数",
@@ -38,10 +41,12 @@
"name": "名称",
"no": "否",
"orderNo": "订单号",
"originalPriceHint": "原价",
"params": "参数",
"prices": "商品促销价",
"priority": "优先级",
"promoPrice": "促销价",
"quantity": "档位",
"rule": "规则",
"ruleDetail": "规则详情",
"rules": "促销规则",
+23 -12
View File
@@ -46,7 +46,7 @@ type RuleFilters = {
};
type PriceFilters = {
rule_id?: string;
promo_rule_id?: string;
subscribe_id?: string;
};
@@ -301,12 +301,13 @@ export default function PromoPage() {
}}
columns={[
{
accessorKey: "rule_id",
accessorKey: "promo_rule_id",
header: t("rule", "Rule"),
cell: ({ row }) =>
row.original.rule_id
? rules.find((rule) => rule.id === row.original.rule_id)
?.name || `#${row.original.rule_id}`
row.original.promo_rule_id
? rules.find(
(rule) => rule.id === row.original.promo_rule_id
)?.name || `#${row.original.promo_rule_id}`
: "--",
},
{
@@ -316,6 +317,11 @@ export default function PromoPage() {
row.original.subscribe_name ||
getSubscribeName(row.original.subscribe_id),
},
{
accessorKey: "quantity",
header: t("quantity", "Quantity"),
cell: ({ row }) => row.original.quantity || "--",
},
{
accessorKey: "unit_price",
header: t("unitPrice", "Unit Price"),
@@ -361,7 +367,7 @@ export default function PromoPage() {
}}
params={[
{
key: "rule_id",
key: "promo_rule_id",
placeholder: t("rule", "Rule"),
options: rules.map((rule) => ({
label: rule.name,
@@ -382,7 +388,7 @@ export default function PromoPage() {
request={async (pagination, filters) => {
const { data } = await getPromoPriceList({
...pagination,
rule_id: parseOptionalNumber(filters.rule_id),
promo_rule_id: parseOptionalNumber(filters.promo_rule_id),
subscribe_id: parseOptionalNumber(filters.subscribe_id),
});
return {
@@ -398,13 +404,13 @@ export default function PromoPage() {
action={usageRef}
columns={[
{
accessorKey: "rule_id",
accessorKey: "promo_rule_id",
header: t("rule", "Rule"),
cell: ({ row }) =>
row.original.rule_name ||
rules.find((rule) => rule.id === row.original.rule_id)
rules.find((rule) => rule.id === row.original.promo_rule_id)
?.name ||
`#${row.original.rule_id}`,
`#${row.original.promo_rule_id}`,
},
{
accessorKey: "user_id",
@@ -418,6 +424,11 @@ export default function PromoPage() {
row.original.subscribe_name ||
getSubscribeName(row.original.subscribe_id),
},
{
accessorKey: "quantity",
header: t("quantity", "Quantity"),
cell: ({ row }) => row.original.quantity || "--",
},
{
accessorKey: "order_no",
header: t("orderNo", "Order No."),
@@ -447,7 +458,7 @@ export default function PromoPage() {
}
params={[
{
key: "rule_id",
key: "promo_rule_id",
placeholder: t("rule", "Rule"),
options: rules.map((rule) => ({
label: rule.name,
@@ -470,7 +481,7 @@ export default function PromoPage() {
request={async (pagination, filters) => {
const { data } = await getPromoUsageList({
...pagination,
rule_id: parseOptionalNumber(filters.rule_id),
promo_rule_id: parseOptionalNumber(filters.promo_rule_id),
subscribe_id: parseOptionalNumber(filters.subscribe_id),
user_id: parseOptionalNumber(filters.user_id),
order_no: filters.order_no?.trim() || undefined,
@@ -0,0 +1,152 @@
/**
* @vitest-environment jsdom
*/
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import PriceForm from "./price-form";
globalThis.ResizeObserver = class ResizeObserver {
observe() {
return;
}
unobserve() {
return;
}
disconnect() {
return;
}
};
Element.prototype.scrollIntoView = function scrollIntoView() {
return;
};
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (_key: string, fallback: string): string => fallback,
}),
}));
const rules: API.PromoRule[] = [
{
id: 1,
name: "Campaign Rule",
type: "campaign",
params: {},
priority: 1,
enabled: true,
},
];
const subscribes: API.SubscribeItem[] = [
{
id: 8,
name: "Monthly Plan",
unit_price: 1200,
unit_time: "Month",
sold: 0,
discount: [
{
quantity: 3,
discount: 90,
},
],
},
];
const subscribesWithoutDiscounts: API.SubscribeItem[] = [
{
id: 8,
name: "Monthly Plan",
unit_price: 1200,
unit_time: "Month",
sold: 0,
discount: [],
},
];
async function selectCombobox(index: number, option: string) {
const combobox = screen.getAllByRole("combobox")[index];
if (!combobox) throw new Error(`Combobox ${index} not found`);
fireEvent.click(combobox);
fireEvent.click(await screen.findByText(option));
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("PriceForm", () => {
it("submits promo price with promo_rule_id and selected quantity item", async () => {
const onSubmit = vi.fn().mockResolvedValue(true);
render(
<PriceForm onSubmit={onSubmit} rules={rules} subscribes={subscribes} />
);
fireEvent.click(screen.getByRole("button", { name: "Create Promo Price" }));
await selectCombobox(0, "Campaign Rule");
await selectCombobox(1, "Monthly Plan");
await selectCombobox(2, "3 x Month");
fireEvent.change(screen.getByPlaceholderText("Enter price"), {
target: { value: "20" },
});
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith({
promo_rule_id: 1,
items: [{ subscribe_id: 8, quantity: 3, promo_price: 2000 }],
})
);
});
it("blocks submit when subscribe has no discount quantities", async () => {
const onSubmit = vi.fn();
render(
<PriceForm
onSubmit={onSubmit}
rules={rules}
subscribes={subscribesWithoutDiscounts}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Create Promo Price" }));
await selectCombobox(1, "Monthly Plan");
expect(
screen.getByText(
"This subscribe has no discount quantities and cannot be configured."
)
).not.toBeNull();
expect(screen.getByRole("button", { name: "Confirm" })).toHaveProperty(
"disabled",
true
);
});
it("keeps the form open and shows an error when promo price reaches original price", async () => {
const onSubmit = vi.fn();
render(
<PriceForm onSubmit={onSubmit} rules={rules} subscribes={subscribes} />
);
fireEvent.click(screen.getByRole("button", { name: "Create Promo Price" }));
await selectCombobox(0, "Campaign Rule");
await selectCombobox(1, "Monthly Plan");
await selectCombobox(2, "3 x Month");
fireEvent.change(screen.getByPlaceholderText("Enter price"), {
target: { value: "36" },
});
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
expect(
await screen.findByText("Promo price must be lower than original price")
).not.toBeNull();
expect(onSubmit).not.toHaveBeenCalled();
});
});
+86 -14
View File
@@ -23,11 +23,16 @@ import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { getSubscribeUnitPrice } from "./utils";
import {
formatCurrency,
getSubscribeDiscounts,
getSubscribeUnitPrice,
} from "./utils";
const priceSchema = z.object({
rule_id: z.number().min(1),
promo_rule_id: z.number().min(1),
subscribe_id: z.number().min(1),
quantity: z.number().min(1),
promo_price: z.number().min(1),
});
@@ -51,26 +56,42 @@ export default function PriceForm({
const form = useForm<PriceFormValues>({
resolver: zodResolver(priceSchema),
defaultValues: {
rule_id: 0,
promo_rule_id: 0,
subscribe_id: 0,
quantity: 0,
promo_price: 0,
},
});
const subscribeId = form.watch("subscribe_id");
const quantity = form.watch("quantity");
const subscribe = subscribes.find((item) => item.id === subscribeId);
const discounts = getSubscribeDiscounts(subscribe);
const unitPrice = getSubscribeUnitPrice(subscribe);
const originalPrice = unitPrice * quantity;
const hasSelectedSubscribe = subscribeId > 0;
const hasDiscounts = discounts.length > 0;
async function handleSubmit(values: PriceFormValues) {
if (unitPrice > 0 && values.promo_price >= unitPrice) {
const maxPrice = unitPrice * values.quantity;
if (maxPrice > 0 && values.promo_price >= maxPrice) {
form.setError("promo_price", {
message: t(
"form.promoPriceLessThanUnitPrice",
"Promo price must be lower than unit price"
"form.promoPriceLessThanOriginalPrice",
"Promo price must be lower than original price"
),
});
return;
}
const success = await onSubmit(values);
const success = await onSubmit({
promo_rule_id: values.promo_rule_id,
items: [
{
subscribe_id: values.subscribe_id,
quantity: values.quantity,
promo_price: values.promo_price,
},
],
});
if (success) setOpen(false);
}
@@ -79,7 +100,12 @@ export default function PriceForm({
<SheetTrigger asChild>
<Button
onClick={() => {
form.reset({ rule_id: 0, subscribe_id: 0, promo_price: 0 });
form.reset({
promo_rule_id: 0,
subscribe_id: 0,
quantity: 0,
promo_price: 0,
});
setOpen(true);
}}
size="sm"
@@ -99,7 +125,7 @@ export default function PriceForm({
>
<FormField
control={form.control}
name="rule_id"
name="promo_rule_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("rule", "Rule")}</FormLabel>
@@ -126,7 +152,14 @@ export default function PriceForm({
<FormLabel>{t("subscribe", "Subscribe")}</FormLabel>
<FormControl>
<Combobox<number>
onChange={field.onChange}
onChange={(value) => {
field.onChange(value);
form.setValue("quantity", 0, {
shouldDirty: true,
shouldValidate: true,
});
form.clearErrors(["quantity", "promo_price"]);
}}
options={subscribes
.filter((item) => typeof item.id === "number")
.map((item) => ({
@@ -144,6 +177,41 @@ export default function PriceForm({
</FormItem>
)}
/>
<FormField
control={form.control}
name="quantity"
render={({ field }) => (
<FormItem>
<FormLabel>{t("quantity", "Quantity")}</FormLabel>
<FormControl>
<Combobox<number>
onChange={field.onChange}
options={discounts.map((item) => ({
label: `${item.quantity} x ${t(
subscribe?.unit_time || "Month",
subscribe?.unit_time || "Month"
)}`,
value: item.quantity,
}))}
placeholder={t(
"form.selectQuantity",
"Select discount quantity"
)}
value={field.value}
/>
</FormControl>
{hasSelectedSubscribe && !hasDiscounts && (
<p className="text-destructive text-xs">
{t(
"form.noDiscounts",
"This subscribe has no discount quantities and cannot be configured."
)}
</p>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="promo_price"
@@ -165,10 +233,10 @@ export default function PriceForm({
value={field.value}
/>
</FormControl>
{unitPrice > 0 && (
{originalPrice > 0 && (
<p className="text-muted-foreground text-xs">
{t("unitPriceHint", "Unit price")}:{" "}
{unitConversion("centsToDollars", unitPrice)}
{t("originalPriceHint", "Original price")}:{" "}
{formatCurrency(originalPrice)}
</p>
)}
<FormMessage />
@@ -178,7 +246,11 @@ export default function PriceForm({
</form>
</Form>
<SheetFooter>
<Button disabled={loading} form="promo-price-form" type="submit">
<Button
disabled={loading || (hasSelectedSubscribe && !hasDiscounts)}
form="promo-price-form"
type="submit"
>
{t("confirm", "Confirm")}
</Button>
</SheetFooter>
+6
View File
@@ -20,3 +20,9 @@ export function parseOptionalNumber(value?: string) {
export function getSubscribeUnitPrice(subscribe?: API.SubscribeItem) {
return typeof subscribe?.unit_price === "number" ? subscribe.unit_price : 0;
}
export function getSubscribeDiscounts(subscribe?: API.SubscribeItem) {
return (subscribe?.discount || []).filter(
(item) => typeof item.quantity === "number" && item.quantity > 0
);
}