feat(intake): move approval workflow entry to demand draft

This commit is contained in:
WuYongJie 2026-04-30 16:45:30 +08:00
parent 1e9fec0bda
commit f8a0952042
13 changed files with 1333 additions and 208 deletions

View File

@ -2,6 +2,7 @@
import Link from 'next/link'
import {
GitBranch,
Grid,
Palette,
ShieldCheck,
@ -54,6 +55,13 @@ const adminModules = [
icon: Grid,
metric: '排序',
},
{
title: '流程单总表',
subtitle: '流程',
href: '/admin/approval-workflows',
icon: GitBranch,
metric: '审批 / 链接',
},
]
export function AdminPortalClient({ role }: AdminPortalClientProps) {

View File

@ -0,0 +1,191 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import type { ApprovalWorkflowStatus } from '@prisma/client'
import { Copy, ExternalLink, Search } from 'lucide-react'
import {
buildApprovalWorkflowUrl,
listApprovalWorkflows,
type ApprovalWorkflowListItem,
} from '@/app/approval-workflows/approvalWorkflowApi'
import { BUSINESS_ACCOUNT_TYPE_LABELS } from '@/lib/business-account-types'
const STATUS_LABELS: Record<ApprovalWorkflowStatus | '', string> = {
'': '全部状态',
DRAFT: '草稿',
PENDING: '待处理',
IN_PROGRESS: '进行中',
COMPLETED: '已完成',
REJECTED: '已驳回',
ARCHIVED: '已归档',
}
export function AdminApprovalWorkflowClient() {
const [keyword, setKeyword] = useState('')
const [status, setStatus] = useState<ApprovalWorkflowStatus | ''>('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [copiedKey, setCopiedKey] = useState<string | null>(null)
const [workflows, setWorkflows] = useState<ApprovalWorkflowListItem[]>([])
const load = async () => {
try {
setLoading(true)
setError(null)
const items = await listApprovalWorkflows({
keyword: keyword.trim() || undefined,
status,
})
setWorkflows(items)
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : '流程记录加载失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
const sortedWorkflows = useMemo(
() => [...workflows].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()),
[workflows],
)
const handleCopy = async (accessKey: string) => {
try {
await navigator.clipboard.writeText(buildApprovalWorkflowUrl(accessKey))
setCopiedKey(accessKey)
window.setTimeout(() => setCopiedKey((current) => (current === accessKey ? null : current)), 1500)
} catch {
setError('复制链接失败,请检查浏览器剪贴板权限')
}
}
return (
<div className="min-h-screen bg-[#f3f6f6] text-[#162124]">
<div className="mx-auto max-w-7xl px-6 py-6">
<div className="rounded-[8px] border border-[#d8e0e1] bg-white p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-[11px] font-black uppercase tracking-[0.18em] text-[#667578]">后台流程记录</p>
<h1 className="mt-2 text-2xl font-black">流程单总表</h1>
<p className="mt-2 text-sm text-[#607073]">
这里负责找旧流程、按审批编号排查、复制链接补发,不替代开品页里的主发起入口。
</p>
</div>
<Link
href="/admin"
className="rounded-[6px] border border-[#d8e0e1] bg-[#f8faf9] px-3 py-2 text-sm font-bold text-[#314145]"
>
返回后台
</Link>
</div>
<div className="mt-5 grid gap-3 lg:grid-cols-[minmax(0,1fr)_220px_120px]">
<label className="space-y-1.5">
<span className="text-xs font-bold text-[#607073]">搜索</span>
<div className="flex h-10 items-center gap-2 rounded-[6px] border border-[#d8e0e1] bg-white px-3">
<Search className="h-4 w-4 text-[#7b8b8d]" />
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索审批编号、流程标题、产品型号或产品名称"
className="w-full border-0 bg-transparent text-sm outline-none"
/>
</div>
</label>
<label className="space-y-1.5">
<span className="text-xs font-bold text-[#607073]">状态</span>
<select
value={status}
onChange={(event) => setStatus(event.target.value as ApprovalWorkflowStatus | '')}
className="h-10 w-full rounded-[6px] border border-[#d8e0e1] bg-white px-3 text-sm outline-none"
>
{Object.entries(STATUS_LABELS).map(([value, label]) => (
<option key={value || 'all'} value={value}>
{label}
</option>
))}
</select>
</label>
<button
type="button"
onClick={() => void load()}
className="h-10 self-end rounded-[6px] border border-[#cad6d7] bg-white px-3 text-sm font-black text-[#223034]"
>
查询
</button>
</div>
{error ? (
<div className="mt-4 rounded-[6px] border border-[#f2c7cc] bg-[#fff1f3] px-4 py-3 text-sm text-[#9f3041]">
{error}
</div>
) : null}
</div>
<div className="mt-4 rounded-[8px] border border-[#d8e0e1] bg-white p-5">
{loading ? (
<div className="py-8 text-center text-sm text-[#607073]">正在加载流程记录...</div>
) : sortedWorkflows.length === 0 ? (
<div className="py-8 text-center text-sm text-[#607073]">当前没有符合条件的流程记录。</div>
) : (
<div className="space-y-3">
{sortedWorkflows.map((workflow) => (
<div key={workflow.id} className="rounded-[6px] border border-[#e0e7e7] bg-[#fbfcfc] p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-black text-[#162124]">{workflow.title}</p>
<span className="rounded-[6px] bg-white px-2 py-0.5 text-[11px] font-bold text-[#607073]">
{STATUS_LABELS[workflow.status]}
</span>
</div>
<p className="mt-1 text-xs text-[#607073]">
审批编号:{workflow.approvalNumber} · 产品:{workflow.product.modelNumber} / {workflow.product.name}
</p>
<p className="mt-1 text-xs text-[#607073]">
参与业务:{workflow.businessRoles.map((role) => BUSINESS_ACCOUNT_TYPE_LABELS[role]).join(' / ') || '未配置'}
</p>
<p className="mt-1 text-xs text-[#7b8b8d]">
最近更新:{new Date(workflow.updatedAt).toLocaleString('zh-CN')}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => void handleCopy(workflow.accessKey)}
className="inline-flex items-center gap-1 rounded-[6px] border border-[#cad6d7] bg-white px-3 py-1.5 text-xs font-black text-[#223034]"
>
<Copy className="h-3.5 w-3.5" />
{copiedKey === workflow.accessKey ? '已复制' : '复制链接'}
</button>
<a
href={`/approval-workflows/${workflow.accessKey}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-[6px] border border-[#cad6d7] bg-white px-3 py-1.5 text-xs font-black text-[#223034]"
>
<ExternalLink className="h-3.5 w-3.5" />
打开流程页
</a>
<Link
href={`/intake/non-integrated/${encodeURIComponent(workflow.productId)}`}
className="inline-flex items-center gap-1 rounded-[6px] border border-[#cad6d7] bg-white px-3 py-1.5 text-xs font-black text-[#223034]"
>
查看需求
</Link>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,12 @@
import { redirect } from 'next/navigation'
import { requireSession } from '@/lib/server-session'
import { AdminApprovalWorkflowClient } from './AdminApprovalWorkflowClient'
export default async function AdminApprovalWorkflowsPage() {
const { authUser } = await requireSession('/login')
if (!['SUPER_ADMIN', 'ADMIN'].includes(authUser.role)) {
redirect('/?error=unauthorized')
}
return <AdminApprovalWorkflowClient />
}

View File

@ -1,8 +1,9 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { AppError } from '@/server/core/errors'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { ApprovalWorkflowStatus, BusinessAccountType } from '@prisma/client'
import { approvalWorkflowService } from '@/server/services/approval-workflow.service'
import { intakeNonIntegratedProductService } from '@/server/services'
export async function GET(request: NextRequest) {
return handleRequest(
@ -11,11 +12,19 @@ export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const productId = searchParams.get('productId') || undefined
const approvalNumber = searchParams.get('approvalNumber') || undefined
const keyword = searchParams.get('keyword') || undefined
const status = (searchParams.get('status') as ApprovalWorkflowStatus | null) || undefined
const workflows = await approvalWorkflowService.list({ productId, approvalNumber, status })
if (productId) {
await intakeNonIntegratedProductService.getById(productId, ctx.user)
} else if (!['SUPER_ADMIN', 'ADMIN'].includes(ctx.user.role)) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '只有后台管理员可以查看全量流程记录', 403, ctx.requestId)
}
const workflows = await approvalWorkflowService.list({ productId, approvalNumber, status, keyword })
return jsonSuccess({ workflows }, 200, ctx.requestId)
},
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM'] }
{ requireAuth: true }
)
}
@ -25,6 +34,7 @@ export async function POST(request: NextRequest) {
async (ctx) => {
const body = await request.json()
try {
await intakeNonIntegratedProductService.getById(body.productId, ctx.user)
const workflow = await approvalWorkflowService.create(
{
approvalNumber: body.approvalNumber,
@ -51,6 +61,6 @@ export async function POST(request: NextRequest) {
throw error
}
},
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT'] }
{ requireAuth: true }
)
}

View File

@ -10,6 +10,9 @@ type FieldMatrixItem = {
key: string
label: string
businessRole: BusinessAccountType
section?: string
helperText?: string
placeholder?: string
required?: boolean
allowSecondEdit?: boolean
}
@ -118,6 +121,36 @@ function ApprovalWorkflowPageBody({ accessKey }: { accessKey: string }) {
return groups
}, [workflow])
const fieldLabelMap = useMemo(() => {
const map = new Map<string, string>()
for (const item of workflow?.fieldMatrix || []) {
map.set(item.key, item.label)
}
return map
}, [workflow])
const groupedFieldSections = useMemo(() => {
const sections = new Map<BusinessAccountType, Array<{ title: string; fields: FieldMatrixItem[] }>>()
for (const role of ['PLANNING', 'PURCHASING', 'WAREHOUSE', 'SUPPLIER'] as BusinessAccountType[]) {
const roleFields = groupedFields.get(role) || []
const bySection = new Map<string, FieldMatrixItem[]>()
for (const field of roleFields) {
const sectionTitle = field.section || '未分组'
const current = bySection.get(sectionTitle) || []
current.push(field)
bySection.set(sectionTitle, current)
}
sections.set(
role,
Array.from(bySection.entries()).map(([title, fields]) => ({
title,
fields,
})),
)
}
return sections
}, [groupedFields])
const myBusinessRoles = user?.businessRoles || []
const handleSaveField = async (field: FieldMatrixItem) => {
@ -208,9 +241,46 @@ function ApprovalWorkflowPageBody({ accessKey }: { accessKey: string }) {
</div>
</section>
<section className="rounded-[8px] border border-[#d8e0e1] bg-white p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<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]">
共 {(workflow.fieldMatrix || []).length} 项
</span>
</div>
<div className="mt-4 grid gap-3 xl:grid-cols-2">
{(['PLANNING', 'PURCHASING', 'WAREHOUSE', 'SUPPLIER'] as BusinessAccountType[]).map((role) => {
const sections = groupedFieldSections.get(role) || []
if (sections.length === 0) return null
return (
<div key={`outline-${role}`} className="rounded-[6px] border border-[#e0e7e7] bg-[#fbfcfc] p-4">
<h3 className="text-sm font-black text-[#162124]">{BUSINESS_ACCOUNT_TYPE_LABELS[role]}</h3>
<div className="mt-3 space-y-3">
{sections.map((section) => (
<div key={`${role}-${section.title}`}>
<p className="text-[11px] font-black text-[#607073]">{section.title}</p>
<ul className="mt-1 space-y-1 text-xs font-medium text-[#223034]">
{section.fields.map((field) => (
<li key={`outline-item-${field.key}`}>{field.label}</li>
))}
</ul>
</div>
))}
</div>
</div>
)
})}
</div>
</section>
{(['PLANNING', 'PURCHASING', 'WAREHOUSE', 'SUPPLIER'] as BusinessAccountType[]).map((role) => {
const fields = groupedFields.get(role) || []
if (fields.length === 0) return null
const sections = groupedFieldSections.get(role) || []
if (sections.length === 0) return null
const mine = myBusinessRoles.includes(role)
return (
<section
@ -229,44 +299,66 @@ function ApprovalWorkflowPageBody({ accessKey }: { accessKey: string }) {
</span>
</div>
<div className="mt-4 grid gap-3">
{fields.map((field) => {
const readOnly = !mine && user?.role !== 'SUPER_ADMIN' && user?.role !== 'ADMIN'
return (
<div key={field.key} className="rounded-[6px] border border-[#e0e7e7] bg-[#fbfcfc] p-4">
<div className="flex items-center justify-between gap-3">
<label className="text-sm font-black text-[#162124]">{field.label}</label>
<span className="text-[11px] font-bold text-[#6a7679]">
{field.required ? '必填' : '选填'}
</span>
</div>
<textarea
value={draftValues[field.key] || ''}
onChange={(event) =>
setDraftValues((current) => ({ ...current, [field.key]: event.target.value }))
}
readOnly={readOnly}
rows={4}
className="mt-3 w-full rounded-[6px] border border-[#d8e0e1] bg-white px-3 py-2 text-sm font-medium text-[#152024] outline-none focus:border-[#7f9799] read-only:bg-[#f5f7f7] read-only:text-[#6a7679]"
/>
<div className="mt-3 flex items-center justify-between gap-3">
<span className="text-[11px] font-medium text-[#6b777a]">
{readOnly ? '当前只读' : '保存后会自动写入字段留痕'}
</span>
{!readOnly ? (
<button
type="button"
onClick={() => void handleSaveField(field)}
disabled={savingKey === field.key}
className="rounded-[6px] border border-[#cad6d7] bg-white px-3 py-1.5 text-xs font-black text-[#223034] disabled:opacity-60"
>
{savingKey === field.key ? '保存中...' : '保存'}
</button>
) : null}
<div className="mt-4 space-y-5">
{sections.map((section) => (
<div
key={`${role}-${section.title}`}
className="rounded-[6px] bg-[#f8faf9] px-4 py-4 first:mt-0"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="border-l-2 border-[#d7e1e1] pl-3">
<h3 className="text-sm font-black text-[#162124]">{section.title}</h3>
<p className="mt-1 text-xs font-medium text-[#607073]">
{section.fields.length} 项内容
</p>
</div>
</div>
)
})}
<div className="mt-4 space-y-3">
{section.fields.map((field) => {
const readOnly = !mine && user?.role !== 'SUPER_ADMIN' && user?.role !== 'ADMIN'
return (
<div key={field.key} className="rounded-[6px] bg-white px-4 py-4 shadow-[inset_0_0_0_1px_rgba(221,229,229,0.45)]">
<div className="flex items-center justify-between gap-3">
<label className="text-sm font-black text-[#162124]">{field.label}</label>
<span className="text-[11px] font-bold text-[#6a7679]">
{field.required ? '必填' : '选填'}
</span>
</div>
{field.helperText ? (
<p className="mt-1 text-xs font-medium leading-5 text-[#607073]">{field.helperText}</p>
) : null}
<textarea
value={draftValues[field.key] || ''}
onChange={(event) =>
setDraftValues((current) => ({ ...current, [field.key]: event.target.value }))
}
readOnly={readOnly}
rows={4}
placeholder={field.placeholder}
className="mt-3 w-full rounded-[6px] border border-[#d8e0e1] bg-white px-3 py-2 text-sm font-medium text-[#152024] outline-none placeholder:text-[#95a3a6] focus:border-[#7f9799] read-only:bg-[#f5f7f7] read-only:text-[#6a7679]"
/>
<div className="mt-3 flex items-center justify-between gap-3">
<span className="text-[11px] font-medium text-[#6b777a]">
{readOnly ? '当前只读' : '保存后会自动写入字段留痕'}
</span>
{!readOnly ? (
<button
type="button"
onClick={() => void handleSaveField(field)}
disabled={savingKey === field.key}
className="rounded-[6px] border border-[#cad6d7] bg-white px-3 py-1.5 text-xs font-black text-[#223034] disabled:opacity-60"
>
{savingKey === field.key ? '保存中...' : '保存'}
</button>
) : null}
</div>
</div>
)
})}
</div>
</div>
))}
</div>
</section>
)
@ -298,7 +390,7 @@ function ApprovalWorkflowPageBody({ accessKey }: { accessKey: string }) {
) : (
workflow.fieldChanges.map((item) => (
<div key={item.id} className="rounded-[6px] border border-[#e4ebeb] bg-[#fbfcfc] px-3 py-3">
<p className="text-xs font-black text-[#223034]">{item.user.name} 修改了 {item.fieldKey}</p>
<p className="text-xs font-black text-[#223034]">{item.user.name} 修改了 {fieldLabelMap.get(item.fieldKey) || item.fieldKey}</p>
<p className="mt-1 text-[11px] font-medium text-[#607073]">{formatDate(item.createdAt)} / {item.fromDingtalkPage ? '钉钉流程页' : item.source}</p>
<p className="mt-2 text-xs font-medium text-[#445458]">
新值:{typeof item.newValue === 'string' ? item.newValue : JSON.stringify(item.newValue)}

View File

@ -0,0 +1,103 @@
'use client'
import type { ApprovalWorkflowStatus, BusinessAccountType } from '@prisma/client'
type ApiEnvelope<T> = {
success?: boolean
data?: T
error?: {
message?: string
}
}
export type ApprovalWorkflowListItem = {
id: string
approvalNumber: string
title: string
status: ApprovalWorkflowStatus
productId: string
accessKey: string
businessRoles: BusinessAccountType[]
participantUserIds: string[]
initiatorId: string
lastSyncedAt: string | null
product: {
id: string
modelNumber: string
name: string
description: string | null
thumbnailUrl: string | null
}
initiator: {
id: string
name: string
role: string
}
createdAt: string
updatedAt: string
}
type WorkflowListResponse = {
workflows: ApprovalWorkflowListItem[]
}
type WorkflowCreateResponse = {
workflow: ApprovalWorkflowListItem
}
async function readApiData<T>(response: Response): Promise<T> {
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
}
export async function listApprovalWorkflows(params: {
productId?: string
approvalNumber?: string
status?: ApprovalWorkflowStatus | ''
keyword?: string
}) {
const searchParams = new URLSearchParams()
if (params.productId) searchParams.set('productId', params.productId)
if (params.approvalNumber) searchParams.set('approvalNumber', params.approvalNumber)
if (params.status) searchParams.set('status', params.status)
if (params.keyword) searchParams.set('keyword', params.keyword)
const query = searchParams.toString()
const response = await fetch(`/api/approval-workflows${query ? `?${query}` : ''}`, {
method: 'GET',
cache: 'no-store',
credentials: 'include',
})
const data = await readApiData<WorkflowListResponse>(response)
return data.workflows
}
export async function createApprovalWorkflow(input: {
approvalNumber: string
title: string
productId: string
businessRoles: BusinessAccountType[]
}) {
const response = await fetch('/api/approval-workflows', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(input),
})
const data = await readApiData<WorkflowCreateResponse>(response)
return data.workflow
}
export function buildApprovalWorkflowUrl(accessKey: string) {
if (typeof window === 'undefined') return `/approval-workflows/${accessKey}`
return `${window.location.origin}/approval-workflows/${accessKey}`
}

View File

@ -0,0 +1,300 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import type { ApprovalWorkflowStatus, BusinessAccountType } from '@prisma/client'
import { Copy, ExternalLink, Link2, Plus, RefreshCw } from 'lucide-react'
import Button from '@/components/ui/Button'
import { BUSINESS_ACCOUNT_TYPE_LABELS } from '@/lib/business-account-types'
import {
buildApprovalWorkflowUrl,
createApprovalWorkflow,
listApprovalWorkflows,
type ApprovalWorkflowListItem,
} from '@/app/approval-workflows/approvalWorkflowApi'
const STATUS_LABELS: Record<ApprovalWorkflowStatus, string> = {
DRAFT: '草稿',
PENDING: '待处理',
IN_PROGRESS: '进行中',
COMPLETED: '已完成',
REJECTED: '已驳回',
ARCHIVED: '已归档',
}
const BUSINESS_ROLE_OPTIONS: BusinessAccountType[] = [
'PLANNING',
'PURCHASING',
'WAREHOUSE',
'SUPPLIER',
]
export default function ApprovalWorkflowWorkspace({
productId,
productName,
demandFlowNo,
}: {
productId?: string
productName: string
demandFlowNo?: string
}) {
const [workflows, setWorkflows] = useState<ApprovalWorkflowListItem[]>([])
const [loading, setLoading] = useState(Boolean(productId))
const [error, setError] = useState<string | null>(null)
const [copiedKey, setCopiedKey] = useState<string | null>(null)
const [showCreateForm, setShowCreateForm] = useState(false)
const [creating, setCreating] = useState(false)
const [title, setTitle] = useState(() => `${productName}开品协同流程`)
const [approvalNumber, setApprovalNumber] = useState(() => demandFlowNo?.trim() || '')
const [businessRoles, setBusinessRoles] = useState<BusinessAccountType[]>(['PLANNING', 'PURCHASING'])
const canCreate = Boolean(productId)
const sortedWorkflows = useMemo(
() => [...workflows].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()),
[workflows],
)
useEffect(() => {
if (!productId) {
setLoading(false)
setWorkflows([])
return
}
let active = true
const load = async () => {
try {
setLoading(true)
setError(null)
const nextWorkflows = await listApprovalWorkflows({ productId })
if (!active) return
setWorkflows(nextWorkflows)
} catch (loadError) {
if (!active) return
setError(loadError instanceof Error ? loadError.message : '流程单加载失败')
} finally {
if (active) setLoading(false)
}
}
void load()
return () => {
active = false
}
}, [productId])
useEffect(() => {
setTitle(`${productName}开品协同流程`)
}, [productName])
useEffect(() => {
if (demandFlowNo?.trim()) {
setApprovalNumber((current) => current || demandFlowNo.trim())
}
}, [demandFlowNo])
const handleCopy = async (accessKey: string) => {
try {
const url = buildApprovalWorkflowUrl(accessKey)
await navigator.clipboard.writeText(url)
setCopiedKey(accessKey)
window.setTimeout(() => setCopiedKey((current) => (current === accessKey ? null : current)), 1500)
} catch {
setError('复制链接失败,请检查浏览器剪贴板权限')
}
}
const handleToggleRole = (role: BusinessAccountType) => {
setBusinessRoles((current) =>
current.includes(role) ? current.filter((item) => item !== role) : [...current, role],
)
}
const handleCreate = async () => {
if (!productId) return
if (!approvalNumber.trim() || !title.trim()) {
setError('请先填写审批编号和流程标题')
return
}
if (businessRoles.length === 0) {
setError('至少选择一个参与业务类型')
return
}
try {
setCreating(true)
setError(null)
const workflow = await createApprovalWorkflow({
productId,
approvalNumber: approvalNumber.trim(),
title: title.trim(),
businessRoles,
})
setWorkflows((current) => [workflow, ...current.filter((item) => item.id !== workflow.id)])
setShowCreateForm(false)
await handleCopy(workflow.accessKey)
} catch (createError) {
setError(createError instanceof Error ? createError.message : '流程单创建失败')
} finally {
setCreating(false)
}
}
return (
<div className="space-y-3">
{!canCreate ? (
<div className="rounded-apple-lg border border-[#f0d7a6] bg-[#fff7e8] px-4 py-3">
<p className="text-sm font-apple-medium text-[#7c5410]">流程单协同入口</p>
<p className="mt-1 text-xs leading-5 text-[#9b6511]">
请先保存当前开品需求,生成稳定记录后,再创建流程单并复制链接发给钉钉协同填写。
</p>
</div>
) : (
<>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm font-apple-semibold text-apple-label-primary">流程单协同</p>
<p className="mt-1 text-xs text-apple-label-secondary">
当前开品需求保存后,就从这里创建流程单、复制链接和进入钉钉协同页。
</p>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
size="small"
variant="plain"
onClick={() => {
if (!productId) return
setLoading(true)
void listApprovalWorkflows({ productId })
.then((items) => {
setWorkflows(items)
setError(null)
})
.catch((loadError) =>
setError(loadError instanceof Error ? loadError.message : '流程单加载失败'),
)
.finally(() => setLoading(false))
}}
>
<RefreshCw className="h-3.5 w-3.5" />
刷新
</Button>
<Button type="button" size="small" onClick={() => setShowCreateForm((current) => !current)}>
<Plus className="h-3.5 w-3.5" />
{showCreateForm ? '收起创建' : '新建流程单'}
</Button>
</div>
</div>
{error ? (
<div className="rounded-apple-lg border border-[#f2c7cc] bg-[#fff1f3] px-4 py-3 text-xs text-[#9f3041]">
{error}
</div>
) : null}
{showCreateForm ? (
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/35 p-3">
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1.5">
<span className="text-xs text-apple-label-secondary">审批编号</span>
<input
value={approvalNumber}
onChange={(event) => setApprovalNumber(event.target.value)}
placeholder="优先填需求流程号或内部审批编号"
className="h-10 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-primary px-3 text-sm text-apple-label-primary outline-none"
/>
</label>
<label className="space-y-1.5">
<span className="text-xs text-apple-label-secondary">流程标题</span>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
className="h-10 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-primary px-3 text-sm text-apple-label-primary outline-none"
/>
</label>
</div>
<div className="mt-3">
<p className="text-xs text-apple-label-secondary">参与业务类型</p>
<div className="mt-2 flex flex-wrap gap-2">
{BUSINESS_ROLE_OPTIONS.map((role) => {
const active = businessRoles.includes(role)
return (
<button
key={role}
type="button"
onClick={() => handleToggleRole(role)}
className={`rounded-apple-lg border px-3 py-1.5 text-xs transition ${
active
? 'border-apple-blue bg-apple-blue/10 text-apple-blue'
: 'border-apple-separator bg-apple-bg-primary text-apple-label-secondary'
}`}
>
{BUSINESS_ACCOUNT_TYPE_LABELS[role]}
</button>
)
})}
</div>
</div>
<div className="mt-3 flex justify-end">
<Button type="button" size="small" onClick={handleCreate} disabled={creating}>
{creating ? '创建中...' : '创建并复制链接'}
</Button>
</div>
</div>
) : null}
{loading ? (
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/25 px-4 py-6 text-center text-sm text-apple-label-secondary">
正在加载流程单...
</div>
) : sortedWorkflows.length === 0 ? (
<div className="rounded-apple-lg border border-dashed border-apple-separator bg-apple-bg-secondary/20 px-4 py-6 text-center">
<Link2 className="mx-auto h-5 w-5 text-apple-label-tertiary" />
<p className="mt-2 text-sm font-apple-medium text-apple-label-primary">当前还没有流程单</p>
<p className="mt-1 text-xs text-apple-label-secondary">先创建一条流程单,再把链接发到钉钉里继续补资料。</p>
</div>
) : (
<div className="space-y-2">
{sortedWorkflows.map((workflow) => (
<div
key={workflow.id}
className="rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/20 px-4 py-3"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-apple-semibold text-apple-label-primary">{workflow.title}</p>
<span className="rounded-apple-sm bg-apple-bg-primary px-2 py-0.5 text-[11px] text-apple-label-secondary">
{STATUS_LABELS[workflow.status]}
</span>
</div>
<p className="mt-1 text-xs text-apple-label-secondary">
审批编号:{workflow.approvalNumber} · 最近更新:{new Date(workflow.updatedAt).toLocaleString('zh-CN')}
</p>
<p className="mt-2 text-xs text-apple-label-secondary">
参与业务:{workflow.businessRoles.map((role) => BUSINESS_ACCOUNT_TYPE_LABELS[role]).join(' / ') || '未配置'}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" size="small" variant="plain" onClick={() => void handleCopy(workflow.accessKey)}>
<Copy className="h-3.5 w-3.5" />
{copiedKey === workflow.accessKey ? '已复制' : '复制链接'}
</Button>
<a href={`/approval-workflows/${workflow.accessKey}`} target="_blank" rel="noreferrer">
<Button type="button" size="small" variant="plain">
<ExternalLink className="h-3.5 w-3.5" />
打开流程页
</Button>
</a>
</div>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
)
}

View File

@ -379,7 +379,7 @@ describe("NonIntegratedDetailClient", () => {
) as NonIntegratedProductRecord[];
expect(savedProducts).toHaveLength(1);
expect(savedProducts[0]?.spu).toBe("");
expect(savedProducts[0]?.spu).toBe("DNZ0001");
expect(savedProducts[0]?.projectStatus).toBe("draft");
expect(savedProducts[0]?.brand).toBe("基本盘");
expect(savedProducts[0]?.designer).toBe("测试设计师");
@ -393,6 +393,33 @@ describe("NonIntegratedDetailClient", () => {
);
});
it("creates a draft temporary code when saving a new demand without manual temporary code", async () => {
const actor = userEvent.setup();
const { container } = render(<NonIntegratedDetailClient mode="create" />);
await actor.type(screen.getByLabelText("开品名称"), "仅填写名称的开品");
const imageFile = new File(["image"], "preview.png", { type: "image/png" });
const fileInputs = container.querySelectorAll('input[type="file"]');
await actor.upload(fileInputs[0] as HTMLInputElement, imageFile);
await actor.click(screen.getByRole("button", { name: "创建开品单" }));
await waitFor(() => expect(push).toHaveBeenCalledTimes(1));
const savedProducts = JSON.parse(
window.localStorage.getItem(NON_INTEGRATED_STORAGE_KEY) ?? "[]",
) as NonIntegratedProductRecord[];
expect(savedProducts).toHaveLength(1);
expect(savedProducts[0]?.openingReference.productName).toBe(
"仅填写名称的开品",
);
expect(savedProducts[0]?.openingReference.temporaryCode).toMatch(/^KP-\d+$/);
expect(savedProducts[0]?.spu).toBe(
savedProducts[0]?.openingReference.temporaryCode,
);
});
it("uses the opening name until a formal SPU is filled", async () => {
const actor = userEvent.setup();
@ -488,7 +515,10 @@ describe("NonIntegratedDetailClient", () => {
it("shows the fixed bottom actions only on the create page", () => {
const createRender = render(<NonIntegratedDetailClient mode="create" />);
expect(screen.getByRole("button", { name: "返回" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "返回开品列表" }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "取消" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "创建开品单" }),
).toBeInTheDocument();
@ -508,7 +538,10 @@ describe("NonIntegratedDetailClient", () => {
/>,
);
expect(screen.queryByRole("button", { name: "返回" })).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "返回开品列表" }),
).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "取消" })).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "创建开品单" }),
).not.toBeInTheDocument();

View File

@ -84,6 +84,7 @@ import {
downloadNonIntegratedSkuWorkbook,
} from "@/lib/non-integrated-sku-export";
import { fetchSubstrates } from "@/lib/open-product/platform-api";
import ApprovalWorkflowWorkspace from "./ApprovalWorkflowWorkspace";
import { extractPreviewModelFromZip } from "../../../lib/model-preview-zip";
import {
applyBrandPrefixToNonIntegratedCode,
@ -5840,7 +5841,10 @@ function buildProductPayload(
projectReview: normalizeNonIntegratedProjectReview(projectReview),
openingReference: normalizedOpeningReference,
patentApplication: normalizePatentApplicationForPayload(patentApplication),
spu: formValues.spu.trim(),
spu:
formValues.spu.trim() ||
normalizedOpeningReference.temporaryCode.trim() ||
id,
year: autoYear,
demandFlowNo: formValues.demandFlowNo.trim(),
processTemplate: formValues.processTemplate.trim(),
@ -11570,10 +11574,12 @@ export default function NonIntegratedDetailClient({
openingReference.productName.trim() ||
openingReference.temporaryCode.trim() ||
`KP-${Date.now()}`;
const draftTemporaryCode =
openingReference.temporaryCode.trim() || `KP-${Date.now()}`;
const nextOpeningReference = normalizeNonIntegratedOpeningReference({
...openingReference,
productName: openingReference.productName.trim() || draftProductName,
temporaryCode: openingReference.temporaryCode.trim(),
temporaryCode: draftTemporaryCode,
currentStage: getOpeningReferenceStageFromStatus(projectStatus),
});
const nextFormValues = buildOpeningReferenceFormValues(
@ -11628,6 +11634,10 @@ export default function NonIntegratedDetailClient({
}
}
function navigateToIntakeList() {
router.push("/intake");
}
function renderCreateFooter() {
if (isReadOnly || mode !== "create") return null;
@ -11637,10 +11647,10 @@ export default function NonIntegratedDetailClient({
<Button
variant="gray"
size="small"
onClick={() => router.push("/intake")}
onClick={navigateToIntakeList}
disabled={isSubmitting}
>
返回
取消
</Button>
<Button
size="small"
@ -15489,19 +15499,38 @@ export default function NonIntegratedDetailClient({
<div className={detailRootClassName}>
{!embedded ? (
<div className="shrink-0 px-6 pt-5">
<div
className="mx-auto flex min-h-[32px] w-full max-w-[1600px] items-center gap-1 overflow-x-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary px-2 py-1 shadow-apple-xs"
role="tablist"
aria-label="当前工作页"
>
<div className="flex h-6 shrink-0 items-center overflow-hidden rounded-apple-md border border-zinc-950 bg-zinc-950 text-white">
<span
role="tab"
aria-selected="true"
className="h-full max-w-[220px] truncate px-3 text-xs leading-6"
>
{standaloneWorkspaceTabLabel}
</span>
<div className="mx-auto flex w-full max-w-[1600px] flex-col gap-2">
{mode === "create" ? (
<div className="flex items-center justify-between gap-3">
<Button
type="button"
variant="plain"
size="small"
onClick={navigateToIntakeList}
className="!min-h-7 !px-2 !py-0 text-[12px] [&>span]:gap-1"
>
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
返回开品列表
</Button>
<p className="text-xs text-apple-label-secondary">
先保存开品需求草稿,保存后再创建流程单并复制链接。
</p>
</div>
) : null}
<div
className="flex min-h-[32px] items-center gap-1 overflow-x-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary px-2 py-1 shadow-apple-xs"
role="tablist"
aria-label="当前工作页"
>
<div className="flex h-6 shrink-0 items-center overflow-hidden rounded-apple-md border border-zinc-950 bg-zinc-950 text-white">
<span
role="tab"
aria-selected="true"
className="h-full max-w-[220px] truncate px-3 text-xs leading-6"
>
{standaloneWorkspaceTabLabel}
</span>
</div>
</div>
</div>
</div>
@ -15517,15 +15546,24 @@ export default function NonIntegratedDetailClient({
className={`min-w-0 flex-1 p-3 ${isDocumentWorkspaceMode ? "overflow-hidden" : "overflow-auto"}`}
>
{renderWorkflowActionBar()}
<div
className={`rounded-apple-xl border border-apple-separator bg-apple-bg-primary shadow-apple-xs ${
isDocumentWorkspaceMode ? "p-3" : "p-4"
} ${
isDocumentWorkspaceMode
<div
className={`rounded-apple-xl border border-apple-separator bg-apple-bg-primary shadow-apple-xs ${
isDocumentWorkspaceMode ? "p-3" : "p-4"
} ${
isDocumentWorkspaceMode
? "flex h-full flex-col overflow-hidden"
: ""
}`}
>
}`}
>
{isDocumentWorkspaceMode ? (
<div className="mb-3 border-b border-apple-separator/70 pb-3">
<ApprovalWorkflowWorkspace
productId={mode === "create" ? undefined : productId}
productName={currentProductDisplayName}
demandFlowNo={formValues.demandFlowNo}
/>
</div>
) : null}
{!isDocumentWorkspaceMode &&
mode !== "create" &&
hasPendingDocumentWorkspaceCategories ? (
@ -15549,131 +15587,42 @@ export default function NonIntegratedDetailClient({
{documentWorkspaceView === "detail" ? (
<div className="space-y-3">
{mode === "create" ? (
<SectionCard
title="基础信息"
description="先录入产品基础资料;当前下拉项先手动占位,后续再逐步接配置。"
>
<div className="grid gap-4 xl:grid-cols-[220px_minmax(0,1fr)] xl:items-start">
<UploadField
label="SPU图"
required
fileInputRef={previewFileInputRef}
previewUrl={formValues.previewImage}
onFileChange={handlePreviewImageChange}
onPreview={() =>
formValues.previewImage
? setIsPreviewOpen(true)
: undefined
}
compact
readOnly={isReadOnly}
/>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6">
<CreateFieldShell>
<BasicField
label="品牌"
type="select"
options={availableBrandOptions}
value={formValues.brand}
readOnly={
isReadOnly ||
mode !== "create" ||
availableBrandOptions.length === 0
}
onChange={(value) => updateField("brand", value)}
compact
/>
</CreateFieldShell>
<CreateFieldShell>
{isCopiedBrandProduct ? (
<SingleAdaptableBrandField
value={formValues.brand}
compact
/>
) : (
<AdaptableBrandField
value={formValues.adaptableBrands}
readOnly={isReadOnly}
onChange={updateAdaptableBrands}
compact
/>
)}
</CreateFieldShell>
{CREATE_TOP_FIELDS.map((field) => (
<CreateFieldShell key={String(field.key)}>
<>
<SectionCard
title="基础信息"
description="先录入产品基础资料;当前下拉项先手动占位,后续再逐步接配置。"
>
<div className="grid gap-4 xl:grid-cols-[220px_minmax(0,1fr)] xl:items-start">
<UploadField
label="SPU图"
required
fileInputRef={previewFileInputRef}
previewUrl={formValues.previewImage}
onFileChange={handlePreviewImageChange}
onPreview={() =>
formValues.previewImage
? setIsPreviewOpen(true)
: undefined
}
compact
readOnly={isReadOnly}
/>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6">
<CreateFieldShell>
<BasicField
label={field.label}
required={field.required}
type={field.type}
placeholder={field.placeholder}
options={field.options}
value={formValues[field.key] as string}
readOnly={isReadOnly || field.auto === true}
onChange={(value) =>
updateField(field.key, value)
label="品牌"
type="select"
options={availableBrandOptions}
value={formValues.brand}
readOnly={
isReadOnly ||
mode !== "create" ||
availableBrandOptions.length === 0
}
onChange={(value) => updateField("brand", value)}
compact
/>
</CreateFieldShell>
))}
</div>
</div>
</SectionCard>
) : (
<SectionCard
title={`${currentProductDisplayName}产品开发明细`}
description="立项后从开品参考和立项评审自动带出基础资料;这里可以继续补正式 SPU、流程和运营配置。"
>
<div className="grid gap-3 xl:grid-cols-[220px_minmax(0,1fr)]">
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/30 p-3">
{formValues.previewImage ? (
<button
type="button"
onClick={() => setIsPreviewOpen(true)}
className="block w-full overflow-hidden rounded-apple-lg border border-apple-separator bg-apple-bg-primary"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={formValues.previewImage}
alt="成品预览图"
className="aspect-[4/3] w-full object-cover"
/>
</button>
) : (
<WorkspacePreviewEmptyState
title="暂无成品预览图"
description="当前还没有上传产品主图。"
/>
)}
</div>
<div className="space-y-3">
<div className="grid gap-2 md:grid-cols-3 xl:grid-cols-5">
{BASIC_INFO_CORE_FIELDS.map((field) => (
<CreateFieldShell key={String(field.key)}>
<BasicField
label={field.label}
type={field.type}
placeholder={field.placeholder}
options={
field.key === "brand"
? availableBrandOptions
: field.options
}
value={formValues[field.key] as string}
readOnly={
isReadOnly ||
field.auto === true ||
(field.key === "brand" &&
(isCopiedBrandProduct ||
availableBrandOptions.length === 0))
}
onChange={(value) =>
updateField(field.key, value)
}
compact
/>
</CreateFieldShell>
))}
<CreateFieldShell>
{isCopiedBrandProduct ? (
<SingleAdaptableBrandField
@ -15689,16 +15638,85 @@ export default function NonIntegratedDetailClient({
/>
)}
</CreateFieldShell>
{CREATE_TOP_FIELDS.map((field) => (
<CreateFieldShell key={String(field.key)}>
<BasicField
label={field.label}
required={field.required}
type={field.type}
placeholder={field.placeholder}
options={field.options}
value={formValues[field.key] as string}
readOnly={isReadOnly || field.auto === true}
onChange={(value) =>
updateField(field.key, value)
}
compact
/>
</CreateFieldShell>
))}
</div>
<div className="border-t border-apple-separator/70 pt-3">
<div className="grid gap-2 md:grid-cols-3 xl:grid-cols-6">
{BASIC_INFO_PROCESS_FIELDS.map((field) => (
</div>
</SectionCard>
<SectionCard
title="流程单协同"
description="流程单入口前置在开品需求阶段。当前页面还没保存前,不提供可复制链接。"
>
<ApprovalWorkflowWorkspace
productName={currentProductDisplayName}
demandFlowNo={formValues.demandFlowNo}
/>
</SectionCard>
</>
) : (
<>
<SectionCard
title={`${currentProductDisplayName}产品开发明细`}
description="立项后从开品参考和立项评审自动带出基础资料;这里可以继续补正式 SPU、流程和运营配置。"
>
<div className="grid gap-3 xl:grid-cols-[220px_minmax(0,1fr)]">
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/30 p-3">
{formValues.previewImage ? (
<button
type="button"
onClick={() => setIsPreviewOpen(true)}
className="block w-full overflow-hidden rounded-apple-lg border border-apple-separator bg-apple-bg-primary"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={formValues.previewImage}
alt="成品预览图"
className="aspect-[4/3] w-full object-cover"
/>
</button>
) : (
<WorkspacePreviewEmptyState
title="暂无成品预览图"
description="当前还没有上传产品主图。"
/>
)}
</div>
<div className="space-y-3">
<div className="grid gap-2 md:grid-cols-3 xl:grid-cols-5">
{BASIC_INFO_CORE_FIELDS.map((field) => (
<CreateFieldShell key={String(field.key)}>
<BasicField
label={field.label}
type={field.type}
placeholder={field.placeholder}
options={
field.key === "brand"
? availableBrandOptions
: field.options
}
value={formValues[field.key] as string}
readOnly={isReadOnly}
readOnly={
isReadOnly ||
field.auto === true ||
(field.key === "brand" &&
(isCopiedBrandProduct ||
availableBrandOptions.length === 0))
}
onChange={(value) =>
updateField(field.key, value)
}
@ -15706,11 +15724,54 @@ export default function NonIntegratedDetailClient({
/>
</CreateFieldShell>
))}
<CreateFieldShell>
{isCopiedBrandProduct ? (
<SingleAdaptableBrandField
value={formValues.brand}
compact
/>
) : (
<AdaptableBrandField
value={formValues.adaptableBrands}
readOnly={isReadOnly}
onChange={updateAdaptableBrands}
compact
/>
)}
</CreateFieldShell>
</div>
<div className="border-t border-apple-separator/70 pt-3">
<div className="grid gap-2 md:grid-cols-3 xl:grid-cols-6">
{BASIC_INFO_PROCESS_FIELDS.map((field) => (
<CreateFieldShell key={String(field.key)}>
<BasicField
label={field.label}
placeholder={field.placeholder}
value={formValues[field.key] as string}
readOnly={isReadOnly}
onChange={(value) =>
updateField(field.key, value)
}
compact
/>
</CreateFieldShell>
))}
</div>
</div>
</div>
</div>
</div>
</SectionCard>
</SectionCard>
<SectionCard
title="流程单协同"
description="开品需求保存后,就从这里创建流程单、复制链接,并继续把资料协同到钉钉流程页。"
>
<ApprovalWorkflowWorkspace
productId={productId}
productName={currentProductDisplayName}
demandFlowNo={formValues.demandFlowNo}
/>
</SectionCard>
</>
)}
{mode !== "create" ? (

View File

@ -560,9 +560,28 @@ type NonIntegratedFormalSpuSource = Pick<
| "documentWorkspaceConfirmations"
| "documentWorkspaceApprovedAt"
| "formalSpuAssigned"
| "id"
>
>;
type NonIntegratedPersistedSpuSource = Pick<
NonIntegratedProductRecord,
"id" | "spu" | "openingReference"
>;
export function resolveNonIntegratedPersistedSpu(
product: NonIntegratedPersistedSpuSource,
) {
const normalizedSpu = product.spu.trim().toUpperCase();
if (normalizedSpu) return normalizedSpu;
const reference = normalizeNonIntegratedOpeningReference(product.openingReference);
const temporaryCode = reference.temporaryCode.trim().toUpperCase();
if (temporaryCode) return temporaryCode;
return product.id.trim().toUpperCase();
}
export function getNonIntegratedFormalSpu(product: NonIntegratedFormalSpuSource) {
const normalizedSpu = product.spu.trim();
if (!normalizedSpu) return "";
@ -570,6 +589,7 @@ export function getNonIntegratedFormalSpu(product: NonIntegratedFormalSpuSource)
const reference = normalizeNonIntegratedOpeningReference(product.openingReference);
const temporaryCode = reference.temporaryCode.trim();
const productName = reference.productName.trim();
const productId = product.id?.trim() ?? "";
const basicInfoIsApproved =
product.documentWorkspaceConfirmations?.[
NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY
@ -592,6 +612,14 @@ export function getNonIntegratedFormalSpu(product: NonIntegratedFormalSpuSource)
return "";
}
if (
productId &&
productName &&
normalizedSpu.toUpperCase() === productId.toUpperCase()
) {
return "";
}
return normalizedSpu;
}
@ -1619,10 +1647,11 @@ export function saveNonIntegratedProduct(
}
const currentList = listNonIntegratedProducts();
const normalizedSpu = product.spu.trim().toUpperCase();
const normalizedSpu = resolveNonIntegratedPersistedSpu(product);
const duplicateProduct = currentList.find(
(item) =>
item.id !== product.id && item.spu.trim().toUpperCase() === normalizedSpu,
item.id !== product.id &&
resolveNonIntegratedPersistedSpu(item) === normalizedSpu,
);
if (normalizedSpu && duplicateProduct) {
@ -1632,6 +1661,7 @@ export function saveNonIntegratedProduct(
const existingProduct = currentList.find((item) => item.id === product.id);
const nextProduct: NonIntegratedProductRecord = {
...product,
spu: normalizedSpu,
projectStatus: normalizeNonIntegratedProjectStatus(product.projectStatus),
projectReview: normalizeNonIntegratedProjectReview(product.projectReview),
openingReference: normalizeNonIntegratedOpeningReference(

View File

@ -143,6 +143,61 @@ describe('intakeNonIntegratedProductService', () => {
)
})
it('persists a draft demand without formal spu by falling back to temporary code', async () => {
const payload = createPayload({
id: 'non-integrated-draft-create',
spu: '',
openingReference: {
productName: '草稿开品',
temporaryCode: 'KP-20260430',
},
})
mockedRepository.findBySpu.mockResolvedValue(null as never)
mockedRepository.findDeletedBySpu.mockResolvedValue(null as never)
mockedRepository.findById.mockResolvedValue(null as never)
mockedRepository.create.mockResolvedValue(
createRecord({
id: 'non-integrated-draft-create',
spu: 'KP-20260430',
spuBase: 'KP-20260430',
payload: createPayload({
id: 'non-integrated-draft-create',
spu: 'KP-20260430',
openingReference: {
productName: '草稿开品',
temporaryCode: 'KP-20260430',
},
}),
}) as never,
)
mockedRepository.findActiveById.mockResolvedValue(
createRecord({
id: 'non-integrated-draft-create',
spu: 'KP-20260430',
spuBase: 'KP-20260430',
payload: createPayload({
id: 'non-integrated-draft-create',
spu: 'KP-20260430',
openingReference: {
productName: '草稿开品',
temporaryCode: 'KP-20260430',
},
}),
}) as never,
)
await intakeNonIntegratedProductService.save(payload, baseUser)
expect(mockedRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
id: 'non-integrated-draft-create',
spu: 'KP-20260430',
spuBase: 'KP-20260430',
}),
)
})
it('allows creating a copied product when the target brand prefix changes the full SPU', async () => {
const payload = createPayload({
id: 'non-integrated-prefixed-create',

View File

@ -7,6 +7,8 @@ interface FieldMatrixItem {
key: string
label: string
businessRole: BusinessAccountType
section?: string
helperText?: string
required?: boolean
editableStatuses?: ApprovalWorkflowStatus[]
allowSecondEdit?: boolean
@ -46,12 +48,211 @@ export interface ApprovalWorkflowDTO {
}
const DEFAULT_FIELD_MATRIX: FieldMatrixItem[] = [
{ key: 'productPositioning', label: '产品定位', businessRole: 'PLANNING', required: true, allowSecondEdit: true },
{ key: 'purchaseStrategy', label: '采购策略', businessRole: 'PURCHASING', required: true, allowSecondEdit: true },
{ key: 'warehouseRequirement', label: '仓储要求', businessRole: 'WAREHOUSE', required: true, allowSecondEdit: true },
{ key: 'supplierNote', label: '供应商备注', businessRole: 'SUPPLIER', allowSecondEdit: true },
{
key: 'referenceOverview',
label: '开品参考与竞品信息',
section: '立项准备',
helperText: '对应正式产品里的开品参考,先把参考图、竞品、价格段和前置资料说明白。',
businessRole: 'PLANNING',
required: true,
allowSecondEdit: true,
placeholder: '填写参考图来源、竞品链接、价格段、参考资料和当前已掌握的信息。',
},
{
key: 'brandCategoryPlan',
label: '品牌方向 / 类目方向',
section: '立项准备',
helperText: '对应正式产品里的品牌方向、类目和适配品牌。',
businessRole: 'PLANNING',
required: true,
allowSecondEdit: true,
placeholder: '填写目标品牌、类目方向、适配品牌和年份阶段等判断。',
},
{
key: 'productPositioning',
label: '产品定位与设计卖点',
section: '立项准备',
helperText: '这里承接开品理由、目标人群、风格方向和设计卖点。',
businessRole: 'PLANNING',
required: true,
allowSecondEdit: true,
placeholder: '说明为什么开这款、目标客群、风格、差异化卖点和预期定位。',
},
{
key: 'projectReviewConclusion',
label: '立项评审结论',
section: '立项评审',
helperText: '对应正式产品里的评审时间、评审结论和是否继续开发。',
businessRole: 'PLANNING',
required: true,
allowSecondEdit: true,
placeholder: '填写会议结论、是否继续开发、当前资料是否足够支撑打样。',
},
{
key: 'projectReviewRevisionNotes',
label: '需修改内容 / 废弃原因',
section: '立项评审',
helperText: '退回修改或废弃不开时,至少要写清缺什么和为什么。',
businessRole: 'PLANNING',
allowSecondEdit: true,
placeholder: '填写需补资料、需修改内容,或废弃不开的原因。',
},
{
key: 'purchasingOwners',
label: '采购负责人 / 供应商负责人',
section: '采购承接',
helperText: '对应立项后的采购负责人、打样跟进人和对接工厂。',
businessRole: 'PURCHASING',
required: true,
allowSecondEdit: true,
placeholder: '填写采购负责人、跟单人、主要对接工厂或供应商。',
},
{
key: 'sampleFactoryPlan',
label: '打样工厂与供应商信息',
section: '采购承接',
helperText: '对应正式产品里的审样工厂、供应商名称、供应商代码。',
businessRole: 'PURCHASING',
required: true,
allowSecondEdit: true,
placeholder: '填写打样工厂、供应商名称、供应商代码和合作基础情况。',
},
{
key: 'sampleReviewArrangement',
label: '审样安排',
section: '样品评审',
helperText: '对应版本、审样日期、审样方式、评审轮次。',
businessRole: 'PURCHASING',
required: true,
allowSecondEdit: true,
placeholder: '填写当前样品版本、审样日期、现场/视频方式和轮次安排。',
},
{
key: 'sampleIssueAndFixPlan',
label: '样品问题与修改方式',
section: '样品评审',
helperText: '对应样品评审里的问题描述、修改方式和责任追踪。',
businessRole: 'PURCHASING',
required: true,
allowSecondEdit: true,
placeholder: '汇总当前样品问题、修改建议、责任方和下一轮确认点。',
},
{
key: 'purchaseStrategy',
label: '采购策略与交付计划',
section: '采购承接',
helperText: '包含定厂策略、物料承接、交期安排和采购侧风险判断。',
businessRole: 'PURCHASING',
required: true,
allowSecondEdit: true,
placeholder: '说明定厂策略、关键物料来源、预计交期和采购阶段安排。',
},
{
key: 'spuSkuIdentity',
label: 'SPU / SKU / 货号信息',
section: '基础资料',
helperText: '对应正式产品和仓库里都会用到的 SPU、SKU、型号、规格码、货号。',
businessRole: 'WAREHOUSE',
required: true,
allowSecondEdit: true,
placeholder: '填写 SPU、SKU、型号、规格码、条码规格名或货号承接信息。',
},
{
key: 'baseAttributeSummary',
label: '尺寸 / 颜色 / 材质 / 安装方式',
section: '基础资料',
helperText: '对应基础信息补充区和 SKU 信息里的核心属性。',
businessRole: 'WAREHOUSE',
required: true,
allowSecondEdit: true,
placeholder: '汇总尺寸、颜色、材质、安装方式,以及是否已确认一致。',
},
{
key: 'componentPreparation',
label: 'SKU 配件准备明细',
section: '执行准备',
helperText: '对应仓库执行准备里的配件编码、配件类型和准备状态。',
businessRole: 'WAREHOUSE',
required: true,
allowSecondEdit: true,
placeholder: '列出核心配件类型、配件编码、是否已识别、是否待补充。',
},
{
key: 'brandMaterialPreparation',
label: '品牌资料准备情况',
section: '执行准备',
helperText: '对应说明书、铭牌、合格证、印刷文件等品牌资料。',
businessRole: 'WAREHOUSE',
required: true,
allowSecondEdit: true,
placeholder: '说明说明书、铭牌、合格证、印刷文件是否齐全,缺什么。',
},
{
key: 'warehouseRequirement',
label: '库位与仓储要求',
section: '仓库承接',
helperText: '对应库位编码、区域、巷道、货架、箱位以及仓储备注。',
businessRole: 'WAREHOUSE',
required: true,
allowSecondEdit: true,
placeholder: '填写库位规划、区域/巷道/货架/箱位、仓储注意事项和备注。',
},
{
key: 'packagingExecutionRequirement',
label: '包装执行与 3D / 资料承接',
section: '仓库承接',
helperText: '对应包装说明、包装 3D 预览、执行资料是否齐备。',
businessRole: 'WAREHOUSE',
required: true,
allowSecondEdit: true,
placeholder: '填写包装说明、3D 模型/预览情况、执行资料和入库前待确认项。',
},
{
key: 'supplierDeliveryRisk',
label: '交期 / 打样风险',
section: '供应商反馈',
helperText: '由供应商补充交期、打样难点、物料风险和异常。',
businessRole: 'SUPPLIER',
required: true,
allowSecondEdit: true,
placeholder: '说明交期风险、打样难点、物料异常和需要提前确认的事项。',
},
{
key: 'supplierModificationPlan',
label: '供应商修改计划',
section: '供应商反馈',
helperText: '对应供应商侧的整改动作、完成时间和回传计划。',
businessRole: 'SUPPLIER',
allowSecondEdit: true,
placeholder: '填写修改动作、预计完成时间、回传资料和下一步节点。',
},
{
key: 'supplierSupportingDocuments',
label: '供应商配套资料',
section: '供应商反馈',
helperText: '用于记录供应商需补的图纸、图片、说明、认证或其他附件。',
businessRole: 'SUPPLIER',
allowSecondEdit: true,
placeholder: '列出供应商需补的图纸、图片、认证、工艺说明或附件清单。',
},
{
key: 'supplierNote',
label: '供应商备注',
section: '供应商反馈',
helperText: '补充说明无法归类到其他字段的信息。',
businessRole: 'SUPPLIER',
allowSecondEdit: true,
placeholder: '填写其他补充说明、对接备注或临时风险提醒。',
},
]
const LEGACY_FIELD_KEYS = new Set([
'productPositioning',
'purchaseStrategy',
'warehouseRequirement',
'supplierNote',
])
class ApprovalWorkflowService {
private async findWorkflowById(id: string) {
return prisma.approvalWorkflow.findUnique({
@ -126,7 +327,7 @@ class ApprovalWorkflowService {
participantUserIds: workflow.participantUserIds,
initiatorId: workflow.initiatorId,
lastSyncedAt: workflow.lastSyncedAt?.toISOString() ?? null,
fieldMatrix: workflow.fieldMatrix,
fieldMatrix: this.getFieldMatrix(workflow.fieldMatrix) as unknown as Prisma.JsonValue,
summary: workflow.summary,
metadata: workflow.metadata,
product: workflow.product,
@ -149,6 +350,8 @@ class ApprovalWorkflowService {
key: record.key,
label: record.label,
businessRole: record.businessRole as BusinessAccountType,
section: typeof record.section === 'string' ? record.section : undefined,
helperText: typeof record.helperText === 'string' ? record.helperText : undefined,
required: record.required === true,
editableStatuses: Array.isArray(record.editableStatuses)
? record.editableStatuses.filter((status): status is ApprovalWorkflowStatus => typeof status === 'string')
@ -157,7 +360,16 @@ class ApprovalWorkflowService {
placeholder: typeof record.placeholder === 'string' ? record.placeholder : undefined,
})
}
return items.length > 0 ? items : DEFAULT_FIELD_MATRIX
if (items.length === 0) return DEFAULT_FIELD_MATRIX
const looksLegacyMatrix =
items.length <= 4 &&
items.every((item) => LEGACY_FIELD_KEYS.has(item.key))
if (looksLegacyMatrix) {
return DEFAULT_FIELD_MATRIX
}
return items
}
private assertWorkflowAccess(
@ -228,12 +440,32 @@ class ApprovalWorkflowService {
productId?: string
approvalNumber?: string
status?: ApprovalWorkflowStatus
keyword?: string
}): Promise<ApprovalWorkflowDTO[]> {
const keyword = options.keyword?.trim()
const workflows = await prisma.approvalWorkflow.findMany({
where: {
productId: options.productId,
approvalNumber: options.approvalNumber,
status: options.status,
...(keyword
? {
OR: [
{ approvalNumber: { contains: keyword, mode: 'insensitive' } },
{ title: { contains: keyword, mode: 'insensitive' } },
{
product: {
is: {
OR: [
{ modelNumber: { contains: keyword, mode: 'insensitive' } },
{ name: { contains: keyword, mode: 'insensitive' } },
],
},
},
},
],
}
: {}),
},
orderBy: { updatedAt: 'desc' },
include: {

View File

@ -4,6 +4,7 @@ import {
NON_INTEGRATED_DUPLICATE_SPU_MESSAGE,
createDefaultDocumentWorkspaceConfirmations as createSharedDocumentWorkspaceConfirmations,
normalizeNonIntegratedProductRecord,
resolveNonIntegratedPersistedSpu,
stripNonIntegratedBrandPrefix,
type NonIntegratedDocumentWorkspaceConfirmationMap,
type NonIntegratedProductRecord,
@ -488,10 +489,7 @@ class IntakeNonIntegratedProductService {
throw new AppError(ErrorCode.VALIDATION_FAILED, '非集成产品数据不完整,无法保存')
}
const normalizedSpu = normalizedProduct.spu.trim().toUpperCase()
if (!normalizedSpu) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, 'SPU 不能为空')
}
const normalizedSpu = resolveNonIntegratedPersistedSpu(normalizedProduct)
const normalizedSpuBase = stripNonIntegratedBrandPrefix(normalizedSpu)
const duplicateRecord = await this.repository.findBySpu(normalizedSpu, normalizedProduct.id)