diff --git a/furniture-3d-system/src/app/api/approval-workflows/route.ts b/furniture-3d-system/src/app/api/approval-workflows/route.ts index b6c84ba3..9195f135 100644 --- a/furniture-3d-system/src/app/api/approval-workflows/route.ts +++ b/furniture-3d-system/src/app/api/approval-workflows/route.ts @@ -3,7 +3,7 @@ import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' import { AppError, ErrorCode } from '@/server/core/errors' import { Prisma, type ApprovalWorkflowStatus } from '@prisma/client' import { approvalWorkflowService } from '@/server/services/approval-workflow.service' -import { intakeNonIntegratedProductService } from '@/server/services' +import { intakeNonIntegratedProductService } from '@/server/services/intake-non-integrated-product.service' export async function GET(request: NextRequest) { return handleRequest( diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/document-preview/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/document-preview/route.ts index bb50a745..9bdda5f2 100644 --- a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/document-preview/route.ts +++ b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/document-preview/route.ts @@ -22,7 +22,7 @@ import { import { NON_INTEGRATED_DOCUMENT_PREVIEW_DIRECTORY } from "@/app/intake/non-integrated/nonIntegratedAssetUrl"; import { AppError, ErrorCode } from "@/server/core/errors"; import { handleRequest, jsonError, jsonSuccess } from "@/server/core/response"; -import { intakeNonIntegratedProductService } from "@/server/services"; +import { intakeNonIntegratedProductService } from "@/server/services/intake-non-integrated-product.service"; export const runtime = "nodejs"; diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/presentation-preview/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/presentation-preview/route.ts index 24d78925..21a30b20 100644 --- a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/presentation-preview/route.ts +++ b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/presentation-preview/route.ts @@ -14,7 +14,7 @@ import { import { NON_INTEGRATED_PRESENTATION_PREVIEW_DIRECTORY } from '@/app/intake/non-integrated/nonIntegratedAssetUrl' import { AppError, ErrorCode } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' -import { intakeNonIntegratedProductService } from '@/server/services' +import { intakeNonIntegratedProductService } from '@/server/services/intake-non-integrated-product.service' export const runtime = 'nodejs' diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/route.ts index f2b6c5dd..3022b58e 100644 --- a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/route.ts +++ b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/route.ts @@ -1,7 +1,7 @@ import { NextRequest } from 'next/server' import { AppError, ErrorCode } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' -import { intakeNonIntegratedProductService } from '@/server/services' +import { intakeNonIntegratedProductService } from '@/server/services/intake-non-integrated-product.service' type RouteContext = { params: Promise<{ id: string }> diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/upload/chunk/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/upload/chunk/route.ts index 931442a4..f5f689ae 100644 --- a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/upload/chunk/route.ts +++ b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/upload/chunk/route.ts @@ -6,7 +6,7 @@ import { pipeline } from 'stream/promises' import { NextRequest, NextResponse } from 'next/server' import { AppError, ErrorCode } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' -import { intakeNonIntegratedProductService } from '@/server/services' +import { intakeNonIntegratedProductService } from '@/server/services/intake-non-integrated-product.service' import { completeNonIntegratedStoredUploadFile, createNonIntegratedUploadTarget, diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/upload/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/upload/route.ts index 1fe678a0..6293b365 100644 --- a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/upload/route.ts +++ b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/upload/route.ts @@ -1,7 +1,7 @@ import { NextRequest } from 'next/server' import { AppError, ErrorCode } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' -import { intakeNonIntegratedProductService } from '@/server/services' +import { intakeNonIntegratedProductService } from '@/server/services/intake-non-integrated-product.service' import { readOptionalFormValue, readUploadMetadata, diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/route.ts index 923b8f71..4e721fec 100644 --- a/furniture-3d-system/src/app/api/intake/non-integrated/route.ts +++ b/furniture-3d-system/src/app/api/intake/non-integrated/route.ts @@ -1,7 +1,7 @@ import { NextRequest } from 'next/server' import { AppError, ErrorCode } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' -import { intakeNonIntegratedProductService } from '@/server/services' +import { intakeNonIntegratedProductService } from '@/server/services/intake-non-integrated-product.service' export async function GET(request: NextRequest) { return handleRequest( diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/upload/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/upload/route.ts index 342fbcc4..ea18f4fa 100644 --- a/furniture-3d-system/src/app/api/intake/non-integrated/upload/route.ts +++ b/furniture-3d-system/src/app/api/intake/non-integrated/upload/route.ts @@ -1,7 +1,7 @@ import { NextRequest } from 'next/server' import { AppError, ErrorCode } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' -import { intakeNonIntegratedProductService } from '@/server/services' +import { intakeNonIntegratedProductService } from '@/server/services/intake-non-integrated-product.service' import { readOptionalFormValue, readUploadMetadata, diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/uploadHelpers.ts b/furniture-3d-system/src/app/api/intake/non-integrated/uploadHelpers.ts index 328b5fa9..43500757 100644 --- a/furniture-3d-system/src/app/api/intake/non-integrated/uploadHelpers.ts +++ b/furniture-3d-system/src/app/api/intake/non-integrated/uploadHelpers.ts @@ -8,7 +8,7 @@ import type { NonIntegratedProductRecord, NonIntegratedUploadFile, } from '@/app/intake/non-integrated/nonIntegratedStorage' -import { intakeNonIntegratedProductService } from '@/server/services' +import { intakeNonIntegratedProductService } from '@/server/services/intake-non-integrated-product.service' import type { AuthUser } from '@/server/core/types' export type NonIntegratedUploadRouteResponse = { diff --git a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx index 7574f2ce..ed67cdca 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx @@ -90,6 +90,7 @@ jest.mock('./non-integrated/NonIntegratedDetailClient', () => ({ onOpenProductWorkspace, onOpenRawMaterialProcessConfig, onReturnToList, + productDevelopmentKind = 'product', }: { productId?: string mode: 'create' | 'edit' | 'view' @@ -111,15 +112,20 @@ jest.mock('./non-integrated/NonIntegratedDetailClient', () => ({ product: { id: string spu: string + productDevelopmentKind?: 'product' | 'series' openingReference?: { productName?: string } }, mode: 'create' | 'edit' | 'view', ) => void onOpenRawMaterialProcessConfig?: (materialName: string) => void onReturnToList?: () => void + productDevelopmentKind?: 'product' | 'series' }) => (
{productId}-{mode} + + {productDevelopmentKind} + {(initialDocumentWorkspaceView ?? 'detail')}- {(initialDocumentWorkspaceCategory ?? '基础信息')} @@ -168,6 +174,7 @@ jest.mock('./non-integrated/NonIntegratedDetailClient', () => ({ { id: 'created-product', spu: 'CREATED001', + productDevelopmentKind, openingReference: { productName: '创建产品' }, }, 'edit', @@ -1662,9 +1669,30 @@ describe('IntakeClient', () => { expect(screen.getByRole('button', { name: '新增产品' })).toBeInTheDocument() }) - it('shows intake entry tabs and the integrated pending series list', async () => { + it('opens the series development list with the same product detail capabilities', async () => { const actor = userEvent.setup() - renderSelector() + + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + createNonIntegratedProduct({ + id: 'series-desk-1', + spu: 'SER001', + productDevelopmentKind: 'series', + openingReference: { + ...createEmptyNonIntegratedOpeningReference(), + productName: '单板桌系列', + }, + }), + createNonIntegratedProduct({ + id: 'product-desk-1', + spu: 'DNZ0001', + productDevelopmentKind: 'product', + }), + ]), + ) + + renderSelector({ eligibleSkuList: [] }) const rightPanel = screen.getByTestId('right-panel') expect(within(rightPanel).getByRole('button', { name: '系列产品开发明细' })).toBeInTheDocument() @@ -1672,14 +1700,15 @@ describe('IntakeClient', () => { await actor.click(within(rightPanel).getByRole('button', { name: '系列产品开发明细' })) - expect(screen.getByLabelText('系列')).toBeInTheDocument() + expect(screen.getByText('SER001')).toBeInTheDocument() + expect(screen.queryByText('DNZ0001')).not.toBeInTheDocument() + expect(screen.getByRole('columnheader', { name: '系列名称' })).toBeInTheDocument() + expect(screen.getByText('单板桌系列')).toBeInTheDocument() expect(screen.getByRole('columnheader', { name: 'SPU' })).toBeInTheDocument() - expect(screen.getByRole('columnheader', { name: '系列' })).toBeInTheDocument() - expect(screen.getByRole('columnheader', { name: '设计师' })).toBeInTheDocument() - expect(screen.getByRole('link', { name: '新增产品' })).toHaveAttribute( - 'href', - '/intake/integrated/new', - ) + expect(screen.getByRole('columnheader', { name: '外观设计师' })).toBeInTheDocument() + expect(screen.getByRole('columnheader', { name: '结构设计师' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '修改' })).toBeInTheDocument() + expect(screen.queryByRole('link', { name: '新增产品' })).not.toBeInTheDocument() }) it('does not show preparation packaging or quality sections in editor mode', () => { @@ -1691,91 +1720,62 @@ describe('IntakeClient', () => { expect(within(rightPanel).queryByRole('button', { name: '质检确认' })).not.toBeInTheDocument() }) - it('builds integrated rows by draft group series and filters by series query', async () => { + it('opens a new series product workspace from the series development list', async () => { const actor = userEvent.setup() - renderSelector({ - eligibleDraftGroups: [ - { - id: 'draft-group-1', - name: 'Nordic Draft Group', - assetSeriesName: 'Nordic Desk', - code: 'DG-001', - }, - { - id: 'draft-group-2', - name: 'Cloud Draft Group', - assetSeriesName: 'Cloud Sofa', - code: 'DG-002', - }, - ], - eligibleSkuList: [ - { - id: 'pre-sku-1', - spuCode: 'GBGJ-DNZ0001', - code: 'GBGJ-DNZ000101', - name: 'Nordic Desk A', - seriesName: 'Nordic Desk', - draftGroupName: 'Nordic Draft Group', - status: '待处理', - componentCodes: [], - }, - { - id: 'pre-sku-2', - spuCode: 'GBGJ-DNZ0001', - code: 'GBGJ-DNZ000102', - name: 'Nordic Desk B', - seriesName: 'Nordic Desk', - draftGroupName: 'Nordic Draft Group', - status: '待处理', - componentCodes: [], - }, - { - id: 'pre-sku-3', - spuCode: 'GBGJ-SF0002', - code: 'GBGJ-SF000201', - name: 'Cloud Sofa A', - seriesName: 'Cloud Sofa', - draftGroupName: 'Cloud Draft Group', - status: '待处理', - componentCodes: [], - }, - ], - }) + renderSelector({ eligibleSkuList: [] }) await actor.click(screen.getByRole('button', { name: '系列产品开发明细' })) + await actor.click(screen.getByRole('button', { name: '新增产品' })) - expect(screen.getByText('Nordic Desk')).toBeInTheDocument() - expect(screen.getByText('Cloud Sofa')).toBeInTheDocument() - expect(screen.getAllByText('GBGJ-DNZ0001')).toHaveLength(1) - expect(screen.queryByText('GBGJ-DNZ000101')).not.toBeInTheDocument() + expect(screen.getByRole('tab', { name: '新增系列产品' })).toHaveAttribute( + 'aria-selected', + 'true', + ) + expect(screen.getByTestId('embedded-development-kind')).toHaveTextContent( + 'series', + ) - await actor.type(screen.getByLabelText('系列'), 'Cloud') - await actor.click(screen.getByRole('button', { name: '查询' })) + await actor.click(screen.getByRole('button', { name: '模拟创建开品单' })) - expect(screen.getByText('Cloud Sofa')).toBeInTheDocument() - expect(screen.queryByText('Nordic Desk')).not.toBeInTheDocument() + expect(screen.queryByRole('tab', { name: '新增系列产品' })).not.toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'CREATED001系列产品开发明细' })).toHaveAttribute( + 'aria-selected', + 'true', + ) + expect(screen.getByTestId('embedded-development-kind')).toHaveTextContent( + 'series', + ) }) - it('paginates integrated series data by 25 rows', async () => { + it('keeps product and series development lists separated', async () => { const actor = userEvent.setup() - const eligibleDraftGroups = Array.from({ length: 26 }, (_, index) => ({ - id: `draft-group-${index + 1}`, - name: `Draft Group ${index + 1}`, - assetSeriesName: `Series ${index + 1}`, - code: `DG-${String(index + 1).padStart(3, '0')}`, - })) - renderSelector({ eligibleDraftGroups }) + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + createNonIntegratedProduct({ + id: 'series-desk-1', + spu: 'SER001', + productDevelopmentKind: 'series', + }), + createNonIntegratedProduct({ + id: 'product-desk-1', + spu: 'DNZ0001', + productDevelopmentKind: 'product', + }), + ]), + ) + + renderSelector({ eligibleSkuList: [] }) + + expect(screen.getByText('DNZ0001')).toBeInTheDocument() + expect(screen.queryByText('SER001')).not.toBeInTheDocument() + expect(screen.queryByRole('columnheader', { name: '系列名称' })).not.toBeInTheDocument() await actor.click(screen.getByRole('button', { name: '系列产品开发明细' })) - expect(screen.getByText(/每页 25 条/)).toBeInTheDocument() - expect(screen.getByText('Series 25')).toBeInTheDocument() - expect(screen.queryByText('Series 26')).not.toBeInTheDocument() - - await actor.click(screen.getByRole('button', { name: '下一页' })) - - expect(screen.getByText('Series 26')).toBeInTheDocument() + expect(screen.getByText('SER001')).toBeInTheDocument() + expect(screen.queryByText('DNZ0001')).not.toBeInTheDocument() }) it('shows locally saved non-integrated products and supports delete', async () => { diff --git a/furniture-3d-system/src/app/intake/IntakeClient.tsx b/furniture-3d-system/src/app/intake/IntakeClient.tsx index 785a838c..03fb5739 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.tsx @@ -64,9 +64,15 @@ import { saveNonIntegratedProduct, type NonIntegratedDocumentWorkspaceCategory, type NonIntegratedProductRecord, + type NonIntegratedSkuComponentDetail, type NonIntegratedWorkflowNodeCategory, } from "./non-integrated/nonIntegratedStorage"; import NonIntegratedDetailClient from "./non-integrated/NonIntegratedDetailClient"; +import { + readPreSkuComponentDetails, + readPreSkuSkuFields, + type SeriesDraftSkuFields, +} from "./seriesComponentDetails"; import { deleteSharedNonIntegratedProduct, saveSharedNonIntegratedProduct, @@ -221,6 +227,9 @@ type IntakeSection = | "patents" | null; type IntakeEntryTab = "integrated" | "nonIntegrated"; +type ProductDevelopmentKind = NonNullable< + NonIntegratedProductRecord["productDevelopmentKind"] +>; type BomLibraryKey = | "materialLibrary" | "edgeBandingLibrary" @@ -296,12 +305,14 @@ type IntakeWorkspaceTab = productId: string; product: NonIntegratedProductRecord; mode: WorkspaceProductMode; + productDevelopmentKind: ProductDevelopmentKind; detailState?: WorkspaceProductDetailState; } | { id: string; kind: "newNonIntegratedProduct"; label: string; + productDevelopmentKind: ProductDevelopmentKind; } | { id: string; @@ -326,6 +337,7 @@ const MAX_WORKSPACE_TABS_PER_PAGE = 3; type WorkbenchSku = { id: string; + draftGroupId?: string; spuCode: string; code: string; name: string; @@ -333,6 +345,8 @@ type WorkbenchSku = { draftGroupName: string; status: string; componentCodes: string[]; + componentDetails?: NonIntegratedSkuComponentDetail[]; + skuFields?: SeriesDraftSkuFields; }; type IntegratedPendingSeriesRow = { @@ -588,6 +602,42 @@ function getPendingEntryTabLabel(tab: IntakeEntryTab) { return tab === "integrated" ? "系列产品开发明细" : "产品开发明细"; } +function getPendingEntryTabFromProductDevelopmentKind( + kind: ProductDevelopmentKind, +): IntakeEntryTab { + return kind === "series" ? "integrated" : "nonIntegrated"; +} + +function getProductDevelopmentKind( + product: Pick, +): ProductDevelopmentKind { + return product.productDevelopmentKind === "series" ? "series" : "product"; +} + +function getProductDevelopmentDetailLabel( + product: NonIntegratedProductRecord, +) { + const suffix = + getProductDevelopmentKind(product) === "series" + ? "系列产品开发明细" + : "产品开发明细"; + + return `${getNonIntegratedProductDisplayName(product)}${suffix}`; +} + +function readDraftGroupSeriesName(draftGroup: { + assetSeriesName?: string | null; + seriesTemplateName?: string | null; + name?: string | null; +}) { + return ( + draftGroup.assetSeriesName?.trim() || + draftGroup.seriesTemplateName?.trim() || + draftGroup.name?.trim() || + "" + ); +} + function createPendingEntryWorkspaceTab( entryTab: IntakeEntryTab, id = DEFAULT_WORKSPACE_TAB_ID, @@ -602,11 +652,13 @@ function createPendingEntryWorkspaceTab( function createNewNonIntegratedProductWorkspaceTab( id = "new-non-integrated:default", + productDevelopmentKind: ProductDevelopmentKind = "product", ): IntakeWorkspaceTab { return { id, kind: "newNonIntegratedProduct", - label: "新增产品", + label: productDevelopmentKind === "series" ? "新增系列产品" : "新增产品", + productDevelopmentKind, }; } @@ -627,7 +679,9 @@ function getWorkspaceTabPageKey(tab: IntakeWorkspaceTab) { if (tab.kind === "nonIntegratedProduct") { return `non-integrated-product:${tab.productId}:${tab.mode}`; } - if (tab.kind === "newNonIntegratedProduct") return "new-non-integrated"; + if (tab.kind === "newNonIntegratedProduct") { + return `new-non-integrated:${tab.productDevelopmentKind}`; + } if (tab.kind === "bomLibrary") return `bom-library:${tab.bomLibrary}`; if (tab.kind === "rawMaterialLibrary") { return `raw-material-library:${tab.rawMaterialLibrary}`; @@ -842,6 +896,71 @@ function buildIntegratedSeriesRows(params: { })); } +function getDraftGroupSeriesNameOptions(props: IntakeClientProps) { + if (isEditorMode(props)) { + return getUniqueNonEmptyValues([ + readDraftGroupSeriesName(props.detail.draftGroup), + props.detail.draftGroup.name, + ...props.detail.preSkus.map((preSku) => + readPreSkuTextField(preSku, "seriesName"), + ), + ]); + } + + return getUniqueNonEmptyValues([ + ...props.eligibleDraftGroups.flatMap((draftGroup) => [ + readDraftGroupSeriesName(draftGroup), + draftGroup.name, + ]), + ...(props.eligibleSkuList ?? []).map((sku) => sku.seriesName), + ]); +} + +function getDraftGroupSeriesSkuOptions(props: IntakeClientProps): WorkbenchSku[] { + if (isEditorMode(props)) { + return props.detail.preSkus.map((preSku, index) => ({ + id: preSku.id, + draftGroupId: props.detail.draftGroup.id, + spuCode: + readPreSkuTextField(preSku, "spuCode") || + readPreSkuTextField(preSku, "spu") || + props.detail.draftGroup.code || + `SPU-${String(index + 1).padStart(3, "0")}`, + code: + readPreSkuTextField(preSku, "draftSkuCode") || + readPreSkuTextField(preSku, "skuSequenceCode") || + readPreSkuTextField(preSku, "skuCode") || + preSku.customName || + preSku.defaultName, + name: preSku.customName || preSku.defaultName, + seriesName: + readPreSkuTextField(preSku, "seriesName") || + readDraftGroupSeriesName(props.detail.draftGroup), + draftGroupName: props.detail.draftGroup.name, + status: mapStatus(preSku.status), + componentCodes: preSku.componentCodes ?? [], + componentDetails: readPreSkuComponentDetails(preSku), + skuFields: readPreSkuSkuFields(preSku), + })); + } + + return props.eligibleSkuList ?? []; +} + +function getExistingSeriesDevelopmentNames( + products: NonIntegratedProductRecord[], +) { + return getUniqueNonEmptyValues( + products + .filter((product) => getProductDevelopmentKind(product) === "series") + .map( + (product) => + product.openingReference.productName || + getNonIntegratedProductDisplayName(product), + ), + ); +} + function toggleSection( current: IntakeSection, section: Exclude, @@ -1606,7 +1725,7 @@ function CopyProductDialog({ ); } -function IntegratedPendingDetail({ +const _IntegratedPendingDetail = function IntegratedPendingDetail({ rows, }: { rows: IntegratedPendingSeriesRow[]; @@ -1901,12 +2020,14 @@ function IntegratedPendingDetail({ ) : null}
); -} +}; function NonIntegratedPendingDetail({ initialProducts, canDeleteProducts, currentUser, + productDevelopmentKind = "product", + emptyMessage = "当前还没有非集成产品,点击右上角“新增产品”开始维护资料。", onProductsChange, onOpenProductWorkspace, onOpenNewNonIntegratedProductWorkspace, @@ -1914,6 +2035,8 @@ function NonIntegratedPendingDetail({ initialProducts: NonIntegratedProductRecord[]; canDeleteProducts: boolean; currentUser: IntakeProductPermissionUser; + productDevelopmentKind?: ProductDevelopmentKind; + emptyMessage?: string; onProductsChange?: (products: NonIntegratedProductRecord[]) => void; onOpenProductWorkspace?: ( product: NonIntegratedProductRecord, @@ -1956,6 +2079,14 @@ function NonIntegratedPendingDetail({ }); const shouldUseSharedNonIntegratedApi = process.env.NODE_ENV !== "test"; + function scopeProductsByDevelopmentKind( + nextProducts: NonIntegratedProductRecord[], + ) { + return nextProducts.filter( + (product) => getProductDevelopmentKind(product) === productDevelopmentKind, + ); + } + useEffect(() => { if (shouldUseSharedNonIntegratedApi) { setProducts(initialProducts); @@ -1963,7 +2094,9 @@ function NonIntegratedPendingDetail({ } function syncProducts() { - const nextProducts = listNonIntegratedProducts(); + const nextProducts = listNonIntegratedProducts().filter( + (product) => getProductDevelopmentKind(product) === productDevelopmentKind, + ); setProducts(nextProducts); } @@ -1971,7 +2104,7 @@ function NonIntegratedPendingDetail({ window.addEventListener(NON_INTEGRATED_PRODUCTS_UPDATED, syncProducts); return () => window.removeEventListener(NON_INTEGRATED_PRODUCTS_UPDATED, syncProducts); - }, [initialProducts, shouldUseSharedNonIntegratedApi]); + }, [initialProducts, productDevelopmentKind, shouldUseSharedNonIntegratedApi]); async function handleDelete(product: NonIntegratedProductRecord) { const productLabel = product.spu?.trim() || "当前产品"; @@ -1992,7 +2125,9 @@ function NonIntegratedPendingDetail({ } deleteNonIntegratedProduct(product.id); - const nextProducts = listNonIntegratedProducts(); + const nextProducts = scopeProductsByDevelopmentKind( + listNonIntegratedProducts(), + ); setProducts(nextProducts); onProductsChange?.(nextProducts); } catch (error) { @@ -2028,7 +2163,9 @@ function NonIntegratedPendingDetail({ } saveNonIntegratedProduct(restoredProduct); - const nextProducts = listNonIntegratedProducts(); + const nextProducts = scopeProductsByDevelopmentKind( + listNonIntegratedProducts(), + ); setProducts(nextProducts); onProductsChange?.(nextProducts); } catch (error) { @@ -2092,7 +2229,9 @@ function NonIntegratedPendingDetail({ openSavedProduct(savedProduct); } else { const savedProduct = saveNonIntegratedProduct(copiedProduct); - const nextProducts = listNonIntegratedProducts(); + const nextProducts = scopeProductsByDevelopmentKind( + listNonIntegratedProducts(), + ); setProducts(nextProducts); onProductsChange?.(nextProducts); openSavedProduct(savedProduct); @@ -2195,6 +2334,11 @@ function NonIntegratedPendingDetail({ { key: "created", label: "我创建的" }, { key: "pending", label: "待处理" }, ]; + const isSeriesDevelopmentList = productDevelopmentKind === "series"; + const listColumnCount = + 11 + + NON_INTEGRATED_DOCUMENT_STATUS_COLUMNS.length + + (isSeriesDevelopmentList ? 1 : 0); return (
@@ -2264,7 +2408,11 @@ function NonIntegratedPendingDetail({
- +
+ {isSeriesDevelopmentList ? ( + + ) : null} @@ -2319,13 +2472,11 @@ function NonIntegratedPendingDetail({ {filteredProducts.length === 0 ? ( @@ -2353,6 +2504,8 @@ function NonIntegratedPendingDetail({ const productPreviewImage = product.openingReference?.productImage?.trim() || product.previewImage; + const seriesDisplayName = + product.openingReference?.productName?.trim() || "-"; return ( + {isSeriesDevelopmentList ? ( + + ) : null} setBarcodeScannerSkuId(item.id) } + componentDetailsCount={ + seriesComponentDetails.length + } + onOpenComponentDetails={ + seriesComponentDetails.length > + 0 + ? () => + setSeriesComponentDialogSkuId( + item.id, + ) + : undefined + } onColumnResize={(width) => handleSkuColumnResize( "barcode", @@ -23196,7 +24374,7 @@ function renderFactoryQualityWorkspace() { @@ -23996,7 +25183,9 @@ function renderFactoryQualityWorkspace() {
@@ -2273,6 +2421,11 @@ function NonIntegratedPendingDetail({ 成品预览图 + 系列名称 +
{products.length === 0 - ? "当前还没有非集成产品,点击右上角“新增产品”开始维护资料。" + ? emptyMessage : "当前查询条件下没有找到资料。"}
+ {seriesDisplayName} + + + {componentDetails.length > 0 ? ( +
+ + + + + + + + + + + + {componentDetails.map((component, index) => ( + + + + + + + + ))} + +
序号部件编码部件名称数量类型/角色
+ {index + 1} + + {component.code || "-"} + + {component.name || "-"} + + {component.quantity || "1"} + + {[component.assetType, component.role] + .filter(Boolean) + .join(" / ") || "-"} +
+
+ ) : ( +
+ 当前条码还没有确认的部件信息。 +
+ )} + + + ); +} + function WorkspacePreviewEmptyState({ title, description, @@ -7178,6 +7549,8 @@ function SkuBarcodeField({ columnWidth, onChange, onOpenScanner, + componentDetailsCount = 0, + onOpenComponentDetails, onColumnResize, }: { value: string; @@ -7187,9 +7560,12 @@ function SkuBarcodeField({ columnWidth: number; onChange: (value: string) => void; onOpenScanner: () => void; + componentDetailsCount?: number; + onOpenComponentDetails?: () => void; onColumnResize: (width: number) => void; }) { - const buttonWidth = readOnly ? 0 : 44; + const hasComponentDetails = componentDetailsCount > 0 && onOpenComponentDetails; + const buttonWidth = (readOnly ? 0 : 44) + (hasComponentDetails ? 36 : 0); return ( )} + {hasComponentDetails ? ( + + ) : null} ); } @@ -7942,17 +8329,24 @@ function buildProductPayload( copiedFromSpu?: string, createdBy?: string, updatedBy?: string, + productDevelopmentKind: ProductDevelopmentKind = "product", ): NonIntegratedProductRecord { const now = new Date().toISOString(); - const derivedSkuItemsForPayload = skuItems.map((item, index) => ({ - ...deriveSkuItem(item, index, formValues.spu, formValues), - model3dFileName: item.model3dFileName?.trim() ?? "", - model3dDataUrl: item.model3dDataUrl?.trim() ?? "", - model3dNodeOffsets: - item.model3dNodeOffsets ?? EMPTY_PREVIEW_NODE_OFFSET_MAP, - model3dNodeRotations: - item.model3dNodeRotations ?? EMPTY_PREVIEW_NODE_ROTATION_MAP, - })); + const derivedSkuItemsForPayload = skuItems.map((item, index) => { + const derivedItem = { + ...deriveSkuItem(item, index, formValues.spu, formValues), + model3dFileName: item.model3dFileName?.trim() ?? "", + model3dDataUrl: item.model3dDataUrl?.trim() ?? "", + model3dNodeOffsets: + item.model3dNodeOffsets ?? EMPTY_PREVIEW_NODE_OFFSET_MAP, + model3dNodeRotations: + item.model3dNodeRotations ?? EMPTY_PREVIEW_NODE_ROTATION_MAP, + }; + + return productDevelopmentKind === "series" + ? cleanLegacySeriesSkuComponentFields(derivedItem) + : derivedItem; + }); const normalizedStyleDetails = Object.entries(styleDetails) .filter(([styleKey]) => Boolean(styleKey)) .map(([styleKey, detail]) => ({ @@ -8168,6 +8562,7 @@ function buildProductPayload( return { id, + productDevelopmentKind, projectStatus, projectReview: normalizeNonIntegratedProjectReview(projectReview), openingReference: normalizedOpeningReference, @@ -8318,6 +8713,10 @@ export default function NonIntegratedDetailClient({ onOpenProductWorkspace, onOpenRawMaterialProcessConfig, onReturnToList, + productDevelopmentKind = "product", + seriesNameOptions = [], + existingSeriesNames = [], + seriesDraftSkuOptions = [], }: { mode: "create" | "edit" | "view"; productId?: string; @@ -8344,6 +8743,10 @@ export default function NonIntegratedDetailClient({ ) => void; onOpenRawMaterialProcessConfig?: (materialName: string) => void; onReturnToList?: () => void; + productDevelopmentKind?: ProductDevelopmentKind; + seriesNameOptions?: string[]; + existingSeriesNames?: string[]; + seriesDraftSkuOptions?: SeriesDraftSkuOption[]; }) { const router = useRouter(); const openingReferenceDesignerDatalistId = useId(); @@ -8431,6 +8834,8 @@ export default function NonIntegratedDetailClient({ setLinkEntryProductPatentDashboard, ] = useState(null); const [activeSkuTab, setActiveSkuTab] = useState("skuInfo"); + const [activeComponentSkuCategory, setActiveComponentSkuCategory] = + useState("all"); const [createProductKind, setCreateProductKind] = useState("new"); const [stockProductSpu, setStockProductSpu] = useState(""); @@ -8481,6 +8886,9 @@ export default function NonIntegratedDetailClient({ const [barcodeScannerSkuId, setBarcodeScannerSkuId] = useState( null, ); + const [seriesComponentDialogSkuId, setSeriesComponentDialogSkuId] = useState< + string | null + >(null); const [factoryRows, setFactoryRows] = useState( existingProduct?.factoryRows ?? [], ); @@ -8660,6 +9068,8 @@ export default function NonIntegratedDetailClient({ const [selectedSupplementSkuId, setSelectedSupplementSkuId] = useState( () => initialBomDraftSnapshot?.selectedSupplementSkuId ?? "", ); + const [selectedSupplementComponentCode, setSelectedSupplementComponentCode] = + useState(""); const [activePackagingWorkspacePanel, setActivePackagingWorkspacePanel] = useState("packaging"); const [activeFirstQualityWorkspacePanel, setActiveFirstQualityWorkspacePanel] = @@ -9341,6 +9751,97 @@ export default function NonIntegratedDetailClient({ const normalizedStockProductSpu = stockProductSpu.trim().toUpperCase(); const isStockProductCreate = mode === "create" && createProductKind === "stock"; + const effectiveProductDevelopmentKind: ProductDevelopmentKind = + mode === "create" + ? productDevelopmentKind + : existingProduct?.productDevelopmentKind === "series" + ? "series" + : productDevelopmentKind; + const isSeriesDevelopment = effectiveProductDevelopmentKind === "series"; + const isStockSeriesProductCreate = + isStockProductCreate && isSeriesDevelopment; + const developmentCreateTitle = isSeriesDevelopment ? "新增系列产品" : "新增产品"; + const developmentNameLabel = isSeriesDevelopment ? "系列名称" : "名称"; + const developmentNamePlaceholder = isSeriesDevelopment + ? "请选择系列名称" + : "请输入开品标题"; + const openingReferenceProductNameLabel = isSeriesDevelopment + ? "系列名称" + : "开品名称"; + const openingReferenceProductNamePlaceholder = isSeriesDevelopment + ? "如:单板桌系列" + : "如:胡桃木色窄边书桌"; + const developmentDetailSuffix = isSeriesDevelopment + ? "系列产品开发明细" + : "产品开发明细"; + const developmentCreatedMessage = isSeriesDevelopment + ? "图片、系列名称、二级类目、商品类目、品牌、外观设计师和企划负责人填写后点击“创建系列产品”,系统会自动生成系列开发明细。" + : "图片、名称、品牌、外观设计师和企划负责人填写后点击“创建开品单”,系统会自动把这张开品明细链接复制到剪贴板。"; + const developmentCreateButtonLabel = isSeriesDevelopment + ? "创建系列产品" + : "创建开品单"; + const existingSeriesNameKeys = useMemo( + () => + new Set( + existingSeriesNames + .map((name) => normalizeSeriesNameKey(name)) + .filter((name) => name.length > 0), + ), + [existingSeriesNames], + ); + const availableSeriesNameOptions = useMemo(() => { + if (!isSeriesDevelopment) return []; + + return getUniqueNonEmptyStrings(seriesNameOptions).filter( + (name) => !existingSeriesNameKeys.has(normalizeSeriesNameKey(name)), + ); + }, [existingSeriesNameKeys, isSeriesDevelopment, seriesNameOptions]); + const selectedSeriesNameKey = normalizeSeriesNameKey( + openingReference.productName, + ); + const selectedSeriesDraftSkuOptions = useMemo(() => { + if (!isSeriesDevelopment || selectedSeriesNameKey.length === 0) return []; + + return seriesDraftSkuOptions + .filter( + (option) => + normalizeSeriesNameKey(option.seriesName) === selectedSeriesNameKey, + ) + .sort( + (left, right) => + normalizeSeriesDraftSkuSequence(left, 0) - + normalizeSeriesDraftSkuSequence(right, 0), + ); + }, [isSeriesDevelopment, selectedSeriesNameKey, seriesDraftSkuOptions]); + const selectedSeriesDraftSkuComponentLookup = useMemo(() => { + const lookup = new Map(); + + selectedSeriesDraftSkuOptions.forEach((option, index) => { + const sequence = normalizeSeriesDraftSkuSequence(option, index); + const details = resolveSeriesDraftSkuComponentDetails(option); + const keys = [ + option.id, + `series-${option.id}`, + option.code.trim(), + String(sequence).padStart(2, "0"), + String(sequence), + ] + .map((key) => key.trim()) + .filter(Boolean); + + keys.forEach((key) => lookup.set(key, details)); + }); + + return lookup; + }, [selectedSeriesDraftSkuOptions]); + const selectedSeriesDraftSkuCount = selectedSeriesDraftSkuOptions.length; + const canUseSelectedSeriesName = + !isSeriesDevelopment || + (selectedSeriesNameKey.length > 0 && + !existingSeriesNameKeys.has(selectedSeriesNameKey) && + availableSeriesNameOptions.some( + (name) => normalizeSeriesNameKey(name) === selectedSeriesNameKey, + )); const normalizedExistingOpeningReference = normalizeNonIntegratedOpeningReference(existingProduct?.openingReference); const normalizedExistingSpu = existingProduct?.spu.trim().toUpperCase() ?? ""; @@ -9361,6 +9862,9 @@ export default function NonIntegratedDetailClient({ const hasFormalSpu = formalSpuValue.length > 0; const spuRuleProductCategory = formValues.productCategory.trim() || openingReference.productCategory.trim(); + const seriesCreateSalesCategory = + formValues.salesCategory.trim() || openingReference.salesCategory.trim(); + const seriesCreateProductCategory = spuRuleProductCategory; const estimatedSpuAssignment = useMemo(() => { if (hasFormalSpu) return null; @@ -9377,6 +9881,21 @@ export default function NonIntegratedDetailClient({ : spuRuleProductCategory ? "未配置SPU规则" : "选择商品类目后自动预估"; + const seriesCreateEstimatedSpuLabel = hasFormalSpu + ? formalSpuValue + : estimatedSpuAssignment + ? `预计SPU:${estimatedSpuAssignment.spu}` + : seriesCreateProductCategory + ? "当前商品类目未配置SPU规则" + : "选择商品类目后自动预估"; + const canConfirmNewSeriesSpu = + !isSeriesDevelopment || + isStockProductCreate || + Boolean( + seriesCreateSalesCategory && + seriesCreateProductCategory && + estimatedSpuAssignment, + ); const isSpuCategoryLocked = hasFormalSpu && !isStockProductRecord; const derivedSkuItems = useMemo( () => @@ -9385,6 +9904,163 @@ export default function NonIntegratedDetailClient({ ), [formValues.spu, formValues.tagMaterial, skuItems], ); + const getSeriesSkuComponentDetails = useCallback( + (item: NonIntegratedSkuItem): NonIntegratedSkuComponentDetail[] => { + const storedDetails = normalizeNonIntegratedSkuComponentDetails( + item.componentDetails, + ); + if (storedDetails.length > 0) return storedDetails; + + const lookupKeys = [ + item.id, + item.id.startsWith("series-") ? item.id.slice("series-".length) : "", + item.model.trim(), + String(item.skuSequence).padStart(2, "0"), + String(item.skuSequence), + ].filter(Boolean); + for (const key of lookupKeys) { + const matchedDetails = selectedSeriesDraftSkuComponentLookup.get(key); + if (matchedDetails?.length) return matchedDetails; + } + + const legacyCodes = [ + ...extractLegacySeriesComponentCodes(item.attributeCode), + ...extractLegacySeriesComponentCodes(item.drawingId), + ]; + + return buildSeriesComponentDetailsFromCodes( + Array.from(new Set(legacyCodes)), + ); + }, + [selectedSeriesDraftSkuComponentLookup], + ); + const selectedSeriesComponentSku = useMemo( + () => + seriesComponentDialogSkuId + ? derivedSkuItems.find((item) => item.id === seriesComponentDialogSkuId) + : null, + [derivedSkuItems, seriesComponentDialogSkuId], + ); + const selectedSeriesComponentDetails = useMemo( + () => + selectedSeriesComponentSku + ? getSeriesSkuComponentDetails(selectedSeriesComponentSku) + : [], + [getSeriesSkuComponentDetails, selectedSeriesComponentSku], + ); + const componentSkuRows = useMemo(() => { + const rows = new Map< + string, + { + code: string; + name: string; + quantity: string; + role: string; + assetType: string; + } + >(); + + derivedSkuItems.forEach((sku) => { + getSeriesSkuComponentDetails(sku).forEach((component) => { + const code = component.code.trim(); + if (!code) return; + + const key = code.toUpperCase(); + if (rows.has(key)) return; + + rows.set(key, { + code, + name: component.name.trim(), + quantity: component.quantity.trim() || "1", + role: component.role.trim(), + assetType: component.assetType.trim(), + }); + }); + }); + + return Array.from(rows.values()).map((row, index) => { + const categoryKey = inferComponentSkuCategory(row); + + return { + ...row, + categoryKey, + categoryLabel: COMPONENT_SKU_CATEGORY_LABEL_BY_KEY[categoryKey], + sequence: index + 1, + }; + }); + }, [derivedSkuItems, getSeriesSkuComponentDetails]); + const orderedComponentSkuRows = useMemo( + () => + [...componentSkuRows].sort((left, right) => { + const categoryOrder = + (COMPONENT_SKU_CATEGORY_ORDER.get(left.categoryKey) ?? 99) - + (COMPONENT_SKU_CATEGORY_ORDER.get(right.categoryKey) ?? 99); + if (categoryOrder !== 0) return categoryOrder; + + const codeOrder = compareComponentSkuCode(left.code, right.code); + if (codeOrder !== 0) return codeOrder; + + const leftName = left.name || left.role || left.code; + const rightName = right.name || right.role || right.code; + const nameOrder = leftName.localeCompare(rightName, "zh-Hans-CN"); + if (nameOrder !== 0) return nameOrder; + + return 0; + }), + [componentSkuRows], + ); + const componentSkuCategoryCounts = useMemo(() => { + const counts = Object.fromEntries( + COMPONENT_SKU_CATEGORY_OPTIONS.map((option) => [option.key, 0]), + ) as Record; + + counts.all = componentSkuRows.length; + componentSkuRows.forEach((row) => { + counts[row.categoryKey] += 1; + }); + + return counts; + }, [componentSkuRows]); + const visibleComponentSkuCategoryOptions = useMemo( + () => + COMPONENT_SKU_CATEGORY_OPTIONS.filter( + (option) => + option.key === "all" || + componentSkuCategoryCounts[option.key] > 0 || + activeComponentSkuCategory === option.key, + ), + [activeComponentSkuCategory, componentSkuCategoryCounts], + ); + const displayedComponentSkuRows = useMemo(() => { + if (activeComponentSkuCategory === "all") return orderedComponentSkuRows; + + return orderedComponentSkuRows.filter( + (row) => row.categoryKey === activeComponentSkuCategory, + ); + }, [activeComponentSkuCategory, orderedComponentSkuRows]); + const componentSkuDisplayGroups = useMemo(() => { + if (activeComponentSkuCategory !== "all") { + return [ + { + key: activeComponentSkuCategory, + label: COMPONENT_SKU_CATEGORY_LABEL_BY_KEY[activeComponentSkuCategory], + rows: displayedComponentSkuRows, + }, + ]; + } + + return COMPONENT_SKU_CONCRETE_CATEGORY_OPTIONS.map((option) => ({ + key: option.key, + label: option.label, + rows: orderedComponentSkuRows.filter( + (row) => row.categoryKey === option.key, + ), + })).filter((group) => group.rows.length > 0); + }, [ + activeComponentSkuCategory, + displayedComponentSkuRows, + orderedComponentSkuRows, + ]); const productFactoryRows = useMemo( () => buildProductFactoryRows(derivedSkuItems, factoryRows), [derivedSkuItems, factoryRows], @@ -9520,21 +10196,71 @@ export default function NonIntegratedDetailClient({ formValues.purchaseOwner, formValues.purchaser, ]); - const supplementBarcodeOptions = derivedSkuItems.map((item) => ({ - value: item.id, - label: item.barcode || item.skuCode || "未生成条码", - })); + const isComponentSupplementMode = + isSeriesDevelopment && activeSkuTab === "componentSkuInfo"; + const effectiveSelectedSupplementComponentCode = + displayedComponentSkuRows.find( + (row) => row.code === selectedSupplementComponentCode, + )?.code ?? + displayedComponentSkuRows[0]?.code ?? + ""; + const selectedSupplementComponentSku = + orderedComponentSkuRows.find( + (row) => row.code === effectiveSelectedSupplementComponentCode, + ) ?? null; + const supplementBarcodeOptions = isComponentSupplementMode + ? displayedComponentSkuRows.map((row) => ({ + value: row.code, + label: row.code, + })) + : derivedSkuItems.map((item) => ({ + value: item.id, + label: item.barcode || item.skuCode || "未生成条码", + })); const effectiveSelectedSupplementSkuId = derivedSkuItems.find((item) => item.id === selectedSupplementSkuId)?.id ?? derivedSkuItems[0]?.id ?? ""; const selectedSupplementSourceSku = - skuItems.find((item) => item.id === effectiveSelectedSupplementSkuId) ?? - null; + isComponentSupplementMode + ? null + : (skuItems.find((item) => item.id === effectiveSelectedSupplementSkuId) ?? + null); const selectedSupplementSku = - derivedSkuItems.find( - (item) => item.id === effectiveSelectedSupplementSkuId, - ) ?? null; + isComponentSupplementMode + ? null + : (derivedSkuItems.find( + (item) => item.id === effectiveSelectedSupplementSkuId, + ) ?? null); + const selectedSupplementSkuComponentDetails = useMemo( + () => + isSeriesDevelopment && !isComponentSupplementMode && selectedSupplementSku + ? getSeriesSkuComponentDetails(selectedSupplementSku) + : [], + [ + getSeriesSkuComponentDetails, + isComponentSupplementMode, + isSeriesDevelopment, + selectedSupplementSku, + ], + ); + const isSeriesSkuComponentAggregateMode = + selectedSupplementSkuComponentDetails.length > 0; + const effectiveSupplementSelectValue = isComponentSupplementMode + ? effectiveSelectedSupplementComponentCode + : effectiveSelectedSupplementSkuId; + const selectedSupplementSize = isComponentSupplementMode + ? inferComponentSkuSize(selectedSupplementComponentSku?.name ?? "") + : (selectedSupplementSku?.size ?? ""); + const selectedSupplementColor = isComponentSupplementMode + ? inferComponentSkuColor(selectedSupplementComponentSku?.name ?? "") + : (selectedSupplementSku?.color ?? ""); + const selectedSupplementMaterial = isComponentSupplementMode + ? inferComponentSkuMaterial(selectedSupplementComponentSku?.name ?? "") || + selectedSupplementComponentSku?.categoryLabel || + selectedSupplementComponentSku?.assetType || + "" + : (selectedSupplementSku?.material ?? ""); const reviewSheetAutoFillValues = useMemo( () => buildReviewSheetAutoFillValues({ @@ -9588,9 +10314,11 @@ export default function NonIntegratedDetailClient({ reviewSheetManualOverrideFieldsRef.current.set(roundId, nextFields); return nextFields; }; - const selectedStyleKey = getStyleKeyFromModel( - selectedSupplementSku?.model ?? "", - ); + const selectedStyleKey = isComponentSupplementMode + ? effectiveSelectedSupplementComponentCode + ? `component:${effectiveSelectedSupplementComponentCode}` + : "" + : getStyleKeyFromModel(selectedSupplementSku?.model ?? ""); const selectedStyleDetail = { ...EMPTY_STYLE_DETAIL, ...(selectedStyleKey ? styleDetails[selectedStyleKey] : null), @@ -9654,7 +10382,7 @@ export default function NonIntegratedDetailClient({ }, [previewNodeRotationMap]); const selectedBomStyleKey = selectedStyleKey; const selectedBomSkuKey = - selectedBomStyleKey && selectedSupplementSku + !isComponentSupplementMode && selectedBomStyleKey && selectedSupplementSku ? buildBomDetailBarcodeStorageKey( selectedBomStyleKey, selectedSupplementSku.barcode, @@ -9683,6 +10411,18 @@ export default function NonIntegratedDetailClient({ [bomDetails, hasSelectedBomOverride, selectedBomSkuKey], ); const selectedBomSyntheticUnitPriceOverrides = useMemo(() => { + if (isSeriesSkuComponentAggregateMode) { + return selectedSupplementSkuComponentDetails.reduce< + NonIntegratedBomSyntheticUnitPriceOverrides + >((overrides, component) => { + const componentKey = `component:${component.code.trim()}`; + return { + ...overrides, + ...(bomDetails[componentKey]?.syntheticUnitPriceOverrides ?? {}), + }; + }, {}); + } + if (!selectedBomStyleKey) return {}; const sharedOverrides = @@ -9699,25 +10439,38 @@ export default function NonIntegratedDetailClient({ }, [ bomDetails, hasSelectedBomOverride, + isSeriesSkuComponentAggregateMode, selectedBomSkuKey, selectedBomStyleKey, + selectedSupplementSkuComponentDetails, ]); const selectedBomRows = useMemo( - () => - selectedBomStyleKey + () => { + if (isSeriesSkuComponentAggregateMode) { + return selectedSupplementSkuComponentDetails.flatMap((component) => { + const componentKey = `component:${component.code.trim()}`; + return bomDetails[componentKey]?.rows ?? []; + }); + } + + return selectedBomStyleKey ? hasSelectedBomOverride ? materializeBomRowsForSkuOverride(selectedBomStyleRows, { rows: selectedBomOverrideRows, hiddenRowIds: selectedBomHiddenRowIds, }) : selectedBomStyleRows - : [], + : []; + }, [ + bomDetails, hasSelectedBomOverride, selectedBomHiddenRowIds, selectedBomOverrideRows, selectedBomStyleKey, selectedBomStyleRows, + isSeriesSkuComponentAggregateMode, + selectedSupplementSkuComponentDetails, ], ); const selectedBomRowsByCategory = useMemo( @@ -9795,6 +10548,8 @@ export default function NonIntegratedDetailClient({ ), [selectedBomRowsByCategory], ); + const isSelectedBomDetailReadOnly = + isReadOnly || isSeriesSkuComponentAggregateMode; const populatedReviewRoundCount = useMemo( () => { if (reviewRounds.length === 0) return 0; @@ -11010,10 +11765,14 @@ export default function NonIntegratedDetailClient({ ? isStockProductCreate ? sharedProductImage.length > 0 && normalizedStockProductSpu.length > 0 && + (!isStockSeriesProductCreate || canUseSelectedSeriesName) && openingReference.designer.trim().length > 0 && formValues.brand.trim().length > 0 : sharedProductImage.length > 0 && - openingReference.productName.trim().length > 0 && + (isSeriesDevelopment + ? canUseSelectedSeriesName + : openingReference.productName.trim().length > 0) && + canConfirmNewSeriesSpu && openingReference.designer.trim().length > 0 && openingReference.planningOwner.trim().length > 0 && formValues.brand.trim().length > 0 @@ -11302,6 +12061,8 @@ export default function NonIntegratedDetailClient({ documentWorkspaceApprovedAt?: NonIntegratedDocumentWorkspaceApprovalMap; reviewRounds?: NonIntegratedReviewRound[]; openingReference?: NonIntegratedOpeningReference; + skuItems?: NonIntegratedSkuItem[]; + skuBatchCount?: number; projectStatus?: NonIntegratedProjectStatus; allowReadOnly?: boolean; }; @@ -11314,6 +12075,8 @@ export default function NonIntegratedDetailClient({ const nextReviewRounds = overrides?.reviewRounds ?? reviewRounds; const nextOpeningReference = overrides?.openingReference ?? openingReferenceRef.current; + const nextSkuItems = overrides?.skuItems ?? skuItems; + const nextSkuBatchCount = overrides?.skuBatchCount ?? skuBatchCount; const nextProjectStatus = overrides?.projectStatus ?? projectStatus; if ( @@ -11336,7 +12099,7 @@ export default function NonIntegratedDetailClient({ buildProductPayload( productId, nextFormValuesForPayload, - skuItems, + nextSkuItems, styleDetails, bomDetails, nextReviewRounds, @@ -11355,7 +12118,7 @@ export default function NonIntegratedDetailClient({ patentApplication, projectReview, nextProjectStatus, - skuBatchCount, + nextSkuBatchCount, skuBatchFlowNos, skuBatchChangeTypes, existingProductCreatedAt, @@ -11363,10 +12126,11 @@ export default function NonIntegratedDetailClient({ existingProduct?.copiedFromSpu, existingProduct?.createdBy, currentUserNameNormalized || currentUserIdNormalized || undefined, + effectiveProductDevelopmentKind, ), ); - const savedProduct = shouldUseRemoteAssetStorage() + const savedProduct = shouldUseRemoteAssetStorage() ? await saveSharedNonIntegratedProduct(nextProduct) : saveNonIntegratedProduct(nextProduct); if ( @@ -11397,6 +12161,7 @@ export default function NonIntegratedDetailClient({ documentWorkspaceNotes, documentWorkspaceApprovedAt, documentWorkspaceConfirmations, + effectiveProductDevelopmentKind, existingProductCreatedAt, existingProduct?.copiedFromProductId, existingProduct?.copiedFromSpu, @@ -13588,11 +14353,17 @@ export default function NonIntegratedDetailClient({ try { startUploadProgress([file]); - const uploadedAsset = await uploadFileToServer( - file, - detachedUploadOptions, - createSingleUploadProgressHandler(file.name), - ); + const uploadedAsset = + mode === "create" + ? { + url: await readFileAsDataUrl(file), + fileName: file.name, + } + : await uploadFileToServer( + file, + detachedUploadOptions, + createSingleUploadProgressHandler(file.name), + ); setFormValuesAndRef((current) => ({ ...current, previewImage: uploadedAsset.url, @@ -15628,6 +16399,46 @@ export default function NonIntegratedDetailClient({ setSaveMessageType("success"); } + async function handleConfirmSeriesSkuFromDraftGroup() { + if (isReadOnly || !isSeriesDevelopment) return; + if (selectedSeriesDraftSkuOptions.length === 0) { + setSaveMessage("当前系列还没有草稿组SKU,先维护草稿组后再确认系列SKU。"); + setSaveMessageType("error"); + return; + } + + const formalSpu = assignFormalSpuForSku(); + if (!formalSpu) return; + + const nextSkuItems = buildSeriesSkuItemsFromDraftSkus( + selectedSeriesDraftSkuOptions, + ); + setSkuItems(nextSkuItems); + setSkuBatchCount(1); + setActiveSkuBatch(1); + setSkuBatchFlowNos({}); + setSkuBatchChangeTypes({}); + setPendingSkuBatchDraft(null); + + const nextSequence = nextSkuItems.length + 1; + const successMessage = `已确认${nextSkuItems.length}个系列SKU,后续新增将从 ${formalSpu}${String(nextSequence).padStart(2, "0")} 开始。`; + + if (mode === "edit") { + await persistEditedProduct({ + successMessage, + errorFallbackMessage: "系列SKU确认保存失败,请稍后再试", + overrides: { + skuItems: nextSkuItems, + skuBatchCount: 1, + }, + }); + return; + } + + setSaveMessage(successMessage); + setSaveMessageType("success"); + } + const displayedSkuItems = useMemo( () => skuItems.filter((item) => @@ -16622,7 +17433,7 @@ export default function NonIntegratedDetailClient({ const stockSpu = normalizedStockProductSpu; const draftProductName = isStockProductCreate - ? stockSpu + ? openingReference.productName.trim() || stockSpu : openingReference.productName.trim() || "待补充开品资料"; const draftTemporaryCode = isStockProductCreate @@ -16642,6 +17453,26 @@ export default function NonIntegratedDetailClient({ ), ]), ); + let createFormalSpu = isStockProductCreate + ? stockSpu + : formValues.spu.trim(); + + if (!isStockProductCreate && isSeriesDevelopment) { + const productCategory = + formValues.productCategory.trim() || + openingReference.productCategory.trim(); + const assignment = reserveSpuAssignmentForProductCategory({ + productCategory, + products: listNonIntegratedProducts(), + }); + + if (!assignment) { + throw new Error("当前商品类目还没有配置SPU规则,请先维护SPU规则库。"); + } + + createFormalSpu = assignment.spu; + } + const nextOpeningReference = normalizeNonIntegratedOpeningReference({ ...openingReference, productName: openingReference.productName.trim() || draftProductName, @@ -16655,8 +17486,8 @@ export default function NonIntegratedDetailClient({ const nextFormValues = buildOpeningReferenceFormValues( { ...formValues, - spu: isStockProductCreate ? stockSpu : formValues.spu, - productCode: isStockProductCreate ? stockSpu : formValues.productCode, + spu: createFormalSpu, + productCode: createFormalSpu || formValues.productCode, processTemplate: isStockProductCreate ? "存量商品" : formValues.processTemplate, @@ -16664,20 +17495,26 @@ export default function NonIntegratedDetailClient({ adaptableBrands: createAdaptableBrands, }, nextOpeningReference, - isStockProductCreate ? stockSpu : formValues.spu, + createFormalSpu, ); + const createSkuItems = + isSeriesDevelopment && selectedSeriesDraftSkuOptions.length > 0 + ? buildSeriesSkuItemsFromDraftSkus(selectedSeriesDraftSkuOptions) + : skuItems; const nextProductId = buildNonIntegratedProductId( nextFormValues.spu || nextOpeningReference.temporaryCode || nextOpeningReference.productName || draftProductName, ); - await compactStoredProducts(); + if (!shouldUseRemoteAssetStorage()) { + await compactStoredProducts(); + } const normalizedProduct = await normalizeProductAssetsForStorage( buildProductPayload( nextProductId, nextFormValues, - skuItems, + createSkuItems, styleDetails, bomDetails, reviewRounds, @@ -16704,6 +17541,7 @@ export default function NonIntegratedDetailClient({ undefined, currentUserNameNormalized || currentUserIdNormalized || undefined, currentUserNameNormalized || currentUserIdNormalized || undefined, + effectiveProductDevelopmentKind, ), { attachToExistingProduct: false }, ); @@ -16736,6 +17574,7 @@ export default function NonIntegratedDetailClient({ } } onProductSaved?.(savedProduct); + setIsSubmitting(false); if (embedded && onOpenProductWorkspace) { onOpenProductWorkspace(savedProduct, "edit"); return; @@ -16789,7 +17628,7 @@ export default function NonIntegratedDetailClient({ ? "创建中..." : isStockProductCreate ? "创建存量商品" - : "创建开品单"} + : developmentCreateButtonLabel} @@ -19539,8 +20378,8 @@ function renderFactoryQualityWorkspace() { }[] = [ { key: "productName", - label: "开品名称", - placeholder: "如:胡桃木色窄边书桌", + label: openingReferenceProductNameLabel, + placeholder: openingReferenceProductNamePlaceholder, }, { key: "temporaryCode", @@ -22056,8 +22895,8 @@ function renderFactoryQualityWorkspace() { : "mx-auto flex min-h-0 w-full max-w-[1600px] flex-1 flex-col overflow-hidden rounded-lg xl:flex-row"; const standaloneWorkspaceTabLabel = mode === "create" - ? "新增产品" - : `${currentProductDisplayName}产品开发明细`; + ? developmentCreateTitle + : `${currentProductDisplayName}${developmentDetailSuffix}`; const activeLinkEntryWorkspaceTab = linkEntryWorkspaceTabs.find( (tab) => tab.id === activeLinkEntryWorkspaceTabId, @@ -22134,7 +22973,9 @@ function renderFactoryQualityWorkspace() { 返回开品列表

- 上传图片并填写名称后,系统会自动生成开品明细链接并复制。 + {isSeriesDevelopment + ? "上传图片并填写系列名称后,系统会自动生成系列开发明细。" + : "上传图片并填写名称后,系统会自动生成开品明细链接并复制。"}

) : null} @@ -22222,8 +23063,12 @@ function renderFactoryQualityWorkspace() {
{mode === "create" ? (
@@ -22267,18 +23112,60 @@ function renderFactoryQualityWorkspace() { readOnly={isReadOnly} />
+ {isSeriesDevelopment ? ( + <> + + {basicInfoSecondaryCategoryOptions.map( + (option) => ( + + + {basicInfoProductCategoryOptions.map( + (option) => ( + + + ) : null} {isStockProductCreate ? ( - - setStockProductSpu(value.toUpperCase()) - } - /> + <> + + setStockProductSpu(value.toUpperCase()) + } + /> + {isSeriesDevelopment ? ( + 0 + ? "请选择系列名称" + : "暂无可选系列" + } + value={openingReference.productName} + readOnly={isReadOnly} + compact + onChange={(value) => + handleOpeningReferenceFieldChange( + "productName", + value, + ) + } + /> + ) : null} + ) : ( <> undefined} /> + {isSeriesDevelopment ? ( + <> + + updateField("salesCategory", value) + } + /> + + updateField("productCategory", value) + } + /> + undefined} + /> + + ) : null} )} {isStockProductCreate - ? "图片、SPU、品牌、外观设计师填写后点击创建,系统会直接生成存量商品明细。" - : "图片、名称、品牌、外观设计师和企划负责人填写后点击“创建开品单”,系统会自动把这张开品明细链接复制到剪贴板。"} + ? isSeriesDevelopment + ? "图片、SPU、系列名称、品牌、外观设计师填写后点击创建,系统会直接生成存量系列产品明细。" + : "图片、SPU、品牌、外观设计师填写后点击创建,系统会直接生成存量商品明细。" + : developmentCreatedMessage}

@@ -22358,7 +23293,7 @@ function renderFactoryQualityWorkspace() { ) : ( <> {canAbandonProduct ? ( @@ -22555,10 +23490,35 @@ function renderFactoryQualityWorkspace() { {mode !== "create" ? ( + + 草稿组SKU {selectedSeriesDraftSkuCount} 个 + + {isReadOnly ? null : ( + + )} +
+ ) : undefined + } >
- {SKU_TAB_OPTIONS.map((option) => ( + {SKU_TAB_OPTIONS.filter( + (option) => + isSeriesDevelopment || + option.key !== "componentSkuInfo", + ).map((option) => ( + ), + )} +
+
+
+
+ + + + {[ + "序号", + "图片", + "型号", + "规格码", + "条码", + "套件量", + "规格名称", + "尺寸", + "颜色", + "材质", + "版本号", + "属性代码", + "图纸ID", + "安装方式", + "执行标准", + "符合国标", + "供应商名称", + "供应商代码", + ].map((label) => ( + + ))} + + + + {componentSkuRows.length === 0 ? ( + + + + ) : displayedComponentSkuRows.length === + 0 ? ( + + + + ) : ( + componentSkuDisplayGroups.map( + (group, groupIndex) => { + const previousRowsCount = + componentSkuDisplayGroups + .slice(0, groupIndex) + .reduce( + (count, currentGroup) => + count + + currentGroup.rows.length, + 0, + ); + + return ( + + {activeComponentSkuCategory === + "all" && + componentSkuDisplayGroups.length > + 1 ? ( + + + + ) : null} + {group.rows.map( + (row, rowIndex) => { + const isSelectedComponentRow = + row.code === + effectiveSelectedSupplementComponentCode; + + return ( + + setSelectedSupplementComponentCode( + row.code, + ) + } + className={`cursor-pointer border-t border-apple-separator align-top transition hover:bg-apple-bg-secondary/50 ${ + isSelectedComponentRow + ? "bg-apple-blue/5" + : "" + }`} + > + + + {[ + row.code, + row.code, + "", + row.quantity, + row.name, + "", + "", + row.assetType || + row.categoryLabel, + "V01", + row.role, + row.code, + "", + "", + "", + "", + "", + ].map( + (value, valueIndex) => ( + + ), + )} + + ); + }, + )} + + ); + }, + ) + )} + +
+ {label} +
+ 当前还没有部件SKU。 +
+ 当前分类下还没有部件SKU。 +
+ {group.label}{" "} + {group.rows.length} 个 +
+ {previousRowsCount + + rowIndex + + 1} + +
+ 暂无图片 +
+
+
+ + {value || "-"} + +
+
+
+
+
+ ) : activeSkuTab === "skuInfo" ? (
{isReadOnly ? null : ( @@ -22927,6 +24073,26 @@ function renderFactoryQualityWorkspace() { getEditableInstallationMode( item.installMethod, ); + const seriesComponentDetails = + isSeriesDevelopment + ? getSeriesSkuComponentDetails(item) + : []; + const hideLegacySeriesComponentFields = + isSeriesDevelopment && + (hasLegacySeriesComponentCodeText( + item.attributeCode, + ) || + hasLegacySeriesComponentCodeText( + derivedItem.drawingId, + )); + const displayAttributeCode = + hideLegacySeriesComponentFields + ? "" + : item.attributeCode; + const displayDrawingId = + hideLegacySeriesComponentFields + ? "" + : derivedItem.drawingId; return (
@@ -23792,7 +24977,7 @@ function renderFactoryQualityWorkspace() { @@ -23801,7 +24986,11 @@ function renderFactoryQualityWorkspace() { @@ -23810,9 +24999,7 @@ function renderFactoryQualityWorkspace() { @@ -23961,7 +25148,7 @@ function renderFactoryQualityWorkspace() { ) => { const tableMinWidth = Math.max( tableColumns.length * 124 + - (isReadOnly ? 0 : 84), + (isSelectedBomDetailReadOnly ? 0 : 84), 720, ); @@ -23983,7 +25170,7 @@ function renderFactoryQualityWorkspace() { {column.label} ))} - {isReadOnly ? null : ( + {isSelectedBomDetailReadOnly ? null : ( 操作 @@ -24105,7 +25294,7 @@ function renderFactoryQualityWorkspace() { column.key as BomVisibleColumnKey, ); const canEditField = - !isReadOnly && + !isSelectedBomDetailReadOnly && !isComputedField && !column.readOnly && (!isSyntheticRow || @@ -24194,7 +25383,7 @@ function renderFactoryQualityWorkspace() { bomLibraryImagePreview } readOnly={ - isReadOnly || + isSelectedBomDetailReadOnly || isSyntheticRow } onUpload={( @@ -24247,7 +25436,7 @@ function renderFactoryQualityWorkspace() { syntheticSelectedItemId } readOnly={ - isReadOnly + isSelectedBomDetailReadOnly } options={ edgeBandingOptions @@ -24279,7 +25468,7 @@ function renderFactoryQualityWorkspace() { "搜索板材并点选" } readOnly={ - isReadOnly + isSelectedBomDetailReadOnly } options={ boardMaterialOptions @@ -24314,7 +25503,7 @@ function renderFactoryQualityWorkspace() { "搜索钢架并点选" } readOnly={ - isReadOnly + isSelectedBomDetailReadOnly } options={ steelFrameOptions @@ -24378,7 +25567,7 @@ function renderFactoryQualityWorkspace() { ); }, )} - {isReadOnly ? null : ( + {isSelectedBomDetailReadOnly ? null : ( {isSyntheticRow ? ( @@ -24424,7 +25613,7 @@ function renderFactoryQualityWorkspace() {

- {isReadOnly || + {isSelectedBomDetailReadOnly || category === "包材" ? null : (
- {isReadOnly ? null : ( + {isSelectedBomDetailReadOnly ? null : (