feat(intake): move approval workflow entry to demand draft
This commit is contained in:
parent
1e9fec0bda
commit
f8a0952042
@ -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) {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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 />
|
||||
}
|
||||
@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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}`
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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" ? (
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user