feat(oa-board): add new product flow monitor
This commit is contained in:
parent
da1b05fe6c
commit
a4f22db9b2
@ -44,6 +44,11 @@
|
||||
"materials:sync-standard": "tsx scripts/sync-standard-finishes.ts",
|
||||
"materials:sync-standard:apply": "tsx scripts/sync-standard-finishes.ts --apply",
|
||||
"sync:jst-sku-stock": "tsx scripts/sync-jst-sku-stock-products.ts",
|
||||
"sync:oa-board": "tsx scripts/sync-oa-board.ts",
|
||||
"sync:oa-board:apply": "tsx scripts/sync-oa-board.ts --apply",
|
||||
"import:oa-board-activity-mappings": "tsx scripts/import-oa-board-activity-mappings.ts",
|
||||
"import:oa-board-node-rules": "tsx scripts/import-oa-board-node-rules.ts",
|
||||
"report:oa-board-activities": "tsx scripts/report-oa-board-activities.ts",
|
||||
"sync:employee-directory": "tsx scripts/sync-employee-directory.ts",
|
||||
"sync:employee-directory:apply": "tsx scripts/sync-employee-directory.ts --apply",
|
||||
"db:verify-master-data:truth": "node scripts/verify-master-data-truth-sources.js",
|
||||
|
||||
104
furniture-3d-system/scripts/sync-oa-board.ts
Normal file
104
furniture-3d-system/scripts/sync-oa-board.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import 'dotenv/config'
|
||||
|
||||
type CliOptions = {
|
||||
apply: boolean
|
||||
limit?: number
|
||||
help: boolean
|
||||
}
|
||||
|
||||
function readNumberArg(value: string | undefined, name: string) {
|
||||
if (!value) return undefined
|
||||
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error(`${name} must be a positive number`)
|
||||
}
|
||||
|
||||
return Math.floor(parsed)
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
const options: CliOptions = {
|
||||
apply: false,
|
||||
help: false,
|
||||
}
|
||||
|
||||
argv.forEach((arg) => {
|
||||
if (arg === '--apply') {
|
||||
options.apply = true
|
||||
return
|
||||
}
|
||||
|
||||
if (arg === '--dry-run') {
|
||||
options.apply = false
|
||||
return
|
||||
}
|
||||
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
options.help = true
|
||||
return
|
||||
}
|
||||
|
||||
if (arg.startsWith('--limit=')) {
|
||||
options.limit = readNumberArg(arg.split('=')[1], '--limit')
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(`Unknown argument: ${arg}`)
|
||||
})
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
Usage:
|
||||
npm run sync:oa-board -- [--dry-run] [--apply] [--limit=100]
|
||||
|
||||
Default mode is --dry-run.
|
||||
当前 OA 新品流程看板采用企划库直读模式,--apply 仅保留为兼容参数,不再写本地数据库。
|
||||
|
||||
Preferred source environment variable:
|
||||
OA_BOARD_SOURCE_DATABASE_URL=mysql://user:password@host:port/database
|
||||
|
||||
Alternative source environment variables:
|
||||
OA_BOARD_SOURCE_DB_HOST # prefer 127.0.0.1 / private IP / internal service name
|
||||
OA_BOARD_SOURCE_DB_PORT
|
||||
OA_BOARD_SOURCE_DB_NAME
|
||||
OA_BOARD_SOURCE_DB_USER
|
||||
OA_BOARD_SOURCE_DB_PASSWORD
|
||||
|
||||
Compatible fallback source environment variables:
|
||||
PLANNING_DATABASE_URL
|
||||
PLANNING_DB_HOST
|
||||
PLANNING_DB_PORT
|
||||
PLANNING_DB_NAME
|
||||
PLANNING_DB_USER
|
||||
PLANNING_DB_PASSWORD
|
||||
`.trim())
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
if (options.help) {
|
||||
printHelp()
|
||||
return
|
||||
}
|
||||
|
||||
if (options.apply) {
|
||||
console.warn('当前为企划库直读模式,--apply 不会写本地数据库,将按 dry-run 执行。')
|
||||
}
|
||||
|
||||
const { syncOaBoard } = await import('../src/server/services/oa-board-sync.service')
|
||||
const result = await syncOaBoard({
|
||||
apply: false,
|
||||
limit: options.limit,
|
||||
})
|
||||
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exitCode = 1
|
||||
})
|
||||
40
furniture-3d-system/src/app/api/intake/oa-board/route.ts
Normal file
40
furniture-3d-system/src/app/api/intake/oa-board/route.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { handleRequest, jsonSuccess } from '@/server/core/response'
|
||||
import { createEmptyOaBoardDashboard, oaBoardService } from '@/server/services/oa-board.service'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const keyword = searchParams.get('keyword') ?? ''
|
||||
const overallStatus = searchParams.get('overallStatus') ?? ''
|
||||
const productType = searchParams.get('productType') ?? ''
|
||||
const timeoutOnly = searchParams.get('timeoutOnly') === '1'
|
||||
|
||||
try {
|
||||
const dashboard = await oaBoardService.getDashboard({
|
||||
keyword: keyword || undefined,
|
||||
overallStatus: overallStatus || undefined,
|
||||
productType: productType || undefined,
|
||||
timeoutOnly,
|
||||
})
|
||||
|
||||
return jsonSuccess(dashboard, 200, ctx.requestId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load OA board dashboard', error)
|
||||
return jsonSuccess(
|
||||
{
|
||||
...createEmptyOaBoardDashboard(),
|
||||
notice: '当前看板表尚未完成迁移或同步,先返回空列表占位。',
|
||||
},
|
||||
200,
|
||||
ctx.requestId,
|
||||
)
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
)
|
||||
}
|
||||
387
furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx
Normal file
387
furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx
Normal file
@ -0,0 +1,387 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle, CheckCircle2, Clock3, Layers3, LoaderCircle } from 'lucide-react'
|
||||
import type {
|
||||
OaBoardDashboardDTO,
|
||||
OaBoardNodeAssessmentDTO,
|
||||
OaBoardProjectListItemDTO,
|
||||
} from '@/server/services/oa-board.service'
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
description,
|
||||
tone = 'default',
|
||||
}: {
|
||||
label: string
|
||||
value: string | number
|
||||
description: string
|
||||
tone?: 'default' | 'warning' | 'danger' | 'success'
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'danger'
|
||||
? 'border-rose-200 bg-rose-50 text-rose-700'
|
||||
: tone === 'warning'
|
||||
? 'border-amber-200 bg-amber-50 text-amber-700'
|
||||
: tone === 'success'
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
: 'border-apple-separator bg-apple-bg-primary text-apple-label-primary'
|
||||
|
||||
return (
|
||||
<div className={`rounded-apple-xl border px-4 py-3 shadow-apple-xs ${toneClass}`}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs">{label}</span>
|
||||
<span className="text-lg font-apple-semibold">{value}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] leading-5 opacity-80">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FlowChip({
|
||||
flow,
|
||||
}: {
|
||||
flow: OaBoardProjectListItemDTO['flowChips'][number]
|
||||
}) {
|
||||
const statusClass =
|
||||
flow.status === 'RUNNING'
|
||||
? 'border-amber-200 bg-amber-50 text-amber-700'
|
||||
: flow.status === 'COMPLETED'
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
: flow.status === 'TERMINATED'
|
||||
? 'border-rose-200 bg-rose-50 text-rose-700'
|
||||
: 'border-apple-separator bg-apple-bg-secondary text-apple-label-secondary'
|
||||
|
||||
return (
|
||||
<div className={`rounded-apple-lg border px-2.5 py-2 ${statusClass}`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-[11px] font-apple-medium">{flow.flowLabel}</span>
|
||||
{flow.usedWorkflowFallback ? (
|
||||
<span className="shrink-0 rounded-full bg-white/60 px-1.5 py-0.5 text-[10px]">兜底</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1 text-[10px] opacity-80">
|
||||
<Clock3 className="h-3 w-3" />
|
||||
<span>{flow.actualHours !== null ? `${flow.actualHours}h` : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectRow({
|
||||
item,
|
||||
index,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
item: OaBoardProjectListItemDTO
|
||||
index: number
|
||||
selected: boolean
|
||||
onSelect: (boardKey: string) => void
|
||||
}) {
|
||||
return (
|
||||
<tr
|
||||
className={[
|
||||
'cursor-pointer border-b border-apple-separator odd:bg-apple-bg-primary even:bg-apple-bg-secondary/35',
|
||||
selected ? 'bg-[color:color-mix(in_srgb,var(--color-blue)_8%,white)]' : '',
|
||||
].join(' ')}
|
||||
onClick={() => onSelect(item.boardKey)}
|
||||
>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-secondary">{index + 1}</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">
|
||||
{item.formSummary.styleImageLabel || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">
|
||||
<div className="font-apple-medium text-apple-label-primary">
|
||||
{item.formSummary.openingCode || item.openingCode || item.boardKey}
|
||||
</div>
|
||||
<div className="mt-1 text-apple-label-secondary">{item.title}</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">
|
||||
{item.formSummary.totalFlowStatusLabel || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">
|
||||
{item.formSummary.approvalStageDisplay || item.currentApprovalStageName || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">
|
||||
{item.formSummary.currentOwnerDisplay || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">
|
||||
{item.formSummary.currentNodeDisplay || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">
|
||||
{item.formSummary.demandType || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">
|
||||
{item.formSummary.developmentPlan || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">
|
||||
{item.formSummary.productType || item.productType || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">
|
||||
{item.formSummary.plannedSalesBrands || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">
|
||||
{item.formSummary.productPlanner || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">
|
||||
{item.formSummary.spuOwner || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">
|
||||
{item.formSummary.purchaseOwner || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">
|
||||
<div>目标:{item.overallTargetHours ?? '-'}h</div>
|
||||
<div className="mt-1">实际:{item.overallActualHours ?? '-'}h</div>
|
||||
<div className={`mt-1 ${item.overallTimeoutFlag ? 'text-rose-600' : 'text-apple-label-secondary'}`}>
|
||||
{item.overallTimeoutFlag ? `超时 ${item.overallTimeoutHours ?? '-'}h` : '未超时'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div className="grid min-w-[240px] grid-cols-2 gap-2">
|
||||
{item.flowChips.map((flow) => (
|
||||
<FlowChip key={`${flow.flowType}:${flow.processInstanceId}`} flow={flow} />
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function NodeAssessmentRow({
|
||||
item,
|
||||
index,
|
||||
}: {
|
||||
item: OaBoardNodeAssessmentDTO
|
||||
index: number
|
||||
}) {
|
||||
return (
|
||||
<tr className="border-b border-apple-separator odd:bg-apple-bg-primary even:bg-apple-bg-secondary/35">
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-secondary">{index + 1}</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">{item.flowLabel}</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">{item.nodeName}</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">{item.approvalStageName || '-'}</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">{item.departmentLabel || '-'}</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">{item.ownerLabel || '-'}</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">{item.targetHours ?? '-'}h</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">{item.actualHours ?? '-'}h</td>
|
||||
<td className={`px-3 py-3 align-top text-[11px] ${item.isOvertime ? 'text-rose-600' : 'text-emerald-700'}`}>
|
||||
{item.isOvertime ? '超时' : '未超时'}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">{item.overtimeHours ?? '-'}h</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">{item.status}</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">{item.startedAtSource || '-'}</td>
|
||||
<td className="px-3 py-3 align-top text-[11px] text-apple-label-primary">{item.finishedAtSource || '-'}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailField({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string
|
||||
value: string | null | undefined
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/45 px-3 py-3">
|
||||
<div className="text-[11px] text-apple-label-secondary">{label}</div>
|
||||
<div className="mt-1 text-xs text-apple-label-primary">{value || '-'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OaBoardClient({
|
||||
dashboard,
|
||||
loadErrorMessage,
|
||||
}: {
|
||||
dashboard: OaBoardDashboardDTO
|
||||
loadErrorMessage?: string | null
|
||||
}) {
|
||||
const { summary, items } = dashboard
|
||||
const [selectedBoardKey, setSelectedBoardKey] = useState(items[0]?.boardKey ?? '')
|
||||
const selectedItem = items.find((item) => item.boardKey === selectedBoardKey) ?? items[0] ?? null
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#eef2f5] px-4 py-4">
|
||||
<div className="mx-auto flex w-full max-w-[1600px] flex-col gap-4">
|
||||
{loadErrorMessage ? (
|
||||
<section className="rounded-apple-xl border border-amber-200 bg-amber-50 px-4 py-3 shadow-apple-xs">
|
||||
<div className="flex items-start gap-2 text-xs text-amber-700">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<div className="font-apple-medium">当前页面已挂载,但企划库流程数据还没准备好。</div>
|
||||
<div className="mt-1 leading-5">{loadErrorMessage}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-apple-xl border border-apple-separator bg-apple-bg-primary px-4 py-4 shadow-apple-xs">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-base font-apple-semibold text-apple-label-primary">OA 新品流程看板</h1>
|
||||
<p className="mt-1 text-xs leading-5 text-apple-label-secondary">
|
||||
直接读取企划库流程数据,集中查看新品开款、审批进度和节点考核时效。今天这版先完成列表和节点详情联看雏形。
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 py-2 text-xs text-apple-label-secondary">
|
||||
<LoaderCircle className="h-3.5 w-3.5" />
|
||||
<span>当前页面直读企划库数据</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard label="项目总数" value={summary.totalProjects} description="当前命中的新品项目总数。" />
|
||||
<StatCard label="流程中" value={summary.runningProjects} description="至少有一个子流程仍处于进行中的项目。" tone="warning" />
|
||||
<StatCard label="已完成" value={summary.completedProjects} description="核心流程已全部走完的项目。" tone="success" />
|
||||
<StatCard label="超时项目" value={summary.timeoutProjects} description="按产品类型总目标时效判断已超时的项目。" tone="danger" />
|
||||
</section>
|
||||
|
||||
<section className="overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-primary shadow-apple-xs">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-apple-separator px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-apple-semibold text-apple-label-primary">项目列表</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-apple-label-secondary">
|
||||
列表字段对齐附件三口径,选中项目后,下方会联动展示该项目的节点考核详情。
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 py-2 text-xs text-apple-label-secondary">
|
||||
<Layers3 className="h-3.5 w-3.5" />
|
||||
<span>{items.length} 个项目</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[720px] overflow-auto">
|
||||
<table className="min-w-[2680px] table-fixed text-left text-[11px]">
|
||||
<thead className="sticky top-0 z-10 bg-apple-bg-primary text-apple-label-secondary">
|
||||
<tr className="border-b border-apple-separator">
|
||||
<th className="w-[60px] px-3 py-2.5 font-apple-medium">序号</th>
|
||||
<th className="w-[90px] px-3 py-2.5 font-apple-medium">款式图片</th>
|
||||
<th className="w-[220px] px-3 py-2.5 font-apple-medium">开款编号</th>
|
||||
<th className="w-[120px] px-3 py-2.5 font-apple-medium">总流程状态</th>
|
||||
<th className="w-[160px] px-3 py-2.5 font-apple-medium">流程审批阶段</th>
|
||||
<th className="w-[140px] px-3 py-2.5 font-apple-medium">当前负责人</th>
|
||||
<th className="w-[180px] px-3 py-2.5 font-apple-medium">当前审批节点</th>
|
||||
<th className="w-[120px] px-3 py-2.5 font-apple-medium">需求类型</th>
|
||||
<th className="w-[120px] px-3 py-2.5 font-apple-medium">开发方案</th>
|
||||
<th className="w-[200px] px-3 py-2.5 font-apple-medium">产品类型</th>
|
||||
<th className="w-[180px] px-3 py-2.5 font-apple-medium">计划售卖品牌</th>
|
||||
<th className="w-[120px] px-3 py-2.5 font-apple-medium">商品企划</th>
|
||||
<th className="w-[120px] px-3 py-2.5 font-apple-medium">SPU负责人</th>
|
||||
<th className="w-[120px] px-3 py-2.5 font-apple-medium">采购负责人</th>
|
||||
<th className="w-[120px] px-3 py-2.5 font-apple-medium">限价负责人</th>
|
||||
<th className="w-[120px] px-3 py-2.5 font-apple-medium">SPU</th>
|
||||
<th className="w-[180px] px-3 py-2.5 font-apple-medium">总时效</th>
|
||||
<th className="w-[320px] px-3 py-2.5 font-apple-medium">流程片段</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={18} className="px-4 py-12 text-center text-xs text-apple-label-secondary">
|
||||
<div className="mx-auto flex max-w-md flex-col items-center gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-apple-label-tertiary" />
|
||||
<p>当前还没有拿到可展示的企划库流程数据。先检查企划库连接与节点/时效规则,再刷新页面。</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((item, index) => (
|
||||
<ProjectRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
selected={selectedItem?.boardKey === item.boardKey}
|
||||
onSelect={setSelectedBoardKey}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-primary shadow-apple-xs">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-apple-separator px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-apple-semibold text-apple-label-primary">节点考核详情</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-apple-label-secondary">
|
||||
选中一个项目后,按节点查看目标时效、实际用时和超时情况。今天先做成单页联看的监控雏形。
|
||||
</p>
|
||||
</div>
|
||||
{selectedItem ? (
|
||||
<div className="inline-flex items-center gap-2 rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 py-2 text-xs text-apple-label-secondary">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
<span>{selectedItem.formSummary.openingCode || selectedItem.boardKey}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!selectedItem ? (
|
||||
<div className="px-4 py-12 text-center text-xs text-apple-label-secondary">
|
||||
暂无可查看的项目节点明细。
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-3 border-b border-apple-separator px-4 py-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<DetailField label="当前审批阶段" value={selectedItem.currentApprovalStageName} />
|
||||
<DetailField label="当前负责人" value={selectedItem.formSummary.currentOwnerDisplay} />
|
||||
<DetailField label="当前审批节点" value={selectedItem.formSummary.currentNodeDisplay} />
|
||||
<DetailField label="产品类型" value={selectedItem.formSummary.productType || selectedItem.productType} />
|
||||
<DetailField label="计划售卖品牌" value={selectedItem.formSummary.plannedSalesBrands} />
|
||||
<DetailField label="流程链接" value={selectedItem.formSummary.workflowLink} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 border-b border-apple-separator px-4 py-4 md:grid-cols-3 xl:grid-cols-6">
|
||||
<StatCard label="节点总数" value={selectedItem.nodeAssessments.length} description="当前项目已识别到的节点考核行。" />
|
||||
<StatCard label="超时节点" value={selectedItem.nodeAssessments.filter((node) => node.isOvertime).length} description="目标时效已被超过的节点数量。" tone="danger" />
|
||||
<StatCard label="项目目标时效" value={selectedItem.overallTargetHours ?? '-'} description="按产品类型配置的总目标时效。" />
|
||||
<StatCard label="项目实际用时" value={selectedItem.overallActualHours ?? '-'} description="当前项目累计实际用时。" tone="warning" />
|
||||
<StatCard label="总超时小时" value={selectedItem.overallTimeoutHours ?? '-'} description="超过项目总目标时效的小时数。" tone={selectedItem.overallTimeoutFlag ? 'danger' : 'success'} />
|
||||
<StatCard label="总流程状态" value={selectedItem.formSummary.totalFlowStatusLabel || '-'} description="当前项目总流程的状态摘要。" />
|
||||
</div>
|
||||
|
||||
<div className="max-h-[520px] overflow-auto">
|
||||
<table className="min-w-[1760px] table-fixed text-left text-[11px]">
|
||||
<thead className="sticky top-0 z-10 bg-apple-bg-primary text-apple-label-secondary">
|
||||
<tr className="border-b border-apple-separator">
|
||||
<th className="w-[60px] px-3 py-2.5 font-apple-medium">序号</th>
|
||||
<th className="w-[160px] px-3 py-2.5 font-apple-medium">流程</th>
|
||||
<th className="w-[180px] px-3 py-2.5 font-apple-medium">节点名称</th>
|
||||
<th className="w-[180px] px-3 py-2.5 font-apple-medium">审批阶段</th>
|
||||
<th className="w-[120px] px-3 py-2.5 font-apple-medium">责任部门</th>
|
||||
<th className="w-[140px] px-3 py-2.5 font-apple-medium">负责人</th>
|
||||
<th className="w-[120px] px-3 py-2.5 font-apple-medium">目标时效</th>
|
||||
<th className="w-[120px] px-3 py-2.5 font-apple-medium">实际用时</th>
|
||||
<th className="w-[100px] px-3 py-2.5 font-apple-medium">是否超时</th>
|
||||
<th className="w-[120px] px-3 py-2.5 font-apple-medium">超时小时</th>
|
||||
<th className="w-[100px] px-3 py-2.5 font-apple-medium">状态</th>
|
||||
<th className="w-[180px] px-3 py-2.5 font-apple-medium">开始时间</th>
|
||||
<th className="w-[180px] px-3 py-2.5 font-apple-medium">完成时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedItem.nodeAssessments.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={13} className="px-4 py-12 text-center text-xs text-apple-label-secondary">
|
||||
当前项目还没有可展示的节点考核数据。
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
selectedItem.nodeAssessments.map((node, index) => (
|
||||
<NodeAssessmentRow key={`${node.taskId}:${index}`} item={node} index={index} />
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
furniture-3d-system/src/app/intake/oa-board/page.tsx
Normal file
37
furniture-3d-system/src/app/intake/oa-board/page.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import { createEmptyOaBoardDashboard, oaBoardService } from '@/server/services/oa-board.service'
|
||||
import OaBoardClient from './OaBoardClient'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function OaBoardPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
await requireSession('/login')
|
||||
const params = searchParams ? await searchParams : {}
|
||||
|
||||
const keyword = typeof params.keyword === 'string' ? params.keyword : ''
|
||||
const overallStatus = typeof params.overallStatus === 'string' ? params.overallStatus : ''
|
||||
const productType = typeof params.productType === 'string' ? params.productType : ''
|
||||
const timeoutOnly = params.timeoutOnly === '1'
|
||||
let loadErrorMessage: string | null = null
|
||||
|
||||
const dashboard = await oaBoardService
|
||||
.getDashboard({
|
||||
keyword: keyword || undefined,
|
||||
overallStatus: overallStatus || undefined,
|
||||
productType: productType || undefined,
|
||||
timeoutOnly,
|
||||
})
|
||||
.catch((error) => {
|
||||
loadErrorMessage =
|
||||
error instanceof Error
|
||||
? `页面骨架已生成,但读取企划库流程数据失败:${error.message}`
|
||||
: '页面骨架已生成,但读取企划库流程数据失败。'
|
||||
return createEmptyOaBoardDashboard()
|
||||
})
|
||||
|
||||
return <OaBoardClient dashboard={dashboard} loadErrorMessage={loadErrorMessage} />
|
||||
}
|
||||
@ -8,6 +8,7 @@ import {
|
||||
Bell,
|
||||
Boxes,
|
||||
ClipboardCheck,
|
||||
ClipboardList,
|
||||
Home,
|
||||
Layers,
|
||||
Menu,
|
||||
@ -31,6 +32,7 @@ const WORKBENCH_NAV_ITEMS = [
|
||||
{ href: '/assisted-assembly', icon: Layers, label: '辅助装配' },
|
||||
{ href: '/draft-groups', icon: PackageOpen, label: '草稿组' },
|
||||
{ href: '/intake', icon: ArrowDownToLine, label: '产品资料' },
|
||||
{ href: '/intake/oa-board', icon: ClipboardList, label: 'OA 新品流程看板' },
|
||||
{ href: '/warehouse', icon: Warehouse, label: '仓库管理' },
|
||||
{ href: '/warehouse-quality', icon: ClipboardCheck, label: '仓库质检' },
|
||||
]
|
||||
|
||||
1239
furniture-3d-system/src/server/services/oa-board-sync.service.ts
Normal file
1239
furniture-3d-system/src/server/services/oa-board-sync.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
377
furniture-3d-system/src/server/services/oa-board.constants.ts
Normal file
377
furniture-3d-system/src/server/services/oa-board.constants.ts
Normal file
@ -0,0 +1,377 @@
|
||||
export const OA_BOARD_PROCESS_CODES = {
|
||||
main: 'PROC-BD1F3F5C-8663-49DA-9B8E-43447298275B',
|
||||
review: 'PROC-3A0218B4-03F7-42E6-B354-F56BD2FDDDDB',
|
||||
order: 'PROC-34AC5E78-83DD-469C-8D5F-479CEC076FB7',
|
||||
art: 'PROC-3571C915-0506-4490-A91A-8AA7EA33F838',
|
||||
pricing: 'PROC-897997DD-180B-4086-B2A1-4C65C998FC5C',
|
||||
} as const
|
||||
|
||||
export type OaBoardFlowType = keyof typeof OA_BOARD_PROCESS_CODES
|
||||
|
||||
export const OA_BOARD_FLOW_LABELS: Record<OaBoardFlowType, string> = {
|
||||
main: '新品开发流程',
|
||||
review: '新品开发立项评审流程',
|
||||
order: '新品首单下单出货流程',
|
||||
art: '美术设计申请',
|
||||
pricing: '新品限价流程',
|
||||
}
|
||||
|
||||
export const OA_BOARD_WORKBOOK_FLOW_NAME_MAP = {
|
||||
新品开发立项评审流程: 'review',
|
||||
新品首单下单流程: 'order',
|
||||
美术设计申请流程: 'art',
|
||||
新品限价流程: 'pricing',
|
||||
} as const satisfies Record<string, Exclude<OaBoardFlowType, 'main'>>
|
||||
|
||||
export const OA_BOARD_FLOW_PRIORITY: OaBoardFlowType[] = [
|
||||
'review',
|
||||
'order',
|
||||
'pricing',
|
||||
'art',
|
||||
'main',
|
||||
]
|
||||
|
||||
export const OA_BOARD_PRODUCT_TARGET_HOURS: Record<string, number> = {
|
||||
'成品开发-实木': 45,
|
||||
'成品开发-板式/钢木/铝制(无需磨具)': 21,
|
||||
'成品开发-钢木/铝制(需磨具)': 46,
|
||||
'成品开发-塑料/软体': 8,
|
||||
'部件开发-实木部件': 15,
|
||||
'部件开发-板式/钢/铝/塑料/五金部件(无需磨具)': 9,
|
||||
'部件开发-板式/钢/铝/塑料/五金部件(需磨具)': 46,
|
||||
'部件开发-纸箱/泡沫': 17,
|
||||
}
|
||||
|
||||
export type OaBoardStageDefinition = {
|
||||
key: string
|
||||
name: string
|
||||
flowType: OaBoardFlowType | 'main' | 'shared'
|
||||
summary: string
|
||||
sequence: number
|
||||
}
|
||||
|
||||
export const OA_BOARD_STAGE_DEFINITIONS: OaBoardStageDefinition[] = [
|
||||
{
|
||||
key: 'review_stage',
|
||||
name: '立项评审',
|
||||
flowType: 'review',
|
||||
summary: '从产品经理分配任务开始,到结构审核为止。',
|
||||
sequence: 1,
|
||||
},
|
||||
{
|
||||
key: 'sample_prepare_stage',
|
||||
name: '打样安排',
|
||||
flowType: 'order',
|
||||
summary: '承接确认打样出货时间、填写样品快递单号。',
|
||||
sequence: 2,
|
||||
},
|
||||
{
|
||||
key: 'sample_review_stage',
|
||||
name: '样品评审',
|
||||
flowType: 'order',
|
||||
summary: '承接样品评审、售卖品牌确认与空间布局。',
|
||||
sequence: 3,
|
||||
},
|
||||
{
|
||||
key: 'order_delivery_stage',
|
||||
name: '首单下单出货',
|
||||
flowType: 'order',
|
||||
summary: '商品资料输出到送仓入库前的供应链推进阶段。',
|
||||
sequence: 4,
|
||||
},
|
||||
{
|
||||
key: 'art_stage',
|
||||
name: '美术设计',
|
||||
flowType: 'art',
|
||||
summary: '品牌策划主管分配任务、出策划案、出图。',
|
||||
sequence: 5,
|
||||
},
|
||||
{
|
||||
key: 'pricing_stage',
|
||||
name: '限价审批',
|
||||
flowType: 'pricing',
|
||||
summary: '发起产品限价到部门负责人审核。',
|
||||
sequence: 6,
|
||||
},
|
||||
{
|
||||
key: 'listing_stage',
|
||||
name: '运营上架',
|
||||
flowType: 'main',
|
||||
summary: '当前以总流程承接,后续再细化审批阶段数据。',
|
||||
sequence: 7,
|
||||
},
|
||||
]
|
||||
|
||||
export type OaBoardStageTargetMatrix = Record<string, number | null>
|
||||
|
||||
export const OA_BOARD_STAGE_TARGET_MATRIX: Record<string, OaBoardStageTargetMatrix> = {
|
||||
'成品开发-实木': {
|
||||
review_stage: 5,
|
||||
sample_prepare_stage: 8,
|
||||
sample_review_stage: 2,
|
||||
order_delivery_stage: 30,
|
||||
art_stage: 4,
|
||||
pricing_stage: 1,
|
||||
listing_stage: 1,
|
||||
},
|
||||
'成品开发-板式/钢木/铝制(无需磨具)': {
|
||||
review_stage: 2,
|
||||
sample_prepare_stage: 3,
|
||||
sample_review_stage: 2,
|
||||
order_delivery_stage: 13,
|
||||
art_stage: 4,
|
||||
pricing_stage: 1,
|
||||
listing_stage: 1,
|
||||
},
|
||||
'成品开发-钢木/铝制(需磨具)': {
|
||||
review_stage: 2,
|
||||
sample_prepare_stage: 7,
|
||||
sample_review_stage: 2,
|
||||
order_delivery_stage: 34,
|
||||
art_stage: 4,
|
||||
pricing_stage: 1,
|
||||
listing_stage: 1,
|
||||
},
|
||||
'成品开发-塑料/软体': {
|
||||
review_stage: 2,
|
||||
sample_prepare_stage: 0,
|
||||
sample_review_stage: 2,
|
||||
order_delivery_stage: 3,
|
||||
art_stage: 4,
|
||||
pricing_stage: 1,
|
||||
listing_stage: 1,
|
||||
},
|
||||
'部件开发-实木部件': {
|
||||
review_stage: 2,
|
||||
sample_prepare_stage: 3,
|
||||
sample_review_stage: 2,
|
||||
order_delivery_stage: 7,
|
||||
art_stage: 4,
|
||||
pricing_stage: 1,
|
||||
listing_stage: 1,
|
||||
},
|
||||
'部件开发-板式/钢/铝/塑料/五金部件(无需磨具)': {
|
||||
review_stage: 2,
|
||||
sample_prepare_stage: 2,
|
||||
sample_review_stage: 2,
|
||||
order_delivery_stage: 2,
|
||||
art_stage: 4,
|
||||
pricing_stage: 1,
|
||||
listing_stage: 1,
|
||||
},
|
||||
'部件开发-板式/钢/铝/塑料/五金部件(需磨具)': {
|
||||
review_stage: 2,
|
||||
sample_prepare_stage: 7,
|
||||
sample_review_stage: 2,
|
||||
order_delivery_stage: 34,
|
||||
art_stage: 4,
|
||||
pricing_stage: 1,
|
||||
listing_stage: 1,
|
||||
},
|
||||
'部件开发-纸箱/泡沫': {
|
||||
review_stage: 2,
|
||||
sample_prepare_stage: 5,
|
||||
sample_review_stage: 2,
|
||||
order_delivery_stage: 7,
|
||||
art_stage: 4,
|
||||
pricing_stage: 1,
|
||||
listing_stage: 1,
|
||||
},
|
||||
}
|
||||
|
||||
export type OaBoardDefaultStageRule = {
|
||||
productType: string
|
||||
ruleType: 'STAGE'
|
||||
ruleKey: string
|
||||
displayName: string
|
||||
flowType: string
|
||||
stageKey: string
|
||||
targetHours: number
|
||||
sequence: number
|
||||
metadata: {
|
||||
source: '看板需求.xlsx'
|
||||
summary: string
|
||||
}
|
||||
}
|
||||
|
||||
export const OA_BOARD_DEFAULT_STAGE_RULES: OaBoardDefaultStageRule[] = Object.entries(
|
||||
OA_BOARD_STAGE_TARGET_MATRIX,
|
||||
).flatMap(([productType, stageTargets]) =>
|
||||
OA_BOARD_STAGE_DEFINITIONS.map((stage) => ({
|
||||
productType,
|
||||
ruleType: 'STAGE' as const,
|
||||
ruleKey: stage.key,
|
||||
displayName: stage.name,
|
||||
flowType: stage.flowType,
|
||||
stageKey: stage.key,
|
||||
targetHours: stageTargets[stage.key] ?? 0,
|
||||
sequence: stage.sequence,
|
||||
metadata: {
|
||||
source: '看板需求.xlsx' as const,
|
||||
summary: stage.summary,
|
||||
},
|
||||
})),
|
||||
)
|
||||
|
||||
export type OaBoardDefaultActivityMapping = {
|
||||
flowType: OaBoardFlowType
|
||||
processCode: string
|
||||
activityId: string
|
||||
nodeKey: string
|
||||
nodeName: string
|
||||
stageKey: string
|
||||
stageName: string
|
||||
timelineStageKey: string
|
||||
timelineStageName: string
|
||||
sequence: number
|
||||
metadata: {
|
||||
source: '流程节点代码汇总表_审批阶段.xlsx'
|
||||
}
|
||||
}
|
||||
|
||||
type MappingSeed = Array<{
|
||||
activityId: string
|
||||
nodeName: string
|
||||
stageName: string | null
|
||||
}>
|
||||
|
||||
const OA_BOARD_ACTIVITY_MAPPING_SEED: Record<OaBoardFlowType, MappingSeed> = {
|
||||
review: [
|
||||
{ activityId: 'sid-startevent', nodeName: '发起人', stageName: '发起申请' },
|
||||
{ activityId: '6053_7843', nodeName: '申请人填写信息', stageName: '发起申请' },
|
||||
{ activityId: 'de44_dd3d', nodeName: '申请人填写信息', stageName: '发起申请' },
|
||||
{ activityId: '2579_b24b', nodeName: '企划市场分析', stageName: '企划市场分析' },
|
||||
{ activityId: '32c3_8a31', nodeName: '申请人填写信息', stageName: '发起申请' },
|
||||
{ activityId: 'a2e4_8dad', nodeName: '申请人填写信息', stageName: '发起申请' },
|
||||
{ activityId: '02a7_67ab', nodeName: '抄送部门负责人', stageName: null },
|
||||
{ activityId: 'e720_4896', nodeName: '企划市场分析', stageName: '企划市场分析' },
|
||||
{ activityId: '9bbc_05e2', nodeName: '企划市场分析会', stageName: '企划市场分析' },
|
||||
{ activityId: '312e_88c8', nodeName: '确认开发方案', stageName: '企划市场分析' },
|
||||
{ activityId: '3acd_4e51', nodeName: '商品企划负责人审核', stageName: '企划市场分析' },
|
||||
{ activityId: '4363_12fe', nodeName: '产品经理分配任务', stageName: '产品设计-外观' },
|
||||
{ activityId: 'f097_3875', nodeName: '设计外观', stageName: '产品设计-外观' },
|
||||
],
|
||||
order: [
|
||||
{ activityId: 'sid-startevent', nodeName: '发起人', stageName: '产品生产-首单下单' },
|
||||
{ activityId: '800d_01f4', nodeName: '首单下单(线下签订合同)', stageName: '产品生产-首单下单' },
|
||||
{ activityId: '1a3f_a9be', nodeName: '供应商核对信息', stageName: '产品生产-首单下单' },
|
||||
{ activityId: 'e191_b262', nodeName: '核对工厂反馈信息', stageName: '产品生产-首单下单' },
|
||||
{ activityId: 'c77b_ba99', nodeName: '产前样确认-采购反馈', stageName: '产品生产-产前样' },
|
||||
{ activityId: '6d5d_7e35', nodeName: '产前样确认-结构设计师确认', stageName: '产品生产-产前样' },
|
||||
{ activityId: 'b715_3d1f', nodeName: '生产/巡检/出货', stageName: '产品生产-生产出货' },
|
||||
],
|
||||
art: [
|
||||
{ activityId: 'sid-startevent', nodeName: '发起人', stageName: '出图-需求发起' },
|
||||
{ activityId: '1de3_de55', nodeName: '部门负责人审核', stageName: '出图-需求审核' },
|
||||
{ activityId: '94c1_1f0d', nodeName: '部门总监审核', stageName: '出图-需求审核' },
|
||||
{ activityId: '3cb8_8096', nodeName: '品牌策划总监审批', stageName: '出图-需求审核' },
|
||||
{ activityId: '38da_544a', nodeName: '品牌策划主管分配任务', stageName: '出图-制作中' },
|
||||
{ activityId: '31cf_5b23', nodeName: '出策划案', stageName: '出图-制作中' },
|
||||
{ activityId: 'a9e1_5be0', nodeName: '出图', stageName: '出图-制作中' },
|
||||
{ activityId: 'de46_708d', nodeName: '平面', stageName: '出图-制作中' },
|
||||
],
|
||||
pricing: [
|
||||
{ activityId: 'sid-startevent', nodeName: '发起人', stageName: '限价-发起' },
|
||||
{ activityId: 'c4f8_b69c', nodeName: '部门负责人审批', stageName: '限价-审核' },
|
||||
{ activityId: '3c61_a425', nodeName: '商品企划负责人审核', stageName: '限价-审核' },
|
||||
],
|
||||
main: [
|
||||
{ activityId: 'sid-startevent', nodeName: '发起人', stageName: '总流程-发起' },
|
||||
],
|
||||
}
|
||||
|
||||
export function resolveOaBoardTimelineStage(
|
||||
flowType: OaBoardFlowType,
|
||||
nodeName: string,
|
||||
approvalStageName: string | null,
|
||||
) {
|
||||
if (flowType === 'review') {
|
||||
return { key: 'review_stage', name: '立项评审' }
|
||||
}
|
||||
|
||||
if (flowType === 'art') {
|
||||
return { key: 'art_stage', name: '美术设计' }
|
||||
}
|
||||
|
||||
if (flowType === 'pricing') {
|
||||
return { key: 'pricing_stage', name: '限价审批' }
|
||||
}
|
||||
|
||||
if (flowType === 'main') {
|
||||
return { key: 'listing_stage', name: '运营上架' }
|
||||
}
|
||||
|
||||
const combinedText = `${nodeName} ${approvalStageName ?? ''}`
|
||||
|
||||
if (
|
||||
combinedText.includes('确认打样出货时间') ||
|
||||
combinedText.includes('填写样品快递单号')
|
||||
) {
|
||||
return { key: 'sample_prepare_stage', name: '打样安排' }
|
||||
}
|
||||
|
||||
if (
|
||||
combinedText.includes('样品评审') ||
|
||||
combinedText.includes('售卖品牌') ||
|
||||
combinedText.includes('空间布局')
|
||||
) {
|
||||
return { key: 'sample_review_stage', name: '样品评审' }
|
||||
}
|
||||
|
||||
return { key: 'order_delivery_stage', name: '首单下单出货' }
|
||||
}
|
||||
|
||||
export const OA_BOARD_DEFAULT_ACTIVITY_MAPPINGS: OaBoardDefaultActivityMapping[] = (
|
||||
Object.entries(OA_BOARD_ACTIVITY_MAPPING_SEED) as Array<[OaBoardFlowType, MappingSeed]>
|
||||
).flatMap(([flowType, rows]) =>
|
||||
rows.map((row, index) => {
|
||||
const stageKey =
|
||||
row.stageName?.trim()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.toLowerCase() ?? `flow_${flowType}`
|
||||
|
||||
const nodeKey = `${flowType}:${row.activityId}`
|
||||
const timelineStage = resolveOaBoardTimelineStage(flowType, row.nodeName, row.stageName)
|
||||
|
||||
return {
|
||||
flowType,
|
||||
processCode: OA_BOARD_PROCESS_CODES[flowType],
|
||||
activityId: row.activityId,
|
||||
nodeKey,
|
||||
nodeName: row.nodeName,
|
||||
stageKey,
|
||||
stageName: row.stageName ?? '未归类阶段',
|
||||
timelineStageKey: timelineStage.key,
|
||||
timelineStageName: timelineStage.name,
|
||||
sequence: index + 1,
|
||||
metadata: {
|
||||
source: '流程节点代码汇总表_审批阶段.xlsx' as const,
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
export function getOaBoardProductTargetHours(productType: string | null | undefined) {
|
||||
if (!productType) return null
|
||||
return OA_BOARD_PRODUCT_TARGET_HOURS[productType] ?? null
|
||||
}
|
||||
|
||||
export const OA_BOARD_JOIN_FIELD_NAMES = {
|
||||
openingCode: ['开款编号'],
|
||||
orderCode: ['新品首单下单号'],
|
||||
pricingCode: ['品牌限价单号'],
|
||||
artCode: ['视觉出图单号'],
|
||||
spu: ['SPU'],
|
||||
productType: ['产品类型'],
|
||||
brand: ['品牌', '开款品牌'],
|
||||
productCategory: ['品类'],
|
||||
categoryName: ['类目'],
|
||||
productName: ['产品名称'],
|
||||
} as const
|
||||
|
||||
export const OA_BOARD_RUNNING_STATUSES = new Set(['RUNNING', 'PROCESSING', 'PENDING'])
|
||||
|
||||
export const OA_BOARD_COMPLETED_STATUSES = new Set(['COMPLETED', 'TERMINATED'])
|
||||
|
||||
export const OA_BOARD_PARALLEL_FLOW_TYPES = new Set<OaBoardFlowType>(['order', 'art'])
|
||||
418
furniture-3d-system/src/server/services/oa-board.service.ts
Normal file
418
furniture-3d-system/src/server/services/oa-board.service.ts
Normal file
@ -0,0 +1,418 @@
|
||||
import {
|
||||
getOaBoardProductTargetHours,
|
||||
OA_BOARD_FLOW_LABELS,
|
||||
OA_BOARD_FLOW_PRIORITY,
|
||||
OA_BOARD_STAGE_DEFINITIONS,
|
||||
OA_BOARD_STAGE_TARGET_MATRIX,
|
||||
} from './oa-board.constants'
|
||||
import { queryOaBoardPreview } from './oa-board-sync.service'
|
||||
|
||||
export interface OaBoardProjectListFilter {
|
||||
keyword?: string
|
||||
overallStatus?: string
|
||||
productType?: string
|
||||
timeoutOnly?: boolean
|
||||
}
|
||||
|
||||
export interface OaBoardFlowChipDTO {
|
||||
flowType: string
|
||||
flowLabel: string
|
||||
processName: string
|
||||
processInstanceId: string
|
||||
status: string
|
||||
result: string | null
|
||||
actualHours: number | null
|
||||
createdAtSource: string | null
|
||||
finishedAtSource: string | null
|
||||
usedWorkflowFallback: boolean
|
||||
}
|
||||
|
||||
export interface OaBoardNodeAssessmentDTO {
|
||||
flowType: string
|
||||
flowLabel: string
|
||||
processName: string
|
||||
taskId: string
|
||||
nodeName: string
|
||||
approvalStageName: string | null
|
||||
ownerLabel: string | null
|
||||
departmentLabel: string | null
|
||||
targetHours: number | null
|
||||
actualHours: number | null
|
||||
overtimeHours: number | null
|
||||
isOvertime: boolean
|
||||
status: string
|
||||
startedAtSource: string | null
|
||||
finishedAtSource: string | null
|
||||
}
|
||||
|
||||
export interface OaBoardProjectListItemDTO {
|
||||
id: string
|
||||
boardKey: string
|
||||
boardKeyType: string
|
||||
title: string
|
||||
productName: string | null
|
||||
productType: string | null
|
||||
brand: string | null
|
||||
productCategory: string | null
|
||||
categoryName: string | null
|
||||
spu: string | null
|
||||
openingCode: string | null
|
||||
orderCode: string | null
|
||||
pricingCode: string | null
|
||||
currentStageKey: string | null
|
||||
currentStageName: string | null
|
||||
currentApprovalStageKey: string | null
|
||||
currentApprovalStageName: string | null
|
||||
overallStatus: string | null
|
||||
overallResult: string | null
|
||||
overallTargetHours: number | null
|
||||
overallActualHours: number | null
|
||||
overallTimeoutHours: number | null
|
||||
overallTimeoutFlag: boolean
|
||||
originatorDeptName: string | null
|
||||
lastSourceUpdatedAt: string | null
|
||||
formSummary: {
|
||||
styleImageLabel: string
|
||||
openingCode: string
|
||||
totalFlowStatusLabel: string
|
||||
approvalStageDisplay: string
|
||||
currentOwnerDisplay: string
|
||||
currentNodeDisplay: string
|
||||
demandType: string
|
||||
developmentPlan: string
|
||||
productType: string
|
||||
shippingType: string
|
||||
needVisualDesign: string
|
||||
plannedSalesBrands: string
|
||||
category: string
|
||||
categoryName: string
|
||||
salesPriceBand: string
|
||||
platformPriceBand: string
|
||||
competitorLink: string
|
||||
openingReason: string
|
||||
isTestStyle: string
|
||||
testStyleCode: string
|
||||
productPlanner: string
|
||||
operationBrandOwner: string
|
||||
spuOwner: string
|
||||
appearanceDesigner: string
|
||||
structureDesigner: string
|
||||
purchaseOwner: string
|
||||
merchandiserOwner: string
|
||||
pricingOwner: string
|
||||
workflowLink: string
|
||||
needPatent: string
|
||||
needTrademark: string
|
||||
reviewResult: string
|
||||
sampleReviewResult: string
|
||||
salesBrands: string
|
||||
spu: string
|
||||
openingBrand: string
|
||||
}
|
||||
flowChips: OaBoardFlowChipDTO[]
|
||||
nodeAssessments: OaBoardNodeAssessmentDTO[]
|
||||
}
|
||||
|
||||
export interface OaBoardSummaryDTO {
|
||||
totalProjects: number
|
||||
timeoutProjects: number
|
||||
runningProjects: number
|
||||
completedProjects: number
|
||||
productTypes: string[]
|
||||
}
|
||||
|
||||
export interface OaBoardStageMatrixRowDTO {
|
||||
productType: string
|
||||
totalTargetHours: number
|
||||
stageTargets: Array<{
|
||||
stageKey: string
|
||||
stageName: string
|
||||
targetHours: number | null
|
||||
}>
|
||||
}
|
||||
|
||||
export interface OaBoardDashboardDTO {
|
||||
items: OaBoardProjectListItemDTO[]
|
||||
summary: OaBoardSummaryDTO
|
||||
stageMatrix: OaBoardStageMatrixRowDTO[]
|
||||
}
|
||||
|
||||
export function createEmptyOaBoardDashboard(productTypes: string[] = []): OaBoardDashboardDTO {
|
||||
return {
|
||||
items: [],
|
||||
summary: {
|
||||
totalProjects: 0,
|
||||
timeoutProjects: 0,
|
||||
runningProjects: 0,
|
||||
completedProjects: 0,
|
||||
productTypes,
|
||||
},
|
||||
stageMatrix: buildStageMatrixRows(),
|
||||
}
|
||||
}
|
||||
|
||||
function toIsoString(value: Date | null | undefined) {
|
||||
return value ? value.toISOString() : null
|
||||
}
|
||||
|
||||
function toHours(value: number | null | undefined) {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) return null
|
||||
return Number(value.toFixed(2))
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function asText(value: unknown) {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
function readFormSummary(value: unknown): OaBoardProjectListItemDTO['formSummary'] {
|
||||
const empty = {
|
||||
styleImageLabel: '',
|
||||
openingCode: '',
|
||||
totalFlowStatusLabel: '',
|
||||
approvalStageDisplay: '',
|
||||
currentOwnerDisplay: '',
|
||||
currentNodeDisplay: '',
|
||||
demandType: '',
|
||||
developmentPlan: '',
|
||||
productType: '',
|
||||
shippingType: '',
|
||||
needVisualDesign: '',
|
||||
plannedSalesBrands: '',
|
||||
category: '',
|
||||
categoryName: '',
|
||||
salesPriceBand: '',
|
||||
platformPriceBand: '',
|
||||
competitorLink: '',
|
||||
openingReason: '',
|
||||
isTestStyle: '',
|
||||
testStyleCode: '',
|
||||
productPlanner: '',
|
||||
operationBrandOwner: '',
|
||||
spuOwner: '',
|
||||
appearanceDesigner: '',
|
||||
structureDesigner: '',
|
||||
purchaseOwner: '',
|
||||
merchandiserOwner: '',
|
||||
pricingOwner: '',
|
||||
workflowLink: '',
|
||||
needPatent: '',
|
||||
needTrademark: '',
|
||||
reviewResult: '',
|
||||
sampleReviewResult: '',
|
||||
salesBrands: '',
|
||||
spu: '',
|
||||
openingBrand: '',
|
||||
}
|
||||
|
||||
if (!isRecord(value)) return empty
|
||||
|
||||
return {
|
||||
styleImageLabel: asText(value.styleImageLabel),
|
||||
openingCode: asText(value.openingCode),
|
||||
totalFlowStatusLabel: asText(value.totalFlowStatusLabel),
|
||||
approvalStageDisplay: asText(value.approvalStageDisplay),
|
||||
currentOwnerDisplay: asText(value.currentOwnerDisplay),
|
||||
currentNodeDisplay: asText(value.currentNodeDisplay),
|
||||
demandType: asText(value.demandType),
|
||||
developmentPlan: asText(value.developmentPlan),
|
||||
productType: asText(value.productType),
|
||||
shippingType: asText(value.shippingType),
|
||||
needVisualDesign: asText(value.needVisualDesign),
|
||||
plannedSalesBrands: asText(value.plannedSalesBrands),
|
||||
category: asText(value.category),
|
||||
categoryName: asText(value.categoryName),
|
||||
salesPriceBand: asText(value.salesPriceBand),
|
||||
platformPriceBand: asText(value.platformPriceBand),
|
||||
competitorLink: asText(value.competitorLink),
|
||||
openingReason: asText(value.openingReason),
|
||||
isTestStyle: asText(value.isTestStyle),
|
||||
testStyleCode: asText(value.testStyleCode),
|
||||
productPlanner: asText(value.productPlanner),
|
||||
operationBrandOwner: asText(value.operationBrandOwner),
|
||||
spuOwner: asText(value.spuOwner),
|
||||
appearanceDesigner: asText(value.appearanceDesigner),
|
||||
structureDesigner: asText(value.structureDesigner),
|
||||
purchaseOwner: asText(value.purchaseOwner),
|
||||
merchandiserOwner: asText(value.merchandiserOwner),
|
||||
pricingOwner: asText(value.pricingOwner),
|
||||
workflowLink: asText(value.workflowLink),
|
||||
needPatent: asText(value.needPatent),
|
||||
needTrademark: asText(value.needTrademark),
|
||||
reviewResult: asText(value.reviewResult),
|
||||
sampleReviewResult: asText(value.sampleReviewResult),
|
||||
salesBrands: asText(value.salesBrands),
|
||||
spu: asText(value.spu),
|
||||
openingBrand: asText(value.openingBrand),
|
||||
}
|
||||
}
|
||||
|
||||
type PreviewProjectRecord = Awaited<ReturnType<typeof queryOaBoardPreview>>['projectGraphs'][number]
|
||||
|
||||
function toProjectListItem(record: PreviewProjectRecord): OaBoardProjectListItemDTO {
|
||||
const metadata = isRecord(record.metadata) ? record.metadata : null
|
||||
const formSummary = readFormSummary(metadata?.formSummary)
|
||||
const nodeAssessments = record.flowInstances
|
||||
.flatMap((flowInstance) =>
|
||||
flowInstance.taskRecords
|
||||
.filter((taskRecord) => taskRecord.status !== 'CANCELED')
|
||||
.map((taskRecord) => ({
|
||||
flowType: flowInstance.flowType,
|
||||
flowLabel:
|
||||
OA_BOARD_FLOW_LABELS[flowInstance.flowType as keyof typeof OA_BOARD_FLOW_LABELS] ??
|
||||
flowInstance.processName,
|
||||
processName: flowInstance.processName,
|
||||
taskId: taskRecord.taskId,
|
||||
nodeName: taskRecord.nodeName ?? taskRecord.activityId,
|
||||
approvalStageName: taskRecord.stageName ?? null,
|
||||
ownerLabel: taskRecord.ownerLabel ?? null,
|
||||
departmentLabel: taskRecord.departmentLabel ?? null,
|
||||
targetHours: toHours(taskRecord.targetHours),
|
||||
actualHours: toHours(taskRecord.actualHours),
|
||||
overtimeHours: toHours(taskRecord.overtimeHours),
|
||||
isOvertime: taskRecord.isOvertime,
|
||||
status: taskRecord.status,
|
||||
startedAtSource: toIsoString(taskRecord.startedAtSource),
|
||||
finishedAtSource: toIsoString(taskRecord.finishedAtSource),
|
||||
})),
|
||||
)
|
||||
.sort((left, right) => {
|
||||
const leftPriority = OA_BOARD_FLOW_PRIORITY.indexOf(left.flowType as (typeof OA_BOARD_FLOW_PRIORITY)[number])
|
||||
const rightPriority = OA_BOARD_FLOW_PRIORITY.indexOf(right.flowType as (typeof OA_BOARD_FLOW_PRIORITY)[number])
|
||||
if (leftPriority !== rightPriority) return leftPriority - rightPriority
|
||||
|
||||
const leftTime = left.startedAtSource ? new Date(left.startedAtSource).getTime() : Number.MAX_SAFE_INTEGER
|
||||
const rightTime = right.startedAtSource ? new Date(right.startedAtSource).getTime() : Number.MAX_SAFE_INTEGER
|
||||
if (leftTime !== rightTime) return leftTime - rightTime
|
||||
|
||||
return left.nodeName.localeCompare(right.nodeName, 'zh-CN')
|
||||
})
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
boardKey: record.boardKey,
|
||||
boardKeyType: record.boardKeyType,
|
||||
title: record.title,
|
||||
productName: record.productName,
|
||||
productType: record.productType,
|
||||
brand: record.brand,
|
||||
productCategory: record.productCategory,
|
||||
categoryName: record.categoryName,
|
||||
spu: record.spu,
|
||||
openingCode: record.openingCode,
|
||||
orderCode: record.orderCode,
|
||||
pricingCode: record.pricingCode,
|
||||
currentStageKey: record.currentStageKey,
|
||||
currentStageName: record.currentStageName,
|
||||
currentApprovalStageKey: record.currentApprovalStageKey,
|
||||
currentApprovalStageName: record.currentApprovalStageName,
|
||||
overallStatus: record.overallStatus,
|
||||
overallResult: record.overallResult,
|
||||
overallTargetHours: toHours(record.overallTargetHours),
|
||||
overallActualHours: toHours(record.overallActualHours),
|
||||
overallTimeoutHours: toHours(record.overallTimeoutHours),
|
||||
overallTimeoutFlag: record.overallTimeoutFlag,
|
||||
originatorDeptName: record.originatorDeptName,
|
||||
lastSourceUpdatedAt: toIsoString(record.lastSourceUpdatedAt),
|
||||
formSummary,
|
||||
flowChips: record.flowInstances.map((flowInstance) => ({
|
||||
flowType: flowInstance.flowType,
|
||||
flowLabel:
|
||||
OA_BOARD_FLOW_LABELS[flowInstance.flowType as keyof typeof OA_BOARD_FLOW_LABELS] ??
|
||||
flowInstance.processName,
|
||||
processName: flowInstance.processName,
|
||||
processInstanceId: flowInstance.processInstanceId,
|
||||
status: flowInstance.status,
|
||||
result: flowInstance.result,
|
||||
actualHours: toHours(flowInstance.actualHours),
|
||||
createdAtSource: toIsoString(flowInstance.createdAtSource),
|
||||
finishedAtSource: toIsoString(flowInstance.finishedAtSource),
|
||||
usedWorkflowFallback: flowInstance.usedWorkflowFallback,
|
||||
})),
|
||||
nodeAssessments,
|
||||
}
|
||||
}
|
||||
|
||||
function buildStageMatrixRows(): OaBoardStageMatrixRowDTO[] {
|
||||
return Object.entries(OA_BOARD_STAGE_TARGET_MATRIX).map(([productType, stageTargets]) => ({
|
||||
productType,
|
||||
totalTargetHours: getOaBoardProductTargetHours(productType) ?? 0,
|
||||
stageTargets: OA_BOARD_STAGE_DEFINITIONS.map((stage) => ({
|
||||
stageKey: stage.key,
|
||||
stageName: stage.name,
|
||||
targetHours: stageTargets[stage.key] ?? null,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
export class OaBoardService {
|
||||
async getDashboard(filter: OaBoardProjectListFilter = {}): Promise<OaBoardDashboardDTO> {
|
||||
const pageLimit = Number(process.env.OA_BOARD_PAGE_LIMIT || '120')
|
||||
const preview = await queryOaBoardPreview(Number.isFinite(pageLimit) && pageLimit > 0 ? pageLimit : 120)
|
||||
const allItems = preview.projectGraphs.map(toProjectListItem)
|
||||
|
||||
const items = allItems.filter((item) => {
|
||||
if (filter.keyword?.trim()) {
|
||||
const keyword = filter.keyword.trim().toLowerCase()
|
||||
const haystack = [
|
||||
item.boardKey,
|
||||
item.title,
|
||||
item.productName,
|
||||
item.productType,
|
||||
item.brand,
|
||||
item.spu,
|
||||
item.openingCode,
|
||||
item.orderCode,
|
||||
item.pricingCode,
|
||||
item.formSummary.currentNodeDisplay,
|
||||
item.formSummary.currentOwnerDisplay,
|
||||
]
|
||||
.map((value) => String(value ?? '').toLowerCase())
|
||||
.join('\n')
|
||||
|
||||
if (!haystack.includes(keyword)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.overallStatus?.trim() && item.overallStatus !== filter.overallStatus.trim()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filter.productType?.trim() && item.productType !== filter.productType.trim()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filter.timeoutOnly && !item.overallTimeoutFlag) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
const productTypes = Array.from(
|
||||
new Set(
|
||||
allItems
|
||||
.map((item) => item.productType)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
),
|
||||
).sort((left, right) => left.localeCompare(right, 'zh-CN'))
|
||||
|
||||
return {
|
||||
items,
|
||||
summary: {
|
||||
totalProjects: items.length,
|
||||
timeoutProjects: items.filter((item) => item.overallTimeoutFlag).length,
|
||||
runningProjects: items.filter((item) => item.overallStatus === 'RUNNING').length,
|
||||
completedProjects: items.filter((item) => item.overallStatus === 'COMPLETED').length,
|
||||
productTypes,
|
||||
},
|
||||
stageMatrix: buildStageMatrixRows(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const oaBoardService = new OaBoardService()
|
||||
Loading…
x
Reference in New Issue
Block a user