2141 lines
72 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
getOaBoardProductTargetHours,
OA_BOARD_DEFAULT_TRACKED_STATUSES,
OA_BOARD_FLOW_LABELS,
OA_BOARD_FLOW_PRIORITY,
OA_BOARD_STAGE_DEFINITIONS,
OA_BOARD_STAGE_GRAPH_EDGES,
OA_BOARD_STAGE_GRAPH_NODES,
OA_BOARD_STAGE_TARGET_MATRIX,
type OaBoardFlowType,
type OaBoardStageGraphLane,
} from './oa-board.constants'
import {
queryOaBoardPreview,
queryOaBoardDailyProjectSnapshots,
queryOaBoardDailyProjectSnapshotWindow,
queryOaBoardSnapshotLatestSyncedAt,
queryOaBoardSnapshotPreview,
type OaBoardDailyProjectSnapshot,
type OaBoardDailyProjectSnapshotWindow,
} from './oa-board-sync.service'
import {
getOaBoardTimelineMetricOvertimeDays,
OA_BOARD_APPROVAL_STAGE_TIMELINE_METRIC_KEYS,
} from '@/lib/oa-board-overtime'
export interface OaBoardProjectListFilter {
keyword?: string
approvalNumber?: string
overallStatus?: string[]
createdDateStart?: string
createdDateEnd?: string
productType?: string
approvalStage?: string
developmentPlan?: string
productPlanner?: string
structureDesigner?: string
appearanceDesigner?: string
purchaseOwner?: string
spuOwner?: string
owner?: string
nodeName?: string
timeoutOnly?: boolean
timeoutStatus?: 'YES' | 'NO'
}
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 OaBoardFormPreviewImageDTO {
url: string
alt: string
}
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 OaBoardTimelineMetricDTO {
key: string
label: string
targetDays: number | null
actualDays: number | null
notes?: string
}
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
createdDate: string | null
finishedDate: string | null
originatorUserName: string | null
originatorDeptName: string | null
lastSourceUpdatedAt: string | null
styleImages: OaBoardFormPreviewImageDTO[]
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
artRequestOwner: string
brandPlanningOwner: 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[]
timelineMetrics: OaBoardTimelineMetricDTO[]
}
export interface OaBoardSummaryDTO {
totalProjects: number
timeoutProjects: number
runningProjects: number
completedProjects: number
productTypes: string[]
}
export interface OaBoardDailyReportDTO {
reportDate: string
source: 'DAILY_SNAPSHOT' | 'REALTIME_FALLBACK'
snapshotObservedAt: string | null
hasStageComparison: boolean
newProjectCount: number
completedProjectCount: number
firstOrderCompletedCount: number
artCompletedCount: number
pricingCompletedCount: number
currentInProgressCount: number
currentOverallOvertimeCount: number
stageStats: OaBoardDailyStageStatDTO[]
riskItems: OaBoardDailyRiskItemDTO[]
}
export interface OaBoardDailyStageStatDTO {
stageName: string
inProgressCount: number
enteredCount: number | null
exitedCount: number | null
overdueCount: number
overdueRate: number | null
longestStayDays: number | null
oldestOpeningCode: string | null
oldestStartedAt: string | null
}
export interface OaBoardDailyRiskItemDTO {
boardKey: string
openingCode: string | null
spu: string | null
stageName: string
nodeName: string
ownerLabel: string
targetDays: number | null
actualDays: number | null
overtimeDays: number | null
riskType: 'OVERDUE' | 'DUE_SOON' | 'STALLED'
riskLabel: string
}
export interface OaBoardMonthlyDurationDTO {
sampleSize: number
totalDays: number | null
averageDays: number | null
medianDays: number | null
p90Days: number | null
}
export interface OaBoardMonthlyDimensionStatDTO {
value: string
newProjectCount: number
completedProjectCount: number
monthEndInProgressCount: number
monthEndOverallOvertimeCount: number
averageCompletedDays: number | null
}
export interface OaBoardMonthlyStageBottleneckDTO {
stageName: string
monthEndInProgressCount: number
overdueCount: number
overdueRate: number | null
longestStayDays: number | null
}
export interface OaBoardMonthlyReportDTO {
reportMonth: string
source: 'DAILY_SNAPSHOT' | 'REALTIME_FALLBACK'
snapshotDays: number
firstSnapshotDate: string | null
lastSnapshotDate: string | null
snapshotObservedAt: string | null
hasMonthComparison: boolean
previousReportMonth: string
newProjectCount: number
completedProjectCount: number
firstOrderCompletedCount: number
artCompletedCount: number
pricingCompletedCount: number
monthEndInProgressCount: number
monthEndOverallOvertimeCount: number
completedDuration: OaBoardMonthlyDurationDTO
comparison: {
newProjectCount: number | null
completedProjectCount: number | null
monthEndInProgressCount: number | null
monthEndOverallOvertimeCount: number | null
averageCompletedDays: number | null
}
dimensions: {
developmentPlan: OaBoardMonthlyDimensionStatDTO[]
demandDepartment: OaBoardMonthlyDimensionStatDTO[]
productCategory: OaBoardMonthlyDimensionStatDTO[]
}
stageBottlenecks: OaBoardMonthlyStageBottleneckDTO[]
}
export interface OaBoardStageGraphFlowCountDTO {
flowType: string
flowLabel: string
count: number
}
export interface OaBoardStageGraphNodeDTO {
id: string
label: string
stageName: string
lane: OaBoardStageGraphLane
column: number
totalCount: number
flowCounts: OaBoardStageGraphFlowCountDTO[]
boardKeys: string[]
openingCodes: string[]
oldestCreatedDate: string | null
oldestOpeningCode: string | null
}
export interface OaBoardStageGraphEdgeDTO {
from: string
to: string
}
export interface OaBoardStageGraphDTO {
nodes: OaBoardStageGraphNodeDTO[]
edges: OaBoardStageGraphEdgeDTO[]
}
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
}
function getShanghaiDateKey(value: Date | string) {
const date = typeof value === 'string' ? new Date(value) : value
if (!Number.isFinite(date.getTime())) return null
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(date)
const partValue = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? ''
const year = partValue('year')
const month = partValue('month')
const day = partValue('day')
return year && month && day ? `${year}-${month}-${day}` : null
}
function isValidDateKey(value: string) {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/)
if (!match) return false
const [, yearText, monthText, dayText] = match
const year = Number(yearText)
const month = Number(monthText)
const day = Number(dayText)
const date = new Date(Date.UTC(year, month - 1, day))
return (
date.getUTCFullYear() === year &&
date.getUTCMonth() === month - 1 &&
date.getUTCDate() === day
)
}
export function normalizeOaBoardDailyReportDate(value?: string, referenceDate = new Date()) {
const normalized = String(value ?? '').trim()
if (isValidDateKey(normalized)) return normalized
return getShanghaiDateKey(referenceDate) ?? '1970-01-01'
}
function getPreviousDateKey(reportDate: string) {
const parsed = new Date(`${reportDate}T00:00:00+08:00`)
if (!Number.isFinite(parsed.getTime())) return reportDate
parsed.setUTCDate(parsed.getUTCDate() - 1)
return getShanghaiDateKey(parsed) ?? reportDate
}
function isSuccessfulCompletedFlow(flow: OaBoardFlowChipDTO) {
const status = flow.status.trim().toUpperCase()
const result = String(flow.result ?? '').trim().toUpperCase()
return status === 'COMPLETED' && result !== 'REFUSE' && result !== 'REFUSED'
}
function latestFinishedDateKey(item: OaBoardProjectListItemDTO) {
const timestamps = item.flowChips
.map((flow) => (flow.finishedAtSource ? new Date(flow.finishedAtSource).getTime() : Number.NaN))
.filter((value) => Number.isFinite(value))
if (timestamps.length === 0) return null
return getShanghaiDateKey(new Date(Math.max(...timestamps)))
}
function countCompletedSubflows(
items: OaBoardProjectListItemDTO[],
reportDate: string,
flowType: string,
) {
const completedInstances = new Set<string>()
items.forEach((item) => {
item.flowChips.forEach((flow) => {
if (
flow.flowType === flowType &&
isSuccessfulCompletedFlow(flow) &&
flow.finishedAtSource &&
getShanghaiDateKey(flow.finishedAtSource) === reportDate
) {
completedInstances.add(flow.processInstanceId || `${item.boardKey}:${flowType}`)
}
})
})
return completedInstances.size
}
function getItemStageNames(item: OaBoardProjectListItemDTO) {
const formSummary = item.formSummary ?? ({} as OaBoardProjectListItemDTO['formSummary'])
return splitApprovalStages(formSummary.approvalStageDisplay || item.currentApprovalStageName)
}
function getStageMetric(item: OaBoardProjectListItemDTO, stageName: string) {
const metricKey = OA_BOARD_APPROVAL_STAGE_TIMELINE_METRIC_KEYS[stageName]
if (!metricKey) return null
const directMetric = (item.timelineMetrics ?? []).find((metric) => metric.key === metricKey)
if (directMetric) return directMetric
return stageName.startsWith('产品生产-')
? (item.timelineMetrics ?? []).find((metric) => metric.key === 'production') ?? null
: null
}
function getStageStartedAt(item: OaBoardProjectListItemDTO, stageName: string) {
const timestamps = (item.nodeAssessments ?? [])
.filter((assessment) => assessment.approvalStageName === stageName)
.map((assessment) => (assessment.startedAtSource ? new Date(assessment.startedAtSource).getTime() : Number.NaN))
.filter((value) => Number.isFinite(value))
if (timestamps.length > 0) return new Date(Math.min(...timestamps)).toISOString()
const flowType = inferFlowTypeFromApprovalStage(stageName)
const flowTimestamp = item.flowChips
.filter((flow) => flow.flowType === flowType)
.map((flow) => (flow.createdAtSource ? new Date(flow.createdAtSource).getTime() : Number.NaN))
.find((value) => Number.isFinite(value))
return flowTimestamp ? new Date(flowTimestamp).toISOString() : null
}
function getStageActualDays(
item: OaBoardProjectListItemDTO,
stageName: string,
referenceAt?: Date | null,
) {
const metricActualDays = getStageMetric(item, stageName)?.actualDays
if (metricActualDays !== null && metricActualDays !== undefined) return metricActualDays
const startedAt = getStageStartedAt(item, stageName)
const effectiveReference = referenceAt ?? new Date()
if (!startedAt || !Number.isFinite(effectiveReference.getTime())) return null
const milliseconds = effectiveReference.getTime() - new Date(startedAt).getTime()
return Number.isFinite(milliseconds) && milliseconds >= 0
? Number((milliseconds / 86_400_000).toFixed(2))
: null
}
function getStageOvertimeDays(
item: OaBoardProjectListItemDTO,
stageName: string,
referenceAt?: Date | null,
) {
const metric = getStageMetric(item, stageName)
const actualDays = metric?.actualDays ?? getStageActualDays(item, stageName, referenceAt)
return getOaBoardTimelineMetricOvertimeDays({
targetDays: metric?.targetDays ?? null,
actualDays,
})
}
function compareNullableDates(left: string | null, right: string | null) {
const leftTime = left ? new Date(left).getTime() : Number.MAX_SAFE_INTEGER
const rightTime = right ? new Date(right).getTime() : Number.MAX_SAFE_INTEGER
return leftTime - rightTime
}
function buildDailyStageStats(
items: OaBoardProjectListItemDTO[],
snapshots?: OaBoardDailyProjectSnapshot[],
previousSnapshots?: OaBoardDailyProjectSnapshot[],
): OaBoardDailyStageStatDTO[] {
const snapshotByBoardKey = new Map(snapshots?.map((snapshot) => [snapshot.boardKey, snapshot]) ?? [])
const previousSnapshotByBoardKey = new Map(
previousSnapshots?.map((snapshot) => [snapshot.boardKey, snapshot]) ?? [],
)
const hasStageComparison = Boolean(snapshots && snapshots.length > 0 && previousSnapshots && previousSnapshots.length > 0)
const stageNames = Array.from(
new Set([
...OA_BOARD_STAGE_GRAPH_NODES.map((node) => node.stageName),
...items.flatMap(getItemStageNames),
...(snapshots ?? []).flatMap((snapshot) => snapshot.observedStageNames),
...(previousSnapshots ?? []).flatMap((snapshot) => snapshot.lastStageNames),
]),
)
return stageNames.flatMap((stageName) => {
const matchingItems = items.filter((item) => {
const snapshot = snapshotByBoardKey.get(item.boardKey)
const latestStages = snapshot?.lastStageNames ?? getItemStageNames(item)
const latestStatus = snapshot?.lastOverallStatus ?? item.overallStatus
return latestStatus === 'RUNNING' && latestStages.includes(stageName)
})
const records = matchingItems.map((item) => ({
item,
snapshot: snapshotByBoardKey.get(item.boardKey),
}))
const overdueRecords = records.filter(({ item, snapshot }) =>
getStageOvertimeDays(item, stageName, snapshot?.lastObservedAt) !== null,
)
const oldestRecord = [...records].sort((left, right) =>
compareNullableDates(
getStageStartedAt(left.item, stageName),
getStageStartedAt(right.item, stageName),
),
)[0]
const longestStayDays = records.reduce<number | null>((max, { item, snapshot }) => {
const actualDays = getStageActualDays(item, stageName, snapshot?.lastObservedAt)
return actualDays !== null && (max === null || actualDays > max) ? actualDays : max
}, null)
const enteredCount = hasStageComparison
? snapshots!.filter((snapshot) =>
snapshot.observedStageNames.includes(stageName) &&
!previousSnapshotByBoardKey.get(snapshot.boardKey)?.lastStageNames.includes(stageName),
).length
: null
const exitedCount = hasStageComparison
? snapshots!.filter((snapshot) =>
previousSnapshotByBoardKey.get(snapshot.boardKey)?.lastStageNames.includes(stageName) &&
!snapshot.lastStageNames.includes(stageName),
).length
: null
if (records.length === 0 && (enteredCount ?? 0) === 0 && (exitedCount ?? 0) === 0) return []
return [{
stageName,
inProgressCount: records.length,
enteredCount,
exitedCount,
overdueCount: overdueRecords.length,
overdueRate: records.length > 0 ? Number((overdueRecords.length / records.length).toFixed(3)) : null,
longestStayDays,
oldestOpeningCode: oldestRecord
? oldestRecord.item.formSummary.openingCode || oldestRecord.item.openingCode
: null,
oldestStartedAt: oldestRecord ? getStageStartedAt(oldestRecord.item, stageName) : null,
}]
})
}
function buildDailyRiskItems(
items: OaBoardProjectListItemDTO[],
snapshots?: OaBoardDailyProjectSnapshot[],
): OaBoardDailyRiskItemDTO[] {
const snapshotByBoardKey = new Map(snapshots?.map((snapshot) => [snapshot.boardKey, snapshot]) ?? [])
const risks: OaBoardDailyRiskItemDTO[] = []
items.forEach((item) => {
const snapshot = snapshotByBoardKey.get(item.boardKey)
const referenceAt = snapshot?.lastObservedAt
const latestStatus = snapshot?.lastOverallStatus ?? item.overallStatus
if (latestStatus !== 'RUNNING') return
;(snapshot?.lastStageNames ?? getItemStageNames(item)).forEach((stageName) => {
const metric = getStageMetric(item, stageName)
const actualDays = getStageActualDays(item, stageName, referenceAt)
const overtimeDays = getStageOvertimeDays(item, stageName, referenceAt)
const base = {
boardKey: item.boardKey,
openingCode: item.formSummary?.openingCode || item.openingCode,
spu: item.formSummary?.spu || item.spu,
stageName,
nodeName: item.formSummary?.currentNodeDisplay || '-',
ownerLabel: resolveCurrentApproverDisplay(item) || '-',
targetDays: metric?.targetDays ?? null,
actualDays,
overtimeDays,
}
if (overtimeDays !== null) {
risks.push({ ...base, riskType: 'OVERDUE', riskLabel: `已超时 ${overtimeDays} 天` })
return
}
if (metric?.targetDays !== null && metric?.targetDays !== undefined && actualDays !== null) {
const remainingDays = Number((metric.targetDays - actualDays).toFixed(2))
if (remainingDays >= 0 && remainingDays <= Math.max(1, metric.targetDays * 0.2)) {
risks.push({ ...base, riskType: 'DUE_SOON', riskLabel: `剩余 ${remainingDays} 天` })
return
}
}
if ((metric?.targetDays === null || metric?.targetDays === undefined) && actualDays !== null && actualDays >= 7) {
risks.push({ ...base, riskType: 'STALLED', riskLabel: `停留 ${actualDays} 天` })
}
})
})
return risks
.sort((left, right) => {
const priority = { OVERDUE: 0, DUE_SOON: 1, STALLED: 2 }
if (priority[left.riskType] !== priority[right.riskType]) {
return priority[left.riskType] - priority[right.riskType]
}
return (right.overtimeDays ?? right.actualDays ?? 0) - (left.overtimeDays ?? left.actualDays ?? 0)
})
.slice(0, 10)
}
function toSnapshotReportItem(snapshot: OaBoardDailyProjectSnapshot): OaBoardProjectListItemDTO {
const item = toProjectListItem(snapshot.projectGraph)
const lastStageDisplay = snapshot.lastStageNames.join(';') || '-'
return {
...item,
overallStatus: snapshot.lastOverallStatus ?? item.overallStatus,
formSummary: {
...item.formSummary,
approvalStageDisplay: lastStageDisplay,
totalFlowStatusLabel:
snapshot.lastOverallStatus === 'RUNNING'
? '进行中'
: snapshot.lastOverallStatus === 'COMPLETED'
? '已完成'
: item.formSummary.totalFlowStatusLabel,
},
}
}
function isDateKeyInRange(value: string | null | undefined, startDate: string, endDate: string) {
const dateKey = value ? getShanghaiDateKey(value) : null
return Boolean(dateKey && dateKey >= startDate && dateKey <= endDate)
}
function getMonthRange(reportMonth: string) {
const [yearText, monthText] = reportMonth.split('-')
const year = Number(yearText)
const month = Number(monthText)
const endDay = new Date(Date.UTC(year, month, 0)).getUTCDate()
return {
startDate: `${reportMonth}-01`,
endDate: `${reportMonth}-${String(endDay).padStart(2, '0')}`,
}
}
function getPreviousMonthKey(reportMonth: string) {
const [yearText, monthText] = reportMonth.split('-')
const date = new Date(Date.UTC(Number(yearText), Number(monthText) - 2, 1))
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`
}
function isValidMonthKey(value: string) {
return /^\d{4}-(0[1-9]|1[0-2])$/.test(value)
}
export function normalizeOaBoardMonthlyReportMonth(value?: string, referenceDate = new Date()) {
const normalized = String(value ?? '').trim()
if (isValidMonthKey(normalized)) return normalized
return (getShanghaiDateKey(referenceDate) ?? '1970-01-01').slice(0, 7)
}
function countCompletedSubflowsInRange(
items: OaBoardProjectListItemDTO[],
startDate: string,
endDate: string,
flowType: string,
) {
const completedInstances = new Set<string>()
items.forEach((item) => {
item.flowChips.forEach((flow) => {
if (
flow.flowType === flowType &&
isSuccessfulCompletedFlow(flow) &&
isDateKeyInRange(flow.finishedAtSource, startDate, endDate)
) {
completedInstances.add(flow.processInstanceId || `${item.boardKey}:${flowType}`)
}
})
})
return completedInstances.size
}
function calculateMonthlyDuration(values: number[]): OaBoardMonthlyDurationDTO {
if (values.length === 0) {
return { sampleSize: 0, totalDays: null, averageDays: null, medianDays: null, p90Days: null }
}
const sorted = [...values].sort((left, right) => left - right)
const atPercentile = (percentile: number) => {
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * percentile) - 1))
return sorted[index]
}
const middle = Math.floor(sorted.length / 2)
const median =
sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
const total = sorted.reduce((sum, value) => sum + value, 0)
return {
sampleSize: sorted.length,
totalDays: Number(total.toFixed(2)),
averageDays: Number((total / sorted.length).toFixed(2)),
medianDays: Number(median.toFixed(2)),
p90Days: Number(atPercentile(0.9).toFixed(2)),
}
}
function getLatestSnapshotByBoardKey(snapshots: OaBoardDailyProjectSnapshot[]) {
const latest = new Map<string, OaBoardDailyProjectSnapshot>()
snapshots.forEach((snapshot) => {
const existing = latest.get(snapshot.boardKey)
if (
!existing ||
snapshot.reportDate > existing.reportDate ||
(snapshot.reportDate === existing.reportDate &&
(snapshot.lastObservedAt?.getTime() ?? 0) > (existing.lastObservedAt?.getTime() ?? 0))
) {
latest.set(snapshot.boardKey, snapshot)
}
})
return latest
}
function buildMonthlyDimensionStats(
items: OaBoardProjectListItemDTO[],
newProjectKeys: Set<string>,
completedProjectKeys: Set<string>,
getValue: (item: OaBoardProjectListItemDTO) => string | null | undefined,
): OaBoardMonthlyDimensionStatDTO[] {
const groups = new Map<
string,
{
newProjectCount: number
completedProjectCount: number
monthEndInProgressCount: number
monthEndOverallOvertimeCount: number
completedDurationDays: number[]
}
>()
items.forEach((item) => {
const value = String(getValue(item) ?? '').trim() || '未填写'
const group = groups.get(value) ?? {
newProjectCount: 0,
completedProjectCount: 0,
monthEndInProgressCount: 0,
monthEndOverallOvertimeCount: 0,
completedDurationDays: [],
}
if (newProjectKeys.has(item.boardKey)) group.newProjectCount += 1
if (completedProjectKeys.has(item.boardKey)) {
group.completedProjectCount += 1
if (item.overallActualHours !== null) group.completedDurationDays.push(item.overallActualHours)
}
if (item.overallStatus === 'RUNNING') group.monthEndInProgressCount += 1
if (hasOaBoardProjectOvertime(item)) group.monthEndOverallOvertimeCount += 1
groups.set(value, group)
})
return Array.from(groups.entries())
.map(([value, group]) => ({
value,
newProjectCount: group.newProjectCount,
completedProjectCount: group.completedProjectCount,
monthEndInProgressCount: group.monthEndInProgressCount,
monthEndOverallOvertimeCount: group.monthEndOverallOvertimeCount,
averageCompletedDays:
group.completedDurationDays.length > 0
? Number(
(
group.completedDurationDays.reduce((sum, duration) => sum + duration, 0) /
group.completedDurationDays.length
).toFixed(2),
)
: null,
}))
.sort(
(left, right) =>
right.completedProjectCount - left.completedProjectCount ||
right.newProjectCount - left.newProjectCount ||
right.monthEndInProgressCount - left.monthEndInProgressCount ||
left.value.localeCompare(right.value, 'zh-CN'),
)
}
type OaBoardMonthlyAggregate = {
newProjectCount: number
completedProjectCount: number
firstOrderCompletedCount: number
artCompletedCount: number
pricingCompletedCount: number
monthEndInProgressCount: number
monthEndOverallOvertimeCount: number
completedDuration: OaBoardMonthlyDurationDTO
dimensions: OaBoardMonthlyReportDTO['dimensions']
stageBottlenecks: OaBoardMonthlyStageBottleneckDTO[]
}
function buildOaBoardMonthlyAggregate(
items: OaBoardProjectListItemDTO[],
startDate: string,
endDate: string,
endSnapshots?: OaBoardDailyProjectSnapshot[],
): OaBoardMonthlyAggregate {
const newProjectKeys = new Set<string>()
const completedProjectKeys = new Set<string>()
const completedDurations: number[] = []
items.forEach((item) => {
const reviewFlow = item.flowChips.find((flow) => flow.flowType === 'review')
if (reviewFlow && isDateKeyInRange(reviewFlow.createdAtSource, startDate, endDate)) {
newProjectKeys.add(item.boardKey)
}
if (
item.overallStatus === 'COMPLETED' &&
isDateKeyInRange(latestFinishedDateKey(item), startDate, endDate)
) {
completedProjectKeys.add(item.boardKey)
if (item.overallActualHours !== null) completedDurations.push(item.overallActualHours)
}
})
const stageBottlenecks = buildDailyStageStats(items, endSnapshots)
.filter((stage) => stage.inProgressCount > 0)
.map((stage) => ({
stageName: stage.stageName,
monthEndInProgressCount: stage.inProgressCount,
overdueCount: stage.overdueCount,
overdueRate: stage.overdueRate,
longestStayDays: stage.longestStayDays,
}))
.sort(
(left, right) =>
right.overdueCount - left.overdueCount ||
right.monthEndInProgressCount - left.monthEndInProgressCount ||
(right.longestStayDays ?? 0) - (left.longestStayDays ?? 0),
)
.slice(0, 8)
return {
newProjectCount: newProjectKeys.size,
completedProjectCount: completedProjectKeys.size,
firstOrderCompletedCount: countCompletedSubflowsInRange(items, startDate, endDate, 'order'),
artCompletedCount: countCompletedSubflowsInRange(items, startDate, endDate, 'art'),
pricingCompletedCount: countCompletedSubflowsInRange(items, startDate, endDate, 'pricing'),
monthEndInProgressCount: items.filter((item) => item.overallStatus === 'RUNNING').length,
monthEndOverallOvertimeCount: items.filter(hasOaBoardProjectOvertime).length,
completedDuration: calculateMonthlyDuration(completedDurations),
dimensions: {
developmentPlan: buildMonthlyDimensionStats(
items,
newProjectKeys,
completedProjectKeys,
(item) => item.formSummary.developmentPlan,
),
demandDepartment: buildMonthlyDimensionStats(
items,
newProjectKeys,
completedProjectKeys,
(item) => item.originatorDeptName,
),
productCategory: buildMonthlyDimensionStats(
items,
newProjectKeys,
completedProjectKeys,
(item) => item.formSummary.categoryName || item.categoryName || item.productCategory,
),
},
stageBottlenecks,
}
}
export function buildOaBoardMonthlyReport(
items: OaBoardProjectListItemDTO[],
requestedMonth?: string,
snapshots?: OaBoardDailyProjectSnapshot[],
previousMonthSnapshots?: OaBoardDailyProjectSnapshot[],
snapshotWindow?: Omit<OaBoardDailyProjectSnapshotWindow, 'snapshots'>,
): OaBoardMonthlyReportDTO {
const reportMonth = normalizeOaBoardMonthlyReportMonth(requestedMonth)
const { startDate, endDate } = getMonthRange(reportMonth)
const previousReportMonth = getPreviousMonthKey(reportMonth)
const previousRange = getMonthRange(previousReportMonth)
const hasSnapshot = Boolean(snapshots && snapshots.length > 0)
const snapshotDates = Array.from(new Set((snapshots ?? []).map((snapshot) => snapshot.reportDate))).sort()
const currentSnapshotByBoardKey = getLatestSnapshotByBoardKey(snapshots ?? [])
const currentItems = hasSnapshot
? Array.from(currentSnapshotByBoardKey.values()).map(toSnapshotReportItem)
: items
const lastSnapshotDate = snapshotWindow?.lastSnapshotDate ?? snapshotDates.at(-1) ?? null
const endSnapshots = lastSnapshotDate
? (snapshots ?? []).filter((snapshot) => snapshot.reportDate === lastSnapshotDate)
: undefined
const aggregate = buildOaBoardMonthlyAggregate(currentItems, startDate, endDate, endSnapshots)
const previousSnapshotDays = Array.from(
new Set((previousMonthSnapshots ?? []).map((snapshot) => snapshot.reportDate)),
)
const hasMonthComparison = hasSnapshot && previousSnapshotDays.length > 0
const previousItems = hasMonthComparison
? Array.from(getLatestSnapshotByBoardKey(previousMonthSnapshots ?? []).values()).map(toSnapshotReportItem)
: []
const previousLastDate = previousSnapshotDays.sort().at(-1) ?? null
const previousEndSnapshots = previousLastDate
? (previousMonthSnapshots ?? []).filter((snapshot) => snapshot.reportDate === previousLastDate)
: undefined
const previousAggregate = hasMonthComparison
? buildOaBoardMonthlyAggregate(
previousItems,
previousRange.startDate,
previousRange.endDate,
previousEndSnapshots,
)
: null
const lastObservedAt = (snapshots ?? [])
.map((snapshot) => snapshot.lastObservedAt)
.filter((value): value is Date => Boolean(value))
.sort((left, right) => right.getTime() - left.getTime())[0]
return {
reportMonth,
source: hasSnapshot ? 'DAILY_SNAPSHOT' : 'REALTIME_FALLBACK',
snapshotDays: snapshotWindow?.snapshotDays ?? snapshotDates.length,
firstSnapshotDate: snapshotWindow?.firstSnapshotDate ?? snapshotDates[0] ?? null,
lastSnapshotDate,
snapshotObservedAt: lastObservedAt?.toISOString() ?? null,
hasMonthComparison,
previousReportMonth,
...aggregate,
comparison: {
newProjectCount: previousAggregate ? aggregate.newProjectCount - previousAggregate.newProjectCount : null,
completedProjectCount: previousAggregate
? aggregate.completedProjectCount - previousAggregate.completedProjectCount
: null,
monthEndInProgressCount: previousAggregate
? aggregate.monthEndInProgressCount - previousAggregate.monthEndInProgressCount
: null,
monthEndOverallOvertimeCount: previousAggregate
? aggregate.monthEndOverallOvertimeCount - previousAggregate.monthEndOverallOvertimeCount
: null,
averageCompletedDays:
previousAggregate !== null &&
previousAggregate.completedDuration.averageDays !== null &&
aggregate.completedDuration.averageDays !== null
? Number(
(aggregate.completedDuration.averageDays - previousAggregate.completedDuration.averageDays).toFixed(2),
)
: null,
},
}
}
export function buildOaBoardDailyReport(
items: OaBoardProjectListItemDTO[],
requestedDate?: string,
snapshots?: OaBoardDailyProjectSnapshot[],
previousSnapshots?: OaBoardDailyProjectSnapshot[],
): OaBoardDailyReportDTO {
const reportDate = normalizeOaBoardDailyReportDate(requestedDate)
const hasSnapshot = Boolean(snapshots && snapshots.length > 0)
const reportItems = hasSnapshot
? snapshots!.map(toSnapshotReportItem)
: items
const newProjects = new Set<string>()
const completedProjects = new Set<string>()
reportItems.forEach((item) => {
const reviewFlow = item.flowChips.find((flow) => flow.flowType === 'review')
if (reviewFlow?.createdAtSource && getShanghaiDateKey(reviewFlow.createdAtSource) === reportDate) {
newProjects.add(item.boardKey)
}
if (item.overallStatus === 'COMPLETED' && latestFinishedDateKey(item) === reportDate) {
completedProjects.add(item.boardKey)
}
})
return {
reportDate,
source: hasSnapshot ? 'DAILY_SNAPSHOT' : 'REALTIME_FALLBACK',
snapshotObservedAt: hasSnapshot
? snapshots!
.map((snapshot) => snapshot.lastObservedAt)
.filter((value): value is Date => Boolean(value))
.sort((left, right) => right.getTime() - left.getTime())[0]
?.toISOString() ?? null
: null,
hasStageComparison: Boolean(previousSnapshots && previousSnapshots.length > 0 && hasSnapshot),
newProjectCount: newProjects.size,
completedProjectCount: completedProjects.size,
firstOrderCompletedCount: countCompletedSubflows(reportItems, reportDate, 'order'),
artCompletedCount: countCompletedSubflows(reportItems, reportDate, 'art'),
pricingCompletedCount: countCompletedSubflows(reportItems, reportDate, 'pricing'),
currentInProgressCount: reportItems.filter((item) => item.overallStatus === 'RUNNING').length,
currentOverallOvertimeCount: reportItems.filter(hasOaBoardProjectOvertime).length,
stageStats: buildDailyStageStats(reportItems, snapshots, previousSnapshots),
riskItems: buildDailyRiskItems(reportItems, snapshots),
}
}
function splitApprovalStages(value: string | null | undefined) {
return String(value ?? '')
.split(';')
.map((entry) => entry.trim())
.filter(Boolean)
}
function inferFlowTypeFromApprovalStage(stageName: string): OaBoardFlowType | null {
if (!stageName) return null
if (stageName === '发起申请' || stageName === '企划市场分析') return 'review'
if (stageName.startsWith('产品设计-')) return 'review'
if (stageName === '产品打样' || stageName === '产品审样') return 'review'
if (stageName === '产品生产-商品资料' || stageName === '产品生产-更新市场分析') return 'review'
if (
stageName === '产品生产-首单下单' ||
stageName === '产品生产-产前样确认' ||
stageName === '产品生产-出货' ||
stageName === '产品生产-质检' ||
stageName === '产品生产-寄拍摄样' ||
stageName === '产品生产-安装图纸'
) {
return 'order'
}
if (stageName.startsWith('出图-')) return 'art'
if (stageName.startsWith('限价-')) return 'pricing'
if (stageName === '运营上架') return 'main'
return null
}
function buildStageGraph(items: OaBoardProjectListItemDTO[]): OaBoardStageGraphDTO {
const nodes = OA_BOARD_STAGE_GRAPH_NODES.map((node) => {
const matchedItems = items.filter((item) =>
splitApprovalStages(item.formSummary.approvalStageDisplay || item.currentApprovalStageName).includes(
node.stageName,
),
)
const oldestItem = matchedItems.reduce<OaBoardProjectListItemDTO | null>((currentOldest, item) => {
const currentTimestamp = parseDateSortValue(item.createdDate)
if (currentTimestamp <= 0) return currentOldest
if (!currentOldest) return item
return currentTimestamp < parseDateSortValue(currentOldest.createdDate) ? item : currentOldest
}, null)
const inferredFlowType = inferFlowTypeFromApprovalStage(node.stageName)
const boardKeys = Array.from(new Set(matchedItems.map((item) => item.boardKey)))
const openingCodes = Array.from(
new Set(
matchedItems
.map((item) => item.formSummary.openingCode || item.openingCode || '')
.filter(Boolean),
),
)
return {
...node,
totalCount: matchedItems.length,
flowCounts:
inferredFlowType === null
? []
: [
{
flowType: inferredFlowType,
flowLabel: OA_BOARD_FLOW_LABELS[inferredFlowType],
count: matchedItems.length,
},
],
boardKeys,
openingCodes,
oldestCreatedDate: oldestItem ? toDateOnly(oldestItem.createdDate) : null,
oldestOpeningCode: oldestItem
? oldestItem.formSummary.openingCode || oldestItem.openingCode || null
: null,
}
})
return {
nodes,
edges: OA_BOARD_STAGE_GRAPH_EDGES.map((edge) => ({ ...edge })),
}
}
export interface OaBoardStageMatrixRowDTO {
productType: string
totalTargetHours: number
stageTargets: Array<{
stageKey: string
stageName: string
targetHours: number | null
}>
}
export interface OaBoardDashboardDTO {
items: OaBoardProjectListItemDTO[]
summary: OaBoardSummaryDTO
dailyReport?: OaBoardDailyReportDTO
monthlyReport?: OaBoardMonthlyReportDTO
stageMatrix: OaBoardStageMatrixRowDTO[]
stageGraph: OaBoardStageGraphDTO
}
export interface OaBoardProjectFieldSnapshotInput {
fieldName: string
fieldValue: string | null
fieldExtValue: string | null
normalizedValue: string | null
metadata: unknown
}
export interface OaBoardProjectTaskRecordInput {
taskId: string
activityId: string
userId: string | null
status: string
result: string | null
nodeKey: string | null
nodeName: string | null
stageKey: string | null
stageName: string | null
timelineStageKey: string | null
timelineStageName: string | null
departmentLabel: string | null
ownerLabel: string | null
targetHours: number | null
overtimeHours: number | null
isOvertime: boolean
startedAtSource: Date | null
finishedAtSource: Date | null
actualHours: number | null
isFallback: boolean
metadata: unknown
}
export interface OaBoardProjectFlowInstanceInput {
flowType: string
processCode: string
processName: string
processInstanceId: string
title: string
status: string
result: string | null
businessId: string | null
originatorUserId: string | null
originatorDeptId: string | null
originatorDeptName: string | null
joinKeyType: string | null
joinKey: string | null
createdAtSource: Date | null
updatedAtSource: Date | null
finishedAtSource: Date | null
actualHours: number | null
usedWorkflowFallback: boolean
metadata: unknown
rawSnapshot: unknown
fieldSnapshots: OaBoardProjectFieldSnapshotInput[]
taskRecords: OaBoardProjectTaskRecordInput[]
}
export interface OaBoardProjectGraphInput {
id: string
boardKey: string
boardKeyType: string
primaryFlowType: 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
sourceBusinessId: string | null
originatorUserId: string | null
originatorUserName: string | null
originatorDeptId: string | null
originatorDeptName: 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
lastSourceUpdatedAt: Date | null
metadata: unknown
flowInstances: OaBoardProjectFlowInstanceInput[]
}
export function createEmptyOaBoardDashboard(productTypes: string[] = []): OaBoardDashboardDTO {
const items: OaBoardProjectListItemDTO[] = []
return {
items,
summary: {
totalProjects: 0,
timeoutProjects: 0,
runningProjects: 0,
completedProjects: 0,
productTypes,
},
dailyReport: buildOaBoardDailyReport(items),
monthlyReport: buildOaBoardMonthlyReport(items),
stageMatrix: buildStageMatrixRows(),
stageGraph: buildStageGraph(items),
}
}
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,
originatorUserName: '系统管理员',
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: '何运营',
artRequestOwner: '陈出图',
brandPlanningOwner: '林策划',
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(hasOaBoardProjectOvertime).length,
runningProjects: items.filter((item) => item.overallStatus === 'RUNNING').length,
completedProjects: items.filter((item) => item.overallStatus === 'COMPLETED').length,
productTypes,
},
dailyReport: buildOaBoardDailyReport(items),
monthlyReport: buildOaBoardMonthlyReport(items),
stageMatrix: buildStageMatrixRows(),
stageGraph: buildStageGraph(items),
}
}
function toIsoString(value: Date | null | undefined) {
return value ? value.toISOString() : null
}
function toDays(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: '',
artRequestOwner: '',
brandPlanningOwner: '',
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),
artRequestOwner: asText(value.artRequestOwner),
brandPlanningOwner: asText(value.brandPlanningOwner),
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),
}
}
function normalizeImageList(value: unknown): OaBoardFormPreviewImageDTO[] {
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return []
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed)
if (Array.isArray(parsed)) {
return parsed
.map((item) => {
if (typeof item === 'string') {
return { url: item, alt: '款式图片' }
}
if (item && typeof item === 'object') {
const record = item as Record<string, unknown>
const url = typeof record.url === 'string' ? record.url : ''
if (!url) return null
return {
url,
alt: typeof record.alt === 'string' ? record.alt : '款式图片',
}
}
return null
})
.filter((item): item is OaBoardFormPreviewImageDTO => Boolean(item))
}
} catch {
return [{ url: trimmed, alt: '款式图片' }]
}
}
return [{ url: trimmed, alt: '款式图片' }]
}
if (Array.isArray(value)) {
return value
.map((item) => {
if (typeof item === 'string') {
return { url: item, alt: '款式图片' }
}
if (item && typeof item === 'object') {
const record = item as Record<string, unknown>
const url = typeof record.url === 'string' ? record.url : ''
if (!url) return null
return {
url,
alt: typeof record.alt === 'string' ? record.alt : '款式图片',
}
}
return null
})
.filter((item): item is OaBoardFormPreviewImageDTO => Boolean(item))
}
return []
}
type PreviewProjectRecord = Awaited<ReturnType<typeof queryOaBoardPreview>>['projectGraphs'][number]
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)
? metadata.timelineMetrics
.map((item): OaBoardTimelineMetricDTO | null => {
if (!isRecord(item)) return null
const notes = asText(item.notes)
return {
key: asText(item.key),
label: asText(item.label),
targetDays:
typeof item.targetDays === 'number' && Number.isFinite(item.targetDays)
? Number(item.targetDays.toFixed(2))
: null,
actualDays:
typeof item.actualDays === 'number' && Number.isFinite(item.actualDays)
? Number(item.actualDays.toFixed(2))
: null,
...(notes ? { notes } : {}),
}
})
.filter(
(
item,
): item is NonNullable<typeof item> =>
Boolean(item?.key) && Boolean(item?.label),
)
: []
const nodeAssessments = record.flowInstances
.flatMap((flowInstance) =>
flowInstance.taskRecords
.filter(
(taskRecord) =>
taskRecord.status !== 'CANCELED' ||
(taskRecord.nodeName === '确认打样出货时间' && Boolean(taskRecord.startedAtSource)),
)
.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: toDays(taskRecord.targetHours),
actualHours: toDays(taskRecord.actualHours),
overtimeHours: toDays(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: toDays(record.overallTargetHours),
overallActualHours: toDays(record.overallActualHours),
overallTimeoutHours: toDays(record.overallTimeoutHours),
overallTimeoutFlag: record.overallTimeoutFlag,
createdDate: toIsoString(reviewFlow?.createdAtSource ?? record.flowInstances[0]?.createdAtSource),
finishedDate: toIsoString(reviewFlow?.finishedAtSource ?? record.flowInstances[0]?.finishedAtSource),
originatorUserName: record.originatorUserName,
originatorDeptName: record.originatorDeptName,
lastSourceUpdatedAt: toIsoString(record.lastSourceUpdatedAt),
styleImages,
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: toDays(flowInstance.actualHours),
createdAtSource: toIsoString(flowInstance.createdAtSource),
finishedAtSource: toIsoString(flowInstance.finishedAtSource),
usedWorkflowFallback: flowInstance.usedWorkflowFallback,
})),
nodeAssessments,
timelineMetrics,
}
}
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,
})),
}))
}
const CURRENT_APPROVER_ROLE_PLACEHOLDERS = new Set([
'-',
'采购',
'跟单',
'采购/跟单',
'商品企划',
'外观设计师',
'结构设计师',
'品牌策划',
'品牌策划负责人',
])
function splitCurrentApproverText(value: string | null | undefined) {
return String(value ?? '')
.split(/[;;,,、/]/)
.map((entry) => entry.trim())
.filter(Boolean)
}
function resolveCurrentApproverDisplay(item: OaBoardProjectListItemDTO) {
const stages = splitApprovalStages(
item.formSummary.approvalStageDisplay || item.currentApprovalStageName,
)
const currentOwners = splitCurrentApproverText(item.formSummary.currentOwnerDisplay)
const owners = currentOwners.filter((owner) => !CURRENT_APPROVER_ROLE_PLACEHOLDERS.has(owner))
const usesRolePlaceholder = currentOwners.some((owner) =>
CURRENT_APPROVER_ROLE_PLACEHOLDERS.has(owner),
)
if (currentOwners.includes('外观设计师')) {
owners.push(...splitCurrentApproverText(item.formSummary.appearanceDesigner))
}
if (currentOwners.includes('结构设计师')) {
owners.push(...splitCurrentApproverText(item.formSummary.structureDesigner))
}
if (currentOwners.includes('品牌策划') || currentOwners.includes('品牌策划负责人')) {
owners.push(...splitCurrentApproverText(item.formSummary.brandPlanningOwner))
}
if (stages.includes('产品生产-首单下单') && (!owners.length || usesRolePlaceholder)) {
owners.push(...splitCurrentApproverText(item.formSummary.merchandiserOwner))
}
if (stages.includes('出图-需求发起')) {
owners.push(...splitCurrentApproverText(item.formSummary.artRequestOwner))
}
if (stages.includes('限价-发起') && (!owners.length || usesRolePlaceholder)) {
owners.push(...splitCurrentApproverText(item.formSummary.pricingOwner))
}
return Array.from(new Set(owners)).join(';')
}
export function hasOaBoardProjectOvertime(item: OaBoardProjectListItemDTO) {
// The detail-table "是否超时" field is strictly the overall SLA result.
// Current-stage SLA is shown separately in timing details and overtime analysis.
return item.overallTimeoutFlag
}
export function filterOaBoardProjectItems(
allItems: OaBoardProjectListItemDTO[],
filter: OaBoardProjectListFilter = {},
defaultStatuses: string[] = [...OA_BOARD_DEFAULT_TRACKED_STATUSES],
) {
const effectiveStatuses =
filter.overallStatus && filter.overallStatus.length > 0
? filter.overallStatus
: defaultStatuses
return allItems.filter((item) => {
if (filter.keyword?.trim()) {
const keyword = filter.keyword.trim().toLowerCase()
const haystack = [
item.spu,
item.formSummary.spu,
item.formSummary.salesBrands,
item.formSummary.plannedSalesBrands,
item.formSummary.openingCode,
item.openingCode,
]
.map((value) => String(value ?? '').toLowerCase())
.join('\n')
if (!haystack.includes(keyword)) {
return false
}
}
if (
filter.approvalNumber?.trim() &&
![
item.boardKey,
item.formSummary.openingCode,
item.openingCode,
item.orderCode,
item.pricingCode,
]
.map((value) => String(value ?? '').toLowerCase())
.some((value) => value.includes(filter.approvalNumber?.trim().toLowerCase() ?? ''))
) {
return false
}
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
}
if (filter.productType?.trim() && item.productType !== filter.productType.trim()) {
return false
}
if (
filter.approvalStage?.trim() &&
!splitApprovalStages(item.formSummary.approvalStageDisplay || item.currentApprovalStageName).includes(
filter.approvalStage.trim(),
)
) {
return false
}
if (
filter.developmentPlan?.trim() &&
item.formSummary.developmentPlan !== filter.developmentPlan.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
}
if (
filter.owner?.trim() &&
!resolveCurrentApproverDisplay(item)
.toLowerCase()
.includes(filter.owner.trim().toLowerCase())
) {
return false
}
if (
filter.nodeName?.trim() &&
!String(item.formSummary.currentNodeDisplay || '')
.toLowerCase()
.includes(filter.nodeName.trim().toLowerCase())
) {
return false
}
const isOvertime = hasOaBoardProjectOvertime(item)
if (filter.timeoutOnly && !isOvertime) {
return false
}
if (filter.timeoutStatus === 'YES' && !isOvertime) {
return false
}
if (filter.timeoutStatus === 'NO' && isOvertime) {
return false
}
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')
})
}
export class OaBoardService {
async getDashboard(
filter: OaBoardProjectListFilter = {},
requestedReportDate?: string,
requestedReportMonth?: string,
includePrivateReports = false,
): Promise<OaBoardDashboardDTO> {
const snapshotMaxAgeMinutesRaw = process.env.OA_BOARD_SNAPSHOT_MAX_AGE_MINUTES?.trim()
const snapshotMaxAgeMinutes = snapshotMaxAgeMinutesRaw ? Number(snapshotMaxAgeMinutesRaw) : 60
const snapshotMaxAgeMs =
Number.isFinite(snapshotMaxAgeMinutes) && snapshotMaxAgeMinutes > 0
? snapshotMaxAgeMinutes * 60 * 1000
: 60 * 60 * 1000
const pageLimitRaw = process.env.OA_BOARD_PAGE_LIMIT?.trim()
const pageLimit = pageLimitRaw ? Number(pageLimitRaw) : undefined
const previewLimit =
typeof pageLimit === 'number' && Number.isFinite(pageLimit) && pageLimit > 0
? pageLimit
: undefined
const snapshotLatestSyncedAt = await queryOaBoardSnapshotLatestSyncedAt().catch(() => null)
const snapshotPreview = await queryOaBoardSnapshotPreview(previewLimit).catch(() => null)
const snapshotIsFresh = Boolean(
snapshotLatestSyncedAt &&
Date.now() - snapshotLatestSyncedAt.getTime() <= snapshotMaxAgeMs,
)
const preview =
snapshotPreview && snapshotPreview.projectGraphs.length > 0
? snapshotIsFresh
? snapshotPreview
: await queryOaBoardPreview(previewLimit).catch(() => snapshotPreview)
: await queryOaBoardPreview(previewLimit)
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 items = filterOaBoardProjectItems(allItems, filter)
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'))
const reportDate = normalizeOaBoardDailyReportDate(requestedReportDate)
const reportMonth = normalizeOaBoardMonthlyReportMonth(requestedReportMonth)
const monthlyRange = getMonthRange(reportMonth)
const previousReportMonth = getPreviousMonthKey(reportMonth)
const previousMonthlyRange = getMonthRange(previousReportMonth)
const dailySnapshots = includePrivateReports
? await queryOaBoardDailyProjectSnapshots(reportDate).catch(() => [])
: []
const previousDailySnapshots =
includePrivateReports && dailySnapshots.length > 0
? await queryOaBoardDailyProjectSnapshots(getPreviousDateKey(reportDate)).catch(() => [])
: []
const monthlySnapshotWindow = includePrivateReports
? await queryOaBoardDailyProjectSnapshotWindow(monthlyRange.startDate, monthlyRange.endDate).catch(
() => ({ snapshotDays: 0, firstSnapshotDate: null, lastSnapshotDate: null, snapshots: [] }),
)
: { snapshotDays: 0, firstSnapshotDate: null, lastSnapshotDate: null, snapshots: [] }
const previousMonthlySnapshotWindow =
includePrivateReports && monthlySnapshotWindow.snapshots.length > 0
? await queryOaBoardDailyProjectSnapshotWindow(
previousMonthlyRange.startDate,
previousMonthlyRange.endDate,
).catch(() => ({ snapshotDays: 0, firstSnapshotDate: null, lastSnapshotDate: null, snapshots: [] }))
: { snapshotDays: 0, firstSnapshotDate: null, lastSnapshotDate: null, snapshots: [] }
return {
items,
summary: {
totalProjects: items.length,
timeoutProjects: items.filter(hasOaBoardProjectOvertime).length,
runningProjects: items.filter((item) => item.overallStatus === 'RUNNING').length,
completedProjects: items.filter((item) => item.overallStatus === 'COMPLETED').length,
productTypes,
},
dailyReport: includePrivateReports
? buildOaBoardDailyReport(allItems, reportDate, dailySnapshots, previousDailySnapshots)
: undefined,
monthlyReport: includePrivateReports
? buildOaBoardMonthlyReport(
allItems,
reportMonth,
monthlySnapshotWindow.snapshots,
previousMonthlySnapshotWindow.snapshots,
monthlySnapshotWindow,
)
: undefined,
stageMatrix: buildStageMatrixRows(),
stageGraph: buildStageGraph(items),
}
}
}
export const oaBoardService = new OaBoardService()