feat: refine non-integrated intake workflow

This commit is contained in:
WuYongJie 2026-05-20 14:17:39 +08:00
parent b7c794ac2f
commit c5a6f044c7
10 changed files with 626 additions and 208 deletions

View File

@ -1,29 +1,17 @@
import { NextRequest } from "next/server";
import { handleRequest, jsonSuccess } from "@/server/core/response";
import { userService } from "@/server/services/user.service";
function getUniqueNames(values: Array<string | null | undefined>) {
return Array.from(
new Set(
values
.map((value) => value?.trim() ?? "")
.filter((value) => value.length > 0),
),
);
}
import { listIntakeOwnerOptionNamesByDepartmentScope } from "@/server/services/intake-owner-options.service";
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const designers = await userService.listAll({
businessRole: "DESIGN",
status: "ACTIVE",
});
const names =
await listIntakeOwnerOptionNamesByDepartmentScope("productResearch");
return jsonSuccess(
{
names: getUniqueNames(designers.map((designer) => designer.name)),
names,
},
200,
ctx.requestId,

View File

@ -1,12 +1,13 @@
import { NextRequest } from "next/server";
import { handleRequest, jsonSuccess } from "@/server/core/response";
import { listIntakeOwnerOptionNamesByCenter } from "@/server/services/intake-owner-options.service";
import { listIntakeOwnerOptionNamesByDepartmentScope } from "@/server/services/intake-owner-options.service";
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const names = await listIntakeOwnerOptionNamesByCenter("operation");
const names =
await listIntakeOwnerOptionNamesByDepartmentScope("operation");
return jsonSuccess(
{

View File

@ -1,12 +1,13 @@
import { NextRequest } from "next/server";
import { handleRequest, jsonSuccess } from "@/server/core/response";
import { listIntakeOwnerOptionNamesByCenter } from "@/server/services/intake-owner-options.service";
import { listIntakeOwnerOptionNamesByDepartmentScope } from "@/server/services/intake-owner-options.service";
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const names = await listIntakeOwnerOptionNamesByCenter("brand");
const names =
await listIntakeOwnerOptionNamesByDepartmentScope("brandPlanning");
return jsonSuccess(
{

View File

@ -1,12 +1,13 @@
import { NextRequest } from "next/server";
import { handleRequest, jsonSuccess } from "@/server/core/response";
import { listIntakeOwnerOptionNamesByCenter } from "@/server/services/intake-owner-options.service";
import { listIntakeOwnerOptionNamesByDepartmentScope } from "@/server/services/intake-owner-options.service";
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const names = await listIntakeOwnerOptionNamesByCenter("purchasing");
const names =
await listIntakeOwnerOptionNamesByDepartmentScope("purchasing");
return jsonSuccess(
{

View File

@ -0,0 +1,22 @@
import { NextRequest } from "next/server";
import { handleRequest, jsonSuccess } from "@/server/core/response";
import { listIntakeOwnerOptionNamesByDepartmentScope } from "@/server/services/intake-owner-options.service";
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const names =
await listIntakeOwnerOptionNamesByDepartmentScope("spaceVisual");
return jsonSuccess(
{
names,
},
200,
ctx.requestId,
);
},
{ requireAuth: true },
);
}

View File

@ -1906,7 +1906,6 @@ function getPopulatedSampleMeasurementRowCountForList(
row.widthCm,
row.heightCm,
row.weightKg,
row.productShape,
].some((value) => value.trim().length > 0),
).length;
}

View File

@ -1935,6 +1935,8 @@ describe("NonIntegratedDetailClient", () => {
data: {
names: url.includes("planning-options")
? ["企划A", "企划B"]
: url.includes("space-options")
? ["空间A", "空间B"]
: url.includes("operation-options")
? ["运营A", "运营B"]
: url.includes("purchasing-options")
@ -1977,6 +1979,7 @@ describe("NonIntegratedDetailClient", () => {
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/intake/designer-options");
expect(fetchMock).toHaveBeenCalledWith("/api/intake/planning-options");
expect(fetchMock).toHaveBeenCalledWith("/api/intake/space-options");
expect(fetchMock).toHaveBeenCalledWith("/api/intake/operation-options");
expect(fetchMock).toHaveBeenCalledWith(
"/api/intake/purchasing-options",
@ -1992,6 +1995,7 @@ describe("NonIntegratedDetailClient", () => {
expect(designerOptions).toHaveTextContent("陆南亭");
expect(designerOptions).toHaveTextContent("结构A");
expect(designerOptions).toHaveTextContent("徐霞客");
expect(designerOptions).not.toHaveTextContent("空间A");
});
await actor.click(structureDesignerInput);
@ -2021,6 +2025,17 @@ describe("NonIntegratedDetailClient", () => {
expect(savedProduct?.openingReference.designer).toBe("手动设计师");
expect(savedProduct?.openingReference.planningOwner).toBe("手动企划");
});
await actor.click(screen.getByTestId("document-workspace-basic-info-trigger"));
const spaceDesignerInput = screen.getByLabelText("空间设计师");
await actor.click(spaceDesignerInput);
await waitFor(() => {
const spaceDesignerOptions = screen.getByRole("listbox");
expect(spaceDesignerOptions).toHaveTextContent("空间A");
expect(spaceDesignerOptions).toHaveTextContent("空间B");
expect(spaceDesignerOptions).not.toHaveTextContent("陆南亭");
expect(spaceDesignerOptions).not.toHaveTextContent("企划A");
});
} finally {
Object.defineProperty(global, "fetch", {
configurable: true,
@ -2644,6 +2659,38 @@ describe("NonIntegratedDetailClient", () => {
target: { value: "2026-06-20" },
});
const productionTypeSelects = within(factoryWorkspace).getAllByRole(
"combobox",
{ name: "产品生产类型" },
);
expect(productionTypeSelects).toHaveLength(3);
await selectInlineOption(actor, productionTypeSelects[1]!, "ODM-Q");
const installationServiceSelects = within(factoryWorkspace).getAllByRole(
"combobox",
{ name: "安装服务" },
);
expect(installationServiceSelects).toHaveLength(3);
await selectInlineOption(actor, installationServiceSelects[2]!, "安装");
const supplierCategorySelects = within(factoryWorkspace).getAllByRole(
"combobox",
{ name: "供应商分类" },
);
expect(supplierCategorySelects).toHaveLength(3);
await selectInlineOption(actor, supplierCategorySelects[0]!, "辅力供应商");
const purchaseOrderMethodSelects = within(factoryWorkspace).getAllByRole(
"combobox",
{ name: "采购下单方式" },
);
expect(purchaseOrderMethodSelects).toHaveLength(3);
await selectInlineOption(
actor,
purchaseOrderMethodSelects[1]!,
"特殊订单式",
);
await waitFor(() => {
const savedProduct = getNonIntegratedProductById(
"ZWJ0218-factory-editable-fields",
@ -2677,6 +2724,22 @@ describe("NonIntegratedDetailClient", () => {
"2026年06月20日",
"2026年06月20日",
]);
expect(savedProduct?.factoryRows.map((row) => row.productionType)).toEqual([
"ODM-Q",
"ODM-Q",
"ODM-Q",
]);
expect(
savedProduct?.factoryRows.map((row) => row.installationService),
).toEqual(["安装", "安装", "安装"]);
expect(savedProduct?.factoryRows.map((row) => row.supplierCategory)).toEqual([
"辅力供应商",
"辅力供应商",
"辅力供应商",
]);
expect(
savedProduct?.factoryRows.map((row) => row.purchaseOrderMethod),
).toEqual(["特殊订单式", "特殊订单式", "特殊订单式"]);
});
const installMethodSelect = within(factoryWorkspace).getAllByRole("combobox", {
@ -2708,6 +2771,226 @@ describe("NonIntegratedDetailClient", () => {
});
});
it("shares product factory selection fields by model without changing SKU content", async () => {
const actor = userEvent.setup();
saveNonIntegratedProduct(
createProductRecord({
id: "SZ0230-factory-model-shared-fields",
spu: "SZ0230",
skuBatchConfirmedAt: CONFIRMED_SKU_BATCH_AT,
skuItems: [
createSkuItem({
id: "sku-factory-model-shared-1",
skuSequence: 1,
model: "SZ0230K01",
manufacturer: "曹县嘉欢",
manufacturerCode: "N396",
installMethod: "整装",
}),
createSkuItem({
id: "sku-factory-model-shared-2",
skuSequence: 2,
model: "SZ0230K01",
manufacturer: "曹县嘉欢",
manufacturerCode: "N396",
installMethod: "整装",
}),
createSkuItem({
id: "sku-factory-model-shared-3",
skuSequence: 3,
model: "SZ0230K02",
manufacturer: "曹县嘉欢",
manufacturerCode: "N396",
installMethod: "整装",
}),
],
}),
);
render(
<NonIntegratedDetailClient
mode="edit"
productId="SZ0230-factory-model-shared-fields"
/>,
);
await actor.click(screen.getByTestId("document-workspace-trigger-产品定厂"));
const factoryWorkspace = screen.getByTestId("product-factory-workspace");
const warehouseInputs = within(factoryWorkspace).getAllByLabelText("仓库");
expect(warehouseInputs).toHaveLength(3);
fireEvent.change(warehouseInputs[0]!, { target: { value: "郑州仓" } });
const minimumOrderQuantityInputs =
within(factoryWorkspace).getAllByPlaceholderText("请输入最低起订量");
expect(minimumOrderQuantityInputs).toHaveLength(3);
fireEvent.change(minimumOrderQuantityInputs[0]!, {
target: { value: "5" },
});
await waitFor(() => {
const savedProduct = getNonIntegratedProductById(
"SZ0230-factory-model-shared-fields",
);
expect(
savedProduct?.factoryRows.map((row) => row.minimumOrderQuantity),
).toEqual(["5", "5", ""]);
});
fireEvent.change(minimumOrderQuantityInputs[0]!, {
target: { value: "50" },
});
await waitFor(() => {
const savedProduct = getNonIntegratedProductById(
"SZ0230-factory-model-shared-fields",
);
expect(
savedProduct?.factoryRows.map((row) => row.minimumOrderQuantity),
).toEqual(["50", "50", ""]);
});
fireEvent.change(minimumOrderQuantityInputs[1]!, {
target: { value: "80" },
});
await selectInlineOption(
actor,
within(factoryWorkspace).getAllByRole("combobox", {
name: "产品生产类型",
})[0]!,
"OEM",
);
await selectInlineOption(
actor,
within(factoryWorkspace).getAllByRole("combobox", {
name: "安装服务",
})[1]!,
"上楼",
);
await selectInlineOption(
actor,
within(factoryWorkspace).getAllByRole("combobox", {
name: "供应商分类",
})[0]!,
"主力供应商",
);
await selectInlineOption(
actor,
within(factoryWorkspace).getAllByRole("combobox", {
name: "采购下单方式",
})[1]!,
"订单式",
);
await waitFor(() => {
const savedProduct = getNonIntegratedProductById(
"SZ0230-factory-model-shared-fields",
);
expect(savedProduct?.factoryRows.map((row) => row.warehouse)).toEqual([
"郑州仓",
"郑州仓",
"",
]);
expect(
savedProduct?.factoryRows.map((row) => row.minimumOrderQuantity),
).toEqual(["50", "80", ""]);
expect(savedProduct?.factoryRows.map((row) => row.productionType)).toEqual([
"OEM",
"OEM",
"",
]);
expect(
savedProduct?.factoryRows.map((row) => row.installationService),
).toEqual(["上楼", "上楼", ""]);
expect(savedProduct?.factoryRows.map((row) => row.supplierCategory)).toEqual([
"主力供应商",
"主力供应商",
"",
]);
expect(
savedProduct?.factoryRows.map((row) => row.purchaseOrderMethod),
).toEqual(["订单式", "订单式", ""]);
expect(savedProduct?.skuItems.map((item) => item.manufacturer)).toEqual([
"曹县嘉欢",
"曹县嘉欢",
"曹县嘉欢",
]);
expect(savedProduct?.skuItems.map((item) => item.manufacturerCode)).toEqual([
"N396",
"N396",
"N396",
]);
expect(savedProduct?.skuItems.map((item) => item.installMethod)).toEqual([
"整装",
"整装",
"整装",
]);
});
fireEvent.change(warehouseInputs[1]!, { target: { value: "杭州仓" } });
await selectInlineOption(
actor,
within(factoryWorkspace).getAllByRole("combobox", {
name: "产品生产类型",
})[1]!,
"ODM",
);
await waitFor(() => {
const savedProduct = getNonIntegratedProductById(
"SZ0230-factory-model-shared-fields",
);
expect(savedProduct?.factoryRows.map((row) => row.warehouse)).toEqual([
"郑州仓",
"杭州仓",
"",
]);
expect(savedProduct?.factoryRows.map((row) => row.productionType)).toEqual([
"OEM",
"ODM",
"",
]);
expect(savedProduct?.skuItems.map((item) => item.manufacturer)).toEqual([
"曹县嘉欢",
"曹县嘉欢",
"曹县嘉欢",
]);
expect(savedProduct?.skuItems.map((item) => item.installMethod)).toEqual([
"整装",
"整装",
"整装",
]);
});
fireEvent.change(warehouseInputs[1]!, { target: { value: "" } });
await selectInlineOption(
actor,
within(factoryWorkspace).getAllByRole("combobox", {
name: "产品生产类型",
})[0]!,
"",
);
await waitFor(() => {
const savedProduct = getNonIntegratedProductById(
"SZ0230-factory-model-shared-fields",
);
expect(savedProduct?.factoryRows.map((row) => row.warehouse)).toEqual([
"郑州仓",
"",
"",
]);
expect(savedProduct?.factoryRows.map((row) => row.productionType)).toEqual([
"",
"ODM",
"",
]);
});
});
it("persists the SKU national standard compliance selection", async () => {
const actor = userEvent.setup();
@ -3142,6 +3425,7 @@ describe("NonIntegratedDetailClient", () => {
expect(panel).toHaveTextContent("条码规格名称");
expect(panel).toHaveTextContent("长(CM)");
expect(panel).toHaveTextContent("重量(KG)");
expect(panel).not.toHaveTextContent("商品外形");
expect(screen.getByText("SZ0230K01")).toBeInTheDocument();
expect(screen.getByText("SZ023001")).toBeInTheDocument();
expect(screen.getByText("SZ023001N1280V01")).toBeInTheDocument();
@ -3177,10 +3461,6 @@ describe("NonIntegratedDetailClient", () => {
screen.getByTestId("sample-measurement-weightKg-sku-sample-1"),
"18.5",
);
await actor.type(
screen.getByTestId("sample-measurement-productShape-sku-sample-1"),
"长方形",
);
await waitFor(() => {
const savedProduct = getNonIntegratedProductById(
@ -3193,7 +3473,6 @@ describe("NonIntegratedDetailClient", () => {
widthCm: "60",
heightCm: "75",
weightKg: "18.5",
productShape: "长方形",
},
]);
});

View File

@ -286,6 +286,10 @@ type ProductFactoryEditableKey = Exclude<
keyof NonIntegratedFactoryRow,
"skuId" | "confirmed"
>;
type ProductFactoryModelAutofillEntry = {
sourceSkuId: string;
value: string;
};
type FinishedSampleShipmentEditableKey = Exclude<
keyof NonIntegratedFinishedSampleShipmentRow,
"skuId"
@ -374,28 +378,24 @@ const PACKAGE_SHARED_PRODUCT_FACTORY_KEYS = new Set<ProductFactoryEditableKey>([
"setCount",
"supplierName",
"supplierCode",
"warehouse",
"minimumOrderQuantity",
"leadTime",
"assemblyMode",
"productionType",
"installationService",
"supplierCategory",
"purchaseOrderMethod",
]);
const PACKAGE_SHARED_PRODUCT_FACTORY_PURCHASING_KEYS =
new Set<ProductFactoryEditableKey>([
"supplierName",
"supplierCode",
"warehouse",
"minimumOrderQuantity",
"leadTime",
"assemblyMode",
"productionType",
"installationService",
"supplierCategory",
"purchaseOrderMethod",
]);
const MODEL_SHARED_PRODUCT_FACTORY_KEYS = new Set<ProductFactoryEditableKey>([
"warehouse",
"minimumOrderQuantity",
"productionType",
"installationService",
"supplierCategory",
"purchaseOrderMethod",
]);
const SKU_COLUMN_MAX_WIDTH = 520;
function normalizeSkuColumnWidth(
@ -2579,25 +2579,6 @@ const BASIC_INFO_CORE_FIELDS: FieldDefinition[] = [
},
];
const BASIC_INFO_PROCESS_FIELDS: FieldDefinition[] = [
{ key: "demandFlowNo", label: "需求流程号", placeholder: "后续回填" },
{ key: "processTemplate", label: "流程模板", placeholder: "后续回填" },
{ key: "demandDepartment", label: "需求部门", placeholder: "后续回填" },
{ key: "visualDirector", label: "视觉主管", placeholder: "后续回填" },
{ key: "patentOwner", label: "专利所有方", placeholder: "后续回填" },
{ key: "salesChannel", label: "销售渠道", placeholder: "后续回填" },
{ key: "salesPlatform", label: "销售平台", placeholder: "后续回填" },
{ key: "productionType", label: "产品生产类型", placeholder: "后续回填" },
{ key: "shippingMode", label: "发货模式", placeholder: "后续回填" },
{ key: "manualProducer", label: "说明书制作方", placeholder: "后续回填" },
{
key: "isSelfDevelopedPatentProduct",
label: "是否自研专利产品",
placeholder: "后续回填",
},
{ key: "needsClearanceHandling", label: "是否处理清货", placeholder: "后续回填" },
];
const EMPTY_FORM_VALUES: FormValues = {
spu: "",
year: "",
@ -3183,6 +3164,21 @@ function getDefaultProductPhotoStyleKey(knownStyleKeys: string[]) {
}
const INSTALL_METHOD_OPTIONS = ["整装", "拆装"] as const;
const PRODUCT_FACTORY_PRODUCTION_TYPE_OPTIONS = ["OEM", "ODM", "ODM-Q"] as const;
const PRODUCT_FACTORY_INSTALLATION_SERVICE_OPTIONS = [
"送货",
"上楼",
"安装",
] as const;
const PRODUCT_FACTORY_SUPPLIER_CATEGORY_OPTIONS = [
"主力供应商",
"辅力供应商",
] as const;
const PRODUCT_FACTORY_PURCHASE_ORDER_METHOD_OPTIONS = [
"非订单式",
"订单式",
"特殊订单式",
] as const;
function normalizeInstallationModeLabel(value: string) {
const normalizedValue = value.trim();
@ -5012,6 +5008,17 @@ function getSkuPackageGroupKey(sku: Pick<NonIntegratedSkuItem, "id" | "packageGr
return sku.packageGroupId.trim() || sku.id;
}
function getSkuFactoryModelKey(sku: Pick<NonIntegratedSkuItem, "model">) {
return sku.model.trim();
}
function getProductFactoryModelAutofillKey(
skuId: string,
key: ProductFactoryEditableKey,
) {
return `${skuId}:${key}`;
}
function hasPackageSharedFactoryRowContent(row: NonIntegratedFactoryRow) {
return Array.from(PACKAGE_SHARED_PRODUCT_FACTORY_PURCHASING_KEYS).some((key) =>
row[key].trim(),
@ -5030,14 +5037,8 @@ function mergePackageSharedFactoryRow(
confirmed: exactRow.confirmed || sharedRow.confirmed,
supplierName: sharedRow.supplierName,
supplierCode: sharedRow.supplierCode,
warehouse: sharedRow.warehouse,
minimumOrderQuantity: sharedRow.minimumOrderQuantity,
leadTime: sharedRow.leadTime,
assemblyMode: sharedRow.assemblyMode,
productionType: sharedRow.productionType,
installationService: sharedRow.installationService,
supplierCategory: sharedRow.supplierCategory,
purchaseOrderMethod: sharedRow.purchaseOrderMethod,
};
}
@ -5097,18 +5098,19 @@ function buildProductFactoryRows(
const sharedRowByPackageGroupKey = new Map<string, NonIntegratedFactoryRow>();
skuItems.forEach((sku) => {
if (!sku.packageGroupId.trim()) return;
const savedRow = rowBySkuId.get(sku.id);
if (!savedRow) return;
const groupKey = getSkuPackageGroupKey(sku);
const currentSharedRow = sharedRowByPackageGroupKey.get(groupKey);
if (
!currentSharedRow ||
(!hasPackageSharedFactoryRowContent(currentSharedRow) &&
hasPackageSharedFactoryRowContent(savedRow))
) {
sharedRowByPackageGroupKey.set(groupKey, savedRow);
if (sku.packageGroupId.trim()) {
const groupKey = getSkuPackageGroupKey(sku);
const currentSharedRow = sharedRowByPackageGroupKey.get(groupKey);
if (
!currentSharedRow ||
(!hasPackageSharedFactoryRowContent(currentSharedRow) &&
hasPackageSharedFactoryRowContent(savedRow))
) {
sharedRowByPackageGroupKey.set(groupKey, savedRow);
}
}
});
@ -5512,7 +5514,6 @@ function createSampleMeasurementRowFromSku(
widthCm: existingRow?.widthCm?.trim() ?? "",
heightCm: existingRow?.heightCm?.trim() ?? "",
weightKg: existingRow?.weightKg?.trim() ?? "",
productShape: existingRow?.productShape?.trim() ?? "",
};
}
@ -5542,7 +5543,6 @@ function normalizeSampleMeasurementRowsForPayload(
widthCm: row.widthCm.trim(),
heightCm: row.heightCm.trim(),
weightKg: row.weightKg.trim(),
productShape: row.productShape.trim(),
}))
.filter(
(row) =>
@ -5552,7 +5552,6 @@ function normalizeSampleMeasurementRowsForPayload(
row.widthCm,
row.heightCm,
row.weightKg,
row.productShape,
].some(Boolean),
);
}
@ -5565,7 +5564,6 @@ function isSampleMeasurementRowComplete(
row.widthCm,
row.heightCm,
row.weightKg,
row.productShape,
].every((value) => value.trim());
}
@ -9811,6 +9809,9 @@ export default function NonIntegratedDetailClient({
const [factoryRows, setFactoryRows] = useState<NonIntegratedFactoryRow[]>(
existingProduct?.factoryRows ?? [],
);
const productFactoryModelAutofillRef = useRef(
new Map<string, ProductFactoryModelAutofillEntry>(),
);
const [pricingRows, setPricingRows] = useState<NonIntegratedPricingRow[]>(
existingProduct?.pricingRows ?? [],
);
@ -9868,6 +9869,9 @@ export default function NonIntegratedDetailClient({
const [planningOwnerNameOptions, setPlanningOwnerNameOptions] = useState<
string[]
>([]);
const [spaceDesignerNameOptions, setSpaceDesignerNameOptions] = useState<
string[]
>([]);
const [operationOwnerNameOptions, setOperationOwnerNameOptions] = useState<
string[]
>([]);
@ -10116,6 +10120,25 @@ export default function NonIntegratedDetailClient({
}
}
async function loadSpaceDesignerNameOptions() {
try {
const response = await fetch("/api/intake/space-options");
const payload = (await response.json()) as {
success?: boolean;
data?: { names?: Array<string | null | undefined> };
};
const names = Array.isArray(payload.data?.names)
? payload.data.names
: [];
if (!cancelled && response.ok && payload.success) {
setSpaceDesignerNameOptions(getUniqueNonEmptyStrings(names));
}
} catch {
// 候选加载失败时仍允许手动输入。
}
}
async function loadPurchasingOwnerNameOptions() {
try {
const response = await fetch("/api/intake/purchasing-options");
@ -10138,6 +10161,7 @@ export default function NonIntegratedDetailClient({
void Promise.all([
loadDesignerNameOptions(),
loadPlanningOwnerNameOptions(),
loadSpaceDesignerNameOptions(),
loadOperationOwnerNameOptions(),
loadPurchasingOwnerNameOptions(),
]);
@ -11202,6 +11226,11 @@ export default function NonIntegratedDetailClient({
formValues.operator,
formValues.operationsOwner,
]);
const basicInfoSpaceDesignerOptions = getUniqueNonEmptyStrings([
...spaceDesignerNameOptions,
formValues.visualPlanning,
formValues.visualDirector,
]);
const basicInfoPurchasingOwnerOptions = getUniqueNonEmptyStrings([
...purchasingOwnerNameOptions,
formValues.purchaseOwner,
@ -11664,8 +11693,7 @@ export default function NonIntegratedDetailClient({
row.widthCm,
row.heightCm,
row.weightKg,
row.productShape,
].some((value) => value.trim()),
].some((value) => (value ?? "").trim()),
).length,
[productSampleMeasurementRows],
);
@ -12709,7 +12737,7 @@ export default function NonIntegratedDetailClient({
getProductPhotoStyleLabel(defaultProductPhotoStyleKey, derivedSkuItems);
const documentWorkspaceTitle = `${getDocumentWorkspaceCategoryLabel(activeDocumentWorkspaceCategory)}工作区`;
const documentWorkspaceDescription = isSampleMeasurementWorkspace
? "从 SKU 信息自动带出基础字段;长宽高、重量和商品外形由采购填写并自动保存。"
? "从 SKU 信息自动带出基础字段;长宽高和重量由采购填写并自动保存。"
: isFinishedSampleShipmentWorkspace
? "从 SKU 信息自动带出基础字段;寄样数量开放填写,是否寄出和快递单号由采购填写并确认。"
: isProductPhotoWorkspace
@ -14573,6 +14601,52 @@ export default function NonIntegratedDetailClient({
);
}
function getProductFactoryTargetSkuIds(
items: Pick<NonIntegratedSkuItem, "id" | "packageGroupId" | "model">[],
targetId: string,
key: ProductFactoryEditableKey,
savedRows: NonIntegratedFactoryRow[] = [],
value = "",
autofillEntries: ReadonlyMap<string, ProductFactoryModelAutofillEntry> =
new Map(),
) {
const targetItem = items.find((item) => item.id === targetId);
if (!targetItem) return new Set([targetId]);
if (MODEL_SHARED_PRODUCT_FACTORY_KEYS.has(key)) {
const targetModelKey = getSkuFactoryModelKey(targetItem);
if (!targetModelKey || !value.trim()) return new Set([targetId]);
const savedRowBySkuId = new Map(
savedRows.map((row) => [row.skuId, row]),
);
const previousTargetValue =
savedRowBySkuId.get(targetId)?.[key]?.trim() ?? "";
return new Set(
items
.filter((item) => {
if (item.id === targetId) return true;
if (getSkuFactoryModelKey(item) !== targetModelKey) return false;
const savedValue = savedRowBySkuId.get(item.id)?.[key]?.trim() ?? "";
const autofillEntry = autofillEntries.get(
getProductFactoryModelAutofillKey(item.id, key),
);
return (
!savedValue ||
(savedValue === previousTargetValue &&
autofillEntry?.sourceSkuId === targetId &&
autofillEntry.value === previousTargetValue)
);
})
.map((item) => item.id),
);
}
return getPackageFactoryTargetSkuIds(items, targetId, key);
}
function updateProductFactoryRow(
skuId: string,
key: ProductFactoryEditableKey,
@ -14589,7 +14663,7 @@ export default function NonIntegratedDetailClient({
const supplierName = supplier.name || value;
setSkuItems((current) => {
const targetSkuIds = getPackageFactoryTargetSkuIds(
const targetSkuIds = getProductFactoryTargetSkuIds(
current,
skuId,
key,
@ -14604,7 +14678,7 @@ export default function NonIntegratedDetailClient({
if (key === "setCount") {
setSkuItems((current) => {
const targetSkuIds = getPackageFactoryTargetSkuIds(
const targetSkuIds = getProductFactoryTargetSkuIds(
current,
skuId,
key,
@ -14653,12 +14727,15 @@ export default function NonIntegratedDetailClient({
current,
supplierCodeByName,
);
const targetSkuIds = getPackageFactoryTargetSkuIds(
const targetSkuIds = getProductFactoryTargetSkuIds(
derivedSkuItems,
skuId,
key,
current,
value,
productFactoryModelAutofillRef.current,
);
return currentRows.map((row) => {
const nextRows = currentRows.map((row) => {
if (!targetSkuIds.has(row.skuId) || row.confirmed) return row;
const derivedSkuItem = derivedSkuItems.find(
(item) => item.id === row.skuId,
@ -14683,6 +14760,29 @@ export default function NonIntegratedDetailClient({
[key]: value,
};
});
if (MODEL_SHARED_PRODUCT_FACTORY_KEYS.has(key)) {
const normalizedValue = value.trim();
productFactoryModelAutofillRef.current.delete(
getProductFactoryModelAutofillKey(skuId, key),
);
nextRows.forEach((row) => {
const autofillKey = getProductFactoryModelAutofillKey(row.skuId, key);
if (row.skuId === skuId || !targetSkuIds.has(row.skuId)) return;
if (normalizedValue && row[key].trim() === normalizedValue) {
productFactoryModelAutofillRef.current.set(autofillKey, {
sourceSkuId: skuId,
value: normalizedValue,
});
} else {
productFactoryModelAutofillRef.current.delete(autofillKey);
}
});
}
return nextRows;
});
setSaveMessage("");
setSaveMessageType("success");
@ -14872,7 +14972,6 @@ export default function NonIntegratedDetailClient({
widthCm: "",
heightCm: "",
weightKg: "",
productShape: "",
...rowBySkuId.get(skuId),
[key]: value,
};
@ -20134,32 +20233,72 @@ export default function NonIntegratedDetailClient({
/>
</td>
<td className="px-2 py-2">
{renderFactoryInput(
row,
"productionType",
"请输入生产类型",
)}
<InlineSelect
ariaLabel="产品生产类型"
value={row.productionType}
options={PRODUCT_FACTORY_PRODUCTION_TYPE_OPTIONS}
placeholder="请选择"
readOnly={isReadOnly || row.confirmed}
onChange={(value) =>
updateProductFactoryRow(
row.skuId,
"productionType",
value,
)
}
buttonClassName="h-8 w-full min-w-[112px] rounded border border-apple-separator bg-apple-bg-secondary px-2 text-xs text-apple-label-primary outline-none disabled:bg-apple-bg-primary disabled:text-apple-label-secondary"
/>
</td>
<td className="px-2 py-2">
{renderFactoryInput(
row,
"installationService",
"请输入安装服务",
)}
<InlineSelect
ariaLabel="安装服务"
value={row.installationService}
options={PRODUCT_FACTORY_INSTALLATION_SERVICE_OPTIONS}
placeholder="请选择"
readOnly={isReadOnly || row.confirmed}
onChange={(value) =>
updateProductFactoryRow(
row.skuId,
"installationService",
value,
)
}
buttonClassName="h-8 w-full min-w-[96px] rounded border border-apple-separator bg-apple-bg-secondary px-2 text-xs text-apple-label-primary outline-none disabled:bg-apple-bg-primary disabled:text-apple-label-secondary"
/>
</td>
<td className="px-2 py-2">
{renderFactoryInput(
row,
"supplierCategory",
"请输入供应商分类",
)}
<InlineSelect
ariaLabel="供应商分类"
value={row.supplierCategory}
options={PRODUCT_FACTORY_SUPPLIER_CATEGORY_OPTIONS}
placeholder="请选择"
readOnly={isReadOnly || row.confirmed}
onChange={(value) =>
updateProductFactoryRow(
row.skuId,
"supplierCategory",
value,
)
}
buttonClassName="h-8 w-full min-w-[120px] rounded border border-apple-separator bg-apple-bg-secondary px-2 text-xs text-apple-label-primary outline-none disabled:bg-apple-bg-primary disabled:text-apple-label-secondary"
/>
</td>
<td className="px-2 py-2">
{renderFactoryInput(
row,
"purchaseOrderMethod",
"请输入采购下单方式",
)}
<InlineSelect
ariaLabel="采购下单方式"
value={row.purchaseOrderMethod}
options={PRODUCT_FACTORY_PURCHASE_ORDER_METHOD_OPTIONS}
placeholder="请选择"
readOnly={isReadOnly || row.confirmed}
onChange={(value) =>
updateProductFactoryRow(
row.skuId,
"purchaseOrderMethod",
value,
)
}
buttonClassName="h-8 w-full min-w-[128px] rounded border border-apple-separator bg-apple-bg-secondary px-2 text-xs text-apple-label-primary outline-none disabled:bg-apple-bg-primary disabled:text-apple-label-secondary"
/>
</td>
<td className="px-2 py-2">
<SkuExecutionStandardField
@ -20306,7 +20445,6 @@ export default function NonIntegratedDetailClient({
"宽(CM)",
"高(CM)",
"重量(KG)",
"商品外形",
].map((label) => (
<th
key={label}
@ -20357,13 +20495,6 @@ export default function NonIntegratedDetailClient({
<td className="px-3 py-2">
{renderMeasurementInput(row, "weightKg", "请输入重量")}
</td>
<td className="px-3 py-2">
{renderMeasurementInput(
row,
"productShape",
"请输入商品外形",
)}
</td>
</tr>
))}
</tbody>
@ -21644,7 +21775,7 @@ function renderFactoryQualityWorkspace() {
].map((label) => (
<th
key={label}
className="whitespace-nowrap border-b border-apple-separator px-3 py-2 text-left font-apple-medium"
className="whitespace-nowrap border-b border-apple-separator px-3 py-2 text-center font-apple-medium"
>
{label}
</th>
@ -21658,18 +21789,16 @@ function renderFactoryQualityWorkspace() {
return (
<tr
key={row.skuId}
className="border-b border-apple-separator align-top last:border-b-0"
className="border-b border-apple-separator align-middle last:border-b-0"
>
<td className="px-3 py-2">
<span className="rounded-apple-md bg-[#eef2f7] px-2 py-1 text-[11px] text-apple-label-primary">
批次{row.batchNo}
</span>
<td className="whitespace-nowrap px-3 py-2 text-center align-middle text-xs text-apple-label-secondary">
批次{row.batchNo}
</td>
<td className="px-3 py-2">
<td className="px-3 py-2 align-middle">
{row.image ? (
<button
type="button"
className="block h-12 w-12 overflow-hidden rounded border border-apple-separator bg-apple-bg-secondary"
className="mx-auto block h-8 w-12 overflow-hidden rounded border border-apple-separator bg-apple-bg-secondary"
onClick={() =>
setSkuImagePreview({
src: row.image,
@ -21685,39 +21814,39 @@ function renderFactoryQualityWorkspace() {
/>
</button>
) : (
<div className="flex h-12 w-12 items-center justify-center rounded border border-dashed border-apple-separator bg-apple-bg-secondary text-[11px] text-apple-label-tertiary">
<div className="mx-auto flex h-8 w-12 items-center justify-center rounded border border-dashed border-apple-separator bg-apple-bg-secondary text-[11px] text-apple-label-tertiary">
无图
</div>
)}
</td>
<td className="px-3 py-2">
<td className="px-3 py-2 align-middle">
{renderReadOnlyCell(row.model)}
</td>
<td className="px-3 py-2">
<td className="px-3 py-2 align-middle">
{renderReadOnlyCell(row.skuCode)}
</td>
<td className="px-3 py-2">
<td className="px-3 py-2 align-middle">
{renderReadOnlyCell(row.barcode, "132px")}
</td>
<td className="px-3 py-2">
<td className="px-3 py-2 align-middle">
{renderReadOnlyCell(row.setCount, "64px")}
</td>
<td className="px-3 py-2">
<td className="px-3 py-2 align-middle">
{renderReadOnlyCell(row.specName, "128px")}
</td>
<td className="px-3 py-2">
<td className="px-3 py-2 align-middle">
{renderReadOnlyCell(row.size, "120px")}
</td>
<td className="px-3 py-2">
<td className="px-3 py-2 align-middle">
{renderReadOnlyCell(row.color, "80px")}
</td>
<td className="px-3 py-2">
<td className="px-3 py-2 align-middle">
{renderReadOnlyCell(row.material, "112px")}
</td>
<td className="px-3 py-2">
<td className="px-3 py-2 align-middle">
{renderReadOnlyCell(row.supplierName, "104px")}
</td>
<td className="px-3 py-2">
<td className="px-3 py-2 align-middle">
<SkuRowField
value={row.qualityBatch}
placeholder="填写质检批次"
@ -21733,7 +21862,7 @@ function renderFactoryQualityWorkspace() {
}
/>
</td>
<td className="px-3 py-2">
<td className="px-3 py-2 align-middle">
<InlineSelect
ariaLabel={`${row.model || row.skuCode}质检结论`}
value={row.conclusion}
@ -21750,8 +21879,9 @@ function renderFactoryQualityWorkspace() {
panelClassName="absolute left-0 top-[calc(100%+4px)] z-50 max-h-48 min-w-full overflow-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-1 shadow-apple-md"
/>
</td>
<td className="px-3 py-2">
<textarea
<td className="px-3 py-2 align-middle">
<input
type="text"
value={row.detail}
readOnly={isReadOnly}
onChange={(event) =>
@ -21762,46 +21892,44 @@ function renderFactoryQualityWorkspace() {
)
}
placeholder="填写首批质检详细说明"
className={`h-16 min-h-16 w-[220px] resize-none rounded border border-apple-separator px-2 py-1.5 text-xs leading-5 outline-none ${
className={`h-8 w-[220px] rounded border border-apple-separator px-2 text-xs leading-8 outline-none ${
isReadOnly
? "bg-apple-bg-primary text-apple-label-secondary"
: "bg-[#eef1f4] text-apple-label-primary"
}`}
/>
</td>
<td className="px-3 py-2">
<div className="flex min-w-[148px] flex-col gap-2">
<div className="rounded-apple-md bg-apple-bg-secondary px-2 py-1 text-[11px] text-apple-label-secondary">
<td className="px-3 py-2 align-middle">
<div className="flex min-w-[196px] items-center justify-center gap-1.5 whitespace-nowrap">
<div className="inline-flex h-7 items-center rounded-apple-md bg-apple-bg-secondary px-2 text-[11px] text-apple-label-secondary">
当前 {rowFiles.length} 个文件
</div>
<div className="flex items-center gap-1.5">
{isReadOnly ? null : (
<label className="inline-flex h-7 cursor-pointer items-center justify-center rounded-apple-md border border-apple-separator px-2 text-[11px] text-apple-label-secondary hover:bg-apple-bg-secondary/60">
上传
<input
type="file"
multiple
className="hidden"
onChange={(event) =>
void handleUploadFirstQualityFiles(
event,
row.skuId,
)
}
/>
</label>
)}
<button
type="button"
onClick={() =>
handleDownloadWorkspaceFiles(rowFiles)
}
disabled={rowFiles.length === 0}
className="inline-flex h-7 items-center justify-center rounded-apple-md border border-apple-separator px-2 text-[11px] text-apple-label-secondary disabled:opacity-40"
>
下载
</button>
</div>
{isReadOnly ? null : (
<label className="inline-flex h-7 cursor-pointer items-center justify-center rounded-apple-md border border-apple-separator px-2 text-[11px] text-apple-label-secondary hover:bg-apple-bg-secondary/60">
上传
<input
type="file"
multiple
className="hidden"
onChange={(event) =>
void handleUploadFirstQualityFiles(
event,
row.skuId,
)
}
/>
</label>
)}
<button
type="button"
onClick={() =>
handleDownloadWorkspaceFiles(rowFiles)
}
disabled={rowFiles.length === 0}
className="inline-flex h-7 items-center justify-center rounded-apple-md border border-apple-separator px-2 text-[11px] text-apple-label-secondary disabled:opacity-40"
>
下载
</button>
</div>
</td>
</tr>
@ -24991,9 +25119,10 @@ function renderFactoryQualityWorkspace() {
? basicInfoSecondaryCategoryOptions
: field.key === "productCategory"
? basicInfoProductCategoryOptions
: field.key === "designer" ||
field.key === "visualPlanning"
: field.key === "designer"
? basicInfoDesignerOptions
: field.key === "visualPlanning"
? basicInfoSpaceDesignerOptions
: field.key === "productPlanning"
? basicInfoPlanningOwnerOptions
: field.key === "spuOwner" ||
@ -25054,24 +25183,6 @@ function renderFactoryQualityWorkspace() {
</Fragment>
))}
</div>
<div className="border-t border-apple-separator/70 pt-3">
<div className="grid gap-2 md:grid-cols-3 xl:grid-cols-6">
{BASIC_INFO_PROCESS_FIELDS.map((field) => (
<CreateFieldShell key={String(field.key)}>
<BasicField
label={field.label}
placeholder={field.placeholder}
value={formValues[field.key] as string}
readOnly={isReadOnly}
onChange={(value) =>
updateField(field.key, value)
}
compact
/>
</CreateFieldShell>
))}
</div>
</div>
</div>
</div>
</SectionCard>

View File

@ -389,7 +389,6 @@ export type NonIntegratedSampleMeasurementRow = {
widthCm: string;
heightCm: string;
weightKg: string;
productShape: string;
};
export type NonIntegratedFinishedSampleShipmentRow = {
@ -1350,7 +1349,6 @@ function normalizeSampleMeasurementRow(
: typeof item.weightKg === "number" && Number.isFinite(item.weightKg)
? String(item.weightKg)
: "",
productShape: normalizeString(item.productShape),
};
}

View File

@ -1,12 +1,11 @@
import { prisma } from "@/lib/prisma";
type IntakeOwnerCenter = "operation" | "purchasing" | "brand";
const INTAKE_OWNER_CENTER_KEYWORDS: Record<IntakeOwnerCenter, string[]> = {
operation: ["运营中心", "运营"],
purchasing: ["供应链中心", "供应链"],
brand: ["品牌中心", "品牌"],
};
type IntakeOwnerDepartmentScope =
| "productResearch"
| "brandPlanning"
| "spaceVisual"
| "operation"
| "purchasing";
function getUniqueNames(values: Array<string | null | undefined>) {
return Array.from(
@ -18,22 +17,41 @@ function getUniqueNames(values: Array<string | null | undefined>) {
);
}
function textMatchesAnyKeyword(
function normalizeDepartmentText(value: string | null | undefined) {
return value?.replace(/\s+/g, "") ?? "";
}
function departmentTextMatchesScope(
values: Array<string | null | undefined>,
keywords: string[],
scope: IntakeOwnerDepartmentScope,
) {
return values.some((value) => {
const normalized = value?.trim();
return normalized
? keywords.some((keyword) => normalized.includes(keyword))
: false;
const text = normalizeDepartmentText(value);
if (!text) return false;
if (scope === "productResearch") {
return text.includes("产品研发部");
}
if (scope === "brandPlanning") {
return text.includes("品牌中心") && text.includes("商品企划部");
}
if (scope === "spaceVisual") {
return text.includes("品牌中心") && text.includes("空间视觉部");
}
if (scope === "operation") {
return text.includes("运营中心");
}
return text.includes("供应链中心");
});
}
export async function listIntakeOwnerOptionNamesByCenter(
center: IntakeOwnerCenter,
export async function listIntakeOwnerOptionNamesByDepartmentScope(
scope: IntakeOwnerDepartmentScope,
) {
const keywords = INTAKE_OWNER_CENTER_KEYWORDS[center];
const users = await prisma.user.findMany({
where: {
status: "ACTIVE",
@ -66,7 +84,7 @@ export async function listIntakeOwnerOptionNamesByCenter(
return getUniqueNames(
users
.filter((user) =>
textMatchesAnyKeyword(
departmentTextMatchesScope(
[
user.department?.name,
user.department?.fullPath,
@ -74,7 +92,7 @@ export async function listIntakeOwnerOptionNamesByCenter(
user.team?.department?.name,
user.team?.department?.fullPath,
],
keywords,
scope,
),
)
.map((user) => user.name),