Update non-integrated intake workflow

This commit is contained in:
WuYongJie 2026-05-06 17:47:54 +08:00
parent ac7c5a81d8
commit 4ab12803f5
4 changed files with 810 additions and 237 deletions

View File

@ -82,12 +82,36 @@ jest.mock('./non-integrated/NonIntegratedDetailClient', () => ({
default: ({
productId,
mode,
initialDocumentWorkspaceView,
initialDocumentWorkspaceCategory,
onDocumentWorkspaceStateChange,
}: {
productId?: string
mode: 'create' | 'edit' | 'view'
initialDocumentWorkspaceView?: 'detail' | 'document'
initialDocumentWorkspaceCategory?: string
onDocumentWorkspaceStateChange?: (state: {
view: 'detail' | 'document'
category: string
}) => void
}) => (
<div data-testid="embedded-non-integrated-detail">
{productId}-{mode}
<span data-testid="embedded-workspace-state">
{(initialDocumentWorkspaceView ?? 'detail')}-
{(initialDocumentWorkspaceCategory ?? '基础信息')}
</span>
<button
type="button"
onClick={() =>
onDocumentWorkspaceStateChange?.({
view: 'document',
category: '专利申请',
})
}
>
模拟切到专利申请
</button>
</div>
),
}))
@ -313,6 +337,9 @@ function createPatentApprovedProduct(overrides: Record<string, unknown> = {}) {
const product = createNonIntegratedProduct(overrides)
return {
...product,
patentApplication:
(overrides.patentApplication as Record<string, unknown> | undefined) ??
createPatentApplicationContent(),
documentWorkspaceConfirmations: {
...createDefaultDocumentWorkspaceConfirmations(),
...(product.documentWorkspaceConfirmations as Record<string, boolean>),
@ -1751,6 +1778,30 @@ describe('IntakeClient', () => {
).not.toBeInTheDocument()
})
it('keeps each product workspace on the last selected right-side node when switching top tabs', async () => {
const actor = userEvent.setup()
window.localStorage.setItem(
NON_INTEGRATED_STORAGE_KEY,
JSON.stringify([createNonIntegratedProduct()]),
)
renderSelector({ eligibleSkuList: [] })
await actor.click(screen.getByRole('button', { name: '修改' }))
expect(screen.getByTestId('embedded-workspace-state')).toHaveTextContent(
'detail-基础信息',
)
await actor.click(screen.getByRole('button', { name: '模拟切到专利申请' }))
await actor.click(screen.getByRole('tab', { name: '非集成系列产品' }))
await actor.click(screen.getByRole('tab', { name: 'DNZ0001产品开发明细' }))
expect(screen.getByTestId('embedded-workspace-state')).toHaveTextContent(
'document-专利申请',
)
})
it('opens a separate workspace tab each time product edit is clicked', async () => {
const actor = userEvent.setup()

View File

@ -6,6 +6,7 @@ import {
type ChangeEvent,
type KeyboardEvent,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
@ -185,6 +186,10 @@ type BomLibraryKey =
| "packagingLibrary"
| "packagingMaterialLibrary";
type WorkspaceProductMode = "view" | "edit";
type WorkspaceProductDetailState = {
view: "detail" | "document";
category: NonIntegratedDocumentWorkspaceCategory;
};
type IntakeWorkspaceTab =
| {
id: string;
@ -214,6 +219,7 @@ type IntakeWorkspaceTab =
productId: string;
product: NonIntegratedProductRecord;
mode: WorkspaceProductMode;
detailState?: WorkspaceProductDetailState;
}
| {
id: string;
@ -6859,6 +6865,44 @@ export default function IntakeClient(props: IntakeClientProps) {
const activeWorkspaceTab =
workspaceTabs.find((tab) => tab.id === activeWorkspaceTabId) ?? null;
const activeProductWorkspaceTabId =
activeWorkspaceTab?.kind === "nonIntegratedProduct"
? activeWorkspaceTab.id
: null;
const updateProductWorkspaceDetailState = useCallback(
(tabId: string, detailState: WorkspaceProductDetailState) => {
setWorkspaceTabs((current) => {
let changed = false;
const nextTabs = current.map((tab) => {
if (tab.id !== tabId || tab.kind !== "nonIntegratedProduct") {
return tab;
}
if (
tab.detailState?.view === detailState.view &&
tab.detailState.category === detailState.category
) {
return tab;
}
changed = true;
return { ...tab, detailState };
});
return changed ? nextTabs : current;
});
},
[],
);
const handleActiveProductWorkspaceDetailStateChange = useCallback(
(detailState: WorkspaceProductDetailState) => {
if (!activeProductWorkspaceTabId) return;
updateProductWorkspaceDetailState(activeProductWorkspaceTabId, detailState);
},
[activeProductWorkspaceTabId, updateProductWorkspaceDetailState],
);
useEffect(() => {
setNonIntegratedProducts(props.initialNonIntegratedProducts);
@ -7087,8 +7131,11 @@ export default function IntakeClient(props: IntakeClientProps) {
mode={activeWorkspaceTab.mode}
productId={activeWorkspaceTab.productId}
initialProduct={activeWorkspaceTab.product}
initialDocumentWorkspaceView={activeWorkspaceTab.detailState?.view}
initialDocumentWorkspaceCategory={activeWorkspaceTab.detailState?.category}
currentUserRole={props.user.role}
onProductSaved={handleNonIntegratedProductSaved}
onDocumentWorkspaceStateChange={handleActiveProductWorkspaceDetailStateChange}
onReturnToList={activateNonIntegratedListWorkspace}
/>
);

View File

@ -26,6 +26,7 @@ import {
import { parseZipFile } from "@/lib/zip-parser";
import { extractPreviewModelFromZip } from "../../../lib/model-preview-zip";
import { fetchSubstrates } from "@/lib/open-product/platform-api";
import { createApprovalWorkflow } from "@/app/approval-workflows/approvalWorkflowApi";
const push = jest.fn();
const modelPreviewPanelMock = jest.fn();
@ -102,6 +103,16 @@ jest.mock("@/lib/open-product/platform-api", () => ({
fetchSubstrates: jest.fn(),
}));
jest.mock("@/app/approval-workflows/approvalWorkflowApi", () => ({
buildApprovalNumber: jest.fn(() => "PD20260505175418001"),
createApprovalWorkflow: jest.fn(async (input) => ({
id: "workflow-test",
accessKey: "workflow-access-key",
status: "PENDING",
...input,
})),
}));
const mockedPreviewSharedNonIntegratedDocumentImages = jest.mocked(
previewSharedNonIntegratedDocumentImages,
);
@ -112,6 +123,7 @@ const mockedUploadSharedNonIntegratedFile = jest.mocked(
uploadSharedNonIntegratedFile,
);
const mockedFetchSubstrates = jest.mocked(fetchSubstrates);
const mockedCreateApprovalWorkflow = jest.mocked(createApprovalWorkflow);
function createSubstrateOption(overrides: {
id: string;
@ -334,6 +346,7 @@ describe("NonIntegratedDetailClient", () => {
mockedSaveSharedNonIntegratedProduct.mockImplementation(async (product) => product);
mockedUploadSharedNonIntegratedFile.mockReset();
mockedFetchSubstrates.mockReset();
mockedCreateApprovalWorkflow.mockClear();
mockedFetchSubstrates.mockResolvedValue([
createSubstrateOption({
id: "substrate-ice",
@ -357,6 +370,12 @@ describe("NonIntegratedDetailClient", () => {
systemName: "20ENF级橡胶木",
}),
]);
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: jest.fn(async () => undefined),
},
configurable: true,
});
});
afterEach(() => {
@ -367,9 +386,9 @@ describe("NonIntegratedDetailClient", () => {
const actor = userEvent.setup();
const { container } = render(<NonIntegratedDetailClient mode="create" />);
await actor.type(screen.getByLabelText("开品名称"), "测试开品");
await actor.type(screen.getByLabelText("临时编号"), "DNZ0001");
await actor.type(screen.getByLabelText("设计师"), "测试设计师");
expect(screen.getByLabelText("审批编号")).toHaveValue("PD20260505175418001");
expect(screen.getByLabelText("产品图片")).toBeInTheDocument();
await actor.type(screen.getByLabelText("名称"), "测试开品");
const imageFile = new File(["image"], "preview.png", { type: "image/png" });
const fileInputs = container.querySelectorAll('input[type="file"]');
@ -383,17 +402,26 @@ describe("NonIntegratedDetailClient", () => {
) as NonIntegratedProductRecord[];
expect(savedProducts).toHaveLength(1);
expect(savedProducts[0]?.spu).toBe("DNZ0001");
expect(savedProducts[0]?.spu).toBe("PD20260505175418001");
expect(savedProducts[0]?.projectStatus).toBe("draft");
expect(savedProducts[0]?.brand).toBe("基本盘");
expect(savedProducts[0]?.designer).toBe("测试设计师");
expect(savedProducts[0]?.openingReference.productName).toBe("测试开品");
expect(savedProducts[0]?.openingReference.temporaryCode).toBe("DNZ0001");
expect(savedProducts[0]?.openingReference.temporaryCode).toBe(
"PD20260505175418001",
);
expect(savedProducts[0]?.previewImage).toContain(
"data:image/png;base64,dGVzdA==",
);
expect(mockedCreateApprovalWorkflow).toHaveBeenCalledWith({
productId: savedProducts[0]?.id,
approvalNumber: "PD20260505175418001",
title: "测试开品",
businessRoles: [],
});
expect(push).toHaveBeenCalledWith(
`/intake/non-integrated/${encodeURIComponent(savedProducts[0]!.id)}`,
`/intake/non-integrated/${encodeURIComponent(
savedProducts[0]!.id,
)}?workspace=opening-reference`,
);
});
@ -401,7 +429,7 @@ describe("NonIntegratedDetailClient", () => {
const actor = userEvent.setup();
const { container } = render(<NonIntegratedDetailClient mode="create" />);
await actor.type(screen.getByLabelText("开品名称"), "仅填写名称的开品");
await actor.type(screen.getByLabelText("名称"), "仅填写名称的开品");
const imageFile = new File(["image"], "preview.png", { type: "image/png" });
const fileInputs = container.querySelectorAll('input[type="file"]');
@ -418,7 +446,9 @@ describe("NonIntegratedDetailClient", () => {
expect(savedProducts[0]?.openingReference.productName).toBe(
"仅填写名称的开品",
);
expect(savedProducts[0]?.openingReference.temporaryCode).toMatch(/^KP-\d+$/);
expect(savedProducts[0]?.openingReference.temporaryCode).toBe(
"PD20260505175418001",
);
expect(savedProducts[0]?.spu).toBe(
savedProducts[0]?.openingReference.temporaryCode,
);
@ -553,7 +583,23 @@ describe("NonIntegratedDetailClient", () => {
it("keeps brand as a single value when adaptable brands change", async () => {
const actor = userEvent.setup();
render(<NonIntegratedDetailClient mode="create" />);
saveNonIntegratedProduct(
createProductRecord({
id: "DNZ0001-brand-single",
spu: "DNZ0001",
openingReference: {
...createEmptyNonIntegratedOpeningReference(),
productName: "测试开品",
temporaryCode: "DNZ0001",
brand: "基本盘",
adaptableBrands: ["基本盘"],
},
}),
);
render(
<NonIntegratedDetailClient mode="edit" productId="DNZ0001-brand-single" />,
);
const brandSelect = screen.getByLabelText("品牌");
const adaptableBrandButton = screen.getByRole("button", {
@ -574,7 +620,6 @@ describe("NonIntegratedDetailClient", () => {
await actor.click(adaptableBrandButton);
await actor.click(screen.getByLabelText("基本盘"));
expect(brandSelect).toHaveValue("顾家");
expect(screen.getByLabelText("临时编号")).toHaveValue("");
});
it("shows the document sidebar in the updated workflow order", () => {
@ -643,8 +688,6 @@ describe("NonIntegratedDetailClient", () => {
"document-workspace-trigger-产品定厂",
"document-workspace-trigger-样包测量",
"document-workspace-trigger-产品核价",
"document-workspace-trigger-工厂质检",
"document-workspace-trigger-检测报告",
]);
expect(
screen.queryByTestId("document-workspace-trigger-立项评审"),
@ -652,13 +695,15 @@ describe("NonIntegratedDetailClient", () => {
const reviewTrigger = screen.getByTestId("document-workspace-trigger-评审资料");
const modelTrigger = screen.getByTestId("document-workspace-trigger-产品模型");
const reportTrigger = screen.getByTestId("document-workspace-trigger-检测报告");
expect(reviewTrigger).toHaveAttribute("data-has-files", "true");
expect(modelTrigger).toHaveAttribute("data-has-files", "true");
expect(reportTrigger).toHaveAttribute("data-has-files", "false");
expect(reportTrigger).toHaveAttribute("data-pending-confirmation", "true");
expect(reportTrigger).toHaveClass("bg-[#fff7ea]");
expect(
screen.queryByTestId("document-workspace-trigger-工厂质检"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("document-workspace-trigger-检测报告"),
).not.toBeInTheDocument();
expect(reviewTrigger).not.toHaveClass("bg-[#e6ebf0]");
expect(
Array.from(
@ -721,8 +766,6 @@ describe("NonIntegratedDetailClient", () => {
"document-workspace-trigger-产品定厂",
"document-workspace-trigger-样包测量",
"document-workspace-trigger-产品核价",
"document-workspace-trigger-工厂质检",
"document-workspace-trigger-检测报告",
]);
expect(
sidebar.querySelector<HTMLButtonElement>(
@ -884,7 +927,14 @@ describe("NonIntegratedDetailClient", () => {
await actor.click(screen.getByTestId("document-workspace-trigger-专利申请"));
expect(screen.getByLabelText("是否申请专利")).toHaveValue("待判断");
const patentApplicationDecision = screen.getByRole("radiogroup", {
name: "是否申请专利",
});
expect(
within(patentApplicationDecision).getByRole("radio", {
name: "待判断",
}),
).toHaveAttribute("aria-checked", "true");
expect(screen.getByTestId("patent-application-pending-choice")).toHaveTextContent(
"请先选择是否申请专利",
);
@ -892,7 +942,9 @@ describe("NonIntegratedDetailClient", () => {
screen.queryByTestId("patent-application-ledger-fields"),
).not.toBeInTheDocument();
await actor.selectOptions(screen.getByLabelText("是否申请专利"), "申请");
await actor.click(
within(patentApplicationDecision).getByRole("radio", { name: "是" }),
);
const ledgerFields = screen.getByTestId("patent-application-ledger-fields");
const ledgerText = ledgerFields.textContent ?? "";
@ -1021,7 +1073,11 @@ describe("NonIntegratedDetailClient", () => {
);
await actor.click(screen.getByTestId("document-workspace-trigger-专利申请"));
await actor.selectOptions(screen.getByLabelText("是否申请专利"), "不申请");
await actor.click(
within(
screen.getByRole("radiogroup", { name: "是否申请专利" }),
).getByRole("radio", { name: "否" }),
);
expect(screen.getByTestId("patent-application-not-needed")).toHaveTextContent(
"产品无需申请专利",
@ -1297,9 +1353,12 @@ describe("NonIntegratedDetailClient", () => {
/>,
);
await actor.click(screen.getByTestId("document-workspace-trigger-工厂质检"));
await actor.click(screen.getByTestId("document-workspace-trigger-包装细节"));
const panel = screen.getByTestId("document-workspace-panel");
expect(
screen.getByTestId("packaging-factory-quality-section"),
).toBeInTheDocument();
expect(screen.getByTestId("factory-quality-workspace")).toBeInTheDocument();
expect(screen.getByTestId("factory-quality-style-K01")).toHaveTextContent(
"SZ0230K01",
@ -1689,6 +1748,45 @@ describe("NonIntegratedDetailClient", () => {
).toHaveAttribute("data-active", "true");
});
it("keeps the sidebar on basic info instead of falling back to opening reference", async () => {
const actor = userEvent.setup();
window.localStorage.setItem(
NON_INTEGRATED_STORAGE_KEY,
JSON.stringify([
createProductRecord({
id: "DNZ0001-basic-info-sidebar",
uploadFiles: [
{
id: "workspace-photo-basic-info",
name: "产品实拍图.png",
category: "产品实拍图",
dataUrl: "data:image/png;base64,test-image",
mimeType: "image/png",
},
],
}),
]),
);
render(
<NonIntegratedDetailClient
mode="edit"
productId="DNZ0001-basic-info-sidebar"
/>,
);
await actor.click(screen.getByTestId("document-workspace-trigger-产品实拍图"));
await actor.click(screen.getByTestId("document-workspace-basic-info-trigger"));
await waitFor(() => {
expect(
screen.getByTestId("document-workspace-basic-info-trigger"),
).toHaveAttribute("data-active", "true");
});
expect(screen.queryByTestId("opening-reference-workspace")).not.toBeInTheDocument();
});
it("marks copied document categories as pending until the current category is confirmed", async () => {
const actor = userEvent.setup();