feat(intake): default product planner by category

This commit is contained in:
WuYongJie 2026-07-17 14:03:07 +08:00
parent 1c9bd11d20
commit 6b1991d953
8 changed files with 385 additions and 8 deletions

View File

@ -0,0 +1,81 @@
import type { OaBoardProjectLookupItem } from "../../core/nonIntegratedApi";
import { createEmptyNonIntegratedOpeningReference } from "../../core/nonIntegratedStorage";
import { buildOaBoardOpeningCodeImportPayload } from "./oaBoardOpeningCodeImport";
import { createFormValuesFromProduct } from "./productFormValueHelpers";
function createOaBoardItem(productPlanner: string): OaBoardProjectLookupItem {
return {
title: "茶几开款",
productName: "测试茶几",
brand: null,
productCategory: "几类",
categoryName: "茶几",
openingCode: "KK-TEA-001",
currentStageName: null,
currentApprovalStageName: null,
styleImages: [],
formSummary: {
openingCode: "KK-TEA-001",
demandType: "",
category: "几类",
categoryName: "茶几",
productPlanner,
operationBrandOwner: "",
spuOwner: "",
appearanceDesigner: "",
structureDesigner: "",
purchaseOwner: "",
merchandiserOwner: "",
competitorLink: "",
openingReason: "",
salesPriceBand: "",
platformPriceBand: "",
openingBrand: "",
},
};
}
describe("buildOaBoardOpeningCodeImportPayload", () => {
it("keeps the OA planner ahead of the category default", () => {
const result = buildOaBoardOpeningCodeImportPayload(
createOaBoardItem("OA指定企划"),
{
formValues: createFormValuesFromProduct(null),
openingReference: createEmptyNonIntegratedOpeningReference(),
},
);
expect(result.formValues.productPlanning).toBe("OA指定企划");
expect(result.openingReference.planningOwner).toBe("OA指定企划");
});
it("uses the category default only when OA and existing values are empty", () => {
const defaulted = buildOaBoardOpeningCodeImportPayload(
createOaBoardItem(""),
{
formValues: createFormValuesFromProduct(null),
openingReference: createEmptyNonIntegratedOpeningReference(),
},
);
const retainedManualValue = buildOaBoardOpeningCodeImportPayload(
createOaBoardItem(""),
{
formValues: {
...createFormValuesFromProduct(null),
productPlanning: "人工指定企划",
},
openingReference: {
...createEmptyNonIntegratedOpeningReference(),
planningOwner: "人工指定企划",
},
},
);
expect(defaulted.formValues.productPlanning).toBe("沈璃");
expect(defaulted.openingReference.planningOwner).toBe("沈璃");
expect(retainedManualValue.formValues.productPlanning).toBe("人工指定企划");
expect(retainedManualValue.openingReference.planningOwner).toBe(
"人工指定企划",
);
});
});

View File

