2141 lines
72 KiB
TypeScript
2141 lines
72 KiB
TypeScript
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()
|