feat(oa-board): refine flow detail filters and layout

This commit is contained in:
jingchuan 2026-06-20 16:08:36 +08:00
parent 9b5f2d9ee8
commit 1cff836933
9 changed files with 1938 additions and 606 deletions

View File

@ -28,6 +28,98 @@ function applyHeaderStyle(worksheet: ExcelJS.Worksheet) {
}
}
type TimelineMetricMap = Record<
string,
{
targetDays: number | null
actualDays: number | null
}
>
type TimingDetailDefinition = {
header: string
metricKey: string
valueType: 'target' | 'actual'
}
const TIMING_DETAIL_DEFINITIONS: TimingDetailDefinition[] = [
{ header: '目标-外观', metricKey: 'exterior', valueType: 'target' },
{ header: '实际-外观', metricKey: 'exterior', valueType: 'actual' },
{ header: '目标-结构', metricKey: 'structure', valueType: 'target' },
{ header: '实际-结构', metricKey: 'structure', valueType: 'actual' },
{ header: '目标-打样', metricKey: 'sample_prepare', valueType: 'target' },
{ header: '实际-打样', metricKey: 'sample_prepare', valueType: 'actual' },
{ header: '目标-审样', metricKey: 'sample_review', valueType: 'target' },
{ header: '实际-审样', metricKey: 'sample_review', valueType: 'actual' },
{ header: '目标-生产', metricKey: 'production', valueType: 'target' },
{ header: '实际-生产', metricKey: 'production', valueType: 'actual' },
{ header: '目标-商品资料', metricKey: 'material', valueType: 'target' },
{ header: '实际-商品资料', metricKey: 'material', valueType: 'actual' },
{ header: '企划更新市场分析', metricKey: 'market_analysis', valueType: 'actual' },
{ header: '目标-首单下单(线下签订合同)', metricKey: 'first_order', valueType: 'target' },
{ header: '实际-首单下单(线下签订合同)', metricKey: 'first_order', valueType: 'actual' },
{ header: '目标-产前样确认', metricKey: 'pre_production', valueType: 'target' },
{ header: '实际-产前样确认', metricKey: 'pre_production', valueType: 'actual' },
{ header: '目标-出货', metricKey: 'shipment', valueType: 'target' },
{ header: '实际-出货', metricKey: 'shipment', valueType: 'actual' },
{ header: '目标-核价', metricKey: 'pricing', valueType: 'target' },
{ header: '实际-核价', metricKey: 'pricing', valueType: 'actual' },
{ header: '实际-核价后并行用时', metricKey: 'pricing_parallel', valueType: 'actual' },
{ header: '目标-送仓质检', metricKey: 'warehouse_qc', valueType: 'target' },
{ header: '实际时效-送仓质检', metricKey: 'warehouse_qc', valueType: 'actual' },
{ header: '目标-安装图纸', metricKey: 'install_manual', valueType: 'target' },
{ header: '实际-安装图纸', metricKey: 'install_manual', valueType: 'actual' },
{ header: '目标-寄样', metricKey: 'sample_send', valueType: 'target' },
{ header: '实际-寄样', metricKey: 'sample_send', valueType: 'actual' },
{ header: '目标-出图', metricKey: 'art', valueType: 'target' },
{ header: '实际-出图', metricKey: 'art', valueType: 'actual' },
{ header: '目标-限价', metricKey: 'pricing_flow', valueType: 'target' },
{ header: '实际-限价', metricKey: 'pricing_flow', valueType: 'actual' },
{ header: '目标-运营上架', metricKey: 'listing', valueType: 'target' },
{ header: '实际-运营上架', metricKey: 'listing', valueType: 'actual' },
]
function formatDayValue(value: number | null | undefined) {
if (value === null || value === undefined || Number.isNaN(value)) return ''
return value
}
function createTimelineMetricMap(
metrics: Array<{ key: string; targetDays: number | null; actualDays: number | null }>,
): TimelineMetricMap {
return Object.fromEntries(
metrics.map((metric) => [
metric.key,
{ targetDays: metric.targetDays, actualDays: metric.actualDays },
]),
)
}
function readMultiValueParam(searchParams: URLSearchParams, key: string) {
return searchParams
.getAll(key)
.flatMap((value) => value.split(','))
.map((value) => value.trim())
.filter(Boolean)
}
function formatDateOnly(value: string | null | undefined) {
const text = String(value ?? '').trim()
return text ? text.slice(0, 10) : ''
}
function readMetricValue(
metrics: TimelineMetricMap,
key: string,
valueType: 'target' | 'actual',
) {
const metric = metrics[key]
if (!metric) return ''
return valueType === 'target'
? formatDayValue(metric.targetDays)
: formatDayValue(metric.actualDays)
}
export async function GET(request: NextRequest) {
return handleRequest(
request,
@ -42,9 +134,16 @@ export async function GET(request: NextRequest) {
const dashboard = await oaBoardService.getDashboard({
keyword: searchParams.get('keyword') || undefined,
approvalNumber: searchParams.get('approvalNumber') || undefined,
overallStatus: searchParams.get('overallStatus') || undefined,
overallStatus: readMultiValueParam(searchParams, 'overallStatus'),
createdDateStart: searchParams.get('createdDateStart') || undefined,
createdDateEnd: searchParams.get('createdDateEnd') || undefined,
productType: searchParams.get('productType') || undefined,
approvalStage: searchParams.get('approvalStage') || undefined,
productPlanner: searchParams.get('productPlanner') || undefined,
structureDesigner: searchParams.get('structureDesigner') || undefined,
appearanceDesigner: searchParams.get('appearanceDesigner') || undefined,
purchaseOwner: searchParams.get('purchaseOwner') || undefined,
spuOwner: searchParams.get('spuOwner') || undefined,
owner: searchParams.get('owner') || undefined,
nodeName: searchParams.get('nodeName') || undefined,
timeoutOnly: searchParams.get('timeoutOnly') === '1',
@ -59,6 +158,7 @@ export async function GET(request: NextRequest) {
{ header: '序号', key: 'index', width: 8 },
{ header: '审批编号', key: 'approvalNumber', width: 24 },
{ header: '开款编号', key: 'openingCode', width: 22 },
{ header: '创建日期', key: 'createdDate', width: 14 },
{ header: '总流程状态', key: 'totalFlowStatusLabel', width: 12 },
{ header: '流程审批阶段', key: 'approvalStageDisplay', width: 20 },
{ header: '当前负责人', key: 'currentOwnerDisplay', width: 18 },
@ -85,6 +185,7 @@ export async function GET(request: NextRequest) {
index: index + 1,
approvalNumber: item.boardKey,
openingCode: item.formSummary.openingCode || item.openingCode || '',
createdDate: formatDateOnly(item.createdDate),
totalFlowStatusLabel: item.formSummary.totalFlowStatusLabel || '',
approvalStageDisplay: item.formSummary.approvalStageDisplay || item.currentApprovalStageName || '',
currentOwnerDisplay: item.formSummary.currentOwnerDisplay || '',
@ -109,9 +210,9 @@ export async function GET(request: NextRequest) {
const detail = workbook.addWorksheet('流程明细')
detail.columns = [
{ header: '审批编号', key: 'approvalNumber', width: 24 },
{ header: '款式图片', key: 'firstImageUrl', width: 54 },
{ header: '开款编号', key: 'openingCode', width: 22 },
{ header: '创建日期', key: 'createdDate', width: 14 },
{ header: '总流程状态', key: 'totalFlowStatusLabel', width: 14 },
{ header: '流程审批阶段', key: 'approvalStageDisplay', width: 20 },
{ header: '当前审批节点', key: 'currentNodeDisplay', width: 24 },
@ -147,19 +248,22 @@ export async function GET(request: NextRequest) {
{ header: 'SPU', key: 'spu', width: 14 },
{ header: '开款品牌', key: 'openingBrand', width: 16 },
{ header: '创建人', key: 'creatorLabel', width: 14 },
{ header: '创建日期', key: 'createdDate', width: 24 },
{ header: '结束日期', key: 'finishedDate', width: 24 },
{ header: '总目标时效', key: 'overallTargetHours', width: 14 },
{ header: '总实际时效', key: 'overallActualHours', width: 14 },
{ header: '是否超时', key: 'isOvertime', width: 10 },
{ header: '详细时效JSON', key: 'timelineMetrics', width: 80 },
...TIMING_DETAIL_DEFINITIONS.map((field) => ({
header: field.header,
key: field.header,
width: 18,
})),
]
dashboard.items.forEach((item) => {
const metricMap = createTimelineMetricMap(item.timelineMetrics)
detail.addRow({
approvalNumber: item.boardKey,
firstImageUrl: item.styleImages[0]?.url || '',
openingCode: item.formSummary.openingCode || item.openingCode || '',
createdDate: formatDateOnly(item.createdDate),
totalFlowStatusLabel: item.formSummary.totalFlowStatusLabel || '',
approvalStageDisplay: item.formSummary.approvalStageDisplay || item.currentApprovalStageName || '',
currentNodeDisplay: item.formSummary.currentNodeDisplay || '',
@ -195,12 +299,15 @@ export async function GET(request: NextRequest) {
spu: item.formSummary.spu || item.spu || '',
openingBrand: item.formSummary.openingBrand || '',
creatorLabel: item.originatorDeptName || item.boardKeyType || '',
createdDate: item.createdDate || '',
finishedDate: item.finishedDate || '',
overallTargetHours: item.overallTargetHours ?? '',
overallActualHours: item.overallActualHours ?? '',
isOvertime: item.overallTimeoutFlag ? '是' : '否',
timelineMetrics: JSON.stringify(item.timelineMetrics, null, 0),
...Object.fromEntries(
TIMING_DETAIL_DEFINITIONS.map((field) => [
field.header,
readMetricValue(metricMap, field.metricKey, field.valueType),
]),
),
})
})

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,10 @@
import { requireSession } from '@/lib/server-session'
import { createEmptyOaBoardDashboard, oaBoardService } from '@/server/services/oa-board.service'
import {
createEmptyOaBoardDashboard,
createMockOaBoardDashboard,
oaBoardService,
} from '@/server/services/oa-board.service'
import { OA_BOARD_DEFAULT_TRACKED_STATUSES } from '@/server/services/oa-board.constants'
import OaBoardClient from './OaBoardClient'
export const dynamic = 'force-dynamic'
@ -12,38 +17,91 @@ export default async function OaBoardPage({
await requireSession('/login')
const params = searchParams ? await searchParams : {}
const keyword = typeof params.keyword === 'string' ? params.keyword : ''
const approvalNumber = typeof params.approvalNumber === 'string' ? params.approvalNumber : ''
const overallStatus = typeof params.overallStatus === 'string' ? params.overallStatus : ''
const productType = typeof params.productType === 'string' ? params.productType : ''
const approvalStage = typeof params.approvalStage === 'string' ? params.approvalStage : ''
const owner = typeof params.owner === 'string' ? params.owner : ''
const nodeName = typeof params.nodeName === 'string' ? params.nodeName : ''
const readSingleParam = (value: string | string[] | undefined) =>
typeof value === 'string' ? value : ''
const readMultiParam = (value: string | string[] | undefined) =>
(Array.isArray(value) ? value : [value])
.flatMap((entry) => String(entry ?? '').split(','))
.map((entry) => entry.trim())
.filter(Boolean)
const keyword = readSingleParam(params.keyword)
const approvalNumber = readSingleParam(params.approvalNumber)
const overallStatus = readMultiParam(params.overallStatus)
const createdDateStart = readSingleParam(params.createdDateStart)
const createdDateEnd = readSingleParam(params.createdDateEnd)
const productType = readSingleParam(params.productType)
const approvalStage = readSingleParam(params.approvalStage)
const productPlanner = readSingleParam(params.productPlanner)
const structureDesigner = readSingleParam(params.structureDesigner)
const appearanceDesigner = readSingleParam(params.appearanceDesigner)
const purchaseOwner = readSingleParam(params.purchaseOwner)
const spuOwner = readSingleParam(params.spuOwner)
const owner = readSingleParam(params.owner)
const nodeName = readSingleParam(params.nodeName)
const timeoutOnly = params.timeoutOnly === '1'
const result = await oaBoardService
.getDashboard({
keyword: keyword || undefined,
approvalNumber: approvalNumber || undefined,
overallStatus: overallStatus || undefined,
overallStatus:
overallStatus.length > 0
? overallStatus
: [...OA_BOARD_DEFAULT_TRACKED_STATUSES],
createdDateStart: createdDateStart || undefined,
createdDateEnd: createdDateEnd || undefined,
productType: productType || undefined,
approvalStage: approvalStage || undefined,
productPlanner: productPlanner || undefined,
structureDesigner: structureDesigner || undefined,
appearanceDesigner: appearanceDesigner || undefined,
purchaseOwner: purchaseOwner || undefined,
spuOwner: spuOwner || undefined,
owner: owner || undefined,
nodeName: nodeName || undefined,
timeoutOnly,
})
.then((dashboard) => ({ dashboard, loadErrorMessage: null as string | null }))
.catch((error) => {
const loadErrorMessage =
const fallbackMessage =
error instanceof Error
? `页面骨架已生成,但读取企划库流程数据失败:${error.message}`
: '页面骨架已生成,但读取企划库流程数据失败。'
const useMockDashboard = process.env.NODE_ENV !== 'production'
return {
dashboard: createEmptyOaBoardDashboard(),
loadErrorMessage,
dashboard: useMockDashboard ? createMockOaBoardDashboard() : createEmptyOaBoardDashboard(),
loadErrorMessage: useMockDashboard
? `${fallbackMessage} 当前已切换为本地人工验证数据,方便验证流程明细进度。`
: fallbackMessage,
}
})
return <OaBoardClient dashboard={result.dashboard} loadErrorMessage={result.loadErrorMessage} />
const pageStateKey = JSON.stringify({
keyword,
approvalNumber,
overallStatus,
createdDateStart,
createdDateEnd,
productType,
approvalStage,
productPlanner,
structureDesigner,
appearanceDesigner,
purchaseOwner,
spuOwner,
owner,
nodeName,
timeoutOnly,
})
return (
<OaBoardClient
key={pageStateKey}
dashboard={result.dashboard}
loadErrorMessage={result.loadErrorMessage}
/>
)
}

View File

@ -83,6 +83,7 @@ function AppShellContent({ children, user }: AppShellProps) {
pathname === '/assisted-assembly' || pathname.startsWith('/assisted-assembly/')
const isDraftGroupsRoute = pathname === '/draft-groups' || pathname.startsWith('/draft-groups/')
const isIntakeRoute = pathname === '/intake' || pathname.startsWith('/intake/')
const isOaBoardRoute = pathname === '/intake/oa-board' || pathname.startsWith('/intake/oa-board/')
const isWarehouseRoute = pathname === '/warehouse' || pathname.startsWith('/warehouse/')
const isWarehouseQualityRoute =
pathname === '/warehouse-quality' || pathname.startsWith('/warehouse-quality/')
@ -106,7 +107,7 @@ function AppShellContent({ children, user }: AppShellProps) {
isWarehouseQualityRoute ||
isAiRenderWorkbenchRoute
const shouldLockMainViewport =
isWorkbenchShellRoute
isWorkbenchShellRoute && !isOaBoardRoute
useEffect(() => {
if (user !== undefined) {
@ -242,7 +243,11 @@ function AppShellContent({ children, user }: AppShellProps) {
<main
className={
isMobileOrTablet
? 'mt-14 h-[calc(100dvh-56px-56px)] min-h-[calc(100dvh-56px-56px)] overflow-hidden overscroll-none'
? isOaBoardRoute
? 'mt-14 h-[calc(100dvh-56px-56px)] min-h-[calc(100dvh-56px-56px)] overflow-auto overscroll-contain'
: 'mt-14 h-[calc(100dvh-56px-56px)] min-h-[calc(100dvh-56px-56px)] overflow-hidden overscroll-none'
: isOaBoardRoute
? 'mt-16 h-[calc(100dvh-64px)] min-h-[calc(100dvh-64px)] overflow-auto overscroll-contain'
: 'mt-16 h-[calc(100dvh-64px)] min-h-[calc(100dvh-64px)] overflow-hidden overscroll-none'
}
style={{ backgroundColor: isMobileOrTablet ? '#F8F9FA' : 'var(--obs-shell-bg)' }}

View File

@ -28,11 +28,17 @@ export function PlatformMetricStrip({
className,
)}
>
{items.map((item) => (
<article
key={`${item.label}-${String(item.value)}`}
className="flex min-h-[104px] flex-col justify-between rounded-apple-lg border border-black/5 bg-white/60 px-4 py-3"
>
{items.map((item) => {
const cardClassName = cn(
'flex min-h-[104px] flex-col justify-between rounded-apple-lg border px-4 py-3 text-left transition',
item.onClick
? 'cursor-pointer border-black/6 bg-white/70 hover:border-black/12 hover:bg-white hover:shadow-apple-sm'
: 'border-black/5 bg-white/60',
item.isActive ? 'border-[color:var(--color-blue)] bg-[#f7fbff] shadow-apple-sm' : null,
)
const content = (
<>
<div className="flex items-start justify-between gap-3">
<p className="text-xs font-apple-medium tracking-apple-wide text-apple-label-tertiary">
{item.label}
@ -52,8 +58,27 @@ export function PlatformMetricStrip({
<p className="text-xs leading-relaxed text-apple-label-secondary">{item.hint}</p>
) : null}
</div>
</>
)
return item.onClick ? (
<button
key={`${item.label}-${String(item.value)}`}
type="button"
onClick={item.onClick}
className={cardClassName}
>
{content}
</button>
) : (
<article
key={`${item.label}-${String(item.value)}`}
className={cardClassName}
>
{content}
</article>
))}
)
})}
</section>
)
}

View File

@ -21,4 +21,6 @@ export interface PlatformMetricItem {
hint?: string | null
tone?: 'default' | 'success' | 'warning' | 'danger' | 'info'
icon?: ReactNode
isActive?: boolean
onClick?: () => void
}

View File

@ -48,6 +48,7 @@ type SourceSchemeRow = RowDataPacket & {
}
type SourceTaskRow = RowDataPacket & {
id: number
processInstanceId: string
processCode: string
result: string | null
@ -445,6 +446,9 @@ function toFlowStatusLabel(status: string | null | undefined, result: string | n
if (normalizedStatus === 'RUNNING' || normalizedStatus === 'PROCESSING' || normalizedStatus === 'PENDING') {
return '进行中'
}
if (normalizedStatus === 'REFUSED') {
return '已驳回'
}
if (normalizedStatus === 'TERMINATED') {
return '已终止'
}
@ -462,9 +466,50 @@ function pickActiveTasks(records: NormalizedFlowRecord[]) {
const orderRecord = records.find((record) => record.flowType === 'order') ?? null
const artRecord = records.find((record) => record.flowType === 'art') ?? null
const pricingRecord = records.find((record) => record.flowType === 'pricing') ?? null
const mainRecord = records.find((record) => record.flowType === 'main') ?? null
const runningTasks = (record: NormalizedFlowRecord | null) =>
record?.taskRecords.filter((taskRecord) => taskRecord.status === 'RUNNING') ?? []
const getTaskSequence = (taskRecord: NormalizedTaskRecord) => {
if (
taskRecord.metadata &&
typeof taskRecord.metadata === 'object' &&
!Array.isArray(taskRecord.metadata) &&
typeof (taskRecord.metadata as { sequence?: unknown }).sequence === 'number'
) {
return (taskRecord.metadata as { sequence: number }).sequence
}
return 0
}
const getTaskRowId = (taskRecord: NormalizedTaskRecord) => {
if (
taskRecord.metadata &&
typeof taskRecord.metadata === 'object' &&
!Array.isArray(taskRecord.metadata) &&
typeof (taskRecord.metadata as { sourceRowId?: unknown }).sourceRowId === 'number'
) {
return (taskRecord.metadata as { sourceRowId: number }).sourceRowId
}
return 0
}
const runningTasks = (record: NormalizedFlowRecord | null) => {
const collapsedTasks =
record?.taskRecords.reduce((map, taskRecord) => {
const current = map.get(taskRecord.activityId)
if (!current || getTaskRowId(taskRecord) > getTaskRowId(current)) {
map.set(taskRecord.activityId, taskRecord)
}
return map
}, new Map<string, NormalizedTaskRecord>()) ?? new Map<string, NormalizedTaskRecord>()
const tasks = Array.from(collapsedTasks.values()).filter(
(taskRecord) => taskRecord.status === 'RUNNING',
)
if (tasks.length <= 1) return tasks
const maxSequence = Math.max(...tasks.map(getTaskSequence))
return tasks.filter((taskRecord) => getTaskSequence(taskRecord) === maxSequence)
}
if (reviewRecord && OA_BOARD_RUNNING_STATUSES.has(reviewRecord.status)) {
return runningTasks(reviewRecord)
@ -482,6 +527,14 @@ function pickActiveTasks(records: NormalizedFlowRecord[]) {
if (pricingTasks.length > 0) return pricingTasks
}
const fallbackTasks = [
...runningTasks(orderRecord),
...runningTasks(artRecord),
...runningTasks(pricingRecord),
...runningTasks(mainRecord),
]
if (fallbackTasks.length > 0) return fallbackTasks
return []
}
@ -518,9 +571,19 @@ function buildTaskRecordsWithMappings(
processName: string,
mappingLookup: ActivityMappingLookup,
): NormalizedTaskRecord[] {
const taskMap = new Map<string, NormalizedTaskRecord>()
const latestRowsByActivity = new Map<string, SourceTaskRow>()
rows.forEach((row) => {
const activityId = normalizeText(row.activityId) ?? ''
const current = latestRowsByActivity.get(activityId)
if (!current || Number(row.id) > Number(current.id)) {
latestRowsByActivity.set(activityId, row)
}
})
const taskMap = new Map<string, NormalizedTaskRecord>()
Array.from(latestRowsByActivity.values()).forEach((row) => {
const startedAtSource = parseChinaDateTime(row.createTime)
const finishedAtSource = parseChinaDateTime(row.finishTime)
const processCode = normalizeText((row as RowDataPacket).processCode) ?? ''
@ -560,8 +623,11 @@ function buildTaskRecordsWithMappings(
? toJsonValue({
sequence: mapped.sequence,
mappedBy: 'default-activity-mapping',
sourceRowId: row.id,
})
: null,
: toJsonValue({
sourceRowId: row.id,
}),
})
})
@ -757,7 +823,14 @@ function computeOverallStatus(records: NormalizedFlowRecord[]) {
if (records.some((record) => OA_BOARD_RUNNING_STATUSES.has(record.status))) {
return 'RUNNING'
}
if (records.some((record) => record.status === 'TERMINATED' || record.result === 'refuse')) {
if (
records.some(
(record) => (normalizeText(record.result)?.toUpperCase() ?? '') === 'REFUSE',
)
) {
return 'REFUSED'
}
if (records.some((record) => record.status === 'TERMINATED')) {
return 'TERMINATED'
}
if (records.every((record) => OA_BOARD_COMPLETED_STATUSES.has(record.status))) {
@ -766,6 +839,22 @@ function computeOverallStatus(records: NormalizedFlowRecord[]) {
return records[0]?.status ?? null
}
function computeOverallResult(records: NormalizedFlowRecord[]) {
const normalizedResults = records
.map((record) => normalizeText(record.result)?.toUpperCase() ?? '')
.filter(Boolean)
if (normalizedResults.includes('REFUSE')) {
return 'REFUSE'
}
if (normalizedResults.includes('AGREE')) {
return 'AGREE'
}
return normalizeText(records[0]?.result) ?? null
}
type TimelineMetricDefinition = {
key: string
label: string
@ -1071,18 +1160,13 @@ function buildProjectGraph(group: PreparedProjectGroup): OaBoardProjectGraphInpu
const reviewRecord = records.find((record) => record.flowType === 'review') ?? null
const orderRecord = records.find((record) => record.flowType === 'order') ?? null
const artRecord = records.find((record) => record.flowType === 'art') ?? null
const pricingRecord = records.find((record) => record.flowType === 'pricing') ?? null
const mainRecord = records.find((record) => record.flowType === 'main') ?? null
const currentFlow = pickCurrentFlow(records)
const activeTasks = pickActiveTasks(records)
const productType =
reviewRecord?.productType ??
orderRecord?.productType ??
pricingRecord?.productType ??
artRecord?.productType ??
null
const productType = reviewRecord?.productType ?? null
const overallStatus = computeOverallStatus(records)
const overallResult = computeOverallResult(records)
const overallActualHours = computeOverallActualHours(records)
const overallTargetHours = getOaBoardProductTargetHours(productType)
@ -1100,7 +1184,7 @@ function buildProjectGraph(group: PreparedProjectGroup): OaBoardProjectGraphInpu
}, null)
const runningTask =
currentFlow?.taskRecords.find((taskRecord) => taskRecord.status === 'RUNNING') ?? null
pickActiveTasks(currentFlow ? [currentFlow] : [])[0] ?? null
const reviewFieldSnapshots = reviewRecord?.fieldSnapshots ?? []
const styleImages = toImageList(readFieldSnapshot(reviewFieldSnapshots, ['款式图片']))
@ -1118,7 +1202,7 @@ function buildProjectGraph(group: PreparedProjectGroup): OaBoardProjectGraphInpu
const formSummary = {
styleImageLabel: toImageCountLabel(readFieldSnapshot(reviewFieldSnapshots, ['款式图片'])),
openingCode: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['开款编号'])),
totalFlowStatusLabel: toFlowStatusLabel(mainRecord?.status ?? reviewRecord?.status, mainRecord?.result ?? reviewRecord?.result),
totalFlowStatusLabel: toFlowStatusLabel(overallStatus, overallResult),
approvalStageDisplay: joinUnique(activeTasks.map((taskRecord) => taskRecord.stageName)),
currentOwnerDisplay: joinUnique(
activeTasks.map((taskRecord) => taskRecord.ownerLabel || taskRecord.userId || null),
@ -1161,86 +1245,26 @@ function buildProjectGraph(group: PreparedProjectGroup): OaBoardProjectGraphInpu
boardKey: group.boardKey,
boardKeyType: group.boardKeyType,
primaryFlowType: reviewRecord?.flowType ?? records[0]?.flowType ?? 'review',
title:
reviewRecord?.title ??
orderRecord?.title ??
artRecord?.title ??
pricingRecord?.title ??
mainRecord?.title ??
group.boardKey,
productName:
reviewRecord?.productName ??
orderRecord?.productName ??
artRecord?.productName ??
pricingRecord?.productName ??
null,
title: reviewRecord?.title ?? group.boardKey,
productName: reviewRecord?.productName ?? null,
productType,
brand:
reviewRecord?.brand ??
orderRecord?.brand ??
artRecord?.brand ??
pricingRecord?.brand ??
null,
productCategory:
reviewRecord?.productCategory ??
orderRecord?.productCategory ??
pricingRecord?.productCategory ??
null,
categoryName:
reviewRecord?.categoryName ??
orderRecord?.categoryName ??
pricingRecord?.categoryName ??
artRecord?.categoryName ??
null,
spu:
reviewRecord?.spu ??
orderRecord?.spu ??
artRecord?.spu ??
pricingRecord?.spu ??
null,
brand: reviewRecord?.brand ?? null,
productCategory: reviewRecord?.productCategory ?? null,
categoryName: reviewRecord?.categoryName ?? null,
spu: reviewRecord?.spu ?? null,
openingCode: reviewRecord?.openingCode ?? null,
orderCode: orderRecord?.orderCode ?? null,
pricingCode: pricingRecord?.pricingCode ?? null,
sourceBusinessId:
reviewRecord?.businessId ??
orderRecord?.businessId ??
pricingRecord?.businessId ??
artRecord?.businessId ??
mainRecord?.businessId ??
null,
originatorUserId:
reviewRecord?.originatorUserId ??
orderRecord?.originatorUserId ??
artRecord?.originatorUserId ??
pricingRecord?.originatorUserId ??
mainRecord?.originatorUserId ??
null,
originatorDeptId:
reviewRecord?.originatorDeptId ??
orderRecord?.originatorDeptId ??
artRecord?.originatorDeptId ??
pricingRecord?.originatorDeptId ??
mainRecord?.originatorDeptId ??
null,
originatorDeptName:
reviewRecord?.originatorDeptName ??
orderRecord?.originatorDeptName ??
artRecord?.originatorDeptName ??
pricingRecord?.originatorDeptName ??
mainRecord?.originatorDeptName ??
null,
sourceBusinessId: reviewRecord?.businessId ?? null,
originatorUserId: reviewRecord?.originatorUserId ?? null,
originatorDeptId: reviewRecord?.originatorDeptId ?? null,
originatorDeptName: reviewRecord?.originatorDeptName ?? null,
currentStageKey: runningTask?.timelineStageKey ?? currentFlow?.flowType ?? null,
currentStageName: runningTask?.timelineStageName ?? currentFlow?.processName ?? null,
currentApprovalStageKey: runningTask?.stageKey ?? null,
currentApprovalStageName: joinUnique(activeTasks.map((taskRecord) => taskRecord.stageName)) || null,
overallStatus: computeOverallStatus(records),
overallResult:
pricingRecord?.result ??
orderRecord?.result ??
artRecord?.result ??
reviewRecord?.result ??
mainRecord?.result ??
null,
overallStatus,
overallResult,
overallTargetHours,
overallActualHours,
overallTimeoutHours,
@ -1315,21 +1339,148 @@ async function fetchSourceData(limit?: number): Promise<NormalizedFlowRecord[]>
try {
const processCodes = Object.values(OA_BOARD_PROCESS_CODES)
const reviewProcessCode = OA_BOARD_PROCESS_CODES.review
const workflowLimitSql = limit ? `LIMIT ${Math.max(1, Math.floor(limit))}` : ''
let workflowRows: SourceWorkflowRow[] = []
const [workflowRows] = await connection.query<SourceWorkflowRow[]>(
if (limit) {
const [reviewWorkflowRows] = await connection.query<SourceWorkflowRow[]>(
`
SELECT finishTime, businessId, title, result, originatorUserId, originatorDeptId,
originatorDeptName, status, bizAction, processInstanceId, joinAt,
updateAt, createAt, processCode, flowType
FROM ding_Workflow
WHERE processCode IN (${processCodes.map(() => '?').join(', ')})
WHERE processCode = ?
ORDER BY updateAt DESC
${workflowLimitSql}
`,
processCodes,
[reviewProcessCode],
)
const reviewProcessInstanceIds = reviewWorkflowRows.map((row) => row.processInstanceId)
const [reviewSchemeRows] =
reviewProcessInstanceIds.length > 0
? await connection.query<SourceSchemeRow[]>(
`
SELECT processInstanceId, componentType, name, CAST(value AS CHAR) AS value, CAST(extValue AS CHAR) AS extValue
FROM ding_WorkflowScheme
WHERE processInstanceId IN (${reviewProcessInstanceIds.map(() => '?').join(', ')})
`,
reviewProcessInstanceIds,
)
: [[] as SourceSchemeRow[]]
const openingCodes = Array.from(
new Set(
reviewSchemeRows
.filter((row) => OA_BOARD_JOIN_FIELD_NAMES.openingCode.includes(String(row.name ?? '').trim()))
.map((row) => normalizeText(row.value))
.filter((value): value is string => Boolean(value)),
),
)
const linkedFieldNames = Array.from(
new Set([
...OA_BOARD_JOIN_FIELD_NAMES.openingCode,
...OA_BOARD_JOIN_FIELD_NAMES.orderCode,
...OA_BOARD_JOIN_FIELD_NAMES.pricingCode,
...OA_BOARD_JOIN_FIELD_NAMES.artCode,
]),
)
const otherProcessCodes = processCodes.filter((code) => code !== reviewProcessCode)
const [linkedWorkflowRows] =
openingCodes.length > 0
? await connection.query<SourceWorkflowRow[]>(
`
SELECT DISTINCT w.finishTime, w.businessId, w.title, w.result, w.originatorUserId, w.originatorDeptId,
w.originatorDeptName, w.status, w.bizAction, w.processInstanceId, w.joinAt,
w.updateAt, w.createAt, w.processCode, w.flowType
FROM ding_Workflow w
JOIN ding_WorkflowScheme s
ON s.processInstanceId = w.processInstanceId
WHERE w.processCode IN (${otherProcessCodes.map(() => '?').join(', ')})
AND s.name IN (${linkedFieldNames.map(() => '?').join(', ')})
AND CAST(s.value AS CHAR) IN (${openingCodes.map(() => '?').join(', ')})
ORDER BY w.updateAt DESC
`,
[...otherProcessCodes, ...linkedFieldNames, ...openingCodes],
)
: [[] as SourceWorkflowRow[]]
const workflowMap = new Map<string, SourceWorkflowRow>()
reviewWorkflowRows.forEach((row) => workflowMap.set(row.processInstanceId, row))
linkedWorkflowRows.forEach((row) => workflowMap.set(row.processInstanceId, row))
workflowRows = Array.from(workflowMap.values())
} else {
const [reviewWorkflowRows] = await connection.query<SourceWorkflowRow[]>(
`
SELECT finishTime, businessId, title, result, originatorUserId, originatorDeptId,
originatorDeptName, status, bizAction, processInstanceId, joinAt,
updateAt, createAt, processCode, flowType
FROM ding_Workflow
WHERE processCode = ?
ORDER BY updateAt DESC
`,
[reviewProcessCode],
)
const reviewProcessInstanceIds = reviewWorkflowRows.map((row) => row.processInstanceId)
const [reviewSchemeRows] =
reviewProcessInstanceIds.length > 0
? await connection.query<SourceSchemeRow[]>(
`
SELECT processInstanceId, componentType, name, CAST(value AS CHAR) AS value, CAST(extValue AS CHAR) AS extValue
FROM ding_WorkflowScheme
WHERE processInstanceId IN (${reviewProcessInstanceIds.map(() => '?').join(', ')})
`,
reviewProcessInstanceIds,
)
: [[] as SourceSchemeRow[]]
const openingCodes = Array.from(
new Set(
reviewSchemeRows
.filter((row) => OA_BOARD_JOIN_FIELD_NAMES.openingCode.includes(String(row.name ?? '').trim()))
.map((row) => normalizeText(row.value))
.filter((value): value is string => Boolean(value)),
),
)
const linkedFieldNames = Array.from(
new Set([
...OA_BOARD_JOIN_FIELD_NAMES.orderCode,
...OA_BOARD_JOIN_FIELD_NAMES.pricingCode,
...OA_BOARD_JOIN_FIELD_NAMES.artCode,
]),
)
const otherProcessCodes = processCodes.filter((code) => code !== reviewProcessCode)
const [linkedWorkflowRows] =
openingCodes.length > 0
? await connection.query<SourceWorkflowRow[]>(
`
SELECT DISTINCT w.finishTime, w.businessId, w.title, w.result, w.originatorUserId, w.originatorDeptId,
w.originatorDeptName, w.status, w.bizAction, w.processInstanceId, w.joinAt,
w.updateAt, w.createAt, w.processCode, w.flowType
FROM ding_Workflow w
JOIN ding_WorkflowScheme s
ON s.processInstanceId = w.processInstanceId
WHERE w.processCode IN (${otherProcessCodes.map(() => '?').join(', ')})
AND s.name IN (${linkedFieldNames.map(() => '?').join(', ')})
AND CAST(s.value AS CHAR) IN (${openingCodes.map(() => '?').join(', ')})
ORDER BY w.updateAt DESC
`,
[...otherProcessCodes, ...linkedFieldNames, ...openingCodes],
)
: [[] as SourceWorkflowRow[]]
const workflowMap = new Map<string, SourceWorkflowRow>()
reviewWorkflowRows.forEach((row) => workflowMap.set(row.processInstanceId, row))
linkedWorkflowRows.forEach((row) => workflowMap.set(row.processInstanceId, row))
workflowRows = Array.from(workflowMap.values())
}
const processInstanceIds = Array.from(
new Set(
workflowRows
@ -1353,7 +1504,7 @@ async function fetchSourceData(limit?: number): Promise<NormalizedFlowRecord[]>
const [taskRows] = await connection.query<SourceTaskRow[]>(
`
SELECT t.processInstanceId, t.result, t.activityId, t.finishTime, t.createTime, t.userId, t.taskId, t.status,
SELECT t.id, t.processInstanceId, t.result, t.activityId, t.finishTime, t.createTime, t.userId, t.taskId, t.status,
w.processCode AS processCode
FROM ding_WorkflowTask t
JOIN ding_Workflow w

View File

@ -26,8 +26,8 @@ export const OA_BOARD_WORKBOOK_FLOW_NAME_MAP = {
export const OA_BOARD_FLOW_PRIORITY: OaBoardFlowType[] = [
'review',
'order',
'pricing',
'art',
'pricing',
'main',
]
@ -534,7 +534,7 @@ export const OA_BOARD_JOIN_FIELD_NAMES = {
openingCode: ['开款编号'],
orderCode: ['新品首单下单号'],
pricingCode: ['品牌限价单号'],
artCode: ['视觉出图单号'],
artCode: ['视觉出图单号', '出图单号'],
spu: ['SPU'],
productType: ['产品类型'],
brand: ['品牌', '开款品牌'],
@ -547,4 +547,6 @@ export const OA_BOARD_RUNNING_STATUSES = new Set(['RUNNING', 'PROCESSING', 'PEND
export const OA_BOARD_COMPLETED_STATUSES = new Set(['COMPLETED', 'TERMINATED'])
export const OA_BOARD_DEFAULT_TRACKED_STATUSES = ['RUNNING', 'COMPLETED'] as const
export const OA_BOARD_PARALLEL_FLOW_TYPES = new Set<OaBoardFlowType>(['order', 'art'])

View File

@ -1,5 +1,6 @@
import {
getOaBoardProductTargetHours,
OA_BOARD_DEFAULT_TRACKED_STATUSES,
OA_BOARD_FLOW_LABELS,
OA_BOARD_FLOW_PRIORITY,
OA_BOARD_STAGE_DEFINITIONS,
@ -10,9 +11,16 @@ import { queryOaBoardPreview } from './oa-board-sync.service'
export interface OaBoardProjectListFilter {
keyword?: string
approvalNumber?: string
overallStatus?: string
overallStatus?: string[]
createdDateStart?: string
createdDateEnd?: string
productType?: string
approvalStage?: string
productPlanner?: string
structureDesigner?: string
appearanceDesigner?: string
purchaseOwner?: string
spuOwner?: string
owner?: string
nodeName?: string
timeoutOnly?: boolean
@ -142,6 +150,24 @@ export interface OaBoardSummaryDTO {
productTypes: string[]
}
function countFilledFormSummaryFields(item: OaBoardProjectListItemDTO) {
return Object.values(item.formSummary).filter((value) => {
if (typeof value !== 'string') return Boolean(value)
const normalized = value.trim()
return normalized !== '' && normalized !== '-'
}).length
}
function toDateOnly(value: string | null | undefined) {
const text = String(value ?? '').trim()
return text ? text.slice(0, 10) : ''
}
function parseDateSortValue(value: string | null | undefined) {
const dateValue = value ? new Date(value).getTime() : Number.NaN
return Number.isFinite(dateValue) ? dateValue : 0
}
export interface OaBoardStageMatrixRowDTO {
productType: string
totalTargetHours: number
@ -263,6 +289,305 @@ export function createEmptyOaBoardDashboard(productTypes: string[] = []): OaBoar
}
}
function buildMockTimelineMetrics(input: {
totalTargetDays: number
totalActualDays: number
exteriorActualDays: number
structureActualDays: number
samplePrepareActualDays: number
sampleReviewActualDays: number
productionActualDays: number
artActualDays: number
pricingActualDays: number
listingActualDays: number
}): OaBoardTimelineMetricDTO[] {
return [
{
key: 'total_actual',
label: '总实际时效',
targetDays: input.totalTargetDays,
actualDays: input.totalActualDays,
notes:
input.totalActualDays > input.totalTargetDays
? '人工验证数据:总实际时效已超过目标时效。'
: '人工验证数据:总实际时效在目标范围内。',
},
{ key: 'exterior', label: '外观', targetDays: 3, actualDays: input.exteriorActualDays },
{ key: 'structure', label: '结构', targetDays: 2, actualDays: input.structureActualDays },
{ key: 'sample_prepare', label: '打样', targetDays: 3, actualDays: input.samplePrepareActualDays },
{ key: 'sample_review', label: '审样', targetDays: 2, actualDays: input.sampleReviewActualDays },
{ key: 'production', label: '生产', targetDays: 13, actualDays: input.productionActualDays },
{ key: 'art', label: '出图', targetDays: 4, actualDays: input.artActualDays },
{ key: 'pricing_flow', label: '限价', targetDays: 1, actualDays: input.pricingActualDays },
{ key: 'listing', label: '运营上架', targetDays: 1, actualDays: input.listingActualDays },
]
}
function buildMockOaBoardItem(input: {
id: string
boardKey: string
title: string
productName: string
productType: string
spu: string
salesBrands: string
plannedSalesBrands: string
openingCode: string
approvalStage: string
currentNode: string
currentOwner: string
overallStatus: 'RUNNING' | 'COMPLETED'
productPlanner: string
structureDesigner: string
appearanceDesigner: string
purchaseOwner: string
spuOwner: string
createdDate: string
finishedDate: string | null
totalTargetDays: number
totalActualDays: number
timelineMetrics: OaBoardTimelineMetricDTO[]
}): OaBoardProjectListItemDTO {
const timeoutHours = Math.max(input.totalActualDays - input.totalTargetDays, 0)
return {
id: input.id,
boardKey: input.boardKey,
boardKeyType: 'MANUAL_SAMPLE',
title: input.title,
productName: input.productName,
productType: input.productType,
brand: input.salesBrands,
productCategory: '成品',
categoryName: '柜类',
spu: input.spu,
openingCode: input.openingCode,
orderCode: null,
pricingCode: null,
currentStageKey: null,
currentStageName: input.approvalStage,
currentApprovalStageKey: null,
currentApprovalStageName: input.approvalStage,
overallStatus: input.overallStatus,
overallResult: input.overallStatus === 'COMPLETED' ? 'agree' : null,
overallTargetHours: input.totalTargetDays,
overallActualHours: input.totalActualDays,
overallTimeoutHours: timeoutHours > 0 ? Number(timeoutHours.toFixed(2)) : 0,
overallTimeoutFlag: timeoutHours > 0,
createdDate: input.createdDate,
finishedDate: input.finishedDate,
originatorDeptName: '产品开发中心',
lastSourceUpdatedAt: new Date().toISOString(),
styleImages: [],
formSummary: {
styleImageLabel: '',
openingCode: input.openingCode,
totalFlowStatusLabel: input.overallStatus === 'COMPLETED' ? '已完成' : '进行中',
approvalStageDisplay: input.approvalStage,
currentOwnerDisplay: input.currentOwner,
currentNodeDisplay: input.currentNode,
demandType: '新品开发',
developmentPlan: '常规开发',
productType: input.productType,
shippingType: '整柜',
needVisualDesign: '需要',
plannedSalesBrands: input.plannedSalesBrands,
category: '成品',
categoryName: '柜类',
salesPriceBand: '1000-1500',
platformPriceBand: '899-1399',
competitorLink: 'https://example.com/competitor',
openingReason: '人工验证数据:用于本地校验流程明细、筛选和进度展示。',
isTestStyle: '否',
testStyleCode: '',
productPlanner: input.productPlanner,
operationBrandOwner: '何运营',
spuOwner: input.spuOwner,
appearanceDesigner: input.appearanceDesigner,
structureDesigner: input.structureDesigner,
purchaseOwner: input.purchaseOwner,
merchandiserOwner: '周跟单',
pricingOwner: '郑限价',
workflowLink: 'https://example.com/workflow',
needPatent: '否',
needTrademark: '否',
reviewResult: input.overallStatus === 'COMPLETED' ? '通过' : '待评审',
sampleReviewResult: input.overallStatus === 'COMPLETED' ? '通过' : '待审样',
salesBrands: input.salesBrands,
spu: input.spu,
openingBrand: '由壹点',
},
flowChips: [],
nodeAssessments: [],
timelineMetrics: input.timelineMetrics,
}
}
export function createMockOaBoardDashboard(): OaBoardDashboardDTO {
const items: OaBoardProjectListItemDTO[] = [
buildMockOaBoardItem({
id: 'mock-oa-001',
boardKey: 'OA-MOCK-202606-001',
title: '奶油白餐边柜',
productName: '奶油白餐边柜',
productType: '成品开发-板式/钢木/铝制(无需磨具)',
spu: 'SPU-CBG-001',
salesBrands: '由壹点',
plannedSalesBrands: '由壹点 / 卓禾',
openingCode: 'KKCBG-001',
approvalStage: '结构设计',
currentNode: '结构设计',
currentOwner: '王结构',
overallStatus: 'RUNNING',
productPlanner: '李企划',
structureDesigner: '王结构',
appearanceDesigner: '张外观',
purchaseOwner: '陈采购',
spuOwner: '孙SPU',
createdDate: '2026-06-14T09:20:00+08:00',
finishedDate: null,
totalTargetDays: 23,
totalActualDays: 9,
timelineMetrics: buildMockTimelineMetrics({
totalTargetDays: 23,
totalActualDays: 9,
exteriorActualDays: 2,
structureActualDays: 3,
samplePrepareActualDays: 1,
sampleReviewActualDays: 0,
productionActualDays: 0,
artActualDays: 3,
pricingActualDays: 0,
listingActualDays: 0,
}),
}),
buildMockOaBoardItem({
id: 'mock-oa-002',
boardKey: 'OA-MOCK-202606-002',
title: '胡桃色电视柜',
productName: '胡桃色电视柜',
productType: '成品开发-实木',
spu: 'SPU-TV-018',
salesBrands: '卓禾',
plannedSalesBrands: '卓禾',
openingCode: 'HTDSG-018',
approvalStage: '样品评审',
currentNode: '样品评审',
currentOwner: '赵企划',
overallStatus: 'RUNNING',
productPlanner: '赵企划',
structureDesigner: '刘结构',
appearanceDesigner: '林外观',
purchaseOwner: '杨采购',
spuOwner: '钱SPU',
createdDate: '2026-06-10T14:00:00+08:00',
finishedDate: null,
totalTargetDays: 47,
totalActualDays: 19,
timelineMetrics: buildMockTimelineMetrics({
totalTargetDays: 47,
totalActualDays: 19,
exteriorActualDays: 3,
structureActualDays: 4,
samplePrepareActualDays: 6,
sampleReviewActualDays: 2,
productionActualDays: 0,
artActualDays: 4,
pricingActualDays: 0,
listingActualDays: 0,
}),
}),
buildMockOaBoardItem({
id: 'mock-oa-003',
boardKey: 'OA-MOCK-202606-003',
title: '轻奢床头柜',
productName: '轻奢床头柜',
productType: '部件开发-纸箱/泡沫',
spu: 'SPU-BG-009',
salesBrands: '由壹点',
plannedSalesBrands: '由壹点 / 京喜',
openingCode: 'QSCTG-009',
approvalStage: '运营上架',
currentNode: '运营上架',
currentOwner: '韩运营',
overallStatus: 'COMPLETED',
productPlanner: '周企划',
structureDesigner: '郭结构',
appearanceDesigner: '宋外观',
purchaseOwner: '许采购',
spuOwner: '吴SPU',
createdDate: '2026-06-01T10:00:00+08:00',
finishedDate: '2026-06-16T18:00:00+08:00',
totalTargetDays: 19,
totalActualDays: 21,
timelineMetrics: buildMockTimelineMetrics({
totalTargetDays: 19,
totalActualDays: 21,
exteriorActualDays: 2,
structureActualDays: 2,
samplePrepareActualDays: 5,
sampleReviewActualDays: 2,
productionActualDays: 7,
artActualDays: 4,
pricingActualDays: 1,
listingActualDays: 1,
}),
}),
buildMockOaBoardItem({
id: 'mock-oa-004',
boardKey: 'OA-MOCK-202606-004',
title: '岩板茶几',
productName: '岩板茶几',
productType: '成品开发-钢木/铝制(需磨具)',
spu: 'SPU-CJ-032',
salesBrands: '卓禾',
plannedSalesBrands: '卓禾 / 京东',
openingCode: 'YBCJ-032',
approvalStage: '已归档',
currentNode: '流程归档',
currentOwner: '-',
overallStatus: 'COMPLETED',
productPlanner: '冯企划',
structureDesigner: '郑结构',
appearanceDesigner: '苏外观',
purchaseOwner: '褚采购',
spuOwner: '卫SPU',
createdDate: '2026-05-28T11:30:00+08:00',
finishedDate: '2026-06-12T16:30:00+08:00',
totalTargetDays: 48,
totalActualDays: 32,
timelineMetrics: buildMockTimelineMetrics({
totalTargetDays: 48,
totalActualDays: 32,
exteriorActualDays: 2,
structureActualDays: 2,
samplePrepareActualDays: 6,
sampleReviewActualDays: 2,
productionActualDays: 15,
artActualDays: 4,
pricingActualDays: 1,
listingActualDays: 0,
}),
}),
]
const productTypes = Array.from(
new Set(items.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(),
}
}
function toIsoString(value: Date | null | undefined) {
return value ? value.toISOString() : null
}
@ -424,6 +749,7 @@ type PreviewProjectRecord = Awaited<ReturnType<typeof queryOaBoardPreview>>['pro
function toProjectListItem(record: PreviewProjectRecord): OaBoardProjectListItemDTO {
const metadata = isRecord(record.metadata) ? record.metadata : null
const reviewFlow = record.flowInstances.find((flowInstance) => flowInstance.flowType === 'review')
const formSummary = readFormSummary(metadata?.formSummary)
const styleImages = normalizeImageList(metadata?.styleImages)
const timelineMetrics = Array.isArray(metadata?.timelineMetrics)
@ -445,7 +771,12 @@ function toProjectListItem(record: PreviewProjectRecord): OaBoardProjectListItem
...(notes ? { notes } : {}),
}
})
.filter((item): item is OaBoardTimelineMetricDTO => item !== null && Boolean(item.key) && Boolean(item.label))
.filter(
(
item,
): item is NonNullable<typeof item> =>
Boolean(item?.key) && Boolean(item?.label),
)
: []
const nodeAssessments = record.flowInstances
.flatMap((flowInstance) =>
@ -507,8 +838,8 @@ function toProjectListItem(record: PreviewProjectRecord): OaBoardProjectListItem
overallActualHours: toDays(record.overallActualHours),
overallTimeoutHours: toDays(record.overallTimeoutHours),
overallTimeoutFlag: record.overallTimeoutFlag,
createdDate: toIsoString(record.flowInstances.find((flowInstance) => flowInstance.flowType === 'main')?.createdAtSource ?? record.flowInstances[0]?.createdAtSource),
finishedDate: toIsoString(record.flowInstances.find((flowInstance) => flowInstance.flowType === 'main')?.finishedAtSource ?? record.flowInstances[0]?.finishedAtSource),
createdDate: toIsoString(reviewFlow?.createdAtSource ?? record.flowInstances[0]?.createdAtSource),
finishedDate: toIsoString(reviewFlow?.finishedAtSource ?? record.flowInstances[0]?.finishedAtSource),
originatorDeptName: record.originatorDeptName,
lastSourceUpdatedAt: toIsoString(record.lastSourceUpdatedAt),
styleImages,
@ -546,25 +877,45 @@ function buildStageMatrixRows(): OaBoardStageMatrixRowDTO[] {
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 pageLimitRaw = process.env.OA_BOARD_PAGE_LIMIT?.trim()
const pageLimit = pageLimitRaw ? Number(pageLimitRaw) : undefined
const preview = await queryOaBoardPreview(
typeof pageLimit === 'number' && Number.isFinite(pageLimit) && pageLimit > 0
? pageLimit
: undefined,
)
const allItems = preview.projectGraphs
.map(toProjectListItem)
.filter((item) => {
const filledFieldCount = countFilledFormSummaryFields(item)
const hasOpeningContext = Boolean(item.formSummary.openingCode || item.openingCode)
const hasActionableOwner = Boolean(
item.formSummary.currentOwnerDisplay &&
item.formSummary.currentOwnerDisplay.trim() !== '-' &&
item.formSummary.currentOwnerDisplay.trim() !== '',
)
const isMainReviewBoard = item.boardKeyType === 'OPENING'
if (isMainReviewBoard) {
return true
}
return hasOpeningContext || hasActionableOwner || filledFieldCount >= 8
})
const effectiveStatuses =
filter.overallStatus && filter.overallStatus.length > 0
? filter.overallStatus
: [...OA_BOARD_DEFAULT_TRACKED_STATUSES]
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,
item.formSummary.spu,
item.formSummary.salesBrands,
item.formSummary.plannedSalesBrands,
]
.map((value) => String(value ?? '').toLowerCase())
.join('\n')
@ -576,14 +927,24 @@ export class OaBoardService {
if (
filter.approvalNumber?.trim() &&
!String(item.boardKey || '')
!String(item.formSummary.openingCode || item.openingCode || '')
.toLowerCase()
.includes(filter.approvalNumber.trim().toLowerCase())
) {
return false
}
if (filter.overallStatus?.trim() && item.overallStatus !== filter.overallStatus.trim()) {
if (!effectiveStatuses.includes(String(item.overallStatus ?? '').trim())) {
return false
}
const createdDateOnly = toDateOnly(item.createdDate)
if (filter.createdDateStart?.trim() && createdDateOnly < filter.createdDateStart.trim()) {
return false
}
if (filter.createdDateEnd?.trim() && createdDateOnly > filter.createdDateEnd.trim()) {
return false
}
@ -593,9 +954,43 @@ export class OaBoardService {
if (
filter.approvalStage?.trim() &&
!String(item.formSummary.approvalStageDisplay || item.currentApprovalStageName || '')
.toLowerCase()
.includes(filter.approvalStage.trim().toLowerCase())
String(item.formSummary.approvalStageDisplay || item.currentApprovalStageName || '') !==
filter.approvalStage.trim()
) {
return false
}
if (
filter.productPlanner?.trim() &&
item.formSummary.productPlanner !== filter.productPlanner.trim()
) {
return false
}
if (
filter.structureDesigner?.trim() &&
item.formSummary.structureDesigner !== filter.structureDesigner.trim()
) {
return false
}
if (
filter.appearanceDesigner?.trim() &&
item.formSummary.appearanceDesigner !== filter.appearanceDesigner.trim()
) {
return false
}
if (
filter.purchaseOwner?.trim() &&
item.formSummary.purchaseOwner !== filter.purchaseOwner.trim()
) {
return false
}
if (
filter.spuOwner?.trim() &&
item.formSummary.spuOwner !== filter.spuOwner.trim()
) {
return false
}
@ -623,6 +1018,10 @@ export class OaBoardService {
}
return true
}).sort((left, right) => {
const timeDiff = parseDateSortValue(right.createdDate) - parseDateSortValue(left.createdDate)
if (timeDiff !== 0) return timeDiff
return right.boardKey.localeCompare(left.boardKey, 'zh-CN')
})
const productTypes = Array.from(