diff --git a/furniture-3d-system/.dockerignore b/furniture-3d-system/.dockerignore index 1da91c56..1502ae8b 100644 --- a/furniture-3d-system/.dockerignore +++ b/furniture-3d-system/.dockerignore @@ -75,7 +75,6 @@ dist # Gatsby文件 .cache/ -public # Vuepress构建输出 .vuepress/dist @@ -225,4 +224,4 @@ PROJECT_SETUP_COMPLETE.md 产品安装展示系统-*.md 开发部署计划.md 项目*.md -架构*.md \ No newline at end of file +架构*.md diff --git a/furniture-3d-system/Dockerfile b/furniture-3d-system/Dockerfile index f1088d30..b0f9e17a 100644 --- a/furniture-3d-system/Dockerfile +++ b/furniture-3d-system/Dockerfile @@ -7,10 +7,9 @@ # ============================================ # 阶段 1: 基础镜像 # ============================================ -FROM node:18-alpine AS base +FROM node:24-alpine AS base -# 使用阿里云镜像源加速(中国网络优化) -RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories +# 保留官方 Alpine 源,避免镜像站同步滞后导致 apk 包 404。 # ============================================ # 阶段 2: 依赖安装 (deps) @@ -71,6 +70,11 @@ FROM base AS builder ARG DATABASE_URL="postgresql://user:password@localhost:5432/placeholder_db?schema=public" ARG NEXTAUTH_URL="" ARG NEXT_PUBLIC_APP_URL="" +ARG S3_ENDPOINT="http://localhost:9000" +ARG S3_PUBLIC_DOMAIN="http://localhost:9000/furniture-3d-models" +ARG S3_BUCKET="furniture-3d-models" +ARG S3_ACCESS_KEY="build-only" +ARG S3_SECRET_KEY="build-only" # Prisma 引擎缓存配置 ARG PRISMA_ENGINE_CACHE_FILE_NAME="libquery_engine-linux-musl-openssl-3.0.x.so.node" @@ -82,6 +86,12 @@ ENV PRISMA_SCHEMA_ENGINE_CACHE_FILE_NAME=${PRISMA_SCHEMA_ENGINE_CACHE_FILE_NAME} ENV DATABASE_URL=${DATABASE_URL} ENV NEXTAUTH_URL=${NEXTAUTH_URL} ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL} +ENV S3_ENDPOINT=${S3_ENDPOINT} +ENV S3_PUBLIC_DOMAIN=${S3_PUBLIC_DOMAIN} +ENV S3_BUCKET=${S3_BUCKET} +ENV S3_ACCESS_KEY=${S3_ACCESS_KEY} +ENV S3_SECRET_KEY=${S3_SECRET_KEY} +ENV PUPPETEER_SKIP_DOWNLOAD=true ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_ENV=production ENV PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1 @@ -237,4 +247,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD curl -f http://localhost:3000/api/health || exit 1 # 启动应用 -CMD ["node", "server.js"] \ No newline at end of file +CMD ["node", "server.js"] diff --git a/furniture-3d-system/local-packages/npm-cache/node_modules/.gitkeep b/furniture-3d-system/local-packages/npm-cache/node_modules/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/furniture-3d-system/local-packages/npm-cache/node_modules/.gitkeep @@ -0,0 +1 @@ + diff --git a/furniture-3d-system/local-packages/prisma-engines/.gitkeep b/furniture-3d-system/local-packages/prisma-engines/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/furniture-3d-system/local-packages/prisma-engines/.gitkeep @@ -0,0 +1 @@ + diff --git a/furniture-3d-system/package-lock.json b/furniture-3d-system/package-lock.json index 4a591dc8..61a3ff44 100644 --- a/furniture-3d-system/package-lock.json +++ b/furniture-3d-system/package-lock.json @@ -34,6 +34,7 @@ "jszip": "^3.10.1", "lucide-react": "^0.563.0", "mssql": "^12.5.0", + "mysql2": "^3.15.3", "nanoid": "^5.1.6", "next": "^16.1.6", "obj2gltf": "^3.2.0", @@ -3016,7 +3017,6 @@ "resolved": "https://registry.npmmirror.com/@img/colour/-/colour-1.0.0.tgz", "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -7521,7 +7521,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6.0.0" @@ -9031,7 +9030,6 @@ "version": "2.1.2", "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -10698,7 +10696,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", - "dev": true, "license": "MIT", "dependencies": { "is-property": "^1.0.2" @@ -11710,7 +11707,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", - "dev": true, "license": "MIT" }, "node_modules/is-regex": { @@ -13844,7 +13840,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz", "integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==", - "dev": true, "license": "MIT", "engines": { "bun": ">=1.0.0", @@ -14170,7 +14165,6 @@ "version": "3.15.3", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", - "dev": true, "license": "MIT", "dependencies": { "aws-ssl-profiles": "^1.1.1", @@ -14191,7 +14185,6 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -14208,7 +14201,6 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", - "dev": true, "license": "MIT", "dependencies": { "lru.min": "^1.1.0" @@ -16156,8 +16148,7 @@ "node_modules/seq-queue": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", - "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", - "dev": true + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" }, "node_modules/set-function-length": { "version": "1.2.2", @@ -16220,7 +16211,6 @@ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", @@ -16264,7 +16254,6 @@ "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, @@ -16486,7 +16475,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" diff --git a/furniture-3d-system/package.json b/furniture-3d-system/package.json index ec311144..f5d5dd9e 100644 --- a/furniture-3d-system/package.json +++ b/furniture-3d-system/package.json @@ -25,7 +25,8 @@ "demo:refresh": "npm run prisma:push && npm run demo:update", "thumbnails:backfill": "tsx scripts/backfill-asset-variant-thumbnails.ts", "materials:sync-textures": "tsx scripts/sync-material-textures.ts", - "materials:sync-standard": "tsx scripts/sync-standard-finishes.ts" + "materials:sync-standard": "tsx scripts/sync-standard-finishes.ts", + "sync:jst-sku-stock": "tsx scripts/sync-jst-sku-stock-products.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.948.0", @@ -54,6 +55,7 @@ "jszip": "^3.10.1", "lucide-react": "^0.563.0", "mssql": "^12.5.0", + "mysql2": "^3.15.3", "nanoid": "^5.1.6", "next": "^16.1.6", "obj2gltf": "^3.2.0", diff --git a/furniture-3d-system/scripts/sync-jst-sku-stock-products.ts b/furniture-3d-system/scripts/sync-jst-sku-stock-products.ts new file mode 100644 index 00000000..dcb4e085 --- /dev/null +++ b/furniture-3d-system/scripts/sync-jst-sku-stock-products.ts @@ -0,0 +1,107 @@ +import 'dotenv/config' +import { prisma } from '../src/lib/prisma' +import { syncJstSkuStockProducts } from '../src/server/services/jst-sku-stock-sync.service' + +type CliOptions = { + apply: boolean + batchSize?: number + limit?: number + help: boolean +} + +function readNumberArg(value: string | undefined, name: string) { + if (!value) return undefined + + const parsed = Number(value) + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive number`) + } + + return Math.floor(parsed) +} + +function parseArgs(argv: string[]): CliOptions { + const options: CliOptions = { + apply: false, + help: false, + } + + argv.forEach((arg) => { + if (arg === '--apply') { + options.apply = true + return + } + + if (arg === '--dry-run') { + options.apply = false + return + } + + if (arg === '--help' || arg === '-h') { + options.help = true + return + } + + if (arg.startsWith('--batch-size=')) { + options.batchSize = readNumberArg(arg.split('=')[1], '--batch-size') + return + } + + if (arg.startsWith('--limit=')) { + options.limit = readNumberArg(arg.split('=')[1], '--limit') + return + } + + throw new Error(`Unknown argument: ${arg}`) + }) + + return options +} + +function printHelp() { + console.log(` +Usage: + npm run sync:jst-sku-stock -- [--dry-run] [--apply] [--limit=100] [--batch-size=500] + +Default mode is --dry-run. Use --apply only when you want to write changes. + +Required environment variables: + DATABASE_URL + JST_SKU_DB_HOST + JST_SKU_DB_PORT + JST_SKU_DB_NAME + JST_SKU_DB_USER + JST_SKU_DB_PASSWORD + +Optional environment variables: + JST_SKU_DB_CHARSET=utf8mb4 + JST_SKU_DB_SSL=false + JST_SKU_SYNC_USER_ID=jst_sku_sync + +Supported alternative: + JST_SKU_DATABASE_URL=mysql://user:password@host:port/database +`.trim()) +} + +async function main() { + const options = parseArgs(process.argv.slice(2)) + if (options.help) { + printHelp() + return + } + + const result = await syncJstSkuStockProducts({ + apply: options.apply, + batchSize: options.batchSize, + limit: options.limit, + }) + + console.log(JSON.stringify(result, null, 2)) +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +}).finally(async () => { + await prisma.$disconnect() +}) diff --git a/furniture-3d-system/src/app/api/asset-center/series/[id]/route.ts b/furniture-3d-system/src/app/api/asset-center/series/[id]/route.ts index 5ac75266..abc3a46a 100644 --- a/furniture-3d-system/src/app/api/asset-center/series/[id]/route.ts +++ b/furniture-3d-system/src/app/api/asset-center/series/[id]/route.ts @@ -39,4 +39,3 @@ export async function PUT( { requireAuth: true, requireInternalContentWrite: true }, ) } - diff --git a/furniture-3d-system/src/app/api/intake/jst-directory-options/route.ts b/furniture-3d-system/src/app/api/intake/jst-directory-options/route.ts new file mode 100644 index 00000000..e8e5a362 --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/jst-directory-options/route.ts @@ -0,0 +1,21 @@ +import { NextRequest } from 'next/server' +import { handleRequest, jsonSuccess } from '@/server/core/response' +import { jstPlanningDirectoryService } from '@/server/services/jst-planning-directory.service' + +export const dynamic = 'force-dynamic' + +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + try { + const options = await jstPlanningDirectoryService.listOptions() + return jsonSuccess(options, 200, ctx.requestId) + } catch (error) { + console.error('Failed to load JST planning directory options', error) + return jsonSuccess({ suppliers: [], warehouses: [] }, 200, ctx.requestId) + } + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/route.ts index 4e721fec..21641e68 100644 --- a/furniture-3d-system/src/app/api/intake/non-integrated/route.ts +++ b/furniture-3d-system/src/app/api/intake/non-integrated/route.ts @@ -1,15 +1,62 @@ 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/intake-non-integrated-product.service' +import { + intakeNonIntegratedProductService, + type IntakeNonIntegratedProductListParams, +} from '@/server/services/intake-non-integrated-product.service' + +function readListParams(request: NextRequest): IntakeNonIntegratedProductListParams { + const searchParams = request.nextUrl.searchParams + const productDevelopmentKind = searchParams.get('productDevelopmentKind') + + return { + page: Number(searchParams.get('page') ?? '1'), + pageSize: Number(searchParams.get('pageSize') ?? '50'), + productDevelopmentKind: + productDevelopmentKind === 'series' + ? 'series' + : productDevelopmentKind === 'product' + ? 'product' + : undefined, + productKind: + searchParams.get('productKind') === 'stock' + ? 'stock' + : searchParams.get('productKind') === 'new' + ? 'new' + : 'all', + quickScope: + searchParams.get('quickScope') === 'created' + ? 'created' + : searchParams.get('quickScope') === 'pending' + ? 'pending' + : 'all', + spu: searchParams.get('spu') ?? '', + appearanceDesigner: searchParams.get('appearanceDesigner') ?? '', + structureDesigner: searchParams.get('structureDesigner') ?? '', + spuOwner: searchParams.get('spuOwner') ?? '', + planningOwner: searchParams.get('planningOwner') ?? '', + spaceDesigner: searchParams.get('spaceDesigner') ?? '', + purchaseOwner: searchParams.get('purchaseOwner') ?? '', + stockSku: searchParams.get('stockSku') ?? '', + stockCategory: searchParams.get('stockCategory') ?? '', + stockItemType: searchParams.get('stockItemType') ?? '', + stockSalesStatus: searchParams.get('stockSalesStatus') ?? '', + stockEnabledStatus: searchParams.get('stockEnabledStatus') ?? '', + stockDataScope: searchParams.get('stockDataScope') ?? '', + } +} export async function GET(request: NextRequest) { return handleRequest( request, async (ctx) => { try { - const products = await intakeNonIntegratedProductService.list(ctx.user) - return jsonSuccess({ products }, 200, ctx.requestId) + const result = await intakeNonIntegratedProductService.listPage( + ctx.user, + readListParams(request), + ) + return jsonSuccess(result, 200, ctx.requestId) } catch (error) { if (error instanceof AppError) { return jsonError(error.code, error.message, error.statusCode, ctx.requestId) diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/stock-category-options/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/stock-category-options/route.ts new file mode 100644 index 00000000..61ff7bd9 --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/non-integrated/stock-category-options/route.ts @@ -0,0 +1,16 @@ +import { NextRequest } from 'next/server' +import { handleRequest, jsonSuccess } from '@/server/core/response' +import { intakeNonIntegratedProductService } from '@/server/services/intake-non-integrated-product.service' + +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const categories = + await intakeNonIntegratedProductService.listStockCategoryOptions(ctx.user) + + return jsonSuccess({ categories }, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx index ed67cdca..1b3be89b 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx @@ -473,6 +473,7 @@ describe('IntakeClient', () => { beforeEach(() => { jest.useRealTimers() window.localStorage.clear() + window.sessionStorage.clear() jest.restoreAllMocks() mockAoaToSheet.mockClear() mockBookNew.mockClear() @@ -1724,7 +1725,10 @@ describe('IntakeClient', () => { const actor = userEvent.setup() renderSelector({ eligibleSkuList: [] }) - await actor.click(screen.getByRole('button', { name: '系列产品开发明细' })) + const rightPanel = screen.getByTestId('right-panel') + await actor.click( + within(rightPanel).getByRole('button', { name: '系列产品开发明细' }), + ) await actor.click(screen.getByRole('button', { name: '新增产品' })) expect(screen.getByRole('tab', { name: '新增系列产品' })).toHaveAttribute( @@ -2310,6 +2314,43 @@ describe('IntakeClient', () => { ) }) + it('restores opened workspace tabs after leaving and returning to intake', async () => { + const actor = userEvent.setup() + const product = createNonIntegratedProduct() + + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([product]), + ) + + const firstRender = renderSelector({ + eligibleSkuList: [], + initialNonIntegratedProducts: [product], + }) + + await actor.click(screen.getByRole('button', { name: '修改' })) + + expect(screen.getByRole('tab', { name: /DNZ0001/ })).toHaveAttribute( + 'aria-selected', + 'true', + ) + + firstRender.unmount() + + renderSelector({ + eligibleSkuList: [], + initialNonIntegratedProducts: [product], + }) + + expect(screen.getByRole('tab', { name: /DNZ0001/ })).toHaveAttribute( + 'aria-selected', + 'true', + ) + expect(screen.getByTestId('embedded-non-integrated-detail')).toHaveTextContent( + 'DNZ0001-1-edit', + ) + }) + it('opens a separate workspace tab each time product edit is clicked', async () => { const actor = userEvent.setup() @@ -2361,6 +2402,10 @@ describe('IntakeClient', () => { await actor.click(screen.getAllByRole('tab')[0]) } + const beforeCopyTabs = screen.getAllByRole('tab') + expect( + beforeCopyTabs.filter((tab) => tab.textContent?.startsWith('SZ0230')), + ).toHaveLength(2) const listRow = screen.getByText('SZ0230').closest('tr') expect(listRow).not.toBeNull() @@ -2377,8 +2422,8 @@ describe('IntakeClient', () => { }) const tabs = screen.getAllByRole('tab') - expect(tabs).toHaveLength(5) - expect(tabs.filter((tab) => tab.textContent?.startsWith('SZ0230'))).toHaveLength(3) + expect(tabs).toHaveLength(4) + expect(tabs.filter((tab) => tab.textContent?.startsWith('SZ0230'))).toHaveLength(2) expect(tabs.some((tab) => tab.textContent?.includes(expectedSpu))).toBe(true) expect(push).not.toHaveBeenCalled() }) @@ -2803,6 +2848,44 @@ describe('IntakeClient', () => { ).toBe(true) }) + it('only offers adaptable brands that do not already have copied brand data', async () => { + const actor = userEvent.setup() + const [sourceBrand, existingBrand, availableBrand] = Object.keys( + NON_INTEGRATED_BRAND_PREFIX_MAP, + ) + + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + createNonIntegratedProduct({ + id: 'SZ0230-source', + spu: 'SZ0230', + brand: sourceBrand, + adaptableBrands: [sourceBrand, existingBrand, availableBrand], + }), + createNonIntegratedProduct({ + id: 'GBGJ-SZ0230-copy', + spu: `${NON_INTEGRATED_BRAND_PREFIX_MAP[existingBrand]}SZ0230`, + brand: existingBrand, + adaptableBrands: [existingBrand], + copiedFromProductId: 'SZ0230-source', + copiedFromSpu: 'SZ0230', + }), + ]), + ) + + renderSelector({ eligibleSkuList: [] }) + + const sourceRow = screen.getByText('SZ0230').closest('tr') + expect(sourceRow).not.toBeNull() + + await actor.click(within(sourceRow as HTMLElement).getByRole('button', { name: '复制' })) + + expect(screen.getByLabelText('目标品牌')).toHaveValue(availableBrand) + expect(screen.queryByRole('option', { name: existingBrand })).not.toBeInTheDocument() + expect(screen.getByRole('option', { name: availableBrand })).toBeInTheDocument() + }) + it('shows owner fields and filters non-integrated products by query button', async () => { const actor = userEvent.setup() diff --git a/furniture-3d-system/src/app/intake/IntakeClient.tsx b/furniture-3d-system/src/app/intake/IntakeClient.tsx index 03fb5739..802ebf35 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.tsx @@ -55,13 +55,13 @@ import { hasPendingDocumentWorkspaceConfirmation, listNonIntegratedProducts, NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY, - NON_INTEGRATED_BRAND_PREFIX_MAP, NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES, NON_INTEGRATED_WORKFLOW_NODE_CATEGORIES, NON_INTEGRATED_PRODUCTS_UPDATED, getNonIntegratedDocumentWorkspaceCategoryLabel, normalizeNonIntegratedPatentApplication, saveNonIntegratedProduct, + stripNonIntegratedBrandPrefix, type NonIntegratedDocumentWorkspaceCategory, type NonIntegratedProductRecord, type NonIntegratedSkuComponentDetail, @@ -75,7 +75,11 @@ import { } from "./seriesComponentDetails"; import { deleteSharedNonIntegratedProduct, + getSharedNonIntegratedProductById, + listSharedNonIntegratedProductPage, + listSharedNonIntegratedStockCategoryOptions, saveSharedNonIntegratedProduct, + type NonIntegratedProductPageInfo, } from "./non-integrated/nonIntegratedApi"; import { canEditAnyNonIntegratedWorkspace, @@ -226,10 +230,11 @@ type IntakeSection = | "referenceEstimator" | "patents" | null; -type IntakeEntryTab = "integrated" | "nonIntegrated"; +type IntakeEntryTab = "integrated" | "nonIntegrated" | "stock"; type ProductDevelopmentKind = NonNullable< NonIntegratedProductRecord["productDevelopmentKind"] >; +type NewNonIntegratedCreateKind = "new" | "stock"; type BomLibraryKey = | "materialLibrary" | "edgeBandingLibrary" @@ -313,6 +318,7 @@ type IntakeWorkspaceTab = kind: "newNonIntegratedProduct"; label: string; productDevelopmentKind: ProductDevelopmentKind; + initialCreateProductKind?: NewNonIntegratedCreateKind; } | { id: string; @@ -333,7 +339,72 @@ type IntakeWorkspaceTab = label: string; }; -const MAX_WORKSPACE_TABS_PER_PAGE = 3; +const MAX_WORKSPACE_TABS_PER_PAGE = 2; +const INTAKE_WORKSPACE_STATE_STORAGE_KEY = "intake:workspace-state:v2"; + +type PersistedIntakeWorkspaceTab = + | { + id: string; + kind: "pendingEntry"; + entryTab: IntakeEntryTab; + } + | { + id: string; + kind: "progressDashboard"; + } + | { + id: string; + kind: "spuRuleLibrary"; + } + | { + id: string; + kind: "productPatentLedger"; + target?: ProductPatentLedgerTarget; + } + | { + id: string; + kind: "productPatentDashboard"; + } + | { + id: string; + kind: "nonIntegratedProduct"; + productId: string; + mode: WorkspaceProductMode; + productDevelopmentKind: ProductDevelopmentKind; + detailState?: WorkspaceProductDetailState; + } + | { + id: string; + kind: "newNonIntegratedProduct"; + productDevelopmentKind: ProductDevelopmentKind; + initialCreateProductKind?: NewNonIntegratedCreateKind; + } + | { + id: string; + kind: "bomLibrary"; + bomLibrary: BomLibraryKey; + } + | { + id: string; + kind: "rawMaterialLibrary"; + rawMaterialLibrary: RawMaterialLibraryKey; + selectedMaterialName?: string; + } + | { + id: string; + kind: "referenceEstimator"; + }; + +type PersistedIntakeWorkspaceState = { + activeTabId: string | null; + tabs: PersistedIntakeWorkspaceTab[]; +}; + +type InitialIntakeWorkspaceState = { + activeTabId: string | null; + sequence: number; + tabs: IntakeWorkspaceTab[]; +}; type WorkbenchSku = { id: string; @@ -519,7 +590,7 @@ function isProductCreatedByCurrentUser( ); } -const PENDING_PAGE_SIZE = 25; +const PENDING_PAGE_SIZE = 50; const TABLE_ACTION_BUTTON_BASE = "!h-8 !min-h-8 !w-8 !rounded-md !px-0 !py-0 !shadow-none"; const TABLE_ACTION_BUTTON_SECONDARY = `${TABLE_ACTION_BUTTON_BASE} !border-zinc-200 !bg-white !text-zinc-500 hover:!border-zinc-300 hover:!bg-zinc-50 hover:!text-zinc-950 focus:!border-zinc-400 disabled:!border-zinc-100 disabled:!bg-zinc-50 disabled:!text-zinc-300`; @@ -527,6 +598,132 @@ const TABLE_ACTION_BUTTON_PRIMARY = `${TABLE_ACTION_BUTTON_BASE} !border-zinc-95 const INTAKE_PRIMARY_BUTTON_CLASS = "!border-zinc-950 !bg-zinc-950 !bg-none !text-white !shadow-none hover:!border-zinc-800 hover:!bg-zinc-800 focus:!border-zinc-600 disabled:!border-zinc-200 disabled:!bg-zinc-200 disabled:!text-zinc-400"; type NonIntegratedQuickScope = "all" | "created" | "pending"; +type NonIntegratedProductKindFilter = "all" | "new" | "stock"; +type NonIntegratedListQuery = { + productKind: NonIntegratedProductKindFilter; + spu: string; + appearanceDesigner: string; + structureDesigner: string; + spuOwner: string; + planningOwner: string; + spaceDesigner: string; + purchaseOwner: string; + stockSku: string; + stockCategory: string; + stockItemType: string; + stockSalesStatus: string; + stockEnabledStatus: string; + stockDataScope: string; +}; + +type PersistedNonIntegratedListState = { + queryDraft?: Partial; + appliedQuery?: Partial; + currentPage?: number; + activeQuickScope?: NonIntegratedQuickScope; +}; + +const NON_INTEGRATED_LIST_STATE_STORAGE_PREFIX = + "non-integrated-list-state-v2"; + +function createDefaultNonIntegratedListQuery( + productKind: NonIntegratedProductKindFilter, +): NonIntegratedListQuery { + return { + productKind, + spu: "", + appearanceDesigner: "", + structureDesigner: "", + spuOwner: "", + planningOwner: "", + spaceDesigner: "", + purchaseOwner: "", + stockSku: "", + stockCategory: "", + stockItemType: "all", + stockSalesStatus: "all", + stockEnabledStatus: "all", + stockDataScope: "all", + }; +} + +function buildNonIntegratedListStateStorageKey( + productDevelopmentKind: ProductDevelopmentKind, + fixedProductKind?: NonIntegratedProductKindFilter, +) { + return [ + NON_INTEGRATED_LIST_STATE_STORAGE_PREFIX, + productDevelopmentKind, + fixedProductKind ?? "all", + ].join(":"); +} + +function normalizeNonIntegratedListQuery( + value: Partial | undefined, + fallbackProductKind: NonIntegratedProductKindFilter, +) { + const defaultQuery = createDefaultNonIntegratedListQuery(fallbackProductKind); + if (!value || typeof value !== "object") return defaultQuery; + + return Object.fromEntries( + Object.entries(defaultQuery).map(([key, fallbackValue]) => { + const nextValue = value[key as keyof NonIntegratedListQuery]; + + return [ + key, + typeof nextValue === "string" ? nextValue : fallbackValue, + ]; + }), + ) as NonIntegratedListQuery; +} + +function normalizePersistedQuickScope(value: unknown): NonIntegratedQuickScope { + return value === "created" || value === "pending" ? value : "all"; +} + +function normalizePersistedPage(value: unknown) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return 1; + + return Math.floor(parsed); +} + +function readPersistedNonIntegratedListState( + key: string, +): PersistedNonIntegratedListState | null { + if (typeof window === "undefined") return null; + + try { + const rawValue = window.localStorage.getItem(key); + if (!rawValue) return null; + const parsed = JSON.parse(rawValue); + + return parsed && typeof parsed === "object" + ? (parsed as PersistedNonIntegratedListState) + : null; + } catch { + return null; + } +} + +function writePersistedNonIntegratedListState( + key: string, + state: PersistedNonIntegratedListState, +) { + if (typeof window === "undefined") return; + + try { + window.localStorage.setItem(key, JSON.stringify(state)); + } catch { + // Ignore storage failures; the filters still work for the current session. + } +} + +function getUniqueSortedStrings(values: Array) { + return Array.from( + new Set(values.map((value) => value?.trim() ?? "").filter(Boolean)), + ).sort((left, right) => left.localeCompare(right, "zh-Hans-CN")); +} const BOM_LIBRARY_OPTIONS: { key: BomLibraryKey; @@ -599,7 +796,9 @@ const REFERENCE_ESTIMATOR_GROUP_LABEL = "预估成本测算"; const REFERENCE_ESTIMATOR_WORKSPACE_LABEL = "参考图预估成本"; function getPendingEntryTabLabel(tab: IntakeEntryTab) { - return tab === "integrated" ? "系列产品开发明细" : "产品开发明细"; + if (tab === "integrated") return "系列产品开发明细"; + if (tab === "stock") return "存量商品明细"; + return "产品开发明细"; } function getPendingEntryTabFromProductDevelopmentKind( @@ -614,12 +813,171 @@ function getProductDevelopmentKind( return product.productDevelopmentKind === "series" ? "series" : "product"; } +function readOptionalRecordString(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +function readOptionalNestedString( + value: unknown, + key: string, +) { + if (!value || typeof value !== "object" || Array.isArray(value)) return ""; + return readOptionalRecordString((value as Record)[key]); +} + +function readStockSourceString( + product: Record, + key: string, +) { + return readOptionalNestedString(product.metadata, key); +} + +function readFirstSkuString( + product: Pick, + key: keyof NonIntegratedProductRecord["skuItems"][number], +) { + const firstSku = product.skuItems[0]; + return firstSku ? readOptionalRecordString(firstSku[key]) : ""; +} + +function matchesOptionalText(value: string, keyword: string) { + const normalizedKeyword = keyword.trim().toUpperCase(); + if (!normalizedKeyword) return true; + + return value.trim().toUpperCase().includes(normalizedKeyword); +} + +function matchesExactFilter(value: string, filterValue: string) { + const normalizedFilter = filterValue.trim(); + if (!normalizedFilter || normalizedFilter === "all") return true; + if (normalizedFilter === "__empty") return value.trim().length === 0; + + return value.trim() === normalizedFilter; +} + +function isNormalJstSkuSpu(product: NonIntegratedProductRecord) { + const sourceItemType = readStockSourceString(product, "sourceItemType"); + const sourceCategory = readStockSourceString(product, "sourceCategory"); + + return ( + sourceItemType === "成品" && + sourceCategory.length > 0 && + !sourceCategory.startsWith("其他 -") + ); +} + +function isNormalOnSaleJstSku(product: NonIntegratedProductRecord) { + return ( + isNormalJstSkuSpu(product) && + readStockSourceString(product, "sourceSalesStatus") === "销售" && + readStockSourceString(product, "sourceEnabled") === "1" + ); +} + +function stockSourceMatchesQuery( + product: NonIntegratedProductRecord, + query: NonIntegratedListQuery, +) { + const sourceSku = readStockSourceString(product, "sourceSkuId"); + const sourceSpu = readStockSourceString(product, "sourceSpu"); + const sourceCategory = readStockSourceString(product, "sourceCategory"); + const sourceItemType = readStockSourceString(product, "sourceItemType"); + const sourceSalesStatus = readStockSourceString(product, "sourceSalesStatus"); + const sourceEnabled = readStockSourceString(product, "sourceEnabled"); + const firstSkuBarcode = readFirstSkuString(product, "barcode"); + const stockSkuMatched = + matchesOptionalText(sourceSku, query.stockSku) || + matchesOptionalText(firstSkuBarcode, query.stockSku); + const stockCategoryMatched = matchesOptionalText( + sourceCategory, + query.stockCategory, + ); + const stockItemTypeMatched = matchesExactFilter( + sourceItemType, + query.stockItemType, + ); + const stockSalesStatusMatched = matchesExactFilter( + sourceSalesStatus, + query.stockSalesStatus, + ); + const stockEnabledMatched = matchesExactFilter( + sourceEnabled, + query.stockEnabledStatus, + ); + const stockDataScopeMatched = + query.stockDataScope === "normalSpu" + ? isNormalJstSkuSpu(product) + : query.stockDataScope === "normalOnSale" + ? isNormalOnSaleJstSku(product) + : true; + + return ( + (matchesOptionalText(sourceSpu, query.spu) || + matchesOptionalText(product.spu, query.spu)) && + stockSkuMatched && + stockCategoryMatched && + stockItemTypeMatched && + stockSalesStatusMatched && + stockEnabledMatched && + stockDataScopeMatched + ); +} + +function isJstSkuSourceMarker(value: string) { + const normalizedValue = value.trim().toLowerCase().replace(/-/g, "_"); + return normalizedValue === "jst_sku" || normalizedValue === "jstsku"; +} + +function isJstSkuImportedStockProduct( + product: Pick< + NonIntegratedProductRecord, + | "id" + | "formalSpuAssigned" + | "openingReference" + | "productDevelopmentKind" + | "spu" + > & + Record, +) { + if (getProductDevelopmentKind(product) !== "product") return false; + if (product.id.trim().startsWith("jst-stock-")) return true; + + const metadata = product.metadata; + const sourceCandidates = [ + readOptionalRecordString(product.source), + readOptionalRecordString(product.sourceTable), + readOptionalRecordString(product.importSource), + readOptionalRecordString(product.externalSource), + readOptionalNestedString(metadata, "source"), + readOptionalNestedString(metadata, "sourceTable"), + readOptionalNestedString(metadata, "importSource"), + readOptionalNestedString(metadata, "externalSource"), + ]; + + return sourceCandidates.some(isJstSkuSourceMarker); +} + +function getPendingEntryTabFromProduct(product: NonIntegratedProductRecord) { + if (getProductDevelopmentKind(product) === "series") return "integrated"; + return isJstSkuImportedStockProduct(product) ? "stock" : "nonIntegrated"; +} + +function getPendingEntryTabFromNewProductTab(tab: { + productDevelopmentKind: ProductDevelopmentKind; + initialCreateProductKind?: NewNonIntegratedCreateKind; +}) { + if (tab.productDevelopmentKind === "series") return "integrated"; + return tab.initialCreateProductKind === "stock" ? "stock" : "nonIntegrated"; +} + function getProductDevelopmentDetailLabel( product: NonIntegratedProductRecord, ) { const suffix = getProductDevelopmentKind(product) === "series" ? "系列产品开发明细" + : isJstSkuImportedStockProduct(product) + ? "存量商品明细" : "产品开发明细"; return `${getNonIntegratedProductDisplayName(product)}${suffix}`; @@ -653,15 +1011,328 @@ function createPendingEntryWorkspaceTab( function createNewNonIntegratedProductWorkspaceTab( id = "new-non-integrated:default", productDevelopmentKind: ProductDevelopmentKind = "product", + initialCreateProductKind: NewNonIntegratedCreateKind = "new", ): IntakeWorkspaceTab { + const isStockCreate = initialCreateProductKind === "stock"; return { id, kind: "newNonIntegratedProduct", - label: productDevelopmentKind === "series" ? "新增系列产品" : "新增产品", + label: + productDevelopmentKind === "series" + ? isStockCreate + ? "新增存量系列产品" + : "新增系列产品" + : isStockCreate + ? "新增存量商品" + : "新增产品", productDevelopmentKind, + initialCreateProductKind, }; } +function getIntakeWorkspaceStateStorageKey(userId: string) { + return `${INTAKE_WORKSPACE_STATE_STORAGE_KEY}:${userId || "anonymous"}`; +} + +function createDefaultIntakeWorkspaceState(): InitialIntakeWorkspaceState { + return { + tabs: [createPendingEntryWorkspaceTab("nonIntegrated")], + activeTabId: DEFAULT_WORKSPACE_TAB_ID, + sequence: 0, + }; +} + +function readWorkspaceTabSequence(tabId: string) { + const maybeSequence = Number(tabId.split(":").at(-1)); + return Number.isFinite(maybeSequence) ? maybeSequence : 0; +} + +function getMaxWorkspaceTabSequence(tabs: IntakeWorkspaceTab[]) { + return tabs.reduce( + (maxSequence, tab) => + Math.max(maxSequence, readWorkspaceTabSequence(tab.id)), + 0, + ); +} + +function limitWorkspaceTabsPerPage(tabs: IntakeWorkspaceTab[]) { + const tabKeys = tabs.map((tab) => getWorkspaceTabPageKey(tab)); + + return tabs.filter((_, tabIndex) => { + const samePageTabIndexes = tabKeys + .map((tabKey, currentIndex) => + tabKey === tabKeys[tabIndex] ? currentIndex : -1, + ) + .filter((currentIndex) => currentIndex >= 0); + const keptTabIndexes = samePageTabIndexes.slice( + -MAX_WORKSPACE_TABS_PER_PAGE, + ); + + return keptTabIndexes.includes(tabIndex); + }); +} + +function normalizeWorkspaceProductDetailState( + value: unknown, +): WorkspaceProductDetailState | undefined { + if (!value || typeof value !== "object") return undefined; + + const record = value as Partial; + if ( + (record.view === "detail" || record.view === "document") && + record.category && + NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES.includes(record.category) + ) { + return { + view: record.view, + category: record.category, + }; + } + + return undefined; +} + +function serializeIntakeWorkspaceTab( + tab: IntakeWorkspaceTab, +): PersistedIntakeWorkspaceTab { + if (tab.kind === "pendingEntry") { + return { id: tab.id, kind: tab.kind, entryTab: tab.entryTab }; + } + if (tab.kind === "nonIntegratedProduct") { + return { + id: tab.id, + kind: tab.kind, + productId: tab.productId, + mode: tab.mode, + productDevelopmentKind: tab.productDevelopmentKind, + detailState: tab.detailState, + }; + } + if (tab.kind === "newNonIntegratedProduct") { + return { + id: tab.id, + kind: tab.kind, + productDevelopmentKind: tab.productDevelopmentKind, + initialCreateProductKind: tab.initialCreateProductKind, + }; + } + if (tab.kind === "bomLibrary") { + return { id: tab.id, kind: tab.kind, bomLibrary: tab.bomLibrary }; + } + if (tab.kind === "rawMaterialLibrary") { + return { + id: tab.id, + kind: tab.kind, + rawMaterialLibrary: tab.rawMaterialLibrary, + selectedMaterialName: tab.selectedMaterialName, + }; + } + if (tab.kind === "productPatentLedger") { + return { id: tab.id, kind: tab.kind, target: tab.target }; + } + + return { id: tab.id, kind: tab.kind }; +} + +function restoreIntakeWorkspaceTab( + tab: PersistedIntakeWorkspaceTab, + productMap: Map, +): IntakeWorkspaceTab | null { + if (!tab.id || typeof tab.id !== "string") return null; + + if (tab.kind === "pendingEntry") { + if ( + tab.entryTab !== "integrated" && + tab.entryTab !== "nonIntegrated" && + tab.entryTab !== "stock" + ) { + return null; + } + return createPendingEntryWorkspaceTab(tab.entryTab, tab.id); + } + if (tab.kind === "progressDashboard") { + return { id: tab.id, kind: tab.kind, label: "产品开发进度" }; + } + if (tab.kind === "spuRuleLibrary") { + return { id: tab.id, kind: tab.kind, label: "SPU规则库" }; + } + if (tab.kind === "productPatentLedger") { + return { + id: tab.id, + kind: tab.kind, + label: PRODUCT_PATENT_LEDGER_LABEL, + target: tab.target, + }; + } + if (tab.kind === "productPatentDashboard") { + return { + id: tab.id, + kind: tab.kind, + label: PRODUCT_PATENT_DASHBOARD_LABEL, + }; + } + if (tab.kind === "nonIntegratedProduct") { + const product = productMap.get(tab.productId); + if (!product) return null; + const productDevelopmentKind = getProductDevelopmentKind(product); + return { + id: tab.id, + kind: tab.kind, + label: getProductDevelopmentDetailLabel(product), + productId: product.id, + product, + mode: tab.mode === "view" ? "view" : "edit", + productDevelopmentKind, + detailState: normalizeWorkspaceProductDetailState(tab.detailState), + }; + } + if (tab.kind === "newNonIntegratedProduct") { + const productDevelopmentKind = + tab.productDevelopmentKind === "series" ? "series" : "product"; + const initialCreateProductKind = + tab.initialCreateProductKind === "stock" ? "stock" : "new"; + return createNewNonIntegratedProductWorkspaceTab( + tab.id, + productDevelopmentKind, + initialCreateProductKind, + ); + } + if (tab.kind === "bomLibrary") { + const option = BOM_LIBRARY_OPTIONS.find( + (item) => item.key === tab.bomLibrary, + ); + if (!option) return null; + return { + id: tab.id, + kind: tab.kind, + label: option.label, + bomLibrary: option.key, + }; + } + if (tab.kind === "rawMaterialLibrary") { + const option = RAW_MATERIAL_LIBRARY_OPTIONS.find( + (item) => item.key === tab.rawMaterialLibrary, + ); + if (!option) return null; + return { + id: tab.id, + kind: tab.kind, + label: option.label, + rawMaterialLibrary: option.key, + selectedMaterialName: tab.selectedMaterialName, + }; + } + if (tab.kind === "referenceEstimator") { + return { + id: tab.id, + kind: tab.kind, + label: REFERENCE_ESTIMATOR_WORKSPACE_LABEL, + }; + } + + return null; +} + +function readInitialIntakeWorkspaceState(params: { + products: NonIntegratedProductRecord[]; + userId: string; + shouldRestore: boolean; +}): InitialIntakeWorkspaceState { + if (!params.shouldRestore || typeof window === "undefined") { + return createDefaultIntakeWorkspaceState(); + } + + try { + const rawState = window.sessionStorage.getItem( + getIntakeWorkspaceStateStorageKey(params.userId), + ); + if (!rawState) return createDefaultIntakeWorkspaceState(); + + const parsed = JSON.parse(rawState) as Partial; + if (!Array.isArray(parsed.tabs)) { + return createDefaultIntakeWorkspaceState(); + } + + const productMap = new Map( + params.products.map((product) => [product.id, product]), + ); + const restoredTabs = parsed.tabs + .map((tab) => + restoreIntakeWorkspaceTab( + tab as PersistedIntakeWorkspaceTab, + productMap, + ), + ) + .filter((tab): tab is IntakeWorkspaceTab => Boolean(tab)); + const tabs = limitWorkspaceTabsPerPage(restoredTabs); + + if (tabs.length === 0) return createDefaultIntakeWorkspaceState(); + + const activeTabId = tabs.some((tab) => tab.id === parsed.activeTabId) + ? parsed.activeTabId ?? tabs[0]?.id ?? null + : tabs[0]?.id ?? null; + + return { + tabs, + activeTabId, + sequence: getMaxWorkspaceTabSequence(tabs), + }; + } catch { + return createDefaultIntakeWorkspaceState(); + } +} + +function persistIntakeWorkspaceState(params: { + activeTabId: string | null; + shouldPersist: boolean; + tabs: IntakeWorkspaceTab[]; + userId: string; +}) { + if (!params.shouldPersist || typeof window === "undefined") return; + + try { + const state: PersistedIntakeWorkspaceState = { + activeTabId: params.activeTabId, + tabs: params.tabs.map(serializeIntakeWorkspaceTab), + }; + window.sessionStorage.setItem( + getIntakeWorkspaceStateStorageKey(params.userId), + JSON.stringify(state), + ); + } catch { + // Ignore quota/private-mode failures; the UI can still rebuild the default tab. + } +} + +function getWorkspaceTabInitialSection(tab: IntakeWorkspaceTab | null) { + if (!tab) return "pending"; + if ( + tab.kind === "pendingEntry" || + tab.kind === "spuRuleLibrary" || + tab.kind === "nonIntegratedProduct" || + tab.kind === "newNonIntegratedProduct" + ) { + return "pending"; + } + if (tab.kind === "progressDashboard") return "progress"; + if (tab.kind === "productPatentLedger") return "patents"; + if (tab.kind === "productPatentDashboard") return "patents"; + if (tab.kind === "bomLibrary") return "bom"; + if (tab.kind === "rawMaterialLibrary") return "rawMaterialLibrary"; + return "referenceEstimator"; +} + +function getWorkspaceTabInitialPendingEntryTab(tab: IntakeWorkspaceTab | null) { + if (tab?.kind === "pendingEntry") return tab.entryTab; + if (tab?.kind === "nonIntegratedProduct") { + return getPendingEntryTabFromProduct(tab.product); + } + if (tab?.kind === "newNonIntegratedProduct") { + return getPendingEntryTabFromNewProductTab(tab); + } + return "nonIntegrated"; +} + function getWorkspaceTabPageKey(tab: IntakeWorkspaceTab) { if (tab.kind === "pendingEntry") return `pending-entry:${tab.entryTab}`; if (tab.kind === "progressDashboard") return "product-progress:dashboard"; @@ -680,7 +1351,9 @@ function getWorkspaceTabPageKey(tab: IntakeWorkspaceTab) { return `non-integrated-product:${tab.productId}:${tab.mode}`; } if (tab.kind === "newNonIntegratedProduct") { - return `new-non-integrated:${tab.productDevelopmentKind}`; + return `new-non-integrated:${tab.productDevelopmentKind}:${ + tab.initialCreateProductKind ?? "new" + }`; } if (tab.kind === "bomLibrary") return `bom-library:${tab.bomLibrary}`; if (tab.kind === "rawMaterialLibrary") { @@ -2027,6 +2700,8 @@ function NonIntegratedPendingDetail({ canDeleteProducts, currentUser, productDevelopmentKind = "product", + fixedProductKind, + createButtonLabel = "新增产品", emptyMessage = "当前还没有非集成产品,点击右上角“新增产品”开始维护资料。", onProductsChange, onOpenProductWorkspace, @@ -2036,6 +2711,8 @@ function NonIntegratedPendingDetail({ canDeleteProducts: boolean; currentUser: IntakeProductPermissionUser; productDevelopmentKind?: ProductDevelopmentKind; + fixedProductKind?: NonIntegratedProductKindFilter; + createButtonLabel?: string; emptyMessage?: string; onProductsChange?: (products: NonIntegratedProductRecord[]) => void; onOpenProductWorkspace?: ( @@ -2045,8 +2722,27 @@ function NonIntegratedPendingDetail({ onOpenNewNonIntegratedProductWorkspace?: () => void; }) { const router = useRouter(); + const initialProductKind = fixedProductKind ?? "all"; + const listStateStorageKey = buildNonIntegratedListStateStorageKey( + productDevelopmentKind, + fixedProductKind, + ); + const initialPersistedListState = useMemo( + () => readPersistedNonIntegratedListState(listStateStorageKey), + [listStateStorageKey], + ); const [products, setProducts] = useState(initialProducts); + const [currentPage, setCurrentPage] = useState(() => + normalizePersistedPage(initialPersistedListState?.currentPage), + ); + const [pageJumpInput, setPageJumpInput] = useState(() => + String(normalizePersistedPage(initialPersistedListState?.currentPage)), + ); + const [pageInfo, setPageInfo] = + useState(null); + const [isListLoading, setIsListLoading] = useState(false); + const [listErrorMessage, setListErrorMessage] = useState(""); const [previewProduct, setPreviewProduct] = useState<{ src: string; alt: string; @@ -2058,26 +2754,31 @@ function NonIntegratedPendingDetail({ const [copyErrorMessage, setCopyErrorMessage] = useState(""); const [isCopySubmitting, setIsCopySubmitting] = useState(false); const [activeQuickScope, setActiveQuickScope] = - useState("all"); - const [queryDraft, setQueryDraft] = useState({ - spu: "", - appearanceDesigner: "", - structureDesigner: "", - spuOwner: "", - planningOwner: "", - spaceDesigner: "", - purchaseOwner: "", - }); - const [appliedQuery, setAppliedQuery] = useState({ - spu: "", - appearanceDesigner: "", - structureDesigner: "", - spuOwner: "", - planningOwner: "", - spaceDesigner: "", - purchaseOwner: "", - }); + useState(() => + normalizePersistedQuickScope(initialPersistedListState?.activeQuickScope), + ); + const [queryDraft, setQueryDraft] = useState(() => + normalizeNonIntegratedListQuery( + initialPersistedListState?.queryDraft, + initialProductKind, + ), + ); + const [appliedQuery, setAppliedQuery] = useState(() => + normalizeNonIntegratedListQuery( + initialPersistedListState?.appliedQuery, + initialProductKind, + ), + ); + const [sharedStockCategoryOptions, setSharedStockCategoryOptions] = useState< + string[] + >([]); + const [stockCategoryOptionsReady, setStockCategoryOptionsReady] = + useState(false); + const listStateStorageKeyRef = useRef(listStateStorageKey); + const skipNextListStatePersistRef = useRef(false); const shouldUseSharedNonIntegratedApi = process.env.NODE_ENV !== "test"; + const effectiveProductKind = fixedProductKind ?? appliedQuery.productKind; + const hasCreateAction = Boolean(onOpenNewNonIntegratedProductWorkspace); function scopeProductsByDevelopmentKind( nextProducts: NonIntegratedProductRecord[], @@ -2089,7 +2790,6 @@ function NonIntegratedPendingDetail({ useEffect(() => { if (shouldUseSharedNonIntegratedApi) { - setProducts(initialProducts); return; } @@ -2106,6 +2806,141 @@ function NonIntegratedPendingDetail({ window.removeEventListener(NON_INTEGRATED_PRODUCTS_UPDATED, syncProducts); }, [initialProducts, productDevelopmentKind, shouldUseSharedNonIntegratedApi]); + useEffect(() => { + if (listStateStorageKeyRef.current === listStateStorageKey) return; + + listStateStorageKeyRef.current = listStateStorageKey; + skipNextListStatePersistRef.current = true; + + const persistedState = + readPersistedNonIntegratedListState(listStateStorageKey); + const nextPage = normalizePersistedPage(persistedState?.currentPage); + setCurrentPage(nextPage); + setPageJumpInput(String(nextPage)); + setActiveQuickScope( + normalizePersistedQuickScope(persistedState?.activeQuickScope), + ); + setQueryDraft( + normalizeNonIntegratedListQuery( + persistedState?.queryDraft, + initialProductKind, + ), + ); + setAppliedQuery( + normalizeNonIntegratedListQuery( + persistedState?.appliedQuery, + initialProductKind, + ), + ); + }, [initialProductKind, listStateStorageKey]); + + useEffect(() => { + if (skipNextListStatePersistRef.current) { + skipNextListStatePersistRef.current = false; + return; + } + + writePersistedNonIntegratedListState(listStateStorageKey, { + queryDraft, + appliedQuery, + currentPage, + activeQuickScope, + }); + }, [ + activeQuickScope, + appliedQuery, + currentPage, + listStateStorageKey, + queryDraft, + ]); + + useEffect(() => { + if (!fixedProductKind) return; + + setQueryDraft((current) => ({ ...current, productKind: fixedProductKind })); + setAppliedQuery((current) => ({ + ...current, + productKind: fixedProductKind, + })); + }, [fixedProductKind]); + + useEffect(() => { + if (!shouldUseSharedNonIntegratedApi || effectiveProductKind !== "stock") { + setStockCategoryOptionsReady(!shouldUseSharedNonIntegratedApi); + return; + } + + let cancelled = false; + setStockCategoryOptionsReady(false); + + listSharedNonIntegratedStockCategoryOptions() + .then((data) => { + if (cancelled) return; + setSharedStockCategoryOptions(data.categories); + }) + .catch(() => { + if (cancelled) return; + setSharedStockCategoryOptions([]); + }) + .finally(() => { + if (!cancelled) { + setStockCategoryOptionsReady(true); + } + }); + + return () => { + cancelled = true; + }; + }, [effectiveProductKind, shouldUseSharedNonIntegratedApi]); + + useEffect(() => { + if (!shouldUseSharedNonIntegratedApi) return; + + let cancelled = false; + setIsListLoading(true); + setListErrorMessage(""); + + listSharedNonIntegratedProductPage({ + page: currentPage, + pageSize: PENDING_PAGE_SIZE, + productDevelopmentKind, + quickScope: activeQuickScope, + ...appliedQuery, + productKind: effectiveProductKind, + }) + .then((data) => { + if (cancelled) return; + + setProducts(data.products); + setPageInfo(data.pageInfo); + }) + .catch((error) => { + if (cancelled) return; + + setListErrorMessage( + error instanceof Error ? error.message : "列表加载失败,请稍后再试", + ); + setProducts([]); + setPageInfo(null); + }) + .finally(() => { + if (!cancelled) { + setIsListLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [ + activeQuickScope, + appliedQuery, + currentPage, + effectiveProductKind, + productDevelopmentKind, + shouldUseSharedNonIntegratedApi, + ]); + async function handleDelete(product: NonIntegratedProductRecord) { const productLabel = product.spu?.trim() || "当前产品"; @@ -2176,11 +3011,31 @@ function NonIntegratedPendingDetail({ } function getAvailableCopyBrands(product: NonIntegratedProductRecord) { - const configuredBrands = product.adaptableBrands?.length - ? product.adaptableBrands - : Object.keys(NON_INTEGRATED_BRAND_PREFIX_MAP); + const configuredBrands = Array.from( + new Set([ + product.brand, + ...(product.adaptableBrands ?? []), + ...(product.openingReference?.adaptableBrands ?? []), + ]), + ).filter((brand) => brand.trim().length > 0); + const sourceBaseSpu = stripNonIntegratedBrandPrefix( + product.copiedFromSpu || product.spu, + ); + const existingBrandsForSpu = new Set( + products + .filter( + (currentProduct) => + stripNonIntegratedBrandPrefix( + currentProduct.copiedFromSpu || currentProduct.spu, + ) === sourceBaseSpu, + ) + .map((currentProduct) => currentProduct.brand) + .filter((brand) => brand.trim().length > 0), + ); - return configuredBrands.filter((brand) => brand !== product.brand); + return configuredBrands.filter( + (brand) => brand !== product.brand && !existingBrandsForSpu.has(brand), + ); } function openCopyDialog(product: NonIntegratedProductRecord) { @@ -2253,12 +3108,25 @@ function NonIntegratedPendingDetail({ const documentWorkspaceCountByCategory = getDocumentWorkspaceCountByCategory(product); const people = getNonIntegratedListPeople(product); + const stockProduct = isJstSkuImportedStockProduct(product); + const sourceSpu = readStockSourceString(product, "sourceSpu"); + const sourceSku = readStockSourceString(product, "sourceSkuId"); + const productKindMatched = + effectiveProductKind === "all" || + (effectiveProductKind === "stock" + ? stockProduct + : !stockProduct); const spuMatched = normalizedSpuKeyword.length === 0 || productDisplayName.includes(normalizedSpuKeyword) || + sourceSpu.toUpperCase().includes(normalizedSpuKeyword) || + sourceSku.toUpperCase().includes(normalizedSpuKeyword) || getSearchableNonIntegratedSpuKeywords(product.spu).some((keyword) => keyword.includes(normalizedSpuKeyword), ); + const stockSourceMatched = + effectiveProductKind !== "stock" || + stockSourceMatchesQuery(product, appliedQuery); const peopleMatched = productMatchesText( people.appearanceDesigner, @@ -2282,14 +3150,118 @@ function NonIntegratedPendingDetail({ documentWorkspaceCountByCategory, )); - return spuMatched && peopleMatched && quickScopeMatched; + return ( + productKindMatched && + spuMatched && + stockSourceMatched && + peopleMatched && + quickScopeMatched + ); }); - }, [activeQuickScope, appliedQuery, currentUser, products]); - const filterFields: Array<{ - key: keyof typeof queryDraft; + }, [activeQuickScope, appliedQuery, currentUser, effectiveProductKind, products]); + const totalRows = shouldUseSharedNonIntegratedApi + ? (pageInfo?.total ?? filteredProducts.length) + : filteredProducts.length; + const totalPages = shouldUseSharedNonIntegratedApi + ? (pageInfo?.totalPages ?? 1) + : Math.max(1, Math.ceil(filteredProducts.length / PENDING_PAGE_SIZE)); + const safeCurrentPage = Math.min(currentPage, totalPages); + const visibleProducts = shouldUseSharedNonIntegratedApi + ? filteredProducts + : filteredProducts.slice( + (safeCurrentPage - 1) * PENDING_PAGE_SIZE, + safeCurrentPage * PENDING_PAGE_SIZE, + ); + + useEffect(() => { + setPageJumpInput(String(safeCurrentPage)); + }, [safeCurrentPage]); + + function commitPageJump(value = pageJumpInput) { + const parsedPage = Number.parseInt(value.trim(), 10); + const nextPage = Number.isFinite(parsedPage) + ? Math.min(totalPages, Math.max(1, parsedPage)) + : safeCurrentPage; + + setCurrentPage(nextPage); + setPageJumpInput(String(nextPage)); + } + + const isSeriesDevelopmentList = productDevelopmentKind === "series"; + const isStockDevelopmentList = effectiveProductKind === "stock"; + const localStockCategoryOptions = useMemo( + () => + getUniqueSortedStrings( + products + .filter(isJstSkuImportedStockProduct) + .map((product) => readStockSourceString(product, "sourceCategory")), + ), + [products], + ); + const stockCategoryOptions = shouldUseSharedNonIntegratedApi + ? sharedStockCategoryOptions + : localStockCategoryOptions; + const stockCategorySelectOptions = useMemo( + () => { + const categories = stockCategoryOptionsReady + ? stockCategoryOptions + : getUniqueSortedStrings([ + ...stockCategoryOptions, + queryDraft.stockCategory, + appliedQuery.stockCategory, + ]); + + return [ + { value: "", label: "全部" }, + ...categories.map((category) => ({ + value: category, + label: category, + })), + ]; + }, + [ + appliedQuery.stockCategory, + queryDraft.stockCategory, + stockCategoryOptions, + stockCategoryOptionsReady, + ], + ); + + useEffect(() => { + if ( + !isStockDevelopmentList || + !stockCategoryOptionsReady || + stockCategoryOptions.length === 0 + ) { + return; + } + + const existingCategories = new Set(stockCategoryOptions); + if ( + queryDraft.stockCategory && + !existingCategories.has(queryDraft.stockCategory) + ) { + setQueryDraft((current) => ({ ...current, stockCategory: "" })); + } + if ( + appliedQuery.stockCategory && + !existingCategories.has(appliedQuery.stockCategory) + ) { + setAppliedQuery((current) => ({ ...current, stockCategory: "" })); + } + }, [ + appliedQuery.stockCategory, + isStockDevelopmentList, + queryDraft.stockCategory, + stockCategoryOptions, + stockCategoryOptionsReady, + ]); + const productFilterFields: Array<{ + key: keyof NonIntegratedListQuery; label: ReactNode; ariaLabel: string; placeholder: string; + options?: Array<{ value: string; label: string }>; }> = [ { key: "spu", label: , ariaLabel: "SPU", placeholder: "搜索 SPU" }, { @@ -2329,12 +3301,78 @@ function NonIntegratedPendingDetail({ placeholder: "搜索采购负责人", }, ]; + const stockFilterFields: typeof productFilterFields = [ + { key: "spu", label: , ariaLabel: "SPU", placeholder: "搜索SPU/i_id" }, + { + key: "stockSku", + label: "SKU条码", + ariaLabel: "SKU条码", + placeholder: "搜索sku_id", + }, + { + key: "stockCategory", + label: "类目", + ariaLabel: "类目", + placeholder: "", + options: stockCategorySelectOptions, + }, + { + key: "stockItemType", + label: "商品类型", + ariaLabel: "商品类型", + placeholder: "", + options: [ + { value: "all", label: "全部" }, + { value: "成品", label: "成品" }, + { value: "半成品", label: "半成品/配件" }, + ], + }, + { + key: "stockSalesStatus", + label: "销售状态", + ariaLabel: "销售状态", + placeholder: "", + options: [ + { value: "all", label: "全部" }, + { value: "销售", label: "销售" }, + { value: "停产", label: "停产" }, + { value: "退市", label: "退市" }, + { value: "__empty", label: "空" }, + ], + }, + { + key: "stockEnabledStatus", + label: "启用状态", + ariaLabel: "启用状态", + placeholder: "", + options: [ + { value: "all", label: "全部" }, + { value: "1", label: "启用" }, + { value: "0", label: "禁用" }, + { value: "-1", label: "停用" }, + { value: "__empty", label: "空" }, + ], + }, + { + key: "stockDataScope", + label: "数据范围", + ariaLabel: "数据范围", + placeholder: "", + options: [ + { value: "all", label: "全部数据" }, + { value: "normalSpu", label: "正常SPU" }, + { value: "normalOnSale", label: "正常SPU+在售SKU" }, + ], + }, + ]; + const filterFields = isStockDevelopmentList + ? stockFilterFields + : productFilterFields; const quickScopes: Array<{ key: NonIntegratedQuickScope; label: string }> = [ { key: "all", label: "全部" }, { key: "created", label: "我创建的" }, { key: "pending", label: "待处理" }, ]; - const isSeriesDevelopmentList = productDevelopmentKind === "series"; const listColumnCount = 11 + NON_INTEGRATED_DOCUMENT_STATUS_COLUMNS.length + @@ -2350,7 +3388,10 @@ function NonIntegratedPendingDetail({ key={scope.key} type="button" aria-pressed={activeQuickScope === scope.key} - onClick={() => setActiveQuickScope(scope.key)} + onClick={() => { + setActiveQuickScope(scope.key); + setCurrentPage(1); + }} className={`h-7 rounded-apple-md px-3 text-xs font-apple-medium ${ activeQuickScope === scope.key ? "bg-zinc-950 text-white" @@ -2361,53 +3402,84 @@ function NonIntegratedPendingDetail({ ))} -
+
{filterFields.map((filter) => ( ))}
-
- -
+ {hasCreateAction ? ( +
+ +
+ ) : null}
-
-
-
+
+
+
- {filteredProducts.length === 0 ? ( + {visibleProducts.length === 0 ? ( ) : ( - filteredProducts.map((product, index) => { + visibleProducts.map((product, index) => { const productDisplayName = getNonIntegratedProductDisplayName(product); const documentWorkspaceCountByCategory = @@ -2513,7 +3588,7 @@ function NonIntegratedPendingDetail({ className="border-b border-apple-separator" >
- {products.length === 0 - ? emptyMessage - : "当前查询条件下没有找到资料。"} + {isListLoading + ? "正在加载资料..." + : listErrorMessage || + (products.length === 0 + ? emptyMessage + : "当前查询条件下没有找到资料。")}
- {index + 1} + {(safeCurrentPage - 1) * PENDING_PAGE_SIZE + index + 1}
+ {totalRows > 0 ? ( +
+ +
+ 共 {totalRows} 条 + 第 + + setPageJumpInput(event.target.value.replace(/\D/g, "")) + } + onBlur={() => commitPageJump()} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + commitPageJump(); + } + }} + disabled={isListLoading} + className="h-8 w-12 rounded-apple-md border border-apple-separator bg-apple-bg-primary px-2 text-center text-xs text-apple-label-primary outline-none focus:border-zinc-400 disabled:bg-apple-bg-secondary disabled:text-apple-label-secondary" + /> + / {totalPages} 页 + 每页 {PENDING_PAGE_SIZE} 条 + {isListLoading ? 加载中... : null} +
+ +
+ ) : null}
{previewProduct ? ( @@ -7547,6 +8673,17 @@ function FormalIntakeWorkbench({ > 产品开发进度 +
@@ -7714,32 +8851,69 @@ export default function IntakeClient(props: IntakeClientProps) { const canDeleteNonIntegratedProducts = canDeleteIntakeProducts( props.user.role, ); - const [activeSection, setActiveSection] = useState( - props.initialActiveSection ?? "pending", + const shouldPersistWorkspaceState = !isEditorMode(props); + const shouldUseSharedNonIntegratedApi = process.env.NODE_ENV !== "test"; + const [initialWorkspaceState] = useState(() => + createDefaultIntakeWorkspaceState(), + ); + const initialActiveWorkspaceTab = + initialWorkspaceState.tabs.find( + (tab) => tab.id === initialWorkspaceState.activeTabId, + ) ?? + initialWorkspaceState.tabs[0] ?? + null; + const [activeSection, setActiveSection] = useState( + props.initialActiveSection ?? + getWorkspaceTabInitialSection(initialActiveWorkspaceTab), + ); + const [pendingEntryTab, setPendingEntryTab] = useState( + getWorkspaceTabInitialPendingEntryTab(initialActiveWorkspaceTab), + ); + const [activeBomLibrary, setActiveBomLibrary] = useState( + initialActiveWorkspaceTab?.kind === "bomLibrary" + ? initialActiveWorkspaceTab.bomLibrary + : "materialLibrary", ); - const [pendingEntryTab, setPendingEntryTab] = - useState("nonIntegrated"); - const [activeBomLibrary, setActiveBomLibrary] = - useState("materialLibrary"); const [activeRawMaterialLibrary, setActiveRawMaterialLibrary] = useState( - props.initialActiveRawMaterialLibrary ?? "processConfig", + props.initialActiveRawMaterialLibrary ?? + (initialActiveWorkspaceTab?.kind === "rawMaterialLibrary" + ? initialActiveWorkspaceTab.rawMaterialLibrary + : "processConfig"), ); const [nonIntegratedProducts, setNonIntegratedProducts] = useState< NonIntegratedProductRecord[] >(props.initialNonIntegratedProducts); const selectedRawMaterialName = props.initialRawMaterialName?.trim() ?? ""; const [workspaceTabs, setWorkspaceTabs] = useState( - () => [createPendingEntryWorkspaceTab("nonIntegrated")], + () => initialWorkspaceState.tabs, ); const [activeWorkspaceTabId, setActiveWorkspaceTabId] = useState< string | null - >(DEFAULT_WORKSPACE_TAB_ID); - const workspaceTabSequenceRef = useRef(0); + >(initialWorkspaceState.activeTabId); + const [loadedProductWorkspaceTabIds, setLoadedProductWorkspaceTabIds] = + useState>(() => new Set()); + const [productWorkspaceLoadErrors, setProductWorkspaceLoadErrors] = useState< + Record + >({}); + const workspaceTabSequenceRef = useRef(initialWorkspaceState.sequence); + const productWorkspaceLoadingRef = useRef>(new Set()); const initialOpenNewNonIntegratedProductRef = useRef(false); + const workspaceStateRestoredRef = useRef(false); + const [workspaceStateReady, setWorkspaceStateReady] = useState( + () => !shouldPersistWorkspaceState, + ); const activeWorkspaceTab = workspaceTabs.find((tab) => tab.id === activeWorkspaceTabId) ?? null; + const activeProductWorkspaceNeedsFullLoad = + shouldUseSharedNonIntegratedApi && + activeWorkspaceTab?.kind === "nonIntegratedProduct" && + !loadedProductWorkspaceTabIds.has(activeWorkspaceTab.id); + const activeProductWorkspaceLoadError = + activeWorkspaceTab?.kind === "nonIntegratedProduct" + ? productWorkspaceLoadErrors[activeWorkspaceTab.id] ?? "" + : ""; const activeProductWorkspaceTabId = activeWorkspaceTab?.kind === "nonIntegratedProduct" ? activeWorkspaceTab.id @@ -7786,16 +8960,71 @@ export default function IntakeClient(props: IntakeClientProps) { setNonIntegratedProducts(props.initialNonIntegratedProducts); }, [props.initialNonIntegratedProducts]); + useEffect(() => { + if (!shouldPersistWorkspaceState) { + setWorkspaceStateReady(true); + return; + } + + if (workspaceStateRestoredRef.current) return; + workspaceStateRestoredRef.current = true; + + const restoredState = readInitialIntakeWorkspaceState({ + products: props.initialNonIntegratedProducts, + userId: props.user.id, + shouldRestore: true, + }); + const restoredActiveTab = + restoredState.tabs.find((tab) => tab.id === restoredState.activeTabId) ?? + restoredState.tabs[0] ?? + null; + + workspaceTabSequenceRef.current = restoredState.sequence; + setWorkspaceTabs(restoredState.tabs); + syncWorkspaceState(restoredActiveTab); + setWorkspaceStateReady(true); + }, [ + props.initialNonIntegratedProducts, + props.user.id, + shouldPersistWorkspaceState, + ]); + + useEffect(() => { + persistIntakeWorkspaceState({ + activeTabId: activeWorkspaceTabId, + shouldPersist: shouldPersistWorkspaceState && workspaceStateReady, + tabs: workspaceTabs, + userId: props.user.id, + }); + }, [ + activeWorkspaceTabId, + props.user.id, + shouldPersistWorkspaceState, + workspaceTabs, + workspaceStateReady, + ]); + function handleScopedProductsChange( productDevelopmentKind: ProductDevelopmentKind, nextProducts: NonIntegratedProductRecord[], + productKindFilter?: NonIntegratedProductKindFilter, ) { setNonIntegratedProducts((current) => [ ...nextProducts, - ...current.filter( - (product) => - getProductDevelopmentKind(product) !== productDevelopmentKind, - ), + ...current.filter((product) => { + if (getProductDevelopmentKind(product) !== productDevelopmentKind) { + return true; + } + + if (productDevelopmentKind !== "product") { + return false; + } + + const stockProduct = isJstSkuImportedStockProduct(product); + if (productKindFilter === "stock") return !stockProduct; + if (productKindFilter === "new") return stockProduct; + return false; + }), ]); } @@ -7828,6 +9057,87 @@ export default function IntakeClient(props: IntakeClientProps) { ); } + const markProductWorkspaceTabLoaded = useCallback((tabId: string) => { + setLoadedProductWorkspaceTabIds((current) => { + if (current.has(tabId)) return current; + const next = new Set(current); + next.add(tabId); + return next; + }); + }, []); + + const applyLoadedProductWorkspace = useCallback(( + tabId: string, + product: NonIntegratedProductRecord, + ) => { + setNonIntegratedProducts((current) => { + const existingIndex = current.findIndex((item) => item.id === product.id); + if (existingIndex < 0) return current; + + return current.map((item) => (item.id === product.id ? product : item)); + }); + setWorkspaceTabs((current) => + current.map((tab) => + tab.id === tabId && tab.kind === "nonIntegratedProduct" + ? { + ...tab, + label: getProductDevelopmentDetailLabel(product), + productId: product.id, + product, + productDevelopmentKind: getProductDevelopmentKind(product), + } + : tab, + ), + ); + markProductWorkspaceTabLoaded(tabId); + }, [markProductWorkspaceTabLoaded]); + + useEffect(() => { + if ( + !shouldUseSharedNonIntegratedApi || + activeWorkspaceTab?.kind !== "nonIntegratedProduct" || + !activeProductWorkspaceNeedsFullLoad || + activeProductWorkspaceLoadError || + productWorkspaceLoadingRef.current.has(activeWorkspaceTab.id) + ) { + return; + } + + const tabId = activeWorkspaceTab.id; + const productId = activeWorkspaceTab.productId; + let cancelled = false; + + productWorkspaceLoadingRef.current.add(tabId); + getSharedNonIntegratedProductById(productId) + .then((product) => { + if (cancelled) return; + applyLoadedProductWorkspace(tabId, product); + }) + .catch((error) => { + if (cancelled) return; + setProductWorkspaceLoadErrors((current) => ({ + ...current, + [tabId]: + error instanceof Error + ? error.message + : "完整产品资料加载失败,请稍后重试。", + })); + }) + .finally(() => { + productWorkspaceLoadingRef.current.delete(tabId); + }); + + return () => { + cancelled = true; + }; + }, [ + activeProductWorkspaceLoadError, + activeProductWorkspaceNeedsFullLoad, + activeWorkspaceTab, + applyLoadedProductWorkspace, + shouldUseSharedNonIntegratedApi, + ]); + function replaceCreateWorkspaceWithProduct( savedProduct: NonIntegratedProductRecord, mode: WorkspaceProductMode, @@ -7837,11 +9147,12 @@ export default function IntakeClient(props: IntakeClientProps) { !canOpenNonIntegratedProductForEdit(savedProduct, props.user) ? "view" : mode; + const nextTabId = createWorkspaceTabId( + "non-integrated-product", + `${savedProduct.id}:${effectiveMode}`, + ); const nextTab: IntakeWorkspaceTab = { - id: createWorkspaceTabId( - "non-integrated-product", - `${savedProduct.id}:${effectiveMode}`, - ), + id: nextTabId, kind: "nonIntegratedProduct", label: getProductDevelopmentDetailLabel(savedProduct), productId: savedProduct.id, @@ -7860,6 +9171,7 @@ export default function IntakeClient(props: IntakeClientProps) { nextTab, ).tabs, ); + markProductWorkspaceTabLoaded(activeTab.id); syncWorkspaceState(activeTab); } @@ -7890,14 +9202,10 @@ export default function IntakeClient(props: IntakeClientProps) { setActiveSection("patents"); } else if (tab.kind === "nonIntegratedProduct") { setActiveSection("pending"); - setPendingEntryTab( - getPendingEntryTabFromProductDevelopmentKind(tab.productDevelopmentKind), - ); + setPendingEntryTab(getPendingEntryTabFromProduct(tab.product)); } else if (tab.kind === "newNonIntegratedProduct") { setActiveSection("pending"); - setPendingEntryTab( - getPendingEntryTabFromProductDevelopmentKind(tab.productDevelopmentKind), - ); + setPendingEntryTab(getPendingEntryTabFromNewProductTab(tab)); } else if (tab.kind === "bomLibrary") { setActiveSection("bom"); setActiveBomLibrary(tab.bomLibrary); @@ -8010,15 +9318,20 @@ export default function IntakeClient(props: IntakeClientProps) { : undefined, }; openWorkspaceTab(nextTab); + if (!shouldUseSharedNonIntegratedApi) { + markProductWorkspaceTabLoaded(tabId); + } } function openNewNonIntegratedProductWorkspace( productDevelopmentKind: ProductDevelopmentKind = "product", + initialCreateProductKind: NewNonIntegratedCreateKind = "new", ) { const existingTab = workspaceTabs.find( (tab) => tab.kind === "newNonIntegratedProduct" && - tab.productDevelopmentKind === productDevelopmentKind, + tab.productDevelopmentKind === productDevelopmentKind && + (tab.initialCreateProductKind ?? "new") === initialCreateProductKind, ); if (existingTab) { syncWorkspaceState(existingTab); @@ -8028,16 +9341,18 @@ export default function IntakeClient(props: IntakeClientProps) { const nextTab = createNewNonIntegratedProductWorkspaceTab( createWorkspaceTabId("new-non-integrated", productDevelopmentKind), productDevelopmentKind, + initialCreateProductKind, ); openWorkspaceTab(nextTab); } function activatePendingEntryListWorkspace( productDevelopmentKind: ProductDevelopmentKind = "product", + entryTabOverride?: IntakeEntryTab, ) { - const entryTab = getPendingEntryTabFromProductDevelopmentKind( - productDevelopmentKind, - ); + const entryTab = + entryTabOverride ?? + getPendingEntryTabFromProductDevelopmentKind(productDevelopmentKind); const existingTab = workspaceTabs.find( (tab) => tab.kind === "pendingEntry" && tab.entryTab === entryTab, ); @@ -8082,9 +9397,10 @@ export default function IntakeClient(props: IntakeClientProps) { closingTab?.kind === "newNonIntegratedProduct" ? closingTab.productDevelopmentKind : "product"; - const entryTab = getPendingEntryTabFromProductDevelopmentKind( - productDevelopmentKind, - ); + const entryTab = + closingTab?.kind === "newNonIntegratedProduct" + ? getPendingEntryTabFromNewProductTab(closingTab) + : getPendingEntryTabFromProductDevelopmentKind(productDevelopmentKind); const listTab = workspaceTabs.find( (tab) => tab.kind === "pendingEntry" && tab.entryTab === entryTab, @@ -8188,7 +9504,41 @@ export default function IntakeClient(props: IntakeClientProps) { let activeContent = mainContent; if (activeWorkspaceTab?.kind === "nonIntegratedProduct") { - activeContent = ( + activeContent = activeProductWorkspaceNeedsFullLoad ? ( +
+
+

+ {activeProductWorkspaceLoadError + ? "完整产品资料加载失败" + : "正在加载完整 SKU 资料..."} +

+ {activeProductWorkspaceLoadError ? ( + <> +

+ {activeProductWorkspaceLoadError} +

+ + + ) : ( +

+ 为避免自动保存覆盖 SKU,加载完成前暂不进入详情编辑。 +

+ )} +
+
+ ) : ( activatePendingEntryListWorkspace( activeWorkspaceTab.productDevelopmentKind, + getPendingEntryTabFromProduct(activeWorkspaceTab.product), ) } /> @@ -8267,6 +9618,9 @@ export default function IntakeClient(props: IntakeClientProps) { key={activeWorkspaceTab.id} mode="create" productDevelopmentKind={activeWorkspaceTab.productDevelopmentKind} + initialCreateProductKind={ + activeWorkspaceTab.initialCreateProductKind ?? "new" + } seriesNameOptions={seriesNameOptions} existingSeriesNames={existingSeriesNames} seriesDraftSkuOptions={seriesDraftSkuOptions} @@ -8334,11 +9688,21 @@ export default function IntakeClient(props: IntakeClientProps) { eligibleSkuList: props.eligibleSkuList, }); const productDevelopmentProducts = nonIntegratedProducts.filter( - (product) => getProductDevelopmentKind(product) === "product", + (product) => + getProductDevelopmentKind(product) === "product" && + !isJstSkuImportedStockProduct(product), + ); + const stockDevelopmentProducts = nonIntegratedProducts.filter( + (product) => + getProductDevelopmentKind(product) === "product" && + isJstSkuImportedStockProduct(product), ); const seriesDevelopmentProducts = nonIntegratedProducts.filter( (product) => getProductDevelopmentKind(product) === "series", ); + const progressProducts = nonIntegratedProducts.filter( + (product) => !isJstSkuImportedStockProduct(product), + ); let leftContent: React.ReactNode = ; if (activeSection === "pending") { @@ -8358,14 +9722,32 @@ export default function IntakeClient(props: IntakeClientProps) { openNewNonIntegratedProductWorkspace("series") } /> + ) : pendingEntryTab === "stock" ? ( + + handleScopedProductsChange("product", nextProducts, "stock") + } + onOpenProductWorkspace={openProductWorkspace} + onOpenNewNonIntegratedProductWorkspace={() => + openNewNonIntegratedProductWorkspace("product", "stock") + } + /> ) : ( - handleScopedProductsChange("product", nextProducts) + handleScopedProductsChange("product", nextProducts, "new") } onOpenProductWorkspace={openProductWorkspace} onOpenNewNonIntegratedProductWorkspace={() => @@ -8377,7 +9759,7 @@ export default function IntakeClient(props: IntakeClientProps) { leftContent = ( @@ -8458,11 +9840,21 @@ export default function IntakeClient(props: IntakeClientProps) { const skuList = buildIntegratedSeriesRows({ detail: props.detail }); const productDevelopmentProducts = nonIntegratedProducts.filter( - (product) => getProductDevelopmentKind(product) === "product", + (product) => + getProductDevelopmentKind(product) === "product" && + !isJstSkuImportedStockProduct(product), + ); + const stockDevelopmentProducts = nonIntegratedProducts.filter( + (product) => + getProductDevelopmentKind(product) === "product" && + isJstSkuImportedStockProduct(product), ); const seriesDevelopmentProducts = nonIntegratedProducts.filter( (product) => getProductDevelopmentKind(product) === "series", ); + const progressProducts = nonIntegratedProducts.filter( + (product) => !isJstSkuImportedStockProduct(product), + ); let leftContent: React.ReactNode = ; if (activeSection === "pending") { leftContent = @@ -8481,14 +9873,32 @@ export default function IntakeClient(props: IntakeClientProps) { openNewNonIntegratedProductWorkspace("series") } /> + ) : pendingEntryTab === "stock" ? ( + + handleScopedProductsChange("product", nextProducts, "stock") + } + onOpenProductWorkspace={openProductWorkspace} + onOpenNewNonIntegratedProductWorkspace={() => + openNewNonIntegratedProductWorkspace("product", "stock") + } + /> ) : ( - handleScopedProductsChange("product", nextProducts) + handleScopedProductsChange("product", nextProducts, "new") } onOpenProductWorkspace={openProductWorkspace} onOpenNewNonIntegratedProductWorkspace={() => @@ -8500,7 +9910,7 @@ export default function IntakeClient(props: IntakeClientProps) { leftContent = ( diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx index 0daf52e1..810ad9a3 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx @@ -43,6 +43,7 @@ import { createApprovalWorkflow } from "@/app/approval-workflows/approvalWorkflo const push = jest.fn(); const modelPreviewPanelMock = jest.fn(); +const CONFIRMED_SKU_BATCH_AT = { "1": "2026-05-19T00:00:00.000Z" }; type NonIntegratedRemoteAssetStorageTestGlobal = typeof globalThis & { __NON_INTEGRATED_REMOTE_ASSET_STORAGE_TEST_OVERRIDE__?: boolean; @@ -172,6 +173,27 @@ const mockedUploadSharedNonIntegratedFile = jest.mocked( const mockedFetchSubstrates = jest.mocked(fetchSubstrates); const mockedCreateApprovalWorkflow = jest.mocked(createApprovalWorkflow); +async function selectInlineOption( + actor: ReturnType, + control: HTMLElement, + optionName: string, +) { + await actor.click(control); + const optionLabel = optionName || "请选择"; + const listboxes = await screen.findAllByRole("listbox"); + const activeListbox = listboxes[listboxes.length - 1]; + expect(activeListbox).toBeDefined(); + const optionByLabel = within(activeListbox!).queryByRole("option", { + name: optionLabel, + }); + const optionByValue = Array.from( + activeListbox!.querySelectorAll('[role="option"]'), + ).find((option) => option.value === optionName); + const option = optionByLabel ?? optionByValue; + expect(option).toBeDefined(); + await actor.click(option!); +} + function createSubstrateOption(overrides: { id: string; code: string; @@ -334,6 +356,7 @@ function createSkuItem( size: "140/60/75", color: "胡桃色", material: "橡胶木", + boardMaterialType: "", versionNo: "V01", attributeCode: "A", drawingId: "SZ0230K01AV0", @@ -460,7 +483,7 @@ describe("NonIntegratedDetailClient", () => { expect(screen.getByLabelText("品牌")).toHaveValue("基本盘"); await actor.type(screen.getByLabelText("名称"), "测试开品"); await actor.type(screen.getByLabelText("外观设计师"), "外观A"); - await actor.selectOptions(screen.getByLabelText("品牌"), "顾家"); + await selectInlineOption(actor, screen.getByLabelText("品牌"), "顾家"); await actor.type(screen.getByLabelText("企划负责人"), "企划A"); const imageFile = new File(["image"], "preview.png", { type: "image/png" }); @@ -521,13 +544,15 @@ describe("NonIntegratedDetailClient", () => { ); expect(screen.getByRole("heading", { name: "新增系列产品" })).toBeInTheDocument(); - expect(screen.getByRole("option", { name: "单板桌系列" })).toBeInTheDocument(); + await actor.click(screen.getByLabelText("系列名称")); + expect(screen.getByRole("listbox")).toHaveTextContent("单板桌系列"); + await actor.keyboard("{Escape}"); - await actor.selectOptions(screen.getByLabelText("系列名称"), "单板桌系列"); + await selectInlineOption(actor, screen.getByLabelText("系列名称"), "单板桌系列"); await actor.type(screen.getByLabelText("二级类目"), "桌类"); await actor.type(screen.getByLabelText("商品类目"), "书桌"); await actor.type(screen.getByLabelText("外观设计师"), "外观A"); - await actor.selectOptions(screen.getByLabelText("品牌"), "顾家"); + await selectInlineOption(actor, screen.getByLabelText("品牌"), "顾家"); await actor.type(screen.getByLabelText("企划负责人"), "企划A"); const imageFile = new File(["image"], "series.png", { type: "image/png" }); @@ -622,9 +647,9 @@ describe("NonIntegratedDetailClient", () => { expect(screen.getByLabelText("系列名称")).toBeInTheDocument(); await actor.type(screen.getByLabelText("SPU"), "CJ0250"); - await actor.selectOptions(screen.getByLabelText("系列名称"), "单板桌系列"); + await selectInlineOption(actor, screen.getByLabelText("系列名称"), "单板桌系列"); await actor.type(screen.getByLabelText("外观设计师"), "外观A"); - await actor.selectOptions(screen.getByLabelText("品牌"), "顾家"); + await selectInlineOption(actor, screen.getByLabelText("品牌"), "顾家"); const imageFile = new File(["image"], "stock-series.png", { type: "image/png", @@ -643,6 +668,9 @@ describe("NonIntegratedDetailClient", () => { expect(savedProducts[0]?.spu).toBe("CJ0250"); expect(savedProducts[0]?.openingReference.productName).toBe("单板桌系列"); expect(savedProducts[0]?.processTemplate).toBe("存量商品"); + expect(savedProducts[0]?.skuBatchConfirmedAt?.["1"]).toEqual( + expect.any(String), + ); expect(savedProducts[0]?.skuItems).toHaveLength(2); expect(savedProducts[0]?.skuItems.map((item) => item.skuCode)).toEqual([ "CJ025001", @@ -739,7 +767,9 @@ describe("NonIntegratedDetailClient", () => { expect(within(dialog).getAllByText("2").length).toBeGreaterThan(0); }); - it("hides already created series names from the create selector", () => { + it("hides already created series names from the create selector", async () => { + const actor = userEvent.setup(); + render( { />, ); - expect( - screen.queryByRole("option", { name: "单板桌系列" }), - ).not.toBeInTheDocument(); - expect(screen.getByRole("option", { name: "餐桌系列" })).toBeInTheDocument(); + await actor.click(screen.getByLabelText("系列名称")); + const seriesOptions = screen.getByRole("listbox"); + expect(seriesOptions).not.toHaveTextContent("单板桌系列"); + expect(seriesOptions).toHaveTextContent("餐桌系列"); }); it("uses detached upload before the product exists when creating remotely", async () => { @@ -776,11 +806,17 @@ describe("NonIntegratedDetailClient", () => { expect(mockedSaveSharedNonIntegratedProduct).toHaveBeenCalledTimes(1), ); + expect(mockedUploadDetachedNonIntegratedFile).toHaveBeenCalledTimes(1); expect(mockedUploadDetachedNonIntegratedFile).toHaveBeenCalledWith( undefined, expect.objectContaining({ name: "remote-preview.png" }), undefined, ); + const [, uploadedFile, uploadProgressHandler] = + mockedUploadDetachedNonIntegratedFile.mock.calls[0] ?? []; + expect(uploadedFile).toBeInstanceOf(File); + expect(uploadedFile.name).toBe("remote-preview.png"); + expect(uploadProgressHandler).toBeUndefined(); expect(mockedSaveSharedNonIntegratedProduct.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ previewImage: "http://test.local/remote-preview.png", @@ -1080,7 +1116,7 @@ describe("NonIntegratedDetailClient", () => { expect(await screen.findByDisplayValue("第一个条码板材")).toBeInTheDocument(); - await actor.selectOptions(screen.getByLabelText("选择条码"), "sku-same-style-2"); + await selectInlineOption(actor, screen.getByLabelText("选择条码"), "sku-same-style-2"); const copiedPartNameInput = await screen.findByDisplayValue( "第一个条码板材", @@ -1223,6 +1259,7 @@ describe("NonIntegratedDetailClient", () => { screen.getByRole("tab", { name: "测试开品产品开发明细" }), ).toBeInTheDocument(); expect(screen.getByLabelText("SPU")).toHaveValue("预计SPU:CJ0267"); + expect(screen.getByDisplayValue("CJ026701")).toBeInTheDocument(); expect(screen.getByLabelText("SPU")).toHaveAttribute("readonly"); expect(screen.getByLabelText("二级类目")).not.toHaveAttribute("readonly"); expect(screen.getByLabelText("商品类目")).not.toHaveAttribute("readonly"); @@ -1421,6 +1458,34 @@ describe("NonIntegratedDetailClient", () => { expect(openingReferenceBrandInput).toHaveAttribute("readonly"); expect(screen.queryByRole("combobox", { name: "品牌" })).not.toBeInTheDocument(); expect(openingReferenceAdaptableBrandButton).toBeInTheDocument(); + expect(openingReferenceAdaptableBrandButton).toHaveAttribute( + "title", + "基本盘、顾家", + ); + + await actor.click(openingReferenceAdaptableBrandButton); + await actor.click(screen.getByLabelText("索菲亚")); + + await actor.click(screen.getByTestId("document-workspace-basic-info-trigger")); + + expect(screen.getByRole("button", { name: "适配品牌" })).toHaveAttribute( + "title", + "基本盘、顾家、索菲亚", + ); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById("DNZ0001-brand-single"); + expect(savedProduct?.adaptableBrands).toEqual([ + "基本盘", + "顾家", + "索菲亚", + ]); + expect(savedProduct?.openingReference.adaptableBrands).toEqual([ + "基本盘", + "顾家", + "索菲亚", + ]); + }); }); it("shows the document sidebar in the updated workflow order", () => { @@ -1705,28 +1770,16 @@ describe("NonIntegratedDetailClient", () => { expect(screen.getByRole("button", { name: "下载" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "删除" })).toBeInTheDocument(); - const secondaryCategoryDatalistId = screen - .getByLabelText("二级类目") - .getAttribute("list"); - const productCategoryDatalistId = screen - .getByLabelText("商品类目") - .getAttribute("list"); - expect(secondaryCategoryDatalistId).toBeTruthy(); - expect(productCategoryDatalistId).toBeTruthy(); - - const secondaryCategoryOptions = Array.from( - document - .getElementById(secondaryCategoryDatalistId ?? "") - ?.querySelectorAll("option") ?? [], - ).map((option) => option.getAttribute("value")); - const productCategoryOptions = Array.from( - document - .getElementById(productCategoryDatalistId ?? "") - ?.querySelectorAll("option") ?? [], - ).map((option) => option.getAttribute("value")); - expect(secondaryCategoryOptions).toEqual(expect.arrayContaining(["桌类"])); - expect(productCategoryOptions).toEqual(expect.arrayContaining(["书桌"])); - expect(productCategoryOptions).not.toContain("鞋柜"); + await actor.click(screen.getByLabelText("二级类目")); + expect( + within(openingReferenceWorkspace).getByRole("listbox"), + ).toHaveTextContent("桌类"); + await actor.click(screen.getByLabelText("商品类目")); + const productCategoryOptions = within( + openingReferenceWorkspace, + ).getByRole("listbox"); + expect(productCategoryOptions).toHaveTextContent("书桌"); + expect(productCategoryOptions).not.toHaveTextContent("鞋柜"); await actor.clear(screen.getByLabelText("开品理由")); await actor.type(screen.getByLabelText("开品理由"), "新的开品理由"); @@ -1758,24 +1811,13 @@ describe("NonIntegratedDetailClient", () => { expect(screen.getByLabelText("外观设计师")).toHaveValue("徐霞客"); expect(screen.getByLabelText("结构设计师")).toHaveValue("结构B"); - const basicSecondaryCategoryDatalistId = screen - .getByLabelText("二级类目") - .getAttribute("list"); - const basicProductCategoryDatalistId = screen - .getByLabelText("商品类目") - .getAttribute("list"); - expect(basicSecondaryCategoryDatalistId).toBeTruthy(); - expect(basicProductCategoryDatalistId).toBeTruthy(); - - const basicProductCategoryOptions = Array.from( - document - .getElementById(basicProductCategoryDatalistId ?? "") - ?.querySelectorAll("option") ?? [], - ).map((option) => option.getAttribute("value")); - expect(basicProductCategoryOptions).toEqual( - expect.arrayContaining(["书桌", "手动商品类目"]), - ); - expect(basicProductCategoryOptions).not.toContain("鞋柜"); + await actor.click(screen.getByLabelText("二级类目")); + expect(screen.getByRole("listbox")).toHaveTextContent("桌类"); + await actor.click(screen.getByLabelText("商品类目")); + const basicProductCategoryOptions = screen.getByRole("listbox"); + expect(basicProductCategoryOptions).toHaveTextContent("书桌"); + expect(basicProductCategoryOptions).toHaveTextContent("手动商品类目"); + expect(basicProductCategoryOptions).not.toHaveTextContent("鞋柜"); await actor.clear(screen.getByLabelText("二级类目")); await actor.type(screen.getByLabelText("二级类目"), "柜类"); @@ -1945,35 +1987,27 @@ describe("NonIntegratedDetailClient", () => { const appearanceDesignerInput = screen.getByLabelText("外观设计师"); const structureDesignerInput = screen.getByLabelText("结构设计师"); const planningOwnerInput = screen.getByLabelText("企划负责人"); - const designerDatalistId = appearanceDesignerInput.getAttribute("list"); - const planningOwnerDatalistId = planningOwnerInput.getAttribute("list"); - - expect(designerDatalistId).toBeTruthy(); - expect(planningOwnerDatalistId).toBeTruthy(); - expect(structureDesignerInput).toHaveAttribute("list", designerDatalistId); - expect(planningOwnerInput).not.toHaveAttribute("list", designerDatalistId); - + await actor.click(appearanceDesignerInput); await waitFor(() => { - const designerDatalist = document.getElementById( - designerDatalistId ?? "", - ); - const planningOwnerDatalist = document.getElementById( - planningOwnerDatalistId ?? "", - ); - const designerOptionValues = Array.from( - designerDatalist?.querySelectorAll("option") ?? [], - ).map((option) => option.getAttribute("value")); - const planningOwnerOptionValues = Array.from( - planningOwnerDatalist?.querySelectorAll("option") ?? [], - ).map((option) => option.getAttribute("value")); + const designerOptions = screen.getByRole("listbox"); + expect(designerOptions).toHaveTextContent("陆南亭"); + expect(designerOptions).toHaveTextContent("结构A"); + expect(designerOptions).toHaveTextContent("徐霞客"); + }); - expect(designerOptionValues).toEqual( - expect.arrayContaining(["陆南亭", "结构A", "徐霞客"]), - ); - expect(planningOwnerOptionValues).toEqual( - expect.arrayContaining(["企划A", "企划B"]), - ); - expect(planningOwnerOptionValues).not.toContain("陆南亭"); + await actor.click(structureDesignerInput); + await waitFor(() => { + const structureDesignerOptions = screen.getByRole("listbox"); + expect(structureDesignerOptions).toHaveTextContent("陆南亭"); + expect(structureDesignerOptions).toHaveTextContent("结构A"); + }); + + await actor.click(planningOwnerInput); + await waitFor(() => { + const planningOwnerOptions = screen.getByRole("listbox"); + expect(planningOwnerOptions).toHaveTextContent("企划A"); + expect(planningOwnerOptions).toHaveTextContent("企划B"); + expect(planningOwnerOptions).not.toHaveTextContent("陆南亭"); }); await actor.clear(appearanceDesignerInput); @@ -2160,9 +2194,9 @@ describe("NonIntegratedDetailClient", () => { expect(screen.getByLabelText("外观设计名称")).toHaveValue("胡桃木色窄边书桌"); expect(screen.getByTestId("patent-application-spu")).toHaveValue("SZ0230"); - await actor.selectOptions(screen.getByLabelText("专利状态"), "已有专利"); - await actor.selectOptions(screen.getByLabelText("是否SA"), "是"); - await actor.selectOptions(screen.getByLabelText("销售状态"), "待上市"); + await selectInlineOption(actor, screen.getByLabelText("专利状态"), "已有专利"); + await selectInlineOption(actor, screen.getByLabelText("是否SA"), "是"); + await selectInlineOption(actor, screen.getByLabelText("销售状态"), "待上市"); await actor.type(screen.getByLabelText("申请号"), "APP-001"); await actor.type(screen.getByLabelText("申请日"), "2026-04-30"); await actor.type(screen.getByLabelText("申请成功后的专利号"), "PAT-001"); @@ -2398,6 +2432,41 @@ describe("NonIntegratedDetailClient", () => { ).not.toBeInTheDocument(); }); + it("keeps imported SKU workbook barcode readonly instead of recalculating it", () => { + saveNonIntegratedProduct( + createProductRecord({ + id: "ZWJ0218-imported-sku-lock", + spu: "ZWJ0218", + skuItems: [ + createSkuItem({ + id: "sku-imported-lock-1", + skuSequence: 1, + model: "", + skuCode: "ZWJ021801", + barcode: "ZWJ02180131978877V01", + manufacturer: "曹县嘉欢", + manufacturerCode: "N396", + source: "sku_workbook", + sourceTable: "sku_workbook", + contentLocked: true, + }), + ], + }), + ); + + render( + , + ); + + const importedBarcode = screen + .getAllByDisplayValue("ZWJ02180131978877V01") + .find((element) => element.tagName.toLowerCase() === "textarea"); + expect(importedBarcode).toBeDefined(); + expect(importedBarcode).toHaveAttribute("readonly"); + expect(screen.getByDisplayValue("N396")).toBeInTheDocument(); + expect(screen.queryByDisplayValue("ZWJ021801N396V01")).not.toBeInTheDocument(); + }); + it("persists the SKU national standard compliance selection", async () => { const actor = userEvent.setup(); @@ -2432,10 +2501,7 @@ describe("NonIntegratedDetailClient", () => { const skuRow = screen.getAllByDisplayValue("SZ0230K01")[0]?.closest("tr"); expect(skuRow).not.toBeNull(); - await actor.selectOptions( - within(skuRow!).getByRole("combobox", { name: "符合国标" }), - "是", - ); + await selectInlineOption(actor, within(skuRow!).getByRole("combobox", { name: "符合国标" }), "是"); await waitFor(() => { const savedProduct = getNonIntegratedProductById( @@ -2447,7 +2513,320 @@ describe("NonIntegratedDetailClient", () => { }); }); - it("hides redundant fields from the product detail summary", () => { + it("persists the SKU board material type selection", async () => { + const actor = userEvent.setup(); + + saveNonIntegratedProduct( + createProductRecord({ + id: "SZ0230-board-material-type", + spu: "SZ0230", + skuItems: [ + createSkuItem({ + id: "sku-board-material-type-1", + model: "SZ0230K01", + }), + ], + }), + ); + + render( + , + ); + + const skuRow = screen.getByDisplayValue("SZ0230K01").closest("tr"); + expect(skuRow).not.toBeNull(); + + await selectInlineOption(actor, within(skuRow!).getByRole("combobox", { name: "SZ0230K01 板材类型" }), "ENF级"); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById( + "SZ0230-board-material-type", + ); + expect(savedProduct?.skuItems[0]?.boardMaterialType).toBe("ENF级"); + }); + }); + + it("requires complete SKU info before showing the SKU confirmation action", async () => { + const actor = userEvent.setup(); + + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + createProductRecord({ + id: "DNZ0001-sku-confirmation-incomplete", + skuItems: [ + createSkuItem({ + boardMaterialType: "", + executeStandard: "GB/T 3324", + nationalStandardCompliant: "是", + manufacturer: "测试供应商", + manufacturerCode: "GYS001", + salesStatus: "销售", + }), + ], + skuBatchCount: 1, + skuBatchChangeTypes: { 1: "新品" }, + skuBatchFlowNos: { 1: "DD20260519001" }, + }), + ]), + ); + + render( + , + ); + + await actor.click(screen.getByTestId("document-workspace-basic-info-trigger")); + + expect( + screen.queryByTestId("non-integrated-confirm-sku-content"), + ).not.toBeInTheDocument(); + + await actor.click(screen.getByTestId("non-integrated-check-sku-content")); + + expect( + screen.getByText(/SKU信息未填写完整.*第1行缺少:板材类型/), + ).toBeInTheDocument(); + }); + + it("locks only the confirmed SKU batch after a second confirmation", async () => { + const actor = userEvent.setup(); + const productId = "DNZ0001-sku-confirmation-complete"; + + saveNonIntegratedProduct( + createProductRecord({ + id: productId, + spu: "DNZ0001", + skuItems: [ + createSkuItem({ + boardMaterialType: "ENF级", + executeStandard: "GB/T 3324", + nationalStandardCompliant: "是", + manufacturer: "测试供应商", + manufacturerCode: "GYS001", + salesStatus: "销售", + }), + ], + skuBatchCount: 1, + skuBatchChangeTypes: { 1: "新品" }, + skuBatchFlowNos: { 1: "DD20260519001" }, + }), + ); + + render( + , + ); + + await actor.click(screen.getByTestId("document-workspace-basic-info-trigger")); + + const confirmButton = screen.getByTestId( + "non-integrated-confirm-sku-content", + ); + expect(confirmButton).toHaveTextContent("确认SKU信息"); + + await actor.click(confirmButton); + + expect( + screen.getByText( + /批次1 SKU 信息已检查完整.*再次点击“确认SKU信息”后/, + ), + ).toBeInTheDocument(); + await waitFor(() => { + expect( + screen.getByTestId("non-integrated-confirm-sku-content"), + ).toHaveTextContent(/^再次确认SKU信息$/); + }); + const secondConfirmButton = screen.getByTestId( + "non-integrated-confirm-sku-content", + ); + + await actor.click(secondConfirmButton); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById(productId); + expect(savedProduct?.skuBatchConfirmedAt?.["1"]).toEqual( + expect.any(String), + ); + }); + + expect(await screen.findByText("已确认SKU信息")).toBeInTheDocument(); + expect( + screen.queryByTestId("non-integrated-confirm-sku-content"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("non-integrated-add-sku"), + ).not.toBeInTheDocument(); + expect(screen.queryByText("上传资料")).not.toBeInTheDocument(); + expect(screen.getByText("下载资料")).toBeInTheDocument(); + expect(screen.getByText("上传图片")).toBeInTheDocument(); + expect(screen.getByText("新增批次")).toBeInTheDocument(); + }); + + it("hides the SKU confirmation prompt after five seconds", async () => { + jest.useFakeTimers(); + const actor = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + + try { + saveNonIntegratedProduct( + createProductRecord({ + id: "DNZ0001-sku-confirmation-prompt-timeout", + skuItems: [ + createSkuItem({ + boardMaterialType: "", + executeStandard: "GB/T 3324", + nationalStandardCompliant: "是", + manufacturer: "测试供应商", + manufacturerCode: "GYS001", + salesStatus: "销售", + }), + ], + skuBatchCount: 1, + skuBatchChangeTypes: { 1: "新品" }, + skuBatchFlowNos: { 1: "DD20260519001" }, + }), + ); + + render( + , + ); + + await actor.click( + screen.getByTestId("document-workspace-basic-info-trigger"), + ); + await actor.click(screen.getByTestId("non-integrated-check-sku-content")); + + expect( + screen.getByText(/SKU信息未填写完整.*第1行缺少:板材类型/), + ).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(5000); + }); + + expect( + screen.queryByText(/SKU信息未填写完整.*第1行缺少:板材类型/), + ).not.toBeInTheDocument(); + } finally { + jest.useRealTimers(); + } + }); + + it("lets admins reopen confirmed SKU content before sample measurement is approved", async () => { + const actor = userEvent.setup(); + const productId = "DNZ0001-sku-reopen-before-measurement"; + + saveNonIntegratedProduct( + createProductRecord({ + id: productId, + spu: "DNZ0001", + skuItems: [ + createSkuItem({ + boardMaterialType: "ENF级", + executeStandard: "GB/T 3324", + nationalStandardCompliant: "是", + manufacturer: "测试供应商", + manufacturerCode: "GYS001", + salesStatus: "销售", + }), + ], + skuBatchCount: 1, + skuBatchChangeTypes: { 1: "新品" }, + skuBatchFlowNos: { 1: "DD20260519001" }, + skuBatchConfirmedAt: { 1: "2026-05-19T10:00:00.000Z" }, + }), + ); + + render( + , + ); + + await actor.click(screen.getByTestId("document-workspace-basic-info-trigger")); + + expect(screen.getByTestId("non-integrated-reopen-sku-content")).toBeEnabled(); + + await actor.click(screen.getByTestId("non-integrated-reopen-sku-content")); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById(productId); + expect(savedProduct?.skuBatchConfirmedAt?.["1"]).toBeUndefined(); + expect(savedProduct?.skuItems[0]?.model).toBe("SZ0230K01"); + }); + + expect( + screen.getByTestId("non-integrated-confirm-sku-content"), + ).toHaveTextContent("确认SKU信息"); + }); + + it("prevents admins from reopening SKU content after sample measurement is approved", async () => { + const actor = userEvent.setup(); + + saveNonIntegratedProduct( + createProductRecord({ + id: "DNZ0001-sku-reopen-after-measurement", + spu: "DNZ0001", + skuItems: [ + createSkuItem({ + boardMaterialType: "ENF级", + executeStandard: "GB/T 3324", + nationalStandardCompliant: "是", + manufacturer: "测试供应商", + manufacturerCode: "GYS001", + salesStatus: "销售", + }), + ], + skuBatchCount: 1, + skuBatchChangeTypes: { 1: "新品" }, + skuBatchFlowNos: { 1: "DD20260519001" }, + skuBatchConfirmedAt: { 1: "2026-05-19T10:00:00.000Z" }, + documentWorkspaceConfirmations: { + ...createDefaultDocumentWorkspaceConfirmations(), + 样包测量: true, + }, + documentWorkspaceApprovedAt: { + 样包测量: "2026-05-19T11:00:00.000Z", + }, + }), + ); + + render( + , + ); + + await actor.click(screen.getByTestId("document-workspace-basic-info-trigger")); + + expect(screen.getByTestId("non-integrated-reopen-sku-content")).toBeDisabled(); + }); + + it("hides redundant fields from the product detail summary", async () => { + const actor = userEvent.setup(); + saveNonIntegratedProduct( createProductRecord({ id: "SZ0230-compact-summary", @@ -2479,7 +2858,9 @@ describe("NonIntegratedDetailClient", () => { expect(screen.getByLabelText("SPU负责人")).toHaveValue("徐霞客"); expect(screen.getByLabelText("空间设计师")).toHaveValue("测试视觉策划"); expect(screen.getByLabelText("材质类型")).toHaveValue("橡胶木"); - expect(screen.getByRole("option", { name: "岩板+钢架" })).toBeInTheDocument(); + await actor.click(screen.getByLabelText("材质类型")); + expect(screen.getByRole("listbox")).toHaveTextContent("岩板+钢架"); + await actor.keyboard("{Escape}"); [ "商品ID", @@ -2529,7 +2910,7 @@ describe("NonIntegratedDetailClient", () => { await actor.clear(screen.getByLabelText("空间设计师")); await actor.type(screen.getByLabelText("空间设计师"), "空间A"); - await actor.selectOptions(screen.getByLabelText("材质类型"), "岩板+钢架"); + await selectInlineOption(actor, screen.getByLabelText("材质类型"), "岩板+钢架"); await waitFor(() => { const savedProduct = getNonIntegratedProductById( @@ -2555,7 +2936,7 @@ describe("NonIntegratedDetailClient", () => { expect(screen.getByLabelText("材质类型")).toHaveValue("岩板+钢架"); await actor.clear(screen.getByLabelText("空间设计师")); - await actor.selectOptions(screen.getByLabelText("材质类型"), ""); + await selectInlineOption(actor, screen.getByLabelText("材质类型"), ""); await waitFor(() => { const savedProduct = getNonIntegratedProductById( @@ -2599,7 +2980,7 @@ describe("NonIntegratedDetailClient", () => { await actor.clear(screen.getByLabelText("空间设计师")); await actor.type(screen.getByLabelText("空间设计师"), "空间新"); - await actor.selectOptions(screen.getByLabelText("材质类型"), "亚克力"); + await selectInlineOption(actor, screen.getByLabelText("材质类型"), "亚克力"); expect(screen.getByTestId("basic-info-confirmed")).toBeInTheDocument(); expect(screen.queryByTestId("basic-info-confirm")).not.toBeInTheDocument(); @@ -2619,6 +3000,7 @@ describe("NonIntegratedDetailClient", () => { createProductRecord({ id: "SZ0230-sample-measurement", spu: "SZ0230", + skuBatchConfirmedAt: CONFIRMED_SKU_BATCH_AT, skuItems: [ createSkuItem({ id: "sku-sample-1", @@ -2695,6 +3077,27 @@ describe("NonIntegratedDetailClient", () => { }, ]); }); + + const confirmMeasurementButton = screen.getByRole("button", { + name: "确认测量", + }); + expect(confirmMeasurementButton).toBeEnabled(); + + await actor.click(confirmMeasurementButton); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById( + "SZ0230-sample-measurement", + ); + expect(savedProduct?.documentWorkspaceConfirmations?.样包测量).toBe(true); + expect(savedProduct?.documentWorkspaceApprovedAt?.样包测量).toEqual( + expect.any(String), + ); + }); + + expect( + screen.getByTestId("sample-measurement-lengthCm-sku-sample-1"), + ).toHaveAttribute("readonly"); }); it("lets purchasing users edit only purchasing document nodes", async () => { @@ -2706,6 +3109,7 @@ describe("NonIntegratedDetailClient", () => { saveNonIntegratedProduct( createProductRecord({ id: "SZ0230-purchasing-permission", + skuBatchConfirmedAt: CONFIRMED_SKU_BATCH_AT, designer: "测试设计师", purchaser: "采购同事", purchaseOwner: "采购同事", @@ -2776,6 +3180,7 @@ describe("NonIntegratedDetailClient", () => { saveNonIntegratedProduct( createProductRecord({ id: "SZ0230-planning-permission", + skuBatchConfirmedAt: CONFIRMED_SKU_BATCH_AT, designer: "测试设计师", productPlanning: "企划同事", openingReference: { @@ -2885,6 +3290,7 @@ describe("NonIntegratedDetailClient", () => { saveNonIntegratedProduct( createProductRecord({ id: "SZ0230-regular-permission", + skuBatchConfirmedAt: CONFIRMED_SKU_BATCH_AT, designer: "测试设计师", skuItems: [ createSkuItem({ @@ -3030,6 +3436,7 @@ describe("NonIntegratedDetailClient", () => { saveNonIntegratedProduct( createProductRecord({ id: "DNZ0001-product-pricing", + skuBatchConfirmedAt: CONFIRMED_SKU_BATCH_AT, skuItems: [ createSkuItem({ id: "sku-pricing-1", @@ -3081,13 +3488,16 @@ describe("NonIntegratedDetailClient", () => { expect(pricingWorkspace).toHaveTextContent("规格码"); expect(pricingWorkspace).toHaveTextContent("条码"); expect(pricingWorkspace).toHaveTextContent("成本价"); - expect(pricingWorkspace).toHaveTextContent("商品标签"); + expect(pricingWorkspace).toHaveTextContent("是否录入系统"); + expect(pricingWorkspace).not.toHaveTextContent("商品标签"); + expect(pricingWorkspace).not.toHaveTextContent("查看条码分摊"); expect(screen.getByText("SZ0230K01")).toBeInTheDocument(); expect(screen.getByText("SZ023001N1280V01")).toBeInTheDocument(); expect(screen.getAllByText("兰考华兰家具").length).toBeGreaterThan(0); + expect(screen.getByText("未录入")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "确认推送" })).toBeDisabled(); await actor.type(screen.getByPlaceholderText("0"), "88"); - await actor.type(screen.getByPlaceholderText("请输入商品标签"), "首发款"); await waitFor(() => { const savedProduct = getNonIntegratedProductById("DNZ0001-product-pricing"); @@ -3095,10 +3505,192 @@ describe("NonIntegratedDetailClient", () => { { skuId: "sku-pricing-1", costPrice: "88", - productTag: "首发款", + productTag: "", }, ]); }); + + expect(screen.getByRole("button", { name: "确认推送" })).toBeDisabled(); + expect(screen.queryByText("已录入")).not.toBeInTheDocument(); + expect(screen.queryByText("已推送")).not.toBeInTheDocument(); + + const confirmPricingButton = screen.getByRole("button", { + name: "确认核价", + }); + expect(confirmPricingButton).toBeEnabled(); + + await actor.click(confirmPricingButton); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById("DNZ0001-product-pricing"); + expect(savedProduct?.documentWorkspaceConfirmations?.产品核价).toBe(true); + expect(savedProduct?.documentWorkspaceApprovedAt?.产品核价).toEqual( + expect.any(String), + ); + }); + + expect(screen.getByPlaceholderText("0")).toHaveAttribute("readonly"); + expect(screen.queryByPlaceholderText("请输入商品标签")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "减少成本价" })).toBeDisabled(); + }); + + it("lets only admins reopen confirmed product factory rows before sample measurement is approved", async () => { + const actor = userEvent.setup(); + const productId = "DNZ0001-product-factory-reopen"; + const confirmedFactoryRow = { + skuId: "sku-factory-reopen-1", + confirmed: true, + model: "SZ0230K01", + skuCode: "SZ023001", + setCount: "1", + barcode: "SZ023001N1280V01", + specName: "款式一 140/60/75", + size: "140/60/75", + color: "胡桃色", + material: "橡胶木", + installMethod: "拆装", + supplierName: "兰考华兰家具", + supplierCode: "N1280", + warehouse: "郑州仓", + minimumOrderQuantity: "50", + leadTime: "30天", + assemblyMode: "", + productionType: "常规生产", + installationService: "无", + supplierCategory: "主供", + purchaseOrderMethod: "系统下单", + executeStandard: "GB/T 3324", + }; + + saveNonIntegratedProduct( + createProductRecord({ + id: productId, + skuBatchConfirmedAt: CONFIRMED_SKU_BATCH_AT, + skuItems: [ + createSkuItem({ + id: "sku-factory-reopen-1", + barcode: "SZ023001N1280V01", + manufacturer: "兰考华兰家具", + manufacturerCode: "N1280", + installMethod: "拆装", + executeStandard: "GB/T 3324", + }), + ], + factoryRows: [confirmedFactoryRow], + }), + ); + + const { unmount } = render( + , + ); + + await actor.click(screen.getByTestId("document-workspace-trigger-产品定厂")); + expect( + screen.queryByTestId("product-factory-reopen-confirmation"), + ).not.toBeInTheDocument(); + + unmount(); + + render( + , + ); + + await actor.click(screen.getByTestId("document-workspace-trigger-产品定厂")); + + const reopenButton = screen.getByTestId( + "product-factory-reopen-confirmation", + ); + expect(reopenButton).toBeEnabled(); + + await actor.click(reopenButton); + + expect(screen.getByRole("button", { name: "确认定厂" })).toBeInTheDocument(); + expect(screen.getByPlaceholderText("请输入最低起订量")).not.toHaveAttribute( + "readonly", + ); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById(productId); + expect(savedProduct?.factoryRows?.[0]?.confirmed).toBe(false); + }); + }); + + it("prevents admins from reopening product factory after sample measurement is approved", async () => { + const actor = userEvent.setup(); + + saveNonIntegratedProduct( + createProductRecord({ + id: "DNZ0001-product-factory-reopen-locked", + skuBatchConfirmedAt: CONFIRMED_SKU_BATCH_AT, + skuItems: [ + createSkuItem({ + id: "sku-factory-locked-1", + barcode: "SZ023001N1280V01", + manufacturer: "兰考华兰家具", + manufacturerCode: "N1280", + installMethod: "拆装", + executeStandard: "GB/T 3324", + }), + ], + factoryRows: [ + { + skuId: "sku-factory-locked-1", + confirmed: true, + model: "SZ0230K01", + skuCode: "SZ023001", + setCount: "1", + barcode: "SZ023001N1280V01", + specName: "款式一 140/60/75", + size: "140/60/75", + color: "胡桃色", + material: "橡胶木", + installMethod: "拆装", + supplierName: "兰考华兰家具", + supplierCode: "N1280", + warehouse: "郑州仓", + minimumOrderQuantity: "50", + leadTime: "30天", + assemblyMode: "", + productionType: "常规生产", + installationService: "无", + supplierCategory: "主供", + purchaseOrderMethod: "系统下单", + executeStandard: "GB/T 3324", + }, + ], + documentWorkspaceConfirmations: { + ...createDefaultDocumentWorkspaceConfirmations(), + 样包测量: true, + }, + documentWorkspaceApprovedAt: { + 样包测量: "2026-05-19T11:00:00.000Z", + }, + }), + ); + + render( + , + ); + + await actor.click(screen.getByTestId("document-workspace-trigger-产品定厂")); + + expect( + screen.getByTestId("product-factory-reopen-confirmation"), + ).toBeDisabled(); }); it("renders planning layout rows from SKU data without SKU edit actions", async () => { @@ -3107,6 +3699,7 @@ describe("NonIntegratedDetailClient", () => { saveNonIntegratedProduct( createProductRecord({ id: "DNZ0001-planning-layout", + skuBatchConfirmedAt: CONFIRMED_SKU_BATCH_AT, skuItems: [ createSkuItem({ id: "sku-planning-layout-1", @@ -3122,6 +3715,15 @@ describe("NonIntegratedDetailClient", () => { manufacturer: "兰考华兰家具", executeStandard: "GB/T3324-2024", nationalStandardCompliant: "是", + salesStatus: "销售", + }), + createSkuItem({ + id: "sku-planning-layout-stopped", + model: "SZ0230K02", + skuCode: "SZ023002", + barcode: "SZ023002N1280V01", + specName: "停产款式 120/60/75", + salesStatus: "停产", }), ], }), @@ -3139,6 +3741,8 @@ describe("NonIntegratedDetailClient", () => { const planningWorkspace = screen.getByTestId("planning-layout-workspace"); expect(planningWorkspace).toHaveTextContent("SZ0230K01"); expect(planningWorkspace).toHaveTextContent("SZ023001N1280V01"); + expect(planningWorkspace).not.toHaveTextContent("SZ0230K02"); + expect(planningWorkspace).not.toHaveTextContent("SZ023002N1280V01"); expect(planningWorkspace).toHaveTextContent("GB/T3324-2024"); expect(planningWorkspace).toHaveTextContent("是"); expect( @@ -3155,13 +3759,57 @@ describe("NonIntegratedDetailClient", () => { ).not.toBeInTheDocument(); }); + it("treats stock product SKU batches as confirmed without a saved confirmation timestamp", async () => { + const actor = userEvent.setup(); + const planningLayoutCategory = NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES[5]; + + saveNonIntegratedProduct( + createProductRecord({ + id: "STOCK001-confirmed-sku", + processTemplate: "\u5b58\u91cf\u5546\u54c1", + openingReference: { + ...createEmptyNonIntegratedOpeningReference(), + currentStage: "\u5b58\u91cf\u5546\u54c1", + }, + skuItems: [ + createSkuItem({ + id: "stock-sku-confirmed-1", + model: "STOCKK01", + skuCode: "STOCK001", + barcode: "STOCK001V01", + specName: "Stock SKU", + salesStatus: "\u9500\u552e", + }), + ], + }), + ); + + render( + , + ); + + await actor.click(screen.getByTestId("document-workspace-basic-info-trigger")); + expect(screen.getByText("已确认SKU信息")).toBeInTheDocument(); + + await actor.click( + screen.getByTestId(`document-workspace-trigger-${planningLayoutCategory}`), + ); + const planningWorkspace = screen.getByTestId("planning-layout-workspace"); + expect(planningWorkspace).toHaveTextContent("STOCKK01"); + expect(planningWorkspace).toHaveTextContent("STOCK001V01"); + }); + it("persists planning layout classification fields per SKU row", async () => { const actor = userEvent.setup(); saveNonIntegratedProduct( createProductRecord({ id: "DNZ0001-planning-layout-fields", - adaptableBrands: ["基本盘", "顾家"], + skuBatchConfirmedAt: CONFIRMED_SKU_BATCH_AT, + adaptableBrands: ["基本盘"], skuItems: [ createSkuItem({ id: "sku-planning-layout-fields-1", @@ -3170,6 +3818,7 @@ describe("NonIntegratedDetailClient", () => { barcode: "SZ023001N1280V01", specName: "款式一 140/60/75 胡桃色", color: "胡桃色", + salesStatus: "销售", }), ], productLimitRows: [ @@ -3296,8 +3945,8 @@ describe("NonIntegratedDetailClient", () => { categories: ["书柜/架", "书桌/电脑桌"], model: "窄边书桌", priceBand: "500-600档", - brand: "基本盘", - brands: ["基本盘", "顾家"], + brand: "顾家", + brands: ["顾家"], shop: "天猫旗舰店", }, ]); @@ -3910,7 +4559,9 @@ describe("NonIntegratedDetailClient", () => { } }); - it("locks copied products to a single brand and shows a read-only adaptable brand field", () => { + it("locks copied products to a single brand while keeping adaptable brands editable", async () => { + const actor = userEvent.setup(); + window.localStorage.setItem( NON_INTEGRATED_STORAGE_KEY, JSON.stringify([ @@ -3944,9 +4595,31 @@ describe("NonIntegratedDetailClient", () => { expect(adaptableBrandField).not.toBeNull(); expect(brandInput).toHaveValue("顾家"); expect(within(adaptableBrandField!).getByText("顾家")).toBeInTheDocument(); + expect(within(adaptableBrandField!).getByText("+2")).toBeInTheDocument(); expect(brandInput).toHaveAttribute("readonly"); expect(screen.queryByRole("combobox", { name: "品牌" })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "适配品牌" })).not.toBeInTheDocument(); + const adaptableBrandButton = screen.getByRole("button", { name: "适配品牌" }); + expect(adaptableBrandButton).toBeInTheDocument(); + + await actor.click(adaptableBrandButton); + await actor.click(screen.getByLabelText("朴美")); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById("GBGJ-DNZ0001-copied-brand"); + expect(savedProduct?.brand).toBe("顾家"); + expect(savedProduct?.adaptableBrands).toEqual([ + "顾家", + "基本盘", + "索菲亚", + "朴美", + ]); + expect(savedProduct?.openingReference.adaptableBrands).toEqual([ + "顾家", + "基本盘", + "索菲亚", + "朴美", + ]); + }); }); it("keeps the brand field locked after an original product is created", async () => { diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx index 8c6b55cb..fbb75a92 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -21,7 +21,7 @@ import { useRef, useState, } from "react"; -import { ArrowLeft, Ban, Plus, Search, X } from "lucide-react"; +import { ArrowLeft, Ban, ChevronDown, Plus, Search, X } from "lucide-react"; import CameraScannerModal from "@/components/mobile/CameraScannerModal"; import { ModelPreviewPanel } from "@/components/platform/ModelPreviewPanel"; import Button from "@/components/ui/Button"; @@ -231,7 +231,11 @@ import { EXECUTION_STANDARD_OPTIONS, normalizeExecutionStandardLookupKey, } from "./executionStandardDirectory"; -import { SUPPLIER_CODE_BY_NAME, SUPPLIER_DIRECTORY } from "./supplierDirectory"; +import { + SUPPLIER_CODE_BY_NAME, + SUPPLIER_DIRECTORY, + type SupplierDirectoryEntry, +} from "./supplierDirectory"; import { canCreateNonIntegratedProductFromIntake, canEditNonIntegratedProductIdentity, @@ -263,6 +267,7 @@ type SkuColumnWidthKey = | "size" | "color" | "material" + | "boardMaterialType" | "versionNo" | "attributeCode" | "drawingId" @@ -271,6 +276,7 @@ type SkuColumnWidthKey = | "nationalStandardCompliant" | "supplierName" | "manufacturerCode" + | "salesStatus" | "batchNo" | "dingTalkFlowNo"; type ProductFactoryTabKey = "factoryInfo" | "skuInfo"; @@ -301,6 +307,8 @@ const SKU_BATCH_CHANGE_TYPE_OPTIONS: SkuBatchChangeType[] = [ "转厂", ]; const NATIONAL_STANDARD_COMPLIANCE_OPTIONS = ["是", "否"] as const; +const SKU_BOARD_MATERIAL_TYPE_OPTIONS = ["ENF级", "非ENF级", "清货板材"] as const; +const SKU_SALES_STATUS_OPTIONS = ["销售", "停产", "退市"] as const; const SKU_COLUMN_DEFAULT_WIDTHS: Record = { model: 96, @@ -311,6 +319,7 @@ const SKU_COLUMN_DEFAULT_WIDTHS: Record = { size: 96, color: 96, material: 136, + boardMaterialType: 104, versionNo: 56, attributeCode: 96, drawingId: 150, @@ -319,6 +328,7 @@ const SKU_COLUMN_DEFAULT_WIDTHS: Record = { nationalStandardCompliant: 96, supplierName: 140, manufacturerCode: 96, + salesStatus: 96, batchNo: 64, dingTalkFlowNo: 120, }; @@ -566,8 +576,7 @@ type FieldDefinition = { required?: boolean; type?: BasicFieldType; placeholder?: string; - options?: string[]; - datalistId?: string; + options?: readonly string[]; auto?: boolean; }; @@ -784,7 +793,9 @@ const PRODUCT_LIMIT_WORKSPACE_CATEGORY: DocumentWorkspaceCategory = const PLANNING_LAYOUT_WORKSPACE_CATEGORY: DocumentWorkspaceCategory = NON_INTEGRATED_PLANNING_LAYOUT_WORKSPACE_CATEGORY; -function getUniqueNonEmptyStrings(values: Array) { +function getUniqueNonEmptyStrings( + values: ReadonlyArray, +) { return Array.from( new Set( values @@ -794,6 +805,31 @@ function getUniqueNonEmptyStrings(values: Array) { ); } +function normalizeJstSupplierOptions(value: unknown): SupplierDirectoryEntry[] { + if (!Array.isArray(value)) return []; + + const supplierByName = new Map(); + value.forEach((item) => { + if (!item || typeof item !== "object") return; + const record = item as { name?: unknown; code?: unknown }; + const name = typeof record.name === "string" ? record.name.trim() : ""; + if (!name || supplierByName.has(name)) return; + supplierByName.set(name, { + name, + code: typeof record.code === "string" ? record.code.trim().toUpperCase() : "", + }); + }); + + return Array.from(supplierByName.values()); +} + +function normalizeJstWarehouseOptions(value: unknown) { + if (!Array.isArray(value)) return []; + return getUniqueNonEmptyStrings( + value.map((item) => (typeof item === "string" ? item : "")), + ); +} + function normalizeSeriesNameKey(value: string) { return value.trim().toLowerCase(); } @@ -1371,7 +1407,8 @@ const COMPONENT_SKU_CATEGORY_ORDER = new Map( ); const SKU_TOOLBAR_BUTTON_BASE_CLASS = - "inline-flex h-8 shrink-0 items-center justify-center rounded-apple-lg border px-3 text-xs font-apple-medium transition disabled:cursor-not-allowed"; + "inline-flex h-8 min-h-8 shrink-0 items-center justify-center rounded-apple-lg border px-3 text-xs font-apple-medium transition disabled:cursor-not-allowed"; +const SKU_TOOLBAR_STATUS_CLASS = `${SKU_TOOLBAR_BUTTON_BASE_CLASS} cursor-default select-none border-apple-separator bg-apple-bg-secondary text-apple-label-secondary`; const SKU_PRIMARY_ACTION_BUTTON_CLASS = "!h-8 !min-h-8 !rounded-apple-lg !border-zinc-950 !bg-zinc-950 !bg-none !px-3 !py-0 !text-xs !text-white !shadow-none hover:!border-zinc-800 hover:!bg-zinc-800 focus:!border-zinc-600 disabled:!border-zinc-200 disabled:!bg-zinc-200 disabled:!text-zinc-400"; const DETAIL_PRIMARY_ADD_BUTTON_CLASS = @@ -1525,6 +1562,8 @@ const DEFAULT_DOCUMENT_WORKSPACE_CATEGORY: DocumentWorkspaceCategory = const PRODUCT_PHOTO_CATEGORY: DocumentWorkspaceCategory = "产品实拍图"; const AUTO_SAVE_DELAY_MS = 500; +const SKU_CONFIRMATION_PROMPT_VISIBLE_MS = 5000; +const STOCK_PRODUCT_LABEL = "\u5b58\u91cf\u5546\u54c1"; function getChangedWorkflowNodeCategories( previousSignatures: Record | null | undefined, @@ -1545,8 +1584,14 @@ function getChangedWorkflowNodeCategories( ); }); } -const PATENT_APPLICATION_STATUS_OPTIONS: NonIntegratedPatentApplication["patentStatus"][] = - ["待确认", "已有专利", "申请中", "申请失败", "无专利", "放弃维护"]; +const PATENT_APPLICATION_STATUS_OPTIONS = [ + "待确认", + "已有专利", + "申请中", + "申请失败", + "无专利", + "放弃维护", +] as const; const PATENT_APPLICATION_DECISION_OPTIONS: Array<{ value: Exclude; label: string; @@ -1555,10 +1600,12 @@ const PATENT_APPLICATION_DECISION_OPTIONS: Array<{ { value: "申请", label: "是" }, { value: "不申请", label: "否" }, ]; -const PATENT_APPLICATION_SA_REQUIRED_OPTIONS: NonIntegratedPatentApplication["saRequired"][] = - ["否", "是"]; -const PATENT_APPLICATION_SALES_STATUS_OPTIONS: NonIntegratedPatentApplication["salesStatus"][] = - ["在售", "待上市", "停售"]; +const PATENT_APPLICATION_SA_REQUIRED_OPTIONS = ["否", "是"] as const; +const PATENT_APPLICATION_SALES_STATUS_OPTIONS = [ + "在售", + "待上市", + "停售", +] as const; const QUALITY_INSPECTION_ROW_FIELD_KEYS = [ ...QUALITY_INSPECTION_EDITABLE_FIELD_KEYS, ...QUALITY_INSPECTION_IMAGE_FIELD_KEYS, @@ -1855,11 +1902,11 @@ const DOCUMENT_WORKSPACE_COPY: Record< emptyDescription: "可先上传卖点图或文案文件,再在这里集中预览。", }, 企划布局: { - description: "按 SKU 带出企划布局需要查看和维护的基础信息。", - noteLabel: "企划布局备注", - notePlaceholder: "可填写企划布局补充说明。", - emptyTitle: "当前还没有企划布局资料", - emptyDescription: "请先维护 SKU 信息,再在这里查看企划布局。", + description: "按 SKU 带出空间布局需要查看和维护的基础信息。", + noteLabel: "空间布局备注", + notePlaceholder: "可填写空间布局补充说明。", + emptyTitle: "当前还没有空间布局资料", + emptyDescription: "请先维护 SKU 信息,再在这里查看空间布局。", }, 安装方式: { description: "按款式集中维护安装图纸、安装视频和版本迭代资料。", @@ -2138,6 +2185,8 @@ function getProductPhotoPanelCompactTitle(group: ProductPhotoGroup) { } const BRAND_OPTIONS = ["基本盘", "顾家", "索菲亚", "朴美"]; +const PLANNING_LAYOUT_BRAND_OPTIONS = ["基本盘", "顾家", "索菲亚", "朴美"]; + const MATERIAL_TYPE_OPTIONS = [ "软包", "注塑", @@ -2624,6 +2673,7 @@ function createEmptySkuItem(): NonIntegratedSkuItem { size: "", color: "", material: "", + boardMaterialType: "", versionNo: "V01", attributeCode: "", drawingId: "", @@ -2632,6 +2682,7 @@ function createEmptySkuItem(): NonIntegratedSkuItem { nationalStandardCompliant: "", manufacturer: "", manufacturerCode: "", + salesStatus: "", tagMaterial: "", model3dFileName: "", model3dDataUrl: "", @@ -3219,6 +3270,25 @@ function buildAutoBarcode( return `${normalizedSkuCode}${normalizedManufacturerCode}${normalizedVersionNo}`; } +const SKU_WORKBOOK_SOURCE = "sku_workbook"; + +function normalizeSkuSourceMarker(value: string | undefined) { + return (value ?? "").trim().toLowerCase().replace(/-/g, "_"); +} + +function isImportedSkuContentLocked(item: NonIntegratedSkuItem) { + const source = normalizeSkuSourceMarker(item.source); + const sourceTable = normalizeSkuSourceMarker(item.sourceTable); + + return ( + item.contentLocked === true || + source === SKU_WORKBOOK_SOURCE || + sourceTable === SKU_WORKBOOK_SOURCE || + source === "jst_sku" || + sourceTable === "jst_sku" + ); +} + function buildAutoBarcodeSkuCandidates(skuCode: string) { const normalizedSkuCode = skuCode.trim().toUpperCase(); const normalizedBaseSkuCode = @@ -4641,12 +4711,23 @@ function deriveSkuItem( const autoSpecName = buildAutoSpecName(item); const versionNo = normalizeSkuVersionNo(item.versionNo); const normalizedItem = { ...item, versionNo }; - const autoDrawingId = buildAutoDrawingId(normalizedItem); + const importedContentLocked = isImportedSkuContentLocked(item); + const resolvedSkuCode = + importedContentLocked && item.skuCode.trim() + ? item.skuCode.trim().toUpperCase() + : finalSkuCode; + const autoDrawingId = buildAutoDrawingId({ + ...normalizedItem, + skuCode: resolvedSkuCode, + }); const autoBarcode = buildAutoBarcode( - finalSkuCode, + resolvedSkuCode, item.manufacturerCode, versionNo, ); + const importedLockedBarcode = importedContentLocked + ? item.barcode.trim().toUpperCase() + : ""; return { ...item, @@ -4654,20 +4735,28 @@ function deriveSkuItem( Number.isFinite(item.batchNo) && item.batchNo > 0 ? Math.floor(item.batchNo) : 1, - skuCode: finalSkuCode, - specName: autoSpecName, - barcode: shouldKeepCustomBarcode( - item.barcode, - finalSkuCode, - item.manufacturerCode, - versionNo, - ) - ? item.barcode.trim() - : autoBarcode, + skuCode: resolvedSkuCode, + specName: + importedContentLocked && item.specName.trim() + ? item.specName.trim() + : autoSpecName, + barcode: + importedLockedBarcode || + (shouldKeepCustomBarcode( + item.barcode, + resolvedSkuCode, + item.manufacturerCode, + versionNo, + ) + ? item.barcode.trim() + : autoBarcode), versionNo, - drawingId: shouldKeepCustomDrawingId(item.drawingId, normalizedItem) - ? item.drawingId.trim() - : autoDrawingId, + drawingId: + importedContentLocked && item.drawingId.trim() + ? item.drawingId.trim().toUpperCase() + : shouldKeepCustomDrawingId(item.drawingId, normalizedItem) + ? item.drawingId.trim() + : autoDrawingId, installMethod: item.installMethod.trim(), executeStandard: item.executeStandard.trim(), nationalStandardCompliant: normalizeNationalStandardCompliance( @@ -4675,6 +4764,8 @@ function deriveSkuItem( ), manufacturer: item.manufacturer.trim(), manufacturerCode: item.manufacturerCode.trim().toUpperCase(), + salesStatus: item.salesStatus?.trim() ?? "", + boardMaterialType: item.boardMaterialType?.trim() ?? "", tagMaterial: formValues.tagMaterial.trim() || item.tagMaterial.trim(), }; } @@ -4701,10 +4792,15 @@ function buildSkuItemFromImportedRow( const supplier = normalizeFactorySupplier(row.manufacturer); const manufacturer = supplier.name || row.manufacturer.trim(); const manufacturerCode = - row.manufacturerCode.trim().toUpperCase() || supplier.code; + supplier.code || row.manufacturerCode.trim().toUpperCase(); const normalizedExecuteStandard = resolveExecutionStandardOption(row.executeStandard) ?? row.executeStandard.trim(); + const skuCode = row.skuCode.trim().toUpperCase(); + const versionNo = normalizeSkuVersionNo(row.versionNo); + const barcode = + row.barcode.trim().toUpperCase() || + buildAutoBarcode(skuCode, manufacturerCode, versionNo); return { ...baseItem, @@ -4717,14 +4813,15 @@ function buildSkuItemFromImportedRow( : fallbackSequence, batchNo: parseImportedSkuBatchNo(row.batchNo, baseItem.batchNo || 1), model: applyBrandPrefixToNonIntegratedCode(row.model, formValues.brand), - skuCode: row.skuCode.trim().toUpperCase(), + skuCode, setCount: String(normalizeSetCountValue(row.setCount || "1")), specName: row.specName.trim(), - barcode: row.barcode.trim().toUpperCase(), + barcode, size: row.size.trim(), color: row.color.trim(), material: row.material.trim(), - versionNo: normalizeSkuVersionNo(row.versionNo), + boardMaterialType: row.boardMaterialType.trim(), + versionNo, attributeCode: row.attributeCode.trim().toUpperCase(), drawingId: row.drawingId.trim().toUpperCase(), installMethod: row.installMethod.trim(), @@ -4734,13 +4831,23 @@ function buildSkuItemFromImportedRow( ), manufacturer, manufacturerCode, + salesStatus: row.salesStatus.trim(), tagMaterial: formValues.tagMaterial.trim() || baseItem.tagMaterial, + source: SKU_WORKBOOK_SOURCE, + sourceTable: SKU_WORKBOOK_SOURCE, + contentLocked: true, }; } -function normalizeFactorySupplier(value: string) { +function normalizeFactorySupplier( + value: string, + supplierCodeByName: ReadonlyMap = SUPPLIER_CODE_BY_NAME, +) { const normalizedName = value.trim(); - const matchedSupplier = resolveSupplierEntryByName(normalizedName); + const matchedSupplier = resolveSupplierEntryByName( + normalizedName, + supplierCodeByName, + ); return { name: matchedSupplier ? matchedSupplier.name : normalizedName, @@ -4775,16 +4882,26 @@ function resolveAutoAwareBarcode( function createProductFactoryRowFromSku( sku: NonIntegratedSkuItem, existingRow?: NonIntegratedFactoryRow, + supplierCodeByName: ReadonlyMap = SUPPLIER_CODE_BY_NAME, ): NonIntegratedFactoryRow { const confirmed = existingRow?.confirmed ?? false; const savedSupplierName = existingRow?.supplierName?.trim() ?? ""; - const supplierSource = confirmed - ? sku.manufacturer || savedSupplierName - : savedSupplierName || sku.manufacturer; - const supplier = normalizeFactorySupplier(supplierSource); + const importedContentLocked = isImportedSkuContentLocked(sku); + const supplierSource = + importedContentLocked && !confirmed + ? sku.manufacturer || savedSupplierName + : confirmed + ? sku.manufacturer || savedSupplierName + : savedSupplierName || sku.manufacturer; + const supplier = normalizeFactorySupplier(supplierSource, supplierCodeByName); const supplierCode = - confirmed - ? sku.manufacturerCode.trim().toUpperCase() || supplier.code + importedContentLocked && !confirmed + ? supplier.code || + sku.manufacturerCode.trim().toUpperCase() || + existingRow?.supplierCode?.trim().toUpperCase() || + "" + : confirmed + ? sku.manufacturerCode.trim().toUpperCase() || supplier.code : existingRow?.supplierCode?.trim().toUpperCase() || supplier.code || sku.manufacturerCode.trim().toUpperCase(); @@ -4823,19 +4940,28 @@ function createProductFactoryRowFromSku( function buildProductFactoryRows( skuItems: NonIntegratedSkuItem[], savedRows: NonIntegratedFactoryRow[], + supplierCodeByName: ReadonlyMap = SUPPLIER_CODE_BY_NAME, ) { const rowBySkuId = new Map(savedRows.map((row) => [row.skuId, row])); return skuItems.map((sku) => - createProductFactoryRowFromSku(sku, rowBySkuId.get(sku.id)), + createProductFactoryRowFromSku( + sku, + rowBySkuId.get(sku.id), + supplierCodeByName, + ), ); } function normalizeFactoryRowsForPayload( rows: NonIntegratedFactoryRow[], + supplierCodeByName: ReadonlyMap = SUPPLIER_CODE_BY_NAME, ): NonIntegratedFactoryRow[] { return rows.map((row) => { - const supplier = normalizeFactorySupplier(row.supplierName); + const supplier = normalizeFactorySupplier( + row.supplierName, + supplierCodeByName, + ); const supplierName = supplier.name || row.supplierName.trim(); const supplierCode = row.supplierCode.trim().toUpperCase() || supplier.code; @@ -4896,6 +5022,7 @@ type ProductPricingDisplayRow = NonIntegratedPricingRow & { setCount: string; specName: string; supplierName: string; + systemEnteredAt: string; }; type PlanningLayoutSkuRow = { @@ -4957,6 +5084,7 @@ function createProductPricingRowFromSku( supplierName: sku.manufacturer.trim() || "-", costPrice: existingRow?.costPrice?.trim() ?? "", productTag: existingRow?.productTag?.trim() ?? "", + systemEnteredAt: existingRow?.systemEnteredAt?.trim() ?? "", }; } @@ -4978,16 +5106,23 @@ function normalizePricingRowsForPayload( rows: NonIntegratedPricingRow[], ): NonIntegratedPricingRow[] { return rows - .map((row) => ({ - skuId: row.skuId, - costPrice: row.costPrice.trim(), - productTag: row.productTag.trim(), - })) - .filter((row) => row.skuId && (row.costPrice || row.productTag)); + .map((row) => { + const systemEnteredAt = row.systemEnteredAt?.trim() ?? ""; + return { + skuId: row.skuId, + costPrice: row.costPrice.trim(), + productTag: row.productTag.trim(), + ...(systemEnteredAt ? { systemEnteredAt } : {}), + }; + }) + .filter( + (row) => + row.skuId && (row.costPrice || row.productTag || row.systemEnteredAt), + ); } function isProductPricingRowComplete(row: NonIntegratedPricingRow) { - return [row.costPrice, row.productTag].every((value) => value.trim()); + return row.costPrice.trim().length > 0; } function createProductLimitRowFromSku( @@ -5128,16 +5263,19 @@ function isProductLimitRowComplete(row: NonIntegratedProductLimitRow) { ].every((value) => value.trim()); } +function isPlanningLayoutVisibleSkuItem(sku: NonIntegratedSkuItem) { + return sku.salesStatus.trim() === "销售"; +} + function buildPlanningLayoutSkuRows( skuItems: NonIntegratedSkuItem[], savedRows: NonIntegratedPlanningLayoutRow[], productLimitRows: NonIntegratedProductLimitRow[], - defaultAdaptableBrand: string, ): PlanningLayoutSkuRow[] { const rowBySkuId = new Map(savedRows.map((row) => [row.skuId, row])); const limitRowBySkuId = new Map(productLimitRows.map((row) => [row.skuId, row])); - return skuItems.map((item) => ({ + return skuItems.filter(isPlanningLayoutVisibleSkuItem).map((item) => ({ ...(() => { const row = rowBySkuId.get(item.id); const savedBrands = getUniqueNonEmptyStrings([ @@ -5173,10 +5311,7 @@ function buildPlanningLayoutSkuRows( categories: row?.categories ?? [], planningModel: row?.model.trim() ?? "", priceBand: row?.priceBand.trim() || autoPriceBand, - brands: - savedBrands.length > 0 || row - ? savedBrands - : getUniqueNonEmptyStrings([defaultAdaptableBrand]), + brands: savedBrands, shop: row?.shop.trim() ?? "", }; })(), @@ -5419,9 +5554,7 @@ function createFormValuesFromProduct( flatOutputPath: product.flatOutputPath ?? "", output3dPath: product.output3dPath ?? "", brand: normalizedBrand, - adaptableBrands: product.copiedFromProductId - ? [normalizedBrand] - : normalizedAdaptableBrands, + adaptableBrands: normalizedAdaptableBrands, designer: normalizedOpeningReference.designer || product.designer || "", operator: product.operator ?? "", purchaser: product.purchaser ?? "", @@ -5887,13 +6020,312 @@ function CreateFieldShell({ children }: { children: ReactNode }) { ); } +function InlineSuggestionInput({ + ariaLabel, + dataTestId, + value, + options, + placeholder, + readOnly = false, + wrapperClassName = "relative", + inputClassName, + panelClassName = "absolute left-0 right-0 top-[calc(100%+4px)] z-50 max-h-48 overflow-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-1 shadow-apple-md", + optionClassName = "flex w-full items-center rounded-apple-md px-2.5 py-1.5 text-left text-xs transition", + showChevron = true, + filterOptions = false, + closeOnChange, + onChange, + onSelect, + onCommit, +}: { + ariaLabel: string; + dataTestId?: string; + value: string; + options?: readonly string[]; + placeholder?: string; + readOnly?: boolean; + wrapperClassName?: string; + inputClassName?: string; + panelClassName?: string; + optionClassName?: string; + showChevron?: boolean; + filterOptions?: boolean; + closeOnChange?: (value: string) => boolean; + onChange: (value: string) => void; + onSelect?: (value: string) => void; + onCommit?: () => void; +}) { + const suggestionListId = useId(); + const [isOpen, setIsOpen] = useState(false); + const previousValueRef = useRef(value); + const normalizedOptions = getUniqueNonEmptyStrings(options ?? []); + const keyword = value.trim().toLowerCase(); + const visibleOptions = normalizedOptions.filter((option) => + filterOptions && keyword ? option.toLowerCase().includes(keyword) : true, + ); + const hasOptions = !readOnly && normalizedOptions.length > 0; + + useEffect(() => { + if (previousValueRef.current && !value) { + setIsOpen(false); + } + previousValueRef.current = value; + }, [value]); + + const closeLater = useCallback(() => { + setIsOpen(false); + }, []); + + const handleSelect = useCallback( + (option: string) => { + onChange(option); + onSelect?.(option); + setIsOpen(false); + }, + [onChange, onSelect], + ); + + return ( +
+ { + const nextValue = event.target.value; + onChange(nextValue); + if (hasOptions) { + setIsOpen(closeOnChange?.(nextValue) ? false : true); + } + }} + onFocus={() => { + if (hasOptions) setIsOpen(true); + }} + onBlur={() => { + closeLater(); + onCommit?.(); + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + setIsOpen(false); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + onCommit?.(); + setIsOpen(false); + } + }} + placeholder={placeholder} + readOnly={readOnly} + className={inputClassName} + /> + {hasOptions && showChevron ? ( + + ) : null} + {hasOptions && isOpen ? ( +
+ {visibleOptions.length > 0 ? ( + visibleOptions.map((option) => ( + + )) + ) : ( +
+ 没有匹配项 +
+ )} +
+ ) : null} +
+ ); +} + +type InlineSelectOption = + | string + | { + value: string; + label?: string; + disabled?: boolean; + }; + +function normalizeInlineSelectOptions( + options: readonly InlineSelectOption[] | undefined, +): { value: string; label: string; disabled?: boolean }[] { + return (options ?? []).map((option) => + typeof option === "string" + ? { value: option, label: option } + : { value: option.value, label: option.label ?? option.value, disabled: option.disabled }, + ); +} + +function InlineSelect({ + ariaLabel, + dataTestId, + value, + options, + placeholder, + readOnly = false, + wrapperClassName = "relative", + buttonClassName, + panelClassName = "absolute left-0 right-0 top-[calc(100%+4px)] z-50 max-h-48 overflow-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-1 shadow-apple-md", + optionClassName = "flex w-full items-center rounded-apple-md px-2.5 py-1.5 text-left text-xs transition", + includeEmptyOption, + emptyOptionDisabled = false, + onChange, +}: { + ariaLabel: string; + dataTestId?: string; + value: string; + options?: readonly InlineSelectOption[]; + placeholder?: string; + readOnly?: boolean; + wrapperClassName?: string; + buttonClassName?: string; + panelClassName?: string; + optionClassName?: string; + includeEmptyOption?: boolean; + emptyOptionDisabled?: boolean; + onChange: (value: string) => void; +}) { + const listId = useId(); + const containerRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const normalizedOptions = normalizeInlineSelectOptions(options); + const shouldIncludeEmptyOption = includeEmptyOption ?? Boolean(placeholder); + const selectOptions = shouldIncludeEmptyOption + ? [ + { + value: "", + label: placeholder ?? "", + disabled: emptyOptionDisabled, + }, + ...normalizedOptions, + ] + : normalizedOptions; + const selectedOption = selectOptions.find((option) => option.value === value); + const displayValue = selectedOption?.label ?? (value || placeholder || ""); + const hasOptions = !readOnly && selectOptions.length > 0; + + useEffect(() => { + if (!isOpen) return; + + function handleDocumentMouseDown(event: MouseEvent) { + if (!containerRef.current?.contains(event.target as Node)) { + setIsOpen(false); + } + } + + document.addEventListener("mousedown", handleDocumentMouseDown); + return () => + document.removeEventListener("mousedown", handleDocumentMouseDown); + }, [isOpen]); + + return ( +
+ + {hasOptions && isOpen ? ( +
+ {selectOptions.map((option) => ( + + ))} +
+ ) : null} +
+ ); +} + function BasicField({ label, required, type = "text", placeholder, options, - datalistId, value, readOnly = false, compact = false, @@ -5903,8 +6335,7 @@ function BasicField({ required?: boolean; type?: BasicFieldType; placeholder?: string; - options?: string[]; - datalistId?: string; + options?: readonly string[]; value: string; readOnly?: boolean; compact?: boolean; @@ -5920,24 +6351,19 @@ function BasicField({ } > - + />
); } @@ -5978,14 +6404,16 @@ function BasicField({ } > - onChange(event.target.value)} + options={options} placeholder={placeholder} readOnly={readOnly} - className={`h-9 w-full rounded-apple-lg border border-apple-separator px-3 text-sm text-apple-label-primary outline-none ${ + onChange={onChange} + inputClassName={`h-9 w-full rounded-apple-lg border border-apple-separator px-3 text-sm text-apple-label-primary outline-none ${ + !readOnly && (options ?? []).length > 0 ? "pr-9" : "" + } ${ readOnly ? "bg-apple-bg-primary text-apple-label-secondary" : "bg-apple-bg-secondary" @@ -7302,11 +7730,14 @@ function attachModelFileNameToUrl(objectUrl: string, fileName: string) { return `${objectUrl}#${encodeURIComponent(normalizedFileName)}`; } -function resolveSupplierEntryByName(value: string) { +function resolveSupplierEntryByName( + value: string, + supplierCodeByName: ReadonlyMap = SUPPLIER_CODE_BY_NAME, +) { const normalizedName = value.trim(); if (!normalizedName) return null; - const code = SUPPLIER_CODE_BY_NAME.get(normalizedName); + const code = supplierCodeByName.get(normalizedName); if (!code) return null; return { @@ -7614,6 +8045,7 @@ function SkuBarcodeField({ function SkuSupplierNameField({ value, + options = SUPPLIER_DIRECTORY.map((entry) => entry.name), readOnly = false, columnWidth = SKU_COLUMN_DEFAULT_WIDTHS.supplierName, onChange, @@ -7621,6 +8053,7 @@ function SkuSupplierNameField({ onColumnResize, }: { value: string; + options?: readonly string[]; readOnly?: boolean; columnWidth?: number; onChange: (value: string) => void; @@ -7632,32 +8065,62 @@ function SkuSupplierNameField({ columnWidth={columnWidth} minWidth={SKU_COLUMN_DEFAULT_WIDTHS.supplierName} onColumnResize={onColumnResize} - className={`rounded border border-apple-separator ${ + className={`overflow-visible rounded border border-apple-separator ${ readOnly ? "bg-apple-bg-primary" : "bg-apple-bg-secondary" }`} > - onChange(event.target.value)} - onBlur={onConfirm} - onKeyDown={(event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - onConfirm(); - }} - className={`h-8 w-full border-0 bg-transparent px-2 text-xs text-apple-label-primary outline-none ${ + filterOptions + onChange={onChange} + onCommit={onConfirm} + inputClassName={`h-8 w-full border-0 bg-transparent px-2 pr-8 text-xs text-apple-label-primary outline-none ${ readOnly ? "text-apple-label-secondary" : "" }`} + panelClassName="absolute left-0 top-[calc(100%+4px)] z-50 max-h-56 min-w-full overflow-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-1 shadow-apple-md" /> ); } +function FactoryWarehouseField({ + value, + options, + readOnly = false, + onChange, +}: { + value: string; + options: readonly string[]; + readOnly?: boolean; + onChange: (value: string) => void; +}) { + return ( +
+ +
+ ); +} + function SkuExecutionStandardField({ value, readOnly = false, @@ -7678,27 +8141,23 @@ function SkuExecutionStandardField({ columnWidth={columnWidth} minWidth={SKU_COLUMN_DEFAULT_WIDTHS.executeStandard} onColumnResize={onColumnResize} - className={`rounded border border-apple-separator ${ + className={`overflow-visible rounded border border-apple-separator ${ readOnly ? "bg-apple-bg-primary" : "bg-apple-bg-secondary" }`} > - onChange(event.target.value)} - onBlur={onConfirm} - onKeyDown={(event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - onConfirm(); - }} - className={`h-8 w-full border-0 bg-transparent px-2 text-xs text-apple-label-primary outline-none ${ + filterOptions + onChange={onChange} + onCommit={onConfirm} + inputClassName={`h-8 w-full border-0 bg-transparent px-2 pr-8 text-xs text-apple-label-primary outline-none ${ readOnly ? "text-apple-label-secondary" : "" }`} + panelClassName="absolute left-0 top-[calc(100%+4px)] z-50 max-h-56 min-w-full overflow-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-1 shadow-apple-md" /> ); @@ -7722,24 +8181,20 @@ function SkuNationalStandardComplianceField({ columnWidth={columnWidth} minWidth={SKU_COLUMN_DEFAULT_WIDTHS.nationalStandardCompliant} onColumnResize={onColumnResize} - className={`rounded border border-apple-separator ${ + className={`overflow-visible rounded border border-apple-separator ${ readOnly ? "bg-apple-bg-primary" : "bg-apple-bg-secondary" }`} > - + options={NATIONAL_STANDARD_COMPLIANCE_OPTIONS} + placeholder="请选择" + readOnly={readOnly} + onChange={onChange} + buttonClassName="h-8 w-full border-0 bg-transparent px-2 text-xs text-apple-label-primary outline-none disabled:cursor-not-allowed disabled:bg-apple-bg-primary disabled:text-apple-label-secondary" + panelClassName="absolute left-0 top-[calc(100%+4px)] z-50 max-h-48 min-w-full overflow-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-1 shadow-apple-md" + /> ); } @@ -8299,6 +8754,47 @@ function BomSteelMaterialField({ ); } +function collectSkuBatchKeys( + skuItems: NonIntegratedSkuItem[], + skuBatchCount: number, +) { + const normalizedBatchCount = Math.max( + 1, + Number.isFinite(skuBatchCount) ? Math.floor(skuBatchCount) : 1, + ); + const batchKeys = new Set(); + + for (let batchNo = 1; batchNo <= normalizedBatchCount; batchNo += 1) { + batchKeys.add(String(batchNo)); + } + + skuItems.forEach((item) => { + const batchNo = Number.isFinite(item.batchNo) + ? Math.max(1, Math.floor(item.batchNo)) + : 1; + batchKeys.add(String(batchNo)); + }); + + return batchKeys; +} + +function ensureSkuBatchConfirmedAt( + skuItems: NonIntegratedSkuItem[], + skuBatchCount: number, + currentConfirmedAt: Record, + confirmedAt: string, +) { + const nextConfirmedAt = { ...currentConfirmedAt }; + + collectSkuBatchKeys(skuItems, skuBatchCount).forEach((batchKey) => { + if (!nextConfirmedAt[batchKey]?.trim()) { + nextConfirmedAt[batchKey] = confirmedAt; + } + }); + + return nextConfirmedAt; +} + function buildProductPayload( id: string, formValues: FormValues, @@ -8324,6 +8820,7 @@ function buildProductPayload( skuBatchCount: number, skuBatchFlowNos: Record, skuBatchChangeTypes: Record, + skuBatchConfirmedAt: Record, createdAt?: string, copiedFromProductId?: string, copiedFromSpu?: string, @@ -8473,16 +8970,14 @@ function buildProductPayload( .filter((detail) => detail !== null) as NonIntegratedBomDetail[]; const normalizedBrand = formValues.brand.trim() || "基本盘"; - const normalizedAdaptableBrands = copiedFromProductId - ? [normalizedBrand] - : Array.from( - new Set([ - normalizedBrand, - ...BRAND_OPTIONS.filter((brand) => - formValues.adaptableBrands.includes(brand), - ), - ]), - ); + const normalizedAdaptableBrands = Array.from( + new Set([ + normalizedBrand, + ...BRAND_OPTIONS.filter((brand) => + formValues.adaptableBrands.includes(brand), + ), + ]), + ); const normalizedReviewRounds = ( reviewRounds.length > 0 ? reviewRounds : [createReviewRound(1)] ).map((round, index) => { @@ -8556,9 +9051,22 @@ function buildProductPayload( rawOpeningReference.productImageName.trim(); const normalizedOpeningReference = { ...rawOpeningReference, + brand: normalizedBrand, + adaptableBrands: normalizedAdaptableBrands, productImage: sharedProductImage, productImageName: sharedProductImageName, }; + const isStockProductPayload = + formValues.processTemplate.trim() === STOCK_PRODUCT_LABEL || + normalizedOpeningReference.currentStage.trim() === STOCK_PRODUCT_LABEL; + const normalizedSkuBatchConfirmedAt = isStockProductPayload + ? ensureSkuBatchConfirmedAt( + skuItems, + skuBatchCount, + skuBatchConfirmedAt, + now, + ) + : skuBatchConfirmedAt; return { id, @@ -8660,6 +9168,7 @@ function buildProductPayload( skuBatchCount, skuBatchFlowNos, skuBatchChangeTypes, + skuBatchConfirmedAt: normalizedSkuBatchConfirmedAt, factoryRows: normalizeFactoryRowsForPayload(factoryRows), pricingRows: normalizePricingRowsForPayload(pricingRows), planningLayoutRows: normalizePlanningLayoutRowsForPayload( @@ -8697,6 +9206,7 @@ export default function NonIntegratedDetailClient({ mode, productId, initialProduct = null, + initialCreateProductKind = "new", initialDocumentWorkspaceCategory, initialDocumentWorkspaceView, embedded = false, @@ -8721,6 +9231,7 @@ export default function NonIntegratedDetailClient({ mode: "create" | "edit" | "view"; productId?: string; initialProduct?: NonIntegratedProductRecord | null; + initialCreateProductKind?: CreateProductKind; initialDocumentWorkspaceCategory?: DocumentWorkspaceCategory; initialDocumentWorkspaceView?: DocumentWorkspaceView; embedded?: boolean; @@ -8749,16 +9260,6 @@ export default function NonIntegratedDetailClient({ seriesDraftSkuOptions?: SeriesDraftSkuOption[]; }) { const router = useRouter(); - const openingReferenceDesignerDatalistId = useId(); - const openingReferencePlanningOwnerDatalistId = useId(); - const openingReferenceSecondaryCategoryDatalistId = useId(); - const openingReferenceProductCategoryDatalistId = useId(); - const basicInfoDesignerDatalistId = useId(); - const basicInfoPlanningOwnerDatalistId = useId(); - const basicInfoOperationOwnerDatalistId = useId(); - const basicInfoPurchasingOwnerDatalistId = useId(); - const basicInfoSecondaryCategoryDatalistId = useId(); - const basicInfoProductCategoryDatalistId = useId(); const previewFileInputRef = useRef(null); const openingReferenceImageInputRef = useRef(null); const batchSkuImageInputRef = useRef(null); @@ -8837,7 +9338,7 @@ export default function NonIntegratedDetailClient({ const [activeComponentSkuCategory, setActiveComponentSkuCategory] = useState("all"); const [createProductKind, setCreateProductKind] = - useState("new"); + useState(initialCreateProductKind); const [stockProductSpu, setStockProductSpu] = useState(""); const [formValues, setFormValues] = useState(() => createFormValuesFromProduct(existingProduct), @@ -8874,6 +9375,15 @@ export default function NonIntegratedDetailClient({ const [skuBatchChangeTypes, setSkuBatchChangeTypes] = useState< Record >(existingProduct?.skuBatchChangeTypes ?? {}); + const [skuBatchConfirmedAt, setSkuBatchConfirmedAt] = useState< + Record + >(existingProduct?.skuBatchConfirmedAt ?? {}); + const [pendingSkuConfirmationBatch, setPendingSkuConfirmationBatch] = + useState(null); + const [skuConfirmationPrompt, setSkuConfirmationPrompt] = useState<{ + type: "success" | "error"; + message: string; + } | null>(null); const [pendingSkuBatchDraft, setPendingSkuBatchDraft] = useState<{ batchNo: number; changeType: string; @@ -8903,10 +9413,6 @@ export default function NonIntegratedDetailClient({ >({}); const [planningLayoutBrandPickerSkuId, setPlanningLayoutBrandPickerSkuId] = useState(null); - const [ - planningLayoutBrandPickerPosition, - setPlanningLayoutBrandPickerPosition, - ] = useState<{ skuId: string; left: number; top: number } | null>(null); const [productLimitRows, setProductLimitRows] = useState< NonIntegratedProductLimitRow[] >(existingProduct?.productLimitRows ?? []); @@ -9570,12 +10076,70 @@ export default function NonIntegratedDetailClient({ } | null>(null); const autoSaveTimeoutRef = useRef(null); const autoSaveNoticeTimeoutRef = useRef(null); + const skuConfirmationPromptTimeoutRef = useRef(null); const assetUploadCacheRef = useRef(new Map>()); const localStorageCompactionFinishedRef = useRef(false); const documentWorkspaceRevisionSignatureRef = useRef | null>(null); + const [supplierDirectoryOptions, setSupplierDirectoryOptions] = + useState(SUPPLIER_DIRECTORY); + const [warehouseDirectoryOptions, setWarehouseDirectoryOptions] = useState< + string[] + >([]); + const supplierNameOptions = useMemo( + () => supplierDirectoryOptions.map((entry) => entry.name), + [supplierDirectoryOptions], + ); + const supplierCodeByName = useMemo( + () => + new Map( + supplierDirectoryOptions.map((entry) => [ + entry.name, + entry.code.trim().toUpperCase(), + ]), + ), + [supplierDirectoryOptions], + ); + + useEffect(() => { + let cancelled = false; + + async function loadDirectoryOptions() { + try { + const response = await fetch("/api/intake/jst-directory-options", { + credentials: "same-origin", + }); + if (!response.ok) return; + const payload = (await response.json()) as { + data?: { + suppliers?: unknown; + warehouses?: unknown; + }; + }; + if (cancelled) return; + + const suppliers = normalizeJstSupplierOptions(payload.data?.suppliers); + const warehouses = normalizeJstWarehouseOptions( + payload.data?.warehouses, + ); + + if (suppliers.length > 0) { + setSupplierDirectoryOptions(suppliers); + } + setWarehouseDirectoryOptions(warehouses); + } catch { + // Keep local fallback options when the planning database is unavailable. + } + } + + loadDirectoryOptions(); + + return () => { + cancelled = true; + }; + }, []); const isFlowAbandoned = projectStatus === "rejected"; const hasCurrentUserContext = Boolean(currentUserRole); @@ -9739,7 +10303,6 @@ export default function NonIntegratedDetailClient({ }; }, []); - const isCopiedBrandProduct = Boolean(existingProduct?.copiedFromProductId); const isDocumentWorkspaceMode = documentWorkspaceView === "document"; const sharedProductImage = openingReference.productImage.trim() || formValues.previewImage.trim(); @@ -9844,20 +10407,21 @@ export default function NonIntegratedDetailClient({ )); const normalizedExistingOpeningReference = normalizeNonIntegratedOpeningReference(existingProduct?.openingReference); - const normalizedExistingSpu = existingProduct?.spu.trim().toUpperCase() ?? ""; - const normalizedExistingTemporaryCode = - normalizedExistingOpeningReference.temporaryCode.trim().toUpperCase(); - const normalizedExistingProductName = - normalizedExistingOpeningReference.productName.trim().toUpperCase(); const isStockProductRecord = - existingProduct?.processTemplate?.trim() === "存量商品" || - normalizedExistingOpeningReference.currentStage.trim() === "存量商品" || - Boolean( - existingProduct?.formalSpuAssigned && - normalizedExistingSpu && - (normalizedExistingSpu === normalizedExistingTemporaryCode || - normalizedExistingSpu === normalizedExistingProductName), - ); + existingProduct?.processTemplate?.trim() === STOCK_PRODUCT_LABEL || + normalizedExistingOpeningReference.currentStage.trim() === + STOCK_PRODUCT_LABEL; + const isSkuContentReadOnly = isReadOnly || isStockProductRecord; + const isSkuBatchConfirmed = useCallback( + (batchNo: number | string) => + isStockProductRecord || Boolean(skuBatchConfirmedAt[String(batchNo)]), + [isStockProductRecord, skuBatchConfirmedAt], + ); + const activeSkuBatchNo = activeSkuBatch === "all" ? null : activeSkuBatch; + const isActiveSkuBatchConfirmed = + activeSkuBatchNo !== null && isSkuBatchConfirmed(activeSkuBatchNo); + const canEditActiveSkuBatch = + !isSkuContentReadOnly && !isActiveSkuBatchConfirmed; const formalSpuValue = formValues.spu.trim(); const hasFormalSpu = formalSpuValue.length > 0; const spuRuleProductCategory = @@ -9897,13 +10461,99 @@ export default function NonIntegratedDetailClient({ estimatedSpuAssignment, ); const isSpuCategoryLocked = hasFormalSpu && !isStockProductRecord; + const skuCodePreviewSpu = formalSpuValue || estimatedSpuAssignment?.spu || ""; const derivedSkuItems = useMemo( () => skuItems.map((item, index) => - deriveSkuItem(item, index, formValues.spu, formValues), + deriveSkuItem(item, index, skuCodePreviewSpu, formValues), ), - [formValues.spu, formValues.tagMaterial, skuItems], + [formValues.tagMaterial, skuCodePreviewSpu, skuItems], ); + const getSkuBatchValidationIssues = useCallback( + (batchNo: number) => { + const batchKey = String(batchNo); + const batchItems = skuItems + .map((item, index) => ({ + item, + derivedItem: derivedSkuItems[index] ?? item, + rowNumber: index + 1, + })) + .filter( + ({ item, derivedItem }) => + (derivedItem.batchNo || item.batchNo || 1) === batchNo, + ); + const issues: string[] = []; + + if (batchItems.length === 0) { + issues.push(`批次${batchNo}还没有 SKU`); + } + + batchItems.forEach(({ item, derivedItem, rowNumber }) => { + const missingFields = [ + ["型号", item.model], + ["规格码", derivedItem.skuCode], + ["条码", derivedItem.barcode], + ["套件量", item.setCount], + ["规格名称", derivedItem.specName], + ["尺寸", item.size], + ["颜色", item.color], + ["材质", item.material], + ["板材类型", item.boardMaterialType], + ["版本号", derivedItem.versionNo], + ["属性代码", item.attributeCode], + ["图纸ID", derivedItem.drawingId], + ["安装方式", item.installMethod], + ["执行标准", item.executeStandard], + ["符合国标", item.nationalStandardCompliant], + ["供应商名称", item.manufacturer], + ["供应商代码", item.manufacturerCode], + ["销售状态", item.salesStatus], + ] + .filter(([, value]) => !String(value ?? "").trim()) + .map(([label]) => label); + + if (missingFields.length > 0) { + issues.push(`第${rowNumber}行缺少:${missingFields.join("、")}`); + } + }); + + const missingBatchFields = [ + ["新增类型", skuBatchChangeTypes[batchKey]], + ["钉钉流程号", skuBatchFlowNos[batchKey]], + ] + .filter(([, value]) => !String(value ?? "").trim()) + .map(([label]) => label); + + if (missingBatchFields.length > 0) { + issues.push(`批次信息缺少:${missingBatchFields.join("、")}`); + } + + return issues; + }, + [derivedSkuItems, skuBatchChangeTypes, skuBatchFlowNos, skuItems], + ); + const activeSkuBatchValidationIssues = useMemo(() => { + if (activeSkuBatchNo === null) return ["请先切换到具体批次"]; + return getSkuBatchValidationIssues(activeSkuBatchNo); + }, [activeSkuBatchNo, getSkuBatchValidationIssues]); + const canConfirmActiveSkuBatch = + Boolean(activeSkuBatchNo) && + canEditActiveSkuBatch && + activeSkuBatchValidationIssues.length === 0; + const isActiveSkuConfirmationPending = + activeSkuBatchNo !== null && + (pendingSkuConfirmationBatch === activeSkuBatchNo || + skuConfirmationPrompt?.message.startsWith( + `批次${activeSkuBatchNo} SKU 信息已检查完整`, + )); + useEffect(() => { + setPendingSkuConfirmationBatch(null); + if (skuConfirmationPromptTimeoutRef.current) { + window.clearTimeout(skuConfirmationPromptTimeoutRef.current); + skuConfirmationPromptTimeoutRef.current = null; + } + setSkuConfirmationPrompt(null); + }, [activeSkuBatch]); const getSeriesSkuComponentDetails = useCallback( (item: NonIntegratedSkuItem): NonIntegratedSkuComponentDetail[] => { const storedDetails = normalizeNonIntegratedSkuComponentDetails( @@ -10062,8 +10712,13 @@ export default function NonIntegratedDetailClient({ orderedComponentSkuRows, ]); const productFactoryRows = useMemo( - () => buildProductFactoryRows(derivedSkuItems, factoryRows), - [derivedSkuItems, factoryRows], + () => + buildProductFactoryRows( + derivedSkuItems, + factoryRows, + supplierCodeByName, + ), + [derivedSkuItems, factoryRows, supplierCodeByName], ); const productPricingRows = useMemo( () => buildProductPricingRows(derivedSkuItems, pricingRows), @@ -10075,15 +10730,8 @@ export default function NonIntegratedDetailClient({ derivedSkuItems, planningLayoutRows, productLimitRows, - formValues.adaptableBrands[0] || formValues.brand, ), - [ - derivedSkuItems, - formValues.adaptableBrands, - formValues.brand, - planningLayoutRows, - productLimitRows, - ], + [derivedSkuItems, planningLayoutRows, productLimitRows], ); const productLimitDisplayRows = useMemo( () => @@ -10111,26 +10759,57 @@ export default function NonIntegratedDetailClient({ () => new Map(derivedSkuItems.map((item) => [item.id, item.batchNo || 1])), [derivedSkuItems], ); + const confirmedSkuItemIdSet = useMemo( + () => + new Set( + derivedSkuItems + .filter((item) => isSkuBatchConfirmed(item.batchNo || 1)) + .map((item) => item.id), + ), + [derivedSkuItems, isSkuBatchConfirmed], + ); + const confirmedProductFactoryRows = useMemo( + () => productFactoryRows.filter((row) => confirmedSkuItemIdSet.has(row.skuId)), + [confirmedSkuItemIdSet, productFactoryRows], + ); + const confirmedProductPricingRows = useMemo( + () => productPricingRows.filter((row) => confirmedSkuItemIdSet.has(row.skuId)), + [confirmedSkuItemIdSet, productPricingRows], + ); + const confirmedPlanningLayoutSkuRows = useMemo( + () => + planningLayoutSkuRows.filter((row) => + confirmedSkuItemIdSet.has(row.skuId), + ), + [confirmedSkuItemIdSet, planningLayoutSkuRows], + ); + const confirmedProductSampleMeasurementRows = useMemo( + () => + productSampleMeasurementRows.filter((row) => + confirmedSkuItemIdSet.has(row.skuId), + ), + [confirmedSkuItemIdSet, productSampleMeasurementRows], + ); const displayedProductFactoryRows = useMemo(() => { - if (activeDownstreamSkuBatch === "all") return productFactoryRows; - return productFactoryRows.filter( + if (activeDownstreamSkuBatch === "all") return confirmedProductFactoryRows; + return confirmedProductFactoryRows.filter( (row) => (skuBatchNoById.get(row.skuId) ?? 1) === activeDownstreamSkuBatch, ); - }, [activeDownstreamSkuBatch, productFactoryRows, skuBatchNoById]); + }, [activeDownstreamSkuBatch, confirmedProductFactoryRows, skuBatchNoById]); const displayedProductPricingRows = useMemo(() => { - if (activeDownstreamSkuBatch === "all") return productPricingRows; - return productPricingRows.filter( + if (activeDownstreamSkuBatch === "all") return confirmedProductPricingRows; + return confirmedProductPricingRows.filter( (row) => (skuBatchNoById.get(row.skuId) ?? 1) === activeDownstreamSkuBatch, ); - }, [activeDownstreamSkuBatch, productPricingRows, skuBatchNoById]); + }, [activeDownstreamSkuBatch, confirmedProductPricingRows, skuBatchNoById]); const displayedPlanningLayoutSkuRows = useMemo(() => { - if (activeDownstreamSkuBatch === "all") return planningLayoutSkuRows; - return planningLayoutSkuRows.filter( + if (activeDownstreamSkuBatch === "all") return confirmedPlanningLayoutSkuRows; + return confirmedPlanningLayoutSkuRows.filter( (row) => row.batchNo === activeDownstreamSkuBatch, ); - }, [activeDownstreamSkuBatch, planningLayoutSkuRows]); + }, [activeDownstreamSkuBatch, confirmedPlanningLayoutSkuRows]); const displayedProductLimitRows = useMemo(() => { if (activeDownstreamSkuBatch === "all") return productLimitDisplayRows; return productLimitDisplayRows.filter( @@ -10140,15 +10819,15 @@ export default function NonIntegratedDetailClient({ }, [activeDownstreamSkuBatch, productLimitDisplayRows, skuBatchNoById]); const displayedProductSampleMeasurementRows = useMemo(() => { if (activeDownstreamSkuBatch === "all") { - return productSampleMeasurementRows; + return confirmedProductSampleMeasurementRows; } - return productSampleMeasurementRows.filter( + return confirmedProductSampleMeasurementRows.filter( (row) => (skuBatchNoById.get(row.skuId) ?? 1) === activeDownstreamSkuBatch, ); }, [ activeDownstreamSkuBatch, - productSampleMeasurementRows, + confirmedProductSampleMeasurementRows, skuBatchNoById, ]); const displayedProductFirstQualityRows = useMemo(() => { @@ -10623,15 +11302,15 @@ export default function NonIntegratedDetailClient({ [groupedUploadFiles], ); const confirmedProductFactoryRowCount = useMemo( - () => productFactoryRows.filter((row) => row.confirmed).length, - [productFactoryRows], + () => confirmedProductFactoryRows.filter((row) => row.confirmed).length, + [confirmedProductFactoryRows], ); const populatedProductPricingRowCount = useMemo( () => - productPricingRows.filter( - (row) => row.costPrice.trim() || row.productTag.trim(), + confirmedProductPricingRows.filter( + (row) => row.costPrice.trim(), ).length, - [productPricingRows], + [confirmedProductPricingRows], ); const populatedProductLimitRowCount = useMemo( () => @@ -10644,10 +11323,10 @@ export default function NonIntegratedDetailClient({ ).length, [productLimitDisplayRows], ); - const planningLayoutHasContent = planningLayoutSkuRows.length > 0; + const planningLayoutHasContent = confirmedPlanningLayoutSkuRows.length > 0; const populatedSampleMeasurementRowCount = useMemo( () => - productSampleMeasurementRows.filter((row) => + confirmedProductSampleMeasurementRows.filter((row) => [ row.lengthCm, row.widthCm, @@ -10656,7 +11335,7 @@ export default function NonIntegratedDetailClient({ row.productShape, ].some((value) => value.trim()), ).length, - [productSampleMeasurementRows], + [confirmedProductSampleMeasurementRows], ); const populatedFirstQualityRowCount = useMemo( () => @@ -10888,14 +11567,32 @@ export default function NonIntegratedDetailClient({ documentWorkspaceConfirmations[category] === true && Boolean(documentWorkspaceApprovedAt[category]) && !dirtyWorkflowNodeCategories.has(category)); + const isDocumentWorkspaceNodeLocked = (category: DocumentWorkspaceCategory) => + documentWorkspaceConfirmations[category] === true && + Boolean(documentWorkspaceApprovedAt[category]); + const isSampleMeasurementNodeLocked = isDocumentWorkspaceNodeLocked( + SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY, + ); + const isProductPricingNodeLocked = isDocumentWorkspaceNodeLocked( + PRODUCT_PRICING_WORKSPACE_CATEGORY, + ); const displayedSampleMeasurementIsConfirmed = isWorkflowNodeConfirmed( SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY, displayedSampleMeasurementRowsComplete, - ); + ) || isSampleMeasurementNodeLocked; + const isSampleMeasurementNodeApproved = + isSampleMeasurementNodeLocked && + !dirtyWorkflowNodeCategories.has(SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY); + const canShowSkuReconfirmationAction = + activeSkuTab === "skuInfo" && + isCurrentUserAdmin && + activeSkuBatchNo !== null && + isActiveSkuBatchConfirmed && + !isSkuContentReadOnly; const displayedProductPricingIsConfirmed = isWorkflowNodeConfirmed( PRODUCT_PRICING_WORKSPACE_CATEGORY, displayedProductPricingRowsComplete, - ); + ) || isProductPricingNodeLocked; const displayedProductLimitIsConfirmed = isWorkflowNodeConfirmed( PRODUCT_LIMIT_WORKSPACE_CATEGORY, displayedProductLimitRowsComplete, @@ -12048,6 +12745,20 @@ export default function NonIntegratedDetailClient({ }, 2000); }, []); + const showSkuConfirmationPrompt = useCallback( + (prompt: { type: "success" | "error"; message: string }) => { + if (skuConfirmationPromptTimeoutRef.current) { + window.clearTimeout(skuConfirmationPromptTimeoutRef.current); + } + setSkuConfirmationPrompt(prompt); + skuConfirmationPromptTimeoutRef.current = window.setTimeout(() => { + setSkuConfirmationPrompt(null); + skuConfirmationPromptTimeoutRef.current = null; + }, SKU_CONFIRMATION_PROMPT_VISIBLE_MS); + }, + [], + ); + const persistEditedProduct = useCallback( async ({ successMessage, @@ -12063,6 +12774,7 @@ export default function NonIntegratedDetailClient({ openingReference?: NonIntegratedOpeningReference; skuItems?: NonIntegratedSkuItem[]; skuBatchCount?: number; + skuBatchConfirmedAt?: Record; projectStatus?: NonIntegratedProjectStatus; allowReadOnly?: boolean; }; @@ -12077,6 +12789,8 @@ export default function NonIntegratedDetailClient({ overrides?.openingReference ?? openingReferenceRef.current; const nextSkuItems = overrides?.skuItems ?? skuItems; const nextSkuBatchCount = overrides?.skuBatchCount ?? skuBatchCount; + const nextSkuBatchConfirmedAt = + overrides?.skuBatchConfirmedAt ?? skuBatchConfirmedAt; const nextProjectStatus = overrides?.projectStatus ?? projectStatus; if ( @@ -12092,7 +12806,7 @@ export default function NonIntegratedDetailClient({ const nextFormValuesForPayload = isStockProductRecord ? { ...formValuesRef.current, - processTemplate: "存量商品", + processTemplate: STOCK_PRODUCT_LABEL, } : formValuesRef.current; const nextProduct = await normalizeProductAssetsForStorage( @@ -12121,6 +12835,7 @@ export default function NonIntegratedDetailClient({ nextSkuBatchCount, skuBatchFlowNos, skuBatchChangeTypes, + nextSkuBatchConfirmedAt, existingProductCreatedAt, existingProduct?.copiedFromProductId, existingProduct?.copiedFromSpu, @@ -12186,6 +12901,7 @@ export default function NonIntegratedDetailClient({ projectStatus, skuBatchCount, skuBatchChangeTypes, + skuBatchConfirmedAt, skuBatchFlowNos, skuItems, styleDetails, @@ -12200,6 +12916,10 @@ export default function NonIntegratedDetailClient({ window.clearTimeout(autoSaveNoticeTimeoutRef.current); autoSaveNoticeTimeoutRef.current = null; } + if (skuConfirmationPromptTimeoutRef.current) { + window.clearTimeout(skuConfirmationPromptTimeoutRef.current); + skuConfirmationPromptTimeoutRef.current = null; + } }; }, []); @@ -12578,6 +13298,7 @@ export default function NonIntegratedDetailClient({ reviewRounds, showAutoSaveNotice, skuBatchChangeTypes, + skuBatchConfirmedAt, skuBatchCount, skuBatchFlowNos, skuItems, @@ -13262,9 +13983,10 @@ export default function NonIntegratedDetailClient({ function updateAdaptableBrands(value: string[]) { if (isReadOnly) return; + const nextBrand = formValues.brand.trim() || "基本盘"; const nextAdaptableBrands = Array.from( new Set([ - formValues.brand, + nextBrand, ...BRAND_OPTIONS.filter((brand) => value.includes(brand)), ]), ); @@ -13273,8 +13995,14 @@ export default function NonIntegratedDetailClient({ ...current, adaptableBrands: nextAdaptableBrands, })); + setOpeningReferenceAndRef((current) => ({ + ...current, + brand: current.brand.trim() || nextBrand, + adaptableBrands: nextAdaptableBrands, + })); setSaveMessage(""); setSaveMessageType("success"); + scheduleAutoSave(); } function updateSkuItem( @@ -13288,6 +14016,13 @@ export default function NonIntegratedDetailClient({ setSkuItems((current) => { const targetItem = current.find((item) => item.id === id); if (!targetItem) return current; + if ( + isStockProductRecord || + isSkuBatchConfirmed(targetItem.batchNo || 1) || + isImportedSkuContentLocked(targetItem) + ) { + return current; + } if (key === "installMethod") { const targetStyleKey = getStyleKeyFromModel(targetItem.model); @@ -13350,7 +14085,10 @@ export default function NonIntegratedDetailClient({ index: number, value: string, ) { - const matchedSupplier = resolveSupplierEntryByName(value); + const matchedSupplier = resolveSupplierEntryByName( + value, + supplierCodeByName, + ); const nextItem = { ...item, manufacturer: matchedSupplier ? matchedSupplier.name : value, @@ -13374,9 +14112,16 @@ export default function NonIntegratedDetailClient({ if (!assignFormalSpuForSku()) return; setSkuItems((current) => - current.map((item, index) => - item.id === id ? applySkuSupplierName(item, index, value) : item, - ), + current.map((item, index) => { + if (item.id !== id) return item; + if ( + isStockProductRecord || + isSkuBatchConfirmed(item.batchNo || 1) || + isImportedSkuContentLocked(item) + ) + return item; + return applySkuSupplierName(item, index, value); + }), ); setSaveMessage(""); setSaveMessageType("success"); @@ -13389,6 +14134,12 @@ export default function NonIntegratedDetailClient({ setSkuItems((current) => current.map((item, index) => { if (item.id !== id) return item; + if ( + isStockProductRecord || + isSkuBatchConfirmed(item.batchNo || 1) || + isImportedSkuContentLocked(item) + ) + return item; return applySkuSupplierName(item, index, item.manufacturer.trim()); }), @@ -13402,9 +14153,16 @@ export default function NonIntegratedDetailClient({ if (!assignFormalSpuForSku()) return; setSkuItems((current) => - current.map((item) => - item.id === id ? { ...item, executeStandard: value } : item, - ), + current.map((item) => { + if (item.id !== id) return item; + if ( + isStockProductRecord || + isSkuBatchConfirmed(item.batchNo || 1) || + isImportedSkuContentLocked(item) + ) + return item; + return { ...item, executeStandard: value }; + }), ); setSaveMessage(""); setSaveMessageType("success"); @@ -13417,6 +14175,12 @@ export default function NonIntegratedDetailClient({ setSkuItems((current) => current.map((item) => { if (item.id !== id) return item; + if ( + isStockProductRecord || + isSkuBatchConfirmed(item.batchNo || 1) || + isImportedSkuContentLocked(item) + ) + return item; const normalizedValue = item.executeStandard.trim(); const matchedOption = resolveExecutionStandardOption(normalizedValue); @@ -13437,14 +14201,21 @@ export default function NonIntegratedDetailClient({ value: string, ) { if (isReadOnly) return; + const currentFactoryRow = productFactoryRows.find( + (row) => row.skuId === skuId, + ); + if (currentFactoryRow?.confirmed) return; if (key === "supplierName") { - const supplier = normalizeFactorySupplier(value); + const supplier = normalizeFactorySupplier(value, supplierCodeByName); const supplierName = supplier.name || value; setSkuItems((current) => current.map((item, index) => - item.id === skuId + item.id === skuId && + !isStockProductRecord && + !isSkuBatchConfirmed(item.batchNo || 1) && + !isImportedSkuContentLocked(item) ? applySkuSupplierName(item, index, supplierName) : item, ), @@ -13452,7 +14223,11 @@ export default function NonIntegratedDetailClient({ } setFactoryRows((current) => { - const currentRows = buildProductFactoryRows(derivedSkuItems, current); + const currentRows = buildProductFactoryRows( + derivedSkuItems, + current, + supplierCodeByName, + ); return currentRows.map((row) => { if (row.skuId !== skuId || row.confirmed) return row; const derivedSkuItem = derivedSkuItems.find( @@ -13461,15 +14236,20 @@ export default function NonIntegratedDetailClient({ const versionNo = derivedSkuItem?.versionNo ?? "V01"; if (key === "supplierName") { - const supplier = normalizeFactorySupplier(value); + const supplier = normalizeFactorySupplier(value, supplierCodeByName); const supplierName = supplier.name || value; const supplierCode = supplier.code; + const importedContentLocked = + derivedSkuItem !== undefined && + isImportedSkuContentLocked(derivedSkuItem); return { ...row, supplierName, supplierCode, - barcode: buildAutoBarcode(row.skuCode, supplierCode, versionNo), + barcode: importedContentLocked + ? row.barcode.trim() || derivedSkuItem?.barcode.trim() || "" + : buildAutoBarcode(row.skuCode, supplierCode, versionNo), }; } @@ -13488,7 +14268,7 @@ export default function NonIntegratedDetailClient({ key: Exclude, value: string, ) { - if (isReadOnly) return; + if (isReadOnly || isProductPricingNodeLocked) return; setPricingRows((current) => { const rowBySkuId = new Map(current.map((row) => [row.skuId, row])); @@ -13551,13 +14331,7 @@ export default function NonIntegratedDetailClient({ setPlanningLayoutRows((current) => { const rowBySkuId = new Map(current.map((row) => [row.skuId, row])); const currentRow = - rowBySkuId.get(skuId) ?? - createEmptyPlanningLayoutRow( - skuId, - getUniqueNonEmptyStrings([ - formValues.adaptableBrands[0] || formValues.brand, - ]), - ); + rowBySkuId.get(skuId) ?? createEmptyPlanningLayoutRow(skuId); const nextRow = typeof updater === "function" ? updater(currentRow) @@ -13656,7 +14430,6 @@ export default function NonIntegratedDetailClient({ function closePlanningLayoutBrandPicker() { setPlanningLayoutBrandPickerSkuId(null); - setPlanningLayoutBrandPickerPosition(null); } function updateSampleMeasurementRow( @@ -13664,7 +14437,7 @@ export default function NonIntegratedDetailClient({ key: Exclude, value: string, ) { - if (isReadOnly) return; + if (isReadOnly || isSampleMeasurementNodeLocked) return; setSampleMeasurementRows((current) => { const rowBySkuId = new Map(current.map((row) => [row.skuId, row])); @@ -13723,7 +14496,11 @@ export default function NonIntegratedDetailClient({ if (isReadOnly) return; const currentRow = productFactoryRows.find((row) => row.skuId === skuId); - const supplier = normalizeFactorySupplier(currentRow?.supplierName ?? ""); + if (currentRow?.confirmed) return; + const supplier = normalizeFactorySupplier( + currentRow?.supplierName ?? "", + supplierCodeByName, + ); const supplierName = supplier.name || currentRow?.supplierName.trim() || ""; @@ -13738,7 +14515,11 @@ export default function NonIntegratedDetailClient({ } setFactoryRows((current) => { - const currentRows = buildProductFactoryRows(derivedSkuItems, current); + const currentRows = buildProductFactoryRows( + derivedSkuItems, + current, + supplierCodeByName, + ); return currentRows.map((row) => { if (row.skuId !== skuId || row.confirmed) return row; const derivedSkuItem = derivedSkuItems.find( @@ -13746,7 +14527,10 @@ export default function NonIntegratedDetailClient({ ); const versionNo = derivedSkuItem?.versionNo ?? "V01"; - const supplier = normalizeFactorySupplier(row.supplierName); + const supplier = normalizeFactorySupplier( + row.supplierName, + supplierCodeByName, + ); const supplierName = supplier.name || row.supplierName.trim(); const supplierCode = row.supplierCode.trim().toUpperCase() || supplier.code; @@ -13781,27 +14565,80 @@ export default function NonIntegratedDetailClient({ const targetSkuIds = new Set( displayedProductFactoryRows.map((row) => row.skuId), ); - const normalizedRows = normalizeFactoryRowsForPayload(productFactoryRows).map( - (row) => - targetSkuIds.has(row.skuId) - ? { - ...row, - confirmed: true, - } - : row, + const normalizedRows = normalizeFactoryRowsForPayload( + productFactoryRows, + supplierCodeByName, + ).map((row) => + targetSkuIds.has(row.skuId) + ? { + ...row, + confirmed: true, + } + : row, ); + const allRowsConfirmed = normalizedRows.every((row) => row.confirmed); + const nextConfirmations = { + ...documentWorkspaceConfirmations, + [PRODUCT_FACTORY_WORKSPACE_CATEGORY]: allRowsConfirmed, + }; + const nextApprovedAt = { ...documentWorkspaceApprovedAt }; + if (allRowsConfirmed) { + nextApprovedAt[PRODUCT_FACTORY_WORKSPACE_CATEGORY] = + new Date().toISOString(); + } else { + delete nextApprovedAt[PRODUCT_FACTORY_WORKSPACE_CATEGORY]; + } setFactoryRows(normalizedRows); - setDocumentWorkspaceConfirmations((current) => ({ - ...current, - [PRODUCT_FACTORY_WORKSPACE_CATEGORY]: normalizedRows.every( - (row) => row.confirmed, - ), - })); + setDocumentWorkspaceConfirmations(nextConfirmations); + setDocumentWorkspaceApprovedAt(nextApprovedAt); setSaveMessage("产品定厂已确认"); setSaveMessageType("success"); } + function handleReopenProductFactoryConfirmation() { + if ( + isReadOnly || + !isCurrentUserAdmin || + displayedProductFactoryRows.length === 0 + ) { + return; + } + + if (isSampleMeasurementNodeLocked) { + setSaveMessage("样包测量节点已确认,产品定厂不能重新确认"); + setSaveMessageType("error"); + return; + } + + const targetSkuIds = new Set( + displayedProductFactoryRows.map((row) => row.skuId), + ); + const normalizedRows = normalizeFactoryRowsForPayload( + productFactoryRows, + supplierCodeByName, + ).map((row) => + targetSkuIds.has(row.skuId) + ? { + ...row, + confirmed: false, + } + : row, + ); + const nextConfirmations = { + ...documentWorkspaceConfirmations, + [PRODUCT_FACTORY_WORKSPACE_CATEGORY]: false, + }; + const nextApprovedAt = { ...documentWorkspaceApprovedAt }; + delete nextApprovedAt[PRODUCT_FACTORY_WORKSPACE_CATEGORY]; + + setFactoryRows(normalizedRows); + setDocumentWorkspaceConfirmations(nextConfirmations); + setDocumentWorkspaceApprovedAt(nextApprovedAt); + setSaveMessage("产品定厂已重新打开,可修改后再次确认"); + setSaveMessageType("success"); + } + function updateStyleDetail(key: keyof StyleDetailForm, value: string) { if (isReadOnly) return; if (!selectedStyleKey) return; @@ -14305,6 +15142,13 @@ export default function NonIntegratedDetailClient({ if (targetIndex < 0) return current; const targetItem = current[targetIndex]; + if ( + isStockProductRecord || + isSkuBatchConfirmed(targetItem.batchNo || 1) || + isImportedSkuContentLocked(targetItem) + ) { + return current; + } const currentGroupId = targetItem.packageGroupId || targetItem.id; const groupStartIndex = targetItem.packageGroupId ? current.findIndex((item) => item.packageGroupId === currentGroupId) @@ -14392,6 +15236,16 @@ export default function NonIntegratedDetailClient({ event: ChangeEvent, ) { if (isReadOnly) return; + if ( + skuItems.some( + (item) => + item.id === targetId && + (isStockProductRecord || isImportedSkuContentLocked(item)), + ) + ) { + event.target.value = ""; + return; + } const file = event.target.files?.[0]; if (!file) return; @@ -14463,6 +15317,9 @@ export default function NonIntegratedDetailClient({ setSkuItems((current) => current.map((item, index) => { + if (isStockProductRecord || isImportedSkuContentLocked(item)) { + return item; + } const derivedItem = deriveSkuItem( item, index, @@ -14524,7 +15381,7 @@ export default function NonIntegratedDetailClient({ return; } - if (isReadOnly) { + if (isReadOnly || isStockProductRecord || isImportedSkuContentLocked(item)) { return; } @@ -14793,21 +15650,29 @@ export default function NonIntegratedDetailClient({ function handleOpeningReferenceAdaptableBrandsChange(value: string[]) { if (isReadOnly) return; - setOpeningReferenceAndRef((current) => { - const nextBrand = current.brand.trim() || "基本盘"; + const nextBrand = + formValues.brand.trim() || + openingReferenceRef.current.brand.trim() || + "基本盘"; + const nextAdaptableBrands = Array.from( + new Set([ + nextBrand, + ...BRAND_OPTIONS.filter((brand) => value.includes(brand)), + ]), + ); - return { - ...current, - adaptableBrands: Array.from( - new Set([ - nextBrand, - ...BRAND_OPTIONS.filter((brand) => value.includes(brand)), - ]), - ), - }; - }); + setOpeningReferenceAndRef((current) => ({ + ...current, + brand: current.brand.trim() || nextBrand, + adaptableBrands: nextAdaptableBrands, + })); + setFormValuesAndRef((current) => ({ + ...current, + adaptableBrands: nextAdaptableBrands, + })); setSaveMessage(""); setSaveMessageType("success"); + scheduleAutoSave(); } async function handleOpeningReferenceImageChange( @@ -16358,7 +17223,12 @@ export default function NonIntegratedDetailClient({ } function handleAddSku(targetBatchNo = skuBatchCount) { - if (isReadOnly) return; + if (isSkuContentReadOnly) return; + if (isSkuBatchConfirmed(targetBatchNo)) { + setSaveMessage(`批次${targetBatchNo}的 SKU 信息已确认,不能继续新增 SKU。`); + setSaveMessageType("error"); + return; + } if (activeSkuBatch === "all") { setSaveMessage("全部只是查看所有 SKU,请先点击最新批次后再新增 SKU。"); setSaveMessageType("error"); @@ -16439,6 +17309,112 @@ export default function NonIntegratedDetailClient({ setSaveMessageType("success"); } + async function handleConfirmSkuContent() { + if (!activeSkuBatchNo || isSkuContentReadOnly) return; + + if (isActiveSkuBatchConfirmed) { + showSkuConfirmationPrompt({ + type: "success", + message: `批次${activeSkuBatchNo}的 SKU 信息已确认。`, + }); + return; + } + + if (isActiveSkuConfirmationPending) { + const confirmedAt = new Date().toISOString(); + const nextSkuBatchConfirmedAt = { + ...skuBatchConfirmedAt, + [String(activeSkuBatchNo)]: confirmedAt, + }; + + setSkuBatchConfirmedAt(nextSkuBatchConfirmedAt); + setPendingSkuConfirmationBatch(null); + + const saved = await persistEditedProduct({ + successMessage: `批次${activeSkuBatchNo} SKU信息已确认`, + errorFallbackMessage: "SKU信息确认保存失败,请稍后再试", + overrides: { + skuBatchConfirmedAt: nextSkuBatchConfirmedAt, + }, + }); + + if (!saved) { + setSkuBatchConfirmedAt(skuBatchConfirmedAt); + } else { + showSkuConfirmationPrompt({ + type: "success", + message: `批次${activeSkuBatchNo} SKU信息已确认,当前批次已锁定。`, + }); + } + return; + } + + const issues = getSkuBatchValidationIssues(activeSkuBatchNo); + if (issues.length > 0) { + const message = `SKU信息未填写完整,暂不能确认:${issues.slice(0, 3).join(";")}${ + issues.length > 3 ? `;还有 ${issues.length - 3} 项` : "" + }`; + setPendingSkuConfirmationBatch(null); + showSkuConfirmationPrompt({ type: "error", message }); + setSaveMessage(message); + setSaveMessageType("error"); + return; + } + + if (pendingSkuConfirmationBatch !== activeSkuBatchNo) { + const message = `批次${activeSkuBatchNo} SKU 信息已检查完整。再次点击“确认SKU信息”后,本批次 SKU 表和批次信息会锁定。`; + setPendingSkuConfirmationBatch(activeSkuBatchNo); + showSkuConfirmationPrompt({ type: "success", message }); + setSaveMessage(message); + setSaveMessageType("success"); + return; + } + } + + async function handleReopenSkuContentConfirmation() { + if ( + !activeSkuBatchNo || + !isCurrentUserAdmin || + !isActiveSkuBatchConfirmed || + isSkuContentReadOnly + ) { + return; + } + + if (isSampleMeasurementNodeApproved) { + const message = "样包测量节点已确认,不能重新确认 SKU 信息。"; + showSkuConfirmationPrompt({ type: "error", message }); + setSaveMessage(message); + setSaveMessageType("error"); + return; + } + + const batchKey = String(activeSkuBatchNo); + const nextSkuBatchConfirmedAt = { ...skuBatchConfirmedAt }; + delete nextSkuBatchConfirmedAt[batchKey]; + + setSkuBatchConfirmedAt(nextSkuBatchConfirmedAt); + setPendingSkuConfirmationBatch(null); + + const saved = await persistEditedProduct({ + successMessage: `批次${activeSkuBatchNo} SKU信息已重新打开`, + errorFallbackMessage: "SKU信息重新打开保存失败,请稍后再试", + overrides: { + skuBatchConfirmedAt: nextSkuBatchConfirmedAt, + }, + }); + + if (!saved) { + setSkuBatchConfirmedAt(skuBatchConfirmedAt); + return; + } + + showSkuConfirmationPrompt({ + type: "success", + message: `批次${activeSkuBatchNo} SKU信息已重新打开,可修改后再次确认。`, + }); + } + const displayedSkuItems = useMemo( () => skuItems.filter((item) => @@ -16448,16 +17424,41 @@ export default function NonIntegratedDetailClient({ ), [activeSkuBatch, skuItems], ); + const showSkuActionColumn = useMemo( + () => + !isSkuContentReadOnly && + skuItems.some((item, index) => { + const derivedItem = derivedSkuItems[index] ?? item; + const rowBatchNo = derivedItem.batchNo || item.batchNo || 1; + + if (activeSkuBatch !== "all" && rowBatchNo !== activeSkuBatch) { + return false; + } + + return ( + !isSkuBatchConfirmed(rowBatchNo) && + !isImportedSkuContentLocked(item) + ); + }), + [ + activeSkuBatch, + derivedSkuItems, + isSkuBatchConfirmed, + isSkuContentReadOnly, + skuItems, + ], + ); const canDeleteActiveSkuBatch = isCurrentUserAdmin && - !isReadOnly && + !isSkuContentReadOnly && + !isActiveSkuBatchConfirmed && !pendingSkuBatchDraft && activeSkuBatch !== "all" && activeSkuBatch > 1 && activeSkuBatch === skuBatchCount; function handleAddSkuBatch() { - if (isReadOnly) return; + if (isSkuContentReadOnly) return; const latestBatchHasSku = skuItems.some( (item) => (item.batchNo || 1) === skuBatchCount, ); @@ -16534,7 +17535,7 @@ export default function NonIntegratedDetailClient({ } function handleDeleteSkuBatch() { - if (!isCurrentUserAdmin || isReadOnly) return; + if (!isCurrentUserAdmin || isSkuContentReadOnly) return; if (activeSkuBatch === "all") { setSaveMessage("请先切到要删除的具体批次。"); @@ -16555,6 +17556,11 @@ export default function NonIntegratedDetailClient({ } const targetBatchNo = activeSkuBatch; + if (isSkuBatchConfirmed(targetBatchNo)) { + setSaveMessage(`批次${targetBatchNo}的 SKU 信息已确认,不能删除批次。`); + setSaveMessageType("error"); + return; + } if ( !window.confirm( `确定删除批次${targetBatchNo}吗?该批次下的 SKU 和批次信息会一起删除。`, @@ -16595,6 +17601,11 @@ export default function NonIntegratedDetailClient({ delete next[targetBatchKey]; return next; }); + setSkuBatchConfirmedAt((current) => { + const next = { ...current }; + delete next[targetBatchKey]; + return next; + }); setPendingSkuBatchDraft(null); setShowSkuBatchInfo(false); setSaveMessage(`已删除批次${targetBatchNo}。`); @@ -16603,6 +17614,7 @@ export default function NonIntegratedDetailClient({ function handleUpdateSkuBatchChangeType(batchNo: number, value: string) { if (isReadOnly) return; + if (isSkuBatchConfirmed(batchNo)) return; setSkuBatchChangeTypes((current) => ({ ...current, [String(batchNo)]: value, @@ -16613,6 +17625,7 @@ export default function NonIntegratedDetailClient({ function handleUpdateSkuBatchFlowNo(batchNo: number, value: string) { if (isReadOnly) return; + if (isSkuBatchConfirmed(batchNo)) return; setSkuBatchFlowNos((current) => ({ ...current, [String(batchNo)]: value, @@ -16635,6 +17648,12 @@ export default function NonIntegratedDetailClient({ if (isReadOnly) return; const targetItem = skuItems.find((item) => item.id === id); if (!targetItem) return; + if ( + isStockProductRecord || + isSkuBatchConfirmed(targetItem.batchNo || 1) || + isImportedSkuContentLocked(targetItem) + ) + return; const nextItems = skuItems.filter((item) => targetItem.packageGroupId @@ -16703,6 +17722,7 @@ export default function NonIntegratedDetailClient({ size: skuItems[index]?.size?.trim() ?? "", color: skuItems[index]?.color?.trim() ?? "", material: skuItems[index]?.material?.trim() ?? "", + boardMaterialType: skuItems[index]?.boardMaterialType?.trim() ?? "", versionNo: item.versionNo.trim(), attributeCode: skuItems[index]?.attributeCode?.trim() ?? "", drawingId: item.drawingId.trim(), @@ -16712,6 +17732,7 @@ export default function NonIntegratedDetailClient({ skuItems[index]?.nationalStandardCompliant?.trim() ?? "", manufacturer: skuItems[index]?.manufacturer?.trim() ?? "", manufacturerCode: skuItems[index]?.manufacturerCode?.trim() ?? "", + salesStatus: skuItems[index]?.salesStatus?.trim() ?? "", batchNo: `批次${item.batchNo || 1}`, batchChangeType: skuBatchChangeTypes[String(item.batchNo || 1)] ?? "", dingTalkFlowNo: skuBatchFlowNos[String(item.batchNo || 1)] ?? "", @@ -16737,6 +17758,12 @@ export default function NonIntegratedDetailClient({ event: ChangeEvent, ) { if (isReadOnly) return; + if (activeSkuBatchNo && isSkuBatchConfirmed(activeSkuBatchNo)) { + setSaveMessage(`批次${activeSkuBatchNo}的 SKU 信息已确认,不能上传资料。`); + setSaveMessageType("error"); + event.target.value = ""; + return; + } const file = event.target.files?.[0]; if (!file) return; @@ -17536,6 +18563,7 @@ export default function NonIntegratedDetailClient({ skuBatchCount, skuBatchFlowNos, skuBatchChangeTypes, + skuBatchConfirmedAt, productCreatedAt, undefined, undefined, @@ -18534,18 +19562,6 @@ export default function NonIntegratedDetailClient({
) : activeProductFactoryTab === "factoryInfo" ? (
- - {SUPPLIER_DIRECTORY.map((entry) => ( - - ))} - - - {EXECUTION_STANDARD_OPTIONS.map((option) => ( - @@ -18619,6 +19635,7 @@ export default function NonIntegratedDetailClient({
updateProductFactoryRow( @@ -18633,7 +19650,18 @@ export default function NonIntegratedDetailClient({ /> - {renderFactoryInput(row, "warehouse", "请输入仓库")} + + updateProductFactoryRow( + row.skuId, + "warehouse", + value, + ) + } + /> {renderFactoryInput( @@ -18646,25 +19674,21 @@ export default function NonIntegratedDetailClient({ {renderFactoryInput(row, "leadTime", "请输入交期")} - + buttonClassName="h-8 w-full min-w-[96px] rounded border border-apple-separator bg-apple-bg-secondary px-2 text-xs text-apple-label-primary outline-none disabled:bg-apple-bg-primary disabled:text-apple-label-secondary" + /> {renderFactoryInput( @@ -18786,7 +19810,7 @@ export default function NonIntegratedDetailClient({ setSkuImagePreview({ src: row.image, - alt: row.model || `企划布局图片${index + 1}`, + alt: row.model || `空间布局图片${index + 1}`, }) } > {/* eslint-disable-next-line @next/next/no-img-element */} {row.model @@ -18950,8 +19974,6 @@ export default function NonIntegratedDetailClient({ minWidth?: string; onChange?: (value: string) => void; }) => { - const listId = `planning-layout-${field}-${row.skuId}`; - return (
- { - const nextValue = event.target.value; + options={options} + onChange={(nextValue) => { if (onChange) { onChange(nextValue); return; @@ -18980,17 +19999,14 @@ export default function NonIntegratedDetailClient({ })); }} placeholder={`选择${label}`} - className={`min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-apple-label-tertiary ${ + wrapperClassName="relative min-w-0 flex-1" + inputClassName={`min-w-0 w-full bg-transparent pr-7 text-xs outline-none placeholder:text-apple-label-tertiary ${ isReadOnly ? "text-apple-label-secondary" : "text-apple-label-primary" }`} + panelClassName="absolute left-0 top-[calc(100%+6px)] z-50 max-h-56 min-w-full overflow-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-1 shadow-apple-md" /> - - {options.map((option) => ( -
); }; @@ -19013,7 +20029,6 @@ export default function NonIntegratedDetailClient({ }) => { const draftKey = getPlanningLayoutDraftKey(row.skuId, field); const draftValue = planningLayoutDraftInputs[draftKey] ?? ""; - const listId = `planning-layout-${field}-${row.skuId}`; const selectedValues = getUniqueNonEmptyStrings(values); return ( @@ -19052,48 +20067,39 @@ export default function NonIntegratedDetailClient({ )} {isReadOnly ? null : ( -
- { - const nextValue = event.target.value; - updatePlanningLayoutDraftInput(row.skuId, field, nextValue); - if (!autoAddOnChange) return; - if (!options.includes(nextValue.trim())) return; - addPlanningLayoutListValue(row.skuId, field, nextValue); - setPlanningLayoutDraftInputs((current) => ({ - ...current, - [draftKey]: "", - })); - }} - onKeyDown={(event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - addPlanningLayoutListValue(row.skuId, field); - }} - onBlur={() => addPlanningLayoutListValue(row.skuId, field)} - placeholder="" - className="h-6 w-full rounded-apple-sm border border-apple-separator bg-apple-bg-primary px-1 pr-3 text-center text-xs text-apple-label-primary outline-none placeholder:text-apple-label-tertiary" - /> - -
+ + autoAddOnChange && options.includes(nextValue.trim()) + } + onChange={(nextValue) => { + updatePlanningLayoutDraftInput(row.skuId, field, nextValue); + if (!autoAddOnChange) return; + if (!options.includes(nextValue.trim())) return; + addPlanningLayoutListValue(row.skuId, field, nextValue); + setPlanningLayoutDraftInputs((current) => ({ + ...current, + [draftKey]: "", + })); + }} + onSelect={(selectedOption) => { + addPlanningLayoutListValue(row.skuId, field, selectedOption); + setPlanningLayoutDraftInputs((current) => ({ + ...current, + [draftKey]: "", + })); + }} + onCommit={() => addPlanningLayoutListValue(row.skuId, field)} + /> )} - - {options.map((option) => ( - ); }; @@ -19111,10 +20117,6 @@ export default function NonIntegratedDetailClient({ const selectedValues = getUniqueNonEmptyStrings(values); const pickerOpen = planningLayoutBrandPickerSkuId === row.skuId; const pickerDropdownId = `planning-layout-brands-dropdown-${row.skuId}`; - const pickerPosition = - pickerOpen && planningLayoutBrandPickerPosition?.skuId === row.skuId - ? planningLayoutBrandPickerPosition - : null; return (
@@ -19159,34 +20161,19 @@ export default function NonIntegratedDetailClient({ aria-expanded={pickerOpen} aria-controls={pickerOpen ? pickerDropdownId : undefined} data-testid={`planning-layout-brands-picker-${row.skuId}`} - onClick={(event) => { - const buttonRect = - event.currentTarget.getBoundingClientRect(); + onClick={() => { setPlanningLayoutBrandPickerSkuId((current) => current === row.skuId ? null : row.skuId, ); - setPlanningLayoutBrandPickerPosition((current) => - current?.skuId === row.skuId - ? null - : { - skuId: row.skuId, - left: buttonRect.right - 148, - top: buttonRect.bottom + 6, - }, - ); }} className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-apple-sm bg-black text-[10px] text-white transition-opacity hover:opacity-80" > ✓ - {pickerOpen && pickerPosition ? ( + {pickerOpen ? (
{options.map((brand) => { @@ -19220,6 +20207,14 @@ export default function NonIntegratedDetailClient({
); }; + const stickyIndexHeaderClass = + "sticky left-0 z-30 w-[56px] min-w-[56px] max-w-[56px] whitespace-nowrap border-b border-r border-apple-separator bg-apple-bg-secondary px-3 py-2 text-left font-apple-medium"; + const stickyImageHeaderClass = + "sticky left-[56px] z-30 w-[76px] min-w-[76px] max-w-[76px] whitespace-nowrap border-b border-r border-apple-separator bg-apple-bg-secondary px-3 py-2 text-left font-apple-medium shadow-[8px_0_12px_-12px_rgba(0,0,0,0.45)]"; + const stickyIndexCellClass = + "sticky left-0 z-20 w-[56px] min-w-[56px] max-w-[56px] border-r border-apple-separator bg-apple-bg-primary px-3 py-2 text-apple-label-secondary"; + const stickyImageCellClass = + "sticky left-[56px] z-20 w-[76px] min-w-[76px] max-w-[76px] border-r border-apple-separator bg-apple-bg-primary px-3 py-2 shadow-[8px_0_12px_-12px_rgba(0,0,0,0.45)]"; return (
-
+
{planningLayoutSkuRows.length === 0 ? (
- 当前还没有 SKU 信息,先维护 SKU 后再查看企划布局。 + {derivedSkuItems.length === 0 + ? "当前还没有 SKU 信息,先维护 SKU 后再查看空间布局。" + : "当前没有销售状态为“销售”的 SKU。"}
) : displayedPlanningLayoutSkuRows.length === 0 ? (
当前批次还没有 SKU 信息。
) : ( -
+
@@ -19271,10 +20268,16 @@ export default function NonIntegratedDetailClient({ "价格带", "适配品牌", "店铺", - ].map((label) => ( + ].map((label, columnIndex) => ( @@ -19287,10 +20290,12 @@ export default function NonIntegratedDetailClient({ key={row.skuId} className="border-b border-apple-separator last:border-b-0" > - - + @@ -19430,8 +20435,7 @@ export default function NonIntegratedDetailClient({ row, values: row.brands, options: getUniqueNonEmptyStrings([ - ...formValues.adaptableBrands, - formValues.brand, + ...PLANNING_LAYOUT_BRAND_OPTIONS, ...row.brands, ]), })} @@ -19613,25 +20617,14 @@ export default function NonIntegratedDetailClient({ } function renderProductPricingWorkspace() { + const isJstPricingPushEnabled = false; const renderReadOnlyCell = (value: string) => (
{value || "-"}
); - const renderPricingInput = ( - row: ProductPricingDisplayRow, - key: Exclude, - placeholder: string, - ) => ( - updateProductPricingRow(row.skuId, key, value)} - /> - ); const adjustCostPrice = (row: ProductPricingDisplayRow, offset: number) => { + if (isReadOnly || isProductPricingNodeLocked) return; const currentValue = Number(row.costPrice); const nextValue = Math.max( 0, @@ -19662,7 +20655,7 @@ export default function NonIntegratedDetailClient({ ) : (
-
{label} + {index + 1} {renderImageCell(row, index)} + {renderImageCell(row, index)} + {renderReadOnlyCell(row.model)}
+
{[ @@ -19673,8 +20666,7 @@ export default function NonIntegratedDetailClient({ "规格名称", "供应商名称", "成本价", - "商品标签", - "查看条码分摊", + "是否录入系统", ].map((label) => ( - {displayedProductPricingRows.map((row) => ( - - - - - - - - - - - - ))} + {displayedProductPricingRows.map((row) => { + const isSystemEntered = + isJstPricingPushEnabled && + row.systemEnteredAt.trim().length > 0; + const pricingReadyForFuturePush = + row.costPrice.trim().length > 0; + + return ( + + + + + + + + + + + ); + })}
- {renderReadOnlyCell(row.model)} - - {renderReadOnlyCell(row.skuCode)} - - {renderReadOnlyCell(row.barcode)} - - {renderReadOnlyCell(row.setCount)} - - {renderReadOnlyCell(row.specName)} - - {renderReadOnlyCell(row.supplierName)} - -
- - - updateProductPricingRow( - row.skuId, - "costPrice", - event.target.value, - ) - } - readOnly={isReadOnly} - placeholder="0" - className="h-8 w-16 bg-transparent px-2 text-center outline-none placeholder:text-apple-label-tertiary" - /> - -
-
- {renderPricingInput(row, "productTag", "请输入商品标签")} - - -
+ {renderReadOnlyCell(row.model)} + + {renderReadOnlyCell(row.skuCode)} + + {renderReadOnlyCell(row.barcode)} + + {renderReadOnlyCell(row.setCount)} + + {renderReadOnlyCell(row.specName)} + + {renderReadOnlyCell(row.supplierName)} + +
+ + + updateProductPricingRow( + row.skuId, + "costPrice", + event.target.value, + ) + } + readOnly={isReadOnly || isProductPricingNodeLocked} + placeholder="0" + className="h-8 w-16 bg-transparent px-2 text-center outline-none placeholder:text-apple-label-tertiary" + /> + +
+
+
+ + {isSystemEntered ? "已录入" : "未录入"} + + +
+
@@ -20031,24 +21046,21 @@ function renderFactoryQualityWorkspace() { />
- + buttonClassName="h-8 w-[92px] rounded border border-apple-separator bg-apple-bg-secondary px-2 text-xs text-apple-label-primary outline-none disabled:bg-apple-bg-primary disabled:text-apple-label-secondary" + panelClassName="absolute left-0 top-[calc(100%+4px)] z-50 max-h-48 min-w-full overflow-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-1 shadow-apple-md" + />