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:
commit
35fed448cf
@ -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;
|
||||
@ -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])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 用户管理
|
||||
// ============================================
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -111,7 +111,7 @@ describe('首页工作台数据注入', () => {
|
||||
mockedGetProducts.mockClear()
|
||||
})
|
||||
|
||||
it('应只把当前账号的草稿和正式产品注入首页工作台', async () => {
|
||||
it('应只把当前账号的草稿和产品资料注入首页工作台', async () => {
|
||||
render(await HomePage({ searchParams: Promise.resolve({}) }))
|
||||
|
||||
expect(screen.getByText('HomeClient')).toBeInTheDocument()
|
||||
|
||||
97
furniture-3d-system/src/app/api/external/intake/product-pricing/route.test.ts
vendored
Normal file
97
furniture-3d-system/src/app/api/external/intake/product-pricing/route.test.ts
vendored
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
94
furniture-3d-system/src/app/api/external/intake/product-pricing/route.ts
vendored
Normal file
94
furniture-3d-system/src/app/api/external/intake/product-pricing/route.ts
vendored
Normal 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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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({
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
51
furniture-3d-system/src/app/api/warehouse-quality/route.ts
Normal file
51
furniture-3d-system/src/app/api/warehouse-quality/route.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
@ -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]">
|
||||
|
||||
@ -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]">
|
||||
|
||||
@ -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)]" />
|
||||
|
||||
3230
furniture-3d-system/src/app/draft-groups/DraftGroupsFormalClient.tsx
Normal file
3230
furniture-3d-system/src/app/draft-groups/DraftGroupsFormalClient.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
@ -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()
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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 ||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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} />
|
||||
}
|
||||
|
||||
@ -99,7 +99,7 @@ describe('AppShell', () => {
|
||||
expect(screen.getByText('assisted assembly content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('正式产品、仓库管理和仓库质检使用顶部工作台导航', () => {
|
||||
it('产品资料、仓库管理和仓库质检使用顶部工作台导航', () => {
|
||||
usePathnameMock.mockReturnValue('/intake')
|
||||
|
||||
const { rerender } = render(
|
||||
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 }
|
||||
@ -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 }
|
||||
@ -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)
|
||||
|
||||
@ -352,7 +352,7 @@ describe('intakeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('CREATE_PRODUCT 模式会创建新的正式产品并回写默认信息', async () => {
|
||||
it('CREATE_PRODUCT 模式会创建新的产品资料并回写默认信息', async () => {
|
||||
mockedDraftGroupRepository.findByIdWithRelations.mockResolvedValue({
|
||||
id: 'draft-group-1',
|
||||
name: '云序桌系第一组',
|
||||
|
||||
@ -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',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
|
||||
@ -156,7 +156,7 @@ const STALE_SERIES_DESCRIPTION_PATTERN =
|
||||
/(固定模板|自动连接件推荐|自动拼装|自动组合|auto-combinations)/i
|
||||
|
||||
function buildNeutralSeriesDescription(name: string): string {
|
||||
return `${name}系列,用于资产中心、辅助装配和正式产品资料承接。`
|
||||
return `${name}系列,用于资产中心、辅助装配和产品资料承接。`
|
||||
}
|
||||
|
||||
function sanitizeAssetSeriesDescription(
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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>>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -22,7 +22,7 @@ function assertCanManageSettings(user: AuthUser) {
|
||||
if (ADMIN_ROLES.has(user.role)) return;
|
||||
throw new AppError(
|
||||
ErrorCode.AUTH_PERMISSION_DENIED,
|
||||
"只有管理员可以维护正式产品节点权限",
|
||||
"只有管理员可以维护产品资料节点权限",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
@ -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 }
|
||||
@ -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
@ -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 }
|
||||
@ -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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user