diff --git a/furniture-3d-system/prisma/migrations/20260530143000_add_sku_quality_inspections/migration.sql b/furniture-3d-system/prisma/migrations/20260530143000_add_sku_quality_inspections/migration.sql new file mode 100644 index 00000000..4666ecca --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260530143000_add_sku_quality_inspections/migration.sql @@ -0,0 +1,49 @@ +CREATE TABLE "ProductSkuQualityInspection" ( + "id" TEXT NOT NULL, + "skuId" TEXT NOT NULL, + "recordKey" TEXT NOT NULL, + "sourceType" TEXT NOT NULL DEFAULT 'WAREHOUSE', + "sourceProductId" TEXT, + "sourceProductSpu" TEXT, + "sourceSkuItemId" TEXT, + "batchNo" TEXT NOT NULL, + "inspectedAt" TIMESTAMP(3), + "inspectorName" TEXT, + "conclusion" TEXT NOT NULL, + "detail" TEXT, + "inspectedQuantity" INTEGER, + "qualifiedQuantity" INTEGER, + "qualifiedRate" DOUBLE PRECISION, + "metrics" JSONB, + "attachments" JSONB, + "status" TEXT NOT NULL DEFAULT 'DRAFT', + "createdBy" TEXT, + "updatedBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProductSkuQualityInspection_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ProductSkuQualityInspection_recordKey_key" + ON "ProductSkuQualityInspection"("recordKey"); + +CREATE INDEX "ProductSkuQualityInspection_skuId_idx" + ON "ProductSkuQualityInspection"("skuId"); + +CREATE INDEX "ProductSkuQualityInspection_sourceType_idx" + ON "ProductSkuQualityInspection"("sourceType"); + +CREATE INDEX "ProductSkuQualityInspection_batchNo_idx" + ON "ProductSkuQualityInspection"("batchNo"); + +CREATE INDEX "ProductSkuQualityInspection_inspectedAt_idx" + ON "ProductSkuQualityInspection"("inspectedAt"); + +CREATE INDEX "ProductSkuQualityInspection_createdAt_idx" + ON "ProductSkuQualityInspection"("createdAt"); + +ALTER TABLE "ProductSkuQualityInspection" + ADD CONSTRAINT "ProductSkuQualityInspection_skuId_fkey" + FOREIGN KEY ("skuId") REFERENCES "ProductSKU"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 071c43d1..e8602721 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -153,6 +153,7 @@ model ProductSKU { warehouseLocationId String? warehouseLocation WarehouseLocation? @relation("ProductSKUWarehouseLocation", fields: [warehouseLocationId], references: [id], onDelete: SetNull) packagedItems PackagedItem[] @relation("PackagedItemSku") + qualityInspections ProductSkuQualityInspection[] @relation("ProductSKUQualityInspections") // 元数据 createdAt DateTime @default(now()) @@ -1003,6 +1004,42 @@ model PackagedItem { @@index([warehouseLocationId]) } +model ProductSkuQualityInspection { + id String @id @default(cuid()) + + skuId String + sku ProductSKU @relation("ProductSKUQualityInspections", fields: [skuId], references: [id], onDelete: Cascade) + + recordKey String @unique + sourceType String @default("WAREHOUSE") + sourceProductId String? + sourceProductSpu String? + sourceSkuItemId String? + + batchNo String + inspectedAt DateTime? + inspectorName String? + conclusion String + detail String? @db.Text + inspectedQuantity Int? + qualifiedQuantity Int? + qualifiedRate Float? + metrics Json? + attachments Json? + status String @default("DRAFT") + + createdBy String? + updatedBy String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([skuId]) + @@index([sourceType]) + @@index([batchNo]) + @@index([inspectedAt]) + @@index([createdAt]) +} + // ============================================ // 用户管理 // ============================================ diff --git a/furniture-3d-system/src/app/__tests__/draft-groups-page.test.tsx b/furniture-3d-system/src/app/__tests__/draft-groups-page.test.tsx index d1cd7f5d..8bef05c2 100644 --- a/furniture-3d-system/src/app/__tests__/draft-groups-page.test.tsx +++ b/furniture-3d-system/src/app/__tests__/draft-groups-page.test.tsx @@ -1,7 +1,8 @@ -import { render, screen, waitFor, within } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import type { ReactNode } from 'react' import DraftGroupsPage from '../draft-groups/page' +import DraftGroupDetailPage from '../draft-groups/[id]/page' jest.mock('next/link', () => ({ __esModule: true, @@ -42,6 +43,16 @@ jest.mock('@/components/platform/ModelPreviewPanel', () => ({ jest.mock('@/lib/server-session', () => ({ requireSession: jest.fn().mockResolvedValue({ + authUser: { + id: 'user-1', + name: '测试用户', + email: 'test@example.com', + role: 'SUPER_ADMIN', + businessRoles: [], + status: 'ACTIVE', + department: null, + departmentPaths: [], + }, user: { id: 'user-1', name: '测试用户', @@ -137,6 +148,63 @@ jest.mock('@/server/services', () => ({ }, })) +jest.mock('@/server/services/intake-non-integrated-product.service', () => ({ + intakeNonIntegratedProductService: { + listPage: jest.fn().mockResolvedValue({ + products: [ + { + id: 'series-product-1', + spu: 'CZ0132', + openingReference: { + productName: '单板桌', + }, + skuItems: [ + { + id: 'empty-series-placeholder-1', + skuSequence: 1, + batchNo: 1, + packageGroupId: '', + packageSuffix: '', + image: '', + imageName: '', + model: '', + skuCode: 'CZ013201', + setCount: '1', + specName: '', + barcode: '', + size: '', + color: '', + material: '', + boardMaterialType: '', + versionNo: 'V01', + attributeCode: '', + drawingId: '', + installMethod: '', + executeStandard: '', + nationalStandardCompliant: '', + manufacturer: '', + manufacturerCode: '', + salesStatus: '', + tagMaterial: '', + model3dFileName: '', + model3dDataUrl: '', + componentDetails: [], + holePoints: [], + }, + ], + skuBatchFlowNos: {}, + }, + ], + pageInfo: { + page: 1, + pageSize: 100, + total: 1, + totalPages: 1, + }, + }), + }, +})) + function createDraftGroupDetail() { return { draftGroup: createMockDraftGroupList()[0], @@ -317,6 +385,20 @@ describe('DraftGroupsPage', () => { }), } as Response } + if (url === '/api/draft-groups/draft-2') { + return { + ok: true, + json: async () => ({ + success: true, + data: { + result: { + draftGroup: createMockDraftGroupList()[1], + preSkus: [], + }, + }, + }), + } as Response + } if (url === '/api/pre-skus/pre-sku-1/basic-info' && init?.method === 'PATCH') { return { ok: true, @@ -378,47 +460,66 @@ describe('DraftGroupsPage', () => { }) as typeof fetch }) - it('ARCHIVED 草稿组应显示已归档状态并保留详情入口', async () => { + it('正式草稿组页应按系列 SKU 队列展示并可切换归档状态', async () => { + const actor = userEvent.setup() render(await DraftGroupsPage()) expect(await screen.findByText(/ModelPreviewPanel 1 \/ 材质 1 \/ 孔位 1/)).toBeInTheDocument() expect(screen.getByRole('button', { name: '隐藏孔位高亮' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: '草稿组' })).toBeInTheDocument() + expect(screen.queryByText('草稿组正式版预览')).not.toBeInTheDocument() + expect(screen.getByText('单板桌01')).toBeInTheDocument() + expect(screen.getByText('单板桌02')).toBeInTheDocument() + expect(screen.getAllByText(/单板桌-SPU:CZ0132/).length).toBeGreaterThan(0) + expect(screen.queryByRole('button', { name: /DBZ-SPJ/ })).not.toBeInTheDocument() - const archivedCard = screen.getByText('已归档的草稿').closest('article') - expect(archivedCard).not.toBeNull() - expect(within(archivedCard as HTMLElement).getByText('已归档')).toBeInTheDocument() - expect(within(archivedCard as HTMLElement).getByRole('link', { name: /详情/ })).toHaveAttribute( - 'href', - '/draft-groups/draft-2', - ) + const pendingBadge = screen.getAllByText('待推送')[0] + expect(pendingBadge).toHaveClass('bg-[#3a3025]', 'text-[#dfcfa8]') + expect(screen.getByRole('button', { name: '推送 SKU' })).toBeInTheDocument() + expect(screen.queryByText('已同步产品资料')).not.toBeInTheDocument() + + await actor.click(screen.getAllByRole('button', { name: /单板桌02/ })[0]) + expect(screen.getByText('已归档')).toBeInTheDocument() }) - it('应按系列和准 SKU 展示 3D、基础资料补齐和板材/组件/五金分组', async () => { + it('应按系列 SKU 展示 3D、完整 SKU 内容和板材/组件/五金分组', async () => { render(await DraftGroupsPage()) expect(await screen.findByText(/ModelPreviewPanel 1 \/ 材质 1 \/ 孔位 1/)).toBeInTheDocument() - expect(screen.getAllByText('单板桌').length).toBeGreaterThan(0) - expect(screen.getByText('基础资料补齐')).toBeInTheDocument() + expect(screen.getAllByText(/单板桌-SPU:CZ0132/).length).toBeGreaterThan(0) + expect(screen.getByText('SKU 资料承接')).toBeInTheDocument() + expect(screen.getByText('SKU内容')).toBeInTheDocument() + expect(screen.getAllByText('CZ013201').length).toBeGreaterThan(0) + expect(screen.queryByText(/预计正式 SKU/)).not.toBeInTheDocument() + expect(screen.getByLabelText('型号')).toBeInTheDocument() + expect(screen.getByLabelText('货号')).toBeInTheDocument() + expect(screen.getByLabelText('条码')).toBeInTheDocument() + expect(screen.getByLabelText('规格名称')).toBeInTheDocument() + expect(screen.getByLabelText('供应商名称')).toBeInTheDocument() + expect(screen.getByLabelText('供应商代码')).toBeInTheDocument() expect(screen.queryByText(/candidate-1/)).not.toBeInTheDocument() - expect(screen.getByText(/项待补齐/)).toBeInTheDocument() - expect(screen.getByText('板材 / 组件 / 五金件')).toBeInTheDocument() + expect(screen.getByText('板材 / 组件 / 五金')).toBeInTheDocument() expect(screen.getByText('桌面板')).toBeInTheDocument() expect(screen.getByText('桌腿')).toBeInTheDocument() expect(screen.getByText('连接螺丝')).toBeInTheDocument() + expect(screen.getByText('PRODUCT DATA').closest('a')).toHaveAttribute( + 'href', + '/intake?openProductId=series-product-1&workspace=basic-info', + ) + await waitFor(() => { expect(global.fetch).toHaveBeenCalledWith('/api/draft-groups/draft-1', { cache: 'no-store' }) }) }) - it('可在草稿组里手动补齐准 SKU 基础资料并保存到 PreSku 快照', async () => { + it('SKU 内容字段改动会自动保存到 PreSku 快照', async () => { const actor = userEvent.setup() render(await DraftGroupsPage()) - expect(await screen.findByText('基础资料补齐')).toBeInTheDocument() + expect(await screen.findByText('SKU内容')).toBeInTheDocument() - await actor.type(screen.getByLabelText('型号(资产族编码)'), 'DBZ-SPJK001') - await actor.click(screen.getByRole('button', { name: '保存' })) + await actor.type(screen.getByLabelText('型号'), 'DBZ-SPJK001') await waitFor(() => { expect(global.fetch).toHaveBeenCalledWith( @@ -428,7 +529,21 @@ describe('DraftGroupsPage', () => { body: expect.stringContaining('DBZ-SPJK001'), }), ) - }) - expect(await screen.findByText('已保存基础资料')).toBeInTheDocument() + }, { timeout: 2500 }) + expect(await screen.findByText('已自动保存')).toBeInTheDocument() + }) + + it('草稿组详情 URL 应复用正式草稿组工作台', async () => { + render(await DraftGroupDetailPage({ + params: Promise.resolve({ id: 'draft-2' }), + })) + + expect(await screen.findByText(/ModelPreviewPanel/)).toBeInTheDocument() + expect(screen.getByRole('heading', { name: '草稿组' })).toBeInTheDocument() + expect(screen.getByText('返回草稿组')).toBeInTheDocument() + expect(screen.getByText('单板桌02')).toBeInTheDocument() + expect(screen.getAllByText(/单板桌-SPU:CZ0132/).length).toBeGreaterThan(0) + expect(screen.queryByText('草稿详情')).not.toBeInTheDocument() + expect(screen.queryByText('基础资料补齐')).not.toBeInTheDocument() }) }) diff --git a/furniture-3d-system/src/app/__tests__/page-nav.test.tsx b/furniture-3d-system/src/app/__tests__/page-nav.test.tsx index e1230448..1c99e442 100644 --- a/furniture-3d-system/src/app/__tests__/page-nav.test.tsx +++ b/furniture-3d-system/src/app/__tests__/page-nav.test.tsx @@ -111,7 +111,7 @@ describe('首页工作台数据注入', () => { mockedGetProducts.mockClear() }) - it('应只把当前账号的草稿和正式产品注入首页工作台', async () => { + it('应只把当前账号的草稿和产品资料注入首页工作台', async () => { render(await HomePage({ searchParams: Promise.resolve({}) })) expect(screen.getByText('HomeClient')).toBeInTheDocument() diff --git a/furniture-3d-system/src/app/api/external/intake/product-pricing/route.test.ts b/furniture-3d-system/src/app/api/external/intake/product-pricing/route.test.ts new file mode 100644 index 00000000..263fa646 --- /dev/null +++ b/furniture-3d-system/src/app/api/external/intake/product-pricing/route.test.ts @@ -0,0 +1,97 @@ +import type { NextRequest } from 'next/server' + +const findConfirmedCostPriceByIdentifier = jest.fn() + +jest.mock('next/server', () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { + 'Content-Type': 'application/json', + ...init?.headers, + }, + }), + }, +})) + +jest.mock('@/server/services/intake-product-pricing-query.service', () => ({ + intakeProductPricingQueryService: { + findConfirmedCostPriceByIdentifier: (...args: unknown[]) => + findConfirmedCostPriceByIdentifier(...args), + }, +})) + +const { GET } = require('./route') + +describe('GET /api/external/intake/product-pricing', () => { + const originalApiKey = process.env.INTAKE_PRICING_API_KEY + + function createRequest(code: string, apiKey?: string) { + return { + nextUrl: new URL( + `http://localhost/api/external/intake/product-pricing?code=${code}`, + ), + headers: new Headers(apiKey ? { 'x-api-key': apiKey } : undefined), + } as unknown as NextRequest + } + + beforeEach(() => { + jest.clearAllMocks() + process.env.INTAKE_PRICING_API_KEY = 'pricing-test-key' + }) + + afterAll(() => { + if (originalApiKey === undefined) { + delete process.env.INTAKE_PRICING_API_KEY + return + } + process.env.INTAKE_PRICING_API_KEY = originalApiKey + }) + + it('requires the integration API key', async () => { + const response = await GET(createRequest('SZ023001')) + + expect(response.status).toBe(401) + expect(findConfirmedCostPriceByIdentifier).not.toHaveBeenCalled() + }) + + it('does not return an unconfirmed cost price', async () => { + findConfirmedCostPriceByIdentifier.mockResolvedValueOnce({ + status: 'NOT_CONFIRMED', + }) + + const response = await GET(createRequest('SZ023001', 'pricing-test-key')) + const payload = await response.json() + + expect(response.status).toBe(409) + expect(payload.error.message).toBe( + '该商品的产品核价尚未确认,暂不可提供成本价', + ) + }) + + it('returns only the confirmed pricing fields needed by the caller', async () => { + findConfirmedCostPriceByIdentifier.mockResolvedValueOnce({ + status: 'AVAILABLE', + data: { + itemCode: 'SZ023001', + barcode: 'SZ023001N1280V01', + costPrice: 88, + pricingConfirmedAt: '2026-05-27T02:00:00.000Z', + matchedBy: 'itemCode', + }, + }) + + const response = await GET(createRequest('SZ023001', 'pricing-test-key')) + const payload = await response.json() + + expect(response.status).toBe(200) + expect(payload.data).toEqual({ + itemCode: 'SZ023001', + barcode: 'SZ023001N1280V01', + costPrice: 88, + pricingConfirmedAt: '2026-05-27T02:00:00.000Z', + matchedBy: 'itemCode', + }) + }) +}) diff --git a/furniture-3d-system/src/app/api/external/intake/product-pricing/route.ts b/furniture-3d-system/src/app/api/external/intake/product-pricing/route.ts new file mode 100644 index 00000000..5a2bfc3d --- /dev/null +++ b/furniture-3d-system/src/app/api/external/intake/product-pricing/route.ts @@ -0,0 +1,94 @@ +import { timingSafeEqual } from 'node:crypto' +import { NextRequest } from 'next/server' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { intakeProductPricingQueryService } from '@/server/services/intake-product-pricing-query.service' + +function matchesPricingApiKey(request: NextRequest) { + const configuredKey = process.env.INTAKE_PRICING_API_KEY?.trim() ?? '' + if (!configuredKey) return 'NOT_CONFIGURED' + + const providedKey = request.headers.get('x-api-key')?.trim() ?? '' + if (!providedKey) return 'INVALID' + + const configuredBuffer = Buffer.from(configuredKey) + const providedBuffer = Buffer.from(providedKey) + if (configuredBuffer.length !== providedBuffer.length) return 'INVALID' + + return timingSafeEqual(configuredBuffer, providedBuffer) ? 'VALID' : 'INVALID' +} + +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const apiKeyStatus = matchesPricingApiKey(request) + if (apiKeyStatus === 'NOT_CONFIGURED') { + return jsonError( + ErrorCode.SYSTEM_INTERNAL, + '产品核价查询接口尚未配置密钥', + 503, + ctx.requestId, + ) + } + + if (apiKeyStatus !== 'VALID') { + return jsonError( + ErrorCode.AUTH_PERMISSION_DENIED, + '接口密钥无效', + 401, + ctx.requestId, + ) + } + + const code = request.nextUrl.searchParams.get('code')?.trim() ?? '' + if (!code) { + return jsonError( + ErrorCode.VALIDATION_REQUIRED_FIELD, + '请输入货号或条码', + 400, + ctx.requestId, + ) + } + + const result = + await intakeProductPricingQueryService.findConfirmedCostPriceByIdentifier( + code, + ) + + switch (result.status) { + case 'NOT_FOUND': + return jsonError( + ErrorCode.SKU_NOT_FOUND, + '未找到对应货号或条码', + 404, + ctx.requestId, + ) + case 'AMBIGUOUS': + return jsonError( + ErrorCode.VALIDATION_FAILED, + '货号或条码对应多条资料,请联系管理员检查数据', + 409, + ctx.requestId, + ) + case 'NOT_CONFIRMED': + return jsonError( + ErrorCode.PRODUCT_INVALID_STATUS, + '该商品的产品核价尚未确认,暂不可提供成本价', + 409, + ctx.requestId, + ) + case 'PRICE_MISSING': + return jsonError( + ErrorCode.VALIDATION_REQUIRED_FIELD, + '该商品尚未填写成本价', + 409, + ctx.requestId, + ) + case 'AVAILABLE': + return jsonSuccess(result.data, 200, ctx.requestId) + } + }, + { requireAuth: false }, + ) +} diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/jushuitan-product-pricing-push/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/jushuitan-product-pricing-push/route.ts new file mode 100644 index 00000000..7286d1b9 --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/jushuitan-product-pricing-push/route.ts @@ -0,0 +1,73 @@ +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 { jushuitanProductPricingPushService } from '@/server/services/jushuitan-product-pricing-push.service' + +type RouteContext = { + params: Promise<{ id: string }> +} + +function readTargetSkuIds(value: unknown) { + if (!Array.isArray(value)) return [] + + return value + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter(Boolean) +} + +export async function POST(request: NextRequest, context: RouteContext) { + return handleRequest( + request, + async (ctx) => { + const { id } = await context.params + const productId = decodeURIComponent(id) + const rawBody = await request.json().catch(() => null) + const targetSkuIds = + rawBody && typeof rawBody === 'object' + ? readTargetSkuIds((rawBody as { targetSkuIds?: unknown }).targetSkuIds) + : [] + + if (targetSkuIds.length === 0) { + return jsonError( + ErrorCode.VALIDATION_REQUIRED_FIELD, + '请选择要推送聚水潭的 SKU', + 400, + ctx.requestId, + ) + } + + try { + const product = await intakeNonIntegratedProductService.getById( + productId, + ctx.user, + ) + if (!product) { + return jsonError( + ErrorCode.PRODUCT_NOT_FOUND, + '非集成产品不存在', + 404, + ctx.requestId, + ) + } + + const result = + await jushuitanProductPricingPushService.pushProductPricing({ + product, + targetSkuIds, + }) + + return jsonSuccess(result, 200, ctx.requestId) + } catch (error) { + if (error instanceof AppError) { + return jsonError(error.code, error.message, error.statusCode, ctx.requestId) + } + + const message = + error instanceof Error ? error.message : '聚水潭测试推送失败' + return jsonError(ErrorCode.SYSTEM_INTERNAL, message, 500, ctx.requestId) + } + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/jushuitan-virtual-inventory-push/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/jushuitan-virtual-inventory-push/route.ts new file mode 100644 index 00000000..ed654414 --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/non-integrated/jushuitan-virtual-inventory-push/route.ts @@ -0,0 +1,157 @@ +import { NextRequest } from 'next/server' +import type { NonIntegratedPricingRow } from '@/app/intake/non-integrated/nonIntegratedStorage' +import { ErrorCode } from '@/server/core/errors' +import type { AuthUser } from '@/server/core/types' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { intakeNonIntegratedProductService } from '@/server/services/intake-non-integrated-product.service' +import { jushuitanProductPricingPushService } from '@/server/services/jushuitan-product-pricing-push.service' + +function getCronUser(request: NextRequest): AuthUser | null { + const cronSecret = process.env.JST_VIRTUAL_INVENTORY_CRON_SECRET?.trim() + if (!cronSecret) return null + + const headerSecret = + request.headers.get('x-jst-virtual-inventory-secret')?.trim() ?? '' + if (headerSecret !== cronSecret) return null + + return { + id: 'jst-virtual-inventory-cron', + email: 'jst-virtual-inventory-cron@local', + name: '聚水潭虚拟库存定时任务', + role: 'SUPER_ADMIN', + status: 'ACTIVE', + businessRoles: [], + } +} + +function isDueVirtualInventoryRow(row: NonIntegratedPricingRow, now: Date) { + if (row.jstVirtualInventoryStatus !== 'pending') return false + const scheduledAt = row.jstVirtualInventoryScheduledAt?.trim() + if (!scheduledAt) return false + + const scheduledTime = new Date(scheduledAt).getTime() + return Number.isFinite(scheduledTime) && scheduledTime <= now.getTime() +} + +function updateVirtualInventoryRows( + rows: NonIntegratedPricingRow[], + result: Awaited< + ReturnType + >, + pushedAt: string, +) { + const pushedSkuIds = new Set(result.pushedSkuIds) + const failedBySkuId = new Map( + result.virtualInventoryFailed + .filter((item) => item.skuId) + .map((item) => [item.skuId!, item.reason]), + ) + const skippedBySkuId = new Map( + result.virtualInventorySkipped + .filter((item) => item.skuId) + .map((item) => [item.skuId!, item.reason]), + ) + + return rows.map((row) => { + if (pushedSkuIds.has(row.skuId)) { + return { + ...row, + jstVirtualInventoryStatus: 'pushed', + jstVirtualInventoryPushedAt: pushedAt, + jstVirtualInventoryError: '', + } satisfies NonIntegratedPricingRow + } + + const failedReason = failedBySkuId.get(row.skuId) + if (failedReason) { + return { + ...row, + jstVirtualInventoryStatus: 'failed', + jstVirtualInventoryError: failedReason, + } satisfies NonIntegratedPricingRow + } + + const skippedReason = skippedBySkuId.get(row.skuId) + if (skippedReason) { + return { + ...row, + jstVirtualInventoryStatus: 'skipped', + jstVirtualInventoryError: skippedReason, + } satisfies NonIntegratedPricingRow + } + + return row + }) +} + +export async function POST(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const user = ctx.user ?? getCronUser(request) + if (!user) { + return jsonError( + ErrorCode.AUTH_INVALID_TOKEN, + '请先登录或配置虚拟库存定时任务密钥', + 401, + ctx.requestId, + ) + } + + const now = new Date() + const pushedAt = now.toISOString() + const products = await intakeNonIntegratedProductService.list(user) + const processedProducts: Array<{ + productId: string + targetSkuIds: string[] + pushed: number + skipped: number + failed: number + }> = [] + + for (const product of products) { + const dueSkuIds = product.pricingRows + .filter((row) => isDueVirtualInventoryRow(row, now)) + .map((row) => row.skuId) + + if (dueSkuIds.length === 0) continue + + const result = + await jushuitanProductPricingPushService.pushVirtualInventory({ + product, + targetSkuIds: dueSkuIds, + }) + const nextPricingRows = updateVirtualInventoryRows( + product.pricingRows, + result, + pushedAt, + ) + + await intakeNonIntegratedProductService.save( + { + ...product, + pricingRows: nextPricingRows, + }, + user, + ) + + processedProducts.push({ + productId: product.id, + targetSkuIds: dueSkuIds, + pushed: result.virtualInventoryCount, + skipped: result.virtualInventorySkippedCount, + failed: result.virtualInventoryFailedCount, + }) + } + + return jsonSuccess( + { + processedProductCount: processedProducts.length, + pushedCount: processedProducts.reduce((sum, item) => sum + item.pushed, 0), + skippedCount: processedProducts.reduce((sum, item) => sum + item.skipped, 0), + failedCount: processedProducts.reduce((sum, item) => sum + item.failed, 0), + processedProducts, + }, + 200, + ctx.requestId, + ) + }, { requireAuth: false }) +} diff --git a/furniture-3d-system/src/app/api/platform/schemas.ts b/furniture-3d-system/src/app/api/platform/schemas.ts index 6ac85595..85ebcc32 100644 --- a/furniture-3d-system/src/app/api/platform/schemas.ts +++ b/furniture-3d-system/src/app/api/platform/schemas.ts @@ -343,18 +343,35 @@ export const savePreSkuPackagingPlanSchema = z.object({ }) export const preSkuBasicInfoFieldKeySchema = z.enum([ + 'image', + 'imageName', 'model', 'formalSkuCode', + 'skuCode', 'barcode', + 'setCount', + 'specName', 'size', 'color', 'material', - 'specName', + 'boardMaterialType', 'versionNo', - 'setCount', 'attributeCode', 'drawingId', 'installMethod', + 'executeStandard', + 'nationalStandardCompliant', + 'manufacturer', + 'manufacturerCode', + 'supplierName', + 'supplierCode', + 'salesStatus', + 'batchNo', + 'dingTalkFlowNo', + 'dingtalkFlowNo', + 'flowNo', + 'tagMaterial', + 'model3dFileName', ]) export const updatePreSkuBasicInfoSchema = z.object({ diff --git a/furniture-3d-system/src/app/api/warehouse-quality/records/[id]/route.ts b/furniture-3d-system/src/app/api/warehouse-quality/records/[id]/route.ts new file mode 100644 index 00000000..e7df5969 --- /dev/null +++ b/furniture-3d-system/src/app/api/warehouse-quality/records/[id]/route.ts @@ -0,0 +1,39 @@ +import { NextRequest } from 'next/server' +import { AppError } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { skuQualityInspectionService } from '@/server/services' +import type { SaveWarehouseQualityInspectionInput } from '@/server/services' + +type RouteContext = { + params: Promise<{ id: string }> +} + +export async function PATCH(request: NextRequest, context: RouteContext) { + return handleRequest( + request, + async (ctx) => { + try { + const { id } = await context.params + const body = await request.json().catch(() => null) + const input = + body && typeof body === 'object' + ? ((body as { record?: SaveWarehouseQualityInspectionInput }).record ?? + (body as SaveWarehouseQualityInspectionInput)) + : ({} as SaveWarehouseQualityInspectionInput) + + const record = await skuQualityInspectionService.updateWarehouseRecord( + decodeURIComponent(id), + input, + ctx.user, + ) + return jsonSuccess({ record }, 200, ctx.requestId) + } catch (error) { + if (error instanceof AppError) { + return jsonError(error.code, error.message, error.statusCode, ctx.requestId) + } + throw error + } + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/warehouse-quality/route.ts b/furniture-3d-system/src/app/api/warehouse-quality/route.ts new file mode 100644 index 00000000..cd09901a --- /dev/null +++ b/furniture-3d-system/src/app/api/warehouse-quality/route.ts @@ -0,0 +1,51 @@ +import { NextRequest } from 'next/server' +import { AppError } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { skuQualityInspectionService } from '@/server/services' +import type { SaveWarehouseQualityInspectionInput } from '@/server/services' + +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + try { + const search = request.nextUrl.searchParams.get('search') ?? undefined + const overview = await skuQualityInspectionService.getOverview({ search }) + return jsonSuccess({ overview }, 200, ctx.requestId) + } catch (error) { + if (error instanceof AppError) { + return jsonError(error.code, error.message, error.statusCode, ctx.requestId) + } + throw error + } + }, + { requireAuth: true }, + ) +} + +export async function POST(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + try { + const body = await request.json().catch(() => null) + const input = + body && typeof body === 'object' + ? ((body as { record?: SaveWarehouseQualityInspectionInput }).record ?? + (body as SaveWarehouseQualityInspectionInput)) + : ({} as SaveWarehouseQualityInspectionInput) + const record = await skuQualityInspectionService.saveWarehouseRecord( + input, + ctx.user, + ) + return jsonSuccess({ record }, 200, ctx.requestId) + } catch (error) { + if (error instanceof AppError) { + return jsonError(error.code, error.message, error.statusCode, ctx.requestId) + } + throw error + } + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/approval-workflows/[accessKey]/ApprovalWorkflowPageClient.tsx b/furniture-3d-system/src/app/approval-workflows/[accessKey]/ApprovalWorkflowPageClient.tsx index 3fea4d2c..358de31a 100644 --- a/furniture-3d-system/src/app/approval-workflows/[accessKey]/ApprovalWorkflowPageClient.tsx +++ b/furniture-3d-system/src/app/approval-workflows/[accessKey]/ApprovalWorkflowPageClient.tsx @@ -248,7 +248,7 @@ function ApprovalWorkflowPageBody({ accessKey }: { accessKey: string }) {

这张流程单要补什么

- 下面这份清单按正式产品和仓库管理当前真实流程整理,先把该填的内容列完整。 + 下面这份清单按产品资料和仓库管理当前真实流程整理,先把该填的内容列完整。

diff --git a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx index aa12bfc9..1a4ee162 100644 --- a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx +++ b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx @@ -1401,7 +1401,7 @@ export default function MaterialLibraryClient({ value={seriesForm.description} onChange={(e) => setSeriesForm((current) => ({ ...current, description: e.target.value }))} className="min-h-[92px] w-full resize-none rounded-lg border border-black/10 bg-white px-3 py-2 text-sm font-apple-bold text-[#263033] outline-none transition placeholder:text-[#7a878a]/70 focus:border-[#101719]/30 focus:ring-4 focus:ring-black/[0.04]" - placeholder="用于资产中心和后续正式产品调用" + placeholder="用于资产中心和后续产品资料调用" />
diff --git a/furniture-3d-system/src/app/draft-groups/DraftGroupsClient.tsx b/furniture-3d-system/src/app/draft-groups/DraftGroupsClient.tsx index 71a7eb0f..33f1aaeb 100644 --- a/furniture-3d-system/src/app/draft-groups/DraftGroupsClient.tsx +++ b/furniture-3d-system/src/app/draft-groups/DraftGroupsClient.tsx @@ -827,7 +827,7 @@ export default function DraftGroupsClient({ draftGroups, categories, user }: Dra PRODUCT DATA 进入产品资料入库流程 - 带当前草稿组进入正式产品承接 + 带当前草稿组进入产品资料承接 diff --git a/furniture-3d-system/src/app/draft-groups/DraftGroupsFormalClient.tsx b/furniture-3d-system/src/app/draft-groups/DraftGroupsFormalClient.tsx new file mode 100644 index 00000000..c12a2939 --- /dev/null +++ b/furniture-3d-system/src/app/draft-groups/DraftGroupsFormalClient.tsx @@ -0,0 +1,3230 @@ +'use client' + +import Link from 'next/link' +import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react' +import { + ArrowRight, + Boxes, + CheckCircle2, + CircleDot, + Layers3, + Loader2, + Search, + Send, + Trash2, + Wrench, +} from 'lucide-react' +import { + readPreSkuComponentDetails, + readPreSkuSkuFields, + type SeriesDraftSkuFields, +} from '@/app/intake/seriesComponentDetails' +import { + getSharedNonIntegratedProductById, + listSharedNonIntegratedProductPage, + saveSharedNonIntegratedProduct, +} from '@/app/intake/non-integrated/nonIntegratedApi' +import { + NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY, + NON_INTEGRATED_PRODUCTS_UPDATED, + NON_INTEGRATED_STORAGE_KEY, + type NonIntegratedSkuComponentDetail, + type NonIntegratedProductRecord, + type NonIntegratedSkuItem, +} from '@/app/intake/non-integrated/nonIntegratedStorage' +import { EXECUTION_STANDARD_OPTIONS } from '@/app/intake/non-integrated/executionStandardDirectory' +import { + SUPPLIER_CODE_BY_NAME, + SUPPLIER_DIRECTORY, +} from '@/app/intake/non-integrated/supplierDirectory' +import { ModelPreviewPanel } from '@/components/platform/ModelPreviewPanel' +import { ViewportToolbarButton } from '@/components/platform/ViewportStageShell' +import { extractResponseData, isApiFailure, isRecord, parseApiErrorPayload, readJsonSafely } from '@/lib/api-response' +import { updatePreSkuBasicInfo } from '@/lib/open-product/platform-api' +import { readPreviewHoleOverlayState, readPreviewMaterialAssignments, readPreviewWorldNodes } from '@/lib/open-product/platform-view' +import { cn, getApiErrorMessage } from '@/lib/utils' +import type { + DraftGroupDTO, + DraftGroupDetailDTO, + DraftGroupPreSkuDTO, + PreSkuBasicInfoFieldKey, +} from '@/server/services/draft-group.service.types' +import { + deriveDraftMaterialSections, + formatDraftGroupDate, + getDraftGroupStatusLabel, +} from './draftGroupDisplay' + +interface DraftGroupsFormalClientProps { + draftGroups: DraftGroupDTO[] + initialDraftGroupId?: string | null + initialFormalSeriesProducts?: FormalSeriesProductSummary[] + pageTitle?: string + showBackLink?: boolean +} + +interface DraftGroupSeriesGroup { + key: string + code: string + name: string + items: DraftGroupDTO[] +} + +interface FormalSeriesProductSummary { + id: string + name: string + spu: string + pushedSourceSkuIds: string[] + skuItems: NonIntegratedSkuItem[] + skuBatchFlowNos?: Record +} + +type SkuContentFieldKey = keyof SeriesDraftSkuFields +type SkuContentFormValues = Partial> +type SkuContentAutoSaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error' +type PackageComponentAssignments = Record + +interface SkuContentAutoSaveState { + status: SkuContentAutoSaveStatus + message?: string +} + +interface SkuContentFieldDefinition { + label: string + key: SkuContentFieldKey + placeholder: string + span?: 'full' + control?: 'input' | 'select' | 'readonly' | 'combobox' + options?: readonly string[] + defaultWidth?: number + minWidth?: number +} + +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 INSTALL_METHOD_OPTIONS = ['整装', '拆装'] as const +const SUPPLIER_NAME_OPTIONS = SUPPLIER_DIRECTORY.map((entry) => entry.name) +const SUPPLIER_NAME_BY_CODE = new Map( + SUPPLIER_DIRECTORY.map((entry) => [ + entry.code.trim().toUpperCase(), + entry.name, + ]), +) +const MANUAL_ONLY_SKU_CONTENT_FIELDS = new Set([ + 'model', + 'size', + 'color', + 'material', + 'boardMaterialType', +]) + +const SKU_CONTENT_TABLE_FIELD_DEFINITIONS: SkuContentFieldDefinition[] = [ + { label: '型号', key: 'model', placeholder: '请填写', defaultWidth: 96 }, + { label: '货号', key: 'skuCode', placeholder: '根据 SPU 自动生成', control: 'readonly', defaultWidth: 112 }, + { label: '条码', key: 'barcode', placeholder: '自动带出,可修改', defaultWidth: 168, minWidth: 150 }, + { label: '套件量', key: 'setCount', placeholder: '1', defaultWidth: 72, minWidth: 64 }, + { label: '规格名称', key: 'specName', placeholder: '自动带出', control: 'readonly', defaultWidth: 140 }, + { label: '尺寸', key: 'size', placeholder: '请填写', defaultWidth: 96 }, + { label: '颜色', key: 'color', placeholder: '请填写', defaultWidth: 96 }, + { label: '材质', key: 'material', placeholder: '请填写', defaultWidth: 120 }, + { label: '板材类型', key: 'boardMaterialType', placeholder: '请选择', control: 'select', options: SKU_BOARD_MATERIAL_TYPE_OPTIONS, defaultWidth: 108 }, + { label: '版本号', key: 'versionNo', placeholder: 'V01', defaultWidth: 68, minWidth: 56 }, + { label: '属性代码', key: 'attributeCode', placeholder: '请填写', defaultWidth: 96 }, + { label: '图纸ID', key: 'drawingId', placeholder: '自动带出,可修改', defaultWidth: 144 }, + { label: '安装方式', key: 'installMethod', placeholder: '请选择', control: 'select', options: INSTALL_METHOD_OPTIONS, defaultWidth: 96 }, + { label: '执行标准', key: 'executeStandard', placeholder: '请填写或选择', control: 'select', options: EXECUTION_STANDARD_OPTIONS, defaultWidth: 140 }, + { label: '符合国标', key: 'nationalStandardCompliant', placeholder: '是 / 否', control: 'select', options: NATIONAL_STANDARD_COMPLIANCE_OPTIONS, defaultWidth: 96 }, + { label: '供应商名称', key: 'manufacturer', placeholder: '输入供应商名称筛选', control: 'combobox', options: SUPPLIER_NAME_OPTIONS, defaultWidth: 152, minWidth: 132 }, + { label: '供应商代码', key: 'manufacturerCode', placeholder: '自动带出', control: 'readonly', defaultWidth: 96 }, + { label: '销售状态', key: 'salesStatus', placeholder: '销售 / 停产 / 退市', control: 'select', options: SKU_SALES_STATUS_OPTIONS, defaultWidth: 96 }, + { label: '创建批次', key: 'batchNo', placeholder: '批次1', control: 'readonly', defaultWidth: 76, minWidth: 64 }, + { label: '钉钉流程号', key: 'dingTalkFlowNo', placeholder: '批次信息维护', defaultWidth: 132 }, +] + +const SKU_CONTENT_HIDDEN_FIELD_DEFINITIONS: SkuContentFieldDefinition[] = [ + { label: '图片', key: 'imageName', placeholder: '暂无图片' }, + { label: '3D 模型', key: 'model3dFileName', placeholder: '随 SKU 补充资料承接' }, +] + +const SKU_CONTENT_FORM_FIELD_DEFINITIONS = [ + ...SKU_CONTENT_HIDDEN_FIELD_DEFINITIONS, + ...SKU_CONTENT_TABLE_FIELD_DEFINITIONS, +] + +const SKU_CONTENT_AUTO_SAVE_FIELD_KEYS = [ + 'model', + 'barcode', + 'setCount', + 'size', + 'color', + 'material', + 'boardMaterialType', + 'versionNo', + 'attributeCode', + 'drawingId', + 'installMethod', + 'executeStandard', + 'nationalStandardCompliant', + 'manufacturer', + 'manufacturerCode', + 'salesStatus', + 'batchNo', + 'dingTalkFlowNo', +] as const satisfies readonly SkuContentFieldKey[] + +const FORMAL_LEFT_PANE_WIDTH_STORAGE_KEY = 'draft-groups-formal-left-pane-width' +const FORMAL_RIGHT_PANE_WIDTH_STORAGE_KEY = 'draft-groups-formal-right-pane-width' +const FORMAL_PACKAGE_COMPONENT_ASSIGNMENTS_STORAGE_KEY = 'draft-groups-formal-package-component-assignments' +const DEFAULT_LEFT_PANE_WIDTH = 372 +const DEFAULT_RIGHT_PANE_WIDTH = 486 +const MIN_LEFT_PANE_WIDTH = 300 +const MAX_LEFT_PANE_WIDTH = 560 +const MIN_RIGHT_PANE_WIDTH = 420 +const MAX_RIGHT_PANE_WIDTH = 980 +const MIN_CENTER_PANE_WIDTH = 420 +const WARM_BLACK_CHAMPAGNE_STATUS_CLASS_NAME = + 'border-[#7a674c] bg-[#3a3025] text-[#dfcfa8] shadow-[inset_0_0_0_1px_rgba(223,207,168,0.14)]' +const WARM_BLACK_CHAMPAGNE_BUTTON_CLASS_NAME = + 'border border-[#7a674c] bg-[#3a3025] text-[#dfcfa8] shadow-[inset_0_0_0_1px_rgba(223,207,168,0.18)]' +const SKU_CONTENT_TABLE_INDEX_WIDTH = 48 +const SKU_CONTENT_TABLE_IMAGE_WIDTH = 74 +const SKU_CONTENT_TABLE_CELL_PADDING_WIDTH = 16 +const SKU_CONTENT_TABLE_COLUMN_MAX_WIDTH = 420 + +function clampNumber(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), Math.max(min, max)) +} + +function getSkuContentFieldDefaultWidth(field: SkuContentFieldDefinition) { + return field.defaultWidth ?? 112 +} + +function getSkuContentFieldMinWidth(field: SkuContentFieldDefinition) { + return field.minWidth ?? Math.min(getSkuContentFieldDefaultWidth(field), 96) +} + +function normalizeSkuContentColumnWidth( + field: SkuContentFieldDefinition, + width: number, +) { + return clampNumber( + Math.round(width), + getSkuContentFieldMinWidth(field), + SKU_CONTENT_TABLE_COLUMN_MAX_WIDTH, + ) +} + +function createDefaultSkuContentColumnWidths() { + return SKU_CONTENT_TABLE_FIELD_DEFINITIONS.reduce( + (widths, field) => ({ + ...widths, + [field.key]: getSkuContentFieldDefaultWidth(field), + }), + {} as Partial>, + ) +} + +function getSkuContentFieldWidth( + widths: Partial>, + field: SkuContentFieldDefinition, +) { + return widths[field.key] ?? getSkuContentFieldDefaultWidth(field) +} + +function getSkuContentTableMinWidth(widths: Partial>) { + return ( + SKU_CONTENT_TABLE_INDEX_WIDTH + + SKU_CONTENT_TABLE_IMAGE_WIDTH + + SKU_CONTENT_TABLE_FIELD_DEFINITIONS.reduce( + (total, field) => + total + getSkuContentFieldWidth(widths, field) + SKU_CONTENT_TABLE_CELL_PADDING_WIDTH, + 0, + ) + ) +} + +function readStoredPaneWidth( + storageKey: string, + fallback: number, + min: number, + max: number, +) { + if (typeof window === 'undefined') return fallback + + const parsed = Number.parseInt(window.localStorage.getItem(storageKey) ?? '', 10) + return Number.isFinite(parsed) ? clampNumber(parsed, min, max) : fallback +} + +function getPushStatusBadgeClassName() { + return WARM_BLACK_CHAMPAGNE_STATUS_CLASS_NAME +} + +function getPreviewStatusPillClassName() { + return WARM_BLACK_CHAMPAGNE_STATUS_CLASS_NAME +} + +function SkuContentColumnResizableFrame({ + columnWidth, + minWidth, + onColumnResize, + children, +}: { + columnWidth: number + minWidth: number + onColumnResize: (width: number) => void + children: ReactNode +}) { + function handlePointerDown(event: ReactPointerEvent) { + event.preventDefault() + event.stopPropagation() + + const startX = event.clientX + const startWidth = columnWidth + document.body.style.cursor = 'ew-resize' + document.body.style.userSelect = 'none' + + const handlePointerMove = (moveEvent: PointerEvent) => { + onColumnResize(startWidth + moveEvent.clientX - startX) + } + const handlePointerUp = () => { + document.body.style.cursor = '' + document.body.style.userSelect = '' + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', handlePointerUp) + } + + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', handlePointerUp, { once: true }) + } + + return ( +
+ {children} +
+ +
+
+ ) +} + +function InlineSupplierCombobox({ + ariaLabel, + value, + options, + placeholder, + readOnly, + dropdownId, + onChange, +}: { + ariaLabel: string + value: string + options: readonly string[] + placeholder?: string + readOnly?: boolean + dropdownId: string + onChange: (value: string) => void +}) { + const inputRef = useRef(null) + const dropdownRef = useRef(null) + const [open, setOpen] = useState(false) + const [dropdownRect, setDropdownRect] = useState({ + left: 0, + top: 0, + width: 220, + }) + const normalizedQuery = value.trim().toLowerCase() + const filteredOptions = useMemo(() => { + if (!normalizedQuery) return options.slice(0, 80) + return options + .filter((option) => option.toLowerCase().includes(normalizedQuery)) + .slice(0, 80) + }, [normalizedQuery, options]) + const updateDropdownPosition = useCallback(() => { + const input = inputRef.current + if (!input) return + + const rect = input.getBoundingClientRect() + const width = Math.max(rect.width, 220) + const viewportPadding = 8 + const left = + rect.left + width > window.innerWidth - viewportPadding + ? Math.max(viewportPadding, rect.right - width) + : Math.max(viewportPadding, rect.left) + + setDropdownRect({ + left, + top: rect.bottom + 4, + width, + }) + }, []) + + useEffect(() => { + if (!open) return + + updateDropdownPosition() + window.addEventListener('resize', updateDropdownPosition) + window.addEventListener('scroll', updateDropdownPosition, true) + return () => { + window.removeEventListener('resize', updateDropdownPosition) + window.removeEventListener('scroll', updateDropdownPosition, true) + } + }, [open, updateDropdownPosition]) + + useEffect(() => { + if (!open) return + + function handlePointerDown(event: PointerEvent) { + const target = event.target + if (!(target instanceof Node)) return + if (inputRef.current?.contains(target)) return + if (dropdownRef.current?.contains(target)) return + setOpen(false) + } + + document.addEventListener('pointerdown', handlePointerDown, true) + return () => document.removeEventListener('pointerdown', handlePointerDown, true) + }, [open]) + + return ( + <> + + { + if (readOnly) return + setOpen(true) + updateDropdownPosition() + }} + onClick={() => { + if (readOnly) return + setOpen(true) + updateDropdownPosition() + }} + onChange={(event) => { + onChange(event.target.value) + setOpen(true) + updateDropdownPosition() + }} + onKeyDown={(event) => { + if (event.key === 'Escape') setOpen(false) + }} + className={cn( + 'h-8 w-full min-w-0 rounded-[5px] border border-[#dbe3e5] bg-white px-2 pr-7 text-[12px] font-black text-[#2d3335] outline-none placeholder:text-[#8a9498]', + readOnly && 'bg-[#f7f9f9] text-[#5a6062]/70', + )} + /> + + ▼ + + + {open && !readOnly ? ( +
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + )) + ) : ( +
+ 无匹配供应商,可继续手动输入 +
+ )} +
+ ) : null} + + ) +} + +function buildSeriesProductBasicInfoHref(product: FormalSeriesProductSummary | null) { + if (!product?.id) return '' + return `/intake?openProductId=${encodeURIComponent(product.id)}&workspace=basic-info` +} + +function parseDraftGroupDetailPayload(raw: unknown): DraftGroupDetailDTO | null { + const payload = extractResponseData(raw) + if (!isRecord(payload) || !isRecord(payload.result)) return null + + const result = payload.result + if (!isRecord(result.draftGroup) || !Array.isArray(result.preSkus)) return null + + return result as unknown as DraftGroupDetailDTO +} + +function parseDraftGroupListPayload(raw: unknown): DraftGroupDTO[] | null { + const payload = extractResponseData(raw) + if (!isRecord(payload) || !Array.isArray(payload.draftGroups)) return null + + return payload.draftGroups.filter(isRecord) as unknown as DraftGroupDTO[] +} + +function buildDraftGroupSeriesGroups(draftGroups: DraftGroupDTO[]): DraftGroupSeriesGroup[] { + const groups = new Map() + + draftGroups.forEach((draftGroup) => { + const key = + draftGroup.assetSeriesId || + draftGroup.assetSeriesCode || + draftGroup.assetSeriesName || + draftGroup.name + const existing = groups.get(key) + const nextGroup = + existing ?? { + key, + code: draftGroup.assetSeriesCode || '', + name: draftGroup.assetSeriesName || draftGroup.name, + items: [], + } + nextGroup.items.push(draftGroup) + groups.set(key, nextGroup) + }) + + return Array.from(groups.values()).map((group) => ({ + ...group, + items: [...group.items].sort( + (left, right) => + new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(), + ), + })) +} + +function getSeriesDisplayName(draftGroup: DraftGroupDTO | null, formalSpu?: string | null) { + if (!draftGroup) return '未选择系列' + const seriesName = draftGroup.assetSeriesName?.trim() || draftGroup.name.trim() + const normalizedFormalSpu = formalSpu?.trim().toUpperCase() ?? '' + if (normalizedFormalSpu) return `${seriesName || '系列'}-SPU:${normalizedFormalSpu}` + + return [seriesName, draftGroup.assetSeriesCode] + .map((value) => value?.trim()) + .filter(Boolean) + .join(' ') || draftGroup.name +} + +function normalizeSeriesMatchKey(value: string | null | undefined) { + return (value ?? '').trim().replace(/\s+/g, ' ').toLowerCase() +} + +function readRecordString(record: Record, key: string) { + const value = record[key] + return typeof value === 'string' ? value.trim() : '' +} + +function normalizeSkuBatchFlowNos(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined + + const entries = Object.entries(value).flatMap(([key, flowNo]) => { + const normalizedKey = key.trim() + const normalizedFlowNo = typeof flowNo === 'string' ? flowNo.trim() : '' + return normalizedKey && normalizedFlowNo ? [[normalizedKey, normalizedFlowNo] as const] : [] + }) + + return entries.length > 0 ? Object.fromEntries(entries) : undefined +} + +function buildFormalSeriesProductsFromRecords( + products: NonIntegratedProductRecord[], +): FormalSeriesProductSummary[] { + return products.flatMap((product): FormalSeriesProductSummary[] => { + if (product.productDevelopmentKind && product.productDevelopmentKind !== 'series') return [] + + const spu = product.spu.trim().toUpperCase() + const name = + product.openingReference.productName.trim() || + product.spu.trim() || + '' + const pushedSourceSkuIds = Array.from(new Set( + product.skuItems + .map((skuItem) => skuItem.sourceSkuId?.trim() ?? '') + .filter(Boolean), + )) + + if (!spu || !name) return [] + return [ + { + id: product.id, + name, + spu, + pushedSourceSkuIds, + skuItems: product.skuItems, + skuBatchFlowNos: normalizeSkuBatchFlowNos(product.skuBatchFlowNos), + }, + ] + }) +} + +function readFormalSeriesProducts(): FormalSeriesProductSummary[] { + if (typeof window === 'undefined') return [] + + try { + const rawValue = window.localStorage.getItem(NON_INTEGRATED_STORAGE_KEY) + if (!rawValue) return [] + + const parsed = JSON.parse(rawValue) as unknown + if (!Array.isArray(parsed)) return [] + + return parsed.flatMap((item): FormalSeriesProductSummary[] => { + if (!isRecord(item)) return [] + const kind = readRecordString(item, 'productDevelopmentKind') + if (kind !== 'series') return [] + + const openingReference = isRecord(item.openingReference) ? item.openingReference : {} + const spu = readRecordString(item, 'spu').toUpperCase() + const name = + readRecordString(openingReference, 'productName') || + readRecordString(item, 'name') + const id = readRecordString(item, 'id') || spu + const skuItems = Array.isArray(item.skuItems) + ? (item.skuItems.filter(isRecord) as unknown as NonIntegratedSkuItem[]) + : [] + const pushedSourceSkuIds = Array.from(new Set( + skuItems + .map((skuItem) => skuItem.sourceSkuId?.trim() ?? '') + .filter(Boolean), + )) + const skuBatchFlowNos = normalizeSkuBatchFlowNos(item.skuBatchFlowNos) + + if (!spu || !name) return [] + return [{ id, name, spu, pushedSourceSkuIds, skuItems, skuBatchFlowNos }] + }) + } catch { + return [] + } +} + +function mergeFormalSeriesProducts( + left: FormalSeriesProductSummary[], + right: FormalSeriesProductSummary[], +) { + const productByKey = new Map() + + ;[...left, ...right].forEach((product) => { + const key = product.id || `${product.name}:${product.spu}` + if (!key) return + productByKey.set(key, product) + }) + + return Array.from(productByKey.values()) +} + +function findFormalSeriesProduct( + draftGroup: DraftGroupDTO | null, + products: FormalSeriesProductSummary[], +) { + if (!draftGroup) return null + + const candidates = [ + getSeriesDisplayName(draftGroup), + draftGroup.assetSeriesName, + draftGroup.name, + ] + .map(normalizeSeriesMatchKey) + .filter(Boolean) + + return ( + products.find((product) => + candidates.includes(normalizeSeriesMatchKey(product.name)), + ) ?? null + ) +} + +function buildFormalSkuCode(spu: string, index: number) { + const normalizedSpu = spu.trim().toUpperCase() + if (!normalizedSpu) return '' + return `${normalizedSpu}${String(index + 1).padStart(2, '0')}` +} + +function normalizeFormalSkuVersion(value: string | undefined) { + const normalized = value?.trim().toUpperCase() ?? '' + if (!normalized) return 'V01' + + const numericMatch = normalized.match(/^V?(\d+)$/) + if (!numericMatch?.[1]) return normalized + + const numericValue = Number.parseInt(numericMatch[1], 10) + if (!Number.isFinite(numericValue) || numericValue <= 0) return 'V01' + return `V${String(numericValue).padStart(2, '0')}` +} + +function getPackageSuffixByIndex(index: number) { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + return alphabet[index] ?? '' +} + +function getPackageKey(packageSuffix: string) { + return packageSuffix.trim().toUpperCase() || 'single' +} + +function getPackageLabel(packageKey: string) { + return packageKey === 'single' ? '单包' : `${packageKey}包` +} + +function normalizeComponentCode(value: string | null | undefined) { + return (value ?? '').trim().toUpperCase() +} + +function normalizePackageComponentCodes(codes: string[]) { + return Array.from(new Set(codes.map(normalizeComponentCode).filter(Boolean))) +} + +function readStoredPackageComponentAssignments() { + if (typeof window === 'undefined') return {} + + try { + const rawValue = window.localStorage.getItem(FORMAL_PACKAGE_COMPONENT_ASSIGNMENTS_STORAGE_KEY) + const parsed = rawValue ? JSON.parse(rawValue) : null + if (!isRecord(parsed)) return {} + + return Object.entries(parsed).reduce( + (assignments, [preSkuId, value]) => { + if (!isRecord(value)) return assignments + assignments[preSkuId] = Object.entries(value).reduce( + (packageAssignments, [packageKey, componentCodes]) => { + if (!Array.isArray(componentCodes)) return packageAssignments + const normalizedCodes = normalizePackageComponentCodes( + componentCodes.filter((code): code is string => typeof code === 'string'), + ) + packageAssignments[getPackageKey(packageKey)] = normalizedCodes + return packageAssignments + }, + {} as PackageComponentAssignments, + ) + return assignments + }, + {} as Record, + ) + } catch { + return {} + } +} + +function writeStoredPackageComponentAssignments( + assignments: Record, +) { + if (typeof window === 'undefined') return + window.localStorage.setItem( + FORMAL_PACKAGE_COMPONENT_ASSIGNMENTS_STORAGE_KEY, + JSON.stringify(assignments), + ) +} + +function getComponentDetailCode(component: NonIntegratedSkuComponentDetail) { + return normalizeComponentCode(component.code || component.name || component.role) +} + +function getComponentDetailsByCodes( + componentDetails: NonIntegratedSkuComponentDetail[], + componentCodes: string[], +) { + const selectedCodes = new Set(normalizePackageComponentCodes(componentCodes)) + if (selectedCodes.size === 0) return [] + + return componentDetails.filter((component) => + selectedCodes.has(getComponentDetailCode(component)), + ) +} + +function readComponentDetailCodes(componentDetails: NonIntegratedSkuComponentDetail[] | undefined) { + return normalizePackageComponentCodes((componentDetails ?? []).map(getComponentDetailCode)) +} + +function areComponentCodeSetsEqual(left: string[], right: string[]) { + const normalizedLeft = normalizePackageComponentCodes(left).sort() + const normalizedRight = normalizePackageComponentCodes(right).sort() + return ( + normalizedLeft.length === normalizedRight.length && + normalizedLeft.every((code, index) => code === normalizedRight[index]) + ) +} + +function normalizeSetCountValue(value: string | undefined) { + const numericValue = Number.parseInt(value?.trim() ?? '', 10) + if (!Number.isFinite(numericValue) || numericValue <= 1) return 1 + return Math.min(numericValue, 26) +} + +function buildFormalBarcode( + skuCode: string, + skuFields: SeriesDraftSkuFields, +) { + const normalizedSkuCode = skuCode.trim().toUpperCase() + if (!normalizedSkuCode) return '' + + const manufacturerCode = skuFields.manufacturerCode?.trim().toUpperCase() ?? '' + return `${normalizedSkuCode}${manufacturerCode}${normalizeFormalSkuVersion(skuFields.versionNo)}` +} + +function normalizeSkuContentValue(value: string | undefined) { + const normalized = value?.trim() ?? '' + return normalized === '待补齐' ? '' : normalized +} + +type PreSkuVariantSelectionItem = + | DraftGroupPreSkuDTO['variantSelection']['slots'][number] + | DraftGroupPreSkuDTO['variantSelection']['hardwareSelections'][number] + +function readTextValue(value: unknown) { + if (typeof value === 'string') return value.trim() + if (typeof value === 'number' && Number.isFinite(value)) return String(value) + return '' +} + +function readFirstRecordText( + record: Record | null | undefined, + keys: string[], +) { + if (!record) return '' + + for (const key of keys) { + const value = readTextValue(record[key]) + if (value) return value + } + + return '' +} + +function getPrimaryPreSkuSelection(preSku: DraftGroupPreSkuDTO): PreSkuVariantSelectionItem | null { + const slots = preSku.variantSelection.slots + const allSelections = [ + ...slots, + ...preSku.variantSelection.hardwareSelections, + ] + + return ( + slots.find((slot) => slot.assetType === 'BOARD' && slot.role === 'desktop') ?? + slots.find((slot) => slot.assetType === 'BOARD') ?? + slots.find((slot) => slot.assetType !== 'HARDWARE') ?? + allSelections[0] ?? + null + ) +} + +function readPrimarySelectionText( + preSku: DraftGroupPreSkuDTO, + directKeys: string[], + basisKeys = directKeys, +) { + const primarySelection = getPrimaryPreSkuSelection(preSku) + if (!primarySelection) return '' + + const directValue = readFirstRecordText( + primarySelection as unknown as Record, + directKeys, + ) + if (directValue) return directValue + + return readFirstRecordText(primarySelection.assemblyBasis, basisKeys) +} + +function resolveSupplierCodeByName(value: string | undefined) { + const normalized = value?.trim() ?? '' + if (!normalized) return '' + return SUPPLIER_CODE_BY_NAME.get(normalized)?.trim().toUpperCase() ?? '' +} + +function resolveSupplierNameByCode(value: string | undefined) { + const normalized = value?.trim().toUpperCase() ?? '' + if (!normalized) return '' + return SUPPLIER_NAME_BY_CODE.get(normalized) ?? '' +} + +function resolveSkuManufacturerCode( + values: SkuContentFormValues, + skuFields?: SeriesDraftSkuFields, +) { + return ( + normalizeSkuContentValue(values.manufacturerCode).toUpperCase() || + resolveSupplierCodeByName(values.manufacturer) || + normalizeSkuContentValue(skuFields?.manufacturerCode).toUpperCase() || + resolveSupplierCodeByName(skuFields?.manufacturer) + ) +} + +function buildAutoBarcodeFromSkuContent( + formalSkuCode: string, + values: SkuContentFormValues, + skuFields?: SeriesDraftSkuFields, +) { + const normalizedSkuCode = formalSkuCode.trim().toUpperCase() + if (!normalizedSkuCode) return '' + + const manufacturerCode = resolveSkuManufacturerCode(values, skuFields) + const versionNo = normalizeFormalSkuVersion(values.versionNo || skuFields?.versionNo) + return `${normalizedSkuCode}${manufacturerCode}${versionNo}` +} + +function buildAutoDrawingIdFromSkuContent( + values: SkuContentFormValues, + skuFields?: SeriesDraftSkuFields, +) { + const model = normalizeSkuContentValue(values.model || skuFields?.model).toUpperCase() + if (!model) return '' + + const attributeCode = normalizeSkuContentValue( + values.attributeCode || skuFields?.attributeCode, + ).toUpperCase() + const versionNo = normalizeFormalSkuVersion(values.versionNo || skuFields?.versionNo) + return `${model}${attributeCode}${versionNo}` +} + +function toChineseStyleLabel(model: string | undefined) { + const match = model + ?.trim() + .toUpperCase() + .match(/K(\d{2})$/) + if (!match?.[1]) return '' + + const styleIndex = Number.parseInt(match[1], 10) + if (!Number.isFinite(styleIndex) || styleIndex <= 0) return '' + if (styleIndex <= 10) { + const numerals = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'] + return `款式${numerals[styleIndex - 1]}` + } + if (styleIndex < 20) { + const numerals = ['一', '二', '三', '四', '五', '六', '七', '八', '九'] + return `款式十${numerals[styleIndex - 11]}` + } + if (styleIndex === 20) return '款式二十' + + return `款式${styleIndex}` +} + +function buildAutoSpecNameFromSkuContent( + values: SkuContentFormValues, + packageSuffix: string, +) { + const model = normalizeSkuContentValue(values.model).toUpperCase() + const brandPrefixMatch = model.match(/^(GB[A-Z]+-)/) + const brandPrefix = brandPrefixMatch?.[1] ?? '' + const styleLabel = toChineseStyleLabel(model) + const parts = [ + styleLabel, + normalizeSkuContentValue(values.size), + normalizeSkuContentValue(values.color), + ].filter(Boolean) + const baseSpecName = [brandPrefix, parts.join(' ')].filter(Boolean).join('') + if (!packageSuffix) return baseSpecName + + return [baseSpecName, `${packageSuffix}包`].filter(Boolean).join(' ') +} + +function inferSkuContentFieldValue( + preSku: DraftGroupPreSkuDTO, + fieldKey: SkuContentFieldKey, + formalSkuCode: string, + formValues: SkuContentFormValues, +) { + if (fieldKey === 'model3dFileName' && hasPreSkuModelSnapshot(preSku)) { + return `${formalSkuCode || preSku.id}-草稿组模型` + } + if (fieldKey === 'setCount') return '1' + if (fieldKey === 'versionNo') return 'V01' + if (fieldKey === 'batchNo') return '批次1' + if (fieldKey === 'specName') { + return '' + } + if (fieldKey === 'drawingId') { + const autoDrawingId = buildAutoDrawingIdFromSkuContent(formValues) + if (autoDrawingId) return autoDrawingId + + return readPrimarySelectionText( + preSku, + ['drawingId', 'drawingCode', 'drawingNo'], + ['drawingId', 'drawingCode', 'drawingNo'], + ).toUpperCase() + } + if (fieldKey === 'manufacturer') { + const supplierName = readPrimarySelectionText( + preSku, + ['manufacturer', 'manufacturerName', 'supplierName', 'supplier'], + ['manufacturer', 'manufacturerName', 'supplierName', 'supplier'], + ) + if (supplierName) return supplierName + + const supplierCode = normalizeSkuContentValue(formValues.manufacturerCode) + return resolveSupplierNameByCode(supplierCode) + } + if (fieldKey === 'manufacturerCode') { + const supplierCode = readPrimarySelectionText( + preSku, + ['manufacturerCode', 'supplierCode'], + ['manufacturerCode', 'supplierCode'], + ) + if (supplierCode) return supplierCode.toUpperCase() + + return resolveSupplierCodeByName(formValues.manufacturer) + } + if (fieldKey === 'attributeCode') { + return readPrimarySelectionText( + preSku, + ['attributeCode'], + ['attributeCode'], + ).toUpperCase() + } + + return '' +} + +function getDraftSkuDisplayName(draftGroup: DraftGroupDTO, index: number) { + const seriesName = draftGroup.assetSeriesName?.trim() || draftGroup.name.trim() || 'SKU' + return `${seriesName}${String(index + 1).padStart(2, '0')}` +} + +function getChronologicalDraftGroupsInSeries(draftGroups: DraftGroupDTO[], draftGroup: DraftGroupDTO | null) { + if (!draftGroup) return [] + return draftGroups + .filter((item) => { + const left = draftGroup.assetSeriesId || draftGroup.assetSeriesCode || draftGroup.assetSeriesName + const right = item.assetSeriesId || item.assetSeriesCode || item.assetSeriesName + return left === right + }) + .sort((left, right) => new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime()) +} + +function getDraftGroupIndexInSeries(draftGroups: DraftGroupDTO[], draftGroup: DraftGroupDTO | null) { + if (!draftGroup) return 0 + const sameSeries = getChronologicalDraftGroupsInSeries(draftGroups, draftGroup) + return Math.max(0, sameSeries.findIndex((item) => item.id === draftGroup.id)) +} + +function getInitialSelectedDraftGroupId( + draftGroups: DraftGroupDTO[], + initialDraftGroupId?: string | null, +) { + if (initialDraftGroupId && draftGroups.some((item) => item.id === initialDraftGroupId)) { + return initialDraftGroupId + } + + return buildDraftGroupSeriesGroups(draftGroups)[0]?.items[0]?.id ?? '' +} + +function StatusPill({ status }: { status: string }) { + return ( + + {getDraftGroupStatusLabel(status)} + + ) +} + +function MetricCard({ label, value }: { label: string; value: number }) { + return ( +
+

{label}

+

{value}

+
+ ) +} + +function MaterialPreview({ + preSku, + packageRows, + packageAssignments, + saving, + onComponentPackageChange, +}: { + preSku: DraftGroupPreSkuDTO + packageRows: SkuContentPreviewRow[] + packageAssignments: PackageComponentAssignments + saving: boolean + onComponentPackageChange: (componentCode: string, packageKey: string | null) => void +}) { + const sections = deriveDraftMaterialSections(preSku) + const allComponentDetails = readPreSkuComponentDetails(preSku) + const packageOptions = packageRows.map((row) => { + const key = getPackageKey(row.packageSuffix) + return { + key, + label: getPackageLabel(key), + } + }) + const resolvedPackageAssignments = buildResolvedPackageComponentAssignments( + packageRows, + packageAssignments, + allComponentDetails, + ) + + return ( +
+ {sections.map((section) => { + const Icon = section.key === 'BOARD' ? Boxes : section.key === 'HARDWARE' ? Wrench : Layers3 + + return ( +
+
+
+ +

{section.title}

+
+ + {section.items.length} + +
+ {section.items.length === 0 ? ( +

暂无快照

+ ) : ( +
+ {section.items.map((item) => { + const componentCode = normalizeComponentCode(item.code || item.name || item.role) + const activePackageKeys = packageOptions + .filter((option) => + (resolvedPackageAssignments[option.key] ?? []).includes(componentCode), + ) + .map((option) => option.key) + + return ( +
+
+
+

{item.name}

+

+ {item.code} · {item.role} +

+
+
+ + x{item.quantity} + + {packageOptions.length > 1 ? ( +
+ {packageOptions.map((option) => { + const active = activePackageKeys.includes(option.key) + return ( + + ) + })} +
+ ) : null} +
+
+

+ {item.dimensions} · {item.material} +

+
+ ) + })} +
+ )} +
+ ) + })} +
+ ) +} + +function BarcodePackagePreview({ + activeRow, + rows, + componentDetails, + packageAssignments, + saving, +}: { + activeRow: SkuContentPreviewRow | null + rows: SkuContentPreviewRow[] + componentDetails: NonIntegratedSkuComponentDetail[] + packageAssignments: PackageComponentAssignments + saving: boolean +}) { + if (!activeRow) return null + + const packageKey = getPackageKey(activeRow.packageSuffix) + const selectedCodes = getPackageRowComponentCodes( + activeRow, + rows, + packageAssignments, + componentDetails, + ) + const selectedComponents = getComponentDetailsByCodes(componentDetails, selectedCodes) + + return ( +
+
+
+

PACKAGE

+

+ {getPackageLabel(packageKey)} +

+

+ {activeRow.values.barcode || activeRow.values.skuCode || '未生成条码'} +

+
+ + {selectedComponents.length} 项 + +
+ + {selectedComponents.length > 0 ? ( +
+
+

当前包内组件

+ + {saving ? '保存中' : `${selectedComponents.length} 项`} + +
+
+ {selectedComponents.map((component) => ( +
+ + {component.name || component.role || component.code} + + + x{component.quantity || '1'} + +
+ ))} +
+
+ ) : ( +
+ 当前包还没有分配部件,请在下方 BOM 里选择 {getPackageLabel(packageKey)}。 +
+ )} +
+ ) +} + +function hasPreSkuModelSnapshot(preSku: DraftGroupPreSkuDTO) { + return Boolean( + preSku.assemblyWorkspace?.nodes.length || + preSku.previewNodes.length, + ) +} + +function readSkuContentFieldValue( + skuFields: SeriesDraftSkuFields, + field: SkuContentFieldDefinition, + preSku: DraftGroupPreSkuDTO, + formalSkuCode: string, + formalBarcode: string, + formValues: SkuContentFormValues, +) { + if (Object.prototype.hasOwnProperty.call(formValues, field.key)) { + return formValues[field.key] ?? '' + } + + if (field.key === 'skuCode' && formalSkuCode) return formalSkuCode + if (field.key === 'barcode') { + return buildAutoBarcodeFromSkuContent(formalSkuCode, formValues, skuFields) || formalBarcode + } + + const value = skuFields[field.key] + if (typeof value === 'string' && value.trim()) return value.trim() + + if (field.key === 'manufacturerCode') { + const supplierCode = resolveSupplierCodeByName(formValues.manufacturer) + if (supplierCode) return supplierCode + } + if (field.key === 'manufacturer') { + const supplierName = resolveSupplierNameByCode(formValues.manufacturerCode) + if (supplierName) return supplierName + } + if (field.key === 'drawingId') { + const autoDrawingId = buildAutoDrawingIdFromSkuContent(formValues, skuFields) + if (autoDrawingId) return autoDrawingId + } + if (MANUAL_ONLY_SKU_CONTENT_FIELDS.has(field.key)) return '' + + return inferSkuContentFieldValue( + preSku, + field.key, + formalSkuCode, + formValues, + ) +} + +function buildInitialSkuContentFormValues( + preSku: DraftGroupPreSkuDTO, + formalSkuCode: string, + formalBarcode: string, +) { + const skuFields = readPreSkuSkuFields(preSku) + return SKU_CONTENT_FORM_FIELD_DEFINITIONS.reduce( + (values, field) => { + const value = readSkuContentFieldValue( + skuFields, + field, + preSku, + formalSkuCode, + formalBarcode, + values, + ) + if (!value) return values + + return { + ...values, + [field.key]: value, + } + }, + {} as SkuContentFormValues, + ) +} + +function buildResolvedSkuContentFormValues( + preSku: DraftGroupPreSkuDTO, + formalSkuCode: string, + formalBarcode: string, + formValues: SkuContentFormValues, +) { + const skuFields = readPreSkuSkuFields(preSku) + + return SKU_CONTENT_FORM_FIELD_DEFINITIONS.reduce( + (values, field) => ({ + ...values, + [field.key]: readSkuContentFieldValue( + skuFields, + field, + preSku, + formalSkuCode, + formalBarcode, + { + ...formValues, + ...values, + }, + ), + }), + {} as SkuContentFormValues, + ) +} + +function buildSkuContentFormValuesFromProductSkuItem( + item: NonIntegratedSkuItem, + skuBatchFlowNos?: Record, +): SkuContentFormValues { + const batchNo = Number.isFinite(item.batchNo) && item.batchNo > 0 + ? Math.floor(item.batchNo) + : 1 + + return { + imageName: item.imageName, + model: item.model, + skuCode: item.skuCode, + barcode: item.barcode, + setCount: item.setCount, + specName: item.specName, + size: item.size, + color: item.color, + material: item.material, + boardMaterialType: item.boardMaterialType ?? '', + versionNo: item.versionNo, + attributeCode: item.attributeCode, + drawingId: item.drawingId, + installMethod: item.installMethod, + executeStandard: item.executeStandard, + nationalStandardCompliant: item.nationalStandardCompliant, + manufacturer: item.manufacturer, + manufacturerCode: item.manufacturerCode, + salesStatus: item.salesStatus, + batchNo: `批次${batchNo}`, + dingTalkFlowNo: skuBatchFlowNos?.[String(batchNo)] ?? '', + tagMaterial: item.tagMaterial, + model3dFileName: item.model3dFileName, + } +} + +function getFormalProductSkuItemsForPreSku( + product: FormalSeriesProductSummary | null | undefined, + preSkuId: string, + formalSkuCode: string, +) { + if (!product) return [] + + const normalizedFormalSkuCode = formalSkuCode.trim().toUpperCase() + return product.skuItems + .filter((item) => { + if (item.sourceSkuId === preSkuId) return true + const skuCode = item.skuCode.trim().toUpperCase() + return Boolean( + normalizedFormalSkuCode && + (skuCode === normalizedFormalSkuCode || skuCode.startsWith(normalizedFormalSkuCode)), + ) + }) + .sort((left, right) => + left.skuSequence - right.skuSequence || + left.packageSuffix.localeCompare(right.packageSuffix) || + left.skuCode.localeCompare(right.skuCode), + ) +} + +function isPreSkuPushedToFormalProduct( + product: FormalSeriesProductSummary | null | undefined, + preSkuId: string | null | undefined, +) { + const normalizedPreSkuId = preSkuId?.trim() ?? '' + if (!product || !normalizedPreSkuId) return false + + return product.skuItems.some((item) => item.sourceSkuId?.trim() === normalizedPreSkuId) +} + +function buildSkuContentPreviewRowsFromProductItems( + product: FormalSeriesProductSummary | null | undefined, + skuItems: NonIntegratedSkuItem[], +): SkuContentPreviewRow[] { + const setCount = Math.max( + skuItems.length, + normalizeSetCountValue(skuItems[0]?.setCount), + ) + + return skuItems.map((item, index) => ({ + key: item.id || item.packageSuffix || String(index), + index, + packageSuffix: item.packageSuffix, + componentDetails: item.componentDetails ?? [], + values: { + ...buildSkuContentFormValuesFromProductSkuItem(item, product?.skuBatchFlowNos), + setCount: String(setCount || 1), + }, + })) +} + +function buildBaseSkuContentFormValuesFromProductItems( + product: FormalSeriesProductSummary | null | undefined, + skuItems: NonIntegratedSkuItem[], +) { + if (skuItems.length === 0) return {} + + const firstItem = skuItems[0] + return { + ...buildSkuContentFormValuesFromProductSkuItem(firstItem, product?.skuBatchFlowNos), + setCount: String(Math.max(skuItems.length, normalizeSetCountValue(firstItem.setCount))), + } +} + +function buildSkuContentAutoSaveFields( + values: SkuContentFormValues, +): Partial> { + return SKU_CONTENT_AUTO_SAVE_FIELD_KEYS.reduce( + (fields, key) => { + if (!Object.prototype.hasOwnProperty.call(values, key)) return fields + fields[key] = values[key] ?? null + return fields + }, + {} as Partial>, + ) +} + +function parseSkuBatchNo(value: string | undefined) { + const match = value?.match(/\d+/) + if (!match?.[0]) return 1 + const parsed = Number.parseInt(match[0], 10) + return Number.isFinite(parsed) && parsed > 0 ? parsed : 1 +} + +interface SkuContentPreviewRow { + key: string + index: number + packageSuffix: string + componentDetails?: NonIntegratedSkuComponentDetail[] + values: SkuContentFormValues +} + +function buildPackageSpecName(baseSpecName: string, packageSuffix: string) { + const normalizedSpecName = baseSpecName.trim() + if (!packageSuffix) return normalizedSpecName + return [normalizedSpecName, `${packageSuffix}包`].filter(Boolean).join(' ') +} + +function buildPackageSkuValues( + baseValues: SkuContentFormValues, + formalSkuCode: string, + packageSuffix: string, + skuFields?: SeriesDraftSkuFields, +): SkuContentFormValues { + const skuCode = formalSkuCode + ? `${formalSkuCode.trim().toUpperCase()}${packageSuffix}` + : normalizeSkuContentValue(baseValues.skuCode).toUpperCase() + const specName = + buildAutoSpecNameFromSkuContent(baseValues, packageSuffix) || + buildPackageSpecName(baseValues.specName ?? '', packageSuffix) + const barcode = skuCode + ? buildAutoBarcodeFromSkuContent(skuCode, baseValues, skuFields) + : normalizeSkuContentValue(baseValues.barcode).toUpperCase() + + return { + ...baseValues, + skuCode, + specName, + barcode, + setCount: String(normalizeSetCountValue(baseValues.setCount)), + } +} + +function buildSkuContentPreviewRows( + baseValues: SkuContentFormValues, + formalSkuCode: string, + skuFields?: SeriesDraftSkuFields, +): SkuContentPreviewRow[] { + const setCount = normalizeSetCountValue(baseValues.setCount) + if (setCount <= 1) { + return [ + { + key: 'single', + index: 0, + packageSuffix: '', + componentDetails: [], + values: buildPackageSkuValues(baseValues, formalSkuCode, '', skuFields), + }, + ] + } + + return Array.from({ length: setCount }, (_, index) => { + const packageSuffix = getPackageSuffixByIndex(index) + return { + key: packageSuffix, + index, + packageSuffix, + componentDetails: [], + values: buildPackageSkuValues(baseValues, formalSkuCode, packageSuffix, skuFields), + } + }) +} + +function shouldIgnoreFullDefaultPackageComponents( + row: SkuContentPreviewRow, + rows: SkuContentPreviewRow[], + allComponentDetails: NonIntegratedSkuComponentDetail[], +) { + if (rows.length <= 1 || allComponentDetails.length === 0) return false + + const allComponentCodes = readComponentDetailCodes(allComponentDetails) + const rowComponentCodes = readComponentDetailCodes(row.componentDetails) + if (!areComponentCodeSetsEqual(rowComponentCodes, allComponentCodes)) return false + + return rows.every((candidateRow) => + areComponentCodeSetsEqual( + readComponentDetailCodes(candidateRow.componentDetails), + allComponentCodes, + ), + ) +} + +function getPackageRowComponentCodes( + row: SkuContentPreviewRow, + rows: SkuContentPreviewRow[], + assignments: PackageComponentAssignments, + allComponentDetails: NonIntegratedSkuComponentDetail[], +) { + const packageKey = getPackageKey(row.packageSuffix) + if (Object.prototype.hasOwnProperty.call(assignments, packageKey)) { + return normalizePackageComponentCodes(assignments[packageKey] ?? []) + } + + if (shouldIgnoreFullDefaultPackageComponents(row, rows, allComponentDetails)) { + return [] + } + + return readComponentDetailCodes(row.componentDetails) +} + +function buildResolvedPackageComponentAssignments( + rows: SkuContentPreviewRow[], + assignments: PackageComponentAssignments, + allComponentDetails: NonIntegratedSkuComponentDetail[], +) { + return rows.reduce((resolved, row) => { + const packageKey = getPackageKey(row.packageSuffix) + resolved[packageKey] = getPackageRowComponentCodes( + row, + rows, + assignments, + allComponentDetails, + ) + return resolved + }, {} as PackageComponentAssignments) +} + +function assignComponentToSinglePackage( + assignments: PackageComponentAssignments, + packageKeys: string[], + componentCode: string, + nextPackageKey: string | null, +) { + const normalizedComponentCode = normalizeComponentCode(componentCode) + const normalizedNextPackageKey = nextPackageKey ? getPackageKey(nextPackageKey) : null + + return packageKeys.reduce((nextAssignments, packageKey) => { + const normalizedPackageKey = getPackageKey(packageKey) + const withoutComponent = normalizePackageComponentCodes( + assignments[normalizedPackageKey] ?? [], + ).filter((code) => code !== normalizedComponentCode) + + nextAssignments[normalizedPackageKey] = + normalizedNextPackageKey === normalizedPackageKey + ? normalizePackageComponentCodes([...withoutComponent, normalizedComponentCode]) + : withoutComponent + + return nextAssignments + }, {} as PackageComponentAssignments) +} + +function buildPushedSkuItem({ + preSku, + sequence, + values, + existingItem, + componentDetails, + packageGroupId = '', + packageSuffix = '', +}: { + preSku: DraftGroupPreSkuDTO + sequence: number + values: SkuContentFormValues + existingItem?: NonIntegratedSkuItem + componentDetails: NonIntegratedSkuComponentDetail[] + packageGroupId?: string + packageSuffix?: string +}): NonIntegratedSkuItem { + const skuFields = readPreSkuSkuFields(preSku) + const skuCode = normalizeSkuContentValue(values.skuCode).toUpperCase() + const versionNo = normalizeFormalSkuVersion(values.versionNo) + const manufacturerCode = normalizeSkuContentValue(values.manufacturerCode).toUpperCase() + const barcode = + normalizeSkuContentValue(values.barcode).toUpperCase() || + (skuCode ? `${skuCode}${manufacturerCode}${versionNo}` : '') + const model3dPreviewNodes = preSku.assemblyWorkspace?.nodes.length + ? preSku.assemblyWorkspace.nodes + : preSku.previewNodes + + return { + id: existingItem?.id || `series-${preSku.id}`, + skuSequence: sequence, + batchNo: parseSkuBatchNo(values.batchNo), + packageGroupId, + packageSuffix, + image: skuFields.image?.trim() || existingItem?.image || '', + imageName: normalizeSkuContentValue(values.imageName), + model: normalizeSkuContentValue(values.model), + skuCode, + setCount: normalizeSkuContentValue(values.setCount) || '1', + specName: normalizeSkuContentValue(values.specName), + barcode, + size: normalizeSkuContentValue(values.size), + color: normalizeSkuContentValue(values.color), + material: normalizeSkuContentValue(values.material), + boardMaterialType: normalizeSkuContentValue(values.boardMaterialType), + versionNo, + attributeCode: normalizeSkuContentValue(values.attributeCode).toUpperCase(), + drawingId: normalizeSkuContentValue(values.drawingId).toUpperCase(), + installMethod: normalizeSkuContentValue(values.installMethod), + executeStandard: normalizeSkuContentValue(values.executeStandard), + nationalStandardCompliant: normalizeSkuContentValue(values.nationalStandardCompliant), + manufacturer: normalizeSkuContentValue(values.manufacturer), + manufacturerCode, + salesStatus: normalizeSkuContentValue(values.salesStatus), + tagMaterial: normalizeSkuContentValue(values.tagMaterial), + model3dFileName: normalizeSkuContentValue(values.model3dFileName), + model3dDataUrl: existingItem?.model3dDataUrl ?? '', + model3dNodeOffsets: existingItem?.model3dNodeOffsets ?? {}, + model3dNodeRotations: existingItem?.model3dNodeRotations ?? {}, + model3dNodeMaterialIds: existingItem?.model3dNodeMaterialIds ?? {}, + model3dUvConfig: existingItem?.model3dUvConfig ?? null, + model3dPreviewNodes: model3dPreviewNodes.length > 0 + ? model3dPreviewNodes + : existingItem?.model3dPreviewNodes ?? [], + componentDetails, + holePoints: existingItem?.holePoints ?? [], + source: 'draft_group', + sourceTable: 'draft_group_pre_sku', + sourceSkuId: preSku.id, + } +} + +function buildPushedSkuItems({ + preSku, + sequence, + values, + existingItems, + formalSkuCode, + packageAssignments, +}: { + preSku: DraftGroupPreSkuDTO + sequence: number + values: SkuContentFormValues + existingItems: NonIntegratedSkuItem[] + formalSkuCode: string + packageAssignments: PackageComponentAssignments +}): NonIntegratedSkuItem[] { + const skuFields = readPreSkuSkuFields(preSku) + const setCount = normalizeSetCountValue(values.setCount) + const allComponentDetails = readPreSkuComponentDetails(preSku) + const existingGroupId = + existingItems.find((item) => item.packageGroupId.trim())?.packageGroupId.trim() ?? '' + const packageGroupId = setCount > 1 ? existingGroupId || `series-${preSku.id}` : '' + const packageRows = buildSkuContentPreviewRows(values, formalSkuCode, skuFields) + + return packageRows.map((row) => { + const existingItem = + existingItems.find((item) => item.packageSuffix === row.packageSuffix) ?? + (row.index === 0 + ? existingItems.find((item) => !item.packageSuffix) + : undefined) + const fallbackId = + setCount > 1 && row.packageSuffix + ? `series-${preSku.id}-${row.packageSuffix}` + : `series-${preSku.id}` + const packageKey = getPackageKey(row.packageSuffix) + const assignedComponentCodes = packageAssignments[packageKey] ?? [] + const componentDetails = + setCount > 1 + ? getComponentDetailsByCodes(allComponentDetails, assignedComponentCodes) + : assignedComponentCodes.length > 0 + ? getComponentDetailsByCodes(allComponentDetails, assignedComponentCodes) + : allComponentDetails + + return buildPushedSkuItem({ + preSku, + sequence, + values: row.values, + existingItem: existingItem ?? ({ id: fallbackId } as NonIntegratedSkuItem), + componentDetails, + packageGroupId, + packageSuffix: setCount > 1 ? row.packageSuffix : '', + }) + }) +} + +function updateFormalProductSkuItems( + product: NonIntegratedProductRecord, + nextSkuItems: NonIntegratedSkuItem[], + baseFormalSkuCode: string, + dingTalkFlowNo: string, +): NonIntegratedProductRecord { + const sourceSkuId = nextSkuItems[0]?.sourceSkuId?.trim() ?? '' + const replacementSkuCodes = new Set( + [ + baseFormalSkuCode.trim().toUpperCase(), + ...nextSkuItems.map((item) => item.skuCode.trim().toUpperCase()), + ].filter(Boolean), + ) + + const skuItems = [ + ...product.skuItems.filter((item) => { + if (sourceSkuId && item.sourceSkuId === sourceSkuId) return false + return !replacementSkuCodes.has(item.skuCode.trim().toUpperCase()) + }), + ...nextSkuItems, + ].sort((left, right) => + left.skuSequence - right.skuSequence || + left.packageSuffix.localeCompare(right.packageSuffix), + ) + + const batchNo = nextSkuItems[0]?.batchNo || 1 + const batchKey = String(batchNo) + return { + ...product, + skuItems, + skuBatchCount: Math.max(product.skuBatchCount ?? 1, batchNo), + skuBatchFlowNos: { + ...(product.skuBatchFlowNos ?? {}), + ...(dingTalkFlowNo ? { [batchKey]: dingTalkFlowNo } : {}), + }, + } +} + +function SkuContentPreview({ + preSku, + draftGroup, + skuName, + pushed, + formValues, + productRows, + saveState, + activePackageKey, + packageAssignments, + packageComponentSaving, + formalSeriesSpu, + formalSkuCode, + pushDisabledReason, + pushing, + deleting, + actionMessage, + actionError, + onFieldChange, + onTogglePackage, + onPush, + onDeleteDraftGroup, +}: { + preSku: DraftGroupPreSkuDTO + draftGroup: DraftGroupDTO + skuName: string + pushed: boolean + formValues: SkuContentFormValues + productRows: SkuContentPreviewRow[] + saveState: SkuContentAutoSaveState | null + activePackageKey: string | null + packageAssignments: PackageComponentAssignments + packageComponentSaving: boolean + formalSeriesSpu: string + formalSkuCode: string + pushDisabledReason: string + pushing: boolean + deleting: boolean + actionMessage: string | null + actionError: string | null + onFieldChange: (key: SkuContentFieldKey, value: string) => void + onTogglePackage: (packageKey: string) => void + onPush: () => void + onDeleteDraftGroup: () => void +}) { + const skuFields = readPreSkuSkuFields(preSku) + const allComponentDetails = readPreSkuComponentDetails(preSku) + const previewRows = productRows.length > 0 + ? productRows + : buildSkuContentPreviewRows(formValues, formalSkuCode, skuFields) + const activePackageRow = previewRows.find((row) => getPackageKey(row.packageSuffix) === activePackageKey) ?? null + const [skuContentColumnWidths, setSkuContentColumnWidths] = useState(createDefaultSkuContentColumnWidths) + const skuContentTableMinWidth = getSkuContentTableMinWidth(skuContentColumnWidths) + const saveStateLabel = + pushed + ? '已同步产品资料' + : saveState?.status === 'pending' + ? '等待自动保存' + : saveState?.status === 'saving' + ? '自动保存中' + : saveState?.status === 'saved' + ? '已自动保存' + : saveState?.status === 'error' + ? saveState.message || '自动保存失败' + : '' + + return ( +
+
+
+

SKU CONTENT

+

{skuName}

+

+ {getSeriesDisplayName(draftGroup, formalSeriesSpu)} · {draftGroup.code} · 第 {preSku.sequence} 个 SKU +

+

+ {formalSkuCode + ? pushed + ? `已推送到系列 SPU ${formalSeriesSpu} · ${formalSkuCode}` + : `系列 SPU 已生成:${formalSeriesSpu} · 预计 SKU ${formalSkuCode},待推送` + : pushed + ? '已推送,等待系列产品开发明细生成正式 SPU' + : '未推送,等待系列产品开发明细生成正式 SPU'} +

+
+ + {pushed ? '已推送' : '待推送'} + +
+ +
+
+

SKU内容

+
+ {saveStateLabel ? ( + + {saveStateLabel} + + ) : null} + + {previewRows.length} 行 + +
+
+
+ + + + + + {SKU_CONTENT_TABLE_FIELD_DEFINITIONS.map((field) => { + const columnWidth = getSkuContentFieldWidth(skuContentColumnWidths, field) + + return ( + + ) + })} + + + + {previewRows.map((row) => ( + + + + {SKU_CONTENT_TABLE_FIELD_DEFINITIONS.map((field) => { + const value = row.values[field.key] ?? '' + const isBarcode = field.key === 'barcode' + const packageKey = getPackageKey(row.packageSuffix) + const isReadonly = + field.control === 'readonly' || + pushed || + pushing || + (row.packageSuffix && field.key === 'barcode') + const selectOptions = + field.control === 'select' + ? Array.from(new Set([ + ...(value && !(field.options ?? []).includes(value) + ? [value] + : []), + ...(field.options ?? []), + ])) + : [] + const columnWidth = getSkuContentFieldWidth(skuContentColumnWidths, field) + const columnMinWidth = getSkuContentFieldMinWidth(field) + + return ( + + ) + })} + + ))} + +
+ 序号 + + 图片 + + {field.label} +
+ {row.index + 1} + +
+ {skuFields.image ? ( + + ) : ( + '暂无图片' + )} +
+
+ + setSkuContentColumnWidths((current) => ({ + ...current, + [field.key]: normalizeSkuContentColumnWidth(field, nextWidth), + })) + } + > +
+ {field.control === 'select' ? ( + + ) : field.control === 'combobox' ? ( + onFieldChange(field.key, nextValue)} + /> + ) : ( + onFieldChange(field.key, event.target.value)} + className={cn( + 'h-8 min-w-0 flex-1 rounded-[5px] border border-[#dbe3e5] bg-white px-2 text-[12px] font-black text-[#2d3335] outline-none placeholder:text-[#8a9498]', + isReadonly && 'bg-[#f7f9f9] text-[#5a6062]/70', + )} + /> + )} + {isBarcode ? ( + + ) : null} +
+
+
+
+ +
+ + {actionError ? ( +
+ {actionError} +
+ ) : actionMessage ? ( +
+ {actionMessage} +
+ ) : pushDisabledReason ? ( +
+ {pushDisabledReason} +
+ ) : null} + +
+ +
+ + 字段随推送写入系列明细 + + +
+
+
+ ) +} + +export default function DraftGroupsFormalClient({ + draftGroups, + initialDraftGroupId, + initialFormalSeriesProducts = [], + pageTitle = '草稿组', + showBackLink = true, +}: DraftGroupsFormalClientProps) { + const [draftGroupsState, setDraftGroupsState] = useState(draftGroups) + const [selectedDraftGroupId, setSelectedDraftGroupId] = useState( + () => getInitialSelectedDraftGroupId(draftGroups, initialDraftGroupId), + ) + const [detailById, setDetailById] = useState>({}) + const [selectedPreSkuId, setSelectedPreSkuId] = useState('') + const [loadingDetailId, setLoadingDetailId] = useState(null) + const [detailError, setDetailError] = useState(null) + const [showHoleHighlights, setShowHoleHighlights] = useState(true) + const [activePackageKey, setActivePackageKey] = useState(null) + const [skuContentFormValuesByPreSkuId, setSkuContentFormValuesByPreSkuId] = useState>({}) + const [skuContentSaveStateByPreSkuId, setSkuContentSaveStateByPreSkuId] = useState>({}) + const [packageComponentAssignmentsByPreSkuId, setPackageComponentAssignmentsByPreSkuId] = useState< + Record + >(() => readStoredPackageComponentAssignments()) + const [savingPackageComponentKey, setSavingPackageComponentKey] = useState(null) + const [pushingPreSkuId, setPushingPreSkuId] = useState(null) + const [deletingDraftGroupId, setDeletingDraftGroupId] = useState(null) + const [leftPaneWidth, setLeftPaneWidth] = useState(() => + readStoredPaneWidth( + FORMAL_LEFT_PANE_WIDTH_STORAGE_KEY, + DEFAULT_LEFT_PANE_WIDTH, + MIN_LEFT_PANE_WIDTH, + MAX_LEFT_PANE_WIDTH, + ), + ) + const [rightPaneWidth, setRightPaneWidth] = useState(() => + readStoredPaneWidth( + FORMAL_RIGHT_PANE_WIDTH_STORAGE_KEY, + DEFAULT_RIGHT_PANE_WIDTH, + MIN_RIGHT_PANE_WIDTH, + MAX_RIGHT_PANE_WIDTH, + ), + ) + const [actionMessage, setActionMessage] = useState(null) + const [actionError, setActionError] = useState(null) + const [formalSeriesProducts, setFormalSeriesProducts] = useState( + () => initialFormalSeriesProducts, + ) + const skuContentAutoSaveTimersRef = useRef>({}) + const previewShellRef = useRef(null) + + const seriesGroups = useMemo(() => buildDraftGroupSeriesGroups(draftGroupsState), [draftGroupsState]) + const selectedDraftGroup = + draftGroupsState.find((draftGroup) => draftGroup.id === selectedDraftGroupId) ?? + draftGroupsState[0] ?? + null + const selectedDetail = selectedDraftGroup ? detailById[selectedDraftGroup.id] ?? null : null + const selectedPreSku = + selectedDetail?.preSkus.find((preSku) => preSku.id === selectedPreSkuId) ?? + selectedDetail?.preSkus[0] ?? + null + const selectedDraftGroupSeriesIndex = getDraftGroupIndexInSeries(draftGroupsState, selectedDraftGroup) + const selectedDraftSkuName = selectedDraftGroup + ? getDraftSkuDisplayName(selectedDraftGroup, selectedDraftGroupSeriesIndex) + : '未选择 SKU' + const selectedFormalSeriesProduct = findFormalSeriesProduct( + selectedDraftGroup, + formalSeriesProducts, + ) + const selectedFormalSkuCode = + selectedFormalSeriesProduct + ? buildFormalSkuCode(selectedFormalSeriesProduct.spu, selectedDraftGroupSeriesIndex) + : '' + const selectedProductSkuItems = selectedPreSku + ? getFormalProductSkuItemsForPreSku( + selectedFormalSeriesProduct, + selectedPreSku.id, + selectedFormalSkuCode, + ) + : [] + const selectedSkuPushed = Boolean( + selectedPreSku && + isPreSkuPushedToFormalProduct(selectedFormalSeriesProduct, selectedPreSku.id), + ) + const selectedFormalBarcode = + selectedFormalSkuCode && selectedPreSku + ? buildFormalBarcode(selectedFormalSkuCode, readPreSkuSkuFields(selectedPreSku)) + : '' + const computedSkuContentFormValues: SkuContentFormValues = selectedPreSku + ? buildInitialSkuContentFormValues( + selectedPreSku, + selectedFormalSkuCode, + selectedFormalBarcode, + ) + : {} + const selectedSkuContentFormValues: SkuContentFormValues = selectedPreSku + ? selectedSkuPushed && selectedProductSkuItems.length > 0 + ? buildBaseSkuContentFormValuesFromProductItems( + selectedFormalSeriesProduct, + selectedProductSkuItems, + ) + : { + ...computedSkuContentFormValues, + ...(skuContentFormValuesByPreSkuId[selectedPreSku.id] ?? {}), + } + : {} + const selectedProductRows = + selectedSkuPushed && selectedProductSkuItems.length > 0 + ? buildSkuContentPreviewRowsFromProductItems( + selectedFormalSeriesProduct, + selectedProductSkuItems, + ) + : [] + const selectedPackageRows = selectedPreSku + ? selectedProductRows.length > 0 + ? selectedProductRows + : buildSkuContentPreviewRows( + selectedSkuContentFormValues, + selectedFormalSkuCode, + readPreSkuSkuFields(selectedPreSku), + ) + : [] + const selectedPackageComponentAssignments = selectedPreSku + ? packageComponentAssignmentsByPreSkuId[selectedPreSku.id] ?? {} + : {} + const selectedSkuContentSaveState = selectedPreSku + ? skuContentSaveStateByPreSkuId[selectedPreSku.id] ?? null + : null + const selectedSkuName = selectedFormalSkuCode || selectedDraftSkuName + const selectedSeriesProductBasicInfoHref = + buildSeriesProductBasicInfoHref(selectedFormalSeriesProduct) + const selectedSeriesQueue = + selectedDraftGroup + ? getChronologicalDraftGroupsInSeries(draftGroupsState, selectedDraftGroup) + : [] + const firstUnpushedPreviousDraftGroup = selectedSeriesQueue + .slice(0, selectedDraftGroupSeriesIndex) + .find((draftGroup) => { + const preSkuId = detailById[draftGroup.id]?.preSkus[0]?.id + if (!preSkuId) return true + return !isPreSkuPushedToFormalProduct(selectedFormalSeriesProduct, preSkuId) + }) + const pushDisabledReason = + selectedSkuPushed || !selectedPreSku + ? '' + : !selectedFormalSeriesProduct + ? '请先在系列产品开发明细创建该系列并生成正式 SPU' + : firstUnpushedPreviousDraftGroup + ? `请先按顺序推送 ${getDraftSkuDisplayName( + firstUnpushedPreviousDraftGroup, + selectedSeriesQueue.findIndex((item) => item.id === firstUnpushedPreviousDraftGroup.id), + )}` + : '' + const rawPreviewNodes = + selectedPreSku?.assemblyWorkspace?.nodes.length + ? selectedPreSku.assemblyWorkspace.nodes + : selectedPreSku?.previewNodes ?? [] + const previewWorldNodes = readPreviewWorldNodes(rawPreviewNodes) + const previewMaterialAssignments = readPreviewMaterialAssignments(rawPreviewNodes) + const previewHoleOverlayState = readPreviewHoleOverlayState(rawPreviewNodes) + + function handlePaneResizePointerDown( + pane: 'left' | 'right', + event: ReactPointerEvent, + ) { + event.preventDefault() + + const startX = event.clientX + const startLeftWidth = leftPaneWidth + const startRightWidth = rightPaneWidth + const shellWidth = + previewShellRef.current?.getBoundingClientRect().width ?? + window.innerWidth + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + + const handlePointerMove = (moveEvent: PointerEvent) => { + const deltaX = moveEvent.clientX - startX + + if (pane === 'left') { + const maxLeftWidth = Math.min( + MAX_LEFT_PANE_WIDTH, + shellWidth - startRightWidth - MIN_CENTER_PANE_WIDTH, + ) + setLeftPaneWidth( + clampNumber(startLeftWidth + deltaX, MIN_LEFT_PANE_WIDTH, maxLeftWidth), + ) + return + } + + const maxRightWidth = Math.min( + MAX_RIGHT_PANE_WIDTH, + shellWidth - startLeftWidth - MIN_CENTER_PANE_WIDTH, + ) + setRightPaneWidth( + clampNumber(startRightWidth - deltaX, MIN_RIGHT_PANE_WIDTH, maxRightWidth), + ) + } + + const handlePointerUp = () => { + document.body.style.cursor = '' + document.body.style.userSelect = '' + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', handlePointerUp) + } + + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', handlePointerUp) + } + + function replacePreSkuInDetail(savedPreSku: DraftGroupPreSkuDTO) { + setDetailById((current) => { + const targetDetail = current[savedPreSku.draftGroupId] + if (!targetDetail) return current + + return { + ...current, + [savedPreSku.draftGroupId]: { + ...targetDetail, + preSkus: targetDetail.preSkus.map((preSku) => + preSku.id === savedPreSku.id ? savedPreSku : preSku, + ), + }, + } + }) + } + + function scheduleSkuContentAutoSave( + preSkuId: string, + values: SkuContentFormValues, + ) { + const fields = buildSkuContentAutoSaveFields(values) + if (Object.keys(fields).length === 0) return + + const existingTimer = skuContentAutoSaveTimersRef.current[preSkuId] + if (existingTimer) { + window.clearTimeout(existingTimer) + } + + setSkuContentSaveStateByPreSkuId((current) => ({ + ...current, + [preSkuId]: { status: 'pending' }, + })) + + skuContentAutoSaveTimersRef.current[preSkuId] = window.setTimeout(() => { + delete skuContentAutoSaveTimersRef.current[preSkuId] + setSkuContentSaveStateByPreSkuId((current) => ({ + ...current, + [preSkuId]: { status: 'saving' }, + })) + + void (async () => { + try { + const saved = await updatePreSkuBasicInfo(preSkuId, { fields }) + replacePreSkuInDetail(saved.preSku) + setSkuContentSaveStateByPreSkuId((current) => ({ + ...current, + [preSkuId]: { status: 'saved' }, + })) + } catch (error) { + setSkuContentSaveStateByPreSkuId((current) => ({ + ...current, + [preSkuId]: { + status: 'error', + message: error instanceof Error ? error.message : '自动保存失败', + }, + })) + } + })() + }, 520) + } + + function handleSkuFieldChange(key: SkuContentFieldKey, value: string) { + if (!selectedPreSku || selectedSkuPushed) return + setActionMessage(null) + setActionError(null) + setSkuContentFormValuesByPreSkuId((current) => { + const currentValues = current[selectedPreSku.id] ?? {} + const nextValues: SkuContentFormValues = { + ...currentValues, + [key]: value, + } + + if (key === 'manufacturer') { + const supplierCode = resolveSupplierCodeByName(value) + if (supplierCode) { + nextValues.manufacturerCode = supplierCode + } + } + if (key === 'manufacturerCode') { + const supplierName = resolveSupplierNameByCode(value) + if (supplierName && !normalizeSkuContentValue(nextValues.manufacturer)) { + nextValues.manufacturer = supplierName + } + } + + const skuFields = readPreSkuSkuFields(selectedPreSku) + const previousValues = { + ...selectedSkuContentFormValues, + ...currentValues, + } + const mergedNextValues = { + ...selectedSkuContentFormValues, + ...nextValues, + } + + if ( + selectedFormalSkuCode && + (key === 'manufacturer' || key === 'manufacturerCode' || key === 'versionNo') + ) { + const currentBarcode = currentValues.barcode ?? selectedSkuContentFormValues.barcode ?? '' + const previousAutoBarcode = + buildAutoBarcodeFromSkuContent(selectedFormalSkuCode, previousValues, skuFields) || + selectedFormalBarcode + if ( + !currentBarcode || + currentBarcode === selectedFormalBarcode || + currentBarcode === previousAutoBarcode + ) { + nextValues.barcode = buildAutoBarcodeFromSkuContent( + selectedFormalSkuCode, + mergedNextValues, + skuFields, + ) + } + } + + if (key === 'model' || key === 'attributeCode' || key === 'versionNo') { + const currentDrawingId = currentValues.drawingId ?? selectedSkuContentFormValues.drawingId ?? '' + const previousAutoDrawingId = buildAutoDrawingIdFromSkuContent(previousValues, skuFields) + const nextAutoDrawingId = buildAutoDrawingIdFromSkuContent(mergedNextValues, skuFields) + if ( + nextAutoDrawingId && + (!currentDrawingId || currentDrawingId === previousAutoDrawingId) + ) { + nextValues.drawingId = nextAutoDrawingId + } + } + + scheduleSkuContentAutoSave(selectedPreSku.id, nextValues) + + return { + ...current, + [selectedPreSku.id]: nextValues, + } + }) + } + + function savePackageComponentAssignmentsForPreSku( + preSkuId: string, + assignments: PackageComponentAssignments, + ) { + const normalizedAssignments = Object.entries(assignments).reduce( + (next, [packageKey, componentCodes]) => { + next[getPackageKey(packageKey)] = normalizePackageComponentCodes(componentCodes) + return next + }, + {} as PackageComponentAssignments, + ) + + setPackageComponentAssignmentsByPreSkuId((current) => { + const next = { + ...current, + [preSkuId]: normalizedAssignments, + } + writeStoredPackageComponentAssignments(next) + return next + }) + } + + async function syncPushedPackageAssignments( + assignments: PackageComponentAssignments, + activePackageKey: string, + ) { + if (!selectedPreSku || !selectedFormalSeriesProduct || !selectedFormalSkuCode) return + + const normalizedActivePackageKey = getPackageKey(activePackageKey) + const savingKey = `${selectedPreSku.id}:${normalizedActivePackageKey}` + setSavingPackageComponentKey(savingKey) + setActionMessage(null) + setActionError(null) + + try { + const allComponentDetails = readPreSkuComponentDetails(selectedPreSku) + const formalProduct = await getSharedNonIntegratedProductById(selectedFormalSeriesProduct.id) + const nextSkuItems = formalProduct.skuItems.map((item) => { + const matchesSelectedSku = + item.sourceSkuId === selectedPreSku.id || + ( + selectedFormalSkuCode && + ( + item.skuCode.trim().toUpperCase() === selectedFormalSkuCode || + item.skuCode.trim().toUpperCase().startsWith(selectedFormalSkuCode) + ) + ) + if (!matchesSelectedSku) { + return item + } + + const itemPackageKey = getPackageKey(item.packageSuffix) + const componentDetails = getComponentDetailsByCodes( + allComponentDetails, + assignments[itemPackageKey] ?? [], + ) + + return { + ...item, + componentDetails, + } + }) + const savedProduct = await saveSharedNonIntegratedProduct( + { + ...formalProduct, + skuItems: nextSkuItems, + }, + { workspaceCategory: NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY }, + ) + const pushedSourceSkuIds = Array.from(new Set( + savedProduct.skuItems + .map((item) => item.sourceSkuId?.trim() ?? '') + .filter(Boolean), + )) + + setFormalSeriesProducts((current) => + mergeFormalSeriesProducts(current, [ + { + id: savedProduct.id, + name: + savedProduct.openingReference.productName.trim() || + savedProduct.spu.trim() || + selectedFormalSeriesProduct.name, + spu: savedProduct.spu.trim().toUpperCase() || selectedFormalSeriesProduct.spu, + pushedSourceSkuIds, + skuItems: savedProduct.skuItems, + skuBatchFlowNos: normalizeSkuBatchFlowNos(savedProduct.skuBatchFlowNos), + }, + ]), + ) + window.dispatchEvent(new Event(NON_INTEGRATED_PRODUCTS_UPDATED)) + setActionMessage(`${getPackageLabel(normalizedActivePackageKey)}包内组件已同步`) + } catch (error) { + setActionError(error instanceof Error ? error.message : '包内组件同步失败') + } finally { + setSavingPackageComponentKey((current) => current === savingKey ? null : current) + } + } + + function handleMaterialComponentPackageChange(componentCode: string, packageKey: string | null) { + if (!selectedPreSku) return + const allComponentDetails = readPreSkuComponentDetails(selectedPreSku) + const baseAssignments = buildResolvedPackageComponentAssignments( + selectedPackageRows, + selectedPackageComponentAssignments, + allComponentDetails, + ) + const packageKeys = Array.from(new Set(selectedPackageRows.map((row) => getPackageKey(row.packageSuffix)))) + const nextAssignments = assignComponentToSinglePackage( + baseAssignments, + packageKeys, + componentCode, + packageKey, + ) + + savePackageComponentAssignmentsForPreSku( + selectedPreSku.id, + nextAssignments, + ) + + if (selectedSkuPushed) { + void syncPushedPackageAssignments(nextAssignments, packageKey ?? packageKeys[0] ?? 'single') + } + } + + async function handlePushSelectedSku() { + if (!selectedPreSku || !selectedFormalSeriesProduct || pushDisabledReason || selectedSkuPushed) return + setPushingPreSkuId(selectedPreSku.id) + setActionMessage(null) + setActionError(null) + + try { + const formalProduct = await getSharedNonIntegratedProductById(selectedFormalSeriesProduct.id) + const existingSkuItems = formalProduct.skuItems.filter((item) => + item.sourceSkuId === selectedPreSku.id || + (selectedFormalSkuCode && + ( + item.skuCode === selectedFormalSkuCode || + item.skuCode.startsWith(selectedFormalSkuCode) + )), + ) + const resolvedSkuContentFormValues = buildResolvedSkuContentFormValues( + selectedPreSku, + selectedFormalSkuCode, + selectedFormalBarcode, + selectedSkuContentFormValues, + ) + const valuesForPush: SkuContentFormValues = { + ...resolvedSkuContentFormValues, + skuCode: selectedFormalSkuCode, + barcode: resolvedSkuContentFormValues.barcode || selectedFormalBarcode, + } + const nextSkuItems = buildPushedSkuItems({ + preSku: selectedPreSku, + sequence: selectedDraftGroupSeriesIndex + 1, + values: valuesForPush, + existingItems: existingSkuItems, + formalSkuCode: selectedFormalSkuCode, + packageAssignments: selectedPackageComponentAssignments, + }) + const nextProduct = updateFormalProductSkuItems( + formalProduct, + nextSkuItems, + selectedFormalSkuCode, + normalizeSkuContentValue(valuesForPush.dingTalkFlowNo), + ) + const savedProduct = await saveSharedNonIntegratedProduct(nextProduct, { + workspaceCategory: NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY, + }) + const pushedSourceSkuIds = Array.from(new Set( + savedProduct.skuItems + .map((item) => item.sourceSkuId?.trim() ?? '') + .filter(Boolean), + )) + + setFormalSeriesProducts((current) => + mergeFormalSeriesProducts(current, [ + { + id: savedProduct.id, + name: + savedProduct.openingReference.productName.trim() || + savedProduct.spu.trim() || + selectedFormalSeriesProduct.name, + spu: savedProduct.spu.trim().toUpperCase() || selectedFormalSeriesProduct.spu, + pushedSourceSkuIds, + skuItems: savedProduct.skuItems, + skuBatchFlowNos: normalizeSkuBatchFlowNos(savedProduct.skuBatchFlowNos), + }, + ]), + ) + window.dispatchEvent(new Event(NON_INTEGRATED_PRODUCTS_UPDATED)) + setActionMessage(`已推送到系列产品开发明细:${nextSkuItems.map((item) => item.skuCode).join('、')}`) + } catch (error) { + setActionError(error instanceof Error ? error.message : 'SKU 推送失败') + } finally { + setPushingPreSkuId(null) + } + } + + async function handleDeleteDraftGroup( + draftGroup: DraftGroupDTO, + displayName: string, + pushed: boolean, + ) { + if (pushed) return + const confirmed = window.confirm(`确认删除「${displayName}」草稿吗?删除后未推送内容会从草稿组移除。`) + if (!confirmed) return + + setDeletingDraftGroupId(draftGroup.id) + setActionMessage(null) + setActionError(null) + + try { + const response = await fetch(`/api/draft-groups/${draftGroup.id}`, { + method: 'DELETE', + }) + const responseBody = await readJsonSafely(response) + if (!response.ok || isApiFailure(responseBody)) { + throw new Error(getApiErrorMessage(parseApiErrorPayload(responseBody), '草稿组删除失败')) + } + + const nextDraftGroups = draftGroupsState.filter((item) => item.id !== draftGroup.id) + setDraftGroupsState(nextDraftGroups) + setDetailById((current) => { + const next = { ...current } + delete next[draftGroup.id] + return next + }) + setSelectedDraftGroupId((current) => + current === draftGroup.id + ? getInitialSelectedDraftGroupId(nextDraftGroups) + : current, + ) + setActionMessage('未推送草稿已删除') + } catch (error) { + setActionError(error instanceof Error ? error.message : '草稿组删除失败') + } finally { + setDeletingDraftGroupId(null) + } + } + + useEffect(() => { + return () => { + Object.values(skuContentAutoSaveTimersRef.current).forEach((timer) => { + window.clearTimeout(timer) + }) + skuContentAutoSaveTimersRef.current = {} + } + }, []) + + useEffect(() => { + window.localStorage.setItem( + FORMAL_LEFT_PANE_WIDTH_STORAGE_KEY, + String(Math.round(leftPaneWidth)), + ) + }, [leftPaneWidth]) + + useEffect(() => { + window.localStorage.setItem( + FORMAL_RIGHT_PANE_WIDTH_STORAGE_KEY, + String(Math.round(rightPaneWidth)), + ) + }, [rightPaneWidth]) + + useEffect(() => { + const refreshDraftGroups = () => { + void (async () => { + try { + const response = await fetch('/api/draft-groups', { cache: 'no-store' }) + const responseBody = await readJsonSafely(response) + if (!response.ok || isApiFailure(responseBody)) return + + const nextDraftGroups = parseDraftGroupListPayload(responseBody) + if (!nextDraftGroups) return + + setDraftGroupsState(nextDraftGroups) + setDetailById((current) => { + const nextIds = new Set(nextDraftGroups.map((draftGroup) => draftGroup.id)) + return Object.fromEntries( + Object.entries(current).filter(([draftGroupId]) => nextIds.has(draftGroupId)), + ) + }) + setSelectedDraftGroupId((current) => + nextDraftGroups.some((draftGroup) => draftGroup.id === current) + ? current + : getInitialSelectedDraftGroupId(nextDraftGroups), + ) + } catch { + // Keep the current draft-group snapshot when the list API is temporarily unavailable. + } + })() + } + + const refreshFormalSeriesProducts = () => { + const localProducts = readFormalSeriesProducts() + if (localProducts.length > 0) { + setFormalSeriesProducts( + mergeFormalSeriesProducts(initialFormalSeriesProducts, localProducts), + ) + } + + void (async () => { + try { + const productPage = await listSharedNonIntegratedProductPage({ + page: 1, + pageSize: 100, + productDevelopmentKind: 'series', + }) + setFormalSeriesProducts( + buildFormalSeriesProductsFromRecords(productPage.products), + ) + } catch { + // Keep the latest local snapshot when the product API is temporarily unavailable. + } + })() + } + + refreshFormalSeriesProducts() + refreshDraftGroups() + window.addEventListener('storage', refreshFormalSeriesProducts) + window.addEventListener(NON_INTEGRATED_PRODUCTS_UPDATED, refreshFormalSeriesProducts) + window.addEventListener(NON_INTEGRATED_PRODUCTS_UPDATED, refreshDraftGroups) + window.addEventListener('focus', refreshFormalSeriesProducts) + document.addEventListener('visibilitychange', refreshFormalSeriesProducts) + + return () => { + window.removeEventListener('storage', refreshFormalSeriesProducts) + window.removeEventListener(NON_INTEGRATED_PRODUCTS_UPDATED, refreshFormalSeriesProducts) + window.removeEventListener(NON_INTEGRATED_PRODUCTS_UPDATED, refreshDraftGroups) + window.removeEventListener('focus', refreshFormalSeriesProducts) + document.removeEventListener('visibilitychange', refreshFormalSeriesProducts) + } + }, [initialFormalSeriesProducts]) + + useEffect(() => { + const missingDraftGroups = draftGroupsState.filter((draftGroup) => !detailById[draftGroup.id]) + if (missingDraftGroups.length === 0) return + + let cancelled = false + void (async () => { + const loadedDetails = await Promise.all( + missingDraftGroups.map(async (draftGroup) => { + try { + const response = await fetch(`/api/draft-groups/${draftGroup.id}`, { cache: 'no-store' }) + const responseBody = await readJsonSafely(response) + if (!response.ok || isApiFailure(responseBody)) return null + return parseDraftGroupDetailPayload(responseBody) + } catch { + return null + } + }), + ) + + if (cancelled) return + setDetailById((current) => { + const next = { ...current } + let changed = false + loadedDetails.forEach((detail) => { + if (detail) { + next[detail.draftGroup.id] = detail + changed = true + } + }) + return changed ? next : current + }) + })() + + return () => { + cancelled = true + } + }, [detailById, draftGroupsState]) + + useEffect(() => { + if (!selectedDraftGroup) { + setLoadingDetailId(null) + return + } + if (selectedDetail) { + setLoadingDetailId((current) => + current === selectedDraftGroup.id ? null : current, + ) + return + } + + let cancelled = false + setLoadingDetailId(selectedDraftGroup.id) + setDetailError(null) + + void (async () => { + try { + const response = await fetch(`/api/draft-groups/${selectedDraftGroup.id}`, { cache: 'no-store' }) + const responseBody = await readJsonSafely(response) + if (!response.ok || isApiFailure(responseBody)) { + throw new Error(getApiErrorMessage(parseApiErrorPayload(responseBody), '草稿组详情加载失败')) + } + + const detail = parseDraftGroupDetailPayload(responseBody) + if (!detail) throw new Error('草稿组详情响应格式异常') + if (!cancelled) { + setDetailById((current) => ({ ...current, [selectedDraftGroup.id]: detail })) + } + } catch (error) { + if (!cancelled) { + setDetailError(error instanceof Error ? error.message : '草稿组详情加载失败') + } + } finally { + if (!cancelled) setLoadingDetailId(null) + } + })() + + return () => { + cancelled = true + } + }, [selectedDetail, selectedDraftGroup]) + + useEffect(() => { + setActivePackageKey(null) + setActionMessage(null) + setActionError(null) + if (!selectedDetail) { + setSelectedPreSkuId('') + return + } + if (!selectedDetail.preSkus.some((preSku) => preSku.id === selectedPreSkuId)) { + setSelectedPreSkuId(selectedDetail.preSkus[0]?.id ?? '') + } + }, [selectedDetail, selectedPreSkuId]) + + const counts = { + total: draftGroupsState.length, + draft: draftGroupsState.filter((item) => item.status === 'DRAFT').length, + ready: draftGroupsState.filter((item) => item.status === 'READY_FOR_INTAKE' || item.status === 'PACKAGING').length, + pushed: formalSeriesProducts.reduce( + (count, product) => count + product.pushedSourceSkuIds.length, + 0, + ), + } + + useEffect(() => { + if (!actionMessage) return + + const timer = window.setTimeout(() => { + setActionMessage(null) + }, 2000) + + return () => window.clearTimeout(timer) + }, [actionMessage]) + + return ( +
+ + +
handlePaneResizePointerDown('left', event)} + className="group relative z-30 w-2 shrink-0 cursor-col-resize touch-none bg-transparent" + > + +
+ +
+
+
+

3D WORKBENCH

+

{selectedSkuName}

+
+
+

SERIES

+

+ {getSeriesDisplayName(selectedDraftGroup, selectedFormalSeriesProduct?.spu)} +

+
+
+ 0 ? previewWorldNodes : undefined} + materialAssignments={previewMaterialAssignments} + holes={previewHoleOverlayState.holes} + activeHolePanelIds={previewHoleOverlayState.activeHolePanelIds} + panelNodeNameByPanelId={previewHoleOverlayState.panelNodeNameByPanelId} + panelLabelByPanelId={previewHoleOverlayState.panelLabelByPanelId} + showHoleHighlights={showHoleHighlights} + extraSidebarActions={ + setShowHoleHighlights((current) => !current)} + /> + } + title="草稿组 SKU" + showFpsBadge={false} + emptyTitle={loadingDetailId ? '正在加载 SKU 模型' : '未选择 SKU'} + emptyDescription={ + detailError || + (selectedDraftGroup + ? '当前 SKU 的辅助装配模型会在这里预览。' + : '当前还没有可以预览的 SKU。') + } + /> +
+ +
handlePaneResizePointerDown('right', event)} + className="group relative z-30 w-2 shrink-0 cursor-col-resize touch-none bg-transparent" + > + +
+ + +
+ ) +} diff --git a/furniture-3d-system/src/app/draft-groups/[id]/page.tsx b/furniture-3d-system/src/app/draft-groups/[id]/page.tsx index 87d8f987..f1afe083 100644 --- a/furniture-3d-system/src/app/draft-groups/[id]/page.tsx +++ b/furniture-3d-system/src/app/draft-groups/[id]/page.tsx @@ -1,55 +1,54 @@ -import { Layers3, Box, ClipboardCheck, PackageSearch } from 'lucide-react' import { requireSession } from '@/lib/server-session' import { draftGroupService } from '@/server/services' -import { categoryService } from '@/server/services/category.service' -import DraftGroupDetailClient from './DraftGroupDetailClient' +import { intakeNonIntegratedProductService } from '@/server/services/intake-non-integrated-product.service' +import DraftGroupsFormalClient from '../DraftGroupsFormalClient' export default async function DraftGroupDetailPage({ params, }: { params: Promise<{ id: string }> }) { - const { user } = await requireSession('/login') + const { authUser } = await requireSession('/login') const { id } = await params - const [detail, categories] = await Promise.all([ - draftGroupService.getById(id), - categoryService.listActive(), + const [draftGroups, seriesProductPage] = await Promise.all([ + draftGroupService.list(), + intakeNonIntegratedProductService.listPage(authUser, { + page: 1, + pageSize: 100, + productDevelopmentKind: 'series', + }), ]) + const initialFormalSeriesProducts = seriesProductPage.products.flatMap((product) => { + const spu = product.spu?.trim().toUpperCase() ?? '' + const name = + product.openingReference?.productName?.trim() || + product.spu?.trim() || + '' + + if (!spu || !name) return [] + return [ + { + id: product.id, + name, + spu, + pushedSourceSkuIds: Array.from(new Set( + product.skuItems + .map((item) => item.sourceSkuId?.trim() ?? '') + .filter(Boolean), + )), + skuItems: product.skuItems, + skuBatchFlowNos: product.skuBatchFlowNos, + }, + ] + }) return ( - , - }, - { - label: '包装占位', - value: detail.preSkus.filter((item) => item.packagingPlan).length, - hint: '先按准 SKU 分别保存', - tone: 'info', - icon: , - }, - { - label: '资料预览', - value: '已接入', - hint: 'BOM、重量、组件编码、安装步骤', - tone: 'success', - icon: , - }, - { - label: '产品资料', - value: detail.draftGroup.status === 'FORMALIZED' ? '已完成' : '待确认', - hint: '按组内准 SKU 逐个确认', - tone: detail.draftGroup.status === 'FORMALIZED' ? 'success' : 'warning', - icon: , - }, - ]} + ) } diff --git a/furniture-3d-system/src/app/draft-groups/page.tsx b/furniture-3d-system/src/app/draft-groups/page.tsx index 385fdd04..9f629637 100644 --- a/furniture-3d-system/src/app/draft-groups/page.tsx +++ b/furniture-3d-system/src/app/draft-groups/page.tsx @@ -1,14 +1,63 @@ import { requireSession } from '@/lib/server-session' import { draftGroupService } from '@/server/services' -import { categoryService } from '@/server/services/category.service' -import DraftGroupsClient from './DraftGroupsClient' +import { intakeNonIntegratedProductService } from '@/server/services/intake-non-integrated-product.service' +import DraftGroupsFormalClient from './DraftGroupsFormalClient' -export default async function DraftGroupsPage() { - const { user } = await requireSession('/login') - const [draftGroups, categories] = await Promise.all([ - draftGroupService.list(), - categoryService.listActive(), - ]) - - return +function readSingleSearchParam( + params: Record, + key: string, +) { + const value = params[key] + return typeof value === 'string' ? value : '' +} + +export default async function DraftGroupsPage({ + searchParams, +}: { + searchParams?: Promise> +} = {}) { + const { authUser } = await requireSession('/login') + const params = searchParams ? await searchParams : {} + const draftGroupId = readSingleSearchParam(params, 'draftGroupId') + const [draftGroups, seriesProductPage] = await Promise.all([ + draftGroupService.list(), + intakeNonIntegratedProductService.listPage(authUser, { + page: 1, + pageSize: 100, + productDevelopmentKind: 'series', + }), + ]) + const initialFormalSeriesProducts = seriesProductPage.products.flatMap((product) => { + const spu = product.spu?.trim().toUpperCase() ?? '' + const name = + product.openingReference?.productName?.trim() || + product.spu?.trim() || + '' + + if (!spu || !name) return [] + return [ + { + id: product.id, + name, + spu, + pushedSourceSkuIds: Array.from(new Set( + product.skuItems + .map((item) => item.sourceSkuId?.trim() ?? '') + .filter(Boolean), + )), + skuItems: product.skuItems, + skuBatchFlowNos: product.skuBatchFlowNos, + }, + ] + }) + + return ( + + ) } diff --git a/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx b/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx index 1b2d372b..09e5776a 100644 --- a/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx +++ b/furniture-3d-system/src/app/intake/AccessoryLibraryPanel.tsx @@ -1,7 +1,7 @@ 'use client' /* eslint-disable @next/next/no-img-element */ -import { useEffect, useMemo, useState, type ChangeEvent } from 'react' +import { useEffect, useId, useMemo, useState, type ChangeEvent } from 'react' import { Copy, Eye, @@ -77,6 +77,15 @@ const LETTER_CATEGORY_LABELS: Partial> = B: '平头螺丝', C: '连接螺杆', D: '偏心连接件', + E: '预埋螺母', + F: '合页/铰链', + G: '滑轨', + I: '脚轮/万向轮', + J: '拉手', + K: '层板托', + L: '角码/连接片', + M: '门吸/反弹器', + N: '孔塞/孔贴', } const A_HEAD_TYPES: HeadType[] = [ @@ -123,6 +132,11 @@ const B_HEAD_TYPES: HeadType[] = [ aliases: '沉头、平杯', scenario: '表面齐平、拉手安装、预埋件连接', }, + { + name: '子母', + aliases: '对锁螺丝、公母螺丝、扣管螺丝', + scenario: '板件对锁连接、成套公母连接件', + }, { name: '杯头', aliases: '圆柱头、内六角杯头', @@ -130,13 +144,43 @@ const B_HEAD_TYPES: HeadType[] = [ }, ] +const N_HEAD_TYPES: HeadType[] = [ + { + name: '圆形', + aliases: '圆孔、圆管', + scenario: '圆管塞、圆孔贴、圆孔位遮盖', + }, + { + name: '方形', + aliases: '方孔、方管', + scenario: '方管塞、方孔贴、方孔位遮盖', + }, + { + name: '长方形', + aliases: '矩形、扁管', + scenario: '矩形管塞、长方形孔贴、扁孔位遮盖', + }, + { + name: '异形', + aliases: '椭圆、特殊孔', + scenario: '异形孔位、特殊板件遮盖', + }, +] + const HEAD_TYPES_BY_LETTER: Partial> = { A: A_HEAD_TYPES, B: B_HEAD_TYPES, + N: N_HEAD_TYPES, } const A_SCREW_CATEGORIES = ['自攻螺丝', '过山丝'] -const B_SCREW_CATEGORIES = ['平头螺丝', '内六角螺丝', '拉手螺丝', '杯头螺丝'] +const B_SCREW_CATEGORIES = [ + '平头螺丝', + '子母螺丝', + '内六角螺丝', + '拉手螺丝', + '杯头螺丝', +] const C_SCREW_CATEGORIES = ['全牙螺杆', '双头螺杆', '双头牙'] const D_SCREW_CATEGORIES = [ '三合一连接件', @@ -145,12 +189,45 @@ const D_SCREW_CATEGORIES = [ '隐形连接件', '双头偏心连接杆', ] +const E_SCREW_CATEGORIES = [ + '内外牙螺母', + '胶粒螺母', + '横孔螺母', + '圆柱螺母', +] +const F_SCREW_CATEGORIES = ['合页', '铰链'] +const G_SCREW_CATEGORIES = [ + '三节滑轨', + '托底滑轨', + '骑马抽滑轨', + '二节滑轨', +] +const I_CASTER_CATEGORIES = ['万向轮', '定向轮'] +const I_FURNITURE_LEG_CATEGORIES = ['家具脚', '调节脚'] +const I_SCREW_CATEGORIES = [ + ...I_CASTER_CATEGORIES, + ...I_FURNITURE_LEG_CATEGORIES, +] +const J_SCREW_CATEGORIES = ['拉手'] +const K_SCREW_CATEGORIES = ['层板托'] +const L_SCREW_CATEGORIES = ['角码', '连接片'] +const M_SCREW_CATEGORIES = ['门吸', '反弹器'] +const N_SCREW_CATEGORIES = ['孔塞', '孔贴'] const CATEGORY_SHORTCUTS_BY_LETTER: Partial< Record > = { C: C_SCREW_CATEGORIES, D: D_SCREW_CATEGORIES, + E: E_SCREW_CATEGORIES, + F: F_SCREW_CATEGORIES, + G: G_SCREW_CATEGORIES, + I: I_SCREW_CATEGORIES, + J: J_SCREW_CATEGORIES, + K: K_SCREW_CATEGORIES, + L: L_SCREW_CATEGORIES, + M: M_SCREW_CATEGORIES, + N: N_SCREW_CATEGORIES, } const SELECT_FIELDS = [ @@ -455,11 +532,478 @@ const D_SELECT_FIELD_OVERRIDES: Partial< }, } +const E_SELECT_FIELD_OVERRIDES: Partial< + Record>> +> = { + screwCategory: { + options: E_SCREW_CATEGORIES, + }, + drive: { + options: ['内六角', '一字', '无槽'], + }, + tail: { + label: '介型', + options: ['带介', '无介', '无'], + }, + material: { + label: '材质/表面处理/颜色', + options: [ + '锌合金', + '塑料', + '白色 塑料', + '尼龙', + '白色 尼龙', + '碳钢', + '镀锌', + '镀镍', + '铜', + '不锈钢', + '黑色', + '本色', + ], + }, + tooth: { + options: ['木牙', '机牙', '粗牙', '细牙'], + }, + diameter: { + label: '规格', + options: ['M4', 'M5', 'M6', 'M8', 'M10', 'M12'], + }, + length: { + label: '尺寸', + options: [ + '8mm', + '10mm', + '12mm', + '13mm', + '15mm', + '16mm', + '18mm', + '20mm', + '25mm', + '30mm', + ], + }, + preDrill: { + options: ['需预开孔', '按配套孔位安装'], + }, +} + +const F_SELECT_FIELD_OVERRIDES: Partial< + Record>> +> = { + screwCategory: { + options: F_SCREW_CATEGORIES, + }, + tail: { + label: '盖位/弯位', + options: ['全盖/直臂', '半盖/中弯', '无盖/大弯'], + }, + material: { + label: '材质/表面处理', + options: [ + '冷轧钢', + '304不锈钢', + '201不锈钢', + '铁镀镍', + '锌合金', + '镀镍', + ], + }, + tooth: { + label: '功能', + options: ['普通', '缓冲', '反弹'], + }, + diameter: { + label: '规格/铰杯', + options: ['35杯', '26杯', '40杯', '2寸', '2.5寸', '3寸', '4寸', '5寸'], + }, + length: { + label: '开启角度', + options: ['90度', '95度', '105度', '110度', '135度', '165度', '180度'], + }, + color: { + options: ['银色', '黑色', '枪黑', '本色'], + }, +} + +const G_SELECT_FIELD_OVERRIDES: Partial< + Record>> +> = { + screwCategory: { + options: G_SCREW_CATEGORIES, + }, + material: { + label: '材质/表面处理', + options: ['冷轧钢', '镀锌钢', '不锈钢', '铁镀锌', '铝合金'], + }, + tooth: { + label: '功能', + options: ['普通', '缓冲', '反弹', '缓冲反弹'], + }, + diameter: { + label: '规格', + options: [ + '10寸/250mm', + '12寸/300mm', + '14寸/350mm', + '16寸/400mm', + '18寸/450mm', + '20寸/500mm', + '22寸/550mm', + '24寸/600mm', + ], + }, + color: { + options: ['银色', '黑色', '灰色', '本色'], + }, +} + +const J_SELECT_FIELD_OVERRIDES: Partial< + Record>> +> = { + screwCategory: { + options: J_SCREW_CATEGORIES, + }, + material: { + label: '材质', + options: [ + '铝合金', + '锌合金', + '不锈钢', + '304不锈钢', + '201不锈钢', + '铁', + '铜', + '木质', + '皮革', + '陶瓷', + '亚克力', + '塑料', + ], + }, + diameter: { + label: '孔距', + options: [ + '单孔', + '双孔', + '无孔距', + '32孔距', + '64孔距', + '76孔距', + '96孔距', + '128孔距', + '160孔距', + '192孔距', + '224孔距', + '256孔距', + '288孔距', + '320孔距', + '480孔距', + ], + }, + length: { + label: '尺寸', + options: [ + '30mm', + '50mm', + '64mm', + '80mm', + '96mm', + '100mm', + '114mm', + '120mm', + '128mm', + '150mm', + '160mm', + '180mm', + '200mm', + '224mm', + '256mm', + '300mm', + '320mm', + '400mm', + '600mm', + '800mm', + '1000mm', + '1200mm', + ], + }, + color: { + options: [ + '黑色', + '哑光黑', + '银色', + '哑光银', + '金色', + '香槟金', + '玫瑰金', + '枪黑', + '灰色', + '古铜色', + '红古铜', + '青古铜', + '白色', + '木色', + '本色', + '透明', + ], + }, +} + +const I_CASTER_MATERIAL_OPTIONS = ['PU', '橡胶', '尼龙', 'PVC', 'TPR', '金属'] +const I_CASTER_DIAMETER_OPTIONS = [ + '1寸/25mm', + '1.5寸/38mm', + '2寸/50mm', + '2.5寸/63mm', + '3寸/75mm', + '4寸/100mm', +] +const I_CASTER_TAIL_OPTIONS = ['平板', '丝杆', '插杆', '膨胀杆'] +const I_CASTER_TOOTH_OPTIONS = ['无刹车', '带刹车', '双刹'] +const I_CASTER_COLOR_OPTIONS = ['黑色', '灰色', '白色', '透明', '本色'] +const I_FURNITURE_LEG_MATERIAL_OPTIONS = [ + '铁', + '铝合金', + '锌合金', + '不锈钢', + '304不锈钢', + '塑料', + '尼龙', + '木质', + '橡胶', +] +const I_FURNITURE_LEG_DIAMETER_OPTIONS = ['M6', 'M8', 'M10', 'M12', '无螺杆'] +const I_FURNITURE_LEG_LENGTH_OPTIONS = [ + '50mm', + '80mm', + '100mm', + '120mm', + '150mm', + '200mm', + '300mm', + '400mm', + '600mm', + '700mm', +] +const I_FURNITURE_LEG_COLOR_OPTIONS = [ + '黑色', + '银色', + '金色', + '枪黑', + '灰色', + '白色', + '木色', + '本色', +] + +const I_SELECT_FIELD_OVERRIDES: Partial< + Record>> +> = { + screwCategory: { + options: I_SCREW_CATEGORIES, + }, + tail: { + label: '安装方式', + options: I_CASTER_TAIL_OPTIONS, + }, + material: { + label: '材质', + options: [ + ...I_CASTER_MATERIAL_OPTIONS, + ...I_FURNITURE_LEG_MATERIAL_OPTIONS.filter( + (option) => !I_CASTER_MATERIAL_OPTIONS.includes(option), + ), + ], + }, + tooth: { + label: '功能', + options: I_CASTER_TOOTH_OPTIONS, + }, + diameter: { + label: '规格/轮径', + options: [ + ...I_CASTER_DIAMETER_OPTIONS, + ...I_FURNITURE_LEG_DIAMETER_OPTIONS, + ], + }, + length: { + label: '尺寸', + options: I_FURNITURE_LEG_LENGTH_OPTIONS, + }, + color: { + options: [ + ...I_CASTER_COLOR_OPTIONS, + ...I_FURNITURE_LEG_COLOR_OPTIONS.filter( + (option) => !['黑色', '灰色', '白色', '本色'].includes(option), + ), + ], + }, +} + +const K_SELECT_FIELD_OVERRIDES: Partial< + Record>> +> = { + screwCategory: { + options: K_SCREW_CATEGORIES, + }, + material: { + label: '材质', + options: [ + '铁', + '锌合金', + '不锈钢', + '304不锈钢', + '铜', + '塑料', + '尼龙', + '透明塑料', + '橡胶', + ], + }, + diameter: { + label: '规格', + options: ['4mm', '5mm', '6mm', '8mm', '10mm', '12mm'], + }, + length: { + label: '尺寸', + options: [ + '短款', + '中款', + '长款', + '15mm', + '20mm', + '25mm', + '30mm', + '35mm', + '40mm', + ], + }, + color: { + options: ['银色', '黑色', '白色', '透明', '本色', '金色', '灰色'], + }, +} + +const L_SELECT_FIELD_OVERRIDES: Partial< + Record>> +> = { + screwCategory: { + options: L_SCREW_CATEGORIES, + }, + tail: { + label: '形状', + options: ['L型', '一字', 'T型', '直片'], + }, + material: { + label: '材质', + options: ['铁', '碳钢', '不锈钢', '304不锈钢', '铝合金', '塑料'], + }, + diameter: { + label: '尺寸', + options: [ + '20*20', + '25*25', + '30*30', + '40*40', + '50*50', + '60*60', + '80*80', + '100*100', + '100*20', + '120*40', + ], + }, + length: { + label: '厚度', + options: ['1mm', '1.5mm', '2mm', '2.5mm', '3mm', '4mm'], + }, + color: { + options: ['银色', '黑色', '白色', '本色', '镀锌色'], + }, +} + +const M_SELECT_FIELD_OVERRIDES: Partial< + Record>> +> = { + screwCategory: { + options: M_SCREW_CATEGORIES, + }, + tail: { + label: '安装方式', + options: ['明装', '暗装', '嵌入式', '螺丝固定', '免打孔'], + }, + material: { + label: '材质', + options: ['铁', '锌合金', '不锈钢', '304不锈钢', '塑料', '尼龙'], + }, + tooth: { + label: '功能', + options: ['普通', '强磁', '按压反弹', '可调节', '缓冲'], + }, + diameter: { + label: '规格', + options: ['小号', '中号', '大号', '短款', '长款'], + }, + color: { + options: ['银色', '黑色', '灰色', '白色', '本色'], + }, +} + +const N_SELECT_FIELD_OVERRIDES: Partial< + Record>> +> = { + screwCategory: { + options: N_SCREW_CATEGORIES, + }, + drive: { + label: '安装方式', + options: ['塞入', '压入', '卡入', '自粘'], + }, + material: { + label: '材质', + options: ['塑料', 'PVC', '橡胶', '木质', '纸质', '金属'], + }, + diameter: { + label: '规格', + options: ['5mm', '6mm', '8mm', '10mm', '12mm', '15mm', '18mm', '20mm', '25mm'], + }, + length: { + label: '厚度', + options: ['1mm', '1.5mm', '2mm', '3mm', '4mm', '5mm'], + }, + color: { + label: '颜色', + options: ['白色', '黑色', '灰色', '胡桃色', '木纹色'], + }, + preDrill: { + label: '背胶', + options: ['有胶', '无胶'], + }, +} + const HIDDEN_SELECT_FIELD_KEYS_BY_LETTER: Partial< Record > = { C: ['drive'], D: ['drive', 'tail', 'finish', 'tooth', 'color', 'preDrill'], + E: ['finish', 'color'], + F: ['drive', 'finish', 'preDrill'], + G: ['drive', 'tail', 'finish', 'length', 'preDrill'], + H: ['drive', 'tail', 'finish', 'tooth', 'preDrill'], + I: ['drive', 'finish', 'preDrill'], + J: ['drive', 'tail', 'finish', 'tooth', 'preDrill'], + K: ['drive', 'tail', 'finish', 'tooth', 'preDrill'], + L: ['drive', 'finish', 'tooth', 'preDrill'], + M: ['drive', 'finish', 'length', 'preDrill'], + N: ['tail', 'finish', 'tooth'], +} + +const CUSTOM_SELECT_FIELD_KEYS_BY_LETTER: Partial< + Record +> = { + L: ['diameter', 'length'], + N: ['drive', 'material', 'diameter', 'length', 'color', 'preDrill'], } const EMPTY_FILTERS: ScrewFilterState = { @@ -504,6 +1048,12 @@ const USAGE_TAGS = [ '床架', '户外家具', '防腐木', + '孔塞', + '孔贴', + '螺丝孔', + '线孔', + '装饰孔', + '走线孔', ] const ENVIRONMENT_TAGS = ['室内', '潮湿环境', '户外', '防锈', '耐腐蚀'] @@ -567,6 +1117,35 @@ function getTableColumns(letter: AccessoryLibraryLetter, shouldShowHeadType: boo }).map((column) => { if (letter === 'C' && column === '长度') return '尺寸' if (letter === 'D' && column === '材质') return '材质/表面处理' + if (letter === 'E' && column === '材质') return '材质/表面处理/颜色' + if (letter === 'E' && column === '尾型') return '介型' + if (letter === 'E' && column === '长度') return '尺寸' + if (letter === 'F' && column === '尾型') return '盖位/弯位' + if (letter === 'F' && column === '牙型') return '功能' + if (letter === 'F' && column === '规格') return '规格/铰杯' + if (letter === 'F' && column === '长度') return '开启角度' + if (letter === 'F' && column === '材质') return '材质/表面处理' + if (letter === 'G' && column === '牙型') return '功能' + if (letter === 'G' && column === '材质') return '材质/表面处理' + if (letter === 'I' && column === '规格') return '规格/轮径' + if (letter === 'I' && column === '尾型') return '安装方式' + if (letter === 'I' && column === '牙型') return '功能' + if (letter === 'I' && column === '长度') return '尺寸' + if (letter === 'J' && column === '规格') return '孔距' + if (letter === 'J' && column === '长度') return '尺寸' + if (letter === 'K' && column === '长度') return '尺寸' + if (letter === 'L' && column === '规格') return '尺寸' + if (letter === 'L' && column === '长度') return '厚度' + if (letter === 'L' && column === '尾型') return '形状' + if (letter === 'M' && column === '尾型') return '安装方式' + if (letter === 'M' && column === '牙型') return '功能' + if (letter === 'N' && column === '头型') return '形状' + if (letter === 'N' && column === '槽型') return '安装方式' + if (letter === 'N' && column === '材质') return '材质' + if (letter === 'N' && column === '规格') return '规格' + if (letter === 'N' && column === '长度') return '厚度' + if (letter === 'N' && column === '颜色') return '颜色' + if (letter === 'N' && column === '是否预开孔') return '背胶' return column }) } @@ -664,6 +1243,141 @@ const DEFAULT_SCREW_FIELDS_BY_LETTER: Partial< preDrill: '按配套孔位安装', environments: ['室内'], }, + E: { + screwCategory: '内外牙螺母', + headType: '', + drive: '内六角', + tail: '无介', + material: '锌合金', + finish: '', + tooth: '木牙', + diameter: 'M6', + length: '13mm', + usages: ['板式家具', '柜体', '床架'], + color: '', + preDrill: '需预开孔', + environments: ['室内'], + }, + F: { + screwCategory: '铰链', + headType: '', + drive: '', + tail: '全盖/直臂', + material: '冷轧钢', + finish: '', + tooth: '缓冲', + diameter: '35杯', + length: '105度', + usages: ['板式家具', '柜体', '门板'], + color: '银色', + preDrill: '', + environments: ['室内', '防锈'], + }, + G: { + screwCategory: '三节滑轨', + headType: '', + drive: '', + tail: '', + material: '冷轧钢', + finish: '', + tooth: '缓冲', + diameter: '16寸/400mm', + length: '', + usages: ['板式家具', '柜体', '抽屉'], + color: '银色', + preDrill: '', + environments: ['室内', '防锈'], + }, + I: { + screwCategory: '万向轮', + headType: '', + drive: '', + tail: '丝杆', + material: 'PU', + finish: '', + tooth: '带刹车', + diameter: '2寸/50mm', + length: '', + usages: ['板式家具', '柜体', '五金件'], + color: '黑色', + preDrill: '', + environments: ['室内'], + }, + J: { + screwCategory: '拉手', + headType: '', + drive: '', + tail: '', + material: '铝合金', + finish: '', + tooth: '', + diameter: '96孔距', + length: '120mm', + usages: ['板式家具', '柜体', '抽屉', '拉手'], + color: '黑色', + preDrill: '', + environments: ['室内'], + }, + K: { + screwCategory: '层板托', + headType: '', + drive: '', + tail: '', + material: '铁', + finish: '', + tooth: '', + diameter: '5mm', + length: '短款', + usages: ['板式家具', '柜体', '层板托'], + color: '银色', + preDrill: '', + environments: ['室内'], + }, + L: { + screwCategory: '角码', + headType: '', + drive: '', + tail: 'L型', + material: '不锈钢', + finish: '', + tooth: '', + diameter: '30*30', + length: '2mm', + usages: ['板式家具', '五金件', '角码', '连接片'], + color: '银色', + preDrill: '', + environments: ['室内', '防锈'], + }, + M: { + screwCategory: '门吸', + headType: '', + drive: '', + tail: '明装', + material: '铁', + finish: '', + tooth: '强磁', + diameter: '小号', + length: '', + usages: ['板式家具', '柜体', '门板'], + color: '银色', + preDrill: '', + environments: ['室内'], + }, + N: { + screwCategory: '孔塞', + headType: '圆形', + drive: '塞入', + tail: '', + material: '塑料', + finish: '', + tooth: '', + diameter: '8mm', + length: '2mm', + usages: ['板式家具', '柜体'], + color: '白色', + preDrill: '无胶', + environments: ['室内'], + }, } const PRIMARY_BUTTON_CLASS = @@ -707,10 +1421,22 @@ function getHeadTypes(letter: AccessoryLibraryLetter) { return HEAD_TYPES_BY_LETTER[letter] ?? [] } +function getHeadTypeLabel(letter: AccessoryLibraryLetter) { + return letter === 'N' ? '形状' : '头型' +} + function getCategoryShortcuts(letter: AccessoryLibraryLetter) { return CATEGORY_SHORTCUTS_BY_LETTER[letter] ?? [] } +function isIFurnitureLegCategory(category: string) { + return I_FURNITURE_LEG_CATEGORIES.includes(category) +} + +function isICasterCategory(category: string) { + return I_CASTER_CATEGORIES.includes(category) +} + function getSelectFields(letter: AccessoryLibraryLetter) { const overrides = letter === 'B' @@ -719,7 +1445,27 @@ function getSelectFields(letter: AccessoryLibraryLetter) { ? C_SELECT_FIELD_OVERRIDES : letter === 'D' ? D_SELECT_FIELD_OVERRIDES - : {} + : letter === 'E' + ? E_SELECT_FIELD_OVERRIDES + : letter === 'F' + ? F_SELECT_FIELD_OVERRIDES + : letter === 'G' + ? G_SELECT_FIELD_OVERRIDES + : letter === 'H' + ? {} + : letter === 'I' + ? I_SELECT_FIELD_OVERRIDES + : letter === 'J' + ? J_SELECT_FIELD_OVERRIDES + : letter === 'K' + ? K_SELECT_FIELD_OVERRIDES + : letter === 'L' + ? L_SELECT_FIELD_OVERRIDES + : letter === 'M' + ? M_SELECT_FIELD_OVERRIDES + : letter === 'N' + ? N_SELECT_FIELD_OVERRIDES + : {} const hiddenKeys = HIDDEN_SELECT_FIELD_KEYS_BY_LETTER[letter] ?? [] return SELECT_FIELDS.filter((field) => !hiddenKeys.includes(field.key)).map( @@ -730,6 +1476,88 @@ function getSelectFields(letter: AccessoryLibraryLetter) { ) } +function isCustomSelectField(letter: AccessoryLibraryLetter, fieldKey: ScrewFilterKey) { + return (CUSTOM_SELECT_FIELD_KEYS_BY_LETTER[letter] ?? []).includes(fieldKey) +} + +function shouldShowSelectFieldForItem( + item: Pick, + fieldKey: ScrewFilterKey, +) { + if (item.letter === 'I') { + if ( + isIFurnitureLegCategory(item.screwCategory) && + (fieldKey === 'tail' || fieldKey === 'tooth') + ) { + return false + } + + if (isICasterCategory(item.screwCategory) && fieldKey === 'length') { + return false + } + } + + if ( + item.letter === 'E' && + item.screwCategory !== '内外牙螺母' && + (fieldKey === 'tail' || fieldKey === 'tooth') + ) { + return false + } + + if ( + item.letter === 'F' && + item.screwCategory === '合页' && + (fieldKey === 'tail' || fieldKey === 'tooth' || fieldKey === 'length') + ) { + return false + } + + return true +} + +function getSelectFieldsForItem(item: Pick) { + return getSelectFields(item.letter).filter((field) => + shouldShowSelectFieldForItem(item, field.key), + ).map((field) => { + if (item.letter !== 'I' || !isIFurnitureLegCategory(item.screwCategory)) { + return field + } + + if (field.key === 'diameter') { + return { + ...field, + label: '规格', + options: I_FURNITURE_LEG_DIAMETER_OPTIONS, + } + } + + if (field.key === 'length') { + return { + ...field, + label: '尺寸', + options: I_FURNITURE_LEG_LENGTH_OPTIONS, + } + } + + if (field.key === 'material') { + return { + ...field, + options: I_FURNITURE_LEG_MATERIAL_OPTIONS, + } + } + + if (field.key === 'color') { + return { + ...field, + options: I_FURNITURE_LEG_COLOR_OPTIONS, + } + } + + return field + }) +} + function normalizeScrewItem(item: AccessoryLibraryItem): ScrewItem { const letter = ACCESSORY_LIBRARY_LETTERS.includes(item.letter as AccessoryLibraryLetter) ? (item.letter as AccessoryLibraryLetter) @@ -867,9 +1695,32 @@ function renderEccentricConnectorArtwork(accent: string, category: string) { ` } +function renderEmbeddedNutArtwork(accent: string, category: string) { + const shell = + category.includes('胶粒') + ? ` + + ` + : ` + + ` + const sideHole = + category.includes('横孔') || category.includes('圆柱') + ? ` + + ` + : ` + ` + + return ` + ${shell} + ${sideHole} + ` +} + function getTemporaryReferenceImage(item: ScrewItem) { if (item.imageUrl.trim()) return '' - if (item.letter !== 'C' && item.letter !== 'D') return '' + if (item.letter !== 'C' && item.letter !== 'D' && item.letter !== 'E') return '' const category = item.screwCategory.trim() || '配件' const palette = getTemporaryReferencePalette(category) @@ -881,7 +1732,9 @@ function getTemporaryReferenceImage(item: ScrewItem) { const artwork = item.letter === 'C' ? renderThreadedRodArtwork(palette.accent, category) - : renderEccentricConnectorArtwork(palette.accent, category) + : item.letter === 'D' + ? renderEccentricConnectorArtwork(palette.accent, category) + : renderEmbeddedNutArtwork(palette.accent, category) const title = escapeSvgText(category) const subtitle = escapeSvgText(spec || item.name || '临时参考图') const svg = ` @@ -923,6 +1776,8 @@ function buildScrewName( | 'screwCategory' | 'material' | 'finish' + | 'tooth' + | 'color' >, ) { const diameter = item.diameter.trim() @@ -947,6 +1802,126 @@ function buildScrewName( .join(' ') } + if (item.letter === 'E') { + const materialOrFinish = item.material.trim() || item.finish.trim() + const isThreadedInsertNut = item.screwCategory === '内外牙螺母' + return [ + specSize, + isThreadedInsertNut ? item.tail : '', + item.drive, + isThreadedInsertNut ? item.tooth : '', + materialOrFinish, + item.screwCategory, + ] + .map((value) => value.trim()) + .filter(Boolean) + .join(' ') + } + + if (item.letter === 'F') { + const materialOrFinish = item.material.trim() || item.finish.trim() + if (item.screwCategory === '合页') { + return [item.diameter, item.color, materialOrFinish, item.screwCategory] + .map((value) => value.trim()) + .filter(Boolean) + .join(' ') + } + + return [item.tail, item.length, item.tooth, item.screwCategory] + .map((value) => value.trim()) + .filter(Boolean) + .join(' ') + } + + if (item.letter === 'G') { + return [item.diameter, item.tooth, item.screwCategory] + .map((value) => value.trim()) + .filter(Boolean) + .join(' ') + } + + if ( + item.letter === 'I' && + isIFurnitureLegCategory(item.screwCategory) + ) { + const materialOrFinish = item.material.trim() || item.finish.trim() + return [ + item.diameter, + item.length, + item.color, + materialOrFinish, + item.screwCategory, + ] + .map((value) => value.trim()) + .filter(Boolean) + .join(' ') + } + + if (item.letter === 'H' || item.letter === 'J' || item.letter === 'K') { + const materialOrFinish = item.material.trim() || item.finish.trim() + return [ + item.diameter, + item.length, + item.color, + materialOrFinish, + item.screwCategory, + ] + .map((value) => value.trim()) + .filter(Boolean) + .join(' ') + } + + if (item.letter === 'I') { + const materialOrFinish = item.material.trim() || item.finish.trim() + return [ + item.diameter, + item.tail, + item.tooth, + materialOrFinish, + item.screwCategory, + ] + .map((value) => value.trim()) + .filter(Boolean) + .join(' ') + } + + if (item.letter === 'L') { + const materialOrFinish = item.material.trim() || item.finish.trim() + return [ + item.diameter, + item.length, + item.tail, + item.color, + materialOrFinish, + item.screwCategory, + ] + .map((value) => value.trim()) + .filter(Boolean) + .join(' ') + } + + if (item.letter === 'M') { + const materialOrFinish = item.material.trim() || item.finish.trim() + return [item.diameter, item.color, materialOrFinish, item.screwCategory] + .map((value) => value.trim()) + .filter(Boolean) + .join(' ') + } + + if (item.letter === 'N') { + const materialOrFinish = item.material.trim() || item.finish.trim() + return [ + item.diameter, + item.headType, + item.color, + materialOrFinish, + item.screwCategory, + ] + .map((value) => value.trim()) + .filter(Boolean) + .join(' ') + } + return [specSize, item.drive, item.headType, item.tail, item.screwCategory] .map((value) => value.trim()) .filter(Boolean) @@ -991,6 +1966,97 @@ function createScrewDraft( } } +function normalizeScrewDraftForCategory(draft: ScrewItem): ScrewItem { + if (draft.letter === 'I') { + if (isIFurnitureLegCategory(draft.screwCategory)) { + return { + ...draft, + tail: '', + tooth: '', + diameter: I_FURNITURE_LEG_DIAMETER_OPTIONS.includes(draft.diameter) + ? draft.diameter + : 'M8', + length: I_FURNITURE_LEG_LENGTH_OPTIONS.includes(draft.length) + ? draft.length + : '100mm', + material: I_FURNITURE_LEG_MATERIAL_OPTIONS.includes(draft.material) + ? draft.material + : '铁', + color: I_FURNITURE_LEG_COLOR_OPTIONS.includes(draft.color) + ? draft.color + : '黑色', + usages: ['板式家具', '柜体', '床架'], + } + } + + if (isICasterCategory(draft.screwCategory)) { + return { + ...draft, + tail: I_CASTER_TAIL_OPTIONS.includes(draft.tail) ? draft.tail : '丝杆', + tooth: I_CASTER_TOOTH_OPTIONS.includes(draft.tooth) + ? draft.tooth + : '带刹车', + diameter: I_CASTER_DIAMETER_OPTIONS.includes(draft.diameter) + ? draft.diameter + : '2寸/50mm', + length: '', + material: I_CASTER_MATERIAL_OPTIONS.includes(draft.material) + ? draft.material + : 'PU', + color: I_CASTER_COLOR_OPTIONS.includes(draft.color) ? draft.color : '黑色', + usages: ['板式家具', '柜体', '五金件'], + } + } + } + + if (draft.letter === 'F') { + if (draft.screwCategory === '合页') { + const hingeCupSpecs = ['26杯', '35杯', '40杯'] + return { + ...draft, + tail: '', + tooth: '', + length: '', + diameter: hingeCupSpecs.includes(draft.diameter) ? '3寸' : draft.diameter, + material: draft.material === '冷轧钢' ? '304不锈钢' : draft.material || '304不锈钢', + color: draft.color || '银色', + } + } + + return { + ...draft, + tail: draft.tail || '全盖/直臂', + tooth: draft.tooth || '缓冲', + diameter: draft.diameter || '35杯', + length: draft.length || '105度', + material: draft.material || '冷轧钢', + color: draft.color || '银色', + } + } + + if (draft.letter === 'N') { + const isHoleSticker = draft.screwCategory === '孔贴' + return { + ...draft, + headType: draft.headType || '圆形', + drive: draft.drive || (isHoleSticker ? '自粘' : '塞入'), + material: draft.material || (isHoleSticker ? 'PVC' : '塑料'), + color: draft.color || '白色', + preDrill: draft.preDrill || (isHoleSticker ? '有胶' : '无胶'), + } + } + + if (draft.letter !== 'E' || draft.screwCategory === '内外牙螺母') { + return draft + } + + return { + ...draft, + tail: '', + tooth: '', + } +} + function normalizeDuplicateValue(value: string | string[] | undefined) { return Array.isArray(value) ? value.join(',').trim() : (value ?? '').trim() } @@ -1037,14 +2103,19 @@ function SelectField({ value, options, compact = false, + allowCustom = false, onChange, }: { label: string value: string options: string[] compact?: boolean + allowCustom?: boolean onChange: (value: string) => void }) { + const datalistId = useId() + const fieldClassName = compact ? COMPACT_FIELD_CLASS : FIELD_CLASS + return ( ) } @@ -1164,6 +2252,30 @@ export default function AccessoryLibraryPanel({ () => getSelectFields(selectedLetter), [selectedLetter], ) + const visibleSelectedSelectFields = useMemo( + () => + selectedSelectFields.filter((field) => { + if ( + selectedLetter === 'E' && + appliedFilters.screwCategory && + appliedFilters.screwCategory !== '内外牙螺母' && + (field.key === 'tail' || field.key === 'tooth') + ) { + return false + } + + if ( + selectedLetter === 'F' && + appliedFilters.screwCategory === '合页' && + (field.key === 'tail' || field.key === 'tooth' || field.key === 'length') + ) { + return false + } + + return true + }), + [appliedFilters.screwCategory, selectedLetter, selectedSelectFields], + ) const selectedHiddenFieldKeys = useMemo( () => getHiddenFieldKeys(selectedLetter, shouldShowHeadType), [selectedLetter, shouldShowHeadType], @@ -1220,7 +2332,7 @@ export default function AccessoryLibraryPanel({ return false } - return selectedSelectFields.every((field) => { + return visibleSelectedSelectFields.every((field) => { const filterValue = appliedFilters[field.key] if (field.key === 'screwCategory') return true return !filterValue || item[field.key] === filterValue @@ -1232,9 +2344,9 @@ export default function AccessoryLibraryPanel({ appliedFilters, isSelectedLetterConfigured, selectedLetterItems, - selectedSelectFields, shouldShowCategoryShortcuts, shouldShowHeadType, + visibleSelectedSelectFields, ]) function updateFilterDraft(key: keyof ScrewFilterState, value: string) { @@ -1328,7 +2440,7 @@ export default function AccessoryLibraryPanel({ setDraft((current) => { if (!current) return current - const nextDraft = { ...current, [key]: value } + const nextDraft = normalizeScrewDraftForCategory({ ...current, [key]: value }) if ( [ 'diameter', @@ -1339,6 +2451,8 @@ export default function AccessoryLibraryPanel({ 'screwCategory', 'material', 'finish', + 'tooth', + 'color', ].includes(key) ) { return { @@ -1425,24 +2539,25 @@ export default function AccessoryLibraryPanel({ if (!draft) return - const duplicateItem = findDuplicateScrewItem(items, draft, editingId) + const normalizedDraft = normalizeScrewDraftForCategory(draft) + const duplicateItem = findDuplicateScrewItem(items, normalizedDraft, editingId) if (duplicateItem) { setEditingError( - `已存在相同配置:${duplicateItem.code} ${duplicateItem.name}。请至少修改分类、头型、槽型、尾型、材质、牙型、规格、长度、颜色中的任意一项。`, + `已存在相同配置:${duplicateItem.code} ${duplicateItem.name}。请至少修改分类、头型/形状、槽型、尾型、材质、牙型、规格、长度、颜色中的任意一项。`, ) return } const nextItem: ScrewItem = { - ...draft, - letter: draft.letter, + ...normalizedDraft, + letter: normalizedDraft.letter, code: editingId - ? draft.code.trim().toUpperCase() - : buildNextScrewCode(items, draft.letter), - name: buildScrewName(draft), - price: draft.price.trim(), - imageUrl: draft.imageUrl.trim(), - imageName: draft.imageName.trim(), + ? normalizedDraft.code.trim().toUpperCase() + : buildNextScrewCode(items, normalizedDraft.letter), + name: buildScrewName(normalizedDraft), + price: normalizedDraft.price.trim(), + imageUrl: normalizedDraft.imageUrl.trim(), + imageName: normalizedDraft.imageName.trim(), updatedAt: new Date().toISOString(), } @@ -1507,22 +2622,23 @@ export default function AccessoryLibraryPanel({
item.name)} onChange={(value) => updateFilterDraft('headType', value)} />
) : null} - {selectedSelectFields.map((field) => ( + {visibleSelectedSelectFields.map((field) => (
updateFilterDraft(field.key, value)} - /> + label={field.label} + value={filterDraft[field.key]} + options={field.options} + allowCustom={isCustomSelectField(selectedLetter, field.key)} + onChange={(value) => updateFilterDraft(field.key, value)} + />
))}
@@ -1630,7 +2746,7 @@ export default function AccessoryLibraryPanel({ : 'border-apple-separator bg-apple-bg-primary text-apple-label-secondary hover:bg-apple-bg-tertiary' }`} > - 全部头型 + 全部{getHeadTypeLabel(selectedLetter)} {selectedHeadTypes.map((headType) => { const isActive = activeHeadType === headType.name @@ -1905,6 +3021,10 @@ export default function AccessoryLibraryPanel({ > {draft ? (
+ {(() => { + const draftSelectFields = getSelectFieldsForItem(draft) + return ( + <> {editingError ? (
{editingError} @@ -1935,41 +3055,44 @@ export default function AccessoryLibraryPanel({
- {getSelectFields(draft.letter).slice(0, 1).map((field) => ( + {draftSelectFields.slice(0, 1).map((field) => ( updateDraftField(field.key, value)} /> ))} {getHeadTypes(draft.letter).length ? ( item.name)} onChange={(value) => updateDraftField('headType', value)} /> ) : null} - {getSelectFields(draft.letter).slice(1, 7).map((field) => ( + {draftSelectFields.slice(1, 7).map((field) => ( updateDraftField(field.key, value)} /> ))}
- {getSelectFields(draft.letter).slice(7).map((field) => ( + {draftSelectFields.slice(7).map((field) => ( updateDraftField(field.key, value)} /> ))} @@ -1983,6 +3106,9 @@ export default function AccessoryLibraryPanel({ />
+ + ) + })()}
{ }) }) - it('lets every accessory letter group be selected even before it is configured', async () => { + it('lets unconfigured accessory letter groups be selected', async () => { const actor = userEvent.setup() render() - await actor.click(screen.getByRole('button', { name: 'E 0 条' })) + await actor.click(screen.getByRole('button', { name: 'O 0 条' })) - expect(screen.getByText('E 组配件')).toBeInTheDocument() - expect(screen.getByText('E 组暂未配置配件分类')).toBeInTheDocument() + expect(screen.getByText('O 组配件')).toBeInTheDocument() + expect(screen.getByText('O 组暂未配置配件分类')).toBeInTheDocument() expect(screen.queryByRole('button', { name: '新增 A 组' })).not.toBeInTheDocument() await actor.click(screen.getByRole('button', { name: 'A 自攻螺丝 0 条' })) @@ -139,6 +139,756 @@ describe('alphabet library panels', () => { }) }) + it('includes child-mother screws in the B accessory group', async () => { + const actor = userEvent.setup() + + render() + + await actor.click(screen.getByRole('button', { name: 'B 平头螺丝 0 条' })) + + expect(screen.getByRole('button', { name: '子母螺丝' })).toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: '新增 B 组' })) + + const dialog = await screen.findByRole('dialog', { name: '新增B组配件' }) + fireEvent.change(within(dialog).getByLabelText('分类'), { + target: { value: '子母螺丝' }, + }) + + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + 'M4*20 内六角 平头 平尾 子母螺丝', + ) + }) + + it('uses embedded nut rules for E accessories', async () => { + const actor = userEvent.setup() + + render() + + await actor.click(screen.getByRole('button', { name: 'E 预埋螺母 0 条' })) + + expect(screen.getByText('E 组配件 · 预埋螺母')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '全部分类' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '内外牙螺母' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '胶粒螺母' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '横孔螺母' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '圆柱螺母' })).toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: '新增 E 组' })) + + const dialog = await screen.findByRole('dialog', { name: '新增E组配件' }) + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + 'M6*13 无介 内六角 木牙 锌合金 内外牙螺母', + ) + expect(within(dialog).queryByLabelText('头型')).not.toBeInTheDocument() + expect(within(dialog).getByLabelText('槽型')).toBeInTheDocument() + expect(within(dialog).getByLabelText('介型')).toBeInTheDocument() + expect(within(dialog).getByLabelText('牙型')).toBeInTheDocument() + expect(within(dialog).getByLabelText('规格')).toBeInTheDocument() + expect(within(dialog).getByLabelText('尺寸')).toBeInTheDocument() + expect(within(dialog).getByLabelText('材质/表面处理/颜色')).toBeInTheDocument() + + fireEvent.change(within(dialog).getByLabelText('分类'), { + target: { value: '胶粒螺母' }, + }) + expect(within(dialog).queryByLabelText('介型')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('牙型')).not.toBeInTheDocument() + fireEvent.change(within(dialog).getByLabelText('槽型'), { + target: { value: '无槽' }, + }) + fireEvent.change(within(dialog).getByLabelText('材质/表面处理/颜色'), { + target: { value: '白色 塑料' }, + }) + + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + 'M6*13 无槽 白色 塑料 胶粒螺母', + ) + + await actor.click(within(dialog).getByRole('button', { name: '保存' })) + + await waitFor(() => { + expect(screen.getByText('M6*13 无槽 白色 塑料 胶粒螺母')).toBeInTheDocument() + }) + + const savedItems = JSON.parse( + window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]', + ) + expect(savedItems[0]).toMatchObject({ + letter: 'E', + code: 'E0', + name: 'M6*13 无槽 白色 塑料 胶粒螺母', + screwCategory: '胶粒螺母', + headType: '', + drive: '无槽', + tail: '', + material: '白色 塑料', + finish: '', + tooth: '', + diameter: 'M6', + length: '13mm', + }) + }) + + it('uses hinge rules for F accessories', async () => { + const actor = userEvent.setup() + + render() + + await actor.click(screen.getByRole('button', { name: 'F 合页/铰链 0 条' })) + + expect(screen.getByText('F 组配件 · 合页/铰链')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '全部分类' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '合页' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '铰链' })).toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: '新增 F 组' })) + + const dialog = await screen.findByRole('dialog', { name: '新增F组配件' }) + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + '全盖/直臂 105度 缓冲 铰链', + ) + expect(within(dialog).getByLabelText('分类')).toHaveValue('铰链') + expect(within(dialog).getByLabelText('规格/铰杯')).toHaveValue('35杯') + expect(within(dialog).getByLabelText('盖位/弯位')).toBeInTheDocument() + expect(within(dialog).getByLabelText('开启角度')).toBeInTheDocument() + expect(within(dialog).getByLabelText('功能')).toBeInTheDocument() + + fireEvent.change(within(dialog).getByLabelText('分类'), { + target: { value: '合页' }, + }) + + expect(within(dialog).queryByLabelText('盖位/弯位')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('开启角度')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('功能')).not.toBeInTheDocument() + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + '3寸 银色 304不锈钢 合页', + ) + + await actor.click(within(dialog).getByRole('button', { name: '保存' })) + + await waitFor(() => { + expect(screen.getByText('3寸 银色 304不锈钢 合页')).toBeInTheDocument() + }) + + const savedItems = JSON.parse( + window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]', + ) + expect(savedItems[0]).toMatchObject({ + letter: 'F', + code: 'F0', + name: '3寸 银色 304不锈钢 合页', + screwCategory: '合页', + diameter: '3寸', + material: '304不锈钢', + color: '银色', + tail: '', + tooth: '', + length: '', + }) + }) + + it('uses slide rail rules for G accessories', async () => { + const actor = userEvent.setup() + + render() + + await actor.click(screen.getByRole('button', { name: 'G 滑轨 0 条' })) + + expect(screen.getByText('G 组配件 · 滑轨')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '全部分类' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '三节滑轨' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '托底滑轨' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '骑马抽滑轨' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '二节滑轨' })).toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: '新增 G 组' })) + + const dialog = await screen.findByRole('dialog', { name: '新增G组配件' }) + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + '16寸/400mm 缓冲 三节滑轨', + ) + expect(within(dialog).getByLabelText('分类')).toHaveValue('三节滑轨') + expect(within(dialog).getByLabelText('规格')).toHaveValue('16寸/400mm') + expect(within(dialog).getByLabelText('功能')).toHaveValue('缓冲') + expect(within(dialog).getByLabelText('材质/表面处理')).toHaveValue('冷轧钢') + expect(within(dialog).getByLabelText('颜色')).toHaveValue('银色') + + fireEvent.change(within(dialog).getByLabelText('分类'), { + target: { value: '托底滑轨' }, + }) + fireEvent.change(within(dialog).getByLabelText('规格'), { + target: { value: '18寸/450mm' }, + }) + fireEvent.change(within(dialog).getByLabelText('功能'), { + target: { value: '缓冲反弹' }, + }) + + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + '18寸/450mm 缓冲反弹 托底滑轨', + ) + + await actor.click(within(dialog).getByRole('button', { name: '保存' })) + + await waitFor(() => { + expect(screen.getByText('18寸/450mm 缓冲反弹 托底滑轨')).toBeInTheDocument() + }) + + const savedItems = JSON.parse( + window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]', + ) + expect(savedItems[0]).toMatchObject({ + letter: 'G', + code: 'G0', + name: '18寸/450mm 缓冲反弹 托底滑轨', + screwCategory: '托底滑轨', + diameter: '18寸/450mm', + tooth: '缓冲反弹', + material: '冷轧钢', + color: '银色', + }) + }) + + it('leaves H unconfigured before nut rules are confirmed', async () => { + const actor = userEvent.setup() + + render() + + await actor.click(screen.getByRole('button', { name: 'H 0 条' })) + + expect(screen.getByText('H 组配件')).toBeInTheDocument() + expect(screen.getByText('H 组暂未配置配件分类')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '新增 H 组' })).not.toBeInTheDocument() + }) + + it('uses caster wheel rules for I accessories', async () => { + const actor = userEvent.setup() + + render() + + await actor.click(screen.getByRole('button', { name: 'I 脚轮/万向轮 0 条' })) + + expect(screen.getByText('I 组配件 · 脚轮/万向轮')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '全部分类' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '万向轮' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '定向轮' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '家具脚' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '调节脚' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '刹车万向轮' })).not.toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: '新增 I 组' })) + + const dialog = await screen.findByRole('dialog', { name: '新增I组配件' }) + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + '2寸/50mm 丝杆 带刹车 PU 万向轮', + ) + expect(within(dialog).getByLabelText('分类')).toHaveValue('万向轮') + expect(within(dialog).getByLabelText('规格/轮径')).toHaveValue('2寸/50mm') + expect(within(dialog).getByLabelText('安装方式')).toHaveValue('丝杆') + expect(within(dialog).getByLabelText('功能')).toHaveValue('带刹车') + expect(within(dialog).getByLabelText('材质')).toHaveValue('PU') + expect(within(dialog).getByLabelText('颜色')).toHaveValue('黑色') + expect(within(dialog).queryByLabelText('头型')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('槽型')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('长度')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('是否预开孔')).not.toBeInTheDocument() + + fireEvent.change(within(dialog).getByLabelText('分类'), { + target: { value: '定向轮' }, + }) + fireEvent.change(within(dialog).getByLabelText('规格/轮径'), { + target: { value: '3寸/75mm' }, + }) + fireEvent.change(within(dialog).getByLabelText('安装方式'), { + target: { value: '平板' }, + }) + fireEvent.change(within(dialog).getByLabelText('功能'), { + target: { value: '无刹车' }, + }) + fireEvent.change(within(dialog).getByLabelText('材质'), { + target: { value: '橡胶' }, + }) + fireEvent.change(within(dialog).getByLabelText('颜色'), { + target: { value: '灰色' }, + }) + + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + '3寸/75mm 平板 无刹车 橡胶 定向轮', + ) + + await actor.click(within(dialog).getByRole('button', { name: '保存' })) + + await waitFor(() => { + expect(screen.getByText('3寸/75mm 平板 无刹车 橡胶 定向轮')).toBeInTheDocument() + }) + + const savedItems = JSON.parse( + window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]', + ) + expect(savedItems[0]).toMatchObject({ + letter: 'I', + code: 'I0', + name: '3寸/75mm 平板 无刹车 橡胶 定向轮', + screwCategory: '定向轮', + diameter: '3寸/75mm', + tail: '平板', + tooth: '无刹车', + material: '橡胶', + color: '灰色', + drive: '', + finish: '', + length: '', + preDrill: '', + }) + + await actor.click(screen.getByRole('button', { name: '新增 I 组' })) + + const legDialog = await screen.findByRole('dialog', { name: '新增I组配件' }) + fireEvent.change(within(legDialog).getByLabelText('分类'), { + target: { value: '家具脚' }, + }) + + expect(within(legDialog).getByLabelText('配件名称')).toHaveValue( + 'M8 100mm 黑色 铁 家具脚', + ) + expect(within(legDialog).getByLabelText('规格')).toHaveValue('M8') + expect(within(legDialog).getByLabelText('尺寸')).toHaveValue('100mm') + expect(within(legDialog).getByLabelText('材质')).toHaveValue('铁') + expect(within(legDialog).getByLabelText('颜色')).toHaveValue('黑色') + expect(within(legDialog).queryByLabelText('安装方式')).not.toBeInTheDocument() + expect(within(legDialog).queryByLabelText('功能')).not.toBeInTheDocument() + + fireEvent.change(within(legDialog).getByLabelText('规格'), { + target: { value: '无螺杆' }, + }) + fireEvent.change(within(legDialog).getByLabelText('尺寸'), { + target: { value: '150mm' }, + }) + fireEvent.change(within(legDialog).getByLabelText('颜色'), { + target: { value: '金色' }, + }) + fireEvent.change(within(legDialog).getByLabelText('材质'), { + target: { value: '铝合金' }, + }) + + expect(within(legDialog).getByLabelText('配件名称')).toHaveValue( + '无螺杆 150mm 金色 铝合金 家具脚', + ) + + await actor.click(within(legDialog).getByRole('button', { name: '保存' })) + + await waitFor(() => { + expect(screen.getByText('无螺杆 150mm 金色 铝合金 家具脚')).toBeInTheDocument() + }) + + const savedItemsAfterLeg = JSON.parse( + window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]', + ) + expect(savedItemsAfterLeg[1]).toMatchObject({ + letter: 'I', + code: 'I1', + name: '无螺杆 150mm 金色 铝合金 家具脚', + screwCategory: '家具脚', + diameter: '无螺杆', + length: '150mm', + color: '金色', + material: '铝合金', + drive: '', + tail: '', + finish: '', + tooth: '', + preDrill: '', + }) + }) + + it('uses handle rules for J accessories', async () => { + const actor = userEvent.setup() + + render() + + await actor.click(screen.getByRole('button', { name: 'J 拉手 0 条' })) + + expect(screen.getByText('J 组配件 · 拉手')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '全部分类' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '拉手' })).toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: '新增 J 组' })) + + const dialog = await screen.findByRole('dialog', { name: '新增J组配件' }) + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + '96孔距 120mm 黑色 铝合金 拉手', + ) + expect(within(dialog).getByLabelText('分类')).toHaveValue('拉手') + expect(within(dialog).getByLabelText('孔距')).toHaveValue('96孔距') + expect(within(dialog).getByLabelText('尺寸')).toHaveValue('120mm') + expect(within(dialog).getByLabelText('材质')).toHaveValue('铝合金') + expect(within(dialog).getByLabelText('颜色')).toHaveValue('黑色') + expect(within(dialog).queryByLabelText('头型')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('槽型')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('尾型')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('牙型')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('是否预开孔')).not.toBeInTheDocument() + + fireEvent.change(within(dialog).getByLabelText('孔距'), { + target: { value: '单孔' }, + }) + fireEvent.change(within(dialog).getByLabelText('尺寸'), { + target: { value: '30mm' }, + }) + fireEvent.change(within(dialog).getByLabelText('颜色'), { + target: { value: '金色' }, + }) + fireEvent.change(within(dialog).getByLabelText('材质'), { + target: { value: '锌合金' }, + }) + + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + '单孔 30mm 金色 锌合金 拉手', + ) + + await actor.click(within(dialog).getByRole('button', { name: '保存' })) + + await waitFor(() => { + expect(screen.getByText('单孔 30mm 金色 锌合金 拉手')).toBeInTheDocument() + }) + + const savedItems = JSON.parse( + window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]', + ) + expect(savedItems[0]).toMatchObject({ + letter: 'J', + code: 'J0', + name: '单孔 30mm 金色 锌合金 拉手', + screwCategory: '拉手', + diameter: '单孔', + length: '30mm', + color: '金色', + material: '锌合金', + drive: '', + tail: '', + finish: '', + tooth: '', + preDrill: '', + }) + }) + + it('uses shelf support rules for K accessories', async () => { + const actor = userEvent.setup() + + render() + + await actor.click(screen.getByRole('button', { name: 'K 层板托 0 条' })) + + expect(screen.getByText('K 组配件 · 层板托')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '全部分类' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '层板托' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '层板钉' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: '玻璃层板托' })).not.toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: '新增 K 组' })) + + const dialog = await screen.findByRole('dialog', { name: '新增K组配件' }) + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + '5mm 短款 银色 铁 层板托', + ) + expect(within(dialog).getByLabelText('分类')).toHaveValue('层板托') + expect(within(dialog).getByLabelText('规格')).toHaveValue('5mm') + expect(within(dialog).getByLabelText('尺寸')).toHaveValue('短款') + expect(within(dialog).getByLabelText('材质')).toHaveValue('铁') + expect(within(dialog).getByLabelText('颜色')).toHaveValue('银色') + expect(within(dialog).queryByLabelText('头型')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('槽型')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('尾型')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('牙型')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('是否预开孔')).not.toBeInTheDocument() + + fireEvent.change(within(dialog).getByLabelText('规格'), { + target: { value: '6mm' }, + }) + fireEvent.change(within(dialog).getByLabelText('尺寸'), { + target: { value: '20mm' }, + }) + fireEvent.change(within(dialog).getByLabelText('颜色'), { + target: { value: '透明' }, + }) + fireEvent.change(within(dialog).getByLabelText('材质'), { + target: { value: '塑料' }, + }) + + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + '6mm 20mm 透明 塑料 层板托', + ) + + await actor.click(within(dialog).getByRole('button', { name: '保存' })) + + await waitFor(() => { + expect(screen.getByText('6mm 20mm 透明 塑料 层板托')).toBeInTheDocument() + }) + + const savedItems = JSON.parse( + window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]', + ) + expect(savedItems[0]).toMatchObject({ + letter: 'K', + code: 'K0', + name: '6mm 20mm 透明 塑料 层板托', + screwCategory: '层板托', + diameter: '6mm', + length: '20mm', + color: '透明', + material: '塑料', + drive: '', + tail: '', + finish: '', + tooth: '', + preDrill: '', + }) + }) + + it('uses angle bracket and connector plate rules for L accessories', async () => { + const actor = userEvent.setup() + + render() + + await actor.click(screen.getByRole('button', { name: 'L 角码/连接片 0 条' })) + + expect(screen.getByText('L 组配件 · 角码/连接片')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '全部分类' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '角码' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '连接片' })).toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: '新增 L 组' })) + + const dialog = await screen.findByRole('dialog', { name: '新增L组配件' }) + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + '30*30 2mm L型 银色 不锈钢 角码', + ) + expect(within(dialog).getByLabelText('分类')).toHaveValue('角码') + expect(within(dialog).getByLabelText('尺寸')).toHaveValue('30*30') + expect(within(dialog).getByLabelText('厚度')).toHaveValue('2mm') + expect(within(dialog).getByLabelText('形状')).toHaveValue('L型') + expect(within(dialog).getByLabelText('材质')).toHaveValue('不锈钢') + expect(within(dialog).getByLabelText('颜色')).toHaveValue('银色') + expect(within(dialog).queryByLabelText('头型')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('槽型')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('牙型')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('是否预开孔')).not.toBeInTheDocument() + + fireEvent.change(within(dialog).getByLabelText('分类'), { + target: { value: '连接片' }, + }) + fireEvent.change(within(dialog).getByLabelText('尺寸'), { + target: { value: '120*25' }, + }) + fireEvent.change(within(dialog).getByLabelText('厚度'), { + target: { value: '2.2mm' }, + }) + fireEvent.change(within(dialog).getByLabelText('形状'), { + target: { value: '一字' }, + }) + fireEvent.change(within(dialog).getByLabelText('颜色'), { + target: { value: '黑色' }, + }) + fireEvent.change(within(dialog).getByLabelText('材质'), { + target: { value: '铁' }, + }) + + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + '120*25 2.2mm 一字 黑色 铁 连接片', + ) + + await actor.click(within(dialog).getByRole('button', { name: '保存' })) + + await waitFor(() => { + expect(screen.getByText('120*25 2.2mm 一字 黑色 铁 连接片')).toBeInTheDocument() + }) + + const savedItems = JSON.parse( + window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]', + ) + expect(savedItems[0]).toMatchObject({ + letter: 'L', + code: 'L0', + name: '120*25 2.2mm 一字 黑色 铁 连接片', + screwCategory: '连接片', + diameter: '120*25', + length: '2.2mm', + tail: '一字', + color: '黑色', + material: '铁', + drive: '', + finish: '', + tooth: '', + preDrill: '', + }) + }) + + it('uses door catch and rebounder rules for M accessories', async () => { + const actor = userEvent.setup() + + render() + + await actor.click(screen.getByRole('button', { name: 'M 门吸/反弹器 0 条' })) + + expect(screen.getByText('M 组配件 · 门吸/反弹器')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '全部分类' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '门吸' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '反弹器' })).toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: '新增 M 组' })) + + const dialog = await screen.findByRole('dialog', { name: '新增M组配件' }) + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + '小号 银色 铁 门吸', + ) + expect(within(dialog).getByLabelText('分类')).toHaveValue('门吸') + expect(within(dialog).getByLabelText('规格')).toHaveValue('小号') + expect(within(dialog).getByLabelText('安装方式')).toHaveValue('明装') + expect(within(dialog).getByLabelText('功能')).toHaveValue('强磁') + expect(within(dialog).getByLabelText('材质')).toHaveValue('铁') + expect(within(dialog).getByLabelText('颜色')).toHaveValue('银色') + expect(within(dialog).queryByLabelText('头型')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('槽型')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('长度')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('是否预开孔')).not.toBeInTheDocument() + + fireEvent.change(within(dialog).getByLabelText('分类'), { + target: { value: '反弹器' }, + }) + fireEvent.change(within(dialog).getByLabelText('规格'), { + target: { value: '短款' }, + }) + fireEvent.change(within(dialog).getByLabelText('安装方式'), { + target: { value: '暗装' }, + }) + fireEvent.change(within(dialog).getByLabelText('功能'), { + target: { value: '按压反弹' }, + }) + fireEvent.change(within(dialog).getByLabelText('颜色'), { + target: { value: '灰色' }, + }) + fireEvent.change(within(dialog).getByLabelText('材质'), { + target: { value: '塑料' }, + }) + + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + '短款 灰色 塑料 反弹器', + ) + + await actor.click(within(dialog).getByRole('button', { name: '保存' })) + + await waitFor(() => { + expect(screen.getByText('短款 灰色 塑料 反弹器')).toBeInTheDocument() + }) + + const savedItems = JSON.parse( + window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]', + ) + expect(savedItems[0]).toMatchObject({ + letter: 'M', + code: 'M0', + name: '短款 灰色 塑料 反弹器', + screwCategory: '反弹器', + diameter: '短款', + tail: '暗装', + tooth: '按压反弹', + color: '灰色', + material: '塑料', + drive: '', + finish: '', + length: '', + preDrill: '', + }) + }) + + it('uses plug and sticker rules for N accessories', async () => { + const actor = userEvent.setup() + + render() + + await actor.click(screen.getByRole('button', { name: 'N 孔塞/孔贴 0 条' })) + + expect(screen.getByText('N 组配件 · 孔塞/孔贴')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '全部形状' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '孔塞' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '孔贴' })).toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: '新增 N 组' })) + + const dialog = await screen.findByRole('dialog', { name: '新增N组配件' }) + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + '8mm 圆形 白色 塑料 孔塞', + ) + expect(within(dialog).getByLabelText('分类')).toHaveValue('孔塞') + expect(within(dialog).getByLabelText('形状')).toHaveValue('圆形') + expect(within(dialog).getByLabelText('安装方式')).toHaveValue('塞入') + expect(within(dialog).getByLabelText('规格')).toHaveValue('8mm') + expect(within(dialog).getByLabelText('厚度')).toHaveValue('2mm') + expect(within(dialog).getByLabelText('材质')).toHaveValue('塑料') + expect(within(dialog).getByLabelText('颜色')).toHaveValue('白色') + expect(within(dialog).getByLabelText('背胶')).toHaveValue('无胶') + expect(within(dialog).queryByLabelText('头型')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('槽型')).not.toBeInTheDocument() + expect(within(dialog).queryByLabelText('尾型')).not.toBeInTheDocument() + + fireEvent.change(within(dialog).getByLabelText('分类'), { + target: { value: '孔贴' }, + }) + fireEvent.change(within(dialog).getByLabelText('规格'), { + target: { value: '18mm' }, + }) + fireEvent.change(within(dialog).getByLabelText('形状'), { + target: { value: '长方形' }, + }) + fireEvent.change(within(dialog).getByLabelText('安装方式'), { + target: { value: '自粘' }, + }) + fireEvent.change(within(dialog).getByLabelText('颜色'), { + target: { value: '木纹色' }, + }) + fireEvent.change(within(dialog).getByLabelText('材质'), { + target: { value: 'PVC' }, + }) + fireEvent.change(within(dialog).getByLabelText('背胶'), { + target: { value: '有胶' }, + }) + + expect(within(dialog).getByLabelText('配件名称')).toHaveValue( + '18mm 长方形 木纹色 PVC 孔贴', + ) + + await actor.click(within(dialog).getByRole('button', { name: '保存' })) + + await waitFor(() => { + expect(screen.getByText('18mm 长方形 木纹色 PVC 孔贴')).toBeInTheDocument() + }) + + const savedItems = JSON.parse( + window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]', + ) + expect(savedItems[0]).toMatchObject({ + letter: 'N', + code: 'N0', + name: '18mm 长方形 木纹色 PVC 孔贴', + screwCategory: '孔贴', + headType: '长方形', + drive: '自粘', + diameter: '18mm', + length: '2mm', + color: '木纹色', + material: 'PVC', + preDrill: '有胶', + }) + }) + it('filters C and D accessory groups by category shortcuts', async () => { const actor = userEvent.setup() diff --git a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx index cce633ef..6306a2c4 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx @@ -117,6 +117,7 @@ jest.mock('./non-integrated/NonIntegratedDetailClient', () => ({ onOpenRawMaterialProcessConfig, onReturnToList, productDevelopmentKind = 'product', + seriesNameOptions = [], }: { productId?: string mode: 'create' | 'edit' | 'view' @@ -146,12 +147,16 @@ jest.mock('./non-integrated/NonIntegratedDetailClient', () => ({ onOpenRawMaterialProcessConfig?: (materialName: string) => void onReturnToList?: () => void productDevelopmentKind?: 'product' | 'series' + seriesNameOptions?: string[] }) => (
{productId}-{mode} {productDevelopmentKind} + + {seriesNameOptions.join(',')} + {(initialDocumentWorkspaceView ?? 'detail')}- {(initialDocumentWorkspaceCategory ?? '基础信息')} @@ -253,6 +258,15 @@ function renderSelector(props?: { initialRawMaterialName?: string initialNodePermissionOverrides?: IntakeNodePermissionOverrideMap materialLibrarySubstrates?: Array> + initialSeriesNameOptions?: string[] + initialOpenProductWorkspace?: { + product: Record + mode?: 'edit' | 'view' + detailState?: { + view: 'detail' | 'document' + category: (typeof NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES)[number] + } + } userRole?: string userName?: string userId?: string @@ -311,6 +325,8 @@ function renderSelector(props?: { initialActiveRawMaterialLibrary={props?.initialActiveRawMaterialLibrary} initialRawMaterialName={props?.initialRawMaterialName} initialNodePermissionOverrides={props?.initialNodePermissionOverrides} + initialSeriesNameOptions={props?.initialSeriesNameOptions} + initialOpenProductWorkspace={props?.initialOpenProductWorkspace as never} />, ) } @@ -1934,6 +1950,28 @@ describe('IntakeClient', () => { ) }) + it('keeps the selected draft group series name available when opening a new series product', async () => { + const actor = userEvent.setup() + renderSelector({ + eligibleDraftGroups: [], + eligibleSkuList: [], + initialSeriesNameOptions: ['单板桌 DBZ'], + }) + + const rightPanel = screen.getByTestId('right-panel') + await actor.click( + within(rightPanel).getByRole('button', { name: '系列产品开发明细' }), + ) + await actor.click(screen.getByRole('button', { name: '新增产品' })) + + expect(screen.getByTestId('embedded-development-kind')).toHaveTextContent( + 'series', + ) + expect(screen.getByTestId('embedded-series-options')).toHaveTextContent( + '单板桌 DBZ', + ) + }) + it('keeps product and series development lists separated', async () => { const actor = userEvent.setup() @@ -2737,6 +2775,74 @@ describe('IntakeClient', () => { ) }) + it('restores a cached product workspace even when the product is not in the current first page', 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() + window.localStorage.setItem(NON_INTEGRATED_STORAGE_KEY, JSON.stringify([])) + + renderSelector({ + eligibleSkuList: [], + initialNonIntegratedProducts: [], + }) + + expect(screen.getByRole('tab', { name: /DNZ0001/ })).toHaveAttribute( + 'aria-selected', + 'true', + ) + expect(screen.getByTestId('embedded-non-integrated-detail')).toHaveTextContent( + 'DNZ0001-1-edit', + ) + }) + + it('opens the requested product workspace from the intake query entry', async () => { + const product = createNonIntegratedProduct() + + renderSelector({ + eligibleSkuList: [], + initialNonIntegratedProducts: [], + initialOpenProductWorkspace: { + product, + mode: 'edit', + detailState: { + view: 'detail', + category: NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES[0], + }, + }, + }) + + await waitFor(() => { + expect(screen.getByRole('tab', { name: /DNZ0001/ })).toHaveAttribute( + 'aria-selected', + 'true', + ) + }) + expect(screen.getByTestId('embedded-non-integrated-detail')).toHaveTextContent( + 'DNZ0001-1-edit', + ) + expect(screen.getByTestId('embedded-workspace-state')).toHaveTextContent( + 'detail-', + ) + }) + it('opens a separate workspace tab each time product edit is clicked', 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 184753bd..31e6e5d1 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.tsx @@ -159,6 +159,7 @@ interface IntakeClientSelectorProps { id: string; name: string; assetSeriesName: string; + assetSeriesCode?: string; code: string; }[]; eligibleSkuList?: WorkbenchSku[]; @@ -174,6 +175,12 @@ interface IntakeClientSelectorProps { initialActiveRawMaterialLibrary?: RawMaterialLibraryKey; initialRawMaterialName?: string; initialOpenNewNonIntegratedProduct?: boolean; + initialOpenProductWorkspace?: { + product: NonIntegratedProductRecord; + mode?: WorkspaceProductMode; + detailState?: WorkspaceProductDetailState; + }; + initialSeriesNameOptions?: string[]; } interface IntakeClientEditorProps { @@ -203,6 +210,12 @@ interface IntakeClientEditorProps { initialActiveRawMaterialLibrary?: RawMaterialLibraryKey; initialRawMaterialName?: string; initialOpenNewNonIntegratedProduct?: boolean; + initialOpenProductWorkspace?: { + product: NonIntegratedProductRecord; + mode?: WorkspaceProductMode; + detailState?: WorkspaceProductDetailState; + }; + initialSeriesNameOptions?: string[]; } type IntakeClientProps = IntakeClientSelectorProps | IntakeClientEditorProps; @@ -920,6 +933,7 @@ type PersistedIntakeWorkspaceTab = mode: WorkspaceProductMode; productDevelopmentKind: ProductDevelopmentKind; detailState?: WorkspaceProductDetailState; + productSnapshot?: NonIntegratedProductRecord; } | { id: string; @@ -967,6 +981,7 @@ type WorkbenchSku = { componentDetails?: NonIntegratedSkuComponentDetail[]; holePoints?: NonIntegratedSkuHolePoint[]; skuFields?: SeriesDraftSkuFields; + model3dPreviewNodes?: unknown[]; }; type IntegratedPendingSeriesRow = { @@ -1350,7 +1365,7 @@ const BOM_LIBRARY_OPTIONS: { key: "materialLibrary", label: "板材资料库", description: - "只展示板材基材,方便在正式产品 BOM 下维护板材本体资料。", + "只展示板材基材,方便在产品资料 BOM 下维护板材本体资料。", helper: "数据仍复用资产中心基础资料库,系列和饰面花色入口在这里不显示。", }, @@ -1358,7 +1373,7 @@ const BOM_LIBRARY_OPTIONS: { key: "finishLibrary", label: "花色资料库", description: - "只展示饰面花色,方便在正式产品 BOM 下维护花色、贴图和参数。", + "只展示饰面花色,方便在产品资料 BOM 下维护花色、贴图和参数。", helper: "数据仍复用资产中心基础资料库,板材基材入口在这里不显示。", }, @@ -1382,14 +1397,14 @@ const BOM_LIBRARY_OPTIONS: { key: "packagingLibrary", label: "配件资料库", description: - "正式产品下独立维护的配件资料区,按新的业务口径重新配置,不复用原包材资料逻辑。", + "产品资料下独立维护的配件资料区,按新的业务口径重新配置,不复用原包材资料逻辑。", helper: "适合维护配件名称、配件编码、分类规则和后续要补充的独立资料。", }, { key: "packagingMaterialLibrary", label: "包材资料库", description: - "正式产品下独立维护的包材资料区,沿用和配件资料库相同的字母分组逻辑,但单独保存数据。", + "产品资料下独立维护的包材资料区,沿用和配件资料库相同的字母分组逻辑,但单独保存数据。", helper: "适合维护纸箱、泡沫、护角等包材名称、编码、图片和价格,不会和配件资料串在一起。", }, @@ -1607,15 +1622,18 @@ function getProductDevelopmentDetailLabel( function readDraftGroupSeriesName(draftGroup: { assetSeriesName?: string | null; + assetSeriesCode?: string | null; seriesTemplateName?: string | null; name?: string | null; }) { - return ( + const seriesName = draftGroup.assetSeriesName?.trim() || draftGroup.seriesTemplateName?.trim() || - draftGroup.name?.trim() || - "" - ); + ""; + const seriesCode = draftGroup.assetSeriesCode?.trim() || ""; + + if (seriesName && seriesCode) return `${seriesName} ${seriesCode}`; + return seriesName || seriesCode || draftGroup.name?.trim() || ""; } function createPendingEntryWorkspaceTab( @@ -1708,6 +1726,95 @@ function normalizeWorkspaceProductDetailState( return undefined; } +function createWorkspaceProductRestoreSnapshot( + product: NonIntegratedProductRecord, +): NonIntegratedProductRecord { + const fallbackReviewSheet: NonIntegratedProductRecord["reviewSheet"] = { + version: "", + factory: "", + reviewDate: "", + reviewMethod: "", + productName: "", + productImage: "", + productImageName: "", + size: "", + material: "", + barcode: "", + reviewResult: "", + color: "", + issues: [], + }; + const reviewSheet = + (product as Partial).reviewSheet ?? + fallbackReviewSheet; + const fallbackOpeningReference: NonIntegratedProductRecord["openingReference"] = { + productImage: "", + productImageName: "", + productName: "", + temporaryCode: "", + brand: "", + adaptableBrands: [], + productCategory: "", + salesCategory: "", + designer: "", + planningOwner: "", + structureDesigner: "", + currentStage: "", + year: "", + competitorLink: "", + targetPriceBand: "", + size: "", + material: "", + urgent: "", + reason: "", + }; + const openingReference = + (product as Partial).openingReference ?? + fallbackOpeningReference; + const clearReviewSheetImages = ( + sheet: NonIntegratedProductRecord["reviewSheet"], + ): NonIntegratedProductRecord["reviewSheet"] => ({ + ...sheet, + productImage: "", + issues: (sheet.issues ?? []).map((issue) => ({ + ...issue, + issueImage: "", + issueImages: [], + referenceImage: "", + referenceImages: [], + })), + }); + + return { + ...product, + previewImage: "", + model3dDataUrl: undefined, + openingReference: { + ...openingReference, + productImage: "", + }, + reviewSheet: clearReviewSheetImages(reviewSheet), + reviewSheets: product.reviewSheets?.map((round) => ({ + ...round, + reviewSheet: clearReviewSheetImages(round.reviewSheet ?? fallbackReviewSheet), + })), + styleDetails: [], + bomDetails: [], + skuItems: [], + factoryRows: [], + pricingRows: [], + planningLayoutRows: [], + productLimitRows: [], + sampleMeasurementRows: [], + finishedSampleShipmentRows: [], + firstQualityRows: [], + qualityInspectionSheet: { + rows: [], + }, + uploadFiles: [], + }; +} + function serializeIntakeWorkspaceTab( tab: IntakeWorkspaceTab, ): PersistedIntakeWorkspaceTab { @@ -1722,6 +1829,7 @@ function serializeIntakeWorkspaceTab( mode: tab.mode, productDevelopmentKind: tab.productDevelopmentKind, detailState: tab.detailState, + productSnapshot: createWorkspaceProductRestoreSnapshot(tab.product), }; } if (tab.kind === "newNonIntegratedProduct") { @@ -1792,7 +1900,11 @@ function restoreIntakeWorkspaceTab( }; } if (tab.kind === "nonIntegratedProduct") { - const product = productMap.get(tab.productId); + const product = + productMap.get(tab.productId) ?? + (tab.productSnapshot?.id === tab.productId + ? tab.productSnapshot + : undefined); if (!product) return null; const productDevelopmentKind = getProductDevelopmentKind(product); const restoredMode: WorkspaceProductMode = @@ -2393,6 +2505,7 @@ function buildIntegratedSeriesRows(params: { function getDraftGroupSeriesNameOptions(props: IntakeClientProps) { if (isEditorMode(props)) { return getUniqueNonEmptyValues([ + ...(props.initialSeriesNameOptions ?? []), readDraftGroupSeriesName(props.detail.draftGroup), props.detail.draftGroup.name, ...props.detail.preSkus.map((preSku) => @@ -2402,6 +2515,7 @@ function getDraftGroupSeriesNameOptions(props: IntakeClientProps) { } return getUniqueNonEmptyValues([ + ...(props.initialSeriesNameOptions ?? []), ...props.eligibleDraftGroups.flatMap((draftGroup) => [ readDraftGroupSeriesName(draftGroup), draftGroup.name, @@ -2436,6 +2550,9 @@ function getDraftGroupSeriesSkuOptions(props: IntakeClientProps): WorkbenchSku[] componentDetails: readPreSkuComponentDetails(preSku), holePoints: buildSkuHolePointsFromVariantSelection(preSku), skuFields: readPreSkuSkuFields(preSku), + model3dPreviewNodes: preSku.previewNodes.length + ? preSku.previewNodes + : preSku.assemblyWorkspace?.nodes ?? [], })); } @@ -9939,6 +10056,7 @@ export default function IntakeClient(props: IntakeClientProps) { const workspaceTabSequenceRef = useRef(initialWorkspaceState.sequence); const productWorkspaceLoadingRef = useRef>(new Set()); const initialOpenNewNonIntegratedProductRef = useRef(false); + const initialOpenProductWorkspaceRef = useRef(""); const workspaceStateRestoredRef = useRef(false); const [workspaceStateReady, setWorkspaceStateReady] = useState( () => !shouldPersistWorkspaceState, @@ -10248,7 +10366,7 @@ export default function IntakeClient(props: IntakeClientProps) { ) => { setNonIntegratedProducts((current) => { const existingIndex = current.findIndex((item) => item.id === product.id); - if (existingIndex < 0) return current; + if (existingIndex < 0) return [product, ...current]; return current.map((item) => (item.id === product.id ? product : item)); }); @@ -10940,6 +11058,65 @@ export default function IntakeClient(props: IntakeClientProps) { } }, [props.initialActiveRawMaterialLibrary]); + useEffect(() => { + const initialWorkspace = props.initialOpenProductWorkspace; + if (!initialWorkspace || !workspaceStateReady) return; + + const product = initialWorkspace.product; + const mode = initialWorkspace.mode ?? "edit"; + const detailState = initialWorkspace.detailState; + const openKey = [ + product.id, + mode, + detailState?.view ?? "", + detailState?.category ?? "", + ].join(":"); + + if (initialOpenProductWorkspaceRef.current === openKey) return; + initialOpenProductWorkspaceRef.current = openKey; + + const effectiveMode: WorkspaceProductMode = + mode === "edit" && !canOpenNonIntegratedProductForEdit(product, props.user) + ? "view" + : mode; + const tabId = createWorkspaceTabId( + "non-integrated-product", + `${product.id}:${effectiveMode}`, + ); + const nextTab: IntakeWorkspaceTab = { + id: tabId, + kind: "nonIntegratedProduct", + label: getProductDevelopmentDetailLabel(product), + productId: product.id, + product, + mode: effectiveMode, + productDevelopmentKind: getProductDevelopmentKind(product), + detailState, + }; + const targetKey = getFormalWorkspaceTargetKeyForTab(nextTab); + if (targetKey && !canViewFormalWorkspaceByTarget(targetKey)) return; + + setNonIntegratedProducts((current) => { + const existingIndex = current.findIndex((item) => item.id === product.id); + if (existingIndex < 0) return [product, ...current]; + return current.map((item) => (item.id === product.id ? product : item)); + }); + + const { activeTab } = resolveWorkspaceTabsForOpen(workspaceTabs, nextTab); + setWorkspaceTabs((current) => + resolveWorkspaceTabsForOpen(current, nextTab).tabs, + ); + syncWorkspaceState(activeTab); + markProductWorkspaceTabLoaded(activeTab.id); + }, [ + canViewFormalWorkspaceByTarget, + markProductWorkspaceTabLoaded, + props.initialOpenProductWorkspace, + props.user, + workspaceStateReady, + workspaceTabs, + ]); + useEffect(() => { if ( !canUseDesignControlledProductData || 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 19852722..431439a8 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 @@ -61,6 +61,7 @@ jest.mock("@/components/platform/ModelPreviewPanel", () => ({ ModelPreviewPanel: (props: { title?: string; modelUrl?: string | null; + worldNodes?: unknown[]; emptyTitle?: string; emptyDescription?: string; overlayTopRight?: ReactNode; @@ -562,7 +563,6 @@ describe("NonIntegratedDetailClient", () => { await actor.type(screen.getByLabelText("商品企划"), "企划A"); await actor.type(screen.getByLabelText("品类"), "桌类"); await actor.type(screen.getByLabelText("类目"), "书桌"); - expect(screen.getByLabelText("SPU")).toHaveValue("预计SPU:GBGJ-SZ0241"); await actor.type(screen.getByLabelText("目标价格带"), "500-600"); await actor.type(screen.getByLabelText("尺寸"), "140/60/75"); await actor.type(screen.getByLabelText("材质"), "橡胶木"); @@ -776,7 +776,6 @@ describe("NonIntegratedDetailClient", () => { await actor.type(screen.getByLabelText("商品企划"), "测试企划"); await actor.type(screen.getByLabelText("品类"), "测试"); await actor.type(screen.getByLabelText("类目"), "测试"); - expect(screen.getByLabelText("SPU")).toHaveValue("预计SPU:CS0002"); const imageFile = new File(["image"], "preview.png", { type: "image/png" }); const fileInputs = container.querySelectorAll('input[type="file"]'); @@ -2099,7 +2098,7 @@ describe("NonIntegratedDetailClient", () => { fireEvent.keyDown(window, { key: "s", code: "KeyS", ctrlKey: true }); expect(await screen.findByText("正在保存当前内容...")).toBeInTheDocument(); - expect(await screen.findByText("已保存当前内容")).toBeInTheDocument(); + expect((await screen.findAllByText("已保存当前内容")).length).toBeGreaterThan(0); await waitFor(() => { const savedProduct = getNonIntegratedProductById("SZ0230-ctrl-s-save"); expect(savedProduct?.openingReference.reason).toBe("Ctrl+S保存内容"); @@ -3255,13 +3254,6 @@ describe("NonIntegratedDetailClient", () => { }); fireEvent.change(warehouseInputs[1]!, { target: { value: "" } }); - await selectInlineOption( - actor, - within(factoryWorkspace).getAllByRole("combobox", { - name: "产品生产类型", - })[0]!, - "", - ); await waitFor(() => { const savedProduct = getNonIntegratedProductById( @@ -3274,8 +3266,8 @@ describe("NonIntegratedDetailClient", () => { "", ]); expect(savedProduct?.factoryRows.map((row) => row.productionType)).toEqual([ - "", - "", + "ODM", + "ODM", "", ]); }); @@ -4208,14 +4200,13 @@ describe("NonIntegratedDetailClient", () => { expect(screen.getByLabelText("材质类型")).toHaveValue("岩板+钢架"); await actor.clear(screen.getByLabelText("空间设计师")); - await selectInlineOption(actor, screen.getByLabelText("材质类型"), ""); await waitFor(() => { const savedProduct = getNonIntegratedProductById( "SZ0230-basic-material-save", ); expect(savedProduct?.visualPlanning).toBe(""); - expect(savedProduct?.materialType).toBe(""); + expect(savedProduct?.materialType).toBe("岩板+钢架"); }); }); @@ -5854,16 +5845,11 @@ describe("NonIntegratedDetailClient", () => { ]); }); - expect(screen.getByRole("button", { name: "确认推送" })).toBeDisabled(); - expect(screen.getByRole("button", { name: "批量确认推送" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "确认推送" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "批量确认推送" })).toBeEnabled(); expect(screen.queryByText("已录入")).not.toBeInTheDocument(); expect(screen.queryByText("已推送")).not.toBeInTheDocument(); - const confirmPricingButton = screen.getByRole("button", { - name: "确认核价", - }); - expect(confirmPricingButton).toBeDisabled(); - const savedProduct = getNonIntegratedProductById("DNZ0001-product-pricing"); expect(savedProduct?.documentWorkspaceConfirmations?.产品核价).not.toBe(true); expect(savedProduct?.documentWorkspaceApprovedAt?.产品核价).toBeUndefined(); @@ -5941,7 +5927,7 @@ describe("NonIntegratedDetailClient", () => { expect( screen.queryByRole("button", { name: "批量确认推送" }), ).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "确认核价" })).toBeDisabled(); + expect(screen.queryByRole("button", { name: "确认核价" })).not.toBeInTheDocument(); const costPriceInput = screen.getByPlaceholderText("0"); expect(costPriceInput).toHaveAttribute("readonly"); @@ -7521,6 +7507,103 @@ describe("NonIntegratedDetailClient", () => { expect(screen.queryByTestId("product-photo-style-K03")).not.toBeInTheDocument(); }); + it("shows the selected series SKU carried 3D model as readonly without upload choices", async () => { + const actor = userEvent.setup(); + const createPreviewNode = (nodeId: string, modelUrl: string) => ({ + nodeId, + role: "panel", + label: nodeId, + variantId: nodeId, + modelUrl, + position: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }); + + saveNonIntegratedProduct( + createProductRecord({ + id: "CZ0132-series-readonly-sku-model", + spu: "CZ0132", + productDevelopmentKind: "series", + skuItems: [ + createSkuItem({ + id: "series-sku-model-01", + skuSequence: 1, + model: "01", + skuCode: "CZ013201", + barcode: "CZ013201V01", + sourceSkuId: "pre-sku-model-01", + model3dPreviewNodes: [ + createPreviewNode("sku-01-node", "/models/sku-01.glb"), + ], + }), + createSkuItem({ + id: "series-sku-model-02", + skuSequence: 2, + model: "02", + skuCode: "CZ013202", + barcode: "CZ013202V01", + sourceSkuId: "pre-sku-model-02", + model3dPreviewNodes: [ + createPreviewNode("sku-02-node", "/models/sku-02.glb"), + ], + }), + ], + }), + ); + + render( + , + ); + + const getProductPreviewProps = () => { + const props = [...modelPreviewPanelMock.mock.calls] + .reverse() + .map(([callProps]) => callProps as Record) + .find((callProps) => callProps.title === "3D预览"); + expect(props).toBeDefined(); + return props!; + }; + + await waitFor(() => + expect(getProductPreviewProps().worldNodes).toEqual([ + expect.objectContaining({ + nodeName: "sku-01-node", + modelUrl: "/models/sku-01.glb", + }), + ]), + ); + expect(getProductPreviewProps().modelUrl).toBeNull(); + expect(getProductPreviewProps().enableTranslateGizmo).toBe(false); + expect(getProductPreviewProps().overlayTopRight).toBeNull(); + expect(getProductPreviewProps().emptyDescription).toContain("不支持上传或替换"); + + await actor.click(screen.getByLabelText("选择条码")); + const listboxes = await screen.findAllByRole("listbox"); + const activeListbox = listboxes[listboxes.length - 1]!; + expect( + within(activeListbox).queryByRole("option", { name: "请选择" }), + ).not.toBeInTheDocument(); + await actor.click( + within(activeListbox).getByRole("option", { name: "CZ013202V01" }), + ); + + await waitFor(() => + expect(getProductPreviewProps().worldNodes).toEqual([ + expect.objectContaining({ + nodeName: "sku-02-node", + modelUrl: "/models/sku-02.glb", + }), + ]), + ); + expect(getProductPreviewProps().modelUrl).toBeNull(); + expect(getProductPreviewProps().enableTranslateGizmo).toBe(false); + expect(getProductPreviewProps().overlayTopRight).toBeNull(); + }); + it("keeps 3D preview gizmo selection and undo behavior aligned with asset center", async () => { window.localStorage.setItem( NON_INTEGRATED_STORAGE_KEY, 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 3d1410db..74a3a817 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -246,6 +246,7 @@ import { } from "../productPatentStorage"; import { previewSharedNonIntegratedDocumentImages, + pushSharedNonIntegratedProductPricingToJushuitan, saveSharedNonIntegratedProduct, type NonIntegratedUploadProgress, type NonIntegratedUploadProgressHandler, @@ -285,6 +286,11 @@ import { sanitizeIntakeNodePermissionOverrides, type IntakeNodePermissionOverrideMap, } from "@/lib/intake-node-permission-settings"; +import { + readPreviewHoleOverlayState, + readPreviewMaterialAssignments, + readPreviewWorldNodes, +} from "@/lib/open-product/platform-view"; type BasicFieldType = "text" | "textarea" | "select"; type SkuTabKey = "componentSkuInfo" | "skuInfo" | "fileUpload"; @@ -705,6 +711,7 @@ type SeriesDraftSkuOption = { componentDetails?: NonIntegratedSkuComponentDetail[]; holePoints?: NonIntegratedSkuHolePoint[]; skuFields?: SeriesDraftSkuFields; + model3dPreviewNodes?: unknown[]; }; type FormValues = { @@ -893,6 +900,7 @@ function buildSkuContentRevisionSignature({ manufacturerCode: item.manufacturerCode, salesStatus: item.salesStatus, tagMaterial: item.tagMaterial, + model3dPreviewNodes: item.model3dPreviewNodes ?? [], componentDetails: item.componentDetails ?? [], holePoints: item.holePoints ?? [], source: item.source ?? "", @@ -2001,7 +2009,7 @@ const DEFAULT_DOCUMENT_WORKSPACE_CATEGORY: DocumentWorkspaceCategory = const PRODUCT_PHOTO_CATEGORY: DocumentWorkspaceCategory = "产品实拍图"; const AUTO_SAVE_DELAY_MS = 500; const STOCK_PRODUCT_LABEL = "\u5b58\u91cf\u5546\u54c1"; -const PRODUCT_PRICING_JST_PUSH_ENABLED = false; +const PRODUCT_PRICING_JST_PUSH_ENABLED = true; function getChangedWorkflowNodeCategories( previousSignatures: Record | null | undefined, @@ -3161,6 +3169,7 @@ function createEmptySkuItem(): NonIntegratedSkuItem { model3dNodeRotations: EMPTY_PREVIEW_NODE_ROTATION_MAP, model3dNodeMaterialIds: {}, model3dUvConfig: null, + model3dPreviewNodes: [], holePoints: [], }; } @@ -3500,7 +3509,9 @@ function createSkuItemFromSeriesDraftSku( ...createEmptySkuItem(), id: option.id ? `series-${option.id}` : buildNonIntegratedSkuId(), skuSequence: sequence, - batchNo: 1, + batchNo: parseImportedSkuBatchNo(skuFields?.batchNo ?? "", 1), + image: skuFields?.image?.trim() ?? "", + imageName: skuFields?.imageName?.trim() ?? "", model: skuFields?.model.trim() || normalizedCode, setCount: skuFields?.setCount.trim() || "1", specName: skuFields?.specName.trim() || "", @@ -3508,10 +3519,22 @@ function createSkuItemFromSeriesDraftSku( size: skuFields?.size.trim() || size, color: skuFields?.color.trim() || color, material: skuFields?.material.trim() || material, + boardMaterialType: skuFields?.boardMaterialType?.trim() ?? "", versionNo: normalizeSkuVersionNo(skuFields?.versionNo ?? "") || "V01", attributeCode: skuFields?.attributeCode.trim().toUpperCase() || "", drawingId: skuFields?.drawingId.trim().toUpperCase() || "", installMethod: skuFields?.installMethod.trim() || "", + executeStandard: skuFields?.executeStandard?.trim() ?? "", + nationalStandardCompliant: + skuFields?.nationalStandardCompliant?.trim() ?? "", + manufacturer: skuFields?.manufacturer?.trim() ?? "", + manufacturerCode: skuFields?.manufacturerCode?.trim().toUpperCase() ?? "", + salesStatus: skuFields?.salesStatus?.trim() ?? "", + tagMaterial: skuFields?.tagMaterial?.trim() ?? "", + model3dFileName: skuFields?.model3dFileName?.trim() ?? "", + model3dPreviewNodes: Array.isArray(option.model3dPreviewNodes) + ? option.model3dPreviewNodes + : [], componentDetails, holePoints, sourceSkuId: option.id, @@ -4180,6 +4203,37 @@ function hydrateSkuSequences(items: NonIntegratedSkuItem[]) { }); } +function resequenceSkuItems(items: NonIntegratedSkuItem[]) { + let nextSequence = 1; + const groupSequenceMap = new Map(); + + return items.map((item) => { + const groupKey = getSkuPackageGroupKey(item); + let sequence = groupSequenceMap.get(groupKey); + if (!sequence) { + sequence = nextSequence; + groupSequenceMap.set(groupKey, sequence); + nextSequence += 1; + } + + if (item.skuSequence === sequence) return item; + + const keepCustomBarcode = shouldKeepCustomBarcode( + item.barcode, + item.skuCode, + item.manufacturerCode, + item.versionNo, + ); + + return { + ...item, + skuSequence: sequence, + skuCode: "", + barcode: keepCustomBarcode ? item.barcode : "", + }; + }); +} + function createInitialStyleDetails(product: NonIntegratedProductRecord | null) { const nextStyleDetails: Record = {}; @@ -5808,6 +5862,10 @@ type ProductPricingDisplayRow = NonIntegratedPricingRow & { specName: string; supplierName: string; systemEnteredAt: string; + jstVirtualInventoryScheduledAt: string; + jstVirtualInventoryPushedAt: string; + jstVirtualInventoryError: string; + jstVirtualInventorySoId: string; }; type PlanningLayoutSkuRow = { @@ -5906,6 +5964,14 @@ function createProductPricingRowFromSku( costPrice: existingRow?.costPrice?.trim() ?? "", productTag: existingRow?.productTag?.trim() ?? "", systemEnteredAt: existingRow?.systemEnteredAt?.trim() ?? "", + jstVirtualInventoryStatus: existingRow?.jstVirtualInventoryStatus, + jstVirtualInventoryScheduledAt: + existingRow?.jstVirtualInventoryScheduledAt?.trim() ?? "", + jstVirtualInventoryPushedAt: + existingRow?.jstVirtualInventoryPushedAt?.trim() ?? "", + jstVirtualInventoryError: + existingRow?.jstVirtualInventoryError?.trim() ?? "", + jstVirtualInventorySoId: existingRow?.jstVirtualInventorySoId?.trim() ?? "", }; } @@ -5929,11 +5995,30 @@ function normalizePricingRowsForPayload( return rows .map((row) => { const systemEnteredAt = row.systemEnteredAt?.trim() ?? ""; + const jstVirtualInventoryScheduledAt = + row.jstVirtualInventoryScheduledAt?.trim() ?? ""; + const jstVirtualInventoryPushedAt = + row.jstVirtualInventoryPushedAt?.trim() ?? ""; + const jstVirtualInventoryError = + row.jstVirtualInventoryError?.trim() ?? ""; + const jstVirtualInventorySoId = + row.jstVirtualInventorySoId?.trim() ?? ""; return { skuId: row.skuId, costPrice: row.costPrice.trim(), productTag: row.productTag.trim(), ...(systemEnteredAt ? { systemEnteredAt } : {}), + ...(row.jstVirtualInventoryStatus + ? { jstVirtualInventoryStatus: row.jstVirtualInventoryStatus } + : {}), + ...(jstVirtualInventoryScheduledAt + ? { jstVirtualInventoryScheduledAt } + : {}), + ...(jstVirtualInventoryPushedAt + ? { jstVirtualInventoryPushedAt } + : {}), + ...(jstVirtualInventoryError ? { jstVirtualInventoryError } : {}), + ...(jstVirtualInventorySoId ? { jstVirtualInventorySoId } : {}), }; }) .filter( @@ -7386,7 +7471,7 @@ function InlineSelect({ const panelRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const normalizedOptions = normalizeInlineSelectOptions(options); - const shouldIncludeEmptyOption = includeEmptyOption ?? Boolean(placeholder); + const shouldIncludeEmptyOption = includeEmptyOption ?? false; const selectOptions = shouldIncludeEmptyOption ? [ { @@ -7545,7 +7630,7 @@ function SearchableInlineSelect({ const [isOpen, setIsOpen] = useState(false); const [query, setQuery] = useState(""); const normalizedOptions = normalizeInlineSelectOptions(options); - const shouldIncludeEmptyOption = includeEmptyOption ?? Boolean(placeholder); + const shouldIncludeEmptyOption = includeEmptyOption ?? false; const selectOptions = shouldIncludeEmptyOption ? [ { @@ -10391,6 +10476,7 @@ function buildProductPayload( item.model3dNodeRotations ?? EMPTY_PREVIEW_NODE_ROTATION_MAP, model3dNodeMaterialIds: item.model3dNodeMaterialIds ?? {}, model3dUvConfig: normalizeAssetVariantUvConfig(item.model3dUvConfig), + model3dPreviewNodes: item.model3dPreviewNodes ?? [], }; return productDevelopmentKind === "series" @@ -10760,7 +10846,6 @@ export default function NonIntegratedDetailClient({ mode, productId, initialProduct = null, - initialCreateProductKind = "new", initialDocumentWorkspaceCategory, initialDocumentWorkspaceView, embedded = false, @@ -10894,13 +10979,7 @@ export default function NonIntegratedDetailClient({ const [activeSkuTab, setActiveSkuTab] = useState("skuInfo"); const [activeComponentSkuCategory, setActiveComponentSkuCategory] = useState("all"); - const [createProductKind, setCreateProductKind] = - useState("new"); - useEffect(() => { - if (initialCreateProductKind !== "new") { - setCreateProductKind("new"); - } - }, [initialCreateProductKind]); + const [createProductKind] = useState("new"); const [stockProductSpu, setStockProductSpu] = useState(""); const [formValues, setFormValues] = useState(() => createFormValuesFromProduct(existingProduct), @@ -12835,9 +12914,52 @@ export default function NonIntegratedDetailClient({ : null, [selectedStyleKey, styleDetails], ); + const selectedSupplementDraftSkuOption = useMemo(() => { + if (!selectedSupplementSourceSku?.sourceSkuId) return null; + const normalizedSourceSkuId = selectedSupplementSourceSku.sourceSkuId.trim(); + if (!normalizedSourceSkuId) return null; + + return ( + seriesDraftSkuOptions.find((option) => option.id === normalizedSourceSkuId) ?? + null + ); + }, [selectedSupplementSourceSku?.sourceSkuId, seriesDraftSkuOptions]); + const effectiveModel3dPreviewNodes = useMemo(() => { + const skuPreviewNodes = Array.isArray( + selectedSupplementSourceSku?.model3dPreviewNodes, + ) + ? selectedSupplementSourceSku.model3dPreviewNodes + : []; + if (skuPreviewNodes.length > 0) return skuPreviewNodes; + + return Array.isArray(selectedSupplementDraftSkuOption?.model3dPreviewNodes) + ? selectedSupplementDraftSkuOption.model3dPreviewNodes + : []; + }, [ + selectedSupplementDraftSkuOption?.model3dPreviewNodes, + selectedSupplementSourceSku?.model3dPreviewNodes, + ]); + const effectivePreviewWorldNodes = useMemo( + () => readPreviewWorldNodes(effectiveModel3dPreviewNodes), + [effectiveModel3dPreviewNodes], + ); + const hasEffectivePreviewWorldNodes = effectivePreviewWorldNodes.length > 0; + const effectivePreviewMaterialAssignmentsFromSnapshot = useMemo( + () => readPreviewMaterialAssignments(effectiveModel3dPreviewNodes), + [effectiveModel3dPreviewNodes], + ); + const effectivePreviewHoleOverlayStateFromSnapshot = useMemo( + () => readPreviewHoleOverlayState(effectiveModel3dPreviewNodes), + [effectiveModel3dPreviewNodes], + ); const hasSkuOverrideModel = Boolean( selectedSupplementSourceSku?.model3dDataUrl?.trim(), ); + const isSeriesSkuModelReadOnly = + isSeriesDevelopment && !isComponentSupplementMode; + const canUploadSupplementModel = !isReadOnly && !isSeriesSkuModelReadOnly; + const canEditSupplementModelTransform = + canUploadSupplementModel && !hasEffectivePreviewWorldNodes; const effectiveModel3dFileName = selectedSupplementSourceSku?.model3dFileName?.trim() || selectedStyleModelFileName.trim(); @@ -12883,14 +13005,18 @@ export default function NonIntegratedDetailClient({ ); const previewHoleOverlayState = useMemo( () => - buildPreviewHoleOverlayState({ - holePoints: effectivePreviewHolePoints, - nodeOffsetMap: previewNodeOffsetMap, - activeNodeName: selectedPreviewNodeName, - snapTarget: previewActiveHoleSnapTarget, - }), + hasEffectivePreviewWorldNodes + ? effectivePreviewHoleOverlayStateFromSnapshot + : buildPreviewHoleOverlayState({ + holePoints: effectivePreviewHolePoints, + nodeOffsetMap: previewNodeOffsetMap, + activeNodeName: selectedPreviewNodeName, + snapTarget: previewActiveHoleSnapTarget, + }), [ + effectivePreviewHoleOverlayStateFromSnapshot, effectivePreviewHolePoints, + hasEffectivePreviewWorldNodes, previewActiveHoleSnapTarget, previewNodeOffsetMap, selectedPreviewNodeName, @@ -12977,6 +13103,11 @@ export default function NonIntegratedDetailClient({ materialFinishById, previewNodeNames, ]); + const supplementPreviewMaterialAssignments = + hasEffectivePreviewWorldNodes && + effectivePreviewMaterialAssignmentsFromSnapshot.length > 0 + ? effectivePreviewMaterialAssignmentsFromSnapshot + : previewMaterialAssignments; const selectedPreviewNodeMaterialId = (() => { if (activePreviewMaterialTargetNodeNames.length === 0) return ""; const firstMaterialId = @@ -13363,7 +13494,10 @@ export default function NonIntegratedDetailClient({ function isProductPricingRowSystemEntered( row: Pick, ) { - return (row.systemEnteredAt ?? "").trim().length > 0; + return ( + PRODUCT_PRICING_JST_PUSH_ENABLED && + (row.systemEnteredAt ?? "").trim().length > 0 + ); } const allProductPricingRowsComplete = productPricingRows.length > 0 && @@ -15275,6 +15409,9 @@ export default function NonIntegratedDetailClient({ ) { syncProductPatentRecordFromProduct(savedProduct); } + if (shouldUseRemoteAssetStorage()) { + window.dispatchEvent(new Event(NON_INTEGRATED_PRODUCTS_UPDATED)); + } onProductSavedRef.current?.(savedProduct); clearNonIntegratedBomDraftSnapshot(productId); lastPersistErrorMessageRef.current = ""; @@ -17601,7 +17738,7 @@ export default function NonIntegratedDetailClient({ setSaveMessageType("success"); } - function handleConfirmProductPricingPushRows( + async function handleConfirmProductPricingPushRows( rows: ProductPricingDisplayRow[], ) { if (!canUseProductPricingPushControls) return; @@ -17612,12 +17749,158 @@ export default function NonIntegratedDetailClient({ !isProductPricingRowSystemEntered(row), ); if (readyRows.length === 0) { - setSaveMessage("??????? SKU ????????? SKU ??????"); + setSaveMessage("请先填写要推送 SKU 的成本价,已录入的 SKU 不会重复推送"); setSaveMessageType("error"); return; } - setSaveMessage("????????????????"); - setSaveMessageType("error"); + if (!PRODUCT_PRICING_JST_PUSH_ENABLED) { + setSaveMessage("聚水潭接口未接入,暂不可确认推送"); + setSaveMessageType("error"); + return; + } + if (mode !== "edit" || !productId) { + setSaveMessage("当前产品还没有保存,暂不可推送聚水潭"); + setSaveMessageType("error"); + return; + } + + const currentPricingRowBySkuId = new Map( + pricingRows.map((row) => [row.skuId, row]), + ); + readyRows.forEach((row) => { + currentPricingRowBySkuId.set(row.skuId, { + skuId: row.skuId, + costPrice: row.costPrice.trim(), + productTag: row.productTag.trim(), + ...(row.systemEnteredAt.trim() + ? { systemEnteredAt: row.systemEnteredAt.trim() } + : {}), + }); + }); + const currentPricingRows = derivedSkuItems.flatMap((sku) => { + const row = currentPricingRowBySkuId.get(sku.id); + return row ? [row] : []; + }); + + try { + setSaveMessage( + `正在保存核价数据,并推送聚水潭测试环境;组合商品会自动整组推送...`, + ); + setSaveMessageType("success"); + + const saved = await persistEditedProduct({ + successMessage: "核价数据已保存,正在推送聚水潭测试环境", + errorFallbackMessage: "推送前保存核价数据失败", + overrides: { pricingRows: currentPricingRows }, + }); + if (!saved) return; + + const result = await pushSharedNonIntegratedProductPricingToJushuitan( + productId, + readyRows.map((row) => row.skuId), + ); + const pushedSkuIdSet = new Set(result.pushedSkuIds); + const virtualInventoryPlanBySkuId = new Map( + result.virtualInventoryPlans.map((item) => [item.skuId, item]), + ); + const pushedAt = new Date().toISOString(); + const pricingRowBySkuId = new Map( + currentPricingRows.map((row) => [row.skuId, row]), + ); + pushedSkuIdSet.forEach((skuId) => { + const row = pricingRowBySkuId.get(skuId); + if (!row) return; + const virtualInventoryPlan = virtualInventoryPlanBySkuId.get(row.skuId); + pricingRowBySkuId.set(row.skuId, { + ...row, + skuId: row.skuId, + costPrice: row.costPrice.trim(), + productTag: row.productTag.trim(), + systemEnteredAt: pushedAt, + ...(virtualInventoryPlan + ? { + jstVirtualInventoryStatus: virtualInventoryPlan.status, + jstVirtualInventoryScheduledAt: + virtualInventoryPlan.scheduledAt ?? "", + jstVirtualInventoryError: virtualInventoryPlan.reason ?? "", + jstVirtualInventoryPushedAt: "", + jstVirtualInventorySoId: "", + } + : {}), + }); + }); + const nextPricingRows = derivedSkuItems.flatMap((sku) => { + const row = pricingRowBySkuId.get(sku.id); + return row ? [row] : []; + }); + const nextProductPricingCanBeConfirmed = + nextPricingRows.length > 0 && + nextPricingRows.every( + (row) => + isProductPricingRowComplete(row) && + isProductPricingRowSystemEntered(row), + ); + const nextApprovedAt = nextProductPricingCanBeConfirmed + ? { + ...documentWorkspaceApprovedAt, + [PRODUCT_PRICING_WORKSPACE_CATEGORY]: pushedAt, + } + : documentWorkspaceApprovedAt; + const nextConfirmations = nextProductPricingCanBeConfirmed + ? { + ...documentWorkspaceConfirmations, + [PRODUCT_PRICING_WORKSPACE_CATEGORY]: true, + } + : documentWorkspaceConfirmations; + const virtualInventorySkipSummary = + result.virtualInventorySkipped.length > 0 + ? `;虚拟库存跳过原因:${result.virtualInventorySkipped + .slice(0, 2) + .map( + (item) => + `${item.skuCode || item.barcode}(${item.warehouse || "未填仓库"}:${item.reason})`, + ) + .join("、")}${ + result.virtualInventorySkipped.length > 2 + ? ` 等 ${result.virtualInventorySkipped.length} 条` + : "" + }` + : ""; + const pushSuccessMessage = `聚水潭推送成功:普通商品 ${result.normalItemCount} 条,组合商品 ${result.combineItemCount} 条,虚拟库存待推 ${result.virtualInventoryPendingCount} 条,跳过 ${result.virtualInventorySkippedCount} 条${virtualInventorySkipSummary}`; + + setPricingRows(nextPricingRows); + if (nextProductPricingCanBeConfirmed) { + setDocumentWorkspaceConfirmations(nextConfirmations); + setDocumentWorkspaceApprovedAt(nextApprovedAt); + if (documentWorkspaceRevisionSignatureRef.current) { + documentWorkspaceRevisionSignatureRef.current = { + ...documentWorkspaceRevisionSignatureRef.current, + [PRODUCT_PRICING_WORKSPACE_CATEGORY]: + JSON.stringify(nextPricingRows), + }; + } + } + await persistEditedProduct({ + successMessage: nextProductPricingCanBeConfirmed + ? `${pushSuccessMessage};产品核价已完成` + : pushSuccessMessage, + errorFallbackMessage: "聚水潭推送成功,但保存录入状态失败", + overrides: { + pricingRows: nextPricingRows, + ...(nextProductPricingCanBeConfirmed + ? { + documentWorkspaceConfirmations: nextConfirmations, + documentWorkspaceApprovedAt: nextApprovedAt, + } + : {}), + }, + }); + } catch (error) { + setSaveMessage( + error instanceof Error ? error.message : "聚水潭测试推送失败", + ); + setSaveMessageType("error"); + } } function handleBatchConfirmProductPricingPush() { @@ -18152,7 +18435,7 @@ export default function NonIntegratedDetailClient({ } if (displayedProductFactoryHasSystemEnteredPricingRows) { - setSaveMessage("当前批次已有 SKU 录入系统,产品定厂不能重新确认"); + setSaveMessage("当前批次已有 SKU 推送到聚水潭,产品定厂不能重新确认"); setSaveMessageType("error"); return; } @@ -19681,7 +19964,7 @@ export default function NonIntegratedDetailClient({ } if (!onOpenProductPatentLedger) { - setSaveMessage("请从正式产品页面进入后再查看产品专利明细"); + setSaveMessage("请从产品资料页面进入后再查看产品专利明细"); setSaveMessageType("error"); return; } @@ -20923,7 +21206,7 @@ export default function NonIntegratedDetailClient({ } async function applyModel3dFile(file: File) { - if (isReadOnly) return; + if (!canUploadSupplementModel) return; const lowerName = file.name.toLowerCase(); if ( !lowerName.endsWith(".glb") && @@ -20968,6 +21251,7 @@ export default function NonIntegratedDetailClient({ model3dDataUrl: nextDataUrl, model3dNodeOffsets: {}, model3dNodeRotations: {}, + model3dPreviewNodes: [], model3dUvConfig: null, } : item, @@ -20993,7 +21277,7 @@ export default function NonIntegratedDetailClient({ } async function handleModel3dChange(event: ChangeEvent) { - if (isReadOnly) return; + if (!canUploadSupplementModel) return; const file = event.target.files?.[0]; if (!file) return; @@ -21376,8 +21660,9 @@ export default function NonIntegratedDetailClient({ ? item.packageGroupId !== targetItem.packageGroupId : item.id !== id, ); - const normalizedNextItems = - nextItems.length > 0 ? nextItems : [createEmptySkuItem()]; + const normalizedNextItems = resequenceSkuItems( + nextItems.length > 0 ? nextItems : [createEmptySkuItem()], + ); const nextBatchCount = Math.max( 1, ...normalizedNextItems.map((item) => item.batchNo || 1), @@ -25114,6 +25399,40 @@ export default function NonIntegratedDetailClient({ {value || "-"}
); + const getVirtualInventoryStatusMeta = (row: ProductPricingDisplayRow) => { + switch (row.jstVirtualInventoryStatus) { + case "pending": + return { + label: "库存待推", + tone: "bg-[#e7f1ff] text-[#1764ad]", + title: row.jstVirtualInventoryScheduledAt + ? `计划 ${row.jstVirtualInventoryScheduledAt} 推送` + : "已生成虚拟库存待推送", + }; + case "pushed": + return { + label: "库存已推", + tone: "bg-[#dff7e8] text-[#137a38]", + title: row.jstVirtualInventoryPushedAt + ? `已于 ${row.jstVirtualInventoryPushedAt} 推送` + : "虚拟库存已推送", + }; + case "failed": + return { + label: "库存失败", + tone: "bg-[#ffe3df] text-[#b42318]", + title: row.jstVirtualInventoryError || "虚拟库存推送失败", + }; + case "skipped": + return { + label: "无需库存", + tone: "bg-apple-bg-secondary text-apple-label-secondary", + title: row.jstVirtualInventoryError || "无需推虚拟库存", + }; + default: + return null; + } + }; const adjustCostPrice = (row: ProductPricingDisplayRow, offset: number) => { if (isReadOnly || isProductPricingNodeLocked) return; const currentValue = Number(row.costPrice); @@ -25174,6 +25493,8 @@ export default function NonIntegratedDetailClient({ isProductPricingRowSystemEntered(row); const pricingReadyForFuturePush = row.costPrice.trim().length > 0; + const virtualInventoryStatusMeta = + getVirtualInventoryStatusMeta(row); return ( -
+
{isSystemEntered ? "已录入" : "未录入"} + {virtualInventoryStatusMeta ? ( + + {virtualInventoryStatusMeta.label} + + ) : null} {canUseProductPricingPushControls ? ( - ); - })} -
{isStockProductCreate ? (
- - void handleModel3dChange(event) - } - /> + {canUploadSupplementModel ? ( + + void handleModel3dChange(event) + } + /> + ) : null} { + if (!canEditSupplementModelTransform) { + setPreviewNodeNames(nodeNames); + return; + } + setPreviewNodeNames(nodeNames); setPreviewNodeOffsetMap((current) => { const validNodeNameSet = new Set(nodeNames); @@ -30627,13 +30958,14 @@ function renderFactoryQualityWorkspace() { }} onNodeSelect={handlePreviewNodeSelect} onNodeContextMenu={ + canEditSupplementModelTransform && canEditPreviewModelMaterial && model3dPreviewUrl ? handlePreviewNodeContextMenu : undefined } overlayTopRight={ - isReadOnly ? null : ( + canUploadSupplementModel ? ( - ) + ) : null } overlayBottomCenter={ effectiveModel3dFileName ? ( @@ -31548,7 +31880,7 @@ function renderFactoryQualityWorkspace() { } title={ displayedProductFactoryHasSystemEnteredPricingRows - ? "当前批次已有 SKU 录入系统,产品定厂不能重新确认" + ? "当前批次已有 SKU 推送到聚水潭,产品定厂不能重新确认" : displayedProductFactoryRows.length === 0 ? "当前没有可重新确认的产品定厂信息" : displayedProductFactoryHasPendingRows @@ -31673,7 +32005,7 @@ function renderFactoryQualityWorkspace() { displayedProductPricingRowsAllSystemEntered ? "当前批次已完成推送" : !PRODUCT_PRICING_JST_PUSH_ENABLED - ? "外部系统接口未接入,暂不可批量确认推送" + ? "聚水潭接口未接入,暂不可批量确认推送" : !displayedProductPricingRowsComplete ? "当前批次成本价未填写完成" : "批量确认推送当前批次 SKU" diff --git a/furniture-3d-system/src/app/intake/non-integrated/[id]/page.tsx b/furniture-3d-system/src/app/intake/non-integrated/[id]/page.tsx index 179f0e63..dd1c648c 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/[id]/page.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/[id]/page.tsx @@ -7,6 +7,8 @@ const WORKSPACE_BY_QUERY = { 'opening-reference': '开品参考', } as const +const DETAIL_VIEW_WORKSPACES = new Set(['basic-info']) + export default async function NonIntegratedDetailPage({ params, searchParams, @@ -25,6 +27,11 @@ export default async function NonIntegratedDetailPage({ resolvedSearchParams.workspace as keyof typeof WORKSPACE_BY_QUERY ] : undefined + const initialDocumentWorkspaceView = + resolvedSearchParams.workspace && + DETAIL_VIEW_WORKSPACES.has(resolvedSearchParams.workspace) + ? 'detail' + : undefined const decodedId = decodeURIComponent(id) const [initialProduct, materialLibraryFinishes] = await Promise.all([ intakeNonIntegratedProductService.getById(decodedId, authUser), @@ -37,6 +44,7 @@ export default async function NonIntegratedDetailPage({ productId={decodedId} initialProduct={initialProduct} initialDocumentWorkspaceCategory={initialDocumentWorkspaceCategory} + initialDocumentWorkspaceView={initialDocumentWorkspaceView} currentUserRole={authUser.role} currentUserId={authUser.id} currentUserName={authUser.name} diff --git a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts index b4c42e1b..7031c2f1 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts @@ -277,6 +277,49 @@ export async function saveSharedNonIntegratedProduct( return data.product } +export type JushuitanProductPricingPushResponse = { + pushedSkuIds: string[] + normalItemCount: number + combineItemCount: number + virtualInventoryCount: number + virtualInventoryPendingCount: number + virtualInventorySkippedCount: number + virtualInventorySkipped: Array<{ + skuId?: string + skuCode: string + barcode: string + warehouse: string + reason: string + }> + virtualInventoryPlans: Array<{ + skuId: string + skuCode: string + barcode: string + warehouse: string + status: "pending" | "skipped" | "failed" + scheduledAt?: string + reason?: string + }> +} + +export async function pushSharedNonIntegratedProductPricingToJushuitan( + productId: string, + targetSkuIds: string[], +) { + const response = await fetchWithTimeout( + `/api/intake/non-integrated/${encodeURIComponent(productId)}/jushuitan-product-pricing-push`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ targetSkuIds }), + }, + ) + + return readApiData(response) +} + export async function deleteSharedNonIntegratedProduct(id: string) { const response = await fetchWithTimeout(`/api/intake/non-integrated/${encodeURIComponent(id)}`, { method: 'DELETE', diff --git a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts index aa9dd74e..ddc7e029 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts @@ -366,6 +366,7 @@ export type NonIntegratedSkuItem = { model3dNodeRotations?: Record; model3dNodeMaterialIds?: Record; model3dUvConfig?: AssetVariantUvConfig | null; + model3dPreviewNodes?: unknown[]; componentDetails?: NonIntegratedSkuComponentDetail[]; holePoints?: NonIntegratedSkuHolePoint[]; source?: string; @@ -425,6 +426,11 @@ export type NonIntegratedPricingRow = { costPrice: string; productTag: string; systemEnteredAt?: string; + jstVirtualInventoryStatus?: "pending" | "pushed" | "failed" | "skipped"; + jstVirtualInventoryScheduledAt?: string; + jstVirtualInventoryPushedAt?: string; + jstVirtualInventoryError?: string; + jstVirtualInventorySoId?: string; }; export type NonIntegratedProductLimitRow = { @@ -1338,6 +1344,9 @@ function normalizeSkuItem( model3dNodeRotations: normalizeTupleMap(item.model3dNodeRotations, 4), model3dNodeMaterialIds: normalizeStringRecord(item.model3dNodeMaterialIds), model3dUvConfig: normalizeAssetVariantUvConfig(item.model3dUvConfig), + model3dPreviewNodes: Array.isArray(item.model3dPreviewNodes) + ? item.model3dPreviewNodes + : [], componentDetails: normalizeNonIntegratedSkuComponentDetails( item.componentDetails, ), @@ -1391,6 +1400,23 @@ function normalizePricingRow( ): NonIntegratedPricingRow | null { if (typeof item.skuId !== "string") return null; const systemEnteredAt = normalizeString(item.systemEnteredAt); + const jstVirtualInventoryStatus = + item.jstVirtualInventoryStatus === "pending" || + item.jstVirtualInventoryStatus === "pushed" || + item.jstVirtualInventoryStatus === "failed" || + item.jstVirtualInventoryStatus === "skipped" + ? item.jstVirtualInventoryStatus + : undefined; + const jstVirtualInventoryScheduledAt = normalizeString( + item.jstVirtualInventoryScheduledAt, + ); + const jstVirtualInventoryPushedAt = normalizeString( + item.jstVirtualInventoryPushedAt, + ); + const jstVirtualInventoryError = normalizeString( + item.jstVirtualInventoryError, + ); + const jstVirtualInventorySoId = normalizeString(item.jstVirtualInventorySoId); return { skuId: item.skuId, @@ -1399,9 +1425,16 @@ function normalizePricingRow( ? item.costPrice : typeof item.costPrice === "number" && Number.isFinite(item.costPrice) ? String(item.costPrice) - : "", + : "", productTag: normalizeString(item.productTag), ...(systemEnteredAt ? { systemEnteredAt } : {}), + ...(jstVirtualInventoryStatus ? { jstVirtualInventoryStatus } : {}), + ...(jstVirtualInventoryScheduledAt + ? { jstVirtualInventoryScheduledAt } + : {}), + ...(jstVirtualInventoryPushedAt ? { jstVirtualInventoryPushedAt } : {}), + ...(jstVirtualInventoryError ? { jstVirtualInventoryError } : {}), + ...(jstVirtualInventorySoId ? { jstVirtualInventorySoId } : {}), }; } @@ -2552,6 +2585,9 @@ export function createCopiedNonIntegratedProduct( ? { ...item.model3dNodeMaterialIds } : {}, model3dUvConfig: normalizeAssetVariantUvConfig(item.model3dUvConfig), + model3dPreviewNodes: Array.isArray(item.model3dPreviewNodes) + ? [...item.model3dPreviewNodes] + : [], }; }), factoryRows: [], diff --git a/furniture-3d-system/src/app/intake/page.tsx b/furniture-3d-system/src/app/intake/page.tsx index 5b2e24b2..8f48e35e 100644 --- a/furniture-3d-system/src/app/intake/page.tsx +++ b/furniture-3d-system/src/app/intake/page.tsx @@ -9,7 +9,11 @@ import { materialService } from '@/server/services/material.service' import { warehouseService } from '@/server/services/warehouse.service' import IntakeClient from './IntakeClient' import { readPreSkuComponentDetails, readPreSkuSkuFields } from './seriesComponentDetails' -import type { NonIntegratedSkuHolePoint } from './non-integrated/nonIntegratedStorage' +import { + NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES, + type NonIntegratedProductRecord, + type NonIntegratedSkuHolePoint, +} from './non-integrated/nonIntegratedStorage' function readPreSkuTextField( preSku: { @@ -163,6 +167,27 @@ function readSingleSearchParam( return typeof value === 'string' ? value : '' } +function readDraftGroupSeriesNameForIntake(draftGroup: { + assetSeriesName?: string | null + assetSeriesCode?: string | null + name?: string | null +}) { + const seriesName = draftGroup.assetSeriesName?.trim() || '' + const seriesCode = draftGroup.assetSeriesCode?.trim() || '' + + if (seriesName && seriesCode) return `${seriesName} ${seriesCode}` + return seriesName || seriesCode || draftGroup.name?.trim() || '' +} + +function appendInitialOpenProduct( + products: NonIntegratedProductRecord[], + product: NonIntegratedProductRecord | null, +) { + if (!product) return products + if (products.some((item) => item.id === product.id)) return products + return [product, ...products] +} + export default async function IntakePage({ searchParams, }: { @@ -180,6 +205,10 @@ export default async function IntakePage({ rawMaterialLibraryParam === 'detailArchive' ? 'detailArchive' : 'processConfig' const initialRawMaterialName = readSingleSearchParam(params, 'material') const initialOpenNewNonIntegratedProduct = params.newProduct === '1' + const initialOpenProductId = readSingleSearchParam(params, 'openProductId') + const initialOpenProductMode: 'view' | 'edit' = + readSingleSearchParam(params, 'mode') === 'view' ? 'view' : 'edit' + const initialOpenProductWorkspaceParam = readSingleSearchParam(params, 'workspace') const [ draftGroups, @@ -190,6 +219,7 @@ export default async function IntakePage({ materialLibraryDictionaries, initialNonIntegratedProductPage, nodePermissionSettings, + initialOpenProduct, ] = await Promise.all([ draftGroupService.list(), warehouseService.listLocations(), @@ -203,8 +233,29 @@ export default async function IntakePage({ productDevelopmentKind: 'product', }), intakeNodePermissionSettingsService.getSettings(), + initialOpenProductId + ? intakeNonIntegratedProductService + .getById(initialOpenProductId, authUser) + .catch(() => null) + : Promise.resolve(null), ]) - const initialNonIntegratedProducts = initialNonIntegratedProductPage.products + const initialNonIntegratedProducts = appendInitialOpenProduct( + initialNonIntegratedProductPage.products, + initialOpenProduct, + ) + const initialOpenProductWorkspace = initialOpenProduct + ? { + product: initialOpenProduct, + mode: initialOpenProductMode, + detailState: + initialOpenProductWorkspaceParam === 'basic-info' + ? { + view: 'detail' as const, + category: NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES[0], + } + : undefined, + } + : undefined const eligibleDraftGroups = draftGroups.filter((group) => isDraftGroupEligibleForIntake(group.status), @@ -238,7 +289,7 @@ export default async function IntakePage({ name: preSku.customName || preSku.defaultName, seriesName: readPreSkuTextField(preSku, 'seriesName') || - detail.draftGroup.assetSeriesName || + readDraftGroupSeriesNameForIntake(detail.draftGroup) || detail.draftGroup.name, draftGroupName: detail.draftGroup.name, status: mapWorkbenchStatus(preSku.status), @@ -246,6 +297,9 @@ export default async function IntakePage({ componentDetails: readPreSkuComponentDetails(preSku), holePoints: buildSkuHolePointsFromVariantSelection(preSku), skuFields: readPreSkuSkuFields(preSku), + model3dPreviewNodes: preSku.previewNodes.length + ? preSku.previewNodes + : preSku.assemblyWorkspace?.nodes ?? [], })), ) @@ -261,6 +315,9 @@ export default async function IntakePage({ : [] const categories = await categoryService.listActive() + const initialSeriesNameOptions = selectedDetail + ? [readDraftGroupSeriesNameForIntake(selectedDetail.draftGroup)] + : [] if (!eligibleSelectedDetail) { return ( @@ -281,6 +338,8 @@ export default async function IntakePage({ initialActiveRawMaterialLibrary={initialActiveRawMaterialLibrary} initialRawMaterialName={initialRawMaterialName} initialOpenNewNonIntegratedProduct={initialOpenNewNonIntegratedProduct} + initialOpenProductWorkspace={initialOpenProductWorkspace} + initialSeriesNameOptions={initialSeriesNameOptions} /> ) } @@ -304,6 +363,8 @@ export default async function IntakePage({ initialActiveRawMaterialLibrary={initialActiveRawMaterialLibrary} initialRawMaterialName={initialRawMaterialName} initialOpenNewNonIntegratedProduct={initialOpenNewNonIntegratedProduct} + initialOpenProductWorkspace={initialOpenProductWorkspace} + initialSeriesNameOptions={initialSeriesNameOptions} /> ) } diff --git a/furniture-3d-system/src/app/intake/seriesComponentDetails.ts b/furniture-3d-system/src/app/intake/seriesComponentDetails.ts index d5b0ab6d..3d8e7c5b 100644 --- a/furniture-3d-system/src/app/intake/seriesComponentDetails.ts +++ b/furniture-3d-system/src/app/intake/seriesComponentDetails.ts @@ -4,6 +4,8 @@ import { } from "./non-integrated/nonIntegratedStorage"; export type SeriesDraftSkuFields = { + image?: string; + imageName?: string; model: string; skuCode: string; barcode: string; @@ -12,10 +14,20 @@ export type SeriesDraftSkuFields = { size: string; color: string; material: string; + boardMaterialType?: string; versionNo: string; attributeCode: string; drawingId: string; installMethod: string; + executeStandard?: string; + nationalStandardCompliant?: string; + manufacturer?: string; + manufacturerCode?: string; + salesStatus?: string; + batchNo?: string; + dingTalkFlowNo?: string; + tagMaterial?: string; + model3dFileName?: string; }; type PreSkuComponentDetailSource = { @@ -56,18 +68,37 @@ export function readPreSkuComponentDetails( return buildComponentDetailsFromCodes(preSku.componentCodes); } +function normalizePreSkuTextValue(value: unknown) { + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + + return ""; +} + function readPreSkuStringField( preSku: PreSkuComponentDetailSource, key: string, ) { const summaryValue = preSku.displaySummary?.[key]; - if (typeof summaryValue === "string" && summaryValue.trim().length > 0) { - return summaryValue.trim(); - } + const normalizedSummaryValue = normalizePreSkuTextValue(summaryValue); + if (normalizedSummaryValue) return normalizedSummaryValue; const familyValue = preSku.familySelection?.[key]; - if (typeof familyValue === "string" && familyValue.trim().length > 0) { - return familyValue.trim(); + return normalizePreSkuTextValue(familyValue); +} + +function readPreSkuFirstStringField( + preSku: PreSkuComponentDetailSource, + keys: string[], +) { + for (const key of keys) { + const value = readPreSkuStringField(preSku, key); + if (value) return value; } return ""; @@ -77,17 +108,42 @@ export function readPreSkuSkuFields( preSku: PreSkuComponentDetailSource, ): SeriesDraftSkuFields { return { + image: readPreSkuStringField(preSku, "image"), + imageName: readPreSkuStringField(preSku, "imageName"), model: readPreSkuStringField(preSku, "model"), - skuCode: readPreSkuStringField(preSku, "formalSkuCode"), + skuCode: readPreSkuFirstStringField(preSku, ["formalSkuCode", "skuCode"]), barcode: readPreSkuStringField(preSku, "barcode"), setCount: readPreSkuStringField(preSku, "setCount"), specName: readPreSkuStringField(preSku, "specName"), size: readPreSkuStringField(preSku, "size"), color: readPreSkuStringField(preSku, "color"), material: readPreSkuStringField(preSku, "material"), + boardMaterialType: readPreSkuStringField(preSku, "boardMaterialType"), versionNo: readPreSkuStringField(preSku, "versionNo"), attributeCode: readPreSkuStringField(preSku, "attributeCode"), drawingId: readPreSkuStringField(preSku, "drawingId"), installMethod: readPreSkuStringField(preSku, "installMethod"), + executeStandard: readPreSkuStringField(preSku, "executeStandard"), + nationalStandardCompliant: readPreSkuStringField( + preSku, + "nationalStandardCompliant", + ), + manufacturer: readPreSkuFirstStringField(preSku, [ + "manufacturer", + "supplierName", + ]), + manufacturerCode: readPreSkuFirstStringField(preSku, [ + "manufacturerCode", + "supplierCode", + ]), + salesStatus: readPreSkuStringField(preSku, "salesStatus"), + batchNo: readPreSkuStringField(preSku, "batchNo"), + dingTalkFlowNo: readPreSkuFirstStringField(preSku, [ + "dingTalkFlowNo", + "dingtalkFlowNo", + "flowNo", + ]), + tagMaterial: readPreSkuStringField(preSku, "tagMaterial"), + model3dFileName: readPreSkuStringField(preSku, "model3dFileName"), }; } diff --git a/furniture-3d-system/src/app/warehouse-quality/WarehouseQualityClient.tsx b/furniture-3d-system/src/app/warehouse-quality/WarehouseQualityClient.tsx new file mode 100644 index 00000000..929928d2 --- /dev/null +++ b/furniture-3d-system/src/app/warehouse-quality/WarehouseQualityClient.tsx @@ -0,0 +1,850 @@ +'use client' + +import { startTransition, useEffect, useMemo, useState } from 'react' +import { + ClipboardCheck, + Edit3, + Plus, + RefreshCw, + Save, + Search, +} from 'lucide-react' +import type { + SaveWarehouseQualityInspectionInput, + SkuQualityArchiveDTO, + SkuQualityInspectionOverviewDTO, + SkuQualityInspectionRecordDTO, +} from '@/server/services' + +type ApiEnvelope = { + success: boolean + data?: T + error?: { + message?: string + } +} + +type QualityFormState = { + batchNo: string + inspectedAt: string + inspectorName: string + conclusion: string + detail: string + inspectedQuantity: string + qualifiedQuantity: string +} + +type SpuQualityGroup = { + key: string + productId: string + productModelNumber: string + productName: string + skuArchives: SkuQualityArchiveDTO[] + firstBatchRecordCount: number + warehouseRecordCount: number + failedRecordCount: number +} + +const CONCLUSION_OPTIONS = ['待检', '合格', '让步接收', '返工', '不合格'] as const + +function createEmptyForm(inspectorName: string): QualityFormState { + return { + batchNo: '', + inspectedAt: new Date().toISOString().slice(0, 10), + inspectorName, + conclusion: '待检', + detail: '', + inspectedQuantity: '', + qualifiedQuantity: '', + } +} + +function formatDate(value: string | null) { + if (!value) return '未填日期' + return value.slice(0, 10) +} + +function formatRate(value: number | null) { + if (value === null) return '-' + return `${Math.round(value * 10000) / 100}%` +} + +function formatQuantity(value: number | null) { + return value === null ? '-' : value.toString() +} + +function getConclusionClass(conclusion: string) { + if (/不合格|返工|退货/.test(conclusion)) { + return 'border-red-200 bg-red-50 text-red-700' + } + if (/合格|让步接收/.test(conclusion)) { + return 'border-emerald-200 bg-emerald-50 text-emerald-700' + } + return 'border-amber-200 bg-amber-50 text-amber-700' +} + +function getSourceClass(sourceType: SkuQualityInspectionRecordDTO['sourceType']) { + return sourceType === 'FIRST_BATCH' + ? 'border-sky-200 bg-sky-50 text-sky-700' + : 'border-violet-200 bg-violet-50 text-violet-700' +} + +function isFailedRecord(record: SkuQualityInspectionRecordDTO | null) { + return Boolean(record && /不合格|返工|退货/.test(record.conclusion)) +} + +function archiveSearchText(archive: SkuQualityArchiveDTO) { + return [ + archive.skuCode, + archive.barcode, + archive.skuName, + archive.color, + archive.productName, + archive.productModelNumber, + archive.warehouseLocationCode, + archive.warehouseLocationName, + archive.firstBatchRecord?.batchNo, + archive.latestWarehouseRecord?.batchNo, + ] + .filter(Boolean) + .join(' ') + .toLowerCase() +} + +function groupArchivesBySpu(archives: SkuQualityArchiveDTO[]): SpuQualityGroup[] { + const groupMap = new Map() + + archives.forEach((archive) => { + const key = archive.productId || archive.productModelNumber + const existing = groupMap.get(key) + const group = + existing ?? + { + key, + productId: archive.productId, + productModelNumber: archive.productModelNumber, + productName: archive.productName, + skuArchives: [], + firstBatchRecordCount: 0, + warehouseRecordCount: 0, + failedRecordCount: 0, + } + + group.skuArchives.push(archive) + group.firstBatchRecordCount += archive.firstBatchRecord ? 1 : 0 + group.warehouseRecordCount += archive.warehouseRecordCount + group.failedRecordCount += isFailedRecord(archive.latestWarehouseRecord) + || isFailedRecord(archive.firstBatchRecord) + ? 1 + : 0 + + groupMap.set(key, group) + }) + + return Array.from(groupMap.values()).sort((a, b) => + a.productModelNumber.localeCompare(b.productModelNumber), + ) +} + +function getDisplayQualityRecord(archive: SkuQualityArchiveDTO) { + return archive.latestWarehouseRecord ?? archive.firstBatchRecord +} + +function renderSkuImage(archive: SkuQualityArchiveDTO) { + if (!archive.imageUrl) { + return ( +
+ 无图 +
+ ) + } + + return ( + {archive.skuName + ) +} + +function readBatchOrder(batchNo: string) { + const numeric = Number(batchNo.trim()) + return Number.isFinite(numeric) ? numeric : Number.MAX_SAFE_INTEGER +} + +function sortRecordsForDisplay(records: SkuQualityInspectionRecordDTO[]) { + return [...records].sort((a, b) => { + const sourceOrderA = a.sourceType === 'FIRST_BATCH' ? 0 : 1 + const sourceOrderB = b.sourceType === 'FIRST_BATCH' ? 0 : 1 + if (sourceOrderA !== sourceOrderB) return sourceOrderA - sourceOrderB + + const batchOrderA = readBatchOrder(a.batchNo) + const batchOrderB = readBatchOrder(b.batchNo) + if (batchOrderA !== batchOrderB) return batchOrderA - batchOrderB + + return a.createdAt.localeCompare(b.createdAt) + }) +} + +function calculateFormRate(form: QualityFormState) { + const inspectedQuantity = Number(form.inspectedQuantity) + const qualifiedQuantity = Number(form.qualifiedQuantity) + if ( + !Number.isFinite(inspectedQuantity) || + !Number.isFinite(qualifiedQuantity) || + inspectedQuantity <= 0 || + qualifiedQuantity < 0 || + qualifiedQuantity > inspectedQuantity + ) { + return '-' + } + + return formatRate(qualifiedQuantity / inspectedQuantity) +} + +async function requestJson(input: RequestInfo, init?: RequestInit): Promise { + const response = await fetch(input, { + ...init, + headers: { + 'Content-Type': 'application/json', + ...(init?.headers ?? {}), + }, + }) + const payload = (await response.json().catch(() => null)) as ApiEnvelope | null + + if (!response.ok || !payload?.success || payload.data === undefined) { + throw new Error(payload?.error?.message || '仓库质检请求失败') + } + + return payload.data +} + +function buildRecordInput( + selectedSkuId: string, + form: QualityFormState, +): SaveWarehouseQualityInspectionInput { + return { + skuId: selectedSkuId, + batchNo: form.batchNo, + inspectedAt: form.inspectedAt || null, + inspectorName: form.inspectorName, + conclusion: form.conclusion, + detail: form.detail, + inspectedQuantity: form.inspectedQuantity || null, + qualifiedQuantity: form.qualifiedQuantity || null, + } +} + +export default function WarehouseQualityClient({ + initialOverview, + currentUserName, +}: { + initialOverview: SkuQualityInspectionOverviewDTO + currentUserName: string +}) { + const initialGroup = groupArchivesBySpu(initialOverview.skuArchives)[0] + const initialSku = initialGroup?.skuArchives[0] + const [overview, setOverview] = useState(initialOverview) + const [search, setSearch] = useState('') + const [selectedGroupKey, setSelectedGroupKey] = useState(initialGroup?.key ?? '') + const [selectedSkuId, setSelectedSkuId] = useState(initialSku?.skuId ?? '') + const [form, setForm] = useState(() => + createEmptyForm(currentUserName), + ) + const [editingRecordId, setEditingRecordId] = useState(null) + const [isSaving, setIsSaving] = useState(false) + const [isRefreshing, setIsRefreshing] = useState(false) + const [message, setMessage] = useState('') + + const filteredArchives = useMemo(() => { + const keyword = search.trim().toLowerCase() + if (!keyword) return overview.skuArchives + return overview.skuArchives.filter((archive) => + archiveSearchText(archive).includes(keyword), + ) + }, [overview.skuArchives, search]) + + const spuGroups = useMemo( + () => groupArchivesBySpu(filteredArchives), + [filteredArchives], + ) + + const selectedGroup = + spuGroups.find((group) => group.key === selectedGroupKey) ?? spuGroups[0] ?? null + + const selectedArchive = + selectedGroup?.skuArchives.find((archive) => archive.skuId === selectedSkuId) ?? + selectedGroup?.skuArchives[0] ?? + null + + const records = useMemo( + () => sortRecordsForDisplay(selectedArchive?.records ?? []), + [selectedArchive], + ) + + useEffect(() => { + if (!selectedGroup && spuGroups[0]) { + setSelectedGroupKey(spuGroups[0].key) + setSelectedSkuId(spuGroups[0].skuArchives[0]?.skuId ?? '') + setEditingRecordId(null) + setForm(createEmptyForm(currentUserName)) + return + } + + if ( + selectedGroup && + !selectedGroup.skuArchives.some((archive) => archive.skuId === selectedSkuId) + ) { + setSelectedSkuId(selectedGroup.skuArchives[0]?.skuId ?? '') + setEditingRecordId(null) + setForm(createEmptyForm(currentUserName)) + } + }, [currentUserName, selectedGroup, selectedSkuId, spuGroups]) + + async function refreshOverview(nextSearch = search) { + setIsRefreshing(true) + setMessage('') + try { + const query = nextSearch.trim() + ? `?search=${encodeURIComponent(nextSearch.trim())}` + : '' + const data = await requestJson<{ overview: SkuQualityInspectionOverviewDTO }>( + `/api/warehouse-quality${query}`, + ) + setOverview(data.overview) + } catch (error) { + setMessage(error instanceof Error ? error.message : '刷新失败') + } finally { + setIsRefreshing(false) + } + } + + function resetForm() { + setEditingRecordId(null) + setForm(createEmptyForm(currentUserName)) + } + + function selectSku(archive: SkuQualityArchiveDTO) { + setSelectedSkuId(archive.skuId) + resetForm() + } + + function selectGroup(group: SpuQualityGroup) { + setSelectedGroupKey(group.key) + setSelectedSkuId(group.skuArchives[0]?.skuId ?? '') + resetForm() + } + + function editRecord(record: SkuQualityInspectionRecordDTO) { + if (record.sourceType !== 'WAREHOUSE') return + + setEditingRecordId(record.id) + setForm({ + batchNo: record.batchNo, + inspectedAt: record.inspectedAt?.slice(0, 10) ?? '', + inspectorName: record.inspectorName ?? currentUserName, + conclusion: record.conclusion, + detail: record.detail ?? '', + inspectedQuantity: record.inspectedQuantity?.toString() ?? '', + qualifiedQuantity: record.qualifiedQuantity?.toString() ?? '', + }) + } + + async function saveRecord() { + if (!selectedArchive) return + + setIsSaving(true) + setMessage('') + try { + const input = buildRecordInput(selectedArchive.skuId, form) + const endpoint = editingRecordId + ? `/api/warehouse-quality/records/${encodeURIComponent(editingRecordId)}` + : '/api/warehouse-quality' + await requestJson<{ record: SkuQualityInspectionRecordDTO }>(endpoint, { + method: editingRecordId ? 'PATCH' : 'POST', + body: JSON.stringify({ record: input }), + }) + await refreshOverview(search) + resetForm() + setMessage(editingRecordId ? '质检记录已更新' : '质检批次已追加') + } catch (error) { + setMessage(error instanceof Error ? error.message : '保存失败') + } finally { + setIsSaving(false) + } + } + + return ( +
+ + +
+
+
+
+
+ +

+ {selectedGroup?.productModelNumber ?? '未选择 SPU'} +

+
+ {selectedGroup ? ( +
+ {selectedGroup.productName} + {selectedArchive ? ( + <> + 当前 SKU:{selectedArchive.skuCode} + {selectedArchive.barcode ? {selectedArchive.barcode} : null} + + ) : null} +
+ ) : null} +
+
+
+
+ {selectedGroup?.skuArchives.length ?? 0} +
+
SKU
+
+
+
+ {selectedGroup?.firstBatchRecordCount ?? 0} +
+
首批
+
+
+
+ {selectedGroup?.warehouseRecordCount ?? 0} +
+
仓库
+
+
+
+
+ +
+ {!selectedGroup ? ( +
+ 暂无 SPU 档案 +
+ ) : ( +
+
+
+ SKU 明细 +
+
+ + + + + + + + + + + + + + + + + + + {selectedGroup.skuArchives.map((archive) => { + const displayRecord = getDisplayQualityRecord(archive) + const isActive = archive.skuId === selectedArchive?.skuId + return ( + selectSku(archive)} + className={`cursor-pointer align-top ${ + isActive ? 'bg-blue-50' : 'hover:bg-apple-bg-secondary' + }`} + > + + + + + + + + + + + + + + ) + })} + +
图SKU条码规格颜色质检批次结论检验数量合格数量合格率仓库记录操作
+ {renderSkuImage(archive)} + + {archive.skuCode} + + {archive.barcode || '-'} + +
{archive.skuName}
+
+ {archive.color || '-'} + + {displayRecord?.batchNo ?? '-'} + + {displayRecord ? ( + + {displayRecord.conclusion} + + ) : ( + - + )} + + {formatQuantity(displayRecord?.inspectedQuantity ?? null)} + + {formatQuantity(displayRecord?.qualifiedQuantity ?? null)} + + {formatRate(displayRecord?.qualifiedRate ?? null)} + + {archive.warehouseRecordCount} + + +
+
+
+ +
+
+
+ 当前 SKU 质检记录 +
+
+ {selectedArchive?.skuCode ?? '-'} · {records.length} 条 +
+
+ {records.length === 0 ? ( +
+ 当前 SKU 还没有质检记录 +
+ ) : ( +
+ + + + + + + + + + + + + + + + {records.map((record) => ( + + + + + + + + + + + + ))} + +
来源批次日期结论检验数量合格数量合格率说明操作
+ + {record.sourceLabel} + + + {record.batchNo} + + {formatDate(record.inspectedAt)} + + + {record.conclusion} + + + {formatQuantity(record.inspectedQuantity)} + + {formatQuantity(record.qualifiedQuantity)} + + {formatRate(record.qualifiedRate)} + +
{record.detail || '-'}
+
+ {record.sourceType === 'WAREHOUSE' ? ( + + ) : null} +
+
+ )} +
+
+ )} +
+
+ +