@ -7,6 +7,7 @@ import {
import type { OaBoardProjectLookupItem } from "../../core/nonIntegratedApi";
import type { FormValues } from "./productFormValueHelpers";
import { BRAND_OPTIONS } from "./basicInfoConfig";
import { getNonIntegratedProductPlanningOwner } from "@/lib/non-integrated-category-planning";
type OaBoardImportPayload = {
formValues: FormValues;
@ -67,6 +68,9 @@ export function buildOaBoardOpeningCodeImportPayload(
item.formSummary.categoryName,
item.categoryName,
);
const importedPlanningOwner = pickFirstNonEmpty(
item.formSummary.productPlanner,
);
const nextProductName = pickFirstNonEmpty(item.productName, item.title);
const nextTargetPriceBand = pickFirstNonEmpty(
item.formSummary.salesPriceBand,
@ -100,8 +104,11 @@ export function buildOaBoardOpeningCodeImportPayload(
pickFirstNonEmpty(item.formSummary.appearanceDesigner) ||
current.openingReference.designer,
planningOwner:
pickFirstNonEmpty(item.formSummary.productPlanner) ||
current.openingReference.planningOwner,
importedPlanningOwner ||
current.openingReference.planningOwner ||
getNonIntegratedProductPlanningOwner(
nextProductCategory || current.openingReference.productCategory,
),
structureDesigner:
pickFirstNonEmpty(item.formSummary.structureDesigner) ||
current.openingReference.structureDesigner,
@ -132,8 +139,11 @@ export function buildOaBoardOpeningCodeImportPayload(
pickFirstNonEmpty(item.formSummary.appearanceDesigner) ||
current.formValues.designer,
productPlanning:
pickFirstNonEmpty(item.formSummary.productPlanner) ||
current.formValues.productPlanning,
importedPlanningOwner ||
current.formValues.productPlanning ||
getNonIntegratedProductPlanningOwner(
nextProductCategory || current.formValues.productCategory,
),
spuOwner:
pickFirstNonEmpty(item.formSummary.spuOwner) || current.formValues.spuOwner,
operator:

View File

@ -62,4 +62,53 @@ describe("useBasicInfoFieldController", () => {
expect(openingReferenceRef.current.productCategory).toBe("");
expect(scheduleAutoSave).toHaveBeenCalledTimes(1);
});
it("defaults planning owner from a category without overwriting a manual choice", () => {
let formValues = createFormValuesFromProduct(null);
let openingReference = createEmptyNonIntegratedOpeningReference();
const formValuesRef = {
current: formValues,
} as MutableRefObject<typeof formValues>;
const openingReferenceRef = {
current: openingReference,
} as MutableRefObject<typeof openingReference>;
const controller = useBasicInfoFieldController({
canUseExistingSpuCreateMode: false,
formValues,
formValuesRef,
isLockedCategoryFormField: () => false,
isManualSeriesProductSpu: false,
isReadOnly: false,
isStockProductCreate: false,
mode: "create",
openingReferenceRef,
scheduleAutoSave: jest.fn(),
setFormValuesAndRef: (updater) => {
formValues = updater(formValues);
formValuesRef.current = formValues;
},
setOpeningReferenceAndRef: (updater) => {
openingReference = updater(openingReference);
openingReferenceRef.current = openingReference;
},
setSaveMessage: jest.fn(),
setSaveMessageType: jest.fn(),
setSelectedExistingSpu: jest.fn(),
setSkuItems: jest.fn(),
setSpuCreateMode: jest.fn(),
setStockProductSpu: jest.fn(),
});
controller.updateField("productCategory", "茶几");
expect(formValuesRef.current.productPlanning).toBe("沈璃");
expect(openingReferenceRef.current.planningOwner).toBe("沈璃");
controller.updateField("productPlanning", "人工指定企划");
controller.updateField("productCategory", "书桌");
expect(formValuesRef.current.productPlanning).toBe("人工指定企划");
expect(openingReferenceRef.current.planningOwner).toBe("人工指定企划");
});
});

View File

@ -13,6 +13,7 @@ import {
type NonIntegratedSkuItem,
} from "../../nonIntegratedStorage";
import { normalizeNonIntegratedProductCategoryForSecondaryCategory } from "@/lib/non-integrated-category-options";
import { getNonIntegratedProductPlanningOwner } from "@/lib/non-integrated-category-planning";
import type {
SpuCreateMode,
} from "./existingSpuCreateOptions";
@ -184,6 +185,28 @@ export function useBasicInfoFieldController({
return;
}
if (key === "productCategory") {
const planningOwner =
formValuesRef.current.productPlanning.trim() ||
openingReferenceRef.current.planningOwner.trim() ||
getNonIntegratedProductPlanningOwner(value);
setFormValuesAndRef((current) => ({
...current,
productCategory: value,
productPlanning: current.productPlanning.trim() || planningOwner,
}));
setOpeningReferenceAndRef((current) => ({
...current,
productCategory: value,
planningOwner: current.planningOwner.trim() || planningOwner,
}));
setSaveMessage("");
setSaveMessageType("success");
scheduleAutoSave();
return;
}
setFormValuesAndRef((current) => ({ ...current, [key]: value }));
syncBasicInfoFieldToOpeningReference(key, value);
setSaveMessage("");

View File

@ -0,0 +1,54 @@
import type { MutableRefObject } from "react";
import { createEmptyNonIntegratedOpeningReference } from "../../core/nonIntegratedStorage";
import { createFormValuesFromProduct } from "../basic-info/productFormValueHelpers";
import { useOpeningReferenceController } from "./useOpeningReferenceController";
describe("useOpeningReferenceController", () => {
it("defaults planning owner from a category and retains a later manual change", () => {
let formValues = createFormValuesFromProduct(null);
let openingReference = createEmptyNonIntegratedOpeningReference();
const formValuesRef = {
current: formValues,
} as MutableRefObject<typeof formValues>;
const openingReferenceRef = {
current: openingReference,
} as MutableRefObject<typeof openingReference>;
const controller = useOpeningReferenceController({
createSingleUploadProgressHandler: jest.fn(),
finishUploadProgress: jest.fn(),
formValues,
isLockedCategoryOpeningReferenceField: () => false,
isReadOnly: false,
isStockProductCreate: false,
openingReferenceRef,
scheduleAutoSave: jest.fn(),
setFormValuesAndRef: (updater) => {
formValues = updater(formValues);
formValuesRef.current = formValues;
},
setOpeningReferenceAndRef: (updater) => {
openingReference = updater(openingReference);
openingReferenceRef.current = openingReference;
},
setSaveMessage: jest.fn(),
setSaveMessageType: jest.fn(),
setSkuItems: jest.fn(),
setStockProductSpu: jest.fn(),
setUploadProgress: jest.fn(),
startUploadProgress: jest.fn(),
});
controller.handleOpeningReferenceFieldChange("productCategory", "茶几");
expect(formValuesRef.current.productPlanning).toBe("沈璃");
expect(openingReferenceRef.current.planningOwner).toBe("沈璃");
controller.handleOpeningReferenceFieldChange("planningOwner", "人工指定企划");
controller.handleOpeningReferenceFieldChange("productCategory", "书桌");
expect(formValuesRef.current.productPlanning).toBe("人工指定企划");
expect(openingReferenceRef.current.planningOwner).toBe("人工指定企划");
});
});

View File

@ -14,6 +14,7 @@ import {
type NonIntegratedSkuItem,
} from "../../nonIntegratedStorage";
import { normalizeNonIntegratedProductCategoryForSecondaryCategory } from "@/lib/non-integrated-category-options";
import { getNonIntegratedProductPlanningOwner } from "@/lib/non-integrated-category-planning";
import type { NonIntegratedUploadProgressHandler } from "../../nonIntegratedApi";
import { BRAND_OPTIONS } from "../basic-info/basicInfoConfig";
import type { FormValues } from "../basic-info/productFormValueHelpers";
@ -82,6 +83,7 @@ export function useOpeningReferenceController({
function syncOpeningReferenceFieldToBasicInfo(
field: OpeningReferenceFieldKey,
value: string,
planningOwnerFallback = "",
) {
if (field === "salesCategory") {
setFormValuesAndRef((current) => ({
@ -95,6 +97,15 @@ export function useOpeningReferenceController({
return;
}
if (field === "productCategory") {
setFormValuesAndRef((current) => ({
...current,
productCategory: value,
productPlanning: current.productPlanning.trim() || planningOwnerFallback,
}));
return;
}
const mappedField = getOpeningReferenceBasicInfoFieldKey(field);
if (field === "brand") {
@ -142,15 +153,33 @@ export function useOpeningReferenceController({
return;
}
setOpeningReferenceAndRef((current) =>
updateOpeningReferenceFieldValue({
const planningOwnerFallback =
field === "productCategory"
? formValues.productPlanning.trim() ||
openingReferenceRef.current.planningOwner.trim() ||
getNonIntegratedProductPlanningOwner(value)
: "";
setOpeningReferenceAndRef((current) => {
const nextReference = updateOpeningReferenceFieldValue({
brandOptions: BRAND_OPTIONS,
current,
field,
value,
}),
});
return field === "productCategory" && !nextReference.planningOwner.trim()
? {
...nextReference,
planningOwner: planningOwnerFallback,
}
: nextReference;
});
syncOpeningReferenceFieldToBasicInfo(
field,
value,
planningOwnerFallback,
);
syncOpeningReferenceFieldToBasicInfo(field, value);
setSaveMessage("");
setSaveMessageType("success");
scheduleAutoSave();

View File

@ -0,0 +1,20 @@
import {
getNonIntegratedProductPlanningOwner,
NON_INTEGRATED_PRODUCT_CATEGORY_PLANNING_OWNER,
} from "./non-integrated-category-planning";
describe("non-integrated category planning owners", () => {
it("returns the configured owner for an exact category after trimming", () => {
expect(getNonIntegratedProductPlanningOwner(" 茶几 ")).toBe("沈璃");
expect(getNonIntegratedProductPlanningOwner("书桌")).toBe("灵犀");
});
it("keeps the imported category mapping available without guessing unmatched categories", () => {
expect(
NON_INTEGRATED_PRODUCT_CATEGORY_PLANNING_OWNER[
"餐桌(新) > 不锈钢/PVC餐桌"
],
).toBe("敬亭");
expect(getNonIntegratedProductPlanningOwner("未配置类目")).toBe("");
});
});

View File

@ -0,0 +1,111 @@
export const NON_INTEGRATED_PRODUCT_CATEGORY_PLANNING_OWNER: Readonly<
Record<string, string>
> = {
"茶几": "沈璃",
"餐桌(新) > 岩板餐桌": "灵犀",
"餐桌(新) > 实木餐桌": "灵犀",
"衣柜(新) > 独立衣柜": "妙玉",
"餐边柜": "昭君",
"梳妆台/桌": "黄庭坚",
"电视柜": "妙玉",
"电脑桌": "灵犀",
"椅类 > 餐椅": "黄庭坚",
"斗柜": "妙玉",
"书架": "敬亭",
"床头柜": "昭君",
"书桌": "灵犀",
"鞋柜": "妙玉",
"椅类 > 沙发椅": "敬亭",
"衣帽架": "涂涂",
"书柜 > 单个书柜": "昭君",
"家用茶台/茶桌": "灵犀",
"角几/边几": "沈璃",
"搁板/置物架/家用陈列架": "涂涂",
"穿衣镜": "沈璃",
"餐桌(新) > 大理石餐桌": "灵犀",
"餐桌(新) > 岛台餐桌": "灵犀",
"折叠桌": "敬亭",
"懒人沙发": "妙玉",
"其它凳子": "敬亭",
"电动升降桌": "黄庭坚",
"鞋架": "黄庭坚",
"变形柜类 > 组合模块柜/书柜": "昭君",
"矮凳": "敬亭",
"椅类 > 折叠椅/躺椅": "敬亭",
"椅类 > 摇椅": "黄庭坚",
"门厅/玄关柜/玄关架": "昭君",
"壁炉架/壁炉柜": "昭君",
"变形柜类 > 自由组合衣柜": "妙玉",
"换鞋凳": "敬亭",
"多宝格/博古架": "涂涂",
"其它柜类": "昭君",
"花架/花几": "涂涂",
"凳类 > 梳妆凳": "黄庭坚",
"酒柜": "妙玉",
"变形几类 > 移动边几/推车": "沈璃",
"餐桌(新) > 板木餐桌": "灵犀",
"凳类 > 床尾凳/长凳": "敬亭",
"角柜": "妙玉",
"坐具 > 公园椅": "灵犀",
"供桌": "灵犀",
"家用陈列柜": "妙玉",
"椅类 > 其它椅子": "敬亭",
"飘窗柜": "妙玉",
"化妆镜": "沈璃",
"阳台柜": "妙玉",
"吊柜/壁柜": "昭君",
"沙发凳/脚踏": "敬亭",
"餐桌(新) > 玻璃餐桌": "灵犀",
"餐桌(新) > 不锈钢/PVC餐桌": "敬亭",
"炕几/飘窗几": "沈璃",
"简易衣柜": "妙玉",
"变形几类 > 升降茶几": "沈璃",
"变形柜类 > 可移动边柜": "昭君",
"组合衣架": "涂涂",
"药柜": "妙玉",
"客厅成套家具 > 茶几+电视柜": "沈璃",
"转角桌": "灵犀",
"变形桌类 > 床边桌": "灵犀",
"旋转书架": "敬亭",
"书报架": "涂涂",
"连体书桌柜": "昭君",
"格架": "涂涂",
"桌子配件 > 桌腿/桌架": "黄庭坚",
"智能床头柜": "昭君",
"家政柜": "妙玉",
"桌 > 阳台桌": "黄庭坚",
"衣挂一体镜": "沈璃",
"椅类 > 普通餐椅": "黄庭坚",
"其它几类": "沈璃",
"落地首饰柜": "妙玉",
"窄缝架": "涂涂",
"折叠/组合鞋架": "黄庭坚",
"卧室成套家具 > 双人床+床头柜": "昭君",
"装饰镜": "沈璃",
"棋桌": "灵犀",
"桌 > 户外烧烤桌": "灵犀",
"桌 > 公园桌": "灵犀",
"桌 > 沙滩桌": "灵犀",
"书房成套家具 > 书桌椅+书柜": "昭君",
"CD架": "涂涂",
"餐厅成套家具 > 餐桌椅+餐边柜": "昭君",
"变形桌类 > 可伸缩/折叠餐桌": "敬亭",
"门厅成套家具 > 隔断+ 鞋柜": "妙玉",
"套几": "沈璃",
"扫地机器人柜": "昭君",
"伸缩衣/裤架": "涂涂",
"变形休闲家具 > 壁挂折叠桌": "敬亭",
"餐厅成套家具 > 餐边柜+酒架": "昭君",
"餐厅成套家具 > 餐桌椅+餐边柜+酒柜+餐车": "妙玉",
"卧室成套家具 > 单人床+床头柜+梳妆台+衣柜": "昭君",
"卧室成套家具 > 双人床+床头柜+梳妆台": "昭君",
"门厅成套家具 > 门厅柜+鞋柜": "妙玉",
"适老餐桌": "灵犀",
"悬挂/壁挂组合陈列架": "涂涂",
};
export function getNonIntegratedProductPlanningOwner(productCategory: string) {
return NON_INTEGRATED_PRODUCT_CATEGORY_PLANNING_OWNER[
productCategory.trim()
] ?? "";
}