Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/xuxiake-assisted-assembly-ai-render

# Conflicts:
#	furniture-3d-system/src/app/draft-groups/DraftGroupsClient.tsx
This commit is contained in:
WuYongJie 2026-06-02 10:34:08 +08:00
commit 35fed448cf
52 changed files with 11687 additions and 292 deletions

View File

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

View File

@ -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])
}
// ============================================
// 用户管理
// ============================================

View File

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

View File

@ -111,7 +111,7 @@ describe('首页工作台数据注入', () => {
mockedGetProducts.mockClear()
})
it('应只把当前账号的草稿和正式产品注入首页工作台', async () => {
it('应只把当前账号的草稿和产品资料注入首页工作台', async () => {
render(await HomePage({ searchParams: Promise.resolve({}) }))
expect(screen.getByText('HomeClient')).toBeInTheDocument()

View File

@ -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',
})
})
})

View File

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

View File

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

View File

@ -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<typeof jushuitanProductPricingPushService.pushVirtualInventory>
>,
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 })
}

View File

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

View File

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

View File

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

View File

@ -248,7 +248,7 @@ function ApprovalWorkflowPageBody({ accessKey }: { accessKey: string }) {
<div>
<h2 className="text-sm font-black">这张流程单要补什么</h2>
<p className="mt-1 text-xs font-medium text-[#607073]">
下面这份清单按正式产品和仓库管理当前真实流程整理,先把该填的内容列完整。
下面这份清单按产品资料和仓库管理当前真实流程整理,先把该填的内容列完整。
</p>
</div>
<span className="rounded-[6px] bg-[#f3f5f5] px-2 py-1 text-xs font-black text-[#6b777a]">

View File

@ -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="用于资产中心和后续产品资料调用"
/>
</label>
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_132px]">

View File

@ -827,7 +827,7 @@ export default function DraftGroupsClient({ draftGroups, categories, user }: Dra
<span className="block text-[10px] font-black uppercase tracking-[0.14em] text-[var(--obs-shell-color)]/55">PRODUCT DATA</span>
<span className="mt-1 block text-[13px] font-black text-[var(--obs-heading-color)]">进入产品资料入库流程</span>
<span className="mt-1 block text-[11px] font-semibold text-[var(--obs-shell-muted)]/58">
带当前草稿组进入正式产品承接
带当前草稿组进入产品资料承接
</span>
</span>
<ArrowRight className="h-4 w-4 text-[var(--obs-shell-color)]" />

File diff suppressed because it is too large Load Diff

View File

@ -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 (
<DraftGroupDetailClient
initialDetail={detail}
categories={categories}
user={user}
metrics={[
{
label: '准 SKU',
value: detail.preSkus.length,
hint: '同组内多条候选统一承接',
icon: <Layers3 className="h-4 w-4" />,
},
{
label: '包装占位',
value: detail.preSkus.filter((item) => item.packagingPlan).length,
hint: '先按准 SKU 分别保存',
tone: 'info',
icon: <Box className="h-4 w-4" />,
},
{
label: '资料预览',
value: '已接入',
hint: 'BOM、重量、组件编码、安装步骤',
tone: 'success',
icon: <ClipboardCheck className="h-4 w-4" />,
},
{
label: '产品资料',
value: detail.draftGroup.status === 'FORMALIZED' ? '已完成' : '待确认',
hint: '按组内准 SKU 逐个确认',
tone: detail.draftGroup.status === 'FORMALIZED' ? 'success' : 'warning',
icon: <PackageSearch className="h-4 w-4" />,
},
]}
<DraftGroupsFormalClient
draftGroups={draftGroups}
initialDraftGroupId={id}
initialFormalSeriesProducts={initialFormalSeriesProducts}
pageTitle="草稿组"
showBackLink
/>
)
}

View File

@ -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 <DraftGroupsClient draftGroups={draftGroups} categories={categories} user={user} />
function readSingleSearchParam(
params: Record<string, string | string[] | undefined>,
key: string,
) {
const value = params[key]
return typeof value === 'string' ? value : ''
}
export default async function DraftGroupsPage({
searchParams,
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>
} = {}) {
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 (
<DraftGroupsFormalClient
draftGroups={draftGroups}
initialDraftGroupId={draftGroupId}
initialFormalSeriesProducts={initialFormalSeriesProducts}
pageTitle="草稿组"
showBackLink={false}
/>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -61,15 +61,15 @@ describe('alphabet library panels', () => {
})
})
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(<AccessoryLibraryPanel />)
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(<AccessoryLibraryPanel />)
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(<AccessoryLibraryPanel />)
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(<AccessoryLibraryPanel />)
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(<AccessoryLibraryPanel />)
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(<AccessoryLibraryPanel />)
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(<AccessoryLibraryPanel />)
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(<AccessoryLibraryPanel />)
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(<AccessoryLibraryPanel />)
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(<AccessoryLibraryPanel />)
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(<AccessoryLibraryPanel />)
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(<AccessoryLibraryPanel />)
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()

View File

