feat(oa-board): add new product flow monitor

This commit is contained in:
jingchuan 2026-06-15 09:54:42 +08:00
parent da1b05fe6c
commit a4f22db9b2
9 changed files with 2609 additions and 0 deletions

View File

@ -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",

View 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
})

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

View 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>
)
}

View 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} />
}

View File

@ -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: '仓库质检' },
]

File diff suppressed because it is too large Load Diff

View 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'])

View 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()