feat(intake): support series component sku details

This commit is contained in:
WuYongJie 2026-05-18 12:40:18 +08:00
parent 1f2d2af257
commit e5f10d6eda
20 changed files with 2162 additions and 250 deletions

View File

@ -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(

View File

@ -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";

View File

@ -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'

View File

@ -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 }>

View File

@ -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,

View File

@ -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,

View File

@ -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(

View File

@ -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,

View File

@ -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 = {

View File

@ -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'
}) => (
<div data-testid="embedded-non-integrated-detail">
{productId}-{mode}
<span data-testid="embedded-development-kind">
{productDevelopmentKind}
</span>
<span data-testid="embedded-workspace-state">
{(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',
seriesTemplateName: 'Nordic Desk',
code: 'DG-001',
},
{
id: 'draft-group-2',
name: 'Cloud Draft Group',
seriesTemplateName: '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}`,
seriesTemplateName: `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 () => {

View File

@ -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,29 @@ function getPendingEntryTabLabel(tab: IntakeEntryTab) {
return tab === "integrated" ? "系列产品开发明细" : "产品开发明细";
}
function getPendingEntryTabFromProductDevelopmentKind(
kind: ProductDevelopmentKind,
): IntakeEntryTab {
return kind === "series" ? "integrated" : "nonIntegrated";
}
function getProductDevelopmentKind(
product: Pick<NonIntegratedProductRecord, "productDevelopmentKind">,
): ProductDevelopmentKind {
return product.productDevelopmentKind === "series" ? "series" : "product";
}
function getProductDevelopmentDetailLabel(
product: NonIntegratedProductRecord,
) {
const suffix =
getProductDevelopmentKind(product) === "series"
? "系列产品开发明细"
: "产品开发明细";
return `${getNonIntegratedProductDisplayName(product)}${suffix}`;
}
function createPendingEntryWorkspaceTab(
entryTab: IntakeEntryTab,
id = DEFAULT_WORKSPACE_TAB_ID,
@ -602,11 +639,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 +666,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 +883,72 @@ function buildIntegratedSeriesRows(params: {
}));
}
function getDraftGroupSeriesNameOptions(props: IntakeClientProps) {
if (isEditorMode(props)) {
return getUniqueNonEmptyValues([
props.detail.draftGroup.seriesTemplateName,
props.detail.draftGroup.name,
...props.detail.preSkus.map((preSku) =>
readPreSkuTextField(preSku, "seriesName"),
),
]);
}
return getUniqueNonEmptyValues([
...props.eligibleDraftGroups.flatMap((draftGroup) => [
draftGroup.seriesTemplateName,
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") ||
props.detail.draftGroup.seriesTemplateName ||
props.detail.draftGroup.name,
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<IntakeSection, null>,
@ -1606,7 +1713,7 @@ function CopyProductDialog({
);
}
function IntegratedPendingDetail({
const _IntegratedPendingDetail = function IntegratedPendingDetail({
rows,
}: {
rows: IntegratedPendingSeriesRow[];
@ -1901,12 +2008,14 @@ function IntegratedPendingDetail({
) : null}
</div>
);
}
};
function NonIntegratedPendingDetail({
initialProducts,
canDeleteProducts,
currentUser,
productDevelopmentKind = "product",
emptyMessage = "当前还没有非集成产品,点击右上角“新增产品”开始维护资料。",
onProductsChange,
onOpenProductWorkspace,
onOpenNewNonIntegratedProductWorkspace,
@ -1914,6 +2023,8 @@ function NonIntegratedPendingDetail({
initialProducts: NonIntegratedProductRecord[];
canDeleteProducts: boolean;
currentUser: IntakeProductPermissionUser;
productDevelopmentKind?: ProductDevelopmentKind;
emptyMessage?: string;
onProductsChange?: (products: NonIntegratedProductRecord[]) => void;
onOpenProductWorkspace?: (
product: NonIntegratedProductRecord,
@ -1956,6 +2067,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 +2082,9 @@ function NonIntegratedPendingDetail({
}
function syncProducts() {
const nextProducts = listNonIntegratedProducts();
const nextProducts = listNonIntegratedProducts().filter(
(product) => getProductDevelopmentKind(product) === productDevelopmentKind,
);
setProducts(nextProducts);
}
@ -1971,7 +2092,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 +2113,9 @@ function NonIntegratedPendingDetail({
}
deleteNonIntegratedProduct(product.id);
const nextProducts = listNonIntegratedProducts();
const nextProducts = scopeProductsByDevelopmentKind(
listNonIntegratedProducts(),
);
setProducts(nextProducts);
onProductsChange?.(nextProducts);
} catch (error) {
@ -2028,7 +2151,9 @@ function NonIntegratedPendingDetail({
}
saveNonIntegratedProduct(restoredProduct);
const nextProducts = listNonIntegratedProducts();
const nextProducts = scopeProductsByDevelopmentKind(
listNonIntegratedProducts(),
);
setProducts(nextProducts);
onProductsChange?.(nextProducts);
} catch (error) {
@ -2092,7 +2217,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 +2322,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 (
<div className="flex h-full flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-primary shadow-apple-xs">
@ -2264,7 +2396,11 @@ function NonIntegratedPendingDetail({
<div className="min-h-0 flex-1 overflow-auto p-4">
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary">
<div className="overflow-x-auto overflow-y-hidden pb-3">
<table className="w-full min-w-[1880px] text-left text-xs">
<table
className={`w-full text-left text-xs ${
isSeriesDevelopmentList ? "min-w-[2020px]" : "min-w-[1880px]"
}`}
>
<thead className="bg-apple-bg-primary text-apple-label-secondary">
<tr className="border-b border-apple-separator">
<th className="min-w-[56px] px-3 py-2.5 font-apple-medium whitespace-nowrap">
@ -2273,6 +2409,11 @@ function NonIntegratedPendingDetail({
<th className="min-w-[96px] px-3 py-2.5 font-apple-medium whitespace-nowrap">
成品预览图
</th>
{isSeriesDevelopmentList ? (
<th className="min-w-[144px] px-3 py-2.5 font-apple-medium whitespace-nowrap">
系列名称
</th>
) : null}
<th className="min-w-[144px] px-3 py-2.5 font-apple-medium whitespace-nowrap">
<SpuLabel />
</th>
@ -2319,13 +2460,11 @@ function NonIntegratedPendingDetail({
{filteredProducts.length === 0 ? (
<tr>
<td
colSpan={
11 + NON_INTEGRATED_DOCUMENT_STATUS_COLUMNS.length
}
colSpan={listColumnCount}
className="px-4 py-10 text-center text-xs text-apple-label-secondary"
>
{products.length === 0
? "当前还没有非集成产品,点击右上角“新增产品”开始维护资料。"
? emptyMessage
: "当前查询条件下没有找到资料。"}
</td>
</tr>
@ -2353,6 +2492,8 @@ function NonIntegratedPendingDetail({
const productPreviewImage =
product.openingReference?.productImage?.trim() ||
product.previewImage;
const seriesDisplayName =
product.openingReference?.productName?.trim() || "-";
return (
<tr
@ -2389,6 +2530,11 @@ function NonIntegratedPendingDetail({
)}
</button>
</td>
{isSeriesDevelopmentList ? (
<td className="px-3 py-3 text-apple-label-primary whitespace-nowrap">
{seriesDisplayName}
</td>
) : null}
<td className="px-3 py-3 text-apple-label-primary whitespace-nowrap">
<button
type="button"
@ -7586,6 +7732,10 @@ export default function IntakeClient(props: IntakeClientProps) {
activeWorkspaceTab?.kind === "nonIntegratedProduct"
? activeWorkspaceTab.id
: null;
const seriesNameOptions = getDraftGroupSeriesNameOptions(props);
const seriesDraftSkuOptions = getDraftGroupSeriesSkuOptions(props);
const existingSeriesNames =
getExistingSeriesDevelopmentNames(nonIntegratedProducts);
const updateProductWorkspaceDetailState = useCallback(
(tabId: string, detailState: WorkspaceProductDetailState) => {
@ -7624,10 +7774,17 @@ export default function IntakeClient(props: IntakeClientProps) {
setNonIntegratedProducts(props.initialNonIntegratedProducts);
}, [props.initialNonIntegratedProducts]);
function handleNonIntegratedProductsChange(
function handleScopedProductsChange(
productDevelopmentKind: ProductDevelopmentKind,
nextProducts: NonIntegratedProductRecord[],
) {
setNonIntegratedProducts(nextProducts);
setNonIntegratedProducts((current) => [
...nextProducts,
...current.filter(
(product) =>
getProductDevelopmentKind(product) !== productDevelopmentKind,
),
]);
}
function handleNonIntegratedProductSaved(
@ -7650,8 +7807,9 @@ export default function IntakeClient(props: IntakeClientProps) {
tab.kind === "nonIntegratedProduct" && tab.productId === savedProduct.id
? {
...tab,
label: `${getNonIntegratedProductDisplayName(savedProduct)}产品开发明细`,
label: getProductDevelopmentDetailLabel(savedProduct),
product: savedProduct,
productDevelopmentKind: getProductDevelopmentKind(savedProduct),
}
: tab,
),
@ -7673,10 +7831,11 @@ export default function IntakeClient(props: IntakeClientProps) {
`${savedProduct.id}:${effectiveMode}`,
),
kind: "nonIntegratedProduct",
label: `${getNonIntegratedProductDisplayName(savedProduct)}产品开发明细`,
label: getProductDevelopmentDetailLabel(savedProduct),
productId: savedProduct.id,
product: savedProduct,
mode: effectiveMode,
productDevelopmentKind: getProductDevelopmentKind(savedProduct),
};
const candidateTabs = workspaceTabs.filter(
@ -7719,10 +7878,14 @@ export default function IntakeClient(props: IntakeClientProps) {
setActiveSection("patents");
} else if (tab.kind === "nonIntegratedProduct") {
setActiveSection("pending");
setPendingEntryTab("nonIntegrated");
setPendingEntryTab(
getPendingEntryTabFromProductDevelopmentKind(tab.productDevelopmentKind),
);
} else if (tab.kind === "newNonIntegratedProduct") {
setActiveSection("pending");
setPendingEntryTab("nonIntegrated");
setPendingEntryTab(
getPendingEntryTabFromProductDevelopmentKind(tab.productDevelopmentKind),
);
} else if (tab.kind === "bomLibrary") {
setActiveSection("bom");
setActiveBomLibrary(tab.bomLibrary);
@ -7802,6 +7965,7 @@ export default function IntakeClient(props: IntakeClientProps) {
product: NonIntegratedProductRecord,
mode: WorkspaceProductMode,
) {
const productDevelopmentKind = getProductDevelopmentKind(product);
const effectiveMode: WorkspaceProductMode =
mode === "edit" && !canOpenNonIntegratedProductForEdit(product, props.user)
? "view"
@ -7824,10 +7988,11 @@ export default function IntakeClient(props: IntakeClientProps) {
const nextTab: IntakeWorkspaceTab = {
id: tabId,
kind: "nonIntegratedProduct",
label: `${getNonIntegratedProductDisplayName(product)}产品开发明细`,
label: getProductDevelopmentDetailLabel(product),
productId: product.id,
product,
mode: effectiveMode,
productDevelopmentKind,
detailState: currentDocumentCategory
? { view: "document", category: currentDocumentCategory }
: undefined,
@ -7835,9 +8000,13 @@ export default function IntakeClient(props: IntakeClientProps) {
openWorkspaceTab(nextTab);
}
function openNewNonIntegratedProductWorkspace() {
function openNewNonIntegratedProductWorkspace(
productDevelopmentKind: ProductDevelopmentKind = "product",
) {
const existingTab = workspaceTabs.find(
(tab) => tab.kind === "newNonIntegratedProduct",
(tab) =>
tab.kind === "newNonIntegratedProduct" &&
tab.productDevelopmentKind === productDevelopmentKind,
);
if (existingTab) {
syncWorkspaceState(existingTab);
@ -7845,34 +8014,50 @@ export default function IntakeClient(props: IntakeClientProps) {
}
const nextTab = createNewNonIntegratedProductWorkspaceTab(
createWorkspaceTabId("new-non-integrated", "create"),
createWorkspaceTabId("new-non-integrated", productDevelopmentKind),
productDevelopmentKind,
);
openWorkspaceTab(nextTab);
}
function activateNonIntegratedListWorkspace() {
function activatePendingEntryListWorkspace(
productDevelopmentKind: ProductDevelopmentKind = "product",
) {
const entryTab = getPendingEntryTabFromProductDevelopmentKind(
productDevelopmentKind,
);
const existingTab = workspaceTabs.find(
(tab) => tab.kind === "pendingEntry" && tab.entryTab === "nonIntegrated",
(tab) => tab.kind === "pendingEntry" && tab.entryTab === entryTab,
);
if (existingTab) {
setWorkspaceTabs((current) =>
current.filter((tab) => tab.kind !== "newNonIntegratedProduct"),
current.filter(
(tab) =>
tab.kind !== "newNonIntegratedProduct" ||
tab.productDevelopmentKind !== productDevelopmentKind,
),
);
syncWorkspaceState(existingTab);
return;
}
const nextTab = createPendingEntryWorkspaceTab(
"nonIntegrated",
createWorkspaceTabId("pending-entry", "nonIntegrated"),
entryTab,
createWorkspaceTabId("pending-entry", entryTab),
);
const candidateTabs = workspaceTabs.filter(
(tab) => tab.kind !== "newNonIntegratedProduct",
(tab) =>
tab.kind !== "newNonIntegratedProduct" ||
tab.productDevelopmentKind !== productDevelopmentKind,
);
const { activeTab } = resolveWorkspaceTabsForOpen(candidateTabs, nextTab);
setWorkspaceTabs((current) =>
resolveWorkspaceTabsForOpen(
current.filter((tab) => tab.kind !== "newNonIntegratedProduct"),
current.filter(
(tab) =>
tab.kind !== "newNonIntegratedProduct" ||
tab.productDevelopmentKind !== productDevelopmentKind,
),
nextTab,
).tabs,
);
@ -7880,13 +8065,21 @@ export default function IntakeClient(props: IntakeClientProps) {
}
function closeNewNonIntegratedProductWorkspace(tabId: string) {
const closingTab = workspaceTabs.find((tab) => tab.id === tabId);
const productDevelopmentKind =
closingTab?.kind === "newNonIntegratedProduct"
? closingTab.productDevelopmentKind
: "product";
const entryTab = getPendingEntryTabFromProductDevelopmentKind(
productDevelopmentKind,
);
const listTab =
workspaceTabs.find(
(tab) =>
tab.kind === "pendingEntry" && tab.entryTab === "nonIntegrated",
) ?? createPendingEntryWorkspaceTab(
"nonIntegrated",
createWorkspaceTabId("pending-entry", "nonIntegrated"),
(tab) => tab.kind === "pendingEntry" && tab.entryTab === entryTab,
) ??
createPendingEntryWorkspaceTab(
entryTab,
createWorkspaceTabId("pending-entry", entryTab),
);
setWorkspaceTabs((current) => {
@ -7990,6 +8183,10 @@ export default function IntakeClient(props: IntakeClientProps) {
mode={activeWorkspaceTab.mode}
productId={activeWorkspaceTab.productId}
initialProduct={activeWorkspaceTab.product}
productDevelopmentKind={activeWorkspaceTab.productDevelopmentKind}
seriesNameOptions={seriesNameOptions}
existingSeriesNames={existingSeriesNames}
seriesDraftSkuOptions={seriesDraftSkuOptions}
initialDocumentWorkspaceView={activeWorkspaceTab.detailState?.view}
initialDocumentWorkspaceCategory={activeWorkspaceTab.detailState?.category}
currentUserRole={props.user.role}
@ -8005,7 +8202,11 @@ export default function IntakeClient(props: IntakeClientProps) {
onOpenRawMaterialProcessConfig={(materialName) =>
openRawMaterialLibraryWorkspace("processConfig", materialName)
}
onReturnToList={activateNonIntegratedListWorkspace}
onReturnToList={() =>
activatePendingEntryListWorkspace(
activeWorkspaceTab.productDevelopmentKind,
)
}
/>
);
} else if (activeWorkspaceTab?.kind === "spuRuleLibrary") {
@ -8053,6 +8254,10 @@ export default function IntakeClient(props: IntakeClientProps) {
embedded
key={activeWorkspaceTab.id}
mode="create"
productDevelopmentKind={activeWorkspaceTab.productDevelopmentKind}
seriesNameOptions={seriesNameOptions}
existingSeriesNames={existingSeriesNames}
seriesDraftSkuOptions={seriesDraftSkuOptions}
currentUserRole={props.user.role}
currentUserId={props.user.id}
currentUserName={props.user.name}
@ -8116,21 +8321,43 @@ export default function IntakeClient(props: IntakeClientProps) {
eligibleDraftGroups: props.eligibleDraftGroups,
eligibleSkuList: props.eligibleSkuList,
});
const productDevelopmentProducts = nonIntegratedProducts.filter(
(product) => getProductDevelopmentKind(product) === "product",
);
const seriesDevelopmentProducts = nonIntegratedProducts.filter(
(product) => getProductDevelopmentKind(product) === "series",
);
let leftContent: React.ReactNode = <EmptyDetail />;
if (activeSection === "pending") {
leftContent =
pendingEntryTab === "integrated" ? (
<IntegratedPendingDetail rows={skuList} />
) : (
<NonIntegratedPendingDetail
initialProducts={nonIntegratedProducts}
initialProducts={seriesDevelopmentProducts}
canDeleteProducts={canDeleteNonIntegratedProducts}
currentUser={props.user}
onProductsChange={handleNonIntegratedProductsChange}
productDevelopmentKind="series"
emptyMessage="当前还没有系列产品,点击右上角“新增产品”开始维护系列资料。"
onProductsChange={(nextProducts) =>
handleScopedProductsChange("series", nextProducts)
}
onOpenProductWorkspace={openProductWorkspace}
onOpenNewNonIntegratedProductWorkspace={
openNewNonIntegratedProductWorkspace
onOpenNewNonIntegratedProductWorkspace={() =>
openNewNonIntegratedProductWorkspace("series")
}
/>
) : (
<NonIntegratedPendingDetail
initialProducts={productDevelopmentProducts}
canDeleteProducts={canDeleteNonIntegratedProducts}
currentUser={props.user}
productDevelopmentKind="product"
onProductsChange={(nextProducts) =>
handleScopedProductsChange("product", nextProducts)
}
onOpenProductWorkspace={openProductWorkspace}
onOpenNewNonIntegratedProductWorkspace={() =>
openNewNonIntegratedProductWorkspace("product")
}
/>
);
@ -8218,20 +8445,42 @@ export default function IntakeClient(props: IntakeClientProps) {
}
const skuList = buildIntegratedSeriesRows({ detail: props.detail });
const productDevelopmentProducts = nonIntegratedProducts.filter(
(product) => getProductDevelopmentKind(product) === "product",
);
const seriesDevelopmentProducts = nonIntegratedProducts.filter(
(product) => getProductDevelopmentKind(product) === "series",
);
let leftContent: React.ReactNode = <EmptyDetail />;
if (activeSection === "pending") {
leftContent =
pendingEntryTab === "integrated" ? (
<IntegratedPendingDetail rows={skuList} />
) : (
<NonIntegratedPendingDetail
initialProducts={nonIntegratedProducts}
initialProducts={seriesDevelopmentProducts}
canDeleteProducts={canDeleteNonIntegratedProducts}
currentUser={props.user}
onProductsChange={handleNonIntegratedProductsChange}
productDevelopmentKind="series"
emptyMessage="当前还没有系列产品,点击右上角“新增产品”开始维护系列资料。"
onProductsChange={(nextProducts) =>
handleScopedProductsChange("series", nextProducts)
}
onOpenProductWorkspace={openProductWorkspace}
onOpenNewNonIntegratedProductWorkspace={
openNewNonIntegratedProductWorkspace
onOpenNewNonIntegratedProductWorkspace={() =>
openNewNonIntegratedProductWorkspace("series")
}
/>
) : (
<NonIntegratedPendingDetail
initialProducts={productDevelopmentProducts}
canDeleteProducts={canDeleteNonIntegratedProducts}
currentUser={props.user}
productDevelopmentKind="product"
onProductsChange={(nextProducts) =>
handleScopedProductsChange("product", nextProducts)
}
onOpenProductWorkspace={openProductWorkspace}
onOpenNewNonIntegratedProductWorkspace={() =>
openNewNonIntegratedProductWorkspace("product")
}
/>
);

View File

@ -506,6 +506,253 @@ describe("NonIntegratedDetailClient", () => {
);
});
it("renders series create copy and saves the series development kind", async () => {
const actor = userEvent.setup();
const { container } = render(
<NonIntegratedDetailClient
mode="create"
productDevelopmentKind="series"
seriesNameOptions={["单板桌系列"]}
currentUserRole="DESIGNER"
currentUserId="creator-user-id"
currentUserName="Creator"
/>,
);
expect(screen.getByRole("heading", { name: "新增系列产品" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "单板桌系列" })).toBeInTheDocument();
await actor.selectOptions(screen.getByLabelText("系列名称"), "单板桌系列");
await actor.type(screen.getByLabelText("二级类目"), "桌类");
await actor.type(screen.getByLabelText("商品类目"), "书桌");
await actor.type(screen.getByLabelText("外观设计师"), "外观A");
await actor.selectOptions(screen.getByLabelText("品牌"), "顾家");
await actor.type(screen.getByLabelText("企划负责人"), "企划A");
const imageFile = new File(["image"], "series.png", { type: "image/png" });
const fileInputs = container.querySelectorAll('input[type="file"]');
await actor.upload(fileInputs[0] as HTMLInputElement, imageFile);
await actor.click(screen.getByRole("button", { name: "创建系列产品" }));
await waitFor(() => expect(push).toHaveBeenCalledTimes(1));
const savedProducts = JSON.parse(
window.localStorage.getItem(NON_INTEGRATED_STORAGE_KEY) ?? "[]",
) as NonIntegratedProductRecord[];
expect(savedProducts[0]?.productDevelopmentKind).toBe("series");
expect(savedProducts[0]?.spu).toBe("SZ0241");
expect(savedProducts[0]?.openingReference.productName).toBe("单板桌系列");
expect(savedProducts[0]?.salesCategory).toBe("桌类");
expect(savedProducts[0]?.productCategory).toBe("书桌");
});
it("adds stock series draft skus when creating a stock series product", async () => {
const actor = userEvent.setup();
const { container } = render(
<NonIntegratedDetailClient
mode="create"
productDevelopmentKind="series"
seriesNameOptions={["单板桌系列"]}
seriesDraftSkuOptions={[
{
id: "pre-sku-01",
draftGroupId: "draft-group-1",
spuCode: "SZ0231",
code: "01",
name: "01 140*60 胡桃色 橡胶木台面 + 口字桌腿",
seriesName: "单板桌系列",
draftGroupName: "单板桌系列",
status: "待入库",
componentCodes: ["SPJ01", "SPJ07", "SPJ09", "SPJ10"],
skuFields: {
model: "DBT140RQ",
skuCode: "CJ025001",
barcode: "CJ025001V01",
setCount: "1",
specName: "140*60 胡桃色 橡胶木 + 黑色口字桌腿",
size: "140*60*75",
color: "胡桃色",
material: "橡胶木",
versionNo: "V01",
attributeCode: "A01",
drawingId: "DBT140RQA01V01",
installMethod: "拆装",
},
componentDetails: [
{
code: "SPJ01",
name: "140*60 胡桃色 橡胶木台面",
quantity: "1",
role: "台面",
assetType: "BOARD",
},
{
code: "SPJ07",
name: "70CM 黑色 口字桌腿",
quantity: "2",
role: "桌腿",
assetType: "COMPONENT",
},
],
},
{
id: "pre-sku-02",
draftGroupId: "draft-group-1",
spuCode: "SZ0231",
code: "02",
name: "02 140*60 胡桃色 橡胶木台面 + K字桌腿",
seriesName: "单板桌系列",
draftGroupName: "单板桌系列",
status: "待入库",
componentCodes: ["SPJ01", "SPJ08", "SPJ09", "SPJ10"],
},
]}
currentUserRole="DESIGNER"
currentUserId="creator-user-id"
currentUserName="Creator"
/>,
);
await actor.click(screen.getByRole("button", { name: "存量商品" }));
expect(screen.getByLabelText("SPU")).toBeInTheDocument();
expect(screen.getByLabelText("系列名称")).toBeInTheDocument();
await actor.type(screen.getByLabelText("SPU"), "CJ0250");
await actor.selectOptions(screen.getByLabelText("系列名称"), "单板桌系列");
await actor.type(screen.getByLabelText("外观设计师"), "外观A");
await actor.selectOptions(screen.getByLabelText("品牌"), "顾家");
const imageFile = new File(["image"], "stock-series.png", {
type: "image/png",
});
const fileInputs = container.querySelectorAll('input[type="file"]');
await actor.upload(fileInputs[0] as HTMLInputElement, imageFile);
await actor.click(screen.getByRole("button", { name: "创建存量商品" }));
await waitFor(() => expect(push).toHaveBeenCalledTimes(1));
const savedProducts = JSON.parse(
window.localStorage.getItem(NON_INTEGRATED_STORAGE_KEY) ?? "[]",
) as NonIntegratedProductRecord[];
expect(savedProducts[0]?.productDevelopmentKind).toBe("series");
expect(savedProducts[0]?.spu).toBe("CJ0250");
expect(savedProducts[0]?.openingReference.productName).toBe("单板桌系列");
expect(savedProducts[0]?.processTemplate).toBe("存量商品");
expect(savedProducts[0]?.skuItems).toHaveLength(2);
expect(savedProducts[0]?.skuItems.map((item) => item.skuCode)).toEqual([
"CJ025001",
"CJ025002",
]);
expect(savedProducts[0]?.skuItems[0]).toEqual(
expect.objectContaining({
model: "DBT140RQ",
barcode: "CJ025001V01",
setCount: "1",
specName: "140*60*75 胡桃色",
size: "140*60*75",
color: "胡桃色",
material: "橡胶木",
attributeCode: "A01",
drawingId: "DBT140RQA01V01",
installMethod: "拆装",
}),
);
expect(savedProducts[0]?.skuItems[0]?.componentDetails?.[0]).toEqual(
expect.objectContaining({
code: "SPJ01",
name: "140*60 胡桃色 橡胶木台面",
quantity: "1",
}),
);
});
it("shows series component details from the barcode magnifier without leaking them into attribute columns", async () => {
const actor = userEvent.setup();
const legacyComponentCodes = "SPJ01/SPJ07/SPJ09/SPJ10";
saveNonIntegratedProduct(
createProductRecord({
id: "SZ0231-series-components",
spu: "SZ0231",
productDevelopmentKind: "series",
openingReference: {
...createEmptyNonIntegratedOpeningReference(),
productName: "单板桌系列",
},
skuItems: [
createSkuItem({
id: "series-pre-sku-01",
skuSequence: 1,
model: "01",
skuCode: "SZ023101",
barcode: "SZ023101V01",
attributeCode: legacyComponentCodes,
drawingId: `01${legacyComponentCodes}V01`,
componentDetails: [
{
code: "SPJ01",
name: "140*60 胡桃色 橡胶木台面",
quantity: "1",
role: "台面",
assetType: "BOARD",
},
{
code: "SPJ07",
name: "70CM 黑色 口字桌腿",
quantity: "2",
role: "桌腿",
assetType: "COMPONENT",
},
],
}),
],
}),
);
render(
<NonIntegratedDetailClient
mode="edit"
productId="SZ0231-series-components"
/>,
);
expect(screen.queryByDisplayValue(legacyComponentCodes)).not.toBeInTheDocument();
expect(
screen.queryByDisplayValue(`01${legacyComponentCodes}V01`),
).not.toBeInTheDocument();
await actor.click(
screen.getByRole("button", { name: "查看01部件明细" }),
);
const dialog = screen.getByRole("dialog", { name: "部件明细" });
expect(within(dialog).getByText("SPJ01")).toBeInTheDocument();
expect(
within(dialog).getByText("140*60 胡桃色 橡胶木台面"),
).toBeInTheDocument();
expect(within(dialog).getByText("SPJ07")).toBeInTheDocument();
expect(within(dialog).getAllByText("2").length).toBeGreaterThan(0);
});
it("hides already created series names from the create selector", () => {
render(
<NonIntegratedDetailClient
mode="create"
productDevelopmentKind="series"
seriesNameOptions={["单板桌系列", "餐桌系列"]}
existingSeriesNames={["单板桌系列"]}
/>,
);
expect(
screen.queryByRole("option", { name: "单板桌系列" }),
).not.toBeInTheDocument();
expect(screen.getByRole("option", { name: "餐桌系列" })).toBeInTheDocument();
});
it("uses detached upload before the product exists when creating remotely", async () => {
(globalThis as NonIntegratedRemoteAssetStorageTestGlobal)
.__NON_INTEGRATED_REMOTE_ASSET_STORAGE_TEST_OVERRIDE__ = true;

View File

@ -1,6 +1,6 @@
import { requireSession } from '@/lib/server-session'
import { redirect } from 'next/navigation'
import { intakeNonIntegratedProductService } from '@/server/services'
import { intakeNonIntegratedProductService } from '@/server/services/intake-non-integrated-product.service'
import { canEditNonIntegratedProductIdentity } from '@/lib/non-integrated-permissions'
import NonIntegratedDetailClient from '../NonIntegratedDetailClient'

View File

@ -9,6 +9,7 @@ type ApiEnvelope<T> = {
}
const NON_INTEGRATED_UPLOAD_CHUNK_BYTES = 512 * 1024
const NON_INTEGRATED_API_TIMEOUT_MS = 60_000
export type NonIntegratedUploadProgress = {
loadedBytes: number
@ -70,6 +71,40 @@ function createUploadId() {
return `upload-${Date.now()}-${Math.random().toString(36).slice(2)}`
}
function createRequestTimeoutError() {
return new Error('请求超时,请稍后重试。')
}
async function fetchWithTimeout(
input: RequestInfo | URL,
init?: RequestInit,
) {
if (typeof AbortController === 'undefined') {
return fetch(input, init)
}
const controller = new AbortController()
const timeoutId = globalThis.setTimeout(
() => controller.abort(),
NON_INTEGRATED_API_TIMEOUT_MS,
)
try {
return await fetch(input, {
...init,
signal: init?.signal ?? controller.signal,
})
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw createRequestTimeoutError()
}
throw error
} finally {
globalThis.clearTimeout(timeoutId)
}
}
function normalizeUploadPercent(value: number) {
if (!Number.isFinite(value)) return 0
@ -97,7 +132,7 @@ async function postFormData(
onProgress?: NonIntegratedUploadProgressHandler,
) {
if (typeof XMLHttpRequest === 'undefined') {
const response = await fetch(url, {
const response = await fetchWithTimeout(url, {
method: 'POST',
body: formData,
})
@ -120,6 +155,7 @@ async function postFormData(
}>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', url)
xhr.timeout = NON_INTEGRATED_API_TIMEOUT_MS
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable || !onProgress) return
@ -143,12 +179,13 @@ async function postFormData(
}
xhr.onerror = () => reject(new Error('文件上传失败,请稍后再试。'))
xhr.onabort = () => reject(new Error('文件上传已取消。'))
xhr.ontimeout = () => reject(createRequestTimeoutError())
xhr.send(formData)
})
}
export async function listSharedNonIntegratedProducts() {
const response = await fetch('/api/intake/non-integrated', {
const response = await fetchWithTimeout('/api/intake/non-integrated', {
method: 'GET',
cache: 'no-store',
})
@ -158,7 +195,7 @@ export async function listSharedNonIntegratedProducts() {
}
export async function getSharedNonIntegratedProductById(id: string) {
const response = await fetch(`/api/intake/non-integrated/${encodeURIComponent(id)}`, {
const response = await fetchWithTimeout(`/api/intake/non-integrated/${encodeURIComponent(id)}`, {
method: 'GET',
cache: 'no-store',
})
@ -168,7 +205,7 @@ export async function getSharedNonIntegratedProductById(id: string) {
}
export async function saveSharedNonIntegratedProduct(product: NonIntegratedProductRecord) {
const response = await fetch(`/api/intake/non-integrated/${encodeURIComponent(product.id)}`, {
const response = await fetchWithTimeout(`/api/intake/non-integrated/${encodeURIComponent(product.id)}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@ -181,7 +218,7 @@ export async function saveSharedNonIntegratedProduct(product: NonIntegratedProdu
}
export async function deleteSharedNonIntegratedProduct(id: string) {
const response = await fetch(`/api/intake/non-integrated/${encodeURIComponent(id)}`, {
const response = await fetchWithTimeout(`/api/intake/non-integrated/${encodeURIComponent(id)}`, {
method: 'DELETE',
})
@ -381,7 +418,7 @@ export async function previewSharedNonIntegratedPresentation(params: {
fileId?: string
fileUrl?: string
}) {
const response = await fetch(
const response = await fetchWithTimeout(
`/api/intake/non-integrated/${encodeURIComponent(params.productId)}/presentation-preview`,
{
method: 'POST',
@ -400,7 +437,7 @@ export async function previewSharedNonIntegratedDocumentImages(params: {
fileId?: string
fileUrl?: string
}) {
const response = await fetch(
const response = await fetchWithTimeout(
`/api/intake/non-integrated/${encodeURIComponent(params.productId)}/document-preview`,
{
method: 'POST',

View File

@ -309,6 +309,15 @@ export type NonIntegratedSkuItem = {
model3dDataUrl: string;
model3dNodeOffsets?: Record<string, [number, number, number]>;
model3dNodeRotations?: Record<string, [number, number, number, number]>;
componentDetails?: NonIntegratedSkuComponentDetail[];
};
export type NonIntegratedSkuComponentDetail = {
code: string;
name: string;
quantity: string;
role: string;
assetType: string;
};
export type NonIntegratedFactoryRow = {
@ -510,6 +519,7 @@ export type NonIntegratedQualityInspectionSheet = {
export type NonIntegratedProductRecord = {
id: string;
productDevelopmentKind?: "product" | "series";
spu: string;
projectStatus: NonIntegratedProjectStatus;
projectReview: NonIntegratedProjectReview;
@ -1040,6 +1050,47 @@ function normalizeSkuBatchChangeTypes(value: unknown): Record<string, string> {
);
}
function normalizeSkuComponentQuantity(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
const normalizedValue = normalizeString(value).trim();
return normalizedValue || "1";
}
function normalizeSkuComponentDetail(
value: unknown,
): NonIntegratedSkuComponentDetail | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const item = value as Partial<NonIntegratedSkuComponentDetail>;
const code = normalizeString(item.code).trim();
const name = normalizeString(item.name).trim();
if (!code && !name) return null;
return {
code,
name,
quantity: normalizeSkuComponentQuantity(item.quantity),
role: normalizeString(item.role).trim(),
assetType: normalizeString(item.assetType).trim(),
};
}
export function normalizeNonIntegratedSkuComponentDetails(
value: unknown,
): NonIntegratedSkuComponentDetail[] {
if (!Array.isArray(value)) return [];
return value
.map((item) => normalizeSkuComponentDetail(item))
.filter((item): item is NonIntegratedSkuComponentDetail => item !== null);
}
function normalizeSkuItem(
item: Partial<NonIntegratedSkuItem>,
): NonIntegratedSkuItem | null {
@ -1090,6 +1141,9 @@ function normalizeSkuItem(
),
model3dNodeOffsets: normalizeTupleMap(item.model3dNodeOffsets, 3),
model3dNodeRotations: normalizeTupleMap(item.model3dNodeRotations, 4),
componentDetails: normalizeNonIntegratedSkuComponentDetails(
item.componentDetails,
),
};
}
@ -1745,6 +1799,8 @@ function toProductRecord(
return {
id: item.id,
productDevelopmentKind:
item.productDevelopmentKind === "series" ? "series" : "product",
spu: item.spu,
projectStatus: normalizeNonIntegratedProjectStatus(item.projectStatus),
projectReview: normalizeNonIntegratedProjectReview(item.projectReview),

View File

@ -1,14 +1,13 @@
import { requireSession } from '@/lib/server-session'
import {
assetCenterService,
categoryService,
draftGroupService,
intakeService,
intakeNonIntegratedProductService,
materialService,
warehouseService,
} from '@/server/services'
import { assetCenterService } from '@/server/services/asset-center.service'
import { categoryService } from '@/server/services/category.service'
import { draftGroupService } from '@/server/services/draft-group.service'
import { intakeService } from '@/server/services/intake.service'
import { intakeNonIntegratedProductService } from '@/server/services/intake-non-integrated-product.service'
import { materialService } from '@/server/services/material.service'
import { warehouseService } from '@/server/services/warehouse.service'
import IntakeClient from './IntakeClient'
import { readPreSkuComponentDetails, readPreSkuSkuFields } from './seriesComponentDetails'
function readPreSkuTextField(
preSku: {
@ -102,12 +101,15 @@ export default async function IntakePage({
const eligibleSkuList = eligibleDraftGroupDetails.flatMap((detail) =>
detail.preSkus.map((preSku, index) => ({
id: preSku.id,
draftGroupId: detail.draftGroup.id,
spuCode:
readPreSkuTextField(preSku, 'spuCode') ||
readPreSkuTextField(preSku, 'spu') ||
detail.draftGroup.code ||
`SPU-${String(index + 1).padStart(3, '0')}`,
code:
readPreSkuTextField(preSku, 'draftSkuCode') ||
readPreSkuTextField(preSku, 'skuSequenceCode') ||
readPreSkuTextField(preSku, 'skuCode') ||
preSku.customName ||
preSku.defaultName,
@ -119,6 +121,8 @@ export default async function IntakePage({
draftGroupName: detail.draftGroup.name,
status: mapWorkbenchStatus(preSku.status),
componentCodes: preSku.componentCodes ?? [],
componentDetails: readPreSkuComponentDetails(preSku),
skuFields: readPreSkuSkuFields(preSku),
})),
)

View File

@ -0,0 +1,93 @@
import {
normalizeNonIntegratedSkuComponentDetails,
type NonIntegratedSkuComponentDetail,
} from "./non-integrated/nonIntegratedStorage";
export type SeriesDraftSkuFields = {
model: string;
skuCode: string;
barcode: string;
setCount: string;
specName: string;
size: string;
color: string;
material: string;
versionNo: string;
attributeCode: string;
drawingId: string;
installMethod: string;
};
type PreSkuComponentDetailSource = {
displaySummary?: Record<string, unknown> | null;
familySelection?: Record<string, unknown> | null;
bomPreview?: unknown[] | null;
componentCodes?: string[] | null;
};
function buildComponentDetailsFromCodes(
componentCodes: string[] | null | undefined,
): NonIntegratedSkuComponentDetail[] {
return (componentCodes ?? [])
.map((code) => code.trim())
.filter(Boolean)
.map((code) => ({
code,
name: "",
quantity: "1",
role: "",
assetType: "",
}));
}
export function readPreSkuComponentDetails(
preSku: PreSkuComponentDetailSource,
): NonIntegratedSkuComponentDetail[] {
const fromDisplaySummary = normalizeNonIntegratedSkuComponentDetails(
preSku.displaySummary?.componentDetails,
);
if (fromDisplaySummary.length > 0) return fromDisplaySummary;
const fromBomPreview = normalizeNonIntegratedSkuComponentDetails(
preSku.bomPreview,
);
if (fromBomPreview.length > 0) return fromBomPreview;
return buildComponentDetailsFromCodes(preSku.componentCodes);
}
function readPreSkuStringField(
preSku: PreSkuComponentDetailSource,
key: string,
) {
const summaryValue = preSku.displaySummary?.[key];
if (typeof summaryValue === "string" && summaryValue.trim().length > 0) {
return summaryValue.trim();
}
const familyValue = preSku.familySelection?.[key];
if (typeof familyValue === "string" && familyValue.trim().length > 0) {
return familyValue.trim();
}
return "";
}
export function readPreSkuSkuFields(
preSku: PreSkuComponentDetailSource,
): SeriesDraftSkuFields {
return {
model: readPreSkuStringField(preSku, "model"),
skuCode: readPreSkuStringField(preSku, "formalSkuCode"),
barcode: readPreSkuStringField(preSku, "barcode"),
setCount: readPreSkuStringField(preSku, "setCount"),
specName: readPreSkuStringField(preSku, "specName"),
size: readPreSkuStringField(preSku, "size"),
color: readPreSkuStringField(preSku, "color"),
material: readPreSkuStringField(preSku, "material"),
versionNo: readPreSkuStringField(preSku, "versionNo"),
attributeCode: readPreSkuStringField(preSku, "attributeCode"),
drawingId: readPreSkuStringField(preSku, "drawingId"),
installMethod: readPreSkuStringField(preSku, "installMethod"),
};
}

View File

@ -20,6 +20,19 @@ const baseProduct = {
}
describe('non-integrated intake permissions', () => {
it('allows admins to create and maintain intake records without business roles', () => {
const localAdmin = {
id: 'admin-id',
name: 'LocalAdmin',
role: 'SUPER_ADMIN',
businessRoles: [],
}
expect(canCreateNonIntegratedProductFromIntake(localAdmin)).toBe(true)
expect(canEditAnyNonIntegratedWorkspace(baseProduct, localAdmin)).toBe(true)
expect(canEditNonIntegratedWorkspace(baseProduct, localAdmin, '产品核价')).toBe(true)
})
it('allows product R&D owners to maintain designer-owned workspaces only', () => {
const user = {
id: 'designer-id',

View File

@ -35,7 +35,11 @@ export type IntakeNonIntegratedWorkspaceCategory =
| null
| undefined
const FULL_DETAIL_EDIT_ROLES = new Set(['ADMIN', 'SUPER_ADMIN', 'SUPERVISOR'])
const ADMIN_FULL_ACCESS_ROLES = new Set(['ADMIN', 'SUPER_ADMIN'])
const FULL_DETAIL_EDIT_ROLES = new Set([
...ADMIN_FULL_ACCESS_ROLES,
'SUPERVISOR',
])
const DELETE_ROLES = new Set(['ADMIN', 'SUPER_ADMIN'])
const OPENING_REFERENCE_WORKSPACE_CATEGORY = '开品参考'
@ -130,6 +134,10 @@ function hasInternalContentBusinessRole(user: IntakeNonIntegratedPermissionUser)
export function isIntakeNonIntegratedFullEditor(
user: IntakeNonIntegratedPermissionUser,
) {
if (user.role && ADMIN_FULL_ACCESS_ROLES.has(user.role)) {
return true
}
return Boolean(
user.role &&
FULL_DETAIL_EDIT_ROLES.has(user.role) &&