@ -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[]
}) => (
<div data-testid="embedded-non-integrated-detail">
{productId}-{mode}
<span data-testid="embedded-development-kind">
{productDevelopmentKind}
</span>
<span data-testid="embedded-series-options">
{seriesNameOptions.join(',')}
</span>
<span data-testid="embedded-workspace-state">
{(initialDocumentWorkspaceView ?? 'detail')}-
{(initialDocumentWorkspaceCategory ?? '基础信息')}
@ -253,6 +258,15 @@ function renderSelector(props?: {
initialRawMaterialName?: string
initialNodePermissionOverrides?: IntakeNodePermissionOverrideMap
materialLibrarySubstrates?: Array<Record<string, unknown>>
initialSeriesNameOptions?: string[]
initialOpenProductWorkspace?: {
product: Record<string, unknown>
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()

View File

@ -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<NonIntegratedProductRecord>).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<NonIntegratedProductRecord>).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<Set<string>>(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 ||

View File

@ -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(
<NonIntegratedDetailClient
mode="edit"
productId="CZ0132-series-readonly-sku-model"
/>,
);
const getProductPreviewProps = () => {
const props = [...modelPreviewPanelMock.mock.calls]
.reverse()
.map(([callProps]) => callProps as Record<string, unknown>)
.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,

View File

@ -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<WorkflowNodeCategory, string> | 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<string, number>();
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<string, StyleDetailForm> = {};
@ -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<HTMLDivElement>(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<SkuTabKey>("skuInfo");
const [activeComponentSkuCategory, setActiveComponentSkuCategory] =
useState<ComponentSkuCategoryKey>("all");
const [createProductKind, setCreateProductKind] =
useState<CreateProductKind>("new");
useEffect(() => {
if (initialCreateProductKind !== "new") {
setCreateProductKind("new");
}
}, [initialCreateProductKind]);
const [createProductKind] = useState<CreateProductKind>("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<NonIntegratedPricingRow, "systemEnteredAt">,
) {
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<string, NonIntegratedPricingRow>(
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<string, NonIntegratedPricingRow>(
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<HTMLInputElement>) {
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 || "-"}
</div>
);
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 (
<tr
@ -25234,7 +25555,7 @@ export default function NonIntegratedDetailClient({
</div>
</td>
<td className="px-3 py-2">
<div className="flex min-w-[168px] items-center gap-2">
<div className="flex min-w-[236px] items-center gap-2">
<span
className={`inline-flex h-6 min-w-[52px] items-center justify-center rounded-full px-2 text-[11px] font-apple-medium ${
isSystemEntered
@ -25244,6 +25565,14 @@ export default function NonIntegratedDetailClient({
>
{isSystemEntered ? "已录入" : "未录入"}
</span>
{virtualInventoryStatusMeta ? (
<span
className={`inline-flex h-6 min-w-[64px] items-center justify-center rounded-full px-2 text-[11px] font-apple-medium ${virtualInventoryStatusMeta.tone}`}
title={virtualInventoryStatusMeta.title}
>
{virtualInventoryStatusMeta.label}
</span>
) : null}
{canUseProductPricingPushControls ? (
<button
type="button"
@ -25259,12 +25588,12 @@ export default function NonIntegratedDetailClient({
}
title={
isSystemEntered
? "当前 SKU 已录入系统,后续以系统状态为准"
? "当前 SKU 已录入系统,后续以聚水潭回传状态为准"
: PRODUCT_PRICING_JST_PUSH_ENABLED &&
pricingReadyForFuturePush
? "确认推送当前 SKU 到外部系统"
? "确认推送当前 SKU 到聚水潭测试环境"
: pricingReadyForFuturePush
? "外部系统接口未接入,暂不可确认推送"
? "聚水潭接口未接入,暂不可确认推送"
: "请先填写成本价"
}
className="inline-flex h-7 min-w-[52px] items-center justify-center rounded-full bg-[#1f8bff] px-3 text-xs font-apple-medium text-white shadow-sm transition hover:bg-[#1478df] disabled:cursor-not-allowed disabled:bg-apple-bg-tertiary disabled:text-apple-label-tertiary disabled:shadow-none"
@ -28601,32 +28930,6 @@ function renderFactoryQualityWorkspace() {
compact
>
<div className="rounded-apple-xl border border-dashed border-apple-separator bg-apple-bg-secondary/20 p-3">
<div className="mb-3 flex w-fit rounded-apple-lg border border-apple-separator bg-apple-bg-secondary p-1">
{[
{ id: "new", label: "新品开品" },
{ id: "stock", label: "存量商品" },
].map((item) => {
const isActive = createProductKind === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => {
setCreateProductKind(item.id as CreateProductKind);
setSaveMessage("");
}}
className={`h-8 rounded-apple-md px-4 text-xs transition ${
isActive
? "bg-zinc-950 text-white shadow-apple-xs"
: "text-apple-label-secondary hover:text-apple-label-primary"
}`}
>
{item.label}
</button>
);
})}
</div>
{isStockProductCreate ? (
<div className="grid gap-3 xl:grid-cols-[240px_minmax(0,1fr)]">
<UploadField
@ -30486,39 +30789,62 @@ function renderFactoryQualityWorkspace() {
data-testid="supplement-preview-panel"
className="flex min-h-[440px] self-start rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/20 p-2.5 xl:h-[510px] xl:min-h-0"
>
<input
ref={model3dInputRef}
type="file"
accept=".zip,.glb,.gltf,.obj,application/zip,model/gltf-binary,model/gltf+json,model/obj"
className="hidden"
onChange={(event) =>
void handleModel3dChange(event)
}
/>
{canUploadSupplementModel ? (
<input
ref={model3dInputRef}
type="file"
accept=".zip,.glb,.gltf,.obj,application/zip,model/gltf-binary,model/gltf+json,model/obj"
className="hidden"
onChange={(event) =>
void handleModel3dChange(event)
}
/>
) : null}
<ModelPreviewPanel
title="3D预览"
modelUrl={model3dPreviewUrl || null}
modelUrl={
hasEffectivePreviewWorldNodes
? null
: model3dPreviewUrl || null
}
worldNodes={
hasEffectivePreviewWorldNodes
? effectivePreviewWorldNodes
: undefined
}
emptyTitle={
isReadOnly ? "暂无3D模型" : "上传3D模型"
isSeriesSkuModelReadOnly
? "暂无承接模型"
: isReadOnly
? "暂无3D模型"
: "上传3D模型"
}
emptyDescription={
isReadOnly
? "当前只支持查看已有 3D 模型。"
: "支持上传 .zip / .glb / .gltf / .obj,上传后可在这里看小窗效果。"
isSeriesSkuModelReadOnly
? "当前 SKU 的模型由草稿组承接,这里只展示,不支持上传或替换。"
: isReadOnly
? "当前只支持查看已有 3D 模型。"
: "支持上传 .zip / .glb / .gltf / .obj,上传后可在这里看小窗效果。"
}
className="h-full min-h-0 flex-1"
showFpsBadge={false}
enableTranslateGizmo={!isReadOnly}
enableTranslateGizmo={
canEditSupplementModelTransform
}
gizmoMode={previewGizmoMode}
onGizmoModeChange={
isReadOnly ? undefined : setPreviewGizmoMode
canEditSupplementModelTransform
? setPreviewGizmoMode
: undefined
}
selectableNodeNames={previewNodeNames}
selectedNodeName={selectedPreviewNodeName}
selectedNodeNames={activeSelectedPreviewNodeNames}
nodePositionOffsets={previewNodeOffsets}
nodeRotationStates={previewNodeRotations}
materialAssignments={previewMaterialAssignments}
materialAssignments={
supplementPreviewMaterialAssignments
}
holes={
isPackagingModelPreviewActive
? []
@ -30541,26 +30867,31 @@ function renderFactoryQualityWorkspace() {
}
showHoleHighlights={!isPackagingModelPreviewActive}
onNodePositionOffsetChange={
isReadOnly
? undefined
: handlePreviewNodePositionOffsetChange
canEditSupplementModelTransform
? handlePreviewNodePositionOffsetChange
: undefined
}
onNodeRotationChange={
isReadOnly
? undefined
: handlePreviewNodeRotationChange
canEditSupplementModelTransform
? handlePreviewNodeRotationChange
: undefined
}
onGizmoInteractionStart={
isReadOnly
? undefined
: handlePreviewTransformStart
canEditSupplementModelTransform
? handlePreviewTransformStart
: undefined
}
onGizmoInteractionEnd={
isReadOnly
? undefined
: handlePreviewTransformEnd
canEditSupplementModelTransform
? handlePreviewTransformEnd
: undefined
}
onModelNodesChange={(nodeNames) => {
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 ? (
<DocumentWorkspaceActionButton
type="button"
size="small"
@ -30645,7 +30977,7 @@ function renderFactoryQualityWorkspace() {
? "替换3D模型"
: "上传3D模型"}
</DocumentWorkspaceActionButton>
)
) : 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"

View File

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

View File

@ -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<JushuitanProductPricingPushResponse>(response)
}
export async function deleteSharedNonIntegratedProduct(id: string) {
const response = await fetchWithTimeout(`/api/intake/non-integrated/${encodeURIComponent(id)}`, {
method: 'DELETE',

View File

@ -366,6 +366,7 @@ export type NonIntegratedSkuItem = {
model3dNodeRotations?: Record<string, [number, number, number, number]>;
model3dNodeMaterialIds?: Record<string, string>;
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: [],

View File

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

View File

@ -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"),
};
}

View File

@ -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<T> = {
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<string, SpuQualityGroup>()
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 (
<div className="flex h-12 w-12 items-center justify-center rounded-apple-lg border border-dashed border-apple-separator bg-apple-bg-secondary text-[10px] text-apple-label-tertiary">
无图
</div>
)
}
return (
<img
src={archive.imageUrl}
alt={archive.skuName || archive.skuCode}
className="h-12 w-12 rounded-apple-lg border border-apple-separator object-cover"
/>
)
}
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<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
const response = await fetch(input, {
...init,
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
})
const payload = (await response.json().catch(() => null)) as ApiEnvelope<T> | 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<QualityFormState>(() =>
createEmptyForm(currentUserName),
)
const [editingRecordId, setEditingRecordId] = useState<string | null>(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 (
<div className="flex h-full min-h-0 gap-3 overflow-hidden bg-apple-bg-secondary p-4">
<aside className="flex w-[270px] shrink-0 flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-primary shadow-apple-xs">
<div className="border-b border-apple-separator p-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h1 className="text-sm font-apple-semibold text-apple-label-primary">
仓库质检
</h1>
<p className="mt-0.5 text-[11px] text-apple-label-secondary">
{spuGroups.length} 个 SPU · {overview.summary.skuCount} 个 SKU ·{' '}
{overview.summary.warehouseRecordCount} 条仓库记录
</p>
</div>
<button
type="button"
onClick={() => {
startTransition(() => {
void refreshOverview(search)
})
}}
className="flex h-8 w-8 items-center justify-center rounded-apple-lg border border-apple-separator text-apple-label-secondary hover:bg-apple-bg-secondary"
title="刷新"
>
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
</button>
</div>
<label className="mt-2.5 flex h-8 items-center gap-2 rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-2.5">
<Search className="h-4 w-4 text-apple-label-tertiary" />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索 SPU / SKU / 条码"
className="min-w-0 flex-1 bg-transparent text-[11px] text-apple-label-primary outline-none placeholder:text-apple-label-tertiary"
/>
</label>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{spuGroups.length === 0 ? (
<div className="flex h-full items-center justify-center rounded-apple-lg border border-dashed border-apple-separator p-6 text-center text-xs text-apple-label-secondary">
暂无正式 SPU
</div>
) : (
<div className="space-y-1.5">
{spuGroups.map((group) => {
const isActive = group.key === selectedGroup?.key
return (
<button
key={group.key}
type="button"
onClick={() => selectGroup(group)}
className={`w-full rounded-apple-lg border px-2.5 py-2 text-left transition ${
isActive
? 'border-apple-blue bg-blue-50'
: 'border-apple-separator bg-apple-bg-primary hover:bg-apple-bg-secondary'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-[13px] font-apple-semibold text-apple-label-primary">
{group.productModelNumber}
</div>
<div className="mt-0.5 truncate text-[11px] text-apple-label-secondary">
{group.productName}
</div>
</div>
<span className="rounded-full border border-apple-separator bg-apple-bg-primary px-1.5 py-0.5 text-[10px] text-apple-label-secondary">
{group.skuArchives.length} SKU
</span>
</div>
<div className="mt-1.5 flex flex-wrap gap-1.5 text-[10px] text-apple-label-secondary">
<span>首批 {group.firstBatchRecordCount}</span>
<span>仓库 {group.warehouseRecordCount}</span>
{group.failedRecordCount > 0 ? (
<span className="text-red-600">异常 {group.failedRecordCount}</span>
) : null}
</div>
</button>
)
})}
</div>
)}
</div>
</aside>
<main className="flex min-w-0 flex-1 flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-primary shadow-apple-xs">
<div className="border-b border-apple-separator p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<ClipboardCheck className="h-5 w-5 text-apple-blue" />
<h2 className="truncate text-lg font-apple-semibold text-apple-label-primary">
{selectedGroup?.productModelNumber ?? '未选择 SPU'}
</h2>
</div>
{selectedGroup ? (
<div className="mt-2 flex flex-wrap gap-2 text-xs text-apple-label-secondary">
<span>{selectedGroup.productName}</span>
{selectedArchive ? (
<>
<span>当前 SKU:{selectedArchive.skuCode}</span>
{selectedArchive.barcode ? <span>{selectedArchive.barcode}</span> : null}
</>
) : null}
</div>
) : null}
</div>
<div className="grid grid-cols-3 gap-2 text-center text-xs">
<div className="rounded-apple-lg bg-apple-bg-secondary px-3 py-2">
<div className="font-apple-semibold text-apple-label-primary">
{selectedGroup?.skuArchives.length ?? 0}
</div>
<div className="mt-0.5 text-apple-label-secondary">SKU</div>
</div>
<div className="rounded-apple-lg bg-apple-bg-secondary px-3 py-2">
<div className="font-apple-semibold text-apple-label-primary">
{selectedGroup?.firstBatchRecordCount ?? 0}
</div>
<div className="mt-0.5 text-apple-label-secondary">首批</div>
</div>
<div className="rounded-apple-lg bg-apple-bg-secondary px-3 py-2">
<div className="font-apple-semibold text-apple-label-primary">
{selectedGroup?.warehouseRecordCount ?? 0}
</div>
<div className="mt-0.5 text-apple-label-secondary">仓库</div>
</div>
</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{!selectedGroup ? (
<div className="flex h-full items-center justify-center rounded-apple-lg border border-dashed border-apple-separator text-sm text-apple-label-secondary">
暂无 SPU 档案
</div>
) : (
<div className="space-y-4">
<div className="overflow-hidden rounded-apple-lg border border-apple-separator">
<div className="border-b border-apple-separator bg-apple-bg-secondary px-3 py-2 text-xs font-apple-semibold text-apple-label-primary">
SKU 明细
</div>
<div className="overflow-x-auto">
<table className="min-w-[1060px] text-left text-xs">
<thead className="bg-apple-bg-secondary text-apple-label-secondary">
<tr>
<th className="w-16 px-3 py-2 font-apple-medium">图</th>
<th className="px-3 py-2 font-apple-medium">SKU</th>
<th className="px-3 py-2 font-apple-medium">条码</th>
<th className="px-3 py-2 font-apple-medium">规格</th>
<th className="px-3 py-2 font-apple-medium">颜色</th>
<th className="px-3 py-2 font-apple-medium">质检批次</th>
<th className="px-3 py-2 font-apple-medium">结论</th>
<th className="px-3 py-2 font-apple-medium">检验数量</th>
<th className="px-3 py-2 font-apple-medium">合格数量</th>
<th className="px-3 py-2 font-apple-medium">合格率</th>
<th className="px-3 py-2 font-apple-medium">仓库记录</th>
<th className="w-20 px-3 py-2 font-apple-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-apple-separator">
{selectedGroup.skuArchives.map((archive) => {
const displayRecord = getDisplayQualityRecord(archive)
const isActive = archive.skuId === selectedArchive?.skuId
return (
<tr
key={archive.skuId}
onClick={() => selectSku(archive)}
className={`cursor-pointer align-top ${
isActive ? 'bg-blue-50' : 'hover:bg-apple-bg-secondary'
}`}
>
<td className="px-3 py-2">
{renderSkuImage(archive)}
</td>
<td className="px-3 py-2 font-apple-medium text-apple-label-primary">
{archive.skuCode}
</td>
<td className="px-3 py-2 text-apple-label-secondary">
{archive.barcode || '-'}
</td>
<td className="max-w-[210px] px-3 py-2 text-apple-label-secondary">
<div className="line-clamp-2">{archive.skuName}</div>
</td>
<td className="px-3 py-2 text-apple-label-secondary">
{archive.color || '-'}
</td>
<td className="px-3 py-2 text-apple-label-primary">
{displayRecord?.batchNo ?? '-'}
</td>
<td className="px-3 py-2">
{displayRecord ? (
<span
className={`rounded-full border px-2 py-0.5 ${getConclusionClass(
displayRecord.conclusion,
)}`}
>
{displayRecord.conclusion}
</span>
) : (
<span className="text-apple-label-tertiary">-</span>
)}
</td>
<td className="px-3 py-2 text-apple-label-secondary">
{formatQuantity(displayRecord?.inspectedQuantity ?? null)}
</td>
<td className="px-3 py-2 text-apple-label-secondary">
{formatQuantity(displayRecord?.qualifiedQuantity ?? null)}
</td>
<td className="px-3 py-2 text-apple-label-secondary">
{formatRate(displayRecord?.qualifiedRate ?? null)}
</td>
<td className="px-3 py-2 text-apple-label-secondary">
{archive.warehouseRecordCount}
</td>
<td className="px-3 py-2">
<button
type="button"
onClick={(event) => {
event.stopPropagation()
selectSku(archive)
}}
className="flex h-8 items-center gap-1.5 rounded-apple-lg border border-apple-separator px-2 text-xs text-apple-label-secondary hover:bg-apple-bg-primary"
>
<Plus className="h-3.5 w-3.5" />
追加
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
<div className="overflow-hidden rounded-apple-lg border border-apple-separator">
<div className="flex items-center justify-between border-b border-apple-separator bg-apple-bg-secondary px-3 py-2">
<div className="text-xs font-apple-semibold text-apple-label-primary">
当前 SKU 质检记录
</div>
<div className="text-xs text-apple-label-secondary">
{selectedArchive?.skuCode ?? '-'} · {records.length} 条
</div>
</div>
{records.length === 0 ? (
<div className="flex h-28 items-center justify-center text-sm text-apple-label-secondary">
当前 SKU 还没有质检记录
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-[760px] text-left text-xs">
<thead className="bg-apple-bg-secondary text-apple-label-secondary">
<tr>
<th className="px-3 py-2 font-apple-medium">来源</th>
<th className="px-3 py-2 font-apple-medium">批次</th>
<th className="px-3 py-2 font-apple-medium">日期</th>
<th className="px-3 py-2 font-apple-medium">结论</th>
<th className="px-3 py-2 font-apple-medium">检验数量</th>
<th className="px-3 py-2 font-apple-medium">合格数量</th>
<th className="px-3 py-2 font-apple-medium">合格率</th>
<th className="px-3 py-2 font-apple-medium">说明</th>
<th className="w-16 px-3 py-2 font-apple-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-apple-separator">
{records.map((record) => (
<tr key={record.id} className="align-top">
<td className="px-3 py-2">
<span
className={`rounded-full border px-2 py-0.5 ${getSourceClass(
record.sourceType,
)}`}
>
{record.sourceLabel}
</span>
</td>
<td className="px-3 py-2 text-apple-label-primary">
{record.batchNo}
</td>
<td className="px-3 py-2 text-apple-label-secondary">
{formatDate(record.inspectedAt)}
</td>
<td className="px-3 py-2">
<span
className={`rounded-full border px-2 py-0.5 ${getConclusionClass(
record.conclusion,
)}`}
>
{record.conclusion}
</span>
</td>
<td className="px-3 py-2 text-apple-label-secondary">
{formatQuantity(record.inspectedQuantity)}
</td>
<td className="px-3 py-2 text-apple-label-secondary">
{formatQuantity(record.qualifiedQuantity)}
</td>
<td className="px-3 py-2 text-apple-label-secondary">
{formatRate(record.qualifiedRate)}
</td>
<td className="max-w-[220px] px-3 py-2 text-apple-label-secondary">
<div className="line-clamp-2">{record.detail || '-'}</div>
</td>
<td className="px-3 py-2">
{record.sourceType === 'WAREHOUSE' ? (
<button
type="button"
onClick={() => editRecord(record)}
className="flex h-8 w-8 items-center justify-center rounded-apple-lg border border-apple-separator text-apple-label-secondary hover:bg-apple-bg-secondary"
title="编辑"
>
<Edit3 className="h-3.5 w-3.5" />
</button>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
</div>
</main>
<aside className="flex w-[320px] shrink-0 flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-primary shadow-apple-xs">
<div className="border-b border-apple-separator p-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h2 className="text-base font-apple-semibold text-apple-label-primary">
{editingRecordId ? '编辑仓库批次' : '追加仓库批次'}
</h2>
<p className="mt-1 truncate text-xs text-apple-label-secondary">
{selectedArchive?.skuCode ?? '未选择 SKU'}
</p>
</div>
<button
type="button"
onClick={resetForm}
className="flex h-8 items-center gap-1.5 rounded-apple-lg border border-apple-separator px-2.5 text-xs text-apple-label-secondary hover:bg-apple-bg-secondary"
>
<Plus className="h-3.5 w-3.5" />
新增
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{selectedArchive ? (
<div className="mb-4 rounded-apple-lg bg-apple-bg-secondary px-3 py-2 text-xs text-apple-label-secondary">
<div className="font-apple-medium text-apple-label-primary">
{selectedArchive.productModelNumber}
</div>
<div className="mt-1 truncate">{selectedArchive.skuName}</div>
<div className="mt-1 truncate">{selectedArchive.barcode || '未生成条码'}</div>
</div>
) : null}
<div className="space-y-3">
<label className="block space-y-1.5">
<span className="text-xs text-apple-label-secondary">质检批次</span>
<input
value={form.batchNo}
onChange={(event) => setForm({ ...form, batchNo: event.target.value })}
className="h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-xs text-apple-label-primary outline-none"
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs text-apple-label-secondary">质检日期</span>
<input
type="date"
value={form.inspectedAt}
onChange={(event) => setForm({ ...form, inspectedAt: event.target.value })}
className="h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-xs text-apple-label-primary outline-none"
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs text-apple-label-secondary">质检人</span>
<input
value={form.inspectorName}
onChange={(event) => setForm({ ...form, inspectorName: event.target.value })}
className="h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-xs text-apple-label-primary outline-none"
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs text-apple-label-secondary">质检结论</span>
<select
value={form.conclusion}
onChange={(event) => setForm({ ...form, conclusion: event.target.value })}
className="h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-xs text-apple-label-primary outline-none"
>
{CONCLUSION_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<div className="grid grid-cols-2 gap-2">
<label className="block space-y-1.5">
<span className="text-xs text-apple-label-secondary">检验数量</span>
<input
value={form.inspectedQuantity}
onChange={(event) =>
setForm({ ...form, inspectedQuantity: event.target.value })
}
inputMode="numeric"
className="h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-xs text-apple-label-primary outline-none"
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs text-apple-label-secondary">合格数量</span>
<input
value={form.qualifiedQuantity}
onChange={(event) =>
setForm({ ...form, qualifiedQuantity: event.target.value })
}
inputMode="numeric"
className="h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-xs text-apple-label-primary outline-none"
/>
</label>
</div>
<div className="flex items-center justify-between rounded-apple-lg bg-apple-bg-secondary px-3 py-2 text-xs">
<span className="text-apple-label-secondary">合格率</span>
<span className="font-apple-semibold text-apple-label-primary">
{calculateFormRate(form)}
</span>
</div>
<label className="block space-y-1.5">
<span className="text-xs text-apple-label-secondary">质检说明</span>
<textarea
value={form.detail}
onChange={(event) => setForm({ ...form, detail: event.target.value })}
rows={6}
className="w-full resize-none rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 py-2 text-xs leading-5 text-apple-label-primary outline-none"
/>
</label>
</div>
</div>
<div className="border-t border-apple-separator p-4">
{message ? (
<div className="mb-3 rounded-apple-lg bg-apple-bg-secondary px-3 py-2 text-xs text-apple-label-secondary">
{message}
</div>
) : null}
<button
type="button"
disabled={!selectedArchive || isSaving}
onClick={() => {
startTransition(() => {
void saveRecord()
})
}}
className="flex h-10 w-full items-center justify-center gap-2 rounded-apple-lg bg-apple-blue px-3 text-sm font-apple-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
<Save className="h-4 w-4" />
{isSaving ? '保存中' : editingRecordId ? '保存修改' : '追加记录'}
</button>
</div>
</aside>
</div>
)
}

View File

@ -1,13 +1,10 @@
import { requireSession } from '@/lib/server-session'
import { skuQualityInspectionService } from '@/server/services'
import WarehouseQualityClient from './WarehouseQualityClient'
export default async function WarehouseQualityPage() {
await requireSession('/login')
const { user } = await requireSession('/login')
const overview = await skuQualityInspectionService.getOverview()
return (
<div className="flex h-full min-h-0 gap-3 overflow-hidden p-4">
<section className="min-w-0 flex-1 rounded-apple-xl border border-apple-separator bg-apple-bg-primary shadow-apple-xs" />
<aside className="w-[220px] shrink-0 rounded-apple-xl border border-apple-separator bg-apple-bg-primary shadow-apple-xs" />
</div>
)
return <WarehouseQualityClient initialOverview={overview} currentUserName={user.name} />
}

View File

@ -99,7 +99,7 @@ describe('AppShell', () => {
expect(screen.getByText('assisted assembly content')).toBeInTheDocument()
})
it('正式产品、仓库管理和仓库质检使用顶部工作台导航', () => {
it('产品资料、仓库管理和仓库质检使用顶部工作台导航', () => {
usePathnameMock.mockReturnValue('/intake')
const { rerender } = render(

View File

@ -15,7 +15,7 @@ describe('intake node permission settings', () => {
'江苏简尚品牌管理有限公司 > 品牌中心 > 空间视觉部',
],
}),
).toEqual(['brandCenter', 'space'])
).toEqual(['space'])
expect(
resolveIntakeNodePermissionSubjectKeys({
@ -29,6 +29,16 @@ describe('intake node permission settings', () => {
}),
).toEqual(['space'])
expect(
resolveIntakeNodePermissionSubjectKeys({
role: 'USER',
businessRoles: [],
departmentPaths: [
'江苏简尚品牌管理有限公司 > 品牌中心 > 商品企划部 > 桌类',
],
}),
).toEqual(['planning'])
expect(
resolveIntakeNodePermissionSubjectKeys({
role: 'USER',
@ -41,7 +51,7 @@ describe('intake node permission settings', () => {
})
it('uses the same matrix for default view, download, and admin-opened overrides', () => {
const spaceKeys = ['brandCenter', 'space'] as const
const spaceKeys = ['space'] as const
expect(
resolveIntakeNodePermissionAccess({}, 'planningLayout', spaceKeys),
).toMatchObject({
@ -98,12 +108,12 @@ describe('intake node permission settings', () => {
const overrides = {
[buildIntakeNodePermissionOverrideKey(
'productData',
'space',
'brandCenter',
'view',
)]: true,
[buildIntakeNodePermissionOverrideKey(
'productData',
'space',
'brandCenter',
'download',
)]: true,
}
@ -111,8 +121,8 @@ describe('intake node permission settings', () => {
resolveIntakeNodePermissionAccess(overrides, 'productData', spaceKeys),
).toMatchObject({
defaultState: 'hidden',
view: true,
download: true,
view: false,
download: false,
edit: false,
})
})

View File

@ -248,7 +248,7 @@ export const CORE_NODE_PERMISSION_SUBJECTS: IntakeNodePermissionSubject[] = [
{
key: "brandCenter",
name: "品牌中心",
department: "品牌中心-事业部 / 策划 / 企划",
department: "品牌中心-事业部 / 策划(不含商品企划、空间视觉)",
},
{
key: "planning",
@ -320,7 +320,7 @@ export const TOP_ENTRY_PERMISSION_TARGETS: IntakeNodePermissionTarget[] = [
},
{
key: "formalProducts",
label: "正式产品",
label: "产品资料",
defaults: NODE_PERMISSION_FORMAL_PRODUCTS_DEFAULT,
},
{
@ -804,20 +804,26 @@ export function resolveIntakeNodePermissionSubjectKeys(
user: IntakeNonIntegratedPermissionUser,
) {
const keys: IntakeNodePermissionSubjectKey[] = [];
const isBrandPlanningUser = hasDepartmentPath(user, isBrandPlanningDepartmentPath);
const isSpaceVisualUser = hasDepartmentPath(user, isSpaceVisualDepartmentPath);
if (hasDepartmentPath(user, isProductResearchDepartmentPath)) {
pushUniqueSubject(keys, "design");
}
if (hasDepartmentPath(user, isBrandCenterDepartmentPath)) {
if (
hasDepartmentPath(user, isBrandCenterDepartmentPath) &&
!isBrandPlanningUser &&
!isSpaceVisualUser
) {
pushUniqueSubject(keys, "brandCenter");
}
if (hasDepartmentPath(user, isBrandPlanningDepartmentPath)) {
if (isBrandPlanningUser) {
pushUniqueSubject(keys, "planning");
}
if (hasDepartmentPath(user, isSpaceVisualDepartmentPath)) {
if (isSpaceVisualUser) {
pushUniqueSubject(keys, "space");
}

View File

@ -147,6 +147,10 @@ export {
export type { DraftGroupWithRelations, PreSkuWithRelations } from './draft-group.repository'
export { warehouseLocationRepository, WarehouseLocationRepositoryClass } from './warehouse.repository'
export {
skuQualityInspectionRepository,
SkuQualityInspectionRepositoryClass,
} from './sku-quality-inspection.repository'
export {
intakeNonIntegratedProductRepository,
IntakeNonIntegratedProductRepositoryClass,

View File

@ -0,0 +1,54 @@
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
export type IntakeProductPricingCandidate = {
itemCode: string
barcode: string
costPrice: string | null
pricingConfirmed: boolean
pricingConfirmedAt: string | null
}
const PRODUCT_PRICING_CATEGORY = '产品核价'
class IntakeProductPricingQueryRepository {
async findByItemCodeOrBarcode(identifier: string) {
const normalizedIdentifier = identifier.trim().toUpperCase()
return prisma.$queryRaw<IntakeProductPricingCandidate[]>(Prisma.sql`
SELECT
COALESCE(sku ->> 'skuCode', '') AS "itemCode",
COALESCE(sku ->> 'barcode', '') AS barcode,
pricing."costPrice",
COALESCE(
product.payload #>> ARRAY['documentWorkspaceConfirmations', ${PRODUCT_PRICING_CATEGORY}],
'false'
) = 'true' AS "pricingConfirmed",
product.payload #>> ARRAY['documentWorkspaceApprovedAt', ${PRODUCT_PRICING_CATEGORY}]
AS "pricingConfirmedAt"
FROM "IntakeNonIntegratedProduct" product
CROSS JOIN LATERAL jsonb_array_elements(
COALESCE(product.payload -> 'skuItems', '[]'::jsonb)
) sku
LEFT JOIN LATERAL (
SELECT pricing_row ->> 'costPrice' AS "costPrice"
FROM jsonb_array_elements(
COALESCE(product.payload -> 'pricingRows', '[]'::jsonb)
) pricing_row
WHERE pricing_row ->> 'skuId' = sku ->> 'id'
LIMIT 1
) pricing ON TRUE
WHERE product."deletedAt" IS NULL
AND (
UPPER(BTRIM(COALESCE(sku ->> 'skuCode', ''))) = ${normalizedIdentifier}
OR UPPER(BTRIM(COALESCE(sku ->> 'barcode', ''))) = ${normalizedIdentifier}
)
LIMIT 2
`)
}
}
export const intakeProductPricingQueryRepository =
new IntakeProductPricingQueryRepository()
export { IntakeProductPricingQueryRepository }

View File

@ -0,0 +1,362 @@
import { Prisma, ProductSKU, ProductSkuQualityInspection } from '@prisma/client'
import { prisma } from '@/lib/prisma'
type FormalProductSourceInput = {
modelNumber: string
name: string
thumbnailUrl?: string | null
createdBy: string
}
type FormalSkuSourceInput = {
productId: string
skuCode: string
attributes: Prisma.InputJsonValue
images: string[]
modelUrl?: string | null
ownerId: string
}
export type SkuQualityArchiveRecord = ProductSKU & {
product: {
id: string
name: string
modelNumber: string
}
warehouseLocation: {
id: string
code: string
name: string
} | null
qualityInspections: ProductSkuQualityInspection[]
}
export type SkuQualityInspectionRecordWithSku = ProductSkuQualityInspection & {
sku: ProductSKU & {
product: {
id: string
name: string
modelNumber: string
}
warehouseLocation: {
id: string
code: string
name: string
} | null
}
}
class SkuQualityInspectionRepositoryClass {
async findSkuArchives(search?: string): Promise<SkuQualityArchiveRecord[]> {
const normalizedSearch = search?.trim()
const where: Prisma.ProductSKUWhereInput = {
deletedAt: null,
isActive: true,
...(normalizedSearch
? {
OR: [
{ skuCode: { contains: normalizedSearch, mode: 'insensitive' } },
{
product: {
name: { contains: normalizedSearch, mode: 'insensitive' },
},
},
{
product: {
modelNumber: { contains: normalizedSearch, mode: 'insensitive' },
},
},
{
attributes: {
path: ['barcode'],
equals: normalizedSearch,
},
},
{
attributes: {
path: ['assetSkuSnapshot', 'barcode'],
equals: normalizedSearch,
},
},
],
}
: {}),
}
return prisma.productSKU.findMany({
where,
orderBy: [{ updatedAt: 'desc' }],
include: {
product: {
select: {
id: true,
name: true,
modelNumber: true,
},
},
warehouseLocation: {
select: {
id: true,
code: true,
name: true,
},
},
qualityInspections: {
orderBy: [{ inspectedAt: 'desc' }, { createdAt: 'desc' }],
},
},
})
}
async findSkuById(skuId: string) {
return prisma.productSKU.findFirst({
where: {
id: skuId,
deletedAt: null,
isActive: true,
},
include: {
product: {
select: {
id: true,
name: true,
modelNumber: true,
},
},
warehouseLocation: {
select: {
id: true,
code: true,
name: true,
},
},
},
})
}
async findSkuByCodeOrBarcode(input: {
skuCodes: string[]
barcode?: string | null
}) {
const skuCodes = Array.from(
new Set(input.skuCodes.map((code) => code.trim()).filter(Boolean)),
)
for (const skuCode of skuCodes) {
const sku = await prisma.productSKU.findFirst({
where: {
skuCode,
deletedAt: null,
isActive: true,
},
})
if (sku) return sku
}
const barcode = input.barcode?.trim()
if (!barcode) return null
return (
(await prisma.productSKU.findFirst({
where: {
deletedAt: null,
isActive: true,
attributes: {
path: ['assetSkuSnapshot', 'barcode'],
equals: barcode,
},
},
})) ??
(await prisma.productSKU.findFirst({
where: {
deletedAt: null,
isActive: true,
attributes: {
path: ['barcode'],
equals: barcode,
},
},
}))
)
}
async findActiveOwnerCandidate(preferredUserId?: string | null) {
const trimmedUserId = preferredUserId?.trim()
if (trimmedUserId) {
const preferredUser = await prisma.user.findFirst({
where: {
id: trimmedUserId,
status: 'ACTIVE',
},
select: {
id: true,
name: true,
},
})
if (preferredUser) return preferredUser
}
return (
(await prisma.user.findFirst({
where: {
status: 'ACTIVE',
role: { in: ['SUPER_ADMIN', 'ADMIN'] },
},
orderBy: [{ updatedAt: 'desc' }],
select: {
id: true,
name: true,
},
})) ??
prisma.user.findFirst({
where: {
status: 'ACTIVE',
},
orderBy: [{ updatedAt: 'desc' }],
select: {
id: true,
name: true,
},
})
)
}
async findProductByModelNumber(modelNumber: string) {
return prisma.product.findUnique({
where: { modelNumber },
select: {
id: true,
name: true,
modelNumber: true,
deletedAt: true,
},
})
}
async ensureProductFromFirstQualitySource(input: FormalProductSourceInput) {
const existing = await this.findProductByModelNumber(input.modelNumber)
if (existing) {
if (existing.deletedAt) {
return prisma.product.update({
where: { id: existing.id },
data: {
deletedAt: null,
name: input.name,
thumbnailUrl: input.thumbnailUrl ?? null,
},
select: {
id: true,
name: true,
modelNumber: true,
},
})
}
return {
id: existing.id,
name: existing.name,
modelNumber: existing.modelNumber,
}
}
return prisma.product.create({
data: {
modelNumber: input.modelNumber,
name: input.name,
thumbnailUrl: input.thumbnailUrl ?? null,
visibility: 'TEAM',
createdBy: input.createdBy,
},
select: {
id: true,
name: true,
modelNumber: true,
},
})
}
async createSkuFromFirstQualitySource(input: FormalSkuSourceInput) {
try {
return await prisma.productSKU.create({
data: {
productId: input.productId,
skuCode: input.skuCode,
attributes: input.attributes,
price: 0,
costPrice: null,
stock: 0,
images: input.images,
modelUrl: input.modelUrl ?? null,
ownerId: input.ownerId,
isActive: true,
},
})
} catch (error) {
if (
typeof error === 'object' &&
error &&
'code' in error &&
(error as { code?: string }).code === 'P2002'
) {
const existing = await this.findSkuByCodeOrBarcode({
skuCodes: [input.skuCode],
})
if (existing) return existing
}
throw error
}
}
async upsertByRecordKey(
data: Prisma.ProductSkuQualityInspectionUncheckedCreateInput,
) {
const { recordKey, ...updateData } = data
return prisma.productSkuQualityInspection.upsert({
where: { recordKey },
create: data,
update: updateData,
})
}
async findRecordByIdWithSku(id: string): Promise<SkuQualityInspectionRecordWithSku | null> {
return prisma.productSkuQualityInspection.findUnique({
where: { id },
include: {
sku: {
include: {
product: {
select: {
id: true,
name: true,
modelNumber: true,
},
},
warehouseLocation: {
select: {
id: true,
code: true,
name: true,
},
},
},
},
},
})
}
async updateRecord(
id: string,
data: Prisma.ProductSkuQualityInspectionUncheckedUpdateInput,
) {
return prisma.productSkuQualityInspection.update({
where: { id },
data,
})
}
}
export const skuQualityInspectionRepository =
new SkuQualityInspectionRepositoryClass()
export { SkuQualityInspectionRepositoryClass }

View File

@ -14,16 +14,39 @@ jest.mock('../../repositories/intake-non-integrated-product.repository', () => (
},
}))
jest.mock('../../repositories/draft-group.repository', () => ({
draftGroupRepository: {
findByIdWithRelations: jest.fn(),
countPackagedItems: jest.fn(),
countPreSkuLocationBindings: jest.fn(),
delete: jest.fn(),
},
preSkuRepository: {
findByIdWithRelations: jest.fn(),
},
}))
jest.mock('../intake-node-permission-settings.service', () => ({
intakeNodePermissionSettingsService: {
getSettings: jest.fn().mockResolvedValue({ overrides: {} }),
},
}))
jest.mock('../sku-quality-inspection.service', () => ({
skuQualityInspectionService: {
syncFirstBatchFromNonIntegratedProduct: jest
.fn()
.mockResolvedValue({ matchedCount: 0, syncedCount: 0 }),
},
}))
import { intakeNonIntegratedProductRepository } from '../../repositories/intake-non-integrated-product.repository'
import { draftGroupRepository, preSkuRepository } from '../../repositories/draft-group.repository'
import { intakeNonIntegratedProductService } from '../intake-non-integrated-product.service'
const mockedRepository = jest.mocked(intakeNonIntegratedProductRepository)
const mockedDraftGroupRepository = jest.mocked(draftGroupRepository)
const mockedPreSkuRepository = jest.mocked(preSkuRepository)
const baseUser = {
id: 'creator-1',
@ -91,6 +114,11 @@ function createRecord(overrides: Record<string, unknown> = {}) {
describe('intakeNonIntegratedProductService', () => {
beforeEach(() => {
jest.clearAllMocks()
mockedDraftGroupRepository.countPackagedItems.mockResolvedValue(0)
mockedDraftGroupRepository.countPreSkuLocationBindings.mockResolvedValue(0)
mockedDraftGroupRepository.delete.mockResolvedValue({} as never)
mockedDraftGroupRepository.findByIdWithRelations.mockResolvedValue(null as never)
mockedPreSkuRepository.findByIdWithRelations.mockResolvedValue(null as never)
})
it('lists all shared records when permissions are relaxed', async () => {
@ -123,6 +151,88 @@ describe('intakeNonIntegratedProductService', () => {
expect(product?.spu).toBe('DNZ0001')
})
it('hydrates pushed series SKU 3D model snapshots from the source pre SKU when reading details', async () => {
const carriedNodes = [
{
nodeId: 'desktop-node',
role: 'desktop',
label: '桌板',
assetFamilyId: 'family-desktop',
variantId: 'variant-desktop',
code: 'DBZ-SPJ00001',
name: '单板桌桌板',
modelUrl: '/models/desktop.glb',
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
]
mockedRepository.findActiveById.mockResolvedValue(
createRecord({
id: 'series-product-1',
spu: 'CZ0132',
payload: createPayload({
id: 'series-product-1',
spu: 'CZ0132',
productDevelopmentKind: 'series',
skuItems: [
{
id: 'sku-1',
skuSequence: 1,
batchNo: 1,
packageGroupId: '',
packageSuffix: '',
image: '',
imageName: '',
model: '单板桌01',
skuCode: 'CZ013201',
setCount: '1',
specName: '',
barcode: 'CZ013201N1280V01',
size: '',
color: '',
material: '',
attributeCode: '',
drawingId: '',
installMethod: '',
executeStandard: '',
nationalStandardCompliant: '',
manufacturer: '',
manufacturerCode: 'N1280',
salesStatus: '',
tagMaterial: '',
model3dFileName: '',
model3dDataUrl: '',
model3dNodeOffsets: {},
model3dNodeRotations: {},
model3dPreviewNodes: [],
sourceSkuId: 'pre-sku-1',
},
],
}),
}) as never,
)
mockedPreSkuRepository.findByIdWithRelations.mockResolvedValue({
id: 'pre-sku-1',
draftGroupId: 'draft-group-1',
previewNodes: [],
displaySummary: {
assemblyWorkspace: {
nodes: carriedNodes,
},
},
} as never)
const product = await intakeNonIntegratedProductService.getById(
'series-product-1',
baseUser,
)
expect(mockedPreSkuRepository.findByIdWithRelations).toHaveBeenCalledWith('pre-sku-1')
expect(product?.skuItems[0]?.model3dPreviewNodes).toEqual(carriedNodes)
})
it('writes scoped department and team fields when creating a record', async () => {
const payload = createPayload({ id: 'non-integrated-create' })
mockedRepository.findBySpu.mockResolvedValue(null as never)
@ -1254,7 +1364,10 @@ describe('intakeNonIntegratedProductService', () => {
const savedProduct = await intakeNonIntegratedProductService.save(
payload,
baseUser,
{
...baseUser,
role: 'SUPER_ADMIN',
},
)
const updatePayload = mockedRepository.update.mock.calls[0]?.[1]?.payload as
| Record<string, unknown>
@ -1268,6 +1381,117 @@ describe('intakeNonIntegratedProductService', () => {
)
})
it('deletes the source draft group after a pushed series SKU is removed from product data', async () => {
const existingPayload = createPayload({
productDevelopmentKind: 'series',
spu: 'CZ0132',
skuItems: [
{
id: 'sku-1',
skuSequence: 1,
batchNo: 1,
packageGroupId: '',
packageSuffix: '',
image: '',
imageName: '',
model: '单板桌01',
skuCode: 'CZ013201',
setCount: '1',
specName: '',
barcode: 'CZ013201N1280V01',
size: '',
color: '',
material: '',
attributeCode: '',
drawingId: '',
installMethod: '',
executeStandard: '',
nationalStandardCompliant: '',
manufacturer: '',
manufacturerCode: 'N1280',
tagMaterial: '',
model3dFileName: '',
model3dDataUrl: '',
model3dNodeOffsets: {},
model3dNodeRotations: {},
sourceSkuId: 'pre-sku-1',
},
{
id: 'sku-2',
skuSequence: 2,
batchNo: 1,
packageGroupId: '',
packageSuffix: '',
image: '',
imageName: '',
model: '单板桌02',
skuCode: 'CZ013202',
setCount: '1',
specName: '',
barcode: 'CZ013202N1280V01',
size: '',
color: '',
material: '',
attributeCode: '',
drawingId: '',
installMethod: '',
executeStandard: '',
nationalStandardCompliant: '',
manufacturer: '',
manufacturerCode: 'N1280',
tagMaterial: '',
model3dFileName: '',
model3dDataUrl: '',
model3dNodeOffsets: {},
model3dNodeRotations: {},
sourceSkuId: 'pre-sku-2',
},
],
})
const incomingPayload = createPayload({
...existingPayload,
skuItems: [
{
...(existingPayload.skuItems as Array<Record<string, unknown>>)[1],
skuSequence: 1,
skuCode: 'CZ013201',
barcode: 'CZ013201N1280V01',
},
],
})
mockedRepository.findBySpu.mockResolvedValue(null as never)
mockedRepository.findById.mockResolvedValue(
createRecord({
payload: existingPayload,
}) as never,
)
mockedRepository.update.mockResolvedValue(createRecord() as never)
mockedRepository.findActiveById.mockResolvedValue(
createRecord({
payload: incomingPayload,
}) as never,
)
mockedPreSkuRepository.findByIdWithRelations.mockResolvedValue({
id: 'pre-sku-1',
draftGroupId: 'draft-group-1',
} as never)
mockedDraftGroupRepository.findByIdWithRelations.mockResolvedValue({
id: 'draft-group-1',
status: 'DRAFT',
} as never)
await intakeNonIntegratedProductService.save(incomingPayload, {
...baseUser,
role: 'SUPER_ADMIN',
})
expect(mockedPreSkuRepository.findByIdWithRelations).toHaveBeenCalledWith('pre-sku-1')
expect(mockedDraftGroupRepository.countPackagedItems).toHaveBeenCalledWith('draft-group-1')
expect(mockedDraftGroupRepository.countPreSkuLocationBindings).toHaveBeenCalledWith('draft-group-1')
expect(mockedDraftGroupRepository.delete).toHaveBeenCalledWith('draft-group-1')
})
it('allows deleting a shared record when permissions are relaxed', async () => {
mockedRepository.findActiveById.mockResolvedValue(createRecord() as never)
mockedRepository.softDeleteByUser.mockResolvedValue({} as never)

View File

@ -352,7 +352,7 @@ describe('intakeService', () => {
})
})
it('CREATE_PRODUCT 模式会创建新的正式产品并回写默认信息', async () => {
it('CREATE_PRODUCT 模式会创建新的产品资料并回写默认信息', async () => {
mockedDraftGroupRepository.findByIdWithRelations.mockResolvedValue({
id: 'draft-group-1',
name: '云序桌系第一组',

View File

@ -0,0 +1,240 @@
jest.mock('../../repositories/sku-quality-inspection.repository', () => ({
skuQualityInspectionRepository: {
findSkuArchives: jest.fn(),
findSkuById: jest.fn(),
findSkuByCodeOrBarcode: jest.fn(),
findActiveOwnerCandidate: jest.fn(),
ensureProductFromFirstQualitySource: jest.fn(),
createSkuFromFirstQualitySource: jest.fn(),
upsertByRecordKey: jest.fn(),
findRecordByIdWithSku: jest.fn(),
updateRecord: jest.fn(),
},
}))
import { skuQualityInspectionRepository } from '../../repositories/sku-quality-inspection.repository'
import { skuQualityInspectionService } from '../sku-quality-inspection.service'
const mockedRepository = jest.mocked(skuQualityInspectionRepository)
const qualityUser = {
id: 'quality-user-1',
email: 'quality@example.com',
name: '质检同事',
role: 'USER' as const,
status: 'ACTIVE' as const,
departmentPaths: ['江苏简尚品牌管理有限公司 > 供应链中心 > 品牌 采购部 > 质检组'],
}
function createSavedRecord(overrides: Record<string, unknown> = {}) {
return {
id: 'quality-record-1',
skuId: 'sku-1',
recordKey: 'sku-1::WAREHOUSE::-::-::WH-001',
sourceType: 'WAREHOUSE',
sourceProductId: null,
sourceProductSpu: null,
sourceSkuItemId: null,
batchNo: 'WH-001',
inspectedAt: new Date('2026-05-30T00:00:00.000Z'),
inspectorName: '质检同事',
conclusion: '合格',
detail: '首批仓库到货通过',
inspectedQuantity: 10,
qualifiedQuantity: 9,
qualifiedRate: 0.9,
metrics: null,
attachments: [],
status: 'RECORDED',
createdBy: 'quality-user-1',
updatedBy: 'quality-user-1',
createdAt: new Date('2026-05-30T01:00:00.000Z'),
updatedAt: new Date('2026-05-30T01:00:00.000Z'),
...overrides,
}
}
describe('skuQualityInspectionService', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('saves warehouse records by SKU and batch', async () => {
mockedRepository.findSkuById.mockResolvedValue({ id: 'sku-1' } as never)
mockedRepository.upsertByRecordKey.mockResolvedValue(createSavedRecord() as never)
mockedRepository.findRecordByIdWithSku.mockResolvedValue(null)
const record = await skuQualityInspectionService.saveWarehouseRecord(
{
skuId: 'sku-1',
batchNo: 'WH-001',
inspectedAt: '2026-05-30',
conclusion: '合格',
detail: '首批仓库到货通过',
inspectedQuantity: '10',
qualifiedQuantity: '9',
},
qualityUser,
)
expect(record.batchNo).toBe('WH-001')
expect(record.qualifiedRate).toBe(0.9)
expect(mockedRepository.upsertByRecordKey).toHaveBeenCalledWith(
expect.objectContaining({
skuId: 'sku-1',
sourceType: 'WAREHOUSE',
batchNo: 'WH-001',
recordKey: 'sku-1::WAREHOUSE::-::-::WH-001',
}),
)
})
it('syncs first quality rows into formal SKU archive when a SKU can be matched', async () => {
mockedRepository.findSkuByCodeOrBarcode.mockResolvedValue({ id: 'sku-1' } as never)
mockedRepository.upsertByRecordKey.mockResolvedValue(
createSavedRecord({
sourceType: 'FIRST_BATCH',
batchNo: 'FQ-001',
inspectedAt: new Date('2026-05-30T08:00:00.000Z'),
}) as never,
)
const result = await skuQualityInspectionService.syncFirstBatchFromNonIntegratedProduct(
{
id: 'non-integrated-1',
spu: 'DNZ0001',
updatedAt: '2026-05-30T08:00:00.000Z',
skuItems: [
{
id: 'sku-item-1',
skuCode: 'DNZ0001-01',
barcode: 'BARCODE-001',
model: 'DNZ0001',
color: '胡桃色',
material: '实木',
manufacturer: '供应商A',
setCount: '1',
batchNo: 1,
},
],
firstQualityRows: [
{
skuId: 'sku-item-1',
qualityBatch: 'FQ-001',
conclusion: '合格',
detail: '首批通过',
},
],
uploadFiles: [
{
id: 'file-1',
name: '质检图.png',
category: '工厂质检',
dataUrl: 'data:image/png;base64,test',
mimeType: 'image/png',
factoryQualityStyleKey: 'sku-item-1',
factoryQualityAssetType: 'drop-image',
},
],
},
qualityUser,
)
expect(result).toEqual({ matchedCount: 1, syncedCount: 1 })
expect(mockedRepository.upsertByRecordKey).toHaveBeenCalledWith(
expect.objectContaining({
skuId: 'sku-1',
sourceType: 'FIRST_BATCH',
sourceProductId: 'non-integrated-1',
sourceProductSpu: 'DNZ0001',
sourceSkuItemId: 'sku-item-1',
batchNo: 'FQ-001',
inspectedAt: new Date('2026-05-30T08:00:00.000Z'),
conclusion: '合格',
}),
)
})
it('creates a formal ProductSKU before syncing first quality rows when no SKU exists yet', async () => {
mockedRepository.findActiveOwnerCandidate.mockResolvedValue({
id: 'quality-user-1',
name: 'quality user',
} as never)
mockedRepository.findSkuByCodeOrBarcode.mockResolvedValue(null)
mockedRepository.ensureProductFromFirstQualitySource.mockResolvedValue({
id: 'product-1',
name: 'DNZ Chair',
modelNumber: 'DNZ0001',
} as never)
mockedRepository.createSkuFromFirstQualitySource.mockResolvedValue({
id: 'sku-created-1',
} as never)
mockedRepository.upsertByRecordKey.mockResolvedValue(
createSavedRecord({
skuId: 'sku-created-1',
sourceType: 'FIRST_BATCH',
batchNo: 'FQ-001',
}) as never,
)
const result = await skuQualityInspectionService.syncFirstBatchFromNonIntegratedProduct(
{
id: 'non-integrated-1',
spu: 'DNZ0001',
openingReference: {
productName: 'DNZ Chair',
productImage: 'https://example.test/product.png',
},
skuItems: [
{
id: 'sku-item-1',
skuCode: 'DNZ000101',
barcode: 'DNZ000101N1280V01',
image: 'https://example.test/sku.png',
model: 'DNZ0001',
specName: '01',
color: 'walnut',
material: 'wood',
manufacturer: 'supplier A',
},
],
firstQualityRows: [
{
skuId: 'sku-item-1',
qualityBatch: 'FQ-001',
conclusion: 'PASS',
detail: 'first batch pass',
},
],
uploadFiles: [],
},
qualityUser,
)
expect(result).toEqual({ matchedCount: 1, syncedCount: 1 })
expect(mockedRepository.ensureProductFromFirstQualitySource).toHaveBeenCalledWith(
expect.objectContaining({
modelNumber: 'DNZ0001',
name: 'DNZ Chair',
thumbnailUrl: 'https://example.test/product.png',
createdBy: 'quality-user-1',
}),
)
expect(mockedRepository.createSkuFromFirstQualitySource).toHaveBeenCalledWith(
expect.objectContaining({
productId: 'product-1',
skuCode: 'DNZ000101',
images: ['https://example.test/sku.png', 'https://example.test/product.png'],
ownerId: 'quality-user-1',
}),
)
expect(mockedRepository.upsertByRecordKey).toHaveBeenCalledWith(
expect.objectContaining({
skuId: 'sku-created-1',
sourceType: 'FIRST_BATCH',
sourceProductId: 'non-integrated-1',
sourceSkuItemId: 'sku-item-1',
}),
)
})
})

View File

@ -97,7 +97,7 @@ const DEFAULT_FIELD_MATRIX: FieldMatrixItem[] = [
key: 'referenceOverview',
label: '开品参考与竞品信息',
section: '立项准备',
helperText: '对应正式产品里的开品参考,先把参考图、竞品、价格段和前置资料说明白。',
helperText: '对应产品资料里的开品参考,先把参考图、竞品、价格段和前置资料说明白。',
businessRole: 'PLANNING',
required: true,
allowSecondEdit: true,
@ -107,7 +107,7 @@ const DEFAULT_FIELD_MATRIX: FieldMatrixItem[] = [
key: 'brandCategoryPlan',
label: '品牌方向 / 类目方向',
section: '立项准备',
helperText: '对应正式产品里的品牌方向、类目和适配品牌。',
helperText: '对应产品资料里的品牌方向、类目和适配品牌。',
businessRole: 'PLANNING',
required: true,
allowSecondEdit: true,
@ -127,7 +127,7 @@ const DEFAULT_FIELD_MATRIX: FieldMatrixItem[] = [
key: 'projectReviewConclusion',
label: '立项评审结论',
section: '立项评审',
helperText: '对应正式产品里的评审时间、评审结论和是否继续开发。',
helperText: '对应产品资料里的评审时间、评审结论和是否继续开发。',
businessRole: 'PLANNING',
required: true,
allowSecondEdit: true,
@ -156,7 +156,7 @@ const DEFAULT_FIELD_MATRIX: FieldMatrixItem[] = [
key: 'sampleFactoryPlan',
label: '打样工厂与供应商信息',
section: '采购承接',
helperText: '对应正式产品里的审样工厂、供应商名称、供应商代码。',
helperText: '对应产品资料里的审样工厂、供应商名称、供应商代码。',
businessRole: 'PURCHASING',
required: true,
allowSecondEdit: true,
@ -196,7 +196,7 @@ const DEFAULT_FIELD_MATRIX: FieldMatrixItem[] = [
key: 'spuSkuIdentity',
label: 'SPU / SKU / 货号信息',
section: '基础资料',
helperText: '对应正式产品和仓库里都会用到的 SPU、SKU、型号、货号。',
helperText: '对应产品资料和仓库里都会用到的 SPU、SKU、型号、货号。',
businessRole: 'WAREHOUSE',
required: true,
allowSecondEdit: true,

View File

@ -156,7 +156,7 @@ const STALE_SERIES_DESCRIPTION_PATTERN =
/(固定模板|自动连接件推荐|自动拼装|自动组合|auto-combinations)/i
function buildNeutralSeriesDescription(name: string): string {
return `${name}系列,用于资产中心、辅助装配和正式产品资料承接。`
return `${name}系列,用于资产中心、辅助装配和产品资料承接。`
}
function sanitizeAssetSeriesDescription(

View File

@ -51,18 +51,35 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}
const PRE_SKU_BASIC_INFO_FIELD_KEYS = new Set([
'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',
])
const PRE_SKU_AUTO_FILL_FIELDS: PreSkuBasicInfoFieldKey[] = [
@ -661,8 +678,8 @@ class DraftGroupService {
addConflict(conflicts, {
field: 'model',
level: 'warning',
source: '正式产品',
message: `正式产品型号已存在:${product.name}`,
source: '产品资料',
message: `产品资料型号已存在:${product.name}`,
})
}
if (assetFamily && !resolveSelectedFamilyIds(preSku).includes(assetFamily.id)) {

View File

@ -267,18 +267,35 @@ export interface SavePreSkuPackagingPlanInput {
}
export type PreSkuBasicInfoFieldKey =
| '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 interface UpdatePreSkuBasicInfoInput {
fields: Partial<Record<PreSkuBasicInfoFieldKey, string | null>>

View File

@ -248,6 +248,17 @@ export type {
WarehouseLocationDTO,
WarehouseOverviewDTO,
} from './warehouse.service'
export {
canManageSkuQualityInspection,
skuQualityInspectionService,
SkuQualityInspectionService,
} from './sku-quality-inspection.service'
export type {
SaveWarehouseQualityInspectionInput,
SkuQualityArchiveDTO,
SkuQualityInspectionOverviewDTO,
SkuQualityInspectionRecordDTO,
} from './sku-quality-inspection.service.types'
export {
openPlatformDemoSeedService,

View File

@ -22,7 +22,7 @@ function assertCanManageSettings(user: AuthUser) {
if (ADMIN_ROLES.has(user.role)) return;
throw new AppError(
ErrorCode.AUTH_PERMISSION_DENIED,
"只有管理员可以维护正式产品节点权限",
"只有管理员可以维护产品资料节点权限",
);
}

View File

@ -21,10 +21,15 @@ import {
intakeNonIntegratedProductRepository,
type IntakeNonIntegratedProductRepositoryClass,
} from '../repositories/intake-non-integrated-product.repository'
import {
draftGroupRepository,
preSkuRepository,
} from '../repositories/draft-group.repository'
import {
DEFAULT_NON_INTEGRATED_VISIBILITY,
intakeNonIntegratedScopeService,
} from './intake-non-integrated-scope.service'
import { skuQualityInspectionService } from './sku-quality-inspection.service'
type NonIntegratedUploadPresentationPreview = {
pdfUrl: string
@ -108,6 +113,11 @@ type DuplicateProductCheckInput = Pick<
spu: string
}
type RemovedSourceSkuDraftGroupTarget = {
draftGroupId: string
preSkuId: string
}
type SaveNonIntegratedProductOptions = {
workspaceCategory?: string | null
}
@ -2380,6 +2390,161 @@ async function assertNoDuplicateProduct(
}
}
function readSourceSkuIdSet(product: SharedNonIntegratedProductRecord | null | undefined) {
return new Set(
(product?.skuItems ?? [])
.map((item) => item.sourceSkuId?.trim() ?? '')
.filter(Boolean),
)
}
function hasIncomingSkuItems(productInput: unknown) {
if (!isRecord(productInput)) return false
const product = isRecord(productInput.product) ? productInput.product : productInput
return Array.isArray(product.skuItems)
}
function hasSkuModelPreviewNodes(item: { model3dPreviewNodes?: unknown[] }) {
return Array.isArray(item.model3dPreviewNodes) && item.model3dPreviewNodes.length > 0
}
function readPreSkuModelPreviewNodes(preSku: unknown): unknown[] {
if (!isRecord(preSku)) return []
const previewNodes = Array.isArray(preSku.previewNodes) ? preSku.previewNodes : []
if (previewNodes.length > 0) {
return previewNodes
}
const displaySummary = isRecord(preSku.displaySummary) ? preSku.displaySummary : null
const assemblyWorkspace = isRecord(displaySummary?.assemblyWorkspace)
? displaySummary.assemblyWorkspace
: null
const assemblyNodes = Array.isArray(assemblyWorkspace?.nodes)
? assemblyWorkspace.nodes
: []
if (assemblyNodes.length > 0) {
return assemblyNodes
}
return []
}
async function hydrateProductSkuModelSnapshots(
product: SharedNonIntegratedProductRecord,
): Promise<SharedNonIntegratedProductRecord> {
const sourceSkuIds = Array.from(
new Set(
product.skuItems
.filter((item) => !hasSkuModelPreviewNodes(item))
.map((item) => item.sourceSkuId?.trim() ?? '')
.filter(Boolean),
),
)
if (sourceSkuIds.length === 0) return product
const previewNodesBySourceSkuId = new Map<string, unknown[]>()
await Promise.all(
sourceSkuIds.map(async (sourceSkuId) => {
try {
const preSku = await preSkuRepository.findByIdWithRelations(sourceSkuId)
const previewNodes = readPreSkuModelPreviewNodes(preSku)
if (previewNodes.length > 0) {
previewNodesBySourceSkuId.set(sourceSkuId, previewNodes)
}
} catch (error) {
console.warn('[IntakeNonIntegrated] failed to hydrate SKU model snapshot', {
productId: product.id,
sourceSkuId,
error,
})
}
}),
)
if (previewNodesBySourceSkuId.size === 0) return product
let changed = false
const skuItems = product.skuItems.map((item) => {
if (hasSkuModelPreviewNodes(item)) return item
const sourceSkuId = item.sourceSkuId?.trim() ?? ''
const previewNodes = previewNodesBySourceSkuId.get(sourceSkuId)
if (!previewNodes || previewNodes.length === 0) return item
changed = true
return {
...item,
model3dPreviewNodes: previewNodes,
}
})
return changed
? {
...product,
skuItems,
}
: product
}
async function resolveRemovedSourceSkuDraftGroups(
sourceSkuIds: string[],
): Promise<RemovedSourceSkuDraftGroupTarget[]> {
const targets = await Promise.all(
sourceSkuIds.map(async (preSkuId) => {
const preSku = await preSkuRepository.findByIdWithRelations(preSkuId)
if (!preSku?.draftGroupId) return null
const draftGroup = await draftGroupRepository.findByIdWithRelations(preSku.draftGroupId)
if (!draftGroup || draftGroup.status !== 'DRAFT') return null
return {
draftGroupId: draftGroup.id,
preSkuId,
}
}),
)
const uniqueTargets = new Map<string, RemovedSourceSkuDraftGroupTarget>()
targets.forEach((target) => {
if (!target) return
uniqueTargets.set(target.draftGroupId, target)
})
return Array.from(uniqueTargets.values())
}
async function deleteDraftGroupsAfterSourceSkuRemoval(
targets: RemovedSourceSkuDraftGroupTarget[],
) {
await Promise.all(
targets.map(async (target) => {
try {
const [packagedItemCount, locationBindingCount] = await Promise.all([
draftGroupRepository.countPackagedItems(target.draftGroupId),
draftGroupRepository.countPreSkuLocationBindings(target.draftGroupId),
])
if (packagedItemCount > 0 || locationBindingCount > 0) {
console.warn('[IntakeNonIntegrated] skipped deleting draft group after SKU removal', {
draftGroupId: target.draftGroupId,
preSkuId: target.preSkuId,
packagedItemCount,
locationBindingCount,
})
return
}
await draftGroupRepository.delete(target.draftGroupId)
} catch (error) {
console.warn('[IntakeNonIntegrated] failed to delete draft group after SKU removal', {
draftGroupId: target.draftGroupId,
preSkuId: target.preSkuId,
error,
})
}
}),
)
}
class IntakeNonIntegratedProductService {
private readonly repository: IntakeNonIntegratedProductRepositoryClass =
intakeNonIntegratedProductRepository
@ -2466,12 +2631,14 @@ class IntakeNonIntegratedProductService {
return null
}
return buildHydratedProductRecord(record.payload, {
const product = buildHydratedProductRecord(record.payload, {
createdAt: record.createdAt,
updatedAt: record.updatedAt,
createdBy: record.createdBy,
updatedBy: record.updatedBy,
})
return product ? await hydrateProductSkuModelSnapshots(product) : null
}
async assertCanWrite(id: string, user: AuthUser, category?: string | null) {
@ -2561,6 +2728,19 @@ class IntakeNonIntegratedProductService {
updatedBy: user.id,
}
const shouldSyncRemovedSourceSkuDraftGroups =
Boolean(existingRecord) && hasIncomingSkuItems(productInput)
const existingProductBeforeSave =
shouldSyncRemovedSourceSkuDraftGroups && existingRecord
? buildHydratedProductRecord(existingRecord.payload, {
createdAt: existingRecord.createdAt,
updatedAt: existingRecord.updatedAt,
createdBy: existingRecord.createdBy,
updatedBy: existingRecord.updatedBy,
})
: null
let removedSourceSkuDraftGroupTargets: RemovedSourceSkuDraftGroupTarget[] = []
try {
if (existingRecord) {
const permittedWrite = await prepareProductForPermittedWrite(
@ -2580,6 +2760,18 @@ class IntakeNonIntegratedProductService {
: permittedProduct.brand,
})
if (shouldSyncRemovedSourceSkuDraftGroups && !permittedWrite.skipUpdate) {
const beforeSourceSkuIds = readSourceSkuIdSet(existingProductBeforeSave)
const afterSourceSkuIds = readSourceSkuIdSet(permittedProduct)
const removedSourceSkuIds = Array.from(beforeSourceSkuIds).filter(
(sourceSkuId) => !afterSourceSkuIds.has(sourceSkuId),
)
removedSourceSkuDraftGroupTargets =
removedSourceSkuIds.length > 0
? await resolveRemovedSourceSkuDraftGroups(removedSourceSkuIds)
: []
}
if (!permittedWrite.skipUpdate) {
await this.repository.update(existingRecord.id, {
spu: permittedWrite.preserveRecordColumns ? existingRecord.spu : permittedSpu,
@ -2679,6 +2871,15 @@ class IntakeNonIntegratedProductService {
throw new AppError(ErrorCode.SYSTEM_INTERNAL, '非集成产品保存后读取失败')
}
await skuQualityInspectionService.syncFirstBatchFromNonIntegratedProduct(
savedProduct,
user,
)
if (removedSourceSkuDraftGroupTargets.length > 0) {
await deleteDraftGroupsAfterSourceSkuRemoval(removedSourceSkuDraftGroupTargets)
}
return savedProduct
}

View File

@ -0,0 +1,66 @@
import {
IntakeProductPricingQueryService,
type IntakeProductPricingQueryResult,
} from './intake-product-pricing-query.service'
function createRepository(
candidates: Array<{
pricingConfirmed?: boolean
costPrice?: string | null
}> = [{}],
) {
return {
findByItemCodeOrBarcode: jest.fn().mockResolvedValue(
candidates.map((candidate) => ({
itemCode: 'SZ023001',
barcode: 'SZ023001N1280V01',
costPrice: candidate.costPrice ?? '88',
pricingConfirmed: candidate.pricingConfirmed ?? true,
pricingConfirmedAt: '2026-05-27T02:00:00.000Z',
})),
),
}
}
describe('IntakeProductPricingQueryService', () => {
it('returns a confirmed cost price when queried by barcode', async () => {
const repository = createRepository()
const service = new IntakeProductPricingQueryService(repository)
const result = await service.findConfirmedCostPriceByIdentifier(
' sz023001n1280v01 ',
)
expect(repository.findByItemCodeOrBarcode).toHaveBeenCalledWith(
'SZ023001N1280V01',
)
expect(result).toEqual({
status: 'AVAILABLE',
data: {
itemCode: 'SZ023001',
barcode: 'SZ023001N1280V01',
costPrice: 88,
pricingConfirmedAt: '2026-05-27T02:00:00.000Z',
matchedBy: 'barcode',
},
} satisfies IntakeProductPricingQueryResult)
})
it('does not expose the price before the product pricing node is confirmed', async () => {
const repository = createRepository([{ pricingConfirmed: false }])
const service = new IntakeProductPricingQueryService(repository)
await expect(
service.findConfirmedCostPriceByIdentifier('SZ023001'),
).resolves.toEqual({ status: 'NOT_CONFIRMED' })
})
it('does not guess a price when one identifier matches multiple SKU rows', async () => {
const repository = createRepository([{}, {}])
const service = new IntakeProductPricingQueryService(repository)
await expect(
service.findConfirmedCostPriceByIdentifier('SZ023001'),
).resolves.toEqual({ status: 'AMBIGUOUS' })
})
})

View File

@ -0,0 +1,90 @@
import {
intakeProductPricingQueryRepository,
type IntakeProductPricingCandidate,
type IntakeProductPricingQueryRepository,
} from '@/server/repositories/intake-product-pricing-query.repository'
export type IntakeProductPricingQueryResult =
| { status: 'NOT_FOUND' }
| { status: 'AMBIGUOUS' }
| { status: 'NOT_CONFIRMED' }
| { status: 'PRICE_MISSING' }
| {
status: 'AVAILABLE'
data: {
itemCode: string
barcode: string
costPrice: number
pricingConfirmedAt: string | null
matchedBy: 'itemCode' | 'barcode'
}
}
type PricingQueryRepository = Pick<
IntakeProductPricingQueryRepository,
'findByItemCodeOrBarcode'
>
function normalizeIdentifier(identifier: string) {
return identifier.trim().toUpperCase()
}
function parseCostPrice(candidate: IntakeProductPricingCandidate) {
const value = candidate.costPrice?.trim() ?? ''
if (!value) return null
const amount = Number(value)
return Number.isFinite(amount) && amount >= 0 ? amount : null
}
class IntakeProductPricingQueryService {
constructor(
private readonly repository: PricingQueryRepository =
intakeProductPricingQueryRepository,
) {}
async findConfirmedCostPriceByIdentifier(
identifier: string,
): Promise<IntakeProductPricingQueryResult> {
const normalizedIdentifier = normalizeIdentifier(identifier)
const candidates =
await this.repository.findByItemCodeOrBarcode(normalizedIdentifier)
if (candidates.length === 0) {
return { status: 'NOT_FOUND' }
}
if (candidates.length > 1) {
return { status: 'AMBIGUOUS' }
}
const candidate = candidates[0]
if (!candidate.pricingConfirmed) {
return { status: 'NOT_CONFIRMED' }
}
const costPrice = parseCostPrice(candidate)
if (costPrice === null) {
return { status: 'PRICE_MISSING' }
}
return {
status: 'AVAILABLE',
data: {
itemCode: candidate.itemCode,
barcode: candidate.barcode,
costPrice,
pricingConfirmedAt: candidate.pricingConfirmedAt,
matchedBy:
normalizeIdentifier(candidate.barcode) === normalizedIdentifier
? 'barcode'
: 'itemCode',
},
}
}
}
export const intakeProductPricingQueryService =
new IntakeProductPricingQueryService()
export { IntakeProductPricingQueryService }

View File

@ -0,0 +1,496 @@
import type {
NonIntegratedFactoryRow,
NonIntegratedPricingRow,
NonIntegratedSampleMeasurementRow,
NonIntegratedSkuItem,
} from '@/app/intake/non-integrated/nonIntegratedStorage'
import {
JushuitanProductPricingPushService,
type JushuitanProductPricingPushInput,
} from './jushuitan-product-pricing-push.service'
function createSku(
overrides: Partial<NonIntegratedSkuItem>,
): NonIntegratedSkuItem {
return {
id: 'sku-1',
skuSequence: 1,
batchNo: 1,
packageGroupId: '',
packageSuffix: '',
image: '',
imageName: '',
model: 'GBGJ-CS0002K01',
skuCode: 'GBGJ-CS000201',
setCount: '1',
specName: 'GBGJ-款式一 140/60/78 胡桃色',
barcode: 'GBGJ-CS000201N1280V01',
size: '140/60/78',
color: '胡桃色',
material: '板木',
boardMaterialType: '颗粒板',
versionNo: 'V01',
attributeCode: '',
drawingId: 'DRAW-1',
installMethod: '',
executeStandard: '',
nationalStandardCompliant: '',
manufacturer: '兰考华兰家具',
manufacturerCode: 'N1280',
salesStatus: '',
tagMaterial: '',
model3dFileName: '',
model3dDataUrl: '',
...overrides,
}
}
function createFactoryRow(
sku: NonIntegratedSkuItem,
): NonIntegratedFactoryRow {
return {
skuId: sku.id,
confirmed: true,
model: sku.model,
skuCode: sku.skuCode,
setCount: sku.setCount,
barcode: sku.barcode,
specName: sku.specName,
size: sku.size,
color: sku.color,
material: sku.material,
installMethod: '',
supplierName: '兰考华兰家具',
supplierCode: 'N1280',
warehouse: '测试仓',
minimumOrderQuantity: '',
taxIncludedUnitPrice: '',
leadTime: '',
assemblyMode: '',
productionType: '成品',
installationService: '',
supplierCategory: '',
purchaseOrderMethod: '非订单式',
executeStandard: '',
}
}
function createPricingRow(
sku: NonIntegratedSkuItem,
costPrice = '60',
): NonIntegratedPricingRow {
return {
skuId: sku.id,
costPrice,
productTag: '',
}
}
function createMeasurementRow(
sku: NonIntegratedSkuItem,
): NonIntegratedSampleMeasurementRow {
return {
skuId: sku.id,
lengthCm: '140',
widthCm: '60',
heightCm: '78',
weightKg: '20',
}
}
function createProduct(
skuItems: NonIntegratedSkuItem[],
pricingRows = skuItems.map((sku) => createPricingRow(sku)),
): JushuitanProductPricingPushInput['product'] {
return {
id: 'product-1',
spu: 'GBGJ-CS0002',
brand: '顾家',
productCategory: '茶几',
productionType: '成品',
skuItems,
factoryRows: skuItems.map((sku) => createFactoryRow(sku)),
pricingRows,
sampleMeasurementRows: skuItems.map((sku) => createMeasurementRow(sku)),
}
}
function createSuccessFetchMock() {
return jest.fn(async (input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/open/wms/partner/query')) {
return {
ok: true,
status: 200,
text: async () =>
JSON.stringify({
code: 0,
msg: 'ok',
data: {
datas: [
{
name: '测试仓',
wms_co_id: 1,
remark2: '',
},
],
},
}),
} as Response
}
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ code: 0, msg: 'ok' }),
} as Response
})
}
function readBiz(fetchMock: jest.Mock, callIndex = 0) {
const body = fetchMock.mock.calls[callIndex]?.[1]?.body as URLSearchParams
return JSON.parse(body.get('biz') ?? '{}') as Record<string, unknown>
}
function findCallIndex(fetchMock: jest.Mock, path: string) {
return fetchMock.mock.calls.findIndex((call) => String(call[0]).includes(path))
}
describe('JushuitanProductPricingPushService', () => {
const originalEnv = process.env
const originalFetch = global.fetch
beforeEach(() => {
process.env = {
...originalEnv,
JST_APP_KEY: 'app-key',
JST_APP_SECRET: 'app-secret',
JST_ACCESS_TOKEN: 'access-token',
JST_API_BASE_URL: 'https://jst.example.test',
}
})
afterEach(() => {
process.env = originalEnv
global.fetch = originalFetch
jest.restoreAllMocks()
})
it('套件量等于 1 时只推普通商品资料', async () => {
const fetchMock = createSuccessFetchMock()
global.fetch = fetchMock as unknown as typeof fetch
const normalSku = createSku({ id: 'normal-sku' })
const result = await new JushuitanProductPricingPushService().pushProductPricing({
product: createProduct([normalSku]),
targetSkuIds: [normalSku.id],
})
const itemSkuCallIndex = findCallIndex(fetchMock, '/open/jushuitan/itemsku/upload')
expect(itemSkuCallIndex).toBeGreaterThanOrEqual(0)
expect(result.normalItemCount).toBe(1)
expect(result.combineItemCount).toBe(0)
expect(result.virtualInventoryCount).toBe(0)
expect(result.pushedSkuIds).toEqual([normalSku.id])
expect(readBiz(fetchMock, itemSkuCallIndex).items).toMatchObject([
{
sku_id: normalSku.barcode,
properties_value: '胡桃色;140/60/78',
},
])
})
it('成本价按系统原值推送,不再除以 0.9', async () => {
const fetchMock = createSuccessFetchMock()
global.fetch = fetchMock as unknown as typeof fetch
const normalSku = createSku({
id: 'normal-sku',
skuCode: 'CS000201',
barcode: 'CS000201N1280V01',
})
await new JushuitanProductPricingPushService().pushProductPricing({
product: createProduct([normalSku], [createPricingRow(normalSku, '60')]),
targetSkuIds: [normalSku.id],
})
const itemSkuCallIndex = findCallIndex(fetchMock, '/open/jushuitan/itemsku/upload')
expect(readBiz(fetchMock, itemSkuCallIndex).items).toMatchObject([
{
sku_id: normalSku.barcode,
c_price: 60,
},
])
})
it('颜色及规格使用基础信息 SKU 的颜色和尺寸', async () => {
const fetchMock = createSuccessFetchMock()
global.fetch = fetchMock as unknown as typeof fetch
const normalSku = createSku({
id: 'normal-sku',
size: '140/60/78',
})
const product = createProduct([normalSku])
product.sampleMeasurementRows = [
{
...createMeasurementRow(normalSku),
lengthCm: '144',
widthCm: '64',
heightCm: '4',
},
]
await new JushuitanProductPricingPushService().pushProductPricing({
product,
targetSkuIds: [normalSku.id],
})
const itemSkuCallIndex = findCallIndex(fetchMock, '/open/jushuitan/itemsku/upload')
expect(readBiz(fetchMock, itemSkuCallIndex).items).toMatchObject([
{
sku_id: normalSku.barcode,
properties_value: '胡桃色;140/60/78',
},
])
})
it('仓库备注命中虚拟库存时在商品资料推送成功后生成库存待推计划', async () => {
const fetchMock = jest.fn(async (input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/open/wms/partner/query')) {
return {
ok: true,
status: 200,
text: async () =>
JSON.stringify({
code: 0,
msg: 'ok',
data: {
datas: [
{
name: '测试仓',
wms_co_id: 101,
remark2: '成品代发仓-虚拟库存',
},
],
},
}),
} as Response
}
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ code: 0, msg: 'ok' }),
} as Response
})
global.fetch = fetchMock as unknown as typeof fetch
const normalSku = createSku({
id: 'normal-sku',
barcode: 'CS000201N1280V01',
})
const product = createProduct([normalSku])
product.skuBatchFlowNos = { '1': 'FLOW-001' }
const result = await new JushuitanProductPricingPushService().pushProductPricing({
product,
targetSkuIds: [normalSku.id],
})
expect(
findCallIndex(fetchMock, '/open/jushuitan/inventoryv2/upload'),
).toBe(-1)
expect(result.virtualInventoryCount).toBe(0)
expect(result.virtualInventoryPendingCount).toBe(1)
expect(result.virtualInventorySkippedCount).toBe(0)
expect(result.virtualInventoryPlans).toMatchObject([
{
skuId: normalSku.id,
skuCode: normalSku.skuCode,
barcode: normalSku.barcode,
warehouse: '测试仓',
status: 'pending',
},
])
expect(result.virtualInventoryPlans[0]?.scheduledAt).toEqual(
expect.any(String),
)
})
it('虚拟库存待推记录到期后单独推送库存盘点', async () => {
const fetchMock = jest.fn(async (input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/open/wms/partner/query')) {
return {
ok: true,
status: 200,
text: async () =>
JSON.stringify({
code: 0,
msg: 'ok',
data: {
datas: [
{
name: '测试仓',
wms_co_id: 101,
remark2: '成品代发仓-虚拟库存',
},
],
},
}),
} as Response
}
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ code: 0, msg: 'ok' }),
} as Response
})
global.fetch = fetchMock as unknown as typeof fetch
const normalSku = createSku({
id: 'normal-sku',
barcode: 'CS000201N1280V01',
})
const product = createProduct([normalSku])
product.skuBatchFlowNos = { '1': 'FLOW-001' }
const result = await new JushuitanProductPricingPushService().pushVirtualInventory({
product,
targetSkuIds: [normalSku.id],
})
const inventoryCallIndex = findCallIndex(
fetchMock,
'/open/jushuitan/inventoryv2/upload',
)
expect(inventoryCallIndex).toBeGreaterThanOrEqual(0)
expect(result.virtualInventoryCount).toBe(1)
expect(result.virtualInventorySkippedCount).toBe(0)
expect(result.virtualInventoryFailedCount).toBe(0)
expect(result.pushedSkuIds).toEqual([normalSku.id])
expect(readBiz(fetchMock, inventoryCallIndex)).toMatchObject({
wms_co_id: 101,
type: 'check',
is_confirm: true,
warehouse: 1,
items: [
{
sku_id: normalSku.barcode,
qty: 100000,
},
],
})
expect(String(readBiz(fetchMock, inventoryCallIndex).so_id)).toContain(
'FLOW-001-',
)
})
it('点击任一多包规格时整组先推普通商品资料,再推组合商品资料', async () => {
const fetchMock = createSuccessFetchMock()
global.fetch = fetchMock as unknown as typeof fetch
const comboA = createSku({
id: 'combo-a',
packageGroupId: 'group-201',
packageSuffix: 'A',
skuCode: 'GBGJ-CS000201A',
barcode: 'GBGJ-CS000201AN1280V01',
setCount: '3',
specName: 'GBGJ-款式一 140/60/78 胡桃色 A包',
})
const comboB = createSku({
id: 'combo-b',
packageGroupId: 'group-201',
packageSuffix: 'B',
skuCode: 'GBGJ-CS000201B',
barcode: 'GBGJ-CS000201BN1280V01',
setCount: '3',
specName: 'GBGJ-款式一 140/60/78 胡桃色 B包',
})
const comboC = createSku({
id: 'combo-c',
packageGroupId: 'group-201',
packageSuffix: 'C',
skuCode: 'GBGJ-CS000201C',
barcode: 'GBGJ-CS000201CN1280V01',
setCount: '3',
specName: 'GBGJ-款式一 140/60/78 胡桃色 C包',
})
const result = await new JushuitanProductPricingPushService().pushProductPricing({
product: createProduct([comboA, comboB, comboC]),
targetSkuIds: [comboA.id],
})
const itemSkuCallIndex = findCallIndex(fetchMock, '/open/jushuitan/itemsku/upload')
const combineSkuCallIndex = findCallIndex(
fetchMock,
'/open/jushuitan/item/combinesku/upload',
)
expect(itemSkuCallIndex).toBeGreaterThanOrEqual(0)
expect(combineSkuCallIndex).toBeGreaterThanOrEqual(0)
expect(result.normalItemCount).toBe(3)
expect(result.combineItemCount).toBe(1)
expect(result.virtualInventoryCount).toBe(0)
expect(result.pushedSkuIds).toEqual([comboA.id, comboB.id, comboC.id])
expect(readBiz(fetchMock, itemSkuCallIndex).items).toMatchObject([
{
sku_id: comboA.barcode,
properties_value: '胡桃色;140/60/78',
},
{
sku_id: comboB.barcode,
properties_value: '胡桃色;140/60/78',
},
{
sku_id: comboC.barcode,
properties_value: '胡桃色;140/60/78',
},
])
expect(readBiz(fetchMock, combineSkuCallIndex)).toMatchObject(
{
sku_id: 'GBGJ-CS000201',
i_id: 'GBGJ-CS000201',
name: 'GBGJ-款式一 140/60/78 胡桃色',
properties_value: '胡桃色;140/60/78',
labels: '双包/多包',
items: [
{ src_sku_id: comboA.barcode, qty: 1, sale_price: 0 },
{ src_sku_id: comboB.barcode, qty: 1, sale_price: 0 },
{ src_sku_id: comboC.barcode, qty: 1, sale_price: 0 },
],
},
)
})
it('多包规格整组内任一包缺资料时不推送', async () => {
const fetchMock = jest.fn()
global.fetch = fetchMock as unknown as typeof fetch
const comboA = createSku({
id: 'combo-a',
packageGroupId: 'group-201',
packageSuffix: 'A',
skuCode: 'GBGJ-CS000201A',
setCount: '2',
})
const comboB = createSku({
id: 'combo-b',
packageGroupId: 'group-201',
packageSuffix: 'B',
skuCode: 'GBGJ-CS000201B',
setCount: '2',
})
await expect(
new JushuitanProductPricingPushService().pushProductPricing({
product: createProduct([comboA, comboB], [createPricingRow(comboA)]),
targetSkuIds: [comboA.id],
}),
).rejects.toThrow('聚水潭推送缺少核价资料:GBGJ-CS000201B')
expect(fetchMock).not.toHaveBeenCalled()
})
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,671 @@
import { Prisma, ProductSkuQualityInspection } from '@prisma/client'
import { hasBusinessRole } from '@/lib/role-scope'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
import {
skuQualityInspectionRepository,
type SkuQualityArchiveRecord,
} from '../repositories/sku-quality-inspection.repository'
import type {
SaveWarehouseQualityInspectionInput,
SkuQualityArchiveDTO,
SkuQualityInspectionAttachmentDTO,
SkuQualityInspectionOverviewDTO,
SkuQualityInspectionRecordDTO,
SkuQualityInspectionSourceType,
} from './sku-quality-inspection.service.types'
const FIRST_BATCH_SOURCE: SkuQualityInspectionSourceType = 'FIRST_BATCH'
const WAREHOUSE_SOURCE: SkuQualityInspectionSourceType = 'WAREHOUSE'
const DEFAULT_PENDING_CONCLUSION = '待检'
type RecordLike = Record<string, unknown>
type FirstQualitySourceProduct = {
id?: unknown
spu?: unknown
openingReference?: unknown
previewImage?: unknown
model3dDataUrl?: unknown
createdAt?: unknown
updatedAt?: unknown
createdBy?: unknown
skuItems?: unknown
firstQualityRows?: unknown
uploadFiles?: unknown
}
function isRecord(value: unknown): value is RecordLike {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function trimText(value: unknown) {
return typeof value === 'string' ? value.trim() : ''
}
function optionalText(value: unknown) {
const text = trimText(value)
return text || null
}
function normalizeDate(value: unknown) {
const text = trimText(value)
if (!text) return null
const date = new Date(text)
if (Number.isNaN(date.getTime())) {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '质检日期格式不正确')
}
return date
}
function readSourceDate(value: unknown) {
const text = trimText(value)
if (!text) return null
const date = new Date(text)
return Number.isNaN(date.getTime()) ? null : date
}
function normalizeFirstQualityInspectedAt(
row: RecordLike,
product: FirstQualitySourceProduct,
) {
return (
readSourceDate(row.inspectedAt) ??
readSourceDate(row.qualityDate) ??
readSourceDate(row.inspectionDate) ??
readSourceDate(row.date) ??
readSourceDate(product.updatedAt) ??
readSourceDate(product.createdAt)
)
}
function normalizeQuantity(value: unknown, fieldLabel: string) {
if (value === null || value === undefined || value === '') return null
const numberValue = typeof value === 'number' ? value : Number(String(value).trim())
if (!Number.isFinite(numberValue) || numberValue < 0) {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, `${fieldLabel}必须是非负数字`)
}
return Math.floor(numberValue)
}
function calculateQualifiedRate(
inspectedQuantity: number | null,
qualifiedQuantity: number | null,
) {
if (!inspectedQuantity || qualifiedQuantity === null) return null
return Number((qualifiedQuantity / inspectedQuantity).toFixed(4))
}
function buildRecordKey(input: {
skuId: string
sourceType: SkuQualityInspectionSourceType
batchNo: string
sourceProductId?: string | null
sourceSkuItemId?: string | null
}) {
return [
input.skuId,
input.sourceType,
input.sourceProductId?.trim() || '-',
input.sourceSkuItemId?.trim() || '-',
input.batchNo.trim(),
].join('::')
}
function toIsoString(value: Date | null) {
return value ? value.toISOString() : null
}
function normalizeSourceType(value: string): SkuQualityInspectionSourceType {
return value === FIRST_BATCH_SOURCE ? FIRST_BATCH_SOURCE : WAREHOUSE_SOURCE
}
function getSourceLabel(sourceType: string) {
return sourceType === FIRST_BATCH_SOURCE ? '首批质检' : '仓库质检'
}
function normalizeJsonRecord(value: unknown): Record<string, unknown> | null {
return isRecord(value) ? value : null
}
function normalizeAttachments(value: unknown): SkuQualityInspectionAttachmentDTO[] {
if (!Array.isArray(value)) return []
return value.flatMap((item) => {
if (!isRecord(item)) return []
const id = trimText(item.id)
const name = trimText(item.name)
const url = trimText(item.url)
if (!id || !name || !url) return []
return [
{
id,
name,
url,
mimeType: optionalText(item.mimeType),
assetType: optionalText(item.assetType),
},
]
})
}
function toRecordDTO(record: ProductSkuQualityInspection): SkuQualityInspectionRecordDTO {
return {
id: record.id,
skuId: record.skuId,
sourceType: normalizeSourceType(record.sourceType),
sourceLabel: getSourceLabel(record.sourceType),
sourceProductId: record.sourceProductId ?? null,
sourceProductSpu: record.sourceProductSpu ?? null,
sourceSkuItemId: record.sourceSkuItemId ?? null,
batchNo: record.batchNo,
inspectedAt: toIsoString(record.inspectedAt),
inspectorName: record.inspectorName ?? null,
conclusion: record.conclusion,
detail: record.detail ?? null,
inspectedQuantity: record.inspectedQuantity ?? null,
qualifiedQuantity: record.qualifiedQuantity ?? null,
qualifiedRate: record.qualifiedRate ?? null,
metrics: normalizeJsonRecord(record.metrics),
attachments: normalizeAttachments(record.attachments),
status: record.status,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
}
}
function readTextFromAttributes(attributes: unknown, keys: string[]) {
if (!isRecord(attributes)) return ''
for (const key of keys) {
const value = attributes[key]
if (typeof value === 'string' && value.trim()) return value.trim()
}
const snapshot = isRecord(attributes.assetSkuSnapshot)
? attributes.assetSkuSnapshot
: null
if (snapshot) {
for (const key of keys) {
const value = snapshot[key]
if (typeof value === 'string' && value.trim()) return value.trim()
}
}
return ''
}
function readNestedText(value: unknown, key: string) {
return isRecord(value) ? trimText(value[key]) : ''
}
function getOpeningReference(product: FirstQualitySourceProduct) {
return isRecord(product.openingReference) ? product.openingReference : null
}
function getSourceProductName(product: FirstQualitySourceProduct, fallback: string) {
const openingReference = getOpeningReference(product)
return (
readNestedText(openingReference, 'productName') ||
readNestedText(product, 'productName') ||
fallback
)
}
function getSourceProductImage(product: FirstQualitySourceProduct) {
const openingReference = getOpeningReference(product)
return (
readNestedText(openingReference, 'productImage') ||
trimText(product.previewImage) ||
''
)
}
function uniqueTextList(values: string[]) {
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
}
function buildFormalSkuCode(input: {
productId: string
productSpu: string
sourceSkuItemId: string
skuItem: RecordLike
}) {
return (
trimText(input.skuItem.skuCode) ||
trimText(input.skuItem.barcode) ||
`${input.productSpu || input.productId}-${input.sourceSkuItemId}`
)
}
function buildFormalSkuAttributes(input: {
productId: string
productSpu: string
productName: string
sourceSkuItemId: string
skuItem: RecordLike
skuCode: string
barcode: string
imageUrl: string
}) {
const skuName = trimText(input.skuItem.specName) || input.skuCode
const model = trimText(input.skuItem.model) || input.productSpu
const color = trimText(input.skuItem.color)
const size = trimText(input.skuItem.size)
const material = trimText(input.skuItem.material)
const supplierName = trimText(input.skuItem.manufacturer)
const supplierCode = trimText(input.skuItem.manufacturerCode)
return {
source: 'NON_INTEGRATED_FIRST_QUALITY',
sourceNonIntegratedProductId: input.productId,
sourceProductSpu: input.productSpu,
sourceSkuItemId: input.sourceSkuItemId,
skuName,
specName: skuName,
skuCode: input.skuCode,
barcode: input.barcode,
model,
color,
size,
material,
setCount: trimText(input.skuItem.setCount),
batchNo: input.skuItem.batchNo ?? null,
versionNo: trimText(input.skuItem.versionNo),
attributeCode: trimText(input.skuItem.attributeCode),
supplierName,
supplierCode,
imageUrl: input.imageUrl || null,
assetSkuSnapshot: {
sourceAssetVariantId: null,
sourceAssetFamilyId: null,
sourceAssetSeriesId: null,
sourceAssetVariantIds: [],
sourceVariants: [],
model,
modelName: input.productName,
specCode: input.skuCode,
specName: skuName,
barcode: input.barcode || null,
size: {},
sizeLabel: size || null,
color: color || null,
material: material || null,
imageUrl: input.imageUrl || null,
sourceStatus: 'ACTIVE',
syncedAt: new Date().toISOString(),
lockedFields: [],
lockedReasons: {},
},
} as unknown as Prisma.InputJsonValue
}
function toArchiveDTO(record: SkuQualityArchiveRecord): SkuQualityArchiveDTO {
const records = record.qualityInspections.map((item) => toRecordDTO(item))
const firstBatchRecord =
records.find((item) => item.sourceType === FIRST_BATCH_SOURCE) ?? null
const latestWarehouseRecord =
records.find((item) => item.sourceType === WAREHOUSE_SOURCE) ?? null
return {
skuId: record.id,
skuCode: record.skuCode,
barcode: readTextFromAttributes(record.attributes, ['barcode']),
imageUrl:
readTextFromAttributes(record.attributes, ['imageUrl', 'thumbnailUrl']) ||
record.images[0] ||
null,
skuName:
readTextFromAttributes(record.attributes, ['skuName', 'name', 'displayName']) ||
record.skuCode,
color: readTextFromAttributes(record.attributes, ['color', 'colour', 'finish']),
stock: record.stock,
productId: record.product.id,
productName: record.product.name,
productModelNumber: record.product.modelNumber,
warehouseLocationId: record.warehouseLocation?.id ?? null,
warehouseLocationCode: record.warehouseLocation?.code ?? null,
warehouseLocationName: record.warehouseLocation?.name ?? null,
firstBatchRecord,
latestWarehouseRecord,
recordCount: records.length,
warehouseRecordCount: records.filter((item) => item.sourceType === WAREHOUSE_SOURCE)
.length,
records,
}
}
function isPendingRecord(record: SkuQualityInspectionRecordDTO) {
return record.status === 'DRAFT' || record.conclusion === DEFAULT_PENDING_CONCLUSION
}
function isFailedRecord(record: SkuQualityInspectionRecordDTO) {
return /不合格|返工|退货/.test(record.conclusion)
}
function hasDepartmentQualityPath(user: AuthUser) {
const paths = [
...(user.departmentPaths ?? []),
user.department?.fullPath,
user.department?.name,
].filter((path): path is string => Boolean(path))
return paths.some((path) => /质检组|仓库质检|仓储质检/.test(path))
}
export function canManageSkuQualityInspection(user: AuthUser) {
if (user.role === 'SUPER_ADMIN' || user.role === 'ADMIN') return true
if (hasBusinessRole(user, 'WAREHOUSE')) return true
return hasDepartmentQualityPath(user)
}
function assertCanManageSkuQualityInspection(user: AuthUser) {
if (canManageSkuQualityInspection(user)) return
throw new AppError(
ErrorCode.AUTH_PERMISSION_DENIED,
'当前账号没有维护仓库质检的权限',
)
}
function buildWarehouseCreateInput(
input: SaveWarehouseQualityInspectionInput,
user: AuthUser,
) {
const skuId = input.skuId.trim()
const batchNo = input.batchNo.trim()
if (!skuId || !batchNo) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, 'SKU 和质检批次不能为空')
}
const inspectedQuantity = normalizeQuantity(input.inspectedQuantity, '验货数量')
const qualifiedQuantity = normalizeQuantity(input.qualifiedQuantity, '合格数量')
if (
inspectedQuantity !== null &&
qualifiedQuantity !== null &&
qualifiedQuantity > inspectedQuantity
) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '合格数量不能大于验货数量')
}
return {
skuId,
recordKey: buildRecordKey({
skuId,
sourceType: WAREHOUSE_SOURCE,
batchNo,
}),
sourceType: WAREHOUSE_SOURCE,
batchNo,
inspectedAt: normalizeDate(input.inspectedAt),
inspectorName: optionalText(input.inspectorName) ?? user.name,
conclusion: optionalText(input.conclusion) ?? DEFAULT_PENDING_CONCLUSION,
detail: optionalText(input.detail),
inspectedQuantity,
qualifiedQuantity,
qualifiedRate: calculateQualifiedRate(inspectedQuantity, qualifiedQuantity),
status: optionalText(input.status) ?? 'RECORDED',
updatedBy: user.id,
} satisfies Omit<
Prisma.ProductSkuQualityInspectionUncheckedCreateInput,
'createdBy'
>
}
function hasFirstQualityContent(row: RecordLike) {
const conclusion = trimText(row.conclusion)
return Boolean(
trimText(row.qualityBatch) ||
trimText(row.detail) ||
(conclusion && conclusion !== DEFAULT_PENDING_CONCLUSION),
)
}
function getFirstQualityAttachments(product: FirstQualitySourceProduct, skuItemId: string) {
if (!Array.isArray(product.uploadFiles)) return []
return product.uploadFiles.flatMap((file) => {
if (!isRecord(file)) return []
if (trimText(file.category) !== '工厂质检') return []
if (trimText(file.factoryQualityStyleKey).toUpperCase() !== skuItemId.toUpperCase()) {
return []
}
const id = trimText(file.id)
const name = trimText(file.name)
const url = trimText(file.fileUrl) || trimText(file.dataUrl)
if (!id || !name || !url) return []
return [
{
id,
name,
url,
mimeType: optionalText(file.mimeType),
assetType: optionalText(file.factoryQualityAssetType),
},
]
})
}
class SkuQualityInspectionService {
private async createFormalSkuFromFirstQuality(input: {
product: FirstQualitySourceProduct
productId: string
productSpu: string
sourceSkuItemId: string
skuItem: RecordLike
skuCode: string
barcode: string
ownerId: string
}) {
const modelNumber = input.productSpu || input.productId
const productImage = getSourceProductImage(input.product)
const productName = getSourceProductName(input.product, modelNumber)
const formalProduct =
await skuQualityInspectionRepository.ensureProductFromFirstQualitySource({
modelNumber,
name: productName,
thumbnailUrl: productImage || null,
createdBy: input.ownerId,
})
const skuImage = trimText(input.skuItem.image)
const images = uniqueTextList([skuImage, productImage])
const imageUrl = images[0] ?? ''
return skuQualityInspectionRepository.createSkuFromFirstQualitySource({
productId: formalProduct.id,
skuCode: input.skuCode,
attributes: buildFormalSkuAttributes({
productId: input.productId,
productSpu: input.productSpu,
productName: formalProduct.name,
sourceSkuItemId: input.sourceSkuItemId,
skuItem: input.skuItem,
skuCode: input.skuCode,
barcode: input.barcode,
imageUrl,
}),
images,
modelUrl: trimText(input.skuItem.model3dDataUrl) || trimText(input.product.model3dDataUrl) || null,
ownerId: input.ownerId,
})
}
async getOverview(input: { search?: string } = {}): Promise<SkuQualityInspectionOverviewDTO> {
const skuArchives = (await skuQualityInspectionRepository.findSkuArchives(input.search)).map(
(record) => toArchiveDTO(record),
)
const allRecords = skuArchives.flatMap((archive) => archive.records)
return {
skuArchives,
summary: {
skuCount: skuArchives.length,
firstBatchRecordCount: allRecords.filter(
(record) => record.sourceType === FIRST_BATCH_SOURCE,
).length,
warehouseRecordCount: allRecords.filter(
(record) => record.sourceType === WAREHOUSE_SOURCE,
).length,
pendingRecordCount: allRecords.filter(isPendingRecord).length,
failedRecordCount: allRecords.filter(isFailedRecord).length,
},
}
}
async saveWarehouseRecord(input: SaveWarehouseQualityInspectionInput, user: AuthUser) {
assertCanManageSkuQualityInspection(user)
const data = buildWarehouseCreateInput(input, user)
const sku = await skuQualityInspectionRepository.findSkuById(data.skuId)
if (!sku) {
throw new AppError(ErrorCode.SKU_NOT_FOUND, '正式 SKU 不存在')
}
const saved = await skuQualityInspectionRepository.upsertByRecordKey({
...data,
createdBy: user.id,
})
const record = await skuQualityInspectionRepository.findRecordByIdWithSku(saved.id)
return toRecordDTO(record ?? saved)
}
async updateWarehouseRecord(
id: string,
input: SaveWarehouseQualityInspectionInput,
user: AuthUser,
) {
assertCanManageSkuQualityInspection(user)
const existing = await skuQualityInspectionRepository.findRecordByIdWithSku(id)
if (!existing) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '质检记录不存在')
}
if (existing.sourceType !== WAREHOUSE_SOURCE) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '首批质检记录不能在仓库质检页面修改')
}
const data = buildWarehouseCreateInput(
{
...input,
skuId: existing.skuId,
},
user,
)
const saved = await skuQualityInspectionRepository.updateRecord(existing.id, data)
return toRecordDTO(saved)
}
async syncFirstBatchFromNonIntegratedProduct(
product: FirstQualitySourceProduct,
user?: Pick<AuthUser, 'id' | 'name'>,
) {
const productId = trimText(product.id)
const productSpu = trimText(product.spu)
if (!productId || !Array.isArray(product.skuItems) || !Array.isArray(product.firstQualityRows)) {
return { matchedCount: 0, syncedCount: 0 }
}
const skuItems = product.skuItems.filter(isRecord)
const skuItemById = new Map(skuItems.map((sku) => [trimText(sku.id), sku]))
const firstQualityRows = product.firstQualityRows
.filter(isRecord)
.filter(hasFirstQualityContent)
let matchedCount = 0
let syncedCount = 0
const ownerCandidate = await skuQualityInspectionRepository.findActiveOwnerCandidate(
user?.id ?? trimText(product.createdBy),
)
for (const row of firstQualityRows) {
const sourceSkuItemId = trimText(row.skuId)
const skuItem = skuItemById.get(sourceSkuItemId)
if (!sourceSkuItemId || !skuItem) continue
const skuCode = buildFormalSkuCode({
productId,
productSpu,
sourceSkuItemId,
skuItem,
})
const barcode = trimText(skuItem.barcode)
let formalSku = await skuQualityInspectionRepository.findSkuByCodeOrBarcode({
skuCodes: [skuCode, barcode],
barcode,
})
if (!formalSku && ownerCandidate) {
formalSku = await this.createFormalSkuFromFirstQuality({
product,
productId,
productSpu,
sourceSkuItemId,
skuItem,
skuCode,
barcode,
ownerId: ownerCandidate.id,
})
}
if (!formalSku) continue
matchedCount += 1
const batchNo =
trimText(row.qualityBatch) ||
`首批-${productSpu || productId}-${skuCode || sourceSkuItemId}`
const conclusion = trimText(row.conclusion) || DEFAULT_PENDING_CONCLUSION
const detail = optionalText(row.detail)
const attachments = getFirstQualityAttachments(product, sourceSkuItemId)
await skuQualityInspectionRepository.upsertByRecordKey({
skuId: formalSku.id,
recordKey: buildRecordKey({
skuId: formalSku.id,
sourceType: FIRST_BATCH_SOURCE,
sourceProductId: productId,
sourceSkuItemId,
batchNo,
}),
sourceType: FIRST_BATCH_SOURCE,
sourceProductId: productId,
sourceProductSpu: productSpu || null,
sourceSkuItemId,
batchNo,
inspectedAt: normalizeFirstQualityInspectedAt(row, product),
inspectorName: user?.name ?? null,
conclusion,
detail,
metrics: {
model: trimText(skuItem.model),
skuCode,
barcode,
color: trimText(skuItem.color),
material: trimText(skuItem.material),
supplierName: trimText(skuItem.manufacturer),
setCount: trimText(skuItem.setCount),
sourceBatchNo: skuItem.batchNo ?? null,
},
attachments,
status: 'CONFIRMED',
createdBy: user?.id ?? null,
updatedBy: user?.id ?? null,
})
syncedCount += 1
}
return { matchedCount, syncedCount }
}
}
export const skuQualityInspectionService = new SkuQualityInspectionService()
export { SkuQualityInspectionService }

View File

@ -0,0 +1,76 @@
export type SkuQualityInspectionSourceType = 'FIRST_BATCH' | 'WAREHOUSE'
export interface SkuQualityInspectionAttachmentDTO {
id: string
name: string
url: string
mimeType: string | null
assetType: string | null
}
export interface SkuQualityInspectionRecordDTO {
id: string
skuId: string
sourceType: SkuQualityInspectionSourceType
sourceLabel: string
sourceProductId: string | null
sourceProductSpu: string | null
sourceSkuItemId: string | null
batchNo: string
inspectedAt: string | null
inspectorName: string | null
conclusion: string
detail: string | null
inspectedQuantity: number | null
qualifiedQuantity: number | null
qualifiedRate: number | null
metrics: Record<string, unknown> | null
attachments: SkuQualityInspectionAttachmentDTO[]
status: string
createdAt: string
updatedAt: string
}
export interface SkuQualityArchiveDTO {
skuId: string
skuCode: string
barcode: string
imageUrl: string | null
skuName: string
color: string
stock: number
productId: string
productName: string
productModelNumber: string
warehouseLocationId: string | null
warehouseLocationCode: string | null
warehouseLocationName: string | null
firstBatchRecord: SkuQualityInspectionRecordDTO | null
latestWarehouseRecord: SkuQualityInspectionRecordDTO | null
recordCount: number
warehouseRecordCount: number
records: SkuQualityInspectionRecordDTO[]
}
export interface SkuQualityInspectionOverviewDTO {
skuArchives: SkuQualityArchiveDTO[]
summary: {
skuCount: number
firstBatchRecordCount: number
warehouseRecordCount: number
pendingRecordCount: number
failedRecordCount: number
}
}
export interface SaveWarehouseQualityInspectionInput {
skuId: string
batchNo: string
inspectedAt?: string | null
inspectorName?: string | null
conclusion?: string | null
detail?: string | null
inspectedQuantity?: number | string | null
qualifiedQuantity?: number | string | null
status?: string | null
}