merge: 同步 xuxiake 正式入库系列明细
This commit is contained in:
commit
2a4dbadbdc
@ -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(
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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 }>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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',
|
||||
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 () => {
|
||||
|
||||
@ -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<NonIntegratedProductRecord, "productDevelopmentKind">,
|
||||
): 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<IntakeSection, null>,
|
||||
@ -1606,7 +1725,7 @@ function CopyProductDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function IntegratedPendingDetail({
|
||||
const _IntegratedPendingDetail = function IntegratedPendingDetail({
|
||||
rows,
|
||||
}: {
|
||||
rows: IntegratedPendingSeriesRow[];
|
||||
@ -1901,12 +2020,14 @@ function IntegratedPendingDetail({
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<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 +2408,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 +2421,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 +2472,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 +2504,8 @@ function NonIntegratedPendingDetail({
|
||||
const productPreviewImage =
|
||||
product.openingReference?.productImage?.trim() ||
|
||||
product.previewImage;
|
||||
const seriesDisplayName =
|
||||
product.openingReference?.productName?.trim() || "-";
|
||||
|
||||
return (
|
||||
<tr
|
||||
@ -2389,6 +2542,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 +7744,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 +7786,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 +7819,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 +7843,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 +7890,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 +7977,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 +8000,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 +8012,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 +8026,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 +8077,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 +8195,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 +8214,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 +8266,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 +8333,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 +8457,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")
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -506,6 +506,255 @@ 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="USER"
|
||||
currentUserBusinessRoles={["DESIGN"]}
|
||||
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="USER"
|
||||
currentUserBusinessRoles={["DESIGN"]}
|
||||
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;
|
||||
@ -530,7 +779,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
expect(mockedUploadDetachedNonIntegratedFile).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
expect.objectContaining({ name: "remote-preview.png" }),
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
expect(mockedSaveSharedNonIntegratedProduct.mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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'
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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),
|
||||
})),
|
||||
)
|
||||
|
||||
|
||||
93
furniture-3d-system/src/app/intake/seriesComponentDetails.ts
Normal file
93
furniture-3d-system/src/app/intake/seriesComponentDetails.ts
Normal 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"),
|
||||
};
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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) &&
